diff --git a/docs/ARCHITECTURE.en.md b/docs/ARCHITECTURE.en.md index b40d7dd..ed23416 100644 --- a/docs/ARCHITECTURE.en.md +++ b/docs/ARCHITECTURE.en.md @@ -21,28 +21,37 @@ ds2api/ ├── docs/ # Project documentation ├── internal/ # Core implementation (non-public packages) │ ├── account/ # Account pool, inflight slots, waiting queue -│ ├── adapter/ # Multi-protocol adapters -│ │ ├── claude/ # Claude protocol adapter -│ │ ├── gemini/ # Gemini protocol adapter -│ │ └── openai/ # OpenAI adapter and shared execution core -│ ├── admin/ # Admin API (config/accounts/ops) │ ├── auth/ # Auth/JWT/credential resolution │ ├── chathistory/ # Server-side conversation history storage/query │ ├── claudeconv/ # Claude message conversion helpers │ ├── compat/ # Compatibility and regression helpers │ ├── config/ # Config loading/validation/hot reload -│ ├── deepseek/ # DeepSeek upstream client capabilities +│ ├── deepseek/ # DeepSeek upstream client/protocol/transport +│ │ ├── client/ # Login/session/completion/upload/delete calls +│ │ ├── protocol/ # DeepSeek URLs, constants, skip path/pattern │ │ └── transport/ # DeepSeek transport details │ ├── devcapture/ # Dev capture and troubleshooting │ ├── format/ # Response formatting layer │ │ ├── claude/ # Claude output formatting │ │ └── openai/ # OpenAI output formatting +│ ├── httpapi/ # HTTP surfaces: OpenAI/Claude/Gemini/Admin +│ │ ├── admin/ # Admin API root assembly and resource packages +│ │ ├── claude/ # Claude HTTP protocol adapter +│ │ ├── gemini/ # Gemini HTTP protocol adapter +│ │ └── openai/ # OpenAI HTTP surface +│ │ ├── chat/ # Chat Completions execution entrypoint +│ │ ├── responses/ # Responses API and response store +│ │ ├── files/ # Files API and inline-file preprocessing +│ │ ├── embeddings/ # Embeddings API +│ │ ├── history/ # OpenAI history split +│ │ └── shared/ # OpenAI HTTP errors/models/tool formatting │ ├── js/ # Node runtime related logic │ │ ├── chat-stream/ # Node streaming bridge │ │ ├── helpers/ # JS helper modules │ │ │ └── stream-tool-sieve/ # JS implementation of tool sieve │ │ └── shared/ # Shared semantics between Go/Node │ ├── prompt/ # Prompt composition +│ ├── promptcompat/ # API request -> DeepSeek web-chat plain-text compatibility │ ├── rawsample/ # Raw sample read/write and management │ ├── server/ # Router and middleware assembly │ │ └── data/ # Router/runtime helper data @@ -51,6 +60,7 @@ ds2api/ │ ├── testsuite/ # Testsuite execution framework │ ├── textclean/ # Text cleanup │ ├── toolcall/ # Tool-call parsing and repair +│ ├── toolstream/ # Go streaming tool-call anti-leak and delta detection │ ├── translatorcliproxy/ # Cross-protocol translation bridge │ ├── util/ # Shared utility helpers │ ├── version/ # Version query/compare @@ -93,33 +103,34 @@ ds2api/ ```mermaid flowchart LR C[Client/SDK] --> R[internal/server/router.go] - R --> OA[OpenAI Adapter] - R --> CA[Claude Adapter] - R --> GA[Gemini Adapter] - R --> AD[Admin API] + R --> OA[OpenAI HTTP API] + R --> CA[Claude HTTP API] + R --> GA[Gemini HTTP API] + R --> AD[Admin HTTP API] CA --> BR[translatorcliproxy] GA --> BR - BR --> CORE[internal/adapter/openai ChatCompletions] + BR --> CORE[internal/httpapi/openai/chat ChatCompletions] OA --> CORE CORE --> AUTH[internal/auth + config key/account resolver] CORE --> POOL[internal/account queue + concurrency] - CORE --> TOOL[internal/toolcall parser + sieve] - CORE --> DS[internal/deepseek client] + CORE --> TOOL[internal/toolcall parser + internal/toolstream sieve] + CORE --> DS[internal/deepseek/client] DS --> U[DeepSeek upstream] ``` ## 3. Responsibilities in `internal/` - `internal/server`: router tree + middlewares (health, protocol routes, Admin/WebUI). -- `internal/adapter/openai`: shared execution core (chat/responses/embeddings + tool semantics). -- `internal/adapter/{claude,gemini}`: protocol wrappers only (no duplicated upstream execution). +- `internal/httpapi/openai/*`: OpenAI HTTP surface split into chat, responses, files, embeddings, history, and shared packages. +- `internal/httpapi/{claude,gemini}`: protocol wrappers only (no duplicated upstream execution). +- `internal/promptcompat`: compatibility core for turning OpenAI/Claude/Gemini requests into DeepSeek web-chat plain-text context. - `internal/translatorcliproxy`: structure translation between Claude/Gemini and OpenAI. -- `internal/deepseek`: upstream request/session/PoW/SSE handling. +- `internal/deepseek/{client,protocol,transport}`: upstream requests, sessions, PoW adaptation, protocol constants, and transport details. - `internal/stream` + `internal/sse`: stream parsing and incremental assembly. -- `internal/toolcall`: canonical XML tool-call parsing + anti-leak sieve (the only executable format is `` / `` / ``). -- `internal/admin`: config/accounts/vercel sync/version/dev-capture endpoints. +- `internal/toolcall` + `internal/toolstream`: canonical XML tool-call parsing + anti-leak sieve (the only executable format is `` / `` / ``). +- `internal/httpapi/admin/*`: Admin API root assembly plus auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version resource packages. - `internal/chathistory`: server-side conversation history persistence, pagination, detail lookup, and retention policy. - `internal/config`: config loading/validation + runtime settings hot-reload. - `internal/account`: managed account pool, inflight slots, waiting queue. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4392f7..24ea5c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,28 +21,37 @@ ds2api/ ├── docs/ # 项目文档目录 ├── internal/ # 核心业务实现(不对外暴露) │ ├── account/ # 账号池、并发槽位、等待队列 -│ ├── adapter/ # 多协议适配层 -│ │ ├── claude/ # Claude 协议适配 -│ │ ├── gemini/ # Gemini 协议适配 -│ │ └── openai/ # OpenAI 协议与统一执行核心 -│ ├── admin/ # Admin API(配置/账号/运维) │ ├── auth/ # 鉴权/JWT/凭证解析 │ ├── chathistory/ # 服务器端对话记录存储与查询 │ ├── claudeconv/ # Claude 消息格式转换工具 │ ├── compat/ # 兼容性辅助与回归支持 │ ├── config/ # 配置加载、校验、热更新 -│ ├── deepseek/ # DeepSeek 上游客户端能力 +│ ├── deepseek/ # DeepSeek 上游 client/protocol/transport +│ │ ├── client/ # 登录、会话、completion、上传/删除等上游调用 +│ │ ├── protocol/ # DeepSeek URL、常量、skip path/pattern │ │ └── transport/ # DeepSeek 传输层细节 │ ├── devcapture/ # 开发抓包与调试采集 │ ├── format/ # 响应格式化层 │ │ ├── claude/ # Claude 输出格式化 │ │ └── openai/ # OpenAI 输出格式化 +│ ├── httpapi/ # HTTP surface:OpenAI/Claude/Gemini/Admin +│ │ ├── admin/ # Admin API 根装配与资源子包 +│ │ ├── claude/ # Claude HTTP 协议适配 +│ │ ├── gemini/ # Gemini HTTP 协议适配 +│ │ └── openai/ # OpenAI HTTP surface +│ │ ├── chat/ # Chat Completions 执行入口 +│ │ ├── responses/ # Responses API 与 response store +│ │ ├── files/ # Files API 与 inline file 预处理 +│ │ ├── embeddings/ # Embeddings API +│ │ ├── history/ # OpenAI history split +│ │ └── shared/ # OpenAI HTTP 公共错误/模型/工具格式 │ ├── js/ # Node Runtime 相关逻辑 │ │ ├── chat-stream/ # Node 流式输出桥接 │ │ ├── helpers/ # JS 辅助函数 │ │ │ └── stream-tool-sieve/ # Tool sieve JS 实现 │ │ └── shared/ # Go/Node 共用语义片段 │ ├── prompt/ # Prompt 组装 +│ ├── promptcompat/ # API 请求到 DeepSeek 网页纯文本上下文兼容层 │ ├── rawsample/ # raw sample 读写与管理 │ ├── server/ # 路由与中间件装配 │ │ └── data/ # 路由/运行时辅助数据 @@ -51,6 +60,7 @@ ds2api/ │ ├── testsuite/ # 测试集执行框架 │ ├── textclean/ # 文本清洗 │ ├── toolcall/ # 工具调用解析与修复 +│ ├── toolstream/ # Go 流式 tool call 防泄漏与增量检测 │ ├── translatorcliproxy/ # 多协议互转桥 │ ├── util/ # 通用工具函数 │ ├── version/ # 版本查询/比较 @@ -93,33 +103,34 @@ ds2api/ ```mermaid flowchart LR C[Client/SDK] --> R[internal/server/router.go] - R --> OA[OpenAI Adapter] - R --> CA[Claude Adapter] - R --> GA[Gemini Adapter] - R --> AD[Admin API] + R --> OA[OpenAI HTTP API] + R --> CA[Claude HTTP API] + R --> GA[Gemini HTTP API] + R --> AD[Admin HTTP API] CA --> BR[translatorcliproxy] GA --> BR - BR --> CORE[internal/adapter/openai ChatCompletions] + BR --> CORE[internal/httpapi/openai/chat ChatCompletions] OA --> CORE CORE --> AUTH[internal/auth + config key/account resolver] CORE --> POOL[internal/account queue + concurrency] - CORE --> TOOL[internal/toolcall parser + sieve] - CORE --> DS[internal/deepseek client] + CORE --> TOOL[internal/toolcall parser + internal/toolstream sieve] + CORE --> DS[internal/deepseek/client] DS --> U[DeepSeek upstream] ``` ## 3. internal/ 子模块职责 - `internal/server`:路由树和中间件挂载(健康检查、协议入口、Admin/WebUI)。 -- `internal/adapter/openai`:统一执行内核(chat/responses/embeddings 与 tool calling 语义)。 -- `internal/adapter/{claude,gemini}`:协议输入输出适配,不重复实现上游调用逻辑。 +- `internal/httpapi/openai/*`:OpenAI HTTP surface,按 chat、responses、files、embeddings、history、shared 拆分。 +- `internal/httpapi/{claude,gemini}`:协议输入输出适配,不重复实现上游调用逻辑。 +- `internal/promptcompat`:OpenAI/Claude/Gemini 请求到 DeepSeek 网页纯文本上下文的兼容内核。 - `internal/translatorcliproxy`:Claude/Gemini 与 OpenAI 结构互转。 -- `internal/deepseek`:上游请求、会话、PoW、SSE 消费。 +- `internal/deepseek/{client,protocol,transport}`:上游请求、会话、PoW 适配、协议常量与传输层。 - `internal/stream` + `internal/sse`:流式解析与增量处理。 -- `internal/toolcall`:canonical XML 工具调用解析与防泄漏筛分(唯一可执行格式:`` / `` / ``)。 -- `internal/admin`:配置管理、账号管理、Vercel 同步、版本检查、开发抓包。 +- `internal/toolcall` + `internal/toolstream`:canonical XML 工具调用解析与防泄漏筛分(唯一可执行格式:`` / `` / ``)。 +- `internal/httpapi/admin/*`:Admin API 根装配与 auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version 等资源子包。 - `internal/chathistory`:服务器端对话记录持久化、分页、单条详情和保留策略。 - `internal/config`:配置加载、校验、运行时 settings 热更新。 - `internal/account`:托管账号池、并发槽位、等待队列。 diff --git a/docs/README.md b/docs/README.md index 6fcd0d7..a80093c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ ### 文档维护约定 -- 文档更新必须以实际代码实现为依据:路由看 `internal/*/handler_routes.go` 与 `internal/admin/handler.go`,配置默认值看 `internal/config/*`,模型/alias 看 `internal/config/models.go`,prompt 兼容链路看 `docs/prompt-compatibility.md` 列出的代码入口。 +- 文档更新必须以实际代码实现为依据:总路由装配看 `internal/server/router.go`,协议/resource 路由看 `internal/httpapi/*/**/routes.go` 与 `internal/httpapi/admin/handler.go`,配置默认值看 `internal/config/*`,模型/alias 看 `internal/config/models.go`,prompt 兼容链路看 `docs/prompt-compatibility.md` 列出的代码入口。 - `README.MD` / `README.en.md`:面向首次接触用户,保留“是什么 + 怎么快速跑起来”。 - `docs/ARCHITECTURE*.md`:面向开发者,集中维护项目结构、模块职责与调用链。 - `API*.md`:面向客户端接入者,聚焦接口行为、鉴权和示例。 @@ -51,7 +51,7 @@ Recommended reading order: ### Maintenance conventions -- Documentation updates must be grounded in the actual implementation: routes live in `internal/*/handler_routes.go` and `internal/admin/handler.go`, config defaults in `internal/config/*`, models/aliases in `internal/config/models.go`, and the prompt compatibility pipeline in the code entrypoints listed by `docs/prompt-compatibility.md`. +- Documentation updates must be grounded in the actual implementation: root routing lives in `internal/server/router.go`, protocol/resource routes live in `internal/httpapi/*/**/routes.go` and `internal/httpapi/admin/handler.go`, config defaults in `internal/config/*`, models/aliases in `internal/config/models.go`, and the prompt compatibility pipeline in the code entrypoints listed by `docs/prompt-compatibility.md`. - `README.MD` / `README.en.md`: onboarding-oriented (“what + quick start”). - `docs/ARCHITECTURE*.md`: developer-oriented source of truth for module boundaries and execution flow. - `API*.md`: integration-oriented behavior/contracts. diff --git a/docs/TESTING.md b/docs/TESTING.md index 3a58e67..40c3501 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -209,8 +209,8 @@ go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/ # 运行 format 相关测试 go test -v ./internal/format/... -# 运行 adapter 相关测试 -go test -v ./internal/adapter/openai/... +# 运行 HTTP API 相关测试 +go test -v ./internal/httpapi/openai/... ``` ### 调试 Tool Call 问题 | Debugging Tool Call Issues diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 82a1496..93128f5 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -53,23 +53,23 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` 对应的关键代码入口: - OpenAI Chat / Responses: - [internal/adapter/openai/standard_request.go](../internal/adapter/openai/standard_request.go) + [internal/promptcompat/request_normalize.go](../internal/promptcompat/request_normalize.go) - OpenAI prompt 组装: - [internal/adapter/openai/prompt_build.go](../internal/adapter/openai/prompt_build.go) + [internal/promptcompat/prompt_build.go](../internal/promptcompat/prompt_build.go) - OpenAI 消息标准化: - [internal/adapter/openai/message_normalize.go](../internal/adapter/openai/message_normalize.go) + [internal/promptcompat/message_normalize.go](../internal/promptcompat/message_normalize.go) - Claude 标准化: - [internal/adapter/claude/standard_request.go](../internal/adapter/claude/standard_request.go) + [internal/httpapi/claude/standard_request.go](../internal/httpapi/claude/standard_request.go) - Claude 消息与 tool_use/tool_result 归一: - [internal/adapter/claude/handler_utils.go](../internal/adapter/claude/handler_utils.go) + [internal/httpapi/claude/handler_utils.go](../internal/httpapi/claude/handler_utils.go) - Gemini 复用 OpenAI prompt builder: - [internal/adapter/gemini/convert_request.go](../internal/adapter/gemini/convert_request.go) + [internal/httpapi/gemini/convert_request.go](../internal/httpapi/gemini/convert_request.go) - DeepSeek prompt 角色标记拼装: [internal/prompt/messages.go](../internal/prompt/messages.go) - prompt 可见 tool history XML: [internal/prompt/tool_calls.go](../internal/prompt/tool_calls.go) - completion payload: - [internal/util/standard_request.go](../internal/util/standard_request.go) + [internal/promptcompat/standard_request.go](../internal/promptcompat/standard_request.go) ## 4. 下游真正收到的东西 @@ -96,7 +96,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` - `prompt` 才是对话上下文主载体。 - `ref_file_ids` 只承载文件引用,不承载普通文本消息。 - `tools` 不会作为“原生工具 schema”直接下发给下游,而是被改写进 `prompt`。 -- OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装;Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 `openai.BuildPromptForAdapter`,Claude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。 +- OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装;Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 `promptcompat.BuildOpenAIPromptForAdapter`,Claude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。 - 客户端传入的 thinking / reasoning 开关会被归一到下游 `thinking_enabled`。Claude surface 没有 `thinking` 字段时按 Anthropic 语义视为关闭;Gemini `generationConfig.thinkingConfig.thinkingBudget` 会翻译成同一套 thinking 开关;关闭时即使上游返回 `response/thinking_content`,兼容层也不会把它当作可见正文输出。 ## 5. prompt 是怎么拼出来的 @@ -148,10 +148,10 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` 4. 把这整段内容并入 system prompt。 OpenAI 路径实现: -[internal/adapter/openai/handler_toolcall_format.go](../internal/adapter/openai/handler_toolcall_format.go) +[internal/promptcompat/tool_prompt.go](../internal/promptcompat/tool_prompt.go) Claude 路径实现: -[internal/adapter/claude/handler_utils.go](../internal/adapter/claude/handler_utils.go) +[internal/httpapi/claude/handler_utils.go](../internal/httpapi/claude/handler_utils.go) 统一工具调用格式模板: [internal/toolcall/tool_prompt.go](../internal/toolcall/tool_prompt.go) @@ -217,9 +217,9 @@ tool / function role 的结果会作为 `<|Tool|>...<|end▁of▁toolresul OpenAI 文件相关实现: - inline/base64/data URL 上传: - [internal/adapter/openai/file_inline_upload.go](../internal/adapter/openai/file_inline_upload.go) + [internal/httpapi/openai/files/file_inline_upload.go](../internal/httpapi/openai/files/file_inline_upload.go) - 文件 ID 收集: - [internal/adapter/openai/file_refs.go](../internal/adapter/openai/file_refs.go) + [internal/promptcompat/file_refs.go](../internal/promptcompat/file_refs.go) 结论: @@ -237,7 +237,7 @@ OpenAI 文件相关实现: - 配置访问器: [internal/config/store_accessors.go](../internal/config/store_accessors.go) - 历史拆分: - [internal/adapter/openai/history_split.go](../internal/adapter/openai/history_split.go) + [internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go) 触发后行为: @@ -286,7 +286,7 @@ OpenAI 文件相关实现: - top-level `system` 优先作为系统提示 - `tool_use` / `tool_result` 会被转换成统一的 assistant/tool 历史语义 - `tools` 同样会被并进 system prompt -- 常规执行通过 `internal/adapter/claude/handler_messages.go` 转到 OpenAI chat 路径,模型 alias 会先解析成 DeepSeek 原生模型 +- 常规执行通过 `internal/httpapi/claude/handler_messages.go` 转到 OpenAI chat 路径,模型 alias 会先解析成 DeepSeek 原生模型 - 当前代码里没有像 OpenAI 那样完整的 `ref_file_ids` 附件链路 ### 10.3 Gemini @@ -295,7 +295,7 @@ OpenAI 文件相关实现: - `systemInstruction`、`contents.parts`、`functionCall`、`functionResponse` 会先归一 - tools 会转成 OpenAI 风格 function schema -- prompt 构建复用 OpenAI 的 `BuildPromptForAdapter` +- prompt 构建复用 OpenAI 的 `promptcompat.BuildOpenAIPromptForAdapter` - 未识别的非文本 part 会被安全序列化进 prompt,并对二进制/疑似 base64 内容做省略或截断处理 也就是说,Gemini 在“最终 prompt 语义”上,尽量和 OpenAI 保持一致。 @@ -348,31 +348,31 @@ OpenAI 文件相关实现: 优先检查这些文件: -- `internal/adapter/openai/standard_request.go` -- `internal/adapter/openai/prompt_build.go` -- `internal/adapter/openai/message_normalize.go` -- `internal/adapter/openai/handler_toolcall_format.go` -- `internal/adapter/openai/file_inline_upload.go` -- `internal/adapter/openai/file_refs.go` -- `internal/adapter/openai/history_split.go` -- `internal/adapter/openai/responses_input_normalize.go` -- `internal/adapter/claude/standard_request.go` -- `internal/adapter/claude/handler_utils.go` -- `internal/adapter/gemini/convert_request.go` -- `internal/adapter/gemini/convert_messages.go` -- `internal/adapter/gemini/convert_tools.go` +- `internal/promptcompat/request_normalize.go` +- `internal/promptcompat/prompt_build.go` +- `internal/promptcompat/message_normalize.go` +- `internal/promptcompat/tool_prompt.go` +- `internal/httpapi/openai/files/file_inline_upload.go` +- `internal/promptcompat/file_refs.go` +- `internal/httpapi/openai/history/history_split.go` +- `internal/promptcompat/responses_input_normalize.go` +- `internal/httpapi/claude/standard_request.go` +- `internal/httpapi/claude/handler_utils.go` +- `internal/httpapi/gemini/convert_request.go` +- `internal/httpapi/gemini/convert_messages.go` +- `internal/httpapi/gemini/convert_tools.go` - `internal/prompt/messages.go` - `internal/prompt/tool_calls.go` -- `internal/util/standard_request.go` +- `internal/promptcompat/standard_request.go` ## 13. 建议的最小验证 改动这条链路后,至少补齐或检查这些测试: - `go test ./internal/prompt/...` -- `go test ./internal/adapter/openai/...` -- `go test ./internal/adapter/claude/...` -- `go test ./internal/adapter/gemini/...` +- `go test ./internal/httpapi/openai/...` +- `go test ./internal/httpapi/claude/...` +- `go test ./internal/httpapi/gemini/...` - `go test ./internal/util/...` 如果改的是 tool call 相关兼容语义,还应同时检查: diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md index 477ad80..2627a0a 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -1,6 +1,6 @@ # Tool call parsing semantics(Go/Node 统一语义) -本文档描述当前代码中的**实际行为**,以 `internal/toolcall` 与 `internal/js/helpers/stream-tool-sieve` 为准。 +本文档描述当前代码中的**实际行为**,以 `internal/toolcall`、`internal/toolstream` 与 `internal/js/helpers/stream-tool-sieve` 为准。 文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [测试指南](./TESTING.md) @@ -58,7 +58,7 @@ 可直接运行: ```bash -go test -v -run 'TestParseToolCalls|TestProcessToolSieve' ./internal/toolcall ./internal/adapter/openai +go test -v -run 'TestParseToolCalls|TestProcessToolSieve' ./internal/toolcall ./internal/toolstream ./internal/httpapi/openai/... node --test tests/node/stream-tool-sieve.test.js ``` diff --git a/internal/adapter/openai/handler_routes.go b/internal/adapter/openai/handler_routes.go deleted file mode 100644 index a08be15..0000000 --- a/internal/adapter/openai/handler_routes.go +++ /dev/null @@ -1,74 +0,0 @@ -package openai - -import ( - "net/http" - "strings" - "sync" - "time" - - "github.com/go-chi/chi/v5" - - "ds2api/internal/auth" - "ds2api/internal/chathistory" - "ds2api/internal/config" - "ds2api/internal/util" -) - -const ( - // openAIUploadMaxSize limits total multipart request body size (100 MiB). - openAIUploadMaxSize = 100 << 20 - // openAIGeneralMaxSize limits total JSON request body size (100 MiB). - openAIGeneralMaxSize = 100 << 20 -) - -// writeJSON is a package-internal alias kept to avoid mass-renaming across -// every call-site in this package. -var writeJSON = util.WriteJSON - -type Handler struct { - Store ConfigReader - Auth AuthResolver - DS DeepSeekCaller - ChatHistory *chathistory.Store - - leaseMu sync.Mutex - streamLeases map[string]streamLease - responsesMu sync.Mutex - responses *responseStore -} - -func (h *Handler) compatStripReferenceMarkers() bool { - if h == nil || h.Store == nil { - return true - } - return h.Store.CompatStripReferenceMarkers() -} - -type streamLease struct { - Auth *auth.RequestAuth - ExpiresAt time.Time -} - -func RegisterRoutes(r chi.Router, h *Handler) { - r.Get("/v1/models", h.ListModels) - r.Get("/v1/models/{model_id}", h.GetModel) - r.Post("/v1/chat/completions", h.ChatCompletions) - r.Post("/v1/responses", h.Responses) - r.Get("/v1/responses/{response_id}", h.GetResponseByID) - r.Post("/v1/files", h.UploadFile) - r.Post("/v1/embeddings", h.Embeddings) -} - -func (h *Handler) ListModels(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, config.OpenAIModelsResponse()) -} - -func (h *Handler) GetModel(w http.ResponseWriter, r *http.Request) { - modelID := strings.TrimSpace(chi.URLParam(r, "model_id")) - model, ok := config.OpenAIModelByID(h.Store, modelID) - if !ok { - writeOpenAIError(w, http.StatusNotFound, "Model not found.") - return - } - writeJSON(w, http.StatusOK, model) -} diff --git a/internal/adapter/openai/handler_toolcall_format.go b/internal/adapter/openai/handler_toolcall_format.go deleted file mode 100644 index 3937610..0000000 --- a/internal/adapter/openai/handler_toolcall_format.go +++ /dev/null @@ -1,170 +0,0 @@ -package openai - -import ( - "ds2api/internal/toolcall" - "encoding/json" - "fmt" - "strings" - - "github.com/google/uuid" - - "ds2api/internal/util" -) - -func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolChoicePolicy) ([]map[string]any, []string) { - if policy.IsNone() { - return messages, nil - } - toolSchemas := make([]string, 0, len(tools)) - names := make([]string, 0, len(tools)) - isAllowed := func(name string) bool { - if strings.TrimSpace(name) == "" { - return false - } - if len(policy.Allowed) == 0 { - return true - } - _, ok := policy.Allowed[name] - return ok - } - - for _, t := range tools { - tool, ok := t.(map[string]any) - if !ok { - continue - } - fn, _ := tool["function"].(map[string]any) - if len(fn) == 0 { - fn = tool - } - name, _ := fn["name"].(string) - desc, _ := fn["description"].(string) - schema, _ := fn["parameters"].(map[string]any) - name = strings.TrimSpace(name) - if !isAllowed(name) { - continue - } - names = append(names, name) - if desc == "" { - desc = "No description available" - } - b, _ := json.Marshal(schema) - toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(b))) - } - if len(toolSchemas) == 0 { - return messages, names - } - toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + buildToolCallInstructions(names) - if policy.Mode == util.ToolChoiceRequired { - toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list." - } - if policy.Mode == util.ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" { - toolPrompt += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName) - toolPrompt += "\n8) Do not call any other tool." - } - - for i := range messages { - if messages[i]["role"] == "system" { - old, _ := messages[i]["content"].(string) - messages[i]["content"] = strings.TrimSpace(old + "\n\n" + toolPrompt) - return messages, names - } - } - messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...) - return messages, names -} - -// buildToolCallInstructions delegates to the shared util implementation. -func buildToolCallInstructions(toolNames []string) string { - return toolcall.BuildToolCallInstructions(toolNames) -} - -func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any { - if len(deltas) == 0 { - return nil - } - out := make([]map[string]any, 0, len(deltas)) - for _, d := range deltas { - if d.Name == "" && d.Arguments == "" { - continue - } - callID, ok := ids[d.Index] - if !ok || callID == "" { - callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "") - ids[d.Index] = callID - } - item := map[string]any{ - "index": d.Index, - "id": callID, - "type": "function", - } - fn := map[string]any{} - if d.Name != "" { - fn["name"] = d.Name - } - if d.Arguments != "" { - fn["arguments"] = d.Arguments - } - if len(fn) > 0 { - item["function"] = fn - } - out = append(out, item) - } - return out -} - -func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, seenNames map[int]string) []toolCallDelta { - if len(deltas) == 0 { - return nil - } - out := make([]toolCallDelta, 0, len(deltas)) - for _, d := range deltas { - if d.Name != "" { - if seenNames != nil { - seenNames[d.Index] = d.Name - } - out = append(out, d) - continue - } - if seenNames == nil { - out = append(out, d) - continue - } - name := strings.TrimSpace(seenNames[d.Index]) - if name == "" { - continue - } - out = append(out, d) - } - return out -} - -func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any { - if len(calls) == 0 { - return nil - } - out := make([]map[string]any, 0, len(calls)) - for i, c := range calls { - callID := "" - if ids != nil { - callID = strings.TrimSpace(ids[i]) - } - if callID == "" { - callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "") - if ids != nil { - ids[i] = callID - } - } - args, _ := json.Marshal(c.Input) - out = append(out, map[string]any{ - "index": i, - "id": callID, - "type": "function", - "function": map[string]any{ - "name": c.Name, - "arguments": string(args), - }, - }) - } - return out -} diff --git a/internal/adapter/openai/handler_toolcall_policy.go b/internal/adapter/openai/handler_toolcall_policy.go deleted file mode 100644 index b29c91f..0000000 --- a/internal/adapter/openai/handler_toolcall_policy.go +++ /dev/null @@ -1,9 +0,0 @@ -package openai - -func (h *Handler) toolcallFeatureMatchEnabled() bool { - return true -} - -func (h *Handler) toolcallEarlyEmitHighConfidence() bool { - return true -} diff --git a/internal/adapter/openai/prompt_build.go b/internal/adapter/openai/prompt_build.go deleted file mode 100644 index 2e1d891..0000000 --- a/internal/adapter/openai/prompt_build.go +++ /dev/null @@ -1,26 +0,0 @@ -package openai - -import ( - "ds2api/internal/deepseek" - "ds2api/internal/util" -) - -func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) { - return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy(), thinkingEnabled) -} - -func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) { - messages := normalizeOpenAIMessagesForPrompt(messagesRaw, traceID) - toolNames := []string{} - if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 { - messages, toolNames = injectToolPrompt(messages, tools, toolPolicy) - } - return deepseek.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames -} - -// BuildPromptForAdapter exposes the OpenAI-compatible prompt building flow so -// other protocol adapters (for example Gemini) can reuse the same tool/history -// normalization logic and remain behavior-compatible with chat/completions. -func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) { - return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID, thinkingEnabled) -} diff --git a/internal/adapter/openai/standard_request_test.go b/internal/adapter/openai/standard_request_test.go deleted file mode 100644 index a242953..0000000 --- a/internal/adapter/openai/standard_request_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package openai - -import ( - "testing" - - "ds2api/internal/config" - "ds2api/internal/util" -) - -func newEmptyStoreForNormalizeTest(t *testing.T) *config.Store { - t.Helper() - t.Setenv("DS2API_CONFIG_JSON", `{}`) - return config.LoadStore() -} - -func TestNormalizeOpenAIChatRequest(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-5-codex", - "messages": []any{ - map[string]any{"role": "user", "content": "hello"}, - }, - "temperature": 0.3, - "stream": true, - } - n, err := normalizeOpenAIChatRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.ResolvedModel != "deepseek-v4-pro" { - t.Fatalf("unexpected resolved model: %s", n.ResolvedModel) - } - if !n.Thinking { - t.Fatalf("expected thinking enabled by default") - } - if !n.Stream { - t.Fatalf("expected stream=true") - } - if _, ok := n.PassThrough["temperature"]; !ok { - t.Fatalf("expected temperature passthrough") - } - if n.FinalPrompt == "" { - t.Fatalf("expected non-empty final prompt") - } -} - -func TestNormalizeOpenAIChatRequestCollectsRefFileIDs(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-5-codex", - "messages": []any{ - map[string]any{ - "role": "user", - "content": []any{ - map[string]any{"type": "input_text", "text": "hello"}, - map[string]any{"type": "input_file", "file_id": "file-msg"}, - }, - }, - }, - "attachments": []any{ - map[string]any{"file_id": "file-attachment"}, - }, - "ref_file_ids": []any{"file-top", "file-attachment"}, - } - n, err := normalizeOpenAIChatRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if len(n.RefFileIDs) != 3 { - t.Fatalf("expected 3 distinct file ids, got %#v", n.RefFileIDs) - } - if n.RefFileIDs[0] != "file-top" || n.RefFileIDs[1] != "file-attachment" || n.RefFileIDs[2] != "file-msg" { - t.Fatalf("unexpected file ids: %#v", n.RefFileIDs) - } -} - -func TestNormalizeOpenAIResponsesRequestInput(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "instructions": "system", - } - n, err := normalizeOpenAIResponsesRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.ResolvedModel != "deepseek-v4-flash" { - t.Fatalf("unexpected resolved model: %s", n.ResolvedModel) - } - if !n.Thinking { - t.Fatalf("expected thinking enabled by default for responses") - } - if len(n.Messages) != 2 { - t.Fatalf("expected 2 normalized messages, got %d", len(n.Messages)) - } -} - -func TestNormalizeOpenAIChatRequestThinkingOverrides(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "messages": []any{ - map[string]any{"role": "user", "content": "hello"}, - }, - "thinking": map[string]any{"type": "disabled"}, - "extra_body": map[string]any{ - "thinking": map[string]any{"type": "enabled"}, - }, - "reasoning_effort": "high", - } - n, err := normalizeOpenAIChatRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.Thinking { - t.Fatalf("expected top-level thinking override to disable thinking") - } -} - -func TestNormalizeOpenAIResponsesRequestThinkingExtraBodyFallback(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "extra_body": map[string]any{ - "thinking": map[string]any{"type": "disabled"}, - }, - } - n, err := normalizeOpenAIResponsesRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.Thinking { - t.Fatalf("expected extra_body thinking override to disable thinking") - } -} - -func TestNormalizeOpenAIResponsesRequestReasoningDisablesThinking(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "reasoning": map[string]any{"effort": "none"}, - } - n, err := normalizeOpenAIResponsesRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.Thinking { - t.Fatalf("expected reasoning.effort=none to disable thinking") - } -} - -func TestNormalizeOpenAIResponsesRequestToolChoiceRequired(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "tools": []any{ - map[string]any{ - "type": "function", - "function": map[string]any{ - "name": "search", - "parameters": map[string]any{ - "type": "object", - }, - }, - }, - }, - "tool_choice": "required", - } - n, err := normalizeOpenAIResponsesRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.ToolChoice.Mode != util.ToolChoiceRequired { - t.Fatalf("expected tool choice mode required, got %q", n.ToolChoice.Mode) - } - if len(n.ToolNames) != 1 || n.ToolNames[0] != "search" { - t.Fatalf("unexpected tool names: %#v", n.ToolNames) - } -} - -func TestNormalizeOpenAIResponsesRequestToolChoiceForcedFunction(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "tools": []any{ - map[string]any{ - "type": "function", - "function": map[string]any{ - "name": "search", - }, - }, - map[string]any{ - "type": "function", - "function": map[string]any{ - "name": "read_file", - }, - }, - }, - "tool_choice": map[string]any{ - "type": "function", - "name": "read_file", - }, - } - n, err := normalizeOpenAIResponsesRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.ToolChoice.Mode != util.ToolChoiceForced { - t.Fatalf("expected tool choice mode forced, got %q", n.ToolChoice.Mode) - } - if n.ToolChoice.ForcedName != "read_file" { - t.Fatalf("expected forced tool name read_file, got %q", n.ToolChoice.ForcedName) - } - if len(n.ToolNames) != 1 || n.ToolNames[0] != "read_file" { - t.Fatalf("expected filtered tool names [read_file], got %#v", n.ToolNames) - } -} - -func TestNormalizeOpenAIResponsesRequestToolChoiceForcedUndeclaredFails(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "tools": []any{ - map[string]any{ - "type": "function", - "function": map[string]any{ - "name": "search", - }, - }, - }, - "tool_choice": map[string]any{ - "type": "function", - "name": "read_file", - }, - } - if _, err := normalizeOpenAIResponsesRequest(store, req, ""); err == nil { - t.Fatalf("expected forced undeclared tool to fail") - } -} - -func TestNormalizeOpenAIResponsesRequestToolChoiceNoneKeepsToolDetectionEnabled(t *testing.T) { - store := newEmptyStoreForNormalizeTest(t) - req := map[string]any{ - "model": "gpt-4o", - "input": "ping", - "tools": []any{ - map[string]any{ - "type": "function", - "function": map[string]any{ - "name": "search", - }, - }, - }, - "tool_choice": "none", - } - n, err := normalizeOpenAIResponsesRequest(store, req, "") - if err != nil { - t.Fatalf("normalize failed: %v", err) - } - if n.ToolChoice.Mode != util.ToolChoiceNone { - t.Fatalf("expected tool choice mode none, got %q", n.ToolChoice.Mode) - } - if len(n.ToolNames) == 0 { - t.Fatalf("expected tool detection sentinel when tool_choice=none, got %#v", n.ToolNames) - } -} diff --git a/internal/admin/handler.go b/internal/admin/handler.go deleted file mode 100644 index a3eb796..0000000 --- a/internal/admin/handler.go +++ /dev/null @@ -1,65 +0,0 @@ -package admin - -import ( - "github.com/go-chi/chi/v5" - - "ds2api/internal/chathistory" -) - -type Handler struct { - Store ConfigStore - Pool PoolController - DS DeepSeekCaller - OpenAI OpenAIChatCaller - ChatHistory *chathistory.Store -} - -func RegisterRoutes(r chi.Router, h *Handler) { - r.Post("/login", h.login) - r.Get("/verify", h.verify) - r.Group(func(pr chi.Router) { - pr.Use(h.requireAdmin) - pr.Get("/vercel/config", h.getVercelConfig) - pr.Get("/config", h.getConfig) - pr.Post("/config", h.updateConfig) - pr.Get("/settings", h.getSettings) - pr.Put("/settings", h.updateSettings) - pr.Post("/settings/password", h.updateSettingsPassword) - pr.Post("/config/import", h.configImport) - pr.Get("/config/export", h.configExport) - pr.Post("/keys", h.addKey) - pr.Put("/keys/{key}", h.updateKey) - pr.Delete("/keys/{key}", h.deleteKey) - pr.Get("/proxies", h.listProxies) - pr.Post("/proxies", h.addProxy) - pr.Put("/proxies/{proxyID}", h.updateProxy) - pr.Delete("/proxies/{proxyID}", h.deleteProxy) - pr.Post("/proxies/test", h.testProxy) - pr.Get("/accounts", h.listAccounts) - pr.Post("/accounts", h.addAccount) - pr.Put("/accounts/{identifier}", h.updateAccount) - pr.Delete("/accounts/{identifier}", h.deleteAccount) - pr.Put("/accounts/{identifier}/proxy", h.updateAccountProxy) - pr.Get("/queue/status", h.queueStatus) - pr.Post("/accounts/test", h.testSingleAccount) - pr.Post("/accounts/test-all", h.testAllAccounts) - pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions) - pr.Post("/import", h.batchImport) - pr.Post("/test", h.testAPI) - pr.Post("/dev/raw-samples/capture", h.captureRawSample) - pr.Get("/dev/raw-samples/query", h.queryRawSampleCaptures) - pr.Post("/dev/raw-samples/save", h.saveRawSampleFromCaptures) - pr.Post("/vercel/sync", h.syncVercel) - pr.Get("/vercel/status", h.vercelStatus) - pr.Post("/vercel/status", h.vercelStatus) - pr.Get("/export", h.exportConfig) - pr.Get("/dev/captures", h.getDevCaptures) - pr.Delete("/dev/captures", h.clearDevCaptures) - pr.Get("/chat-history", h.getChatHistory) - pr.Get("/chat-history/{id}", h.getChatHistoryItem) - pr.Delete("/chat-history", h.clearChatHistory) - pr.Delete("/chat-history/{id}", h.deleteChatHistoryItem) - pr.Put("/chat-history/settings", h.updateChatHistorySettings) - pr.Get("/version", h.getVersion) - }) -} diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client/client_auth.go similarity index 93% rename from internal/deepseek/client_auth.go rename to internal/deepseek/client/client_auth.go index 21afa66..b582df9 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client/client_auth.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "errors" "fmt" "net/http" @@ -28,7 +29,7 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) } else { return "", errors.New("missing email/mobile") } - resp, err := c.postJSON(ctx, clients.regular, clients.fallback, DeepSeekLoginURL, BaseHeaders, payload) + resp, err := c.postJSON(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekLoginURL, dsprotocol.BaseHeaders, payload) if err != nil { return "", err } @@ -58,7 +59,7 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte refreshed := false for attempts < maxAttempts { headers := c.authHeaders(a.DeepSeekToken) - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"}) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"}) if err != nil { config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID) attempts++ @@ -91,7 +92,7 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte } func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) { - return c.GetPowForTarget(ctx, a, DeepSeekCompletionTargetPath, maxAttempts) + return c.GetPowForTarget(ctx, a, dsprotocol.DeepSeekCompletionTargetPath, maxAttempts) } func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targetPath string, maxAttempts int) (string, error) { @@ -100,7 +101,7 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe } targetPath = strings.TrimSpace(targetPath) if targetPath == "" { - targetPath = DeepSeekCompletionTargetPath + targetPath = dsprotocol.DeepSeekCompletionTargetPath } clients := c.requestClientsForAuth(ctx, a) attempts := 0 @@ -109,7 +110,7 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe lastFailureMessage := "" for attempts < maxAttempts { headers := c.authHeaders(a.DeepSeekToken) - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreatePowURL, headers, map[string]any{"target_path": targetPath}) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreatePowURL, headers, map[string]any{"target_path": targetPath}) if err != nil { config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath) lastFailureKind = FailureUnknown @@ -158,8 +159,8 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe } func (c *Client) authHeaders(token string) map[string]string { - headers := make(map[string]string, len(BaseHeaders)+1) - for k, v := range BaseHeaders { + headers := make(map[string]string, len(dsprotocol.BaseHeaders)+1) + for k, v := range dsprotocol.BaseHeaders { headers[k] = v } headers["authorization"] = "Bearer " + token diff --git a/internal/deepseek/client_auth_mobile_test.go b/internal/deepseek/client/client_auth_mobile_test.go similarity index 98% rename from internal/deepseek/client_auth_mobile_test.go rename to internal/deepseek/client/client_auth_mobile_test.go index de81690..e676b4e 100644 --- a/internal/deepseek/client_auth_mobile_test.go +++ b/internal/deepseek/client/client_auth_mobile_test.go @@ -1,4 +1,4 @@ -package deepseek +package client import "testing" diff --git a/internal/deepseek/client_auth_refresh_test.go b/internal/deepseek/client/client_auth_refresh_test.go similarity index 98% rename from internal/deepseek/client_auth_refresh_test.go rename to internal/deepseek/client/client_auth_refresh_test.go index 2506a00..2cc1ff1 100644 --- a/internal/deepseek/client_auth_refresh_test.go +++ b/internal/deepseek/client/client_auth_refresh_test.go @@ -1,4 +1,4 @@ -package deepseek +package client import "testing" diff --git a/internal/deepseek/client_auth_test.go b/internal/deepseek/client/client_auth_test.go similarity index 97% rename from internal/deepseek/client_auth_test.go rename to internal/deepseek/client/client_auth_test.go index 3ce81d5..6e23877 100644 --- a/internal/deepseek/client_auth_test.go +++ b/internal/deepseek/client/client_auth_test.go @@ -1,4 +1,4 @@ -package deepseek +package client import "testing" diff --git a/internal/deepseek/client_completion.go b/internal/deepseek/client/client_completion.go similarity index 87% rename from internal/deepseek/client_completion.go rename to internal/deepseek/client/client_completion.go index c27a88f..1b91ce2 100644 --- a/internal/deepseek/client_completion.go +++ b/internal/deepseek/client/client_completion.go @@ -1,8 +1,9 @@ -package deepseek +package client import ( "bytes" "context" + dsprotocol "ds2api/internal/deepseek/protocol" "encoding/json" "errors" "net/http" @@ -20,10 +21,10 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa clients := c.requestClientsForAuth(ctx, a) headers := c.authHeaders(a.DeepSeekToken) headers["x-ds-pow-response"] = powResp - captureSession := c.capture.Start("deepseek_completion", DeepSeekCompletionURL, a.AccountID, payload) + captureSession := c.capture.Start("deepseek_completion", dsprotocol.DeepSeekCompletionURL, a.AccountID, payload) attempts := 0 for attempts < maxAttempts { - resp, err := c.streamPost(ctx, clients.stream, DeepSeekCompletionURL, headers, payload) + resp, err := c.streamPost(ctx, clients.stream, dsprotocol.DeepSeekCompletionURL, headers, payload) if err != nil { attempts++ time.Sleep(time.Second) diff --git a/internal/deepseek/client_continue.go b/internal/deepseek/client/client_continue.go similarity index 96% rename from internal/deepseek/client_continue.go rename to internal/deepseek/client/client_continue.go index f3354f7..aea30cc 100644 --- a/internal/deepseek/client_continue.go +++ b/internal/deepseek/client/client_continue.go @@ -1,9 +1,10 @@ -package deepseek +package client import ( "bufio" "bytes" "context" + dsprotocol "ds2api/internal/deepseek/protocol" "encoding/json" "errors" "io" @@ -60,8 +61,8 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI "fallback_to_resume": true, } config.Logger.Info("[auto_continue] calling continue", "session_id", sessionID, "message_id", responseMessageID) - captureSession := c.capture.Start("deepseek_continue", DeepSeekContinueURL, a.AccountID, payload) - resp, err := c.streamPost(ctx, clients.stream, DeepSeekContinueURL, headers, payload) + captureSession := c.capture.Start("deepseek_continue", dsprotocol.DeepSeekContinueURL, a.AccountID, payload) + resp, err := c.streamPost(ctx, clients.stream, dsprotocol.DeepSeekContinueURL, headers, payload) if err != nil { return nil, err } diff --git a/internal/deepseek/client_continue_test.go b/internal/deepseek/client/client_continue_test.go similarity index 91% rename from internal/deepseek/client_continue_test.go rename to internal/deepseek/client/client_continue_test.go index 4758ab0..83a42af 100644 --- a/internal/deepseek/client_continue_test.go +++ b/internal/deepseek/client/client_continue_test.go @@ -1,8 +1,9 @@ -package deepseek +package client import ( "bytes" "context" + dsprotocol "ds2api/internal/deepseek/protocol" "errors" "io" "net/http" @@ -58,8 +59,8 @@ func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) { if seenPow != "pow-response-abc" { t.Fatalf("continue request pow header=%q want=%q", seenPow, "pow-response-abc") } - if seenURL != DeepSeekContinueURL { - t.Fatalf("continue request url=%q want=%q", seenURL, DeepSeekContinueURL) + if seenURL != dsprotocol.DeepSeekContinueURL { + t.Fatalf("continue request url=%q want=%q", seenURL, dsprotocol.DeepSeekContinueURL) } } @@ -112,8 +113,8 @@ func TestCallCompletionAutoContinueThreadsPowHeader(t *testing.T) { if seenPow != "pow-response-xyz" { t.Fatalf("threaded continue pow header=%q want=%q", seenPow, "pow-response-xyz") } - if seenContinueURL != DeepSeekContinueURL { - t.Fatalf("continue url=%q want=%q", seenContinueURL, DeepSeekContinueURL) + if seenContinueURL != dsprotocol.DeepSeekContinueURL { + t.Fatalf("continue url=%q want=%q", seenContinueURL, dsprotocol.DeepSeekContinueURL) } if !bytes.Contains(out, []byte(`"status":"WIP"`)) { t.Fatalf("expected initial stream content in body, got=%s", string(out)) diff --git a/internal/deepseek/client_core.go b/internal/deepseek/client/client_core.go similarity index 98% rename from internal/deepseek/client_core.go rename to internal/deepseek/client/client_core.go index 57aeadb..f730e88 100644 --- a/internal/deepseek/client_core.go +++ b/internal/deepseek/client/client_core.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "context" diff --git a/internal/deepseek/client_file_status.go b/internal/deepseek/client/client_file_status.go similarity index 97% rename from internal/deepseek/client_file_status.go rename to internal/deepseek/client/client_file_status.go index ba50ab8..e9bfe28 100644 --- a/internal/deepseek/client_file_status.go +++ b/internal/deepseek/client/client_file_status.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "errors" "fmt" "net/http" @@ -70,7 +71,7 @@ func (c *Client) fetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fil return nil, errors.New("file id is required") } clients := c.requestClientsForAuth(ctx, a) - reqURL := DeepSeekFetchFilesURL + "?file_ids=" + url.QueryEscape(fileID) + reqURL := dsprotocol.DeepSeekFetchFilesURL + "?file_ids=" + url.QueryEscape(fileID) headers := c.authHeaders(a.DeepSeekToken) resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers) diff --git a/internal/deepseek/client_http_helpers.go b/internal/deepseek/client/client_http_helpers.go similarity index 70% rename from internal/deepseek/client_http_helpers.go rename to internal/deepseek/client/client_http_helpers.go index 14cfbdd..dd690d9 100644 --- a/internal/deepseek/client_http_helpers.go +++ b/internal/deepseek/client/client_http_helpers.go @@ -1,7 +1,6 @@ -package deepseek +package client import ( - "bufio" "compress/gzip" "io" "net/http" @@ -41,17 +40,10 @@ func (c *Client) jsonHeaders(headers map[string]string) map[string]string { return out } -func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error { - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 2*1024*1024) - for scanner.Scan() { - if !onLine(scanner.Bytes()) { - break - } +func cloneStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v } - if err := scanner.Err(); err != nil { - return err - } - return nil + return out } diff --git a/internal/deepseek/client_http_json.go b/internal/deepseek/client/client_http_json.go similarity index 99% rename from internal/deepseek/client_http_json.go rename to internal/deepseek/client/client_http_json.go index 88eebae..06c8138 100644 --- a/internal/deepseek/client_http_json.go +++ b/internal/deepseek/client/client_http_json.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "bytes" diff --git a/internal/deepseek/client_http_json_test.go b/internal/deepseek/client/client_http_json_test.go similarity index 98% rename from internal/deepseek/client_http_json_test.go rename to internal/deepseek/client/client_http_json_test.go index ee553ab..d2188e9 100644 --- a/internal/deepseek/client_http_json_test.go +++ b/internal/deepseek/client/client_http_json_test.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "context" diff --git a/internal/deepseek/client_session.go b/internal/deepseek/client/client_session.go similarity index 96% rename from internal/deepseek/client_session.go rename to internal/deepseek/client/client_session.go index 4b571d1..98a7feb 100644 --- a/internal/deepseek/client_session.go +++ b/internal/deepseek/client/client_session.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "errors" "fmt" "net/http" @@ -49,7 +50,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt headers := c.authHeaders(a.DeepSeekToken) // 构建请求 URL - reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false" + reqURL := dsprotocol.DeepSeekFetchSessionURL + "?lte_cursor.pinned=false" resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers) if err != nil { @@ -109,7 +110,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) { clients := c.requestClientsFromContext(ctx) headers := c.authHeaders(token) - reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false" + reqURL := dsprotocol.DeepSeekFetchSessionURL + "?lte_cursor.pinned=false" resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers) if err != nil { @@ -202,7 +203,7 @@ func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, curs if cursor != "" { params.Set("lte_cursor", cursor) } - reqURL := DeepSeekFetchSessionURL + "?" + params.Encode() + reqURL := dsprotocol.DeepSeekFetchSessionURL + "?" + params.Encode() resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers) if err != nil { diff --git a/internal/deepseek/client_session_delete.go b/internal/deepseek/client/client_session_delete.go similarity index 92% rename from internal/deepseek/client_session_delete.go rename to internal/deepseek/client/client_session_delete.go index 2df4abe..fa810fd 100644 --- a/internal/deepseek/client_session_delete.go +++ b/internal/deepseek/client/client_session_delete.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "errors" "fmt" "net/http" @@ -43,7 +44,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session "chat_session_id": sessionID, } - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteSessionURL, headers, payload) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteSessionURL, headers, payload) if err != nil { config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID) attempts++ @@ -97,7 +98,7 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio "chat_session_id": sessionID, } - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteSessionURL, headers, payload) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteSessionURL, headers, payload) if err != nil { result.ErrorMessage = err.Error() return result, err @@ -120,7 +121,7 @@ func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) err headers := c.authHeaders(a.DeepSeekToken) payload := map[string]any{} - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteAllSessionsURL, headers, payload) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteAllSessionsURL, headers, payload) if err != nil { config.Logger.Warn("[delete_all_sessions] request error", "error", err) return err @@ -142,7 +143,7 @@ func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) er headers := c.authHeaders(token) payload := map[string]any{} - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteAllSessionsURL, headers, payload) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteAllSessionsURL, headers, payload) if err != nil { config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err) return err diff --git a/internal/deepseek/client_upload.go b/internal/deepseek/client/client_upload.go similarity index 96% rename from internal/deepseek/client_upload.go rename to internal/deepseek/client/client_upload.go index 0666a4f..9e95a23 100644 --- a/internal/deepseek/client_upload.go +++ b/internal/deepseek/client/client_upload.go @@ -1,8 +1,9 @@ -package deepseek +package client import ( "bytes" "context" + dsprotocol "ds2api/internal/deepseek/protocol" "encoding/json" "errors" "fmt" @@ -63,7 +64,7 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload "purpose": purpose, "bytes": len(req.Data), } - captureSession := c.capture.Start("deepseek_upload_file", DeepSeekUploadFileURL, a.AccountID, capturePayload) + captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload) attempts := 0 refreshed := false powHeader := "" @@ -72,7 +73,7 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload for attempts < maxAttempts { clients := c.requestClientsForAuth(ctx, a) if strings.TrimSpace(powHeader) == "" { - powHeader, err = c.GetPowForTarget(ctx, a, DeepSeekUploadTargetPath, maxAttempts) + powHeader, err = c.GetPowForTarget(ctx, a, dsprotocol.DeepSeekUploadTargetPath, maxAttempts) if err != nil { return nil, err } @@ -83,7 +84,7 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload headers["x-ds-pow-response"] = powHeader headers["x-file-size"] = strconv.Itoa(len(req.Data)) headers["x-thinking-enabled"] = "1" - resp, err := c.doUpload(ctx, clients.regular, clients.fallback, DeepSeekUploadFileURL, headers, body) + resp, err := c.doUpload(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekUploadFileURL, headers, body) if err != nil { config.Logger.Warn("[upload_file] request error", "error", err, "account", a.AccountID, "filename", filename) powHeader = "" diff --git a/internal/deepseek/client_upload_test.go b/internal/deepseek/client/client_upload_test.go similarity index 93% rename from internal/deepseek/client_upload_test.go rename to internal/deepseek/client/client_upload_test.go index 7a41073..90e11cd 100644 --- a/internal/deepseek/client_upload_test.go +++ b/internal/deepseek/client/client_upload_test.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "encoding/base64" "encoding/hex" "encoding/json" @@ -75,7 +76,7 @@ func TestExtractUploadFileResultSupportsNestedShapes(t *testing.T) { func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) { challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42")) - powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + DeepSeekUploadTargetPath + `"}}}}` + powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + dsprotocol.DeepSeekUploadTargetPath + `"}}}}` uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}` var seenPow string var seenTargetPath string @@ -119,7 +120,7 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) { if result.ID != "file_789" { t.Fatalf("expected uploaded file id file_789, got %#v", result) } - if !strings.Contains(seenTargetPath, `"target_path":"`+DeepSeekUploadTargetPath+`"`) { + if !strings.Contains(seenTargetPath, `"target_path":"`+dsprotocol.DeepSeekUploadTargetPath+`"`) { t.Fatalf("expected upload target_path in pow request, got %q", seenTargetPath) } if strings.TrimSpace(seenPow) == "" { @@ -133,8 +134,8 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) { if err := json.Unmarshal(rawPow, &powHeader); err != nil { t.Fatalf("unmarshal pow header failed: %v", err) } - if powHeader["target_path"] != DeepSeekUploadTargetPath { - t.Fatalf("expected pow target_path %q, got %#v", DeepSeekUploadTargetPath, powHeader["target_path"]) + if powHeader["target_path"] != dsprotocol.DeepSeekUploadTargetPath { + t.Fatalf("expected pow target_path %q, got %#v", dsprotocol.DeepSeekUploadTargetPath, powHeader["target_path"]) } if seenFileSize != "5" { t.Fatalf("expected x-file-size=5, got %q", seenFileSize) @@ -153,7 +154,7 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) { defer func() { fileReadySleep = oldSleep }() challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42")) - powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + DeepSeekUploadTargetPath + `"}}}}` + powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + dsprotocol.DeepSeekUploadTargetPath + `"}}}}` uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}}}}` pendingFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}]}}}` processedFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":true}]}}}` @@ -165,7 +166,7 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) { switch call { case 1: bodyBytes, _ := io.ReadAll(req.Body) - if !strings.Contains(string(bodyBytes), `"target_path":"`+DeepSeekUploadTargetPath+`"`) { + if !strings.Contains(string(bodyBytes), `"target_path":"`+dsprotocol.DeepSeekUploadTargetPath+`"`) { t.Fatalf("expected pow target path request, got %s", string(bodyBytes)) } return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powResponse)), Request: req}, nil diff --git a/internal/deepseek/deepseek_edge_test.go b/internal/deepseek/client/deepseek_edge_test.go similarity index 99% rename from internal/deepseek/deepseek_edge_test.go rename to internal/deepseek/client/deepseek_edge_test.go index e321954..fb0b413 100644 --- a/internal/deepseek/deepseek_edge_test.go +++ b/internal/deepseek/client/deepseek_edge_test.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "context" diff --git a/internal/deepseek/errors.go b/internal/deepseek/client/errors.go similarity index 98% rename from internal/deepseek/errors.go rename to internal/deepseek/client/errors.go index dd8dc08..0c2c18a 100644 --- a/internal/deepseek/errors.go +++ b/internal/deepseek/client/errors.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "errors" diff --git a/internal/deepseek/pow.go b/internal/deepseek/client/pow.go similarity index 99% rename from internal/deepseek/pow.go rename to internal/deepseek/client/pow.go index 9d839de..6a58fe1 100644 --- a/internal/deepseek/pow.go +++ b/internal/deepseek/client/pow.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "context" diff --git a/internal/deepseek/pow_test.go b/internal/deepseek/client/pow_test.go similarity index 96% rename from internal/deepseek/pow_test.go rename to internal/deepseek/client/pow_test.go index 0161f62..5367e0a 100644 --- a/internal/deepseek/pow_test.go +++ b/internal/deepseek/client/pow_test.go @@ -1,4 +1,4 @@ -package deepseek +package client import ( "context" diff --git a/internal/deepseek/proxy.go b/internal/deepseek/client/proxy.go similarity index 98% rename from internal/deepseek/proxy.go rename to internal/deepseek/client/proxy.go index 84bf439..f09cf9f 100644 --- a/internal/deepseek/proxy.go +++ b/internal/deepseek/client/proxy.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "fmt" "net" "net/http" @@ -172,7 +173,7 @@ func applyProxyConnectivityHeaders(req *http.Request) { if req == nil { return } - for key, value := range BaseHeaders { + for key, value := range dsprotocol.BaseHeaders { key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" || value == "" { diff --git a/internal/deepseek/proxy_test.go b/internal/deepseek/client/proxy_test.go similarity index 95% rename from internal/deepseek/proxy_test.go rename to internal/deepseek/client/proxy_test.go index 102adee..cbb931d 100644 --- a/internal/deepseek/proxy_test.go +++ b/internal/deepseek/client/proxy_test.go @@ -1,7 +1,8 @@ -package deepseek +package client import ( "context" + dsprotocol "ds2api/internal/deepseek/protocol" "net/http" "strings" "testing" @@ -52,7 +53,7 @@ func TestApplyProxyConnectivityHeadersUsesBaseHeaders(t *testing.T) { applyProxyConnectivityHeaders(req) - for key, want := range BaseHeaders { + for key, want := range dsprotocol.BaseHeaders { if got := req.Header.Get(key); got != want { t.Fatalf("expected header %q=%q, got %q", key, want, got) } diff --git a/internal/deepseek/prompt.go b/internal/deepseek/prompt.go deleted file mode 100644 index 77fd36f..0000000 --- a/internal/deepseek/prompt.go +++ /dev/null @@ -1,11 +0,0 @@ -package deepseek - -import "ds2api/internal/prompt" - -func MessagesPrepare(messages []map[string]any) string { - return prompt.MessagesPrepare(messages) -} - -func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string { - return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled) -} diff --git a/internal/deepseek/constants.go b/internal/deepseek/protocol/constants.go similarity index 99% rename from internal/deepseek/constants.go rename to internal/deepseek/protocol/constants.go index 577725f..79e218e 100644 --- a/internal/deepseek/constants.go +++ b/internal/deepseek/protocol/constants.go @@ -1,4 +1,4 @@ -package deepseek +package protocol import ( _ "embed" diff --git a/internal/deepseek/constants_shared.json b/internal/deepseek/protocol/constants_shared.json similarity index 100% rename from internal/deepseek/constants_shared.json rename to internal/deepseek/protocol/constants_shared.json diff --git a/internal/deepseek/constants_test.go b/internal/deepseek/protocol/constants_test.go similarity index 96% rename from internal/deepseek/constants_test.go rename to internal/deepseek/protocol/constants_test.go index 03c6788..b64e579 100644 --- a/internal/deepseek/constants_test.go +++ b/internal/deepseek/protocol/constants_test.go @@ -1,4 +1,4 @@ -package deepseek +package protocol import "testing" diff --git a/internal/deepseek/protocol/sse.go b/internal/deepseek/protocol/sse.go new file mode 100644 index 0000000..c11b72b --- /dev/null +++ b/internal/deepseek/protocol/sse.go @@ -0,0 +1,21 @@ +package protocol + +import ( + "bufio" + "net/http" +) + +func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error { + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 2*1024*1024) + for scanner.Scan() { + if !onLine(scanner.Bytes()) { + break + } + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} diff --git a/internal/httpapi/admin/accounts/deps.go b/internal/httpapi/admin/accounts/deps.go new file mode 100644 index 0000000..568487c --- /dev/null +++ b/internal/httpapi/admin/accounts/deps.go @@ -0,0 +1,46 @@ +package accounts + +import ( + "net/http" + + "ds2api/internal/chathistory" + "ds2api/internal/config" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON + +func reverseAccounts(a []config.Account) { adminshared.ReverseAccounts(a) } +func intFromQuery(r *http.Request, key string, d int) int { + return adminshared.IntFromQuery(r, key, d) +} +func maskSecretPreview(secret string) string { + return adminshared.MaskSecretPreview(secret) +} +func toAccount(m map[string]any) config.Account { + return adminshared.ToAccount(m) +} +func fieldStringOptional(m map[string]any, key string) (string, bool) { + return adminshared.FieldStringOptional(m, key) +} +func accountMatchesIdentifier(acc config.Account, identifier string) bool { + return adminshared.AccountMatchesIdentifier(acc, identifier) +} +func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) { + return adminshared.FindProxyByID(c, proxyID) +} +func findAccountByIdentifier(store adminshared.ConfigStore, identifier string) (config.Account, bool) { + return adminshared.FindAccountByIdentifier(store, identifier) +} +func newRequestError(detail string) error { return adminshared.NewRequestError(detail) } +func requestErrorDetail(err error) (string, bool) { + return adminshared.RequestErrorDetail(err) +} diff --git a/internal/admin/handler_accounts_crud.go b/internal/httpapi/admin/accounts/handler_accounts_crud.go similarity index 99% rename from internal/admin/handler_accounts_crud.go rename to internal/httpapi/admin/accounts/handler_accounts_crud.go index 8ab25ee..7375b40 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/httpapi/admin/accounts/handler_accounts_crud.go @@ -1,4 +1,4 @@ -package admin +package accounts import ( "encoding/json" diff --git a/internal/admin/handler_accounts_crud_test.go b/internal/httpapi/admin/accounts/handler_accounts_crud_test.go similarity index 99% rename from internal/admin/handler_accounts_crud_test.go rename to internal/httpapi/admin/accounts/handler_accounts_crud_test.go index 7b838c6..be2b0ba 100644 --- a/internal/admin/handler_accounts_crud_test.go +++ b/internal/httpapi/admin/accounts/handler_accounts_crud_test.go @@ -1,4 +1,4 @@ -package admin +package accounts import ( "encoding/json" diff --git a/internal/admin/handler_accounts_identifier_test.go b/internal/httpapi/admin/accounts/handler_accounts_identifier_test.go similarity index 99% rename from internal/admin/handler_accounts_identifier_test.go rename to internal/httpapi/admin/accounts/handler_accounts_identifier_test.go index 6dd6efe..5edaf27 100644 --- a/internal/admin/handler_accounts_identifier_test.go +++ b/internal/httpapi/admin/accounts/handler_accounts_identifier_test.go @@ -1,4 +1,4 @@ -package admin +package accounts import ( "bytes" diff --git a/internal/admin/handler_accounts_queue.go b/internal/httpapi/admin/accounts/handler_accounts_queue.go similarity index 89% rename from internal/admin/handler_accounts_queue.go rename to internal/httpapi/admin/accounts/handler_accounts_queue.go index 108f802..48b68e8 100644 --- a/internal/admin/handler_accounts_queue.go +++ b/internal/httpapi/admin/accounts/handler_accounts_queue.go @@ -1,4 +1,4 @@ -package admin +package accounts import "net/http" diff --git a/internal/admin/handler_accounts_testing.go b/internal/httpapi/admin/accounts/handler_accounts_testing.go similarity index 97% rename from internal/admin/handler_accounts_testing.go rename to internal/httpapi/admin/accounts/handler_accounts_testing.go index 1658bef..3b41c60 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/httpapi/admin/accounts/handler_accounts_testing.go @@ -1,4 +1,4 @@ -package admin +package accounts import ( "bytes" @@ -13,9 +13,9 @@ import ( authn "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + "ds2api/internal/prompt" + "ds2api/internal/promptcompat" "ds2api/internal/sse" - "ds2api/internal/util" ) type modelAliasSnapshotReader struct { @@ -174,9 +174,9 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me result["message"] = "获取 PoW 失败: " + err.Error() return result } - payload := util.StandardRequest{ + payload := promptcompat.StandardRequest{ ResolvedModel: model, - FinalPrompt: deepseek.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}), + FinalPrompt: prompt.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}), Thinking: thinking, Search: search, }.CompletionPayload(sessionID) diff --git a/internal/admin/handler_accounts_testing_test.go b/internal/httpapi/admin/accounts/handler_accounts_testing_test.go similarity index 96% rename from internal/admin/handler_accounts_testing_test.go rename to internal/httpapi/admin/accounts/handler_accounts_testing_test.go index 9c4e5ba..d8f6ece 100644 --- a/internal/admin/handler_accounts_testing_test.go +++ b/internal/httpapi/admin/accounts/handler_accounts_testing_test.go @@ -1,4 +1,4 @@ -package admin +package accounts import ( "bytes" @@ -13,7 +13,7 @@ import ( "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type testingDSMock struct { @@ -58,8 +58,8 @@ func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) e return nil } -func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) { - return &deepseek.SessionStats{Success: true}, nil +func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) { + return &dsclient.SessionStats{Success: true}, nil } func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) { @@ -163,8 +163,8 @@ func (m *completionPayloadDSMock) DeleteAllSessionsForToken(_ context.Context, _ return nil } -func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) { - return &deepseek.SessionStats{Success: true}, nil +func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) { + return &dsclient.SessionStats{Success: true}, nil } func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T) { diff --git a/internal/httpapi/admin/accounts/routes.go b/internal/httpapi/admin/accounts/routes.go new file mode 100644 index 0000000..13491c1 --- /dev/null +++ b/internal/httpapi/admin/accounts/routes.go @@ -0,0 +1,38 @@ +package accounts + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/config" +) + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/accounts", h.listAccounts) + r.Post("/accounts", h.addAccount) + r.Put("/accounts/{identifier}", h.updateAccount) + r.Delete("/accounts/{identifier}", h.deleteAccount) + r.Get("/queue/status", h.queueStatus) + r.Post("/accounts/test", h.testSingleAccount) + r.Post("/accounts/test-all", h.testAllAccounts) + r.Post("/accounts/sessions/delete-all", h.deleteAllSessions) + r.Post("/test", h.testAPI) +} + +func RunAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any { + return runAccountTestsConcurrently(accounts, maxConcurrency, testFn) +} + +func (h *Handler) TestAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { + return h.testAccount(ctx, acc, model, message) +} + +func (h *Handler) ListAccounts(w http.ResponseWriter, r *http.Request) { h.listAccounts(w, r) } +func (h *Handler) AddAccount(w http.ResponseWriter, r *http.Request) { h.addAccount(w, r) } +func (h *Handler) UpdateAccount(w http.ResponseWriter, r *http.Request) { h.updateAccount(w, r) } +func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) { h.deleteAccount(w, r) } +func (h *Handler) DeleteAllSessions(w http.ResponseWriter, r *http.Request) { + h.deleteAllSessions(w, r) +} diff --git a/internal/httpapi/admin/accounts/test_http_helpers_test.go b/internal/httpapi/admin/accounts/test_http_helpers_test.go new file mode 100644 index 0000000..4a4f736 --- /dev/null +++ b/internal/httpapi/admin/accounts/test_http_helpers_test.go @@ -0,0 +1,35 @@ +package accounts + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/account" + "ds2api/internal/config" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", rawConfig) + store := config.LoadStore() + h := &Handler{ + Store: store, + Pool: account.NewPool(store), + DS: ds, + } + r := chi.NewRouter() + RegisterRoutes(r, h) + return r +} + +func adminReq(method, path string, body []byte) *http.Request { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer admin") + req.Header.Set("Content-Type", "application/json") + return req +} diff --git a/internal/httpapi/admin/auth/deps.go b/internal/httpapi/admin/auth/deps.go new file mode 100644 index 0000000..72063f6 --- /dev/null +++ b/internal/httpapi/admin/auth/deps.go @@ -0,0 +1,19 @@ +package auth + +import ( + "ds2api/internal/chathistory" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON +var intFrom = adminshared.IntFrom + +func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) } diff --git a/internal/admin/handler_auth.go b/internal/httpapi/admin/auth/handler_auth.go similarity index 99% rename from internal/admin/handler_auth.go rename to internal/httpapi/admin/auth/handler_auth.go index 9b96b2f..18ef6fa 100644 --- a/internal/admin/handler_auth.go +++ b/internal/httpapi/admin/auth/handler_auth.go @@ -1,4 +1,4 @@ -package admin +package auth import ( "encoding/json" diff --git a/internal/httpapi/admin/auth/routes.go b/internal/httpapi/admin/auth/routes.go new file mode 100644 index 0000000..91ec102 --- /dev/null +++ b/internal/httpapi/admin/auth/routes.go @@ -0,0 +1,20 @@ +package auth + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (h *Handler) RequireAdmin(next http.Handler) http.Handler { + return h.requireAdmin(next) +} + +func RegisterPublicRoutes(r chi.Router, h *Handler) { + r.Post("/login", h.login) + r.Get("/verify", h.verify) +} + +func RegisterProtectedRoutes(r chi.Router, h *Handler) { + r.Get("/vercel/config", h.getVercelConfig) +} diff --git a/internal/httpapi/admin/configmgmt/deps.go b/internal/httpapi/admin/configmgmt/deps.go new file mode 100644 index 0000000..8b9a1cc --- /dev/null +++ b/internal/httpapi/admin/configmgmt/deps.go @@ -0,0 +1,50 @@ +package configmgmt + +import ( + "ds2api/internal/chathistory" + "ds2api/internal/config" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON + +func maskSecretPreview(secret string) string { + return adminshared.MaskSecretPreview(secret) +} +func toStringSlice(v any) ([]string, bool) { return adminshared.ToStringSlice(v) } +func toAccount(m map[string]any) config.Account { + return adminshared.ToAccount(m) +} +func toAPIKeys(v any) ([]config.APIKey, bool) { return adminshared.ToAPIKeys(v) } +func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) { + return adminshared.MergeAPIKeysPreferStructured(existing, incoming) +} +func fieldString(m map[string]any, key string) string { + return adminshared.FieldString(m, key) +} +func fieldStringOptional(m map[string]any, key string) (string, bool) { + return adminshared.FieldStringOptional(m, key) +} +func normalizeAccountForStorage(acc config.Account) config.Account { + return adminshared.NormalizeAccountForStorage(acc) +} +func accountDedupeKey(acc config.Account) string { return adminshared.AccountDedupeKey(acc) } +func normalizeAndDedupeAccounts(accounts []config.Account) []config.Account { + return adminshared.NormalizeAndDedupeAccounts(accounts) +} +func newRequestError(detail string) error { return adminshared.NewRequestError(detail) } +func requestErrorDetail(err error) (string, bool) { + return adminshared.RequestErrorDetail(err) +} +func normalizeSettingsConfig(c *config.Config) { adminshared.NormalizeSettingsConfig(c) } +func validateSettingsConfig(c config.Config) error { + return adminshared.ValidateSettingsConfig(c) +} diff --git a/internal/admin/handler_config_import.go b/internal/httpapi/admin/configmgmt/handler_config_import.go similarity index 93% rename from internal/admin/handler_config_import.go rename to internal/httpapi/admin/configmgmt/handler_config_import.go index fe5faff..cd1d860 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/httpapi/admin/configmgmt/handler_config_import.go @@ -1,9 +1,7 @@ -package admin +package configmgmt import ( - "crypto/md5" "encoding/json" - "fmt" "net/http" "strings" @@ -145,13 +143,3 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { "message": "config imported", }) } - -func (h *Handler) computeSyncHash() string { - snap := h.Store.Snapshot().Clone() - snap.ClearAccountTokens() - snap.VercelSyncHash = "" - snap.VercelSyncTime = 0 - b, _ := json.Marshal(snap) - sum := md5.Sum(b) - return fmt.Sprintf("%x", sum) -} diff --git a/internal/admin/handler_config_read.go b/internal/httpapi/admin/configmgmt/handler_config_read.go similarity index 99% rename from internal/admin/handler_config_read.go rename to internal/httpapi/admin/configmgmt/handler_config_read.go index 9fc876f..e039bd1 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/httpapi/admin/configmgmt/handler_config_read.go @@ -1,4 +1,4 @@ -package admin +package configmgmt import ( "net/http" diff --git a/internal/admin/handler_config_write.go b/internal/httpapi/admin/configmgmt/handler_config_write.go similarity index 99% rename from internal/admin/handler_config_write.go rename to internal/httpapi/admin/configmgmt/handler_config_write.go index 7f6afb8..8b1aa88 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/httpapi/admin/configmgmt/handler_config_write.go @@ -1,4 +1,4 @@ -package admin +package configmgmt import ( "encoding/json" diff --git a/internal/admin/handler_keys_test.go b/internal/httpapi/admin/configmgmt/handler_keys_test.go similarity index 99% rename from internal/admin/handler_keys_test.go rename to internal/httpapi/admin/configmgmt/handler_keys_test.go index 82ff5e2..9c2c80c 100644 --- a/internal/admin/handler_keys_test.go +++ b/internal/httpapi/admin/configmgmt/handler_keys_test.go @@ -1,4 +1,4 @@ -package admin +package configmgmt import ( "bytes" diff --git a/internal/httpapi/admin/configmgmt/routes.go b/internal/httpapi/admin/configmgmt/routes.go new file mode 100644 index 0000000..a3ece47 --- /dev/null +++ b/internal/httpapi/admin/configmgmt/routes.go @@ -0,0 +1,27 @@ +package configmgmt + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/config", h.getConfig) + r.Post("/config", h.updateConfig) + r.Post("/config/import", h.configImport) + r.Get("/config/export", h.configExport) + r.Get("/export", h.exportConfig) + r.Post("/keys", h.addKey) + r.Put("/keys/{key}", h.updateKey) + r.Delete("/keys/{key}", h.deleteKey) + r.Post("/import", h.batchImport) +} + +func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { h.getConfig(w, r) } +func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) { h.updateConfig(w, r) } +func (h *Handler) ConfigImport(w http.ResponseWriter, r *http.Request) { h.configImport(w, r) } +func (h *Handler) BatchImport(w http.ResponseWriter, r *http.Request) { h.batchImport(w, r) } +func (h *Handler) AddKey(w http.ResponseWriter, r *http.Request) { h.addKey(w, r) } +func (h *Handler) UpdateKey(w http.ResponseWriter, r *http.Request) { h.updateKey(w, r) } +func (h *Handler) DeleteKey(w http.ResponseWriter, r *http.Request) { h.deleteKey(w, r) } diff --git a/internal/httpapi/admin/configmgmt/test_helpers_test.go b/internal/httpapi/admin/configmgmt/test_helpers_test.go new file mode 100644 index 0000000..1d2f96c --- /dev/null +++ b/internal/httpapi/admin/configmgmt/test_helpers_test.go @@ -0,0 +1,18 @@ +package configmgmt + +import ( + "testing" + + "ds2api/internal/account" + "ds2api/internal/config" +) + +func newAdminTestHandler(t *testing.T, raw string) *Handler { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", raw) + store := config.LoadStore() + return &Handler{ + Store: store, + Pool: account.NewPool(store), + } +} diff --git a/internal/httpapi/admin/devcapture/deps.go b/internal/httpapi/admin/devcapture/deps.go new file mode 100644 index 0000000..5eaa7cd --- /dev/null +++ b/internal/httpapi/admin/devcapture/deps.go @@ -0,0 +1,16 @@ +package devcapture + +import ( + "ds2api/internal/chathistory" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON diff --git a/internal/admin/handler_dev_capture.go b/internal/httpapi/admin/devcapture/handler_dev_capture.go similarity index 96% rename from internal/admin/handler_dev_capture.go rename to internal/httpapi/admin/devcapture/handler_dev_capture.go index 9b3615c..b1f4ced 100644 --- a/internal/admin/handler_dev_capture.go +++ b/internal/httpapi/admin/devcapture/handler_dev_capture.go @@ -1,4 +1,4 @@ -package admin +package devcapture import ( "net/http" diff --git a/internal/admin/handler_dev_capture_test.go b/internal/httpapi/admin/devcapture/handler_dev_capture_test.go similarity index 98% rename from internal/admin/handler_dev_capture_test.go rename to internal/httpapi/admin/devcapture/handler_dev_capture_test.go index 90ced8b..a588cca 100644 --- a/internal/admin/handler_dev_capture_test.go +++ b/internal/httpapi/admin/devcapture/handler_dev_capture_test.go @@ -1,4 +1,4 @@ -package admin +package devcapture import ( "encoding/json" diff --git a/internal/httpapi/admin/devcapture/routes.go b/internal/httpapi/admin/devcapture/routes.go new file mode 100644 index 0000000..34e826a --- /dev/null +++ b/internal/httpapi/admin/devcapture/routes.go @@ -0,0 +1,8 @@ +package devcapture + +import "github.com/go-chi/chi/v5" + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/dev/captures", h.getDevCaptures) + r.Delete("/dev/captures", h.clearDevCaptures) +} diff --git a/internal/httpapi/admin/handler.go b/internal/httpapi/admin/handler.go new file mode 100644 index 0000000..a524593 --- /dev/null +++ b/internal/httpapi/admin/handler.go @@ -0,0 +1,70 @@ +package admin + +import ( + "github.com/go-chi/chi/v5" + + "ds2api/internal/chathistory" + adminaccounts "ds2api/internal/httpapi/admin/accounts" + adminauth "ds2api/internal/httpapi/admin/auth" + adminconfig "ds2api/internal/httpapi/admin/configmgmt" + admindevcapture "ds2api/internal/httpapi/admin/devcapture" + adminhistory "ds2api/internal/httpapi/admin/history" + adminproxies "ds2api/internal/httpapi/admin/proxies" + adminrawsamples "ds2api/internal/httpapi/admin/rawsamples" + adminsettings "ds2api/internal/httpapi/admin/settings" + adminshared "ds2api/internal/httpapi/admin/shared" + adminvercel "ds2api/internal/httpapi/admin/vercel" + adminversion "ds2api/internal/httpapi/admin/version" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +func RegisterRoutes(r chi.Router, h *Handler) { + deps := adminsharedDeps(h) + authHandler := &adminauth.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + accountsHandler := &adminaccounts.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + configHandler := &adminconfig.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + settingsHandler := &adminsettings.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + proxiesHandler := &adminproxies.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + rawSamplesHandler := &adminrawsamples.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + vercelHandler := &adminvercel.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + historyHandler := &adminhistory.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + devCaptureHandler := &admindevcapture.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + versionHandler := &adminversion.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory} + + adminauth.RegisterPublicRoutes(r, authHandler) + r.Group(func(pr chi.Router) { + pr.Use(authHandler.RequireAdmin) + adminauth.RegisterProtectedRoutes(pr, authHandler) + adminconfig.RegisterRoutes(pr, configHandler) + adminsettings.RegisterRoutes(pr, settingsHandler) + adminproxies.RegisterRoutes(pr, proxiesHandler) + adminaccounts.RegisterRoutes(pr, accountsHandler) + adminrawsamples.RegisterRoutes(pr, rawSamplesHandler) + adminvercel.RegisterRoutes(pr, vercelHandler) + admindevcapture.RegisterRoutes(pr, devCaptureHandler) + adminhistory.RegisterRoutes(pr, historyHandler) + adminversion.RegisterRoutes(pr, versionHandler) + }) +} + +func adminsharedDeps(h *Handler) adminsharedDepsValue { + if h == nil { + return adminsharedDepsValue{} + } + return adminsharedDepsValue{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory} +} + +type adminsharedDepsValue struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} diff --git a/internal/admin/handler_settings_test.go b/internal/httpapi/admin/handler_settings_test.go similarity index 100% rename from internal/admin/handler_settings_test.go rename to internal/httpapi/admin/handler_settings_test.go diff --git a/internal/admin/handler_test.go b/internal/httpapi/admin/handler_test.go similarity index 100% rename from internal/admin/handler_test.go rename to internal/httpapi/admin/handler_test.go diff --git a/internal/httpapi/admin/history/deps.go b/internal/httpapi/admin/history/deps.go new file mode 100644 index 0000000..7552596 --- /dev/null +++ b/internal/httpapi/admin/history/deps.go @@ -0,0 +1,16 @@ +package history + +import ( + "ds2api/internal/chathistory" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON diff --git a/internal/admin/handler_chat_history.go b/internal/httpapi/admin/history/handler_chat_history.go similarity index 99% rename from internal/admin/handler_chat_history.go rename to internal/httpapi/admin/history/handler_chat_history.go index 2eb61e6..e05a9e3 100644 --- a/internal/admin/handler_chat_history.go +++ b/internal/httpapi/admin/history/handler_chat_history.go @@ -1,4 +1,4 @@ -package admin +package history import ( "encoding/json" diff --git a/internal/admin/handler_chat_history_test.go b/internal/httpapi/admin/history/handler_chat_history_test.go similarity index 99% rename from internal/admin/handler_chat_history_test.go rename to internal/httpapi/admin/history/handler_chat_history_test.go index ba8448c..1397bae 100644 --- a/internal/admin/handler_chat_history_test.go +++ b/internal/httpapi/admin/history/handler_chat_history_test.go @@ -1,4 +1,4 @@ -package admin +package history import ( "bytes" diff --git a/internal/httpapi/admin/history/routes.go b/internal/httpapi/admin/history/routes.go new file mode 100644 index 0000000..c6f1f43 --- /dev/null +++ b/internal/httpapi/admin/history/routes.go @@ -0,0 +1,11 @@ +package history + +import "github.com/go-chi/chi/v5" + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/chat-history", h.getChatHistory) + r.Get("/chat-history/{id}", h.getChatHistoryItem) + r.Delete("/chat-history", h.clearChatHistory) + r.Delete("/chat-history/{id}", h.deleteChatHistoryItem) + r.Put("/chat-history/settings", h.updateChatHistorySettings) +} diff --git a/internal/httpapi/admin/proxies/deps.go b/internal/httpapi/admin/proxies/deps.go new file mode 100644 index 0000000..f02a639 --- /dev/null +++ b/internal/httpapi/admin/proxies/deps.go @@ -0,0 +1,32 @@ +package proxies + +import ( + "ds2api/internal/chathistory" + "ds2api/internal/config" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON + +func fieldString(m map[string]any, key string) string { + return adminshared.FieldString(m, key) +} +func accountMatchesIdentifier(acc config.Account, identifier string) bool { + return adminshared.AccountMatchesIdentifier(acc, identifier) +} +func toProxy(m map[string]any) config.Proxy { return adminshared.ToProxy(m) } +func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) { + return adminshared.FindProxyByID(c, proxyID) +} +func newRequestError(detail string) error { return adminshared.NewRequestError(detail) } +func requestErrorDetail(err error) (string, bool) { + return adminshared.RequestErrorDetail(err) +} diff --git a/internal/admin/handler_proxies.go b/internal/httpapi/admin/proxies/handler_proxies.go similarity index 98% rename from internal/admin/handler_proxies.go rename to internal/httpapi/admin/proxies/handler_proxies.go index eeb653c..b87ce8f 100644 --- a/internal/admin/handler_proxies.go +++ b/internal/httpapi/admin/proxies/handler_proxies.go @@ -1,4 +1,4 @@ -package admin +package proxies import ( "context" @@ -10,11 +10,11 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) var proxyConnectivityTester = func(ctx context.Context, proxy config.Proxy) map[string]any { - return deepseek.TestProxyConnectivity(ctx, proxy) + return dsclient.TestProxyConnectivity(ctx, proxy) } func validateProxyMutation(cfg *config.Config) error { diff --git a/internal/admin/handler_proxies_test.go b/internal/httpapi/admin/proxies/handler_proxies_test.go similarity index 99% rename from internal/admin/handler_proxies_test.go rename to internal/httpapi/admin/proxies/handler_proxies_test.go index f1f6d33..2c6a81c 100644 --- a/internal/admin/handler_proxies_test.go +++ b/internal/httpapi/admin/proxies/handler_proxies_test.go @@ -1,4 +1,4 @@ -package admin +package proxies import ( "bytes" diff --git a/internal/httpapi/admin/proxies/routes.go b/internal/httpapi/admin/proxies/routes.go new file mode 100644 index 0000000..bf03701 --- /dev/null +++ b/internal/httpapi/admin/proxies/routes.go @@ -0,0 +1,24 @@ +package proxies + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/proxies", h.listProxies) + r.Post("/proxies", h.addProxy) + r.Put("/proxies/{proxyID}", h.updateProxy) + r.Delete("/proxies/{proxyID}", h.deleteProxy) + r.Post("/proxies/test", h.testProxy) + r.Put("/accounts/{identifier}/proxy", h.updateAccountProxy) +} + +func (h *Handler) AddProxy(w http.ResponseWriter, r *http.Request) { h.addProxy(w, r) } +func (h *Handler) UpdateProxy(w http.ResponseWriter, r *http.Request) { h.updateProxy(w, r) } +func (h *Handler) DeleteProxy(w http.ResponseWriter, r *http.Request) { h.deleteProxy(w, r) } +func (h *Handler) TestProxy(w http.ResponseWriter, r *http.Request) { h.testProxy(w, r) } +func (h *Handler) UpdateAccountProxy(w http.ResponseWriter, r *http.Request) { + h.updateAccountProxy(w, r) +} diff --git a/internal/httpapi/admin/proxies/test_http_helpers_test.go b/internal/httpapi/admin/proxies/test_http_helpers_test.go new file mode 100644 index 0000000..96c609e --- /dev/null +++ b/internal/httpapi/admin/proxies/test_http_helpers_test.go @@ -0,0 +1,57 @@ +package proxies + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/account" + "ds2api/internal/auth" + "ds2api/internal/config" + dsclient "ds2api/internal/deepseek/client" + adminconfig "ds2api/internal/httpapi/admin/configmgmt" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type testingDSMock struct{} + +func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) { + return "token", nil +} +func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "session-id", nil +} +func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} +func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} +func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error { return nil } +func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) { + return &dsclient.SessionStats{}, nil +} + +func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", rawConfig) + store := config.LoadStore() + pool := account.NewPool(store) + h := &Handler{Store: store, Pool: pool, DS: ds} + configHandler := &adminconfig.Handler{Store: store, Pool: pool, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + r.Get("/config", configHandler.GetConfig) + return r +} + +func adminReq(method, path string, body []byte) *http.Request { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer admin") + req.Header.Set("Content-Type", "application/json") + return req +} diff --git a/internal/httpapi/admin/rawsamples/deps.go b/internal/httpapi/admin/rawsamples/deps.go new file mode 100644 index 0000000..618d0d1 --- /dev/null +++ b/internal/httpapi/admin/rawsamples/deps.go @@ -0,0 +1,27 @@ +package rawsamples + +import ( + "net/http" + + "ds2api/internal/chathistory" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON + +func intFromQuery(r *http.Request, key string, d int) int { + return adminshared.IntFromQuery(r, key, d) +} +func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) } +func toStringSlice(v any) ([]string, bool) { return adminshared.ToStringSlice(v) } +func fieldString(m map[string]any, key string) string { + return adminshared.FieldString(m, key) +} diff --git a/internal/admin/handler_raw_samples.go b/internal/httpapi/admin/rawsamples/handler_raw_samples.go similarity index 98% rename from internal/admin/handler_raw_samples.go rename to internal/httpapi/admin/rawsamples/handler_raw_samples.go index d24eeda..a30e214 100644 --- a/internal/admin/handler_raw_samples.go +++ b/internal/httpapi/admin/rawsamples/handler_raw_samples.go @@ -1,4 +1,4 @@ -package admin +package rawsamples import ( "bytes" @@ -13,6 +13,7 @@ import ( "ds2api/internal/config" "ds2api/internal/devcapture" + adminshared "ds2api/internal/httpapi/admin/shared" "ds2api/internal/rawsample" ) @@ -93,7 +94,7 @@ func (h *Handler) captureRawSample(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(w, bytes.NewReader(rec.Body.Bytes())) } -func prepareRawSampleCaptureRequest(store ConfigStore, req map[string]any) (map[string]any, string, string, error) { +func prepareRawSampleCaptureRequest(store adminshared.ConfigStore, req map[string]any) (map[string]any, string, string, error) { payload := cloneMap(req) sampleID := strings.TrimSpace(fieldString(payload, "sample_id")) apiKey := strings.TrimSpace(fieldString(payload, "api_key")) diff --git a/internal/admin/handler_raw_samples_test.go b/internal/httpapi/admin/rawsamples/handler_raw_samples_test.go similarity index 99% rename from internal/admin/handler_raw_samples_test.go rename to internal/httpapi/admin/rawsamples/handler_raw_samples_test.go index ad49ad3..780c0ef 100644 --- a/internal/admin/handler_raw_samples_test.go +++ b/internal/httpapi/admin/rawsamples/handler_raw_samples_test.go @@ -1,4 +1,4 @@ -package admin +package rawsamples import ( "bytes" diff --git a/internal/httpapi/admin/rawsamples/routes.go b/internal/httpapi/admin/rawsamples/routes.go new file mode 100644 index 0000000..9eb2109 --- /dev/null +++ b/internal/httpapi/admin/rawsamples/routes.go @@ -0,0 +1,9 @@ +package rawsamples + +import "github.com/go-chi/chi/v5" + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Post("/dev/raw-samples/capture", h.captureRawSample) + r.Get("/dev/raw-samples/query", h.queryRawSampleCaptures) + r.Post("/dev/raw-samples/save", h.saveRawSampleFromCaptures) +} diff --git a/internal/httpapi/admin/settings/deps.go b/internal/httpapi/admin/settings/deps.go new file mode 100644 index 0000000..6df91f4 --- /dev/null +++ b/internal/httpapi/admin/settings/deps.go @@ -0,0 +1,29 @@ +package settings + +import ( + "ds2api/internal/chathistory" + "ds2api/internal/config" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON +var intFrom = adminshared.IntFrom + +func fieldString(m map[string]any, key string) string { + return adminshared.FieldString(m, key) +} +func validateRuntimeSettings(runtime config.RuntimeConfig) error { + return adminshared.ValidateRuntimeSettings(runtime) +} + +func (h *Handler) computeSyncHash() string { + return adminshared.ComputeSyncHash(h.Store) +} diff --git a/internal/admin/handler_settings_parse.go b/internal/httpapi/admin/settings/handler_settings_parse.go similarity index 99% rename from internal/admin/handler_settings_parse.go rename to internal/httpapi/admin/settings/handler_settings_parse.go index 0cc297e..53507f3 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/httpapi/admin/settings/handler_settings_parse.go @@ -1,4 +1,4 @@ -package admin +package settings import ( "fmt" diff --git a/internal/admin/handler_settings_read.go b/internal/httpapi/admin/settings/handler_settings_read.go similarity index 98% rename from internal/admin/handler_settings_read.go rename to internal/httpapi/admin/settings/handler_settings_read.go index 2f9e7d2..7587004 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/httpapi/admin/settings/handler_settings_read.go @@ -1,4 +1,4 @@ -package admin +package settings import ( "net/http" diff --git a/internal/admin/handler_settings_runtime.go b/internal/httpapi/admin/settings/handler_settings_runtime.go similarity index 98% rename from internal/admin/handler_settings_runtime.go rename to internal/httpapi/admin/settings/handler_settings_runtime.go index a713c08..eee3c6e 100644 --- a/internal/admin/handler_settings_runtime.go +++ b/internal/httpapi/admin/settings/handler_settings_runtime.go @@ -1,4 +1,4 @@ -package admin +package settings import "ds2api/internal/config" diff --git a/internal/admin/handler_settings_write.go b/internal/httpapi/admin/settings/handler_settings_write.go similarity index 99% rename from internal/admin/handler_settings_write.go rename to internal/httpapi/admin/settings/handler_settings_write.go index 2510d01..11ac6b4 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/httpapi/admin/settings/handler_settings_write.go @@ -1,4 +1,4 @@ -package admin +package settings import ( "encoding/json" diff --git a/internal/httpapi/admin/settings/routes.go b/internal/httpapi/admin/settings/routes.go new file mode 100644 index 0000000..0d44584 --- /dev/null +++ b/internal/httpapi/admin/settings/routes.go @@ -0,0 +1,20 @@ +package settings + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/settings", h.getSettings) + r.Put("/settings", h.updateSettings) + r.Post("/settings/password", h.updateSettingsPassword) +} + +func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) { h.getSettings(w, r) } +func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { h.updateSettings(w, r) } +func (h *Handler) UpdateSettingsPassword(w http.ResponseWriter, r *http.Request) { + h.updateSettingsPassword(w, r) +} +func BoolFrom(v any) bool { return boolFrom(v) } diff --git a/internal/admin/deps.go b/internal/httpapi/admin/shared/deps.go similarity index 90% rename from internal/admin/deps.go rename to internal/httpapi/admin/shared/deps.go index 436775c..9adc755 100644 --- a/internal/admin/deps.go +++ b/internal/httpapi/admin/shared/deps.go @@ -1,4 +1,4 @@ -package admin +package shared import ( "context" @@ -7,7 +7,7 @@ import ( "ds2api/internal/account" "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type ConfigStore interface { @@ -54,10 +54,10 @@ type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) - GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error) + GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error) DeleteAllSessionsForToken(ctx context.Context, token string) error } var _ ConfigStore = (*config.Store)(nil) var _ PoolController = (*account.Pool)(nil) -var _ DeepSeekCaller = (*deepseek.Client)(nil) +var _ DeepSeekCaller = (*dsclient.Client)(nil) diff --git a/internal/admin/helpers.go b/internal/httpapi/admin/shared/helpers.go similarity index 71% rename from internal/admin/helpers.go rename to internal/httpapi/admin/shared/helpers.go index c44dccf..93b6937 100644 --- a/internal/admin/helpers.go +++ b/internal/httpapi/admin/shared/helpers.go @@ -1,6 +1,8 @@ -package admin +package shared import ( + "crypto/md5" + "encoding/json" "fmt" "net/http" "strconv" @@ -10,10 +12,95 @@ import ( "ds2api/internal/util" ) -// writeJSON and intFrom are package-internal aliases for the shared util versions. -var writeJSON = util.WriteJSON var intFrom = util.IntFrom +var WriteJSON = util.WriteJSON +var IntFrom = util.IntFrom + +func ReverseAccounts(a []config.Account) { reverseAccounts(a) } +func IntFromQuery(r *http.Request, key string, d int) int { + return intFromQuery(r, key, d) +} +func NilIfEmpty(s string) any { return nilIfEmpty(s) } +func NilIfZero(v int64) any { return nilIfZero(v) } +func MaskSecretPreview(secret string) string { + return maskSecretPreview(secret) +} +func ToStringSlice(v any) ([]string, bool) { return toStringSlice(v) } +func ToAccount(m map[string]any) config.Account { + return toAccount(m) +} +func ToAPIKeys(v any) ([]config.APIKey, bool) { + return toAPIKeys(v) +} +func NormalizeAPIKeyForStorage(item config.APIKey) config.APIKey { + return normalizeAPIKeyForStorage(item) +} +func APIKeyHasMetadata(item config.APIKey) bool { + return apiKeyHasMetadata(item) +} +func MergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) { + return mergeAPIKeysPreferStructured(existing, incoming) +} +func MergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey { + return mergeAPIKeyRecord(existing, incoming) +} +func FieldString(m map[string]any, key string) string { + return fieldString(m, key) +} +func FieldStringOptional(m map[string]any, key string) (string, bool) { + return fieldStringOptional(m, key) +} +func StatusOr(v int, d int) int { return statusOr(v, d) } +func AccountMatchesIdentifier(acc config.Account, identifier string) bool { + return accountMatchesIdentifier(acc, identifier) +} +func NormalizeAccountForStorage(acc config.Account) config.Account { + return normalizeAccountForStorage(acc) +} +func ToProxy(m map[string]any) config.Proxy { + return toProxy(m) +} +func FindProxyByID(c config.Config, proxyID string) (config.Proxy, bool) { + return findProxyByID(c, proxyID) +} +func AccountDedupeKey(acc config.Account) string { return accountDedupeKey(acc) } +func NormalizeAndDedupeAccounts(accounts []config.Account) []config.Account { + return normalizeAndDedupeAccounts(accounts) +} +func FindAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) { + return findAccountByIdentifier(store, identifier) +} + +func ComputeSyncHash(store ConfigStore) string { + if store == nil { + return "" + } + snap := store.Snapshot().Clone() + snap.ClearAccountTokens() + snap.VercelSyncHash = "" + snap.VercelSyncTime = 0 + b, _ := json.Marshal(snap) + sum := md5.Sum(b) + return fmt.Sprintf("%x", sum) +} + +func SyncHashForJSON(s string) string { + var cfg config.Config + if err := json.Unmarshal([]byte(s), &cfg); err != nil { + return "" + } + cfg.VercelSyncHash = "" + cfg.VercelSyncTime = 0 + cfg.ClearAccountTokens() + b, err := json.Marshal(cfg) + if err != nil { + return "" + } + sum := md5.Sum(b) + return fmt.Sprintf("%x", sum) +} + func reverseAccounts(a []config.Account) { for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 { a[i], a[j] = a[j], a[i] diff --git a/internal/admin/helpers_edge_test.go b/internal/httpapi/admin/shared/helpers_edge_test.go similarity index 99% rename from internal/admin/helpers_edge_test.go rename to internal/httpapi/admin/shared/helpers_edge_test.go index 17bb3d7..5163005 100644 --- a/internal/admin/helpers_edge_test.go +++ b/internal/httpapi/admin/shared/helpers_edge_test.go @@ -1,4 +1,4 @@ -package admin +package shared import ( "net/http" diff --git a/internal/admin/request_error.go b/internal/httpapi/admin/shared/request_error.go similarity index 67% rename from internal/admin/request_error.go rename to internal/httpapi/admin/shared/request_error.go index 5431a3d..e17433e 100644 --- a/internal/admin/request_error.go +++ b/internal/httpapi/admin/shared/request_error.go @@ -1,4 +1,4 @@ -package admin +package shared import "errors" @@ -14,6 +14,10 @@ func newRequestError(detail string) error { return &requestError{detail: detail} } +func NewRequestError(detail string) error { + return newRequestError(detail) +} + func requestErrorDetail(err error) (string, bool) { var reqErr *requestError if errors.As(err, &reqErr) { @@ -21,3 +25,7 @@ func requestErrorDetail(err error) (string, bool) { } return "", false } + +func RequestErrorDetail(err error) (string, bool) { + return requestErrorDetail(err) +} diff --git a/internal/admin/settings_validation.go b/internal/httpapi/admin/shared/settings_validation.go similarity index 61% rename from internal/admin/settings_validation.go rename to internal/httpapi/admin/shared/settings_validation.go index c18f955..981e19e 100644 --- a/internal/admin/settings_validation.go +++ b/internal/httpapi/admin/shared/settings_validation.go @@ -1,4 +1,4 @@ -package admin +package shared import ( "strings" @@ -14,10 +14,22 @@ func normalizeSettingsConfig(c *config.Config) { c.Embeddings.Provider = strings.TrimSpace(c.Embeddings.Provider) } +func NormalizeSettingsConfig(c *config.Config) { + normalizeSettingsConfig(c) +} + func validateSettingsConfig(c config.Config) error { return config.ValidateConfig(c) } +func ValidateSettingsConfig(c config.Config) error { + return validateSettingsConfig(c) +} + func validateRuntimeSettings(runtime config.RuntimeConfig) error { return config.ValidateRuntimeConfig(runtime) } + +func ValidateRuntimeSettings(runtime config.RuntimeConfig) error { + return validateRuntimeSettings(runtime) +} diff --git a/internal/httpapi/admin/test_bridge_test.go b/internal/httpapi/admin/test_bridge_test.go new file mode 100644 index 0000000..5d523b1 --- /dev/null +++ b/internal/httpapi/admin/test_bridge_test.go @@ -0,0 +1,123 @@ +package admin + +import ( + "context" + "net/http" + "testing" + + "ds2api/internal/account" + "ds2api/internal/auth" + "ds2api/internal/config" + dsclient "ds2api/internal/deepseek/client" + adminaccounts "ds2api/internal/httpapi/admin/accounts" + adminconfig "ds2api/internal/httpapi/admin/configmgmt" + adminsettings "ds2api/internal/httpapi/admin/settings" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +var intFrom = adminshared.IntFrom + +func toAccount(m map[string]any) config.Account { return adminshared.ToAccount(m) } +func fieldString(m map[string]any, key string) string { + return adminshared.FieldString(m, key) +} +func maskSecretPreview(secret string) string { return adminshared.MaskSecretPreview(secret) } +func boolFrom(v any) bool { return adminsettings.BoolFrom(v) } + +func newAdminTestHandler(t *testing.T, raw string) *Handler { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", raw) + store := config.LoadStore() + return &Handler{ + Store: store, + Pool: account.NewPool(store), + } +} + +type testingDSMock struct { + loginToken string + deleteAllSessionsError error + deleteAllSessionsErrorOnce bool + sessionCount *dsclient.SessionStats + loginCalls int + deleteAllCalls int +} + +func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) { + m.loginCalls++ + if m.loginToken == "" { + return "token", nil + } + return m.loginToken, nil +} + +func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "session-id", nil +} + +func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} + +func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error { + m.deleteAllCalls++ + if m.deleteAllSessionsError != nil { + err := m.deleteAllSessionsError + if m.deleteAllSessionsErrorOnce { + m.deleteAllSessionsError = nil + } + return err + } + return nil +} + +func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) { + if m.sessionCount != nil { + return m.sessionCount, nil + } + return &dsclient.SessionStats{}, nil +} + +func (h *Handler) configHandler() *adminconfig.Handler { + return &adminconfig.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory} +} + +func (h *Handler) settingsHandler() *adminsettings.Handler { + return &adminsettings.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory} +} + +func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) { + h.configHandler().GetConfig(w, r) +} + +func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { + h.configHandler().UpdateConfig(w, r) +} + +func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { + h.configHandler().ConfigImport(w, r) +} + +func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) { + h.configHandler().BatchImport(w, r) +} + +func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) { + h.settingsHandler().GetSettings(w, r) +} + +func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { + h.settingsHandler().UpdateSettings(w, r) +} + +func (h *Handler) updateSettingsPassword(w http.ResponseWriter, r *http.Request) { + h.settingsHandler().UpdateSettingsPassword(w, r) +} + +func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any { + return adminaccounts.RunAccountTestsConcurrently(accounts, maxConcurrency, testFn) +} diff --git a/internal/admin/token_runtime_http_test.go b/internal/httpapi/admin/token_runtime_http_test.go similarity index 95% rename from internal/admin/token_runtime_http_test.go rename to internal/httpapi/admin/token_runtime_http_test.go index 3af3da0..0933fb7 100644 --- a/internal/admin/token_runtime_http_test.go +++ b/internal/httpapi/admin/token_runtime_http_test.go @@ -12,9 +12,10 @@ import ( "ds2api/internal/account" "ds2api/internal/config" + adminshared "ds2api/internal/httpapi/admin/shared" ) -func newHTTPAdminHarness(t *testing.T, rawConfig string, ds DeepSeekCaller) http.Handler { +func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler { t.Helper() t.Setenv("DS2API_CONFIG_JSON", rawConfig) store := config.LoadStore() diff --git a/internal/httpapi/admin/vercel/deps.go b/internal/httpapi/admin/vercel/deps.go new file mode 100644 index 0000000..c719edc --- /dev/null +++ b/internal/httpapi/admin/vercel/deps.go @@ -0,0 +1,24 @@ +package vercel + +import ( + "ds2api/internal/chathistory" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON +var intFrom = adminshared.IntFrom + +func nilIfZero(v int64) any { return adminshared.NilIfZero(v) } +func statusOr(v int, d int) int { return adminshared.StatusOr(v, d) } + +func (h *Handler) computeSyncHash() string { + return adminshared.ComputeSyncHash(h.Store) +} diff --git a/internal/admin/handler_vercel.go b/internal/httpapi/admin/vercel/handler_vercel.go similarity index 99% rename from internal/admin/handler_vercel.go rename to internal/httpapi/admin/vercel/handler_vercel.go index e0734ed..cfd13e1 100644 --- a/internal/admin/handler_vercel.go +++ b/internal/httpapi/admin/vercel/handler_vercel.go @@ -1,4 +1,4 @@ -package admin +package vercel import ( "bytes" diff --git a/internal/httpapi/admin/vercel/routes.go b/internal/httpapi/admin/vercel/routes.go new file mode 100644 index 0000000..dec4d1b --- /dev/null +++ b/internal/httpapi/admin/vercel/routes.go @@ -0,0 +1,9 @@ +package vercel + +import "github.com/go-chi/chi/v5" + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Post("/vercel/sync", h.syncVercel) + r.Get("/vercel/status", h.vercelStatus) + r.Post("/vercel/status", h.vercelStatus) +} diff --git a/internal/httpapi/admin/version/deps.go b/internal/httpapi/admin/version/deps.go new file mode 100644 index 0000000..cf181ca --- /dev/null +++ b/internal/httpapi/admin/version/deps.go @@ -0,0 +1,16 @@ +package version + +import ( + "ds2api/internal/chathistory" + adminshared "ds2api/internal/httpapi/admin/shared" +) + +type Handler struct { + Store adminshared.ConfigStore + Pool adminshared.PoolController + DS adminshared.DeepSeekCaller + OpenAI adminshared.OpenAIChatCaller + ChatHistory *chathistory.Store +} + +var writeJSON = adminshared.WriteJSON diff --git a/internal/admin/handler_version.go b/internal/httpapi/admin/version/handler_version.go similarity index 99% rename from internal/admin/handler_version.go rename to internal/httpapi/admin/version/handler_version.go index 2d2ef53..fb6271e 100644 --- a/internal/admin/handler_version.go +++ b/internal/httpapi/admin/version/handler_version.go @@ -1,4 +1,4 @@ -package admin +package version import ( "encoding/json" diff --git a/internal/httpapi/admin/version/routes.go b/internal/httpapi/admin/version/routes.go new file mode 100644 index 0000000..31368b0 --- /dev/null +++ b/internal/httpapi/admin/version/routes.go @@ -0,0 +1,7 @@ +package version + +import "github.com/go-chi/chi/v5" + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/version", h.getVersion) +} diff --git a/internal/adapter/claude/convert.go b/internal/httpapi/claude/convert.go similarity index 100% rename from internal/adapter/claude/convert.go rename to internal/httpapi/claude/convert.go diff --git a/internal/adapter/claude/deps.go b/internal/httpapi/claude/deps.go similarity index 90% rename from internal/adapter/claude/deps.go rename to internal/httpapi/claude/deps.go index 7f82ba8..f5c27f9 100644 --- a/internal/adapter/claude/deps.go +++ b/internal/httpapi/claude/deps.go @@ -6,7 +6,7 @@ import ( "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type AuthResolver interface { @@ -30,5 +30,5 @@ type OpenAIChatRunner interface { } var _ AuthResolver = (*auth.Resolver)(nil) -var _ DeepSeekCaller = (*deepseek.Client)(nil) +var _ DeepSeekCaller = (*dsclient.Client)(nil) var _ ConfigReader = (*config.Store)(nil) diff --git a/internal/adapter/claude/deps_injection_test.go b/internal/httpapi/claude/deps_injection_test.go similarity index 100% rename from internal/adapter/claude/deps_injection_test.go rename to internal/httpapi/claude/deps_injection_test.go diff --git a/internal/adapter/claude/error_shape_test.go b/internal/httpapi/claude/error_shape_test.go similarity index 100% rename from internal/adapter/claude/error_shape_test.go rename to internal/httpapi/claude/error_shape_test.go diff --git a/internal/adapter/claude/handler_errors.go b/internal/httpapi/claude/handler_errors.go similarity index 100% rename from internal/adapter/claude/handler_errors.go rename to internal/httpapi/claude/handler_errors.go diff --git a/internal/adapter/claude/handler_helpers_misc.go b/internal/httpapi/claude/handler_helpers_misc.go similarity index 100% rename from internal/adapter/claude/handler_helpers_misc.go rename to internal/httpapi/claude/handler_helpers_misc.go diff --git a/internal/adapter/claude/handler_messages.go b/internal/httpapi/claude/handler_messages.go similarity index 100% rename from internal/adapter/claude/handler_messages.go rename to internal/httpapi/claude/handler_messages.go diff --git a/internal/adapter/claude/handler_routes.go b/internal/httpapi/claude/handler_routes.go similarity index 78% rename from internal/adapter/claude/handler_routes.go rename to internal/httpapi/claude/handler_routes.go index 3683456..390b97d 100644 --- a/internal/adapter/claude/handler_routes.go +++ b/internal/httpapi/claude/handler_routes.go @@ -7,7 +7,7 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsprotocol "ds2api/internal/deepseek/protocol" "ds2api/internal/util" ) @@ -29,9 +29,9 @@ func (h *Handler) compatStripReferenceMarkers() bool { } var ( - claudeStreamPingInterval = time.Duration(deepseek.KeepAliveTimeout) * time.Second - claudeStreamIdleTimeout = time.Duration(deepseek.StreamIdleTimeout) * time.Second - claudeStreamMaxKeepaliveCnt = deepseek.MaxKeepaliveCount + claudeStreamPingInterval = time.Duration(dsprotocol.KeepAliveTimeout) * time.Second + claudeStreamIdleTimeout = time.Duration(dsprotocol.StreamIdleTimeout) * time.Second + claudeStreamMaxKeepaliveCnt = dsprotocol.MaxKeepaliveCount ) func RegisterRoutes(r chi.Router, h *Handler) { diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/httpapi/claude/handler_stream_test.go similarity index 100% rename from internal/adapter/claude/handler_stream_test.go rename to internal/httpapi/claude/handler_stream_test.go diff --git a/internal/adapter/claude/handler_tokens.go b/internal/httpapi/claude/handler_tokens.go similarity index 100% rename from internal/adapter/claude/handler_tokens.go rename to internal/httpapi/claude/handler_tokens.go diff --git a/internal/adapter/claude/handler_util_test.go b/internal/httpapi/claude/handler_util_test.go similarity index 100% rename from internal/adapter/claude/handler_util_test.go rename to internal/httpapi/claude/handler_util_test.go diff --git a/internal/adapter/claude/handler_utils.go b/internal/httpapi/claude/handler_utils.go similarity index 100% rename from internal/adapter/claude/handler_utils.go rename to internal/httpapi/claude/handler_utils.go diff --git a/internal/adapter/claude/handler_utils_sanitize.go b/internal/httpapi/claude/handler_utils_sanitize.go similarity index 100% rename from internal/adapter/claude/handler_utils_sanitize.go rename to internal/httpapi/claude/handler_utils_sanitize.go diff --git a/internal/adapter/claude/output_clean.go b/internal/httpapi/claude/output_clean.go similarity index 100% rename from internal/adapter/claude/output_clean.go rename to internal/httpapi/claude/output_clean.go diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/httpapi/claude/proxy_vercel_test.go similarity index 100% rename from internal/adapter/claude/proxy_vercel_test.go rename to internal/httpapi/claude/proxy_vercel_test.go diff --git a/internal/adapter/claude/route_alias_test.go b/internal/httpapi/claude/route_alias_test.go similarity index 100% rename from internal/adapter/claude/route_alias_test.go rename to internal/httpapi/claude/route_alias_test.go diff --git a/internal/adapter/claude/standard_request.go b/internal/httpapi/claude/standard_request.go similarity index 92% rename from internal/adapter/claude/standard_request.go rename to internal/httpapi/claude/standard_request.go index 0779602..26c6fda 100644 --- a/internal/adapter/claude/standard_request.go +++ b/internal/httpapi/claude/standard_request.go @@ -5,12 +5,13 @@ import ( "strings" "ds2api/internal/config" - "ds2api/internal/deepseek" + "ds2api/internal/prompt" + "ds2api/internal/promptcompat" "ds2api/internal/util" ) type claudeNormalizedRequest struct { - Standard util.StandardRequest + Standard promptcompat.StandardRequest NormalizedMessages []any } @@ -36,14 +37,14 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma searchEnabled = false } thinkingEnabled := util.ResolveThinkingEnabled(req, false) - finalPrompt := deepseek.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled) + finalPrompt := prompt.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled) toolNames := extractClaudeToolNames(toolsRequested) if len(toolNames) == 0 && len(toolsRequested) > 0 { toolNames = []string{"__any_tool__"} } return claudeNormalizedRequest{ - Standard: util.StandardRequest{ + Standard: promptcompat.StandardRequest{ Surface: "anthropic_messages", RequestedModel: strings.TrimSpace(model), ResolvedModel: dsModel, diff --git a/internal/adapter/claude/standard_request_test.go b/internal/httpapi/claude/standard_request_test.go similarity index 100% rename from internal/adapter/claude/standard_request_test.go rename to internal/httpapi/claude/standard_request_test.go diff --git a/internal/adapter/claude/stream_runtime_core.go b/internal/httpapi/claude/stream_runtime_core.go similarity index 100% rename from internal/adapter/claude/stream_runtime_core.go rename to internal/httpapi/claude/stream_runtime_core.go diff --git a/internal/adapter/claude/stream_runtime_emit.go b/internal/httpapi/claude/stream_runtime_emit.go similarity index 100% rename from internal/adapter/claude/stream_runtime_emit.go rename to internal/httpapi/claude/stream_runtime_emit.go diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/httpapi/claude/stream_runtime_finalize.go similarity index 100% rename from internal/adapter/claude/stream_runtime_finalize.go rename to internal/httpapi/claude/stream_runtime_finalize.go diff --git a/internal/adapter/claude/stream_status_test.go b/internal/httpapi/claude/stream_status_test.go similarity index 100% rename from internal/adapter/claude/stream_status_test.go rename to internal/httpapi/claude/stream_status_test.go diff --git a/internal/adapter/claude/tool_call_state.go b/internal/httpapi/claude/tool_call_state.go similarity index 100% rename from internal/adapter/claude/tool_call_state.go rename to internal/httpapi/claude/tool_call_state.go diff --git a/internal/adapter/gemini/convert_messages.go b/internal/httpapi/gemini/convert_messages.go similarity index 100% rename from internal/adapter/gemini/convert_messages.go rename to internal/httpapi/gemini/convert_messages.go diff --git a/internal/adapter/gemini/convert_messages_test.go b/internal/httpapi/gemini/convert_messages_test.go similarity index 100% rename from internal/adapter/gemini/convert_messages_test.go rename to internal/httpapi/gemini/convert_messages_test.go diff --git a/internal/adapter/gemini/convert_passthrough.go b/internal/httpapi/gemini/convert_passthrough.go similarity index 100% rename from internal/adapter/gemini/convert_passthrough.go rename to internal/httpapi/gemini/convert_passthrough.go diff --git a/internal/adapter/gemini/convert_request.go b/internal/httpapi/gemini/convert_request.go similarity index 66% rename from internal/adapter/gemini/convert_request.go rename to internal/httpapi/gemini/convert_request.go index 60fea3f..1d32105 100644 --- a/internal/adapter/gemini/convert_request.go +++ b/internal/httpapi/gemini/convert_request.go @@ -4,35 +4,35 @@ import ( "fmt" "strings" - "ds2api/internal/adapter/openai" "ds2api/internal/config" + "ds2api/internal/promptcompat" "ds2api/internal/util" ) //nolint:unused // kept for native Gemini adapter route compatibility. -func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[string]any, stream bool) (util.StandardRequest, error) { +func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[string]any, stream bool) (promptcompat.StandardRequest, error) { requestedModel := strings.TrimSpace(routeModel) if requestedModel == "" { - return util.StandardRequest{}, fmt.Errorf("model is required in request path") + return promptcompat.StandardRequest{}, fmt.Errorf("model is required in request path") } resolvedModel, ok := config.ResolveModel(store, requestedModel) if !ok { - return util.StandardRequest{}, fmt.Errorf("model %q is not available", requestedModel) + return promptcompat.StandardRequest{}, fmt.Errorf("model %q is not available", requestedModel) } defaultThinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel) thinkingEnabled := util.ResolveThinkingEnabled(req, defaultThinkingEnabled) messagesRaw := geminiMessagesFromRequest(req) if len(messagesRaw) == 0 { - return util.StandardRequest{}, fmt.Errorf("request must include non-empty contents") + return promptcompat.StandardRequest{}, fmt.Errorf("request must include non-empty contents") } toolsRaw := convertGeminiTools(req["tools"]) - finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled) + finalPrompt, toolNames := promptcompat.BuildOpenAIPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled) passThrough := collectGeminiPassThrough(req) - return util.StandardRequest{ + return promptcompat.StandardRequest{ Surface: "google_gemini", RequestedModel: requestedModel, ResolvedModel: resolvedModel, diff --git a/internal/adapter/gemini/convert_tools.go b/internal/httpapi/gemini/convert_tools.go similarity index 100% rename from internal/adapter/gemini/convert_tools.go rename to internal/httpapi/gemini/convert_tools.go diff --git a/internal/adapter/gemini/deps.go b/internal/httpapi/gemini/deps.go similarity index 90% rename from internal/adapter/gemini/deps.go rename to internal/httpapi/gemini/deps.go index 9a9e658..326d56c 100644 --- a/internal/adapter/gemini/deps.go +++ b/internal/httpapi/gemini/deps.go @@ -6,7 +6,7 @@ import ( "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type AuthResolver interface { @@ -30,5 +30,5 @@ type OpenAIChatRunner interface { } var _ AuthResolver = (*auth.Resolver)(nil) -var _ DeepSeekCaller = (*deepseek.Client)(nil) +var _ DeepSeekCaller = (*dsclient.Client)(nil) var _ ConfigReader = (*config.Store)(nil) diff --git a/internal/adapter/gemini/handler_errors.go b/internal/httpapi/gemini/handler_errors.go similarity index 100% rename from internal/adapter/gemini/handler_errors.go rename to internal/httpapi/gemini/handler_errors.go diff --git a/internal/adapter/gemini/handler_generate.go b/internal/httpapi/gemini/handler_generate.go similarity index 100% rename from internal/adapter/gemini/handler_generate.go rename to internal/httpapi/gemini/handler_generate.go diff --git a/internal/adapter/gemini/handler_routes.go b/internal/httpapi/gemini/handler_routes.go similarity index 100% rename from internal/adapter/gemini/handler_routes.go rename to internal/httpapi/gemini/handler_routes.go diff --git a/internal/adapter/gemini/handler_stream_runtime.go b/internal/httpapi/gemini/handler_stream_runtime.go similarity index 95% rename from internal/adapter/gemini/handler_stream_runtime.go rename to internal/httpapi/gemini/handler_stream_runtime.go index 5c7d1ee..13729fb 100644 --- a/internal/adapter/gemini/handler_stream_runtime.go +++ b/internal/httpapi/gemini/handler_stream_runtime.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "ds2api/internal/deepseek" + dsprotocol "ds2api/internal/deepseek/protocol" "ds2api/internal/sse" streamengine "ds2api/internal/stream" ) @@ -39,9 +39,9 @@ func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Req Body: resp.Body, ThinkingEnabled: thinkingEnabled, InitialType: initialType, - KeepAliveInterval: time.Duration(deepseek.KeepAliveTimeout) * time.Second, - IdleTimeout: time.Duration(deepseek.StreamIdleTimeout) * time.Second, - MaxKeepAliveNoInput: deepseek.MaxKeepaliveCount, + KeepAliveInterval: time.Duration(dsprotocol.KeepAliveTimeout) * time.Second, + IdleTimeout: time.Duration(dsprotocol.StreamIdleTimeout) * time.Second, + MaxKeepAliveNoInput: dsprotocol.MaxKeepaliveCount, }, streamengine.ConsumeHooks{ OnParsed: runtime.onParsed, OnFinalize: func(_ streamengine.StopReason, _ error) { diff --git a/internal/adapter/gemini/handler_test.go b/internal/httpapi/gemini/handler_test.go similarity index 100% rename from internal/adapter/gemini/handler_test.go rename to internal/httpapi/gemini/handler_test.go diff --git a/internal/adapter/gemini/output_clean.go b/internal/httpapi/gemini/output_clean.go similarity index 100% rename from internal/adapter/gemini/output_clean.go rename to internal/httpapi/gemini/output_clean.go diff --git a/internal/adapter/gemini/proxy_vercel_test.go b/internal/httpapi/gemini/proxy_vercel_test.go similarity index 100% rename from internal/adapter/gemini/proxy_vercel_test.go rename to internal/httpapi/gemini/proxy_vercel_test.go diff --git a/internal/adapter/openai/chat_history.go b/internal/httpapi/openai/chat/chat_history.go similarity index 98% rename from internal/adapter/openai/chat_history.go rename to internal/httpapi/openai/chat/chat_history.go index 41b4c54..fb274fc 100644 --- a/internal/adapter/openai/chat_history.go +++ b/internal/httpapi/openai/chat/chat_history.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "errors" @@ -11,7 +11,7 @@ import ( "ds2api/internal/config" openaifmt "ds2api/internal/format/openai" "ds2api/internal/prompt" - "ds2api/internal/util" + "ds2api/internal/promptcompat" ) const adminWebUISourceHeader = "X-Ds2-Source" @@ -27,7 +27,7 @@ type chatHistorySession struct { disabled bool } -func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.RequestAuth, stdReq util.StandardRequest) *chatHistorySession { +func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) *chatHistorySession { if store == nil || r == nil || a == nil { return nil } diff --git a/internal/adapter/openai/chat_history_test.go b/internal/httpapi/openai/chat/chat_history_test.go similarity index 99% rename from internal/adapter/openai/chat_history_test.go rename to internal/httpapi/openai/chat/chat_history_test.go index d3a60fc..66dfc59 100644 --- a/internal/adapter/openai/chat_history_test.go +++ b/internal/httpapi/openai/chat/chat_history_test.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "context" @@ -13,7 +13,7 @@ import ( "ds2api/internal/auth" "ds2api/internal/chathistory" - "ds2api/internal/util" + "ds2api/internal/promptcompat" ) func newTestChatHistoryStore(t *testing.T) *chathistory.Store { @@ -114,7 +114,7 @@ func TestStartChatHistoryRecoversFromTransientWriteFailure(t *testing.T) { CallerID: "caller:test", AccountID: "acct:test", } - stdReq := util.StandardRequest{ + stdReq := promptcompat.StandardRequest{ ResponseModel: "deepseek-v4-flash", Stream: true, Messages: []any{ diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/httpapi/openai/chat/chat_stream_runtime.go similarity index 97% rename from internal/adapter/openai/chat_stream_runtime.go rename to internal/httpapi/openai/chat/chat_stream_runtime.go index 25124c7..0f65fd0 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/httpapi/openai/chat/chat_stream_runtime.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "ds2api/internal/toolcall" @@ -9,6 +9,7 @@ import ( openaifmt "ds2api/internal/format/openai" "ds2api/internal/sse" streamengine "ds2api/internal/stream" + "ds2api/internal/toolstream" ) type chatStreamRuntime struct { @@ -32,7 +33,7 @@ type chatStreamRuntime struct { toolCallsEmitted bool toolCallsDoneEmitted bool - toolSieve toolStreamSieveState + toolSieve toolstream.State streamToolCallIDs map[int]string streamToolNames map[int]string thinking strings.Builder @@ -152,7 +153,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) { s.toolCallsEmitted = true s.toolCallsDoneEmitted = true } else if s.bufferToolContent { - for _, evt := range flushToolSieve(&s.toolSieve, s.toolNames) { + for _, evt := range toolstream.Flush(&s.toolSieve, s.toolNames) { if len(evt.ToolCalls) > 0 { finishReason = "tool_calls" s.toolCallsEmitted = true @@ -269,7 +270,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD if !s.bufferToolContent { delta["content"] = trimmed } else { - events := processToolSieveChunk(&s.toolSieve, trimmed, s.toolNames) + events := toolstream.ProcessChunk(&s.toolSieve, trimmed, s.toolNames) for _, evt := range events { if len(evt.ToolCallDeltas) > 0 { if !s.emitEarlyToolDeltas { diff --git a/internal/httpapi/openai/chat/handler.go b/internal/httpapi/openai/chat/handler.go new file mode 100644 index 0000000..81d1d22 --- /dev/null +++ b/internal/httpapi/openai/chat/handler.go @@ -0,0 +1,127 @@ +package chat + +import ( + "context" + "net/http" + "sync" + "time" + + "ds2api/internal/auth" + "ds2api/internal/chathistory" + "ds2api/internal/httpapi/openai/files" + "ds2api/internal/httpapi/openai/history" + "ds2api/internal/httpapi/openai/shared" + "ds2api/internal/promptcompat" + "ds2api/internal/toolcall" + "ds2api/internal/toolstream" +) + +const openAIGeneralMaxSize = shared.GeneralMaxSize + +var writeJSON = shared.WriteJSON + +type Handler struct { + Store shared.ConfigReader + Auth shared.AuthResolver + DS shared.DeepSeekCaller + ChatHistory *chathistory.Store + + leaseMu sync.Mutex + streamLeases map[string]streamLease +} + +type streamLease struct { + Auth *auth.RequestAuth + ExpiresAt time.Time +} + +func (h *Handler) compatStripReferenceMarkers() bool { + if h == nil { + return true + } + return shared.CompatStripReferenceMarkers(h.Store) +} + +func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) { + if h == nil { + return stdReq, nil + } + return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq) +} + +func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error { + if h == nil { + return nil + } + return (&files.Handler{Store: h.Store, Auth: h.Auth, DS: h.DS, ChatHistory: h.ChatHistory}).PreprocessInlineFileInputs(ctx, a, req) +} + +func (h *Handler) toolcallFeatureMatchEnabled() bool { + if h == nil { + return shared.ToolcallFeatureMatchEnabled(nil) + } + return shared.ToolcallFeatureMatchEnabled(h.Store) +} + +func (h *Handler) toolcallEarlyEmitHighConfidence() bool { + if h == nil { + return shared.ToolcallEarlyEmitHighConfidence(nil) + } + return shared.ToolcallEarlyEmitHighConfidence(h.Store) +} + +func writeOpenAIError(w http.ResponseWriter, status int, message string) { + shared.WriteOpenAIError(w, status, message) +} + +func openAIErrorType(status int) string { + return shared.OpenAIErrorType(status) +} + +func writeOpenAIInlineFileError(w http.ResponseWriter, err error) { + files.WriteInlineFileError(w, err) +} + +func mapHistorySplitError(err error) (int, string) { + return history.MapError(err) +} + +func requestTraceID(r *http.Request) string { + return shared.RequestTraceID(r) +} + +func asString(v any) string { + return shared.AsString(v) +} + +func cleanVisibleOutput(text string, stripReferenceMarkers bool) string { + return shared.CleanVisibleOutput(text, stripReferenceMarkers) +} + +func replaceCitationMarkersWithLinks(text string, links map[int]string) string { + return shared.ReplaceCitationMarkersWithLinks(text, links) +} + +func shouldWriteUpstreamEmptyOutputError(text string) bool { + return shared.ShouldWriteUpstreamEmptyOutputError(text) +} + +func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) { + return shared.UpstreamEmptyOutputDetail(contentFilter, text, thinking) +} + +func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool { + return shared.WriteUpstreamEmptyOutputError(w, text, thinking, contentFilter) +} + +func formatIncrementalStreamToolCallDeltas(deltas []toolstream.ToolCallDelta, ids map[int]string) []map[string]any { + return shared.FormatIncrementalStreamToolCallDeltas(deltas, ids) +} + +func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta, seenNames map[int]string) []toolstream.ToolCallDelta { + return shared.FilterIncrementalToolCallDeltasByAllowed(deltas, seenNames) +} + +func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any { + return shared.FormatFinalStreamToolCallsWithStableIDs(calls, ids) +} diff --git a/internal/adapter/openai/handler_chat.go b/internal/httpapi/openai/chat/handler_chat.go similarity index 95% rename from internal/adapter/openai/handler_chat.go rename to internal/httpapi/openai/chat/handler_chat.go index 4890e83..4a6d01a 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/httpapi/openai/chat/handler_chat.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "context" @@ -10,8 +10,9 @@ import ( "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsprotocol "ds2api/internal/deepseek/protocol" openaifmt "ds2api/internal/format/openai" + "ds2api/internal/promptcompat" "ds2api/internal/sse" streamengine "ds2api/internal/stream" ) @@ -58,7 +59,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIInlineFileError(w, err) return } - stdReq, err := normalizeOpenAIChatRequest(h.Store, req, requestTraceID(r)) + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, requestTraceID(r)) if err != nil { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return @@ -232,9 +233,9 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt Body: resp.Body, ThinkingEnabled: thinkingEnabled, InitialType: initialType, - KeepAliveInterval: time.Duration(deepseek.KeepAliveTimeout) * time.Second, - IdleTimeout: time.Duration(deepseek.StreamIdleTimeout) * time.Second, - MaxKeepAliveNoInput: deepseek.MaxKeepaliveCount, + KeepAliveInterval: time.Duration(dsprotocol.KeepAliveTimeout) * time.Second, + IdleTimeout: time.Duration(dsprotocol.StreamIdleTimeout) * time.Second, + MaxKeepAliveNoInput: dsprotocol.MaxKeepaliveCount, }, streamengine.ConsumeHooks{ OnKeepAlive: func() { streamRuntime.sendKeepAlive() diff --git a/internal/adapter/openai/handler_chat_auto_delete_test.go b/internal/httpapi/openai/chat/handler_chat_auto_delete_test.go similarity index 87% rename from internal/adapter/openai/handler_chat_auto_delete_test.go rename to internal/httpapi/openai/chat/handler_chat_auto_delete_test.go index 4fd1469..15645aa 100644 --- a/internal/adapter/openai/handler_chat_auto_delete_test.go +++ b/internal/httpapi/openai/chat/handler_chat_auto_delete_test.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "context" @@ -8,7 +8,7 @@ import ( "testing" "ds2api/internal/auth" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type autoDeleteModeDSStub struct { @@ -27,18 +27,18 @@ func (m *autoDeleteModeDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ return "pow", nil } -func (m *autoDeleteModeDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { - return &deepseek.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil +func (m *autoDeleteModeDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + return &dsclient.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil } func (m *autoDeleteModeDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { return m.resp, nil } -func (m *autoDeleteModeDSStub) DeleteSessionForToken(_ context.Context, _ string, sessionID string) (*deepseek.DeleteSessionResult, error) { +func (m *autoDeleteModeDSStub) DeleteSessionForToken(_ context.Context, _ string, sessionID string) (*dsclient.DeleteSessionResult, error) { m.singleCalls++ m.lastSessionID = sessionID - return &deepseek.DeleteSessionResult{SessionID: sessionID, Success: true}, nil + return &dsclient.DeleteSessionResult{SessionID: sessionID, Success: true}, nil } func (m *autoDeleteModeDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { @@ -46,11 +46,11 @@ func (m *autoDeleteModeDSStub) DeleteAllSessionsForToken(_ context.Context, _ st return nil } -func (m *autoDeleteModeDSStub) DeleteSessionForTokenCtx(ctx context.Context, _ string, sessionID string) (*deepseek.DeleteSessionResult, error) { +func (m *autoDeleteModeDSStub) DeleteSessionForTokenCtx(ctx context.Context, _ string, sessionID string) (*dsclient.DeleteSessionResult, error) { m.singleCalls++ m.lastSessionID = sessionID m.lastCtxErr = ctx.Err() - return &deepseek.DeleteSessionResult{SessionID: sessionID, Success: true}, nil + return &dsclient.DeleteSessionResult{SessionID: sessionID, Success: true}, nil } func TestChatCompletionsAutoDeleteModes(t *testing.T) { @@ -110,7 +110,7 @@ type autoDeleteCtxDSStub struct { autoDeleteModeDSStub } -func (m *autoDeleteCtxDSStub) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) { +func (m *autoDeleteCtxDSStub) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*dsclient.DeleteSessionResult, error) { return m.DeleteSessionForTokenCtx(ctx, token, sessionID) } diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/httpapi/openai/chat/handler_toolcall_test.go similarity index 99% rename from internal/adapter/openai/handler_toolcall_test.go rename to internal/httpapi/openai/chat/handler_toolcall_test.go index 4810caa..f949a46 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/httpapi/openai/chat/handler_toolcall_test.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "encoding/json" diff --git a/internal/httpapi/openai/chat/test_helpers_test.go b/internal/httpapi/openai/chat/test_helpers_test.go new file mode 100644 index 0000000..0423f4e --- /dev/null +++ b/internal/httpapi/openai/chat/test_helpers_test.go @@ -0,0 +1,202 @@ +package chat + +import ( + "context" + "io" + "net/http" + "strings" + + "ds2api/internal/auth" + dsclient "ds2api/internal/deepseek/client" +) + +type mockOpenAIConfig struct { + aliases map[string]string + wideInput bool + autoDeleteMode string + toolMode string + earlyEmit string + responsesTTL int + embedProv string + historySplitEnabled bool + historySplitTurns int +} + +func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } +func (m mockOpenAIConfig) CompatWideInputStrictOutput() bool { + return m.wideInput +} +func (m mockOpenAIConfig) CompatStripReferenceMarkers() bool { return true } +func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMode } +func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit } +func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL } +func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv } +func (m mockOpenAIConfig) AutoDeleteMode() string { + if m.autoDeleteMode == "" { + return "none" + } + return m.autoDeleteMode +} +func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) HistorySplitEnabled() bool { return m.historySplitEnabled } +func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int { + if m.historySplitTurns <= 0 { + return 1 + } + return m.historySplitTurns +} + +type streamStatusAuthStub struct{} + +func (streamStatusAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + UseConfigToken: false, + DeepSeekToken: "direct-token", + CallerID: "caller:test", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (streamStatusAuthStub) DetermineCaller(_ *http.Request) (*auth.RequestAuth, error) { + return (&streamStatusAuthStub{}).Determine(nil) +} + +func (streamStatusAuthStub) Release(_ *auth.RequestAuth) {} + +type streamStatusManagedAuthStub struct{} + +func (streamStatusManagedAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + UseConfigToken: true, + DeepSeekToken: "managed-token", + CallerID: "caller:test", + AccountID: "acct:test", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (streamStatusManagedAuthStub) DetermineCaller(_ *http.Request) (*auth.RequestAuth, error) { + return (&streamStatusManagedAuthStub{}).Determine(nil) +} + +func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {} + +type streamStatusDSStub struct { + resp *http.Response +} + +func (m streamStatusDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "session-id", nil +} + +func (m streamStatusDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} + +func (m streamStatusDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + return &dsclient.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil +} + +func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + return m.resp, nil +} + +func (m streamStatusDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*dsclient.DeleteSessionResult, error) { + return &dsclient.DeleteSessionResult{Success: true}, nil +} + +func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + return nil +} + +func makeOpenAISSEHTTPResponse(lines ...string) *http.Response { + body := strings.Join(lines, "\n") + if !strings.HasSuffix(body, "\n") { + body += "\n" + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +type inlineUploadDSStub struct { + uploadCalls []dsclient.UploadFileRequest + lastCtx context.Context + completionReq map[string]any + createSession string + uploadErr error + completionResp *http.Response +} + +func (m *inlineUploadDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + if strings.TrimSpace(m.createSession) == "" { + return "session-id", nil + } + return m.createSession, nil +} + +func (m *inlineUploadDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} + +func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + m.lastCtx = ctx + m.uploadCalls = append(m.uploadCalls, req) + if m.uploadErr != nil { + return nil, m.uploadErr + } + return &dsclient.UploadFileResult{ + ID: "file-inline-1", + Filename: req.Filename, + Bytes: int64(len(req.Data)), + Status: "uploaded", + Purpose: req.Purpose, + }, nil +} + +func (m *inlineUploadDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) { + m.completionReq = payload + if m.completionResp != nil { + return m.completionResp, nil + } + return makeOpenAISSEHTTPResponse( + `data: {"p":"response/content","v":"ok"}`, + `data: [DONE]`, + ), nil +} + +func (m *inlineUploadDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*dsclient.DeleteSessionResult, error) { + return &dsclient.DeleteSessionResult{Success: true}, nil +} + +func (m *inlineUploadDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + return nil +} + +func historySplitTestMessages() []any { + toolCalls := []any{ + map[string]any{ + "name": "search", + "arguments": map[string]any{"query": "docs"}, + }, + } + return []any{ + map[string]any{"role": "system", "content": "system instructions"}, + map[string]any{"role": "user", "content": "first user turn"}, + map[string]any{ + "role": "assistant", + "content": "", + "reasoning_content": "hidden reasoning", + "tool_calls": toolCalls, + }, + map[string]any{ + "role": "tool", + "name": "search", + "tool_call_id": "call-1", + "content": "tool result", + }, + map[string]any{"role": "user", "content": "latest user turn"}, + } +} diff --git a/internal/adapter/openai/vercel_prepare_test.go b/internal/httpapi/openai/chat/vercel_prepare_test.go similarity index 96% rename from internal/adapter/openai/vercel_prepare_test.go rename to internal/httpapi/openai/chat/vercel_prepare_test.go index 0ec0dd4..8cd948f 100644 --- a/internal/adapter/openai/vercel_prepare_test.go +++ b/internal/httpapi/openai/chat/vercel_prepare_test.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "encoding/json" @@ -9,7 +9,7 @@ import ( "time" "ds2api/internal/auth" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) func TestIsVercelStreamPrepareRequest(t *testing.T) { @@ -148,7 +148,7 @@ func TestHandleVercelStreamPrepareMapsHistorySplitManagedAuthFailureTo401(t *tes t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret") ds := &inlineUploadDSStub{ - uploadErr: &deepseek.RequestFailure{Op: "upload file", Kind: deepseek.FailureManagedUnauthorized, Message: "expired token"}, + uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureManagedUnauthorized, Message: "expired token"}, } h := &Handler{ Store: mockOpenAIConfig{ diff --git a/internal/adapter/openai/vercel_stream.go b/internal/httpapi/openai/chat/vercel_stream.go similarity index 98% rename from internal/adapter/openai/vercel_stream.go rename to internal/httpapi/openai/chat/vercel_stream.go index c5c754d..1a3c00d 100644 --- a/internal/adapter/openai/vercel_stream.go +++ b/internal/httpapi/openai/chat/vercel_stream.go @@ -1,4 +1,4 @@ -package openai +package chat import ( "crypto/subtle" @@ -11,6 +11,7 @@ import ( "ds2api/internal/auth" "ds2api/internal/config" + "ds2api/internal/promptcompat" "ds2api/internal/util" "github.com/google/uuid" @@ -59,7 +60,7 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque writeOpenAIError(w, http.StatusBadRequest, "stream must be true") return } - stdReq, err := normalizeOpenAIChatRequest(h.Store, req, requestTraceID(r)) + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, requestTraceID(r)) if err != nil { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/adapter/openai/citation_links_test.go b/internal/httpapi/openai/citation_links_test.go similarity index 100% rename from internal/adapter/openai/citation_links_test.go rename to internal/httpapi/openai/citation_links_test.go diff --git a/internal/adapter/openai/deps_injection_test.go b/internal/httpapi/openai/deps_injection_test.go similarity index 87% rename from internal/adapter/openai/deps_injection_test.go rename to internal/httpapi/openai/deps_injection_test.go index 1989b2f..0d906aa 100644 --- a/internal/adapter/openai/deps_injection_test.go +++ b/internal/httpapi/openai/deps_injection_test.go @@ -1,6 +1,10 @@ package openai -import "testing" +import ( + "testing" + + "ds2api/internal/promptcompat" +) type mockOpenAIConfig struct { aliases map[string]string @@ -49,9 +53,9 @@ func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { "model": "my-model", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, } - out, err := normalizeOpenAIChatRequest(cfg, req, "") + out, err := promptcompat.NormalizeOpenAIChatRequest(cfg, req, "") if err != nil { - t.Fatalf("normalizeOpenAIChatRequest error: %v", err) + t.Fatalf("promptcompat.NormalizeOpenAIChatRequest error: %v", err) } if out.ResolvedModel != "deepseek-v4-flash-search" { t.Fatalf("resolved model mismatch: got=%q", out.ResolvedModel) @@ -67,7 +71,7 @@ func TestNormalizeOpenAIResponsesRequestWideInputPolicyFromInterface(t *testing. "input": "hi", } - _, err := normalizeOpenAIResponsesRequest(mockOpenAIConfig{ + _, err := promptcompat.NormalizeOpenAIResponsesRequest(mockOpenAIConfig{ aliases: map[string]string{}, wideInput: false, }, req, "") @@ -75,7 +79,7 @@ func TestNormalizeOpenAIResponsesRequestWideInputPolicyFromInterface(t *testing. t.Fatal("expected error when wide input is disabled and only input is provided") } - out, err := normalizeOpenAIResponsesRequest(mockOpenAIConfig{ + out, err := promptcompat.NormalizeOpenAIResponsesRequest(mockOpenAIConfig{ aliases: map[string]string{}, wideInput: true, }, req, "") diff --git a/internal/adapter/openai/embeddings_handler.go b/internal/httpapi/openai/embeddings/embeddings_handler.go similarity index 66% rename from internal/adapter/openai/embeddings_handler.go rename to internal/httpapi/openai/embeddings/embeddings_handler.go index 48dfdd8..8c5b340 100644 --- a/internal/adapter/openai/embeddings_handler.go +++ b/internal/httpapi/openai/embeddings/embeddings_handler.go @@ -1,4 +1,4 @@ -package openai +package embeddings import ( "crypto/sha256" @@ -9,10 +9,19 @@ import ( "strings" "ds2api/internal/auth" + "ds2api/internal/chathistory" "ds2api/internal/config" + "ds2api/internal/httpapi/openai/shared" "ds2api/internal/util" ) +type Handler struct { + Store shared.ConfigReader + Auth shared.AuthResolver + DS shared.DeepSeekCaller + ChatHistory *chathistory.Store +} + func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) { a, err := h.Auth.Determine(r) if err != nil { @@ -21,35 +30,35 @@ func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) { if err == auth.ErrNoAccount { status = http.StatusTooManyRequests } - writeOpenAIError(w, status, detail) + shared.WriteOpenAIError(w, status, detail) return } defer h.Auth.Release(a) - r.Body = http.MaxBytesReader(w, r.Body, openAIGeneralMaxSize) + r.Body = http.MaxBytesReader(w, r.Body, shared.GeneralMaxSize) var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if strings.Contains(strings.ToLower(err.Error()), "too large") { - writeOpenAIError(w, http.StatusRequestEntityTooLarge, "request body too large") + shared.WriteOpenAIError(w, http.StatusRequestEntityTooLarge, "request body too large") return } - writeOpenAIError(w, http.StatusBadRequest, "invalid json") + shared.WriteOpenAIError(w, http.StatusBadRequest, "invalid json") return } model, _ := req["model"].(string) model = strings.TrimSpace(model) if model == "" { - writeOpenAIError(w, http.StatusBadRequest, "Request must include 'model'.") + shared.WriteOpenAIError(w, http.StatusBadRequest, "Request must include 'model'.") return } if _, ok := config.ResolveModel(h.Store, model); !ok { - writeOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("Model '%s' is not available.", model)) + shared.WriteOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("Model '%s' is not available.", model)) return } - inputs := extractEmbeddingInputs(req["input"]) + inputs := ExtractEmbeddingInputs(req["input"]) if len(inputs) == 0 { - writeOpenAIError(w, http.StatusBadRequest, "Request must include non-empty 'input'.") + shared.WriteOpenAIError(w, http.StatusBadRequest, "Request must include non-empty 'input'.") return } @@ -58,14 +67,14 @@ func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) { provider = strings.ToLower(strings.TrimSpace(h.Store.EmbeddingsProvider())) } if provider == "" { - writeOpenAIError(w, http.StatusNotImplemented, "Embeddings provider is not configured. Set embeddings.provider in config.") + shared.WriteOpenAIError(w, http.StatusNotImplemented, "Embeddings provider is not configured. Set embeddings.provider in config.") return } switch provider { case "mock", "deterministic", "builtin": // supported local deterministic provider default: - writeOpenAIError(w, http.StatusNotImplemented, fmt.Sprintf("Embeddings provider '%s' is not supported.", provider)) + shared.WriteOpenAIError(w, http.StatusNotImplemented, fmt.Sprintf("Embeddings provider '%s' is not supported.", provider)) return } @@ -76,10 +85,10 @@ func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) { data = append(data, map[string]any{ "object": "embedding", "index": i, - "embedding": deterministicEmbedding(input), + "embedding": DeterministicEmbedding(input), }) } - writeJSON(w, http.StatusOK, map[string]any{ + shared.WriteJSON(w, http.StatusOK, map[string]any{ "object": "list", "data": data, "model": model, @@ -90,7 +99,7 @@ func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) { }) } -func extractEmbeddingInputs(raw any) []string { +func ExtractEmbeddingInputs(raw any) []string { switch v := raw.(type) { case string: s := strings.TrimSpace(v) @@ -123,7 +132,7 @@ func extractEmbeddingInputs(raw any) []string { } } -func deterministicEmbedding(input string) []float64 { +func DeterministicEmbedding(input string) []float64 { // Keep response shape stable without external dependencies. const dims = 64 out := make([]float64, dims) diff --git a/internal/adapter/openai/embeddings_route_test.go b/internal/httpapi/openai/embeddings_route_test.go similarity index 94% rename from internal/adapter/openai/embeddings_route_test.go rename to internal/httpapi/openai/embeddings_route_test.go index 4395d16..6962a05 100644 --- a/internal/adapter/openai/embeddings_route_test.go +++ b/internal/httpapi/openai/embeddings_route_test.go @@ -28,9 +28,9 @@ func newResolverWithConfigJSON(t *testing.T, cfgJSON string) (*config.Store, *au func TestEmbeddingsRouteContract(t *testing.T) { store, resolver := newResolverWithConfigJSON(t, `{"embeddings":{"provider":"deterministic"}}`) - h := &Handler{Store: store, Auth: resolver} + h := &openAITestSurface{Store: store, Auth: resolver} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) t.Run("unauthorized", func(t *testing.T) { body := bytes.NewBufferString(`{"model":"gpt-4o","input":"hello"}`) @@ -69,9 +69,9 @@ func TestEmbeddingsRouteContract(t *testing.T) { func TestEmbeddingsRouteProviderMissing(t *testing.T) { store, resolver := newResolverWithConfigJSON(t, `{}`) - h := &Handler{Store: store, Auth: resolver} + h := &openAITestSurface{Store: store, Auth: resolver} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) body := bytes.NewBufferString(`{"model":"gpt-4o","input":"hello"}`) req := httptest.NewRequest(http.MethodPost, "/v1/embeddings", body) diff --git a/internal/adapter/openai/error_shape_test.go b/internal/httpapi/openai/error_shape_test.go similarity index 100% rename from internal/adapter/openai/error_shape_test.go rename to internal/httpapi/openai/error_shape_test.go diff --git a/internal/adapter/openai/file_inline_upload_test.go b/internal/httpapi/openai/file_inline_upload_test.go similarity index 89% rename from internal/adapter/openai/file_inline_upload_test.go rename to internal/httpapi/openai/file_inline_upload_test.go index d5e33b0..4ea2445 100644 --- a/internal/adapter/openai/file_inline_upload_test.go +++ b/internal/httpapi/openai/file_inline_upload_test.go @@ -12,11 +12,11 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/auth" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type inlineUploadDSStub struct { - uploadCalls []deepseek.UploadFileRequest + uploadCalls []dsclient.UploadFileRequest lastCtx context.Context completionReq map[string]any createSession string @@ -35,13 +35,13 @@ func (m *inlineUploadDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ in return "pow", nil } -func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth, req deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { +func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { m.lastCtx = ctx m.uploadCalls = append(m.uploadCalls, req) if m.uploadErr != nil { return nil, m.uploadErr } - return &deepseek.UploadFileResult{ + return &dsclient.UploadFileResult{ ID: "file-inline-1", Filename: req.Filename, Bytes: int64(len(req.Data)), @@ -61,8 +61,8 @@ func (m *inlineUploadDSStub) CallCompletion(_ context.Context, _ *auth.RequestAu ), nil } -func (m *inlineUploadDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { - return &deepseek.DeleteSessionResult{Success: true}, nil +func (m *inlineUploadDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*dsclient.DeleteSessionResult, error) { + return &dsclient.DeleteSessionResult{Success: true}, nil } func (m *inlineUploadDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { @@ -71,7 +71,7 @@ func (m *inlineUploadDSStub) DeleteAllSessionsForToken(_ context.Context, _ stri func TestPreprocessInlineFileInputsReplacesDataURLAndCollectsRefFileIDs(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{DS: ds} + h := &openAITestSurface{DS: ds} req := map[string]any{ "messages": []any{ map[string]any{ @@ -121,7 +121,7 @@ func TestPreprocessInlineFileInputsReplacesDataURLAndCollectsRefFileIDs(t *testi func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{DS: ds} + h := &openAITestSurface{DS: ds} req := map[string]any{ "messages": []any{ map[string]any{ @@ -148,7 +148,7 @@ func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) { func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) req.Header.Set("Authorization", "Bearer direct-token") @@ -174,9 +174,9 @@ func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) { func TestResponsesUploadsInlineFilesBeforeCompletion(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","input":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"input_image","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) req.Header.Set("Authorization", "Bearer direct-token") @@ -199,7 +199,7 @@ func TestResponsesUploadsInlineFilesBeforeCompletion(t *testing.T) { func TestChatCompletionsInlineUploadFailureReturnsBadRequest(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,%%%"}}]}],"stream":false}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) req.Header.Set("Authorization", "Bearer direct-token") @@ -218,9 +218,9 @@ func TestChatCompletionsInlineUploadFailureReturnsBadRequest(t *testing.T) { func TestResponsesInlineUploadFailureReturnsInternalServerError(t *testing.T) { ds := &inlineUploadDSStub{uploadErr: errors.New("boom")} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","input":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) req.Header.Set("Authorization", "Bearer direct-token") @@ -241,9 +241,9 @@ func TestVercelPrepareUploadsInlineFilesBeforeLeasePayload(t *testing.T) { t.Setenv("VERCEL", "1") t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret") ds := &inlineUploadDSStub{} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":true}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?__stream_prepare=1", strings.NewReader(reqBody)) req.Header.Set("Authorization", "Bearer direct-token") diff --git a/internal/adapter/openai/file_inline_upload.go b/internal/httpapi/openai/files/file_inline_upload.go similarity index 89% rename from internal/adapter/openai/file_inline_upload.go rename to internal/httpapi/openai/files/file_inline_upload.go index 5955e81..c8d59a9 100644 --- a/internal/adapter/openai/file_inline_upload.go +++ b/internal/httpapi/openai/files/file_inline_upload.go @@ -1,4 +1,4 @@ -package openai +package files import ( "context" @@ -12,7 +12,9 @@ import ( "strings" "ds2api/internal/auth" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/httpapi/openai/shared" + "ds2api/internal/promptcompat" ) const maxInlineFilesPerRequest = 50 @@ -51,7 +53,7 @@ type inlineDecodedFile struct { ReplacementType string } -func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error { +func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error { if h == nil || h.DS == nil || len(req) == 0 { return nil } @@ -70,16 +72,16 @@ func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.Reques req[key] = updated } } - if refIDs := collectOpenAIRefFileIDs(req); len(refIDs) > 0 { + if refIDs := promptcompat.CollectOpenAIRefFileIDs(req); len(refIDs) > 0 { req["ref_file_ids"] = stringsToAnySlice(refIDs) } return nil } -func writeOpenAIInlineFileError(w http.ResponseWriter, err error) { +func WriteInlineFileError(w http.ResponseWriter, err error) { inlineErr, ok := err.(*inlineFileUploadError) if !ok || inlineErr == nil { - writeOpenAIError(w, http.StatusInternalServerError, "Failed to process file input.") + shared.WriteOpenAIError(w, http.StatusInternalServerError, "Failed to process file input.") return } status := inlineErr.status @@ -90,7 +92,7 @@ func writeOpenAIInlineFileError(w http.ResponseWriter, err error) { if message == "" { message = "Failed to process file input." } - writeOpenAIError(w, status, message) + shared.WriteOpenAIError(w, status, message) } func (s *inlineUploadState) walk(raw any) (any, error) { @@ -163,7 +165,7 @@ func (s *inlineUploadState) uploadInlineFile(file inlineDecodedFile) (string, er if contentType == "" { contentType = http.DetectContentType(file.Data) } - result, err := s.handler.DS.UploadFile(s.ctx, s.auth, deepseek.UploadFileRequest{ + result, err := s.handler.DS.UploadFile(s.ctx, s.auth, dsclient.UploadFileRequest{ Filename: file.Filename, ContentType: contentType, Data: file.Data, @@ -183,7 +185,7 @@ func decodeOpenAIInlineFileBlock(block map[string]any) (inlineDecodedFile, bool, if block == nil { return inlineDecodedFile{}, false, nil } - if strings.TrimSpace(asString(block["file_id"])) != "" { + if strings.TrimSpace(shared.AsString(block["file_id"])) != "" { return inlineDecodedFile{}, false, nil } if nested, ok := block["file"].(map[string]any); ok { @@ -196,7 +198,7 @@ func decodeOpenAIInlineFileBlock(block map[string]any) (inlineDecodedFile, bool, } return decoded, true, nil } - blockType := strings.ToLower(strings.TrimSpace(asString(block["type"]))) + blockType := strings.ToLower(strings.TrimSpace(shared.AsString(block["type"]))) if raw, matched := extractInlineImageDataURL(block); matched { data, contentType, err := decodeInlinePayload(raw, contentTypeFromMap(block)) if err != nil { @@ -232,11 +234,11 @@ func extractInlineImageDataURL(block map[string]any) (string, bool) { return strings.TrimSpace(x), true } case map[string]any: - if raw := strings.TrimSpace(asString(x["url"])); isDataURL(raw) { + if raw := strings.TrimSpace(shared.AsString(x["url"])); isDataURL(raw) { return raw, true } } - if raw := strings.TrimSpace(asString(block["url"])); isDataURL(raw) { + if raw := strings.TrimSpace(shared.AsString(block["url"])); isDataURL(raw) { return raw, true } return "", false @@ -244,7 +246,7 @@ func extractInlineImageDataURL(block map[string]any) (string, bool) { func extractInlineFilePayload(block map[string]any, blockType string) (string, bool) { for _, value := range []any{block["file_data"], block["base64"], block["data"]} { - if raw := strings.TrimSpace(asString(value)); raw != "" { + if raw := strings.TrimSpace(shared.AsString(value)); raw != "" { if strings.Contains(blockType, "file") || block["file_data"] != nil || block["filename"] != nil || block["file_name"] != nil || block["name"] != nil { return raw, true } @@ -319,13 +321,13 @@ func decodeBase64Flexible(raw string) ([]byte, error) { func contentTypeFromMap(block map[string]any) string { for _, value := range []any{block["mime_type"], block["mimeType"], block["content_type"], block["contentType"], block["media_type"], block["mediaType"]} { - if contentType := strings.TrimSpace(asString(value)); contentType != "" { + if contentType := strings.TrimSpace(shared.AsString(value)); contentType != "" { return contentType } } if imageURL, ok := block["image_url"].(map[string]any); ok { for _, value := range []any{imageURL["mime_type"], imageURL["mimeType"], imageURL["content_type"], imageURL["contentType"]} { - if contentType := strings.TrimSpace(asString(value)); contentType != "" { + if contentType := strings.TrimSpace(shared.AsString(value)); contentType != "" { return contentType } } @@ -335,7 +337,7 @@ func contentTypeFromMap(block map[string]any) string { func pickInlineFilename(block map[string]any, contentType string, prefix string) string { for _, value := range []any{block["filename"], block["file_name"], block["name"]} { - if name := strings.TrimSpace(asString(value)); name != "" { + if name := strings.TrimSpace(shared.AsString(value)); name != "" { return filepath.Base(name) } } diff --git a/internal/adapter/openai/handler_files.go b/internal/httpapi/openai/files/handler_files.go similarity index 66% rename from internal/adapter/openai/handler_files.go rename to internal/httpapi/openai/files/handler_files.go index f15ea3b..edfb653 100644 --- a/internal/adapter/openai/handler_files.go +++ b/internal/httpapi/openai/files/handler_files.go @@ -1,4 +1,4 @@ -package openai +package files import ( "io" @@ -7,11 +7,20 @@ import ( "time" "ds2api/internal/auth" - "ds2api/internal/deepseek" + "ds2api/internal/chathistory" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/httpapi/openai/shared" ) const openAIUploadMaxMemory = 32 << 20 +type Handler struct { + Store shared.ConfigReader + Auth shared.AuthResolver + DS shared.DeepSeekCaller + ChatHistory *chathistory.Store +} + func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { a, err := h.Auth.Determine(r) if err != nil { @@ -20,22 +29,22 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { if err == auth.ErrNoAccount { status = http.StatusTooManyRequests } - writeOpenAIError(w, status, detail) + shared.WriteOpenAIError(w, status, detail) return } defer h.Auth.Release(a) if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "multipart/form-data") { - writeOpenAIError(w, http.StatusBadRequest, "content-type must be multipart/form-data") + shared.WriteOpenAIError(w, http.StatusBadRequest, "content-type must be multipart/form-data") return } // Enforce a hard cap on the total request body size to prevent OOM - r.Body = http.MaxBytesReader(w, r.Body, openAIUploadMaxSize) + r.Body = http.MaxBytesReader(w, r.Body, shared.UploadMaxSize) if err := r.ParseMultipartForm(openAIUploadMaxMemory); err != nil { if strings.Contains(strings.ToLower(err.Error()), "too large") { - writeOpenAIError(w, http.StatusRequestEntityTooLarge, "file size exceeds limit") + shared.WriteOpenAIError(w, http.StatusRequestEntityTooLarge, "file size exceeds limit") return } - writeOpenAIError(w, http.StatusBadRequest, "invalid multipart form") + shared.WriteOpenAIError(w, http.StatusBadRequest, "invalid multipart form") return } if r.MultipartForm != nil { @@ -44,36 +53,36 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { r = r.WithContext(auth.WithAuth(r.Context(), a)) file, header, err := r.FormFile("file") if err != nil { - writeOpenAIError(w, http.StatusBadRequest, "file is required") + shared.WriteOpenAIError(w, http.StatusBadRequest, "file is required") return } defer func() { _ = file.Close() }() data, err := io.ReadAll(file) if err != nil { - writeOpenAIError(w, http.StatusBadRequest, "failed to read uploaded file") + shared.WriteOpenAIError(w, http.StatusBadRequest, "failed to read uploaded file") return } contentType := strings.TrimSpace(header.Header.Get("Content-Type")) if contentType == "" && len(data) > 0 { contentType = http.DetectContentType(data) } - result, err := h.DS.UploadFile(r.Context(), a, deepseek.UploadFileRequest{ + result, err := h.DS.UploadFile(r.Context(), a, dsclient.UploadFileRequest{ Filename: header.Filename, ContentType: contentType, Purpose: strings.TrimSpace(r.FormValue("purpose")), Data: data, }, 3) if err != nil { - writeOpenAIError(w, http.StatusInternalServerError, "Failed to upload file.") + shared.WriteOpenAIError(w, http.StatusInternalServerError, "Failed to upload file.") return } if result != nil && result.AccountID == "" { result.AccountID = a.AccountID } - writeJSON(w, http.StatusOK, buildOpenAIFileObject(result)) + shared.WriteJSON(w, http.StatusOK, buildOpenAIFileObject(result)) } -func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any { +func buildOpenAIFileObject(result *dsclient.UploadFileResult) map[string]any { if result == nil { obj := map[string]any{ "id": "", diff --git a/internal/adapter/openai/files_route_test.go b/internal/httpapi/openai/files_route_test.go similarity index 85% rename from internal/adapter/openai/files_route_test.go rename to internal/httpapi/openai/files_route_test.go index 6c8eb0b..2b9c205 100644 --- a/internal/adapter/openai/files_route_test.go +++ b/internal/httpapi/openai/files_route_test.go @@ -13,7 +13,7 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/auth" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type managedFilesAuthStub struct{} @@ -41,8 +41,8 @@ func (managedFilesAuthStub) DetermineCaller(_ *http.Request) (*auth.RequestAuth, func (managedFilesAuthStub) Release(_ *auth.RequestAuth) {} type filesRouteDSStub struct { - lastReq deepseek.UploadFileRequest - upload *deepseek.UploadFileResult + lastReq dsclient.UploadFileRequest + upload *dsclient.UploadFileResult err error } @@ -54,7 +54,7 @@ func (m *filesRouteDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) return "", nil } -func (m *filesRouteDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, req deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { +func (m *filesRouteDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { m.lastReq = req if m.err != nil { return nil, m.err @@ -62,15 +62,15 @@ func (m *filesRouteDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, re if m.upload != nil { return m.upload, nil } - return &deepseek.UploadFileResult{ID: "file-123", Filename: req.Filename, Bytes: int64(len(req.Data)), Purpose: req.Purpose, Status: "uploaded"}, nil + return &dsclient.UploadFileResult{ID: "file-123", Filename: req.Filename, Bytes: int64(len(req.Data)), Purpose: req.Purpose, Status: "uploaded"}, nil } func (m *filesRouteDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { return nil, errors.New("not implemented") } -func (m *filesRouteDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { - return &deepseek.DeleteSessionResult{Success: true}, nil +func (m *filesRouteDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*dsclient.DeleteSessionResult, error) { + return &dsclient.DeleteSessionResult{Success: true}, nil } func (m *filesRouteDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { @@ -104,9 +104,9 @@ func newMultipartUploadRequest(t *testing.T, purpose string, filename string, da func TestFilesRouteUploadSuccess(t *testing.T) { ds := &filesRouteDSStub{} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world")) rec := httptest.NewRecorder() @@ -141,9 +141,9 @@ func TestFilesRouteUploadSuccess(t *testing.T) { func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) { ds := &filesRouteDSStub{} - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: managedFilesAuthStub{}, DS: ds} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: managedFilesAuthStub{}, DS: ds} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world")) rec := httptest.NewRecorder() @@ -162,9 +162,9 @@ func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) { } func TestFilesRouteRejectsNonMultipart(t *testing.T) { - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) req := httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewBufferString(`{"purpose":"assistants"}`)) req.Header.Set("Authorization", "Bearer direct-token") @@ -178,9 +178,9 @@ func TestFilesRouteRejectsNonMultipart(t *testing.T) { } func TestFilesRouteRequiresFileField(t *testing.T) { - h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) var body bytes.Buffer writer := multipart.NewWriter(&body) diff --git a/internal/adapter/openai/history_split.go b/internal/httpapi/openai/history/history_split.go similarity index 61% rename from internal/adapter/openai/history_split.go rename to internal/httpapi/openai/history/history_split.go index 8589e63..c92cd69 100644 --- a/internal/adapter/openai/history_split.go +++ b/internal/httpapi/openai/history/history_split.go @@ -1,4 +1,4 @@ -package openai +package history import ( "context" @@ -7,36 +7,41 @@ import ( "strings" "ds2api/internal/auth" - "ds2api/internal/deepseek" - "ds2api/internal/util" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/httpapi/openai/shared" + "ds2api/internal/promptcompat" ) const ( - historySplitFilename = "HISTORY.txt" - historySplitInjectedFilename = "IGNORE" - historySplitContentType = "text/plain; charset=utf-8" - historySplitPurpose = "assistants" + historySplitFilename = "HISTORY.txt" + historySplitContentType = "text/plain; charset=utf-8" + historySplitPurpose = "assistants" ) -func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq util.StandardRequest) (util.StandardRequest, error) { - if h == nil || h.DS == nil || h.Store == nil || a == nil { +type Service struct { + Store shared.ConfigReader + DS shared.DeepSeekCaller +} + +func (s Service) Apply(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) { + if s.DS == nil || s.Store == nil || a == nil { return stdReq, nil } - if !h.Store.HistorySplitEnabled() { + if !s.Store.HistorySplitEnabled() { return stdReq, nil } - promptMessages, historyMessages := splitOpenAIHistoryMessages(stdReq.Messages, h.Store.HistorySplitTriggerAfterTurns()) + promptMessages, historyMessages := SplitOpenAIHistoryMessages(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns()) if len(historyMessages) == 0 { return stdReq, nil } - historyText := buildOpenAIHistoryTranscript(historyMessages) + historyText := promptcompat.BuildOpenAIHistoryTranscript(historyMessages) if strings.TrimSpace(historyText) == "" { return stdReq, errors.New("history split produced empty transcript") } - result, err := h.DS.UploadFile(ctx, a, deepseek.UploadFileRequest{ + result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{ Filename: historySplitFilename, ContentType: historySplitContentType, Purpose: historySplitPurpose, @@ -53,11 +58,11 @@ func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, st stdReq.Messages = promptMessages stdReq.HistoryText = historyText stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) - stdReq.FinalPrompt, stdReq.ToolNames = buildOpenAIFinalPromptWithPolicy(promptMessages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking) + stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(promptMessages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking) return stdReq, nil } -func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { +func SplitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { if triggerAfterTurns <= 0 { triggerAfterTurns = 1 } @@ -68,7 +73,7 @@ func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, [ if !ok { continue } - role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + role := strings.ToLower(strings.TrimSpace(shared.AsString(msg["role"]))) if role != "user" { continue } @@ -91,7 +96,7 @@ func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, [ } continue } - role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + role := strings.ToLower(strings.TrimSpace(shared.AsString(msg["role"]))) switch role { case "system", "developer": promptMessages = append(promptMessages, raw) @@ -109,15 +114,6 @@ func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, [ return promptMessages, historyMessages } -func buildOpenAIHistoryTranscript(messages []any) string { - normalized := normalizeOpenAIMessagesForPrompt(messages, "") - transcript := strings.TrimSpace(deepseek.MessagesPrepare(normalized)) - if transcript == "" { - return "" - } - return fmt.Sprintf("[file content end]\n\n%s\n\n[file name]: %s\n[file content begin]\n", transcript, historySplitInjectedFilename) -} - func prependUniqueRefFileID(existing []string, fileID string) []string { fileID = strings.TrimSpace(fileID) if fileID == "" { diff --git a/internal/adapter/openai/history_split_error.go b/internal/httpapi/openai/history/history_split_error.go similarity index 61% rename from internal/adapter/openai/history_split_error.go rename to internal/httpapi/openai/history/history_split_error.go index 4ab7894..df7c503 100644 --- a/internal/adapter/openai/history_split_error.go +++ b/internal/httpapi/openai/history/history_split_error.go @@ -1,16 +1,16 @@ -package openai +package history import ( "net/http" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) -func mapHistorySplitError(err error) (int, string) { +func MapError(err error) (int, string) { switch { - case deepseek.IsManagedUnauthorizedError(err): + case dsclient.IsManagedUnauthorizedError(err): return http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin." - case deepseek.IsDirectUnauthorizedError(err): + case dsclient.IsDirectUnauthorizedError(err): return http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first." default: return http.StatusInternalServerError, err.Error() diff --git a/internal/adapter/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go similarity index 93% rename from internal/adapter/openai/history_split_test.go rename to internal/httpapi/openai/history_split_test.go index 5703d75..c6059d7 100644 --- a/internal/adapter/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -12,8 +12,8 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/auth" - "ds2api/internal/deepseek" - "ds2api/internal/util" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/promptcompat" ) func historySplitTestMessages() []any { @@ -99,7 +99,7 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { t.Fatalf("expected both prompt and history messages, got prompt=%d history=%d", len(promptMessages), len(historyMessages)) } - promptText, _ := buildOpenAIFinalPromptWithPolicy(promptMessages, nil, "", defaultToolChoicePolicy(), true) + promptText, _ := promptcompat.BuildOpenAIPrompt(promptMessages, nil, "", defaultToolChoicePolicy(), true) if !strings.Contains(promptText, "latest user turn") { t.Fatalf("expected latest user turn in prompt, got %s", promptText) } @@ -118,7 +118,7 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -132,7 +132,7 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { map[string]any{"role": "user", "content": "hello"}, }, } - stdReq, err := normalizeOpenAIChatRequest(h.Store, req, "") + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "") if err != nil { t.Fatalf("normalize failed: %v", err) } @@ -151,7 +151,7 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { func TestApplyHistorySplitCarriesHistoryText(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -163,7 +163,7 @@ func TestApplyHistorySplitCarriesHistoryText(t *testing.T) { "model": "deepseek-v4-flash", "messages": historySplitTestMessages(), } - stdReq, err := normalizeOpenAIChatRequest(h.Store, req, "") + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "") if err != nil { t.Fatalf("normalize failed: %v", err) } @@ -182,7 +182,7 @@ func TestApplyHistorySplitCarriesHistoryText(t *testing.T) { func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -241,7 +241,7 @@ func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *te func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -251,7 +251,7 @@ func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { DS: ds, } r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody, _ := json.Marshal(map[string]any{ "model": "deepseek-v4-flash", "messages": historySplitTestMessages(), @@ -284,9 +284,9 @@ func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { func TestChatCompletionsHistorySplitMapsManagedAuthFailureTo401(t *testing.T) { ds := &inlineUploadDSStub{ - uploadErr: &deepseek.RequestFailure{Op: "upload file", Kind: deepseek.FailureManagedUnauthorized, Message: "expired token"}, + uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureManagedUnauthorized, Message: "expired token"}, } - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -317,9 +317,9 @@ func TestChatCompletionsHistorySplitMapsManagedAuthFailureTo401(t *testing.T) { func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) { ds := &inlineUploadDSStub{ - uploadErr: &deepseek.RequestFailure{Op: "upload file", Kind: deepseek.FailureDirectUnauthorized, Message: "invalid token"}, + uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureDirectUnauthorized, Message: "invalid token"}, } - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -329,7 +329,7 @@ func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) { DS: ds, } r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody, _ := json.Marshal(map[string]any{ "model": "deepseek-v4-flash", "messages": historySplitTestMessages(), @@ -352,7 +352,7 @@ func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) { func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) { ds := &inlineUploadDSStub{uploadErr: errors.New("boom")} - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, historySplitEnabled: true, @@ -382,7 +382,7 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) { for _, mode := range []string{"none", "single", "all"} { t.Run(mode, func(t *testing.T) { ds := &inlineUploadDSStub{} - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{ wideInput: true, autoDeleteMode: mode, @@ -421,6 +421,6 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) { } } -func defaultToolChoicePolicy() util.ToolChoicePolicy { - return util.DefaultToolChoicePolicy() +func defaultToolChoicePolicy() promptcompat.ToolChoicePolicy { + return promptcompat.DefaultToolChoicePolicy() } diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/httpapi/openai/leaked_output_sanitize_test.go similarity index 100% rename from internal/adapter/openai/leaked_output_sanitize_test.go rename to internal/httpapi/openai/leaked_output_sanitize_test.go diff --git a/internal/adapter/openai/models_route_test.go b/internal/httpapi/openai/models_route_test.go similarity index 93% rename from internal/adapter/openai/models_route_test.go rename to internal/httpapi/openai/models_route_test.go index ba83020..9e318f9 100644 --- a/internal/adapter/openai/models_route_test.go +++ b/internal/httpapi/openai/models_route_test.go @@ -9,9 +9,9 @@ import ( ) func TestGetModelRouteDirectAndAlias(t *testing.T) { - h := &Handler{} + h := &openAITestSurface{} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) t.Run("direct", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/models/deepseek-v4-flash", nil) @@ -51,9 +51,9 @@ func TestGetModelRouteDirectAndAlias(t *testing.T) { } func TestGetModelRouteNotFound(t *testing.T) { - h := &Handler{} + h := &openAITestSurface{} r := chi.NewRouter() - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) req := httptest.NewRequest(http.MethodGet, "/v1/models/not-exists", nil) rec := httptest.NewRecorder() diff --git a/internal/httpapi/openai/responses/handler.go b/internal/httpapi/openai/responses/handler.go new file mode 100644 index 0000000..09feb91 --- /dev/null +++ b/internal/httpapi/openai/responses/handler.go @@ -0,0 +1,108 @@ +package responses + +import ( + "context" + "net/http" + "sync" + + "ds2api/internal/auth" + "ds2api/internal/chathistory" + "ds2api/internal/httpapi/openai/files" + "ds2api/internal/httpapi/openai/history" + "ds2api/internal/httpapi/openai/shared" + "ds2api/internal/promptcompat" + "ds2api/internal/toolstream" +) + +const openAIGeneralMaxSize = shared.GeneralMaxSize + +var writeJSON = shared.WriteJSON + +type Handler struct { + Store shared.ConfigReader + Auth shared.AuthResolver + DS shared.DeepSeekCaller + ChatHistory *chathistory.Store + + responsesMu sync.Mutex + responses *responseStore +} + +func (h *Handler) compatStripReferenceMarkers() bool { + if h == nil { + return true + } + return shared.CompatStripReferenceMarkers(h.Store) +} + +func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) { + if h == nil { + return stdReq, nil + } + return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq) +} + +func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error { + if h == nil { + return nil + } + return (&files.Handler{Store: h.Store, Auth: h.Auth, DS: h.DS, ChatHistory: h.ChatHistory}).PreprocessInlineFileInputs(ctx, a, req) +} + +func (h *Handler) toolcallFeatureMatchEnabled() bool { + if h == nil { + return shared.ToolcallFeatureMatchEnabled(nil) + } + return shared.ToolcallFeatureMatchEnabled(h.Store) +} + +func (h *Handler) toolcallEarlyEmitHighConfidence() bool { + if h == nil { + return shared.ToolcallEarlyEmitHighConfidence(nil) + } + return shared.ToolcallEarlyEmitHighConfidence(h.Store) +} + +func writeOpenAIError(w http.ResponseWriter, status int, message string) { + shared.WriteOpenAIError(w, status, message) +} + +func writeOpenAIErrorWithCode(w http.ResponseWriter, status int, message, code string) { + shared.WriteOpenAIErrorWithCode(w, status, message, code) +} + +func openAIErrorType(status int) string { + return shared.OpenAIErrorType(status) +} + +func writeOpenAIInlineFileError(w http.ResponseWriter, err error) { + files.WriteInlineFileError(w, err) +} + +func mapHistorySplitError(err error) (int, string) { + return history.MapError(err) +} + +func requestTraceID(r *http.Request) string { + return shared.RequestTraceID(r) +} + +func cleanVisibleOutput(text string, stripReferenceMarkers bool) string { + return shared.CleanVisibleOutput(text, stripReferenceMarkers) +} + +func replaceCitationMarkersWithLinks(text string, links map[int]string) string { + return shared.ReplaceCitationMarkersWithLinks(text, links) +} + +func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) { + return shared.UpstreamEmptyOutputDetail(contentFilter, text, thinking) +} + +func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool { + return shared.WriteUpstreamEmptyOutputError(w, text, thinking, contentFilter) +} + +func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta, seenNames map[int]string) []toolstream.ToolCallDelta { + return shared.FilterIncrementalToolCallDeltasByAllowed(deltas, seenNames) +} diff --git a/internal/adapter/openai/response_store.go b/internal/httpapi/openai/responses/response_store.go similarity index 99% rename from internal/adapter/openai/response_store.go rename to internal/httpapi/openai/responses/response_store.go index 63ebbaa..8d7ec75 100644 --- a/internal/adapter/openai/response_store.go +++ b/internal/httpapi/openai/responses/response_store.go @@ -1,4 +1,4 @@ -package openai +package responses import ( "sync" diff --git a/internal/adapter/openai/responses_embeddings_test.go b/internal/httpapi/openai/responses/responses_embeddings_test.go similarity index 87% rename from internal/adapter/openai/responses_embeddings_test.go rename to internal/httpapi/openai/responses/responses_embeddings_test.go index a75cc3f..cfff04b 100644 --- a/internal/adapter/openai/responses_embeddings_test.go +++ b/internal/httpapi/openai/responses/responses_embeddings_test.go @@ -1,13 +1,16 @@ -package openai +package responses import ( "strings" "testing" "time" + + "ds2api/internal/httpapi/openai/embeddings" + "ds2api/internal/promptcompat" ) func TestNormalizeResponsesInputAsMessagesString(t *testing.T) { - msgs := normalizeResponsesInputAsMessages("hello") + msgs := promptcompat.NormalizeResponsesInputAsMessages("hello") if len(msgs) != 1 { t.Fatalf("expected one message, got %d", len(msgs)) } @@ -23,7 +26,7 @@ func TestResponsesMessagesFromRequestWithInstructions(t *testing.T) { "input": "ping", "instructions": "system text", } - msgs := responsesMessagesFromRequest(req) + msgs := promptcompat.ResponsesMessagesFromRequest(req) if len(msgs) != 2 { t.Fatalf("expected two messages, got %d", len(msgs)) } @@ -34,7 +37,7 @@ func TestResponsesMessagesFromRequestWithInstructions(t *testing.T) { } func TestNormalizeResponsesInputAsMessagesObjectRoleContentBlocks(t *testing.T) { - msgs := normalizeResponsesInputAsMessages(map[string]any{ + msgs := promptcompat.NormalizeResponsesInputAsMessages(map[string]any{ "role": "user", "content": []any{ map[string]any{"type": "input_text", "text": "line-1"}, @@ -48,13 +51,13 @@ func TestNormalizeResponsesInputAsMessagesObjectRoleContentBlocks(t *testing.T) if m["role"] != "user" { t.Fatalf("unexpected role: %#v", m) } - if strings.TrimSpace(normalizeOpenAIContentForPrompt(m["content"])) != "line-1\nline-2" { + if strings.TrimSpace(promptcompat.NormalizeOpenAIContentForPrompt(m["content"])) != "line-1\nline-2" { t.Fatalf("unexpected content: %#v", m["content"]) } } func TestNormalizeResponsesInputAsMessagesFunctionCallOutput(t *testing.T) { - msgs := normalizeResponsesInputAsMessages([]any{ + msgs := promptcompat.NormalizeResponsesInputAsMessages([]any{ map[string]any{ "type": "function_call_output", "call_id": "call_123", @@ -74,7 +77,7 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallOutput(t *testing.T) { } func TestNormalizeResponsesInputAsMessagesBackfillsToolResultNameFromCallID(t *testing.T) { - msgs := normalizeResponsesInputAsMessages([]any{ + msgs := promptcompat.NormalizeResponsesInputAsMessages([]any{ map[string]any{ "type": "function_call", "call_id": "call_999", @@ -100,7 +103,7 @@ func TestNormalizeResponsesInputAsMessagesBackfillsToolResultNameFromCallID(t *t } func TestNormalizeResponsesInputAsMessagesFunctionCallItem(t *testing.T) { - msgs := normalizeResponsesInputAsMessages([]any{ + msgs := promptcompat.NormalizeResponsesInputAsMessages([]any{ map[string]any{ "type": "function_call", "call_id": "call_456", @@ -136,7 +139,7 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallItem(t *testing.T) { } func TestNormalizeResponsesInputAsMessagesFunctionCallItemPreservesConcatenatedArguments(t *testing.T) { - msgs := normalizeResponsesInputAsMessages([]any{ + msgs := promptcompat.NormalizeResponsesInputAsMessages([]any{ map[string]any{ "type": "function_call", "call_id": "call_456", @@ -157,7 +160,7 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallItemPreservesConcatenatedA } func TestCollectOpenAIRefFileIDs(t *testing.T) { - got := collectOpenAIRefFileIDs(map[string]any{ + got := promptcompat.CollectOpenAIRefFileIDs(map[string]any{ "ref_file_ids": []any{"file-top", "file-dup"}, "attachments": []any{ map[string]any{"file_id": "file-attachment"}, @@ -184,15 +187,15 @@ func TestCollectOpenAIRefFileIDs(t *testing.T) { } func TestExtractEmbeddingInputs(t *testing.T) { - got := extractEmbeddingInputs([]any{"a", "b"}) + got := embeddings.ExtractEmbeddingInputs([]any{"a", "b"}) if len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Fatalf("unexpected inputs: %#v", got) } } func TestDeterministicEmbeddingStable(t *testing.T) { - a := deterministicEmbedding("hello") - b := deterministicEmbedding("hello") + a := embeddings.DeterministicEmbedding("hello") + b := embeddings.DeterministicEmbedding("hello") if len(a) != 64 || len(b) != 64 { t.Fatalf("expected 64 dims, got %d and %d", len(a), len(b)) } diff --git a/internal/adapter/openai/responses_handler.go b/internal/httpapi/openai/responses/responses_handler.go similarity index 91% rename from internal/adapter/openai/responses_handler.go rename to internal/httpapi/openai/responses/responses_handler.go index 3518722..8913322 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/httpapi/openai/responses/responses_handler.go @@ -1,4 +1,4 @@ -package openai +package responses import ( "ds2api/internal/toolcall" @@ -13,11 +13,11 @@ import ( "ds2api/internal/auth" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsprotocol "ds2api/internal/deepseek/protocol" openaifmt "ds2api/internal/format/openai" + "ds2api/internal/promptcompat" "ds2api/internal/sse" streamengine "ds2api/internal/stream" - "ds2api/internal/util" ) func (h *Handler) GetResponseByID(w http.ResponseWriter, r *http.Request) { @@ -80,7 +80,7 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { return } traceID := requestTraceID(r) - stdReq, err := normalizeOpenAIResponsesRequest(h.Store, req, traceID) + stdReq, err := promptcompat.NormalizeOpenAIResponsesRequest(h.Store, req, traceID) if err != nil { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return @@ -121,7 +121,7 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { h.handleResponsesNonStream(w, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID) } -func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) { +func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice promptcompat.ToolChoicePolicy, traceID string) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -152,7 +152,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res writeJSON(w, http.StatusOK, responseObj) } -func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) { +func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice promptcompat.ToolChoicePolicy, traceID string) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -200,9 +200,9 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, Body: resp.Body, ThinkingEnabled: thinkingEnabled, InitialType: initialType, - KeepAliveInterval: time.Duration(deepseek.KeepAliveTimeout) * time.Second, - IdleTimeout: time.Duration(deepseek.StreamIdleTimeout) * time.Second, - MaxKeepAliveNoInput: deepseek.MaxKeepaliveCount, + KeepAliveInterval: time.Duration(dsprotocol.KeepAliveTimeout) * time.Second, + IdleTimeout: time.Duration(dsprotocol.StreamIdleTimeout) * time.Second, + MaxKeepAliveNoInput: dsprotocol.MaxKeepaliveCount, }, streamengine.ConsumeHooks{ OnParsed: streamRuntime.onParsed, OnFinalize: func(_ streamengine.StopReason, _ error) { @@ -211,7 +211,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, }) } -func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed toolcall.ToolCallParseResult, channel string) { +func logResponsesToolPolicyRejection(traceID string, policy promptcompat.ToolChoicePolicy, parsed toolcall.ToolCallParseResult, channel string) { rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames) if !parsed.RejectedByPolicy || len(rejected) == 0 { return diff --git a/internal/adapter/openai/responses_route_test.go b/internal/httpapi/openai/responses/responses_route_test.go similarity index 99% rename from internal/adapter/openai/responses_route_test.go rename to internal/httpapi/openai/responses/responses_route_test.go index 574c6fa..1d6a847 100644 --- a/internal/adapter/openai/responses_route_test.go +++ b/internal/httpapi/openai/responses/responses_route_test.go @@ -1,4 +1,4 @@ -package openai +package responses import ( "bytes" diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/httpapi/openai/responses/responses_stream_runtime_core.go similarity index 94% rename from internal/adapter/openai/responses_stream_runtime_core.go rename to internal/httpapi/openai/responses/responses_stream_runtime_core.go index bba0b43..1bd81e6 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_core.go @@ -1,4 +1,4 @@ -package openai +package responses import ( "ds2api/internal/toolcall" @@ -7,9 +7,10 @@ import ( "ds2api/internal/config" openaifmt "ds2api/internal/format/openai" + "ds2api/internal/promptcompat" "ds2api/internal/sse" streamengine "ds2api/internal/stream" - "ds2api/internal/util" + "ds2api/internal/toolstream" ) type responsesStreamRuntime struct { @@ -22,7 +23,7 @@ type responsesStreamRuntime struct { finalPrompt string toolNames []string traceID string - toolChoice util.ToolChoicePolicy + toolChoice promptcompat.ToolChoicePolicy thinkingEnabled bool searchEnabled bool @@ -33,7 +34,7 @@ type responsesStreamRuntime struct { toolCallsEmitted bool toolCallsDoneEmitted bool - sieve toolStreamSieveState + sieve toolstream.State thinking strings.Builder text strings.Builder visibleText strings.Builder @@ -68,7 +69,7 @@ func newResponsesStreamRuntime( toolNames []string, bufferToolContent bool, emitEarlyToolDeltas bool, - toolChoice util.ToolChoicePolicy, + toolChoice promptcompat.ToolChoicePolicy, traceID string, persistResponse func(obj map[string]any), ) *responsesStreamRuntime { @@ -129,7 +130,7 @@ func (s *responsesStreamRuntime) finalize() { finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) if s.bufferToolContent { - s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true, true) + s.processToolStreamEvents(toolstream.Flush(&s.sieve, s.toolNames), true, true) } textParsed := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) @@ -221,7 +222,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa s.emitTextDelta(trimmed) continue } - s.processToolStreamEvents(processToolSieveChunk(&s.sieve, trimmed, s.toolNames), true, true) + s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, trimmed, s.toolNames), true, true) } return streamengine.ParsedDecision{ContentSeen: contentSeen} diff --git a/internal/adapter/openai/responses_stream_runtime_events.go b/internal/httpapi/openai/responses/responses_stream_runtime_events.go similarity index 92% rename from internal/adapter/openai/responses_stream_runtime_events.go rename to internal/httpapi/openai/responses/responses_stream_runtime_events.go index a010236..d497f04 100644 --- a/internal/adapter/openai/responses_stream_runtime_events.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_events.go @@ -1,9 +1,10 @@ -package openai +package responses import ( "encoding/json" openaifmt "ds2api/internal/format/openai" + "ds2api/internal/toolstream" ) func (s *responsesStreamRuntime) nextSequence() int { @@ -39,7 +40,7 @@ func (s *responsesStreamRuntime) sendDone() { } } -func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEvent, emitContent bool, resetAfterToolCalls bool) { +func (s *responsesStreamRuntime) processToolStreamEvents(events []toolstream.Event, emitContent bool, resetAfterToolCalls bool) { for _, evt := range events { if emitContent && evt.Content != "" { s.emitTextDelta(evt.Content) diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls.go b/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls.go similarity index 98% rename from internal/adapter/openai/responses_stream_runtime_toolcalls.go rename to internal/httpapi/openai/responses/responses_stream_runtime_toolcalls.go index 639a6d0..d3023c9 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls.go @@ -1,7 +1,8 @@ -package openai +package responses import ( "ds2api/internal/toolcall" + "ds2api/internal/toolstream" "encoding/json" "strings" @@ -201,7 +202,7 @@ func (s *responsesStreamRuntime) ensureFunctionItemAdded(callIndex int, name str s.toolCallsEmitted = true } -func (s *responsesStreamRuntime) emitFunctionCallDeltaEvents(deltas []toolCallDelta) { +func (s *responsesStreamRuntime) emitFunctionCallDeltaEvents(deltas []toolstream.ToolCallDelta) { for _, d := range deltas { s.ensureFunctionItemAdded(d.Index, d.Name) if strings.TrimSpace(d.Arguments) == "" { diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go b/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go similarity index 99% rename from internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go rename to internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go index 249ad22..4195c80 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go @@ -1,4 +1,4 @@ -package openai +package responses import ( "ds2api/internal/toolcall" diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/httpapi/openai/responses/responses_stream_test.go similarity index 92% rename from internal/adapter/openai/responses_stream_test.go rename to internal/httpapi/openai/responses/responses_stream_test.go index 44284e3..c19f311 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/httpapi/openai/responses/responses_stream_test.go @@ -1,4 +1,4 @@ -package openai +package responses import ( "bufio" @@ -9,7 +9,7 @@ import ( "strings" "testing" - "ds2api/internal/util" + "ds2api/internal/promptcompat" ) func TestHandleResponsesStreamDoesNotEmitReasoningTextCompatEvents(t *testing.T) { @@ -27,7 +27,7 @@ func TestHandleResponsesStreamDoesNotEmitReasoningTextCompatEvents(t *testing.T) Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if !strings.Contains(body, "event: response.reasoning.delta") { @@ -57,7 +57,7 @@ func TestHandleResponsesStreamEmitsOutputTextDoneBeforeContentPartDone(t *testin Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if !strings.Contains(body, "event: response.output_text.done") { t.Fatalf("expected response.output_text.done payload, body=%s", body) @@ -91,7 +91,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) { Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() deltaPayload, ok := extractSSEEventPayload(body, "response.output_text.delta") @@ -130,7 +130,7 @@ func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, []string{"read_file", "search"}, util.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, []string{"read_file", "search"}, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() doneEvents := extractSSEEventPayloads(body, "response.function_call_arguments.done") @@ -179,8 +179,8 @@ func TestHandleResponsesStreamRequiredToolChoiceFailure(t *testing.T) { Body: io.NopCloser(strings.NewReader(streamBody)), } - policy := util.ToolChoicePolicy{ - Mode: util.ToolChoiceRequired, + policy := promptcompat.ToolChoicePolicy{ + Mode: promptcompat.ToolChoiceRequired, Allowed: map[string]struct{}{"read_file": {}}, } h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, []string{"read_file"}, policy, "") @@ -213,7 +213,7 @@ func TestHandleResponsesStreamFailsWhenUpstreamHasOnlyThinking(t *testing.T) { Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if !strings.Contains(body, "event: response.failed") { @@ -242,8 +242,8 @@ func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) { `data: [DONE]` + "\n", )), } - policy := util.ToolChoicePolicy{ - Mode: util.ToolChoiceRequired, + policy := promptcompat.ToolChoicePolicy{ + Mode: promptcompat.ToolChoiceRequired, Allowed: map[string]struct{}{"read_file": {}}, } @@ -269,8 +269,8 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t `data: [DONE]` + "\n", )), } - policy := util.ToolChoicePolicy{ - Mode: util.ToolChoiceRequired, + policy := promptcompat.ToolChoicePolicy{ + Mode: promptcompat.ToolChoiceRequired, Allowed: map[string]struct{}{"read_file": {}}, } @@ -296,7 +296,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -318,7 +318,7 @@ func TestHandleResponsesNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWi )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for filtered empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -340,7 +340,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testin )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String()) } diff --git a/internal/httpapi/openai/responses/test_helpers_test.go b/internal/httpapi/openai/responses/test_helpers_test.go new file mode 100644 index 0000000..f239aa5 --- /dev/null +++ b/internal/httpapi/openai/responses/test_helpers_test.go @@ -0,0 +1,28 @@ +package responses + +import ( + "encoding/json" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/httpapi/openai/shared" +) + +func asString(v any) string { + return shared.AsString(v) +} + +func decodeJSONBody(t *testing.T, body string) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode json failed: %v, body=%s", err, body) + } + return out +} + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Post("/v1/responses", h.Responses) + r.Get("/v1/responses/{response_id}", h.GetResponseByID) +} diff --git a/internal/adapter/openai/citation_links.go b/internal/httpapi/openai/shared/citation_links.go similarity index 88% rename from internal/adapter/openai/citation_links.go rename to internal/httpapi/openai/shared/citation_links.go index 009d728..60d7408 100644 --- a/internal/adapter/openai/citation_links.go +++ b/internal/httpapi/openai/shared/citation_links.go @@ -1,4 +1,4 @@ -package openai +package shared import ( "fmt" @@ -9,7 +9,7 @@ import ( var citationMarkerPattern = regexp.MustCompile(`(?i)\[citation:\s*(\d+)\]`) -func replaceCitationMarkersWithLinks(text string, links map[int]string) string { +func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string { if strings.TrimSpace(text) == "" || len(links) == 0 { return text } diff --git a/internal/adapter/openai/deps.go b/internal/httpapi/openai/shared/deps.go similarity index 58% rename from internal/adapter/openai/deps.go rename to internal/httpapi/openai/shared/deps.go index 50118ff..3db5b37 100644 --- a/internal/adapter/openai/deps.go +++ b/internal/httpapi/openai/shared/deps.go @@ -1,12 +1,21 @@ -package openai +package shared import ( "context" "net/http" "ds2api/internal/auth" + "ds2api/internal/chathistory" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/util" +) + +const ( + // UploadMaxSize limits total multipart request body size (100 MiB). + UploadMaxSize = 100 << 20 + // GeneralMaxSize limits total JSON request body size (100 MiB). + GeneralMaxSize = 100 << 20 ) type AuthResolver interface { @@ -18,9 +27,9 @@ type AuthResolver interface { type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) - UploadFile(ctx context.Context, a *auth.RequestAuth, req deepseek.UploadFileRequest, maxAttempts int) (*deepseek.UploadFileResult, error) + UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) - DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) + DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*dsclient.DeleteSessionResult, error) DeleteAllSessionsForToken(ctx context.Context, token string) error } @@ -38,6 +47,22 @@ type ConfigReader interface { HistorySplitTriggerAfterTurns() int } +type Deps struct { + Store ConfigReader + Auth AuthResolver + DS DeepSeekCaller + ChatHistory *chathistory.Store +} + +func CompatStripReferenceMarkers(store ConfigReader) bool { + if store == nil { + return true + } + return store.CompatStripReferenceMarkers() +} + +var WriteJSON = util.WriteJSON + var _ AuthResolver = (*auth.Resolver)(nil) -var _ DeepSeekCaller = (*deepseek.Client)(nil) +var _ DeepSeekCaller = (*dsclient.Client)(nil) var _ ConfigReader = (*config.Store)(nil) diff --git a/internal/adapter/openai/handler_errors.go b/internal/httpapi/openai/shared/handler_errors.go similarity index 73% rename from internal/adapter/openai/handler_errors.go rename to internal/httpapi/openai/shared/handler_errors.go index 2e60d73..52f399e 100644 --- a/internal/adapter/openai/handler_errors.go +++ b/internal/httpapi/openai/shared/handler_errors.go @@ -1,26 +1,26 @@ -package openai +package shared import "net/http" -func writeOpenAIError(w http.ResponseWriter, status int, message string) { - writeOpenAIErrorWithCode(w, status, message, "") +func WriteOpenAIError(w http.ResponseWriter, status int, message string) { + WriteOpenAIErrorWithCode(w, status, message, "") } -func writeOpenAIErrorWithCode(w http.ResponseWriter, status int, message, code string) { +func WriteOpenAIErrorWithCode(w http.ResponseWriter, status int, message, code string) { if code == "" { - code = openAIErrorCode(status) + code = OpenAIErrorCode(status) } - writeJSON(w, status, map[string]any{ + WriteJSON(w, status, map[string]any{ "error": map[string]any{ "message": message, - "type": openAIErrorType(status), + "type": OpenAIErrorType(status), "code": code, "param": nil, }, }) } -func openAIErrorType(status int) string { +func OpenAIErrorType(status int) string { switch status { case http.StatusBadRequest: return "invalid_request_error" @@ -40,7 +40,7 @@ func openAIErrorType(status int) string { } } -func openAIErrorCode(status int) string { +func OpenAIErrorCode(status int) string { switch status { case http.StatusBadRequest: return "invalid_request" diff --git a/internal/httpapi/openai/shared/handler_toolcall_format.go b/internal/httpapi/openai/shared/handler_toolcall_format.go new file mode 100644 index 0000000..15cd7ea --- /dev/null +++ b/internal/httpapi/openai/shared/handler_toolcall_format.go @@ -0,0 +1,101 @@ +package shared + +import ( + "ds2api/internal/toolcall" + "encoding/json" + "strings" + + "github.com/google/uuid" + + "ds2api/internal/toolstream" +) + +func FormatIncrementalStreamToolCallDeltas(deltas []toolstream.ToolCallDelta, ids map[int]string) []map[string]any { + if len(deltas) == 0 { + return nil + } + out := make([]map[string]any, 0, len(deltas)) + for _, d := range deltas { + if d.Name == "" && d.Arguments == "" { + continue + } + callID, ok := ids[d.Index] + if !ok || callID == "" { + callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "") + ids[d.Index] = callID + } + item := map[string]any{ + "index": d.Index, + "id": callID, + "type": "function", + } + fn := map[string]any{} + if d.Name != "" { + fn["name"] = d.Name + } + if d.Arguments != "" { + fn["arguments"] = d.Arguments + } + if len(fn) > 0 { + item["function"] = fn + } + out = append(out, item) + } + return out +} + +func FilterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta, seenNames map[int]string) []toolstream.ToolCallDelta { + if len(deltas) == 0 { + return nil + } + out := make([]toolstream.ToolCallDelta, 0, len(deltas)) + for _, d := range deltas { + if d.Name != "" { + if seenNames != nil { + seenNames[d.Index] = d.Name + } + out = append(out, d) + continue + } + if seenNames == nil { + out = append(out, d) + continue + } + name := strings.TrimSpace(seenNames[d.Index]) + if name == "" { + continue + } + out = append(out, d) + } + return out +} + +func FormatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any { + if len(calls) == 0 { + return nil + } + out := make([]map[string]any, 0, len(calls)) + for i, c := range calls { + callID := "" + if ids != nil { + callID = strings.TrimSpace(ids[i]) + } + if callID == "" { + callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if ids != nil { + ids[i] = callID + } + } + args, _ := json.Marshal(c.Input) + out = append(out, map[string]any{ + "index": i, + "id": callID, + "type": "function", + "function": map[string]any{ + "name": c.Name, + "arguments": string(args), + }, + }) + } + return out +} diff --git a/internal/httpapi/openai/shared/handler_toolcall_policy.go b/internal/httpapi/openai/shared/handler_toolcall_policy.go new file mode 100644 index 0000000..181a627 --- /dev/null +++ b/internal/httpapi/openai/shared/handler_toolcall_policy.go @@ -0,0 +1,9 @@ +package shared + +func ToolcallFeatureMatchEnabled(_ ConfigReader) bool { + return true +} + +func ToolcallEarlyEmitHighConfidence(_ ConfigReader) bool { + return true +} diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/httpapi/openai/shared/leaked_output_sanitize.go similarity index 99% rename from internal/adapter/openai/leaked_output_sanitize.go rename to internal/httpapi/openai/shared/leaked_output_sanitize.go index 70f6eeb..0b0b897 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/httpapi/openai/shared/leaked_output_sanitize.go @@ -1,4 +1,4 @@ -package openai +package shared import ( "regexp" diff --git a/internal/httpapi/openai/shared/models.go b/internal/httpapi/openai/shared/models.go new file mode 100644 index 0000000..81ba607 --- /dev/null +++ b/internal/httpapi/openai/shared/models.go @@ -0,0 +1,28 @@ +package shared + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/config" +) + +type ModelsHandler struct { + Store ConfigReader +} + +func (h *ModelsHandler) ListModels(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, config.OpenAIModelsResponse()) +} + +func (h *ModelsHandler) GetModel(w http.ResponseWriter, r *http.Request) { + modelID := strings.TrimSpace(chi.URLParam(r, "model_id")) + model, ok := config.OpenAIModelByID(h.Store, modelID) + if !ok { + WriteOpenAIError(w, http.StatusNotFound, "Model not found.") + return + } + WriteJSON(w, http.StatusOK, model) +} diff --git a/internal/adapter/openai/output_clean.go b/internal/httpapi/openai/shared/output_clean.go similarity index 72% rename from internal/adapter/openai/output_clean.go rename to internal/httpapi/openai/shared/output_clean.go index b749876..a890565 100644 --- a/internal/adapter/openai/output_clean.go +++ b/internal/httpapi/openai/shared/output_clean.go @@ -1,8 +1,8 @@ -package openai +package shared import textclean "ds2api/internal/textclean" -func cleanVisibleOutput(text string, stripReferenceMarkers bool) string { +func CleanVisibleOutput(text string, stripReferenceMarkers bool) string { if text == "" { return text } diff --git a/internal/httpapi/openai/shared/string_helpers.go b/internal/httpapi/openai/shared/string_helpers.go new file mode 100644 index 0000000..2c334a9 --- /dev/null +++ b/internal/httpapi/openai/shared/string_helpers.go @@ -0,0 +1,8 @@ +package shared + +func AsString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/internal/adapter/openai/trace.go b/internal/httpapi/openai/shared/trace.go similarity index 84% rename from internal/adapter/openai/trace.go rename to internal/httpapi/openai/shared/trace.go index 8ea58f0..06dd9f9 100644 --- a/internal/adapter/openai/trace.go +++ b/internal/httpapi/openai/shared/trace.go @@ -1,4 +1,4 @@ -package openai +package shared import ( "net/http" @@ -7,7 +7,7 @@ import ( "github.com/go-chi/chi/v5/middleware" ) -func requestTraceID(r *http.Request) string { +func RequestTraceID(r *http.Request) string { if r == nil { return "" } diff --git a/internal/adapter/openai/upstream_empty.go b/internal/httpapi/openai/shared/upstream_empty.go similarity index 62% rename from internal/adapter/openai/upstream_empty.go rename to internal/httpapi/openai/shared/upstream_empty.go index 23e82e6..a52c4b3 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/httpapi/openai/shared/upstream_empty.go @@ -1,12 +1,12 @@ -package openai +package shared import "net/http" -func shouldWriteUpstreamEmptyOutputError(text string) bool { +func ShouldWriteUpstreamEmptyOutputError(text string) bool { return text == "" } -func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) { +func UpstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) { _ = text if contentFilter { return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter" @@ -17,11 +17,11 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned empty output.", "upstream_empty_output" } -func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool { - if !shouldWriteUpstreamEmptyOutputError(text) { +func WriteUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool { + if !ShouldWriteUpstreamEmptyOutputError(text) { return false } - status, message, code := upstreamEmptyOutputDetail(contentFilter, text, thinking) - writeOpenAIErrorWithCode(w, status, message, code) + status, message, code := UpstreamEmptyOutputDetail(contentFilter, text, thinking) + WriteOpenAIErrorWithCode(w, status, message, code) return true } diff --git a/internal/adapter/openai/stream_status_test.go b/internal/httpapi/openai/stream_status_test.go similarity index 94% rename from internal/adapter/openai/stream_status_test.go rename to internal/httpapi/openai/stream_status_test.go index 49ce12e..3c2827f 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/httpapi/openai/stream_status_test.go @@ -13,7 +13,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" "ds2api/internal/auth" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" ) type streamStatusAuthStub struct{} @@ -50,16 +50,16 @@ func (m streamStatusDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int return "pow", nil } -func (m streamStatusDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { - return &deepseek.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil +func (m streamStatusDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + return &dsclient.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil } func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { return m.resp, nil } -func (m streamStatusDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { - return &deepseek.DeleteSessionResult{Success: true}, nil +func (m streamStatusDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*dsclient.DeleteSessionResult, error) { + return &dsclient.DeleteSessionResult{Success: true}, nil } func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { @@ -90,14 +90,14 @@ func captureStatusMiddleware(statuses *[]int) func(http.Handler) http.Handler { func TestChatCompletionsStreamStatusCapturedAs200(t *testing.T) { statuses := make([]int, 0, 1) - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello"}`, "data: [DONE]")}, } r := chi.NewRouter() r.Use(captureStatusMiddleware(&statuses)) - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":true}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) @@ -119,14 +119,14 @@ func TestChatCompletionsStreamStatusCapturedAs200(t *testing.T) { func TestResponsesStreamStatusCapturedAs200(t *testing.T) { statuses := make([]int, 0, 1) - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello"}`, "data: [DONE]")}, } r := chi.NewRouter() r.Use(captureStatusMiddleware(&statuses)) - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","input":"hi","stream":true}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) @@ -148,7 +148,7 @@ func TestResponsesStreamStatusCapturedAs200(t *testing.T) { func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T) { statuses := make([]int, 0, 1) - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse( @@ -159,7 +159,7 @@ func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T } r := chi.NewRouter() r.Use(captureStatusMiddleware(&statuses)) - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":true}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) @@ -198,14 +198,14 @@ func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T func TestChatCompletionsStreamEmitsFailureFrameWhenUpstreamOutputEmpty(t *testing.T) { statuses := make([]int, 0, 1) - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse("data: [DONE]")}, } r := chi.NewRouter() r.Use(captureStatusMiddleware(&statuses)) - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":true}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) @@ -241,7 +241,7 @@ func TestChatCompletionsStreamEmitsFailureFrameWhenUpstreamOutputEmpty(t *testin func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) { statuses := make([]int, 0, 1) - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse( @@ -251,7 +251,7 @@ func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) { } r := chi.NewRouter() r.Use(captureStatusMiddleware(&statuses)) - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","input":"hi","stream":true}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) @@ -289,7 +289,7 @@ func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) { func TestResponsesNonStreamUsageIgnoresPromptAndOutputTokenUsage(t *testing.T) { statuses := make([]int, 0, 1) - h := &Handler{ + h := &openAITestSurface{ Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse( @@ -299,7 +299,7 @@ func TestResponsesNonStreamUsageIgnoresPromptAndOutputTokenUsage(t *testing.T) { } r := chi.NewRouter() r.Use(captureStatusMiddleware(&statuses)) - RegisterRoutes(r, h) + registerOpenAITestRoutes(r, h) reqBody := `{"model":"deepseek-v4-flash","input":"hi","stream":false}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) diff --git a/internal/httpapi/openai/test_bridge_test.go b/internal/httpapi/openai/test_bridge_test.go new file mode 100644 index 0000000..91549ce --- /dev/null +++ b/internal/httpapi/openai/test_bridge_test.go @@ -0,0 +1,157 @@ +package openai + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/auth" + "ds2api/internal/chathistory" + "ds2api/internal/httpapi/openai/chat" + "ds2api/internal/httpapi/openai/embeddings" + "ds2api/internal/httpapi/openai/files" + "ds2api/internal/httpapi/openai/history" + "ds2api/internal/httpapi/openai/responses" + "ds2api/internal/httpapi/openai/shared" + "ds2api/internal/promptcompat" +) + +type openAITestSurface struct { + Store shared.ConfigReader + Auth shared.AuthResolver + DS shared.DeepSeekCaller + ChatHistory *chathistory.Store + + chat *chat.Handler + responses *responses.Handler + files *files.Handler + embeddings *embeddings.Handler + models *shared.ModelsHandler +} + +func (h *openAITestSurface) deps() shared.Deps { + if h == nil { + return shared.Deps{} + } + return shared.Deps{Store: h.Store, Auth: h.Auth, DS: h.DS, ChatHistory: h.ChatHistory} +} + +func (h *openAITestSurface) chatHandler() *chat.Handler { + if h.chat == nil { + deps := h.deps() + h.chat = &chat.Handler{Store: deps.Store, Auth: deps.Auth, DS: deps.DS, ChatHistory: deps.ChatHistory} + } + return h.chat +} + +func (h *openAITestSurface) responsesHandler() *responses.Handler { + if h.responses == nil { + deps := h.deps() + h.responses = &responses.Handler{Store: deps.Store, Auth: deps.Auth, DS: deps.DS, ChatHistory: deps.ChatHistory} + } + return h.responses +} + +func (h *openAITestSurface) filesHandler() *files.Handler { + if h.files == nil { + deps := h.deps() + h.files = &files.Handler{Store: deps.Store, Auth: deps.Auth, DS: deps.DS, ChatHistory: deps.ChatHistory} + } + return h.files +} + +func (h *openAITestSurface) embeddingsHandler() *embeddings.Handler { + if h.embeddings == nil { + deps := h.deps() + h.embeddings = &embeddings.Handler{Store: deps.Store, Auth: deps.Auth, DS: deps.DS, ChatHistory: deps.ChatHistory} + } + return h.embeddings +} + +func (h *openAITestSurface) modelsHandler() *shared.ModelsHandler { + if h.models == nil { + h.models = &shared.ModelsHandler{Store: h.Store} + } + return h.models +} + +func (h *openAITestSurface) ChatCompletions(w http.ResponseWriter, r *http.Request) { + h.chatHandler().ChatCompletions(w, r) +} + +func (h *openAITestSurface) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) { + return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq) +} + +func (h *openAITestSurface) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error { + return h.filesHandler().PreprocessInlineFileInputs(ctx, a, req) +} + +func registerOpenAITestRoutes(r chi.Router, h *openAITestSurface) { + r.Get("/v1/models", h.modelsHandler().ListModels) + r.Get("/v1/models/{model_id}", h.modelsHandler().GetModel) + r.Post("/v1/chat/completions", h.chatHandler().ChatCompletions) + r.Post("/v1/responses", h.responsesHandler().Responses) + r.Get("/v1/responses/{response_id}", h.responsesHandler().GetResponseByID) + r.Post("/v1/files", h.filesHandler().UploadFile) + r.Post("/v1/embeddings", h.embeddingsHandler().Embeddings) +} + +func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { + return history.SplitOpenAIHistoryMessages(messages, triggerAfterTurns) +} + +func buildOpenAIHistoryTranscript(messages []any) string { + return promptcompat.BuildOpenAIHistoryTranscript(messages) +} + +func writeOpenAIError(w http.ResponseWriter, status int, message string) { + shared.WriteOpenAIError(w, status, message) +} + +func replaceCitationMarkersWithLinks(text string, links map[int]string) string { + return shared.ReplaceCitationMarkersWithLinks(text, links) +} + +func sanitizeLeakedOutput(text string) string { + return shared.CleanVisibleOutput(text, false) +} + +func requestTraceID(r *http.Request) string { + return shared.RequestTraceID(r) +} + +func asString(v any) string { + return shared.AsString(v) +} + +func parseSSEDataFrames(t *testing.T, body string) ([]map[string]any, bool) { + t.Helper() + lines := strings.Split(body, "\n") + frames := make([]map[string]any, 0, len(lines)) + done := false + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "" { + continue + } + if payload == "[DONE]" { + done = true + continue + } + var frame map[string]any + if err := json.Unmarshal([]byte(payload), &frame); err != nil { + t.Fatalf("decode sse frame failed: %v, payload=%s", err, payload) + } + frames = append(frames, frame) + } + return frames, done +} diff --git a/internal/adapter/openai/trace_test.go b/internal/httpapi/openai/trace_test.go similarity index 100% rename from internal/adapter/openai/trace_test.go rename to internal/httpapi/openai/trace_test.go diff --git a/internal/adapter/openai/file_refs.go b/internal/promptcompat/file_refs.go similarity index 96% rename from internal/adapter/openai/file_refs.go rename to internal/promptcompat/file_refs.go index d1cef34..86006b6 100644 --- a/internal/adapter/openai/file_refs.go +++ b/internal/promptcompat/file_refs.go @@ -1,8 +1,8 @@ -package openai +package promptcompat import "strings" -func collectOpenAIRefFileIDs(req map[string]any) []string { +func CollectOpenAIRefFileIDs(req map[string]any) []string { if len(req) == 0 { return nil } diff --git a/internal/promptcompat/history_transcript.go b/internal/promptcompat/history_transcript.go new file mode 100644 index 0000000..cd9a238 --- /dev/null +++ b/internal/promptcompat/history_transcript.go @@ -0,0 +1,19 @@ +package promptcompat + +import ( + "fmt" + "strings" + + "ds2api/internal/prompt" +) + +const historySplitInjectedFilename = "IGNORE" + +func BuildOpenAIHistoryTranscript(messages []any) string { + normalized := NormalizeOpenAIMessagesForPrompt(messages, "") + transcript := strings.TrimSpace(prompt.MessagesPrepare(normalized)) + if transcript == "" { + return "" + } + return fmt.Sprintf("[file content end]\n\n%s\n\n[file name]: %s\n[file content begin]\n", transcript, historySplitInjectedFilename) +} diff --git a/internal/adapter/openai/message_normalize.go b/internal/promptcompat/message_normalize.go similarity index 90% rename from internal/adapter/openai/message_normalize.go rename to internal/promptcompat/message_normalize.go index 906c377..2e87259 100644 --- a/internal/adapter/openai/message_normalize.go +++ b/internal/promptcompat/message_normalize.go @@ -1,4 +1,4 @@ -package openai +package promptcompat import ( "strings" @@ -8,7 +8,7 @@ import ( const assistantReasoningLabel = "reasoning_content" -func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any { +func NormalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any { _ = traceID out := make([]map[string]any, 0, len(raw)) for _, item := range raw { @@ -36,10 +36,10 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an case "user", "system", "developer": out = append(out, map[string]any{ "role": normalizeOpenAIRoleForPrompt(role), - "content": normalizeOpenAIContentForPrompt(msg["content"]), + "content": NormalizeOpenAIContentForPrompt(msg["content"]), }) default: - content := normalizeOpenAIContentForPrompt(msg["content"]) + content := NormalizeOpenAIContentForPrompt(msg["content"]) if content == "" { continue } @@ -56,7 +56,7 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an } func buildAssistantContentForPrompt(msg map[string]any) string { - content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + content := strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"])) reasoning := strings.TrimSpace(normalizeOpenAIReasoningContentForPrompt(msg["reasoning_content"])) if reasoning == "" { reasoning = strings.TrimSpace(extractOpenAIReasoningContentFromMessage(msg["content"])) @@ -149,14 +149,14 @@ func formatPromptLabeledBlock(label, text string) string { } func buildToolContentForPrompt(msg map[string]any) string { - content := normalizeOpenAIContentForPrompt(msg["content"]) + content := NormalizeOpenAIContentForPrompt(msg["content"]) if strings.TrimSpace(content) == "" { return "null" } return content } -func normalizeOpenAIContentForPrompt(v any) string { +func NormalizeOpenAIContentForPrompt(v any) string { return prompt.NormalizeContent(v) } diff --git a/internal/adapter/openai/message_normalize_test.go b/internal/promptcompat/message_normalize_test.go similarity index 92% rename from internal/adapter/openai/message_normalize_test.go rename to internal/promptcompat/message_normalize_test.go index 1354a9e..36079d0 100644 --- a/internal/adapter/openai/message_normalize_test.go +++ b/internal/promptcompat/message_normalize_test.go @@ -1,4 +1,4 @@ -package openai +package promptcompat import ( "strings" @@ -33,7 +33,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 4 { t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized)) } @@ -67,7 +67,7 @@ func TestNormalizeOpenAIMessagesForPrompt_ToolObjectContentPreserved(t *testing. }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") got, _ := normalized[0]["content"].(string) if !strings.Contains(got, `"temp":18`) || !strings.Contains(got, `"condition":"sunny"`) { t.Fatalf("expected serialized object in tool content, got %q", got) @@ -88,7 +88,7 @@ func TestNormalizeOpenAIMessagesForPrompt_ToolArrayBlocksJoined(t *testing.T) { }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") got, _ := normalized[0]["content"].(string) if !strings.Contains(got, `line-1`) || !strings.Contains(got, `line-2`) { t.Fatalf("expected tool content blocks preserved, got %q", got) @@ -107,7 +107,7 @@ func TestNormalizeOpenAIMessagesForPrompt_FunctionRoleCompatible(t *testing.T) { }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 1 { t.Fatalf("expected one normalized message, got %d", len(normalized)) } @@ -134,7 +134,7 @@ func TestNormalizeOpenAIMessagesForPrompt_EmptyToolContentPreservedAsNull(t *tes }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 2 { t.Fatalf("expected tool completion turn to be preserved, got %#v", normalized) } @@ -172,7 +172,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 1 { t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized) } @@ -201,7 +201,7 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t * }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 1 { t.Fatalf("expected assistant tool_call-only content preserved, got %#v", normalized) } @@ -227,7 +227,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 0 { t.Fatalf("expected assistant tool_calls without text to be dropped when name is missing, got %#v", normalized) } @@ -250,7 +250,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 1 { t.Fatalf("expected nil-content assistant tool_call-only message preserved, got %#v", normalized) } @@ -268,7 +268,7 @@ func TestNormalizeOpenAIMessagesForPrompt_DeveloperRoleMapsToSystem(t *testing.T map[string]any{"role": "developer", "content": "必须先走工具调用"}, map[string]any{"role": "user", "content": "你好"}, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 2 { t.Fatalf("expected 2 normalized messages, got %d", len(normalized)) } @@ -287,7 +287,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantArrayContentFallbackWhenTextE }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 1 { t.Fatalf("expected one normalized message, got %d", len(normalized)) } @@ -306,7 +306,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantReasoningContentPreserved(t * }, } - normalized := normalizeOpenAIMessagesForPrompt(raw, "") + normalized := NormalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 1 { t.Fatalf("expected one normalized assistant message, got %#v", normalized) } diff --git a/internal/promptcompat/prompt_build.go b/internal/promptcompat/prompt_build.go new file mode 100644 index 0000000..9d2ee4e --- /dev/null +++ b/internal/promptcompat/prompt_build.go @@ -0,0 +1,25 @@ +package promptcompat + +import ( + "ds2api/internal/prompt" +) + +func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) { + return BuildOpenAIPrompt(messagesRaw, toolsRaw, traceID, DefaultToolChoicePolicy(), thinkingEnabled) +} + +func BuildOpenAIPrompt(messagesRaw []any, toolsRaw any, traceID string, toolPolicy ToolChoicePolicy, thinkingEnabled bool) (string, []string) { + messages := NormalizeOpenAIMessagesForPrompt(messagesRaw, traceID) + toolNames := []string{} + if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 { + messages, toolNames = injectToolPrompt(messages, tools, toolPolicy) + } + return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames +} + +// BuildOpenAIPromptForAdapter exposes the OpenAI-compatible prompt building flow so +// other protocol adapters (for example Gemini) can reuse the same tool/history +// normalization logic and remain behavior-compatible with chat/completions. +func BuildOpenAIPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) { + return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID, thinkingEnabled) +} diff --git a/internal/adapter/openai/prompt_build_test.go b/internal/promptcompat/prompt_build_test.go similarity index 99% rename from internal/adapter/openai/prompt_build_test.go rename to internal/promptcompat/prompt_build_test.go index cf345e8..82101d3 100644 --- a/internal/adapter/openai/prompt_build_test.go +++ b/internal/promptcompat/prompt_build_test.go @@ -1,4 +1,4 @@ -package openai +package promptcompat import ( "strings" diff --git a/internal/adapter/openai/standard_request.go b/internal/promptcompat/request_normalize.go similarity index 73% rename from internal/adapter/openai/standard_request.go rename to internal/promptcompat/request_normalize.go index 08ba8ad..6d3f12d 100644 --- a/internal/adapter/openai/standard_request.go +++ b/internal/promptcompat/request_normalize.go @@ -1,4 +1,4 @@ -package openai +package promptcompat import ( "fmt" @@ -8,15 +8,20 @@ import ( "ds2api/internal/util" ) -func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID string) (util.StandardRequest, error) { +type ConfigReader interface { + ModelAliases() map[string]string + CompatWideInputStrictOutput() bool +} + +func NormalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID string) (StandardRequest, error) { model, _ := req["model"].(string) messagesRaw, _ := req["messages"].([]any) if strings.TrimSpace(model) == "" || len(messagesRaw) == 0 { - return util.StandardRequest{}, fmt.Errorf("request must include 'model' and 'messages'") + return StandardRequest{}, fmt.Errorf("request must include 'model' and 'messages'") } resolvedModel, ok := config.ResolveModel(store, model) if !ok { - return util.StandardRequest{}, fmt.Errorf("model %q is not available", model) + return StandardRequest{}, fmt.Errorf("model %q is not available", model) } defaultThinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel) thinkingEnabled := util.ResolveThinkingEnabled(req, defaultThinkingEnabled) @@ -24,13 +29,13 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID if responseModel == "" { responseModel = resolvedModel } - toolPolicy := util.DefaultToolChoicePolicy() - finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled) + toolPolicy := DefaultToolChoicePolicy() + finalPrompt, toolNames := BuildOpenAIPrompt(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled) toolNames = ensureToolDetectionEnabled(toolNames, req["tools"]) passThrough := collectOpenAIChatPassThrough(req) - refFileIDs := collectOpenAIRefFileIDs(req) + refFileIDs := CollectOpenAIRefFileIDs(req) - return util.StandardRequest{ + return StandardRequest{ Surface: "openai_chat", RequestedModel: strings.TrimSpace(model), ResolvedModel: resolvedModel, @@ -48,15 +53,15 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID }, nil } -func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, traceID string) (util.StandardRequest, error) { +func NormalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, traceID string) (StandardRequest, error) { model, _ := req["model"].(string) model = strings.TrimSpace(model) if model == "" { - return util.StandardRequest{}, fmt.Errorf("request must include 'model'") + return StandardRequest{}, fmt.Errorf("request must include 'model'") } resolvedModel, ok := config.ResolveModel(store, model) if !ok { - return util.StandardRequest{}, fmt.Errorf("model %q is not available", model) + return StandardRequest{}, fmt.Errorf("model %q is not available", model) } defaultThinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel) thinkingEnabled := util.ResolveThinkingEnabled(req, defaultThinkingEnabled) @@ -68,26 +73,26 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra } var messagesRaw []any if allowWideInput { - messagesRaw = responsesMessagesFromRequest(req) + messagesRaw = ResponsesMessagesFromRequest(req) } else if msgs, ok := req["messages"].([]any); ok && len(msgs) > 0 { messagesRaw = msgs } if len(messagesRaw) == 0 { - return util.StandardRequest{}, fmt.Errorf("request must include 'input' or 'messages'") + return StandardRequest{}, fmt.Errorf("request must include 'input' or 'messages'") } toolPolicy, err := parseToolChoicePolicy(req["tool_choice"], req["tools"]) if err != nil { - return util.StandardRequest{}, err + return StandardRequest{}, err } - finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled) + finalPrompt, toolNames := BuildOpenAIPrompt(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled) toolNames = ensureToolDetectionEnabled(toolNames, req["tools"]) if !toolPolicy.IsNone() { toolPolicy.Allowed = namesToSet(toolNames) } passThrough := collectOpenAIChatPassThrough(req) - refFileIDs := collectOpenAIRefFileIDs(req) + refFileIDs := CollectOpenAIRefFileIDs(req) - return util.StandardRequest{ + return StandardRequest{ Surface: "openai_responses", RequestedModel: model, ResolvedModel: resolvedModel, @@ -137,8 +142,8 @@ func collectOpenAIChatPassThrough(req map[string]any) map[string]any { return out } -func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePolicy, error) { - policy := util.DefaultToolChoicePolicy() +func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (ToolChoicePolicy, error) { + policy := DefaultToolChoicePolicy() declaredNames := extractDeclaredToolNames(toolsRaw) declaredSet := namesToSet(declaredNames) if len(declaredNames) > 0 { @@ -153,25 +158,25 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli case string: switch strings.ToLower(strings.TrimSpace(v)) { case "", "auto": - policy.Mode = util.ToolChoiceAuto + policy.Mode = ToolChoiceAuto case "none": - policy.Mode = util.ToolChoiceNone + policy.Mode = ToolChoiceNone policy.Allowed = nil case "required": - policy.Mode = util.ToolChoiceRequired + policy.Mode = ToolChoiceRequired default: - return util.ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice: %q", v) + return ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice: %q", v) } case map[string]any: allowedOverride, hasAllowedOverride, err := parseAllowedToolNames(v["allowed_tools"]) if err != nil { - return util.ToolChoicePolicy{}, err + return ToolChoicePolicy{}, err } if hasAllowedOverride { filtered := make([]string, 0, len(allowedOverride)) for _, name := range allowedOverride { if _, ok := declaredSet[name]; !ok { - return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice.allowed_tools contains undeclared tool %q", name) + return ToolChoicePolicy{}, fmt.Errorf("tool_choice.allowed_tools contains undeclared tool %q", name) } filtered = append(filtered, name) } @@ -184,46 +189,46 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli if hasFunctionSelector(v) { name, err := parseForcedToolName(v) if err != nil { - return util.ToolChoicePolicy{}, err + return ToolChoicePolicy{}, err } - policy.Mode = util.ToolChoiceForced + policy.Mode = ToolChoiceForced policy.ForcedName = name policy.Allowed = namesToSet([]string{name}) } else { - policy.Mode = util.ToolChoiceAuto + policy.Mode = ToolChoiceAuto } case "none": - policy.Mode = util.ToolChoiceNone + policy.Mode = ToolChoiceNone policy.Allowed = nil case "required": - policy.Mode = util.ToolChoiceRequired + policy.Mode = ToolChoiceRequired case "function": name, err := parseForcedToolName(v) if err != nil { - return util.ToolChoicePolicy{}, err + return ToolChoicePolicy{}, err } - policy.Mode = util.ToolChoiceForced + policy.Mode = ToolChoiceForced policy.ForcedName = name policy.Allowed = namesToSet([]string{name}) default: - return util.ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice.type: %q", typ) + return ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice.type: %q", typ) } default: - return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice must be a string or object") + return ToolChoicePolicy{}, fmt.Errorf("tool_choice must be a string or object") } - if policy.Mode == util.ToolChoiceRequired || policy.Mode == util.ToolChoiceForced { + if policy.Mode == ToolChoiceRequired || policy.Mode == ToolChoiceForced { if len(declaredNames) == 0 { - return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice=%s requires non-empty tools", policy.Mode) + return ToolChoicePolicy{}, fmt.Errorf("tool_choice=%s requires non-empty tools", policy.Mode) } } - if policy.Mode == util.ToolChoiceForced { + if policy.Mode == ToolChoiceForced { if _, ok := declaredSet[policy.ForcedName]; !ok { - return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice forced function %q is not declared in tools", policy.ForcedName) + return ToolChoicePolicy{}, fmt.Errorf("tool_choice forced function %q is not declared in tools", policy.ForcedName) } } - if len(policy.Allowed) == 0 && (policy.Mode == util.ToolChoiceRequired || policy.Mode == util.ToolChoiceForced) { - return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice policy resolved to empty allowed tool set") + if len(policy.Allowed) == 0 && (policy.Mode == ToolChoiceRequired || policy.Mode == ToolChoiceForced) { + return ToolChoicePolicy{}, fmt.Errorf("tool_choice policy resolved to empty allowed tool set") } return policy, nil } diff --git a/internal/adapter/openai/responses_input_items.go b/internal/promptcompat/responses_input_items.go similarity index 97% rename from internal/adapter/openai/responses_input_items.go rename to internal/promptcompat/responses_input_items.go index d405d44..92139d3 100644 --- a/internal/adapter/openai/responses_input_items.go +++ b/internal/promptcompat/responses_input_items.go @@ -1,4 +1,4 @@ -package openai +package promptcompat import ( "fmt" @@ -167,7 +167,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str } } if content, ok := m["content"]; ok { - if strings.TrimSpace(normalizeOpenAIContentForPrompt(content)) != "" { + if strings.TrimSpace(NormalizeOpenAIContentForPrompt(content)) != "" { return map[string]any{ "role": "user", "content": content, @@ -215,7 +215,7 @@ func normalizeResponsesFallbackPart(m map[string]any) string { return txt } if content, ok := m["content"]; ok { - if normalized := strings.TrimSpace(normalizeOpenAIContentForPrompt(content)); normalized != "" { + if normalized := strings.TrimSpace(NormalizeOpenAIContentForPrompt(content)); normalized != "" { return normalized } } diff --git a/internal/adapter/openai/responses_input_items_test.go b/internal/promptcompat/responses_input_items_test.go similarity index 98% rename from internal/adapter/openai/responses_input_items_test.go rename to internal/promptcompat/responses_input_items_test.go index 6bf30c4..4a782f2 100644 --- a/internal/adapter/openai/responses_input_items_test.go +++ b/internal/promptcompat/responses_input_items_test.go @@ -1,4 +1,4 @@ -package openai +package promptcompat import "testing" diff --git a/internal/adapter/openai/responses_input_normalize.go b/internal/promptcompat/responses_input_normalize.go similarity index 88% rename from internal/adapter/openai/responses_input_normalize.go rename to internal/promptcompat/responses_input_normalize.go index 6514669..e362d0e 100644 --- a/internal/adapter/openai/responses_input_normalize.go +++ b/internal/promptcompat/responses_input_normalize.go @@ -1,16 +1,16 @@ -package openai +package promptcompat import ( "fmt" "strings" ) -func responsesMessagesFromRequest(req map[string]any) []any { +func ResponsesMessagesFromRequest(req map[string]any) []any { if msgs, ok := req["messages"].([]any); ok && len(msgs) > 0 { return prependInstructionMessage(msgs, req["instructions"]) } if rawInput, ok := req["input"]; ok { - if msgs := normalizeResponsesInputAsMessages(rawInput); len(msgs) > 0 { + if msgs := NormalizeResponsesInputAsMessages(rawInput); len(msgs) > 0 { return prependInstructionMessage(msgs, req["instructions"]) } } @@ -29,7 +29,7 @@ func prependInstructionMessage(messages []any, instructions any) []any { return out } -func normalizeResponsesInputAsMessages(input any) []any { +func NormalizeResponsesInputAsMessages(input any) []any { switch v := input.(type) { case string: if strings.TrimSpace(v) == "" { @@ -46,7 +46,7 @@ func normalizeResponsesInputAsMessages(input any) []any { return []any{map[string]any{"role": "user", "content": txt}} } if content, ok := v["content"]; ok { - if strings.TrimSpace(normalizeOpenAIContentForPrompt(content)) != "" { + if strings.TrimSpace(NormalizeOpenAIContentForPrompt(content)) != "" { return []any{map[string]any{"role": "user", "content": content}} } } diff --git a/internal/util/standard_request.go b/internal/promptcompat/standard_request.go similarity index 98% rename from internal/util/standard_request.go rename to internal/promptcompat/standard_request.go index b809dfd..9ec3781 100644 --- a/internal/util/standard_request.go +++ b/internal/promptcompat/standard_request.go @@ -1,4 +1,4 @@ -package util +package promptcompat import "ds2api/internal/config" diff --git a/internal/util/standard_request_test.go b/internal/promptcompat/standard_request_test.go similarity index 98% rename from internal/util/standard_request_test.go rename to internal/promptcompat/standard_request_test.go index e6db5ec..7b529a6 100644 --- a/internal/util/standard_request_test.go +++ b/internal/promptcompat/standard_request_test.go @@ -1,4 +1,4 @@ -package util +package promptcompat import "testing" diff --git a/internal/promptcompat/tool_prompt.go b/internal/promptcompat/tool_prompt.go new file mode 100644 index 0000000..ba5f2cf --- /dev/null +++ b/internal/promptcompat/tool_prompt.go @@ -0,0 +1,72 @@ +package promptcompat + +import ( + "encoding/json" + "fmt" + "strings" + + "ds2api/internal/toolcall" +) + +func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoicePolicy) ([]map[string]any, []string) { + if policy.IsNone() { + return messages, nil + } + toolSchemas := make([]string, 0, len(tools)) + names := make([]string, 0, len(tools)) + isAllowed := func(name string) bool { + if strings.TrimSpace(name) == "" { + return false + } + if len(policy.Allowed) == 0 { + return true + } + _, ok := policy.Allowed[name] + return ok + } + + for _, t := range tools { + tool, ok := t.(map[string]any) + if !ok { + continue + } + fn, _ := tool["function"].(map[string]any) + if len(fn) == 0 { + fn = tool + } + name, _ := fn["name"].(string) + desc, _ := fn["description"].(string) + schema, _ := fn["parameters"].(map[string]any) + name = strings.TrimSpace(name) + if !isAllowed(name) { + continue + } + names = append(names, name) + if desc == "" { + desc = "No description available" + } + b, _ := json.Marshal(schema) + toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(b))) + } + if len(toolSchemas) == 0 { + return messages, names + } + toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + toolcall.BuildToolCallInstructions(names) + if policy.Mode == ToolChoiceRequired { + toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list." + } + if policy.Mode == ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" { + toolPrompt += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName) + toolPrompt += "\n8) Do not call any other tool." + } + + for i := range messages { + if messages[i]["role"] == "system" { + old, _ := messages[i]["content"].(string) + messages[i]["content"] = strings.TrimSpace(old + "\n\n" + toolPrompt) + return messages, names + } + } + messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...) + return messages, names +} diff --git a/internal/server/router.go b/internal/server/router.go index 0e547e0..60db26b 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -15,14 +15,18 @@ import ( "github.com/go-chi/chi/v5/middleware" "ds2api/internal/account" - "ds2api/internal/adapter/claude" - "ds2api/internal/adapter/gemini" - "ds2api/internal/adapter/openai" - "ds2api/internal/admin" "ds2api/internal/auth" "ds2api/internal/chathistory" "ds2api/internal/config" - "ds2api/internal/deepseek" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/httpapi/admin" + "ds2api/internal/httpapi/claude" + "ds2api/internal/httpapi/gemini" + "ds2api/internal/httpapi/openai/chat" + "ds2api/internal/httpapi/openai/embeddings" + "ds2api/internal/httpapi/openai/files" + "ds2api/internal/httpapi/openai/responses" + "ds2api/internal/httpapi/openai/shared" "ds2api/internal/webui" ) @@ -30,7 +34,7 @@ type App struct { Store *config.Store Pool *account.Pool Resolver *auth.Resolver - DS *deepseek.Client + DS *dsclient.Client Router http.Handler } @@ -40,11 +44,11 @@ func NewApp() (*App, error) { return nil, fmt.Errorf("load config: %w", err) } pool := account.NewPool(store) - var dsClient *deepseek.Client + var dsClient *dsclient.Client resolver := auth.NewResolver(store, pool, func(ctx context.Context, acc config.Account) (string, error) { return dsClient.Login(ctx, acc) }) - dsClient = deepseek.NewClient(store, resolver) + dsClient = dsclient.NewClient(store, resolver) if err := dsClient.PreloadPow(context.Background()); err != nil { config.Logger.Warn("[PoW] init failed", "error", err) } else { @@ -55,10 +59,14 @@ func NewApp() (*App, error) { config.Logger.Warn("[chat_history] unavailable", "path", chatHistoryStore.Path(), "error", err) } - openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} - claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} - geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} - adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: openaiHandler, ChatHistory: chatHistoryStore} + modelsHandler := &shared.ModelsHandler{Store: store} + chatHandler := &chat.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} + responsesHandler := &responses.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} + filesHandler := &files.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} + embeddingsHandler := &embeddings.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} + claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler} + geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler} + adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} webuiHandler := webui.NewHandler() r := chi.NewRouter() @@ -83,7 +91,13 @@ func NewApp() (*App, error) { r.Head("/healthz", healthzHandler) r.Get("/readyz", readyzHandler) r.Head("/readyz", readyzHandler) - openai.RegisterRoutes(r, openaiHandler) + r.Get("/v1/models", modelsHandler.ListModels) + r.Get("/v1/models/{model_id}", modelsHandler.GetModel) + r.Post("/v1/chat/completions", chatHandler.ChatCompletions) + r.Post("/v1/responses", responsesHandler.Responses) + r.Get("/v1/responses/{response_id}", responsesHandler.GetResponseByID) + r.Post("/v1/files", filesHandler.UploadFile) + r.Post("/v1/embeddings", embeddingsHandler.Embeddings) claude.RegisterRoutes(r, claudeHandler) gemini.RegisterRoutes(r, geminiHandler) r.Route("/admin", func(ar chi.Router) { diff --git a/internal/server/router_routes_test.go b/internal/server/router_routes_test.go new file mode 100644 index 0000000..3891c8d --- /dev/null +++ b/internal/server/router_routes_test.go @@ -0,0 +1,99 @@ +package server + +import ( + "fmt" + "net/http" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestAPIRoutesRemainRegistered(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"u@example.com","password":"p"}]}`) + t.Setenv("DS2API_ENV_WRITEBACK", "0") + + app, err := NewApp() + if err != nil { + t.Fatalf("NewApp() error: %v", err) + } + routes, ok := app.Router.(chi.Routes) + if !ok { + t.Fatalf("app router does not expose chi routes: %T", app.Router) + } + + got := map[string]bool{} + if err := chi.Walk(routes, func(method string, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + got[fmt.Sprintf("%s %s", method, route)] = true + return nil + }); err != nil { + t.Fatalf("walk routes: %v", err) + } + + for _, want := range []string{ + "GET /v1/models", + "GET /v1/models/{model_id}", + "POST /v1/chat/completions", + "POST /v1/responses", + "GET /v1/responses/{response_id}", + "POST /v1/files", + "POST /v1/embeddings", + "GET /anthropic/v1/models", + "POST /anthropic/v1/messages", + "POST /anthropic/v1/messages/count_tokens", + "POST /v1/messages", + "POST /messages", + "POST /v1/messages/count_tokens", + "POST /messages/count_tokens", + "POST /v1beta/models/{model}:generateContent", + "POST /v1beta/models/{model}:streamGenerateContent", + "POST /v1/models/{model}:generateContent", + "POST /v1/models/{model}:streamGenerateContent", + "POST /admin/login", + "GET /admin/verify", + "GET /admin/config", + "POST /admin/config", + "GET /admin/settings", + "PUT /admin/settings", + "POST /admin/settings/password", + "POST /admin/config/import", + "GET /admin/config/export", + "POST /admin/keys", + "PUT /admin/keys/{key}", + "DELETE /admin/keys/{key}", + "GET /admin/proxies", + "POST /admin/proxies", + "PUT /admin/proxies/{proxyID}", + "DELETE /admin/proxies/{proxyID}", + "POST /admin/proxies/test", + "GET /admin/accounts", + "POST /admin/accounts", + "PUT /admin/accounts/{identifier}", + "DELETE /admin/accounts/{identifier}", + "PUT /admin/accounts/{identifier}/proxy", + "GET /admin/queue/status", + "POST /admin/accounts/test", + "POST /admin/accounts/test-all", + "POST /admin/accounts/sessions/delete-all", + "POST /admin/import", + "POST /admin/test", + "POST /admin/dev/raw-samples/capture", + "GET /admin/dev/raw-samples/query", + "POST /admin/dev/raw-samples/save", + "POST /admin/vercel/sync", + "GET /admin/vercel/status", + "POST /admin/vercel/status", + "GET /admin/export", + "GET /admin/dev/captures", + "DELETE /admin/dev/captures", + "GET /admin/chat-history", + "GET /admin/chat-history/{id}", + "DELETE /admin/chat-history", + "DELETE /admin/chat-history/{id}", + "PUT /admin/chat-history/settings", + "GET /admin/version", + } { + if !got[want] { + t.Fatalf("expected route %s to be registered", want) + } + } +} diff --git a/internal/sse/consumer.go b/internal/sse/consumer.go index 0af4746..1a9adf8 100644 --- a/internal/sse/consumer.go +++ b/internal/sse/consumer.go @@ -4,7 +4,7 @@ import ( "net/http" "strings" - "ds2api/internal/deepseek" + dsprotocol "ds2api/internal/deepseek/protocol" ) // CollectResult holds the aggregated text and thinking content from a @@ -35,7 +35,7 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co if thinkingEnabled { currentType = "thinking" } - _ = deepseek.ScanSSELines(resp, func(line []byte) bool { + _ = dsprotocol.ScanSSELines(resp, func(line []byte) bool { chunk, done, parsed := ParseDeepSeekSSELine(line) if parsed && !done { collector.ingestChunk(chunk) diff --git a/internal/sse/parser.go b/internal/sse/parser.go index a5ed223..3057eda 100644 --- a/internal/sse/parser.go +++ b/internal/sse/parser.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "ds2api/internal/deepseek" + dsprotocol "ds2api/internal/deepseek/protocol" ) type ContentPart struct { @@ -34,10 +34,10 @@ func shouldSkipPath(path string) bool { if isFragmentStatusPath(path) { return true } - if _, ok := deepseek.SkipExactPathSet[path]; ok { + if _, ok := dsprotocol.SkipExactPathSet[path]; ok { return true } - for _, p := range deepseek.SkipContainsPatterns { + for _, p := range dsprotocol.SkipContainsPatterns { if strings.Contains(path, p) { return true } diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/toolstream/tool_sieve_core.go similarity index 73% rename from internal/adapter/openai/tool_sieve_core.go rename to internal/toolstream/tool_sieve_core.go index 4fbd64d..2ec0914 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/toolstream/tool_sieve_core.go @@ -1,4 +1,4 @@ -package openai +package toolstream import ( "strings" @@ -6,16 +6,16 @@ import ( "ds2api/internal/toolcall" ) -func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames []string) []toolStreamEvent { +func ProcessChunk(state *State, chunk string, toolNames []string) []Event { if state == nil { return nil } if chunk != "" { state.pending.WriteString(chunk) } - events := make([]toolStreamEvent, 0, 2) + events := make([]Event, 0, 2) if len(state.pendingToolCalls) > 0 { - events = append(events, toolStreamEvent{ToolCalls: state.pendingToolCalls}) + events = append(events, Event{ToolCalls: state.pendingToolCalls}) state.pendingToolRaw = "" state.pendingToolCalls = nil } @@ -37,7 +37,7 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames if len(calls) > 0 { if prefix != "" { state.noteText(prefix) - events = append(events, toolStreamEvent{Content: prefix}) + events = append(events, Event{Content: prefix}) } if suffix != "" { state.pending.WriteString(suffix) @@ -48,7 +48,7 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames } if prefix != "" { state.noteText(prefix) - events = append(events, toolStreamEvent{Content: prefix}) + events = append(events, Event{Content: prefix}) } if suffix != "" { state.pending.WriteString(suffix) @@ -65,7 +65,7 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames prefix := pending[:start] if prefix != "" { state.noteText(prefix) - events = append(events, toolStreamEvent{Content: prefix}) + events = append(events, Event{Content: prefix}) } state.pending.Reset() state.capture.WriteString(pending[start:]) @@ -81,19 +81,19 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames state.pending.Reset() state.pending.WriteString(hold) state.noteText(safe) - events = append(events, toolStreamEvent{Content: safe}) + events = append(events, Event{Content: safe}) } return events } -func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStreamEvent { +func Flush(state *State, toolNames []string) []Event { if state == nil { return nil } - events := processToolSieveChunk(state, "", toolNames) + events := ProcessChunk(state, "", toolNames) if len(state.pendingToolCalls) > 0 { - events = append(events, toolStreamEvent{ToolCalls: state.pendingToolCalls}) + events = append(events, Event{ToolCalls: state.pendingToolCalls}) state.pendingToolRaw = "" state.pendingToolCalls = nil } @@ -102,14 +102,14 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea if ready { if consumedPrefix != "" { state.noteText(consumedPrefix) - events = append(events, toolStreamEvent{Content: consumedPrefix}) + events = append(events, Event{Content: consumedPrefix}) } if len(consumedCalls) > 0 { - events = append(events, toolStreamEvent{ToolCalls: consumedCalls}) + events = append(events, Event{ToolCalls: consumedCalls}) } if consumedSuffix != "" { state.noteText(consumedSuffix) - events = append(events, toolStreamEvent{Content: consumedSuffix}) + events = append(events, Event{Content: consumedSuffix}) } } else { content := state.capture.String() @@ -117,7 +117,7 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea // If capture never resolved into a real tool call, release the // buffered text instead of swallowing it. state.noteText(content) - events = append(events, toolStreamEvent{Content: content}) + events = append(events, Event{Content: content}) } } state.capture.Reset() @@ -128,13 +128,13 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea content := state.pending.String() // If pending never resolved into a real tool call, release it as text. state.noteText(content) - events = append(events, toolStreamEvent{Content: content}) + events = append(events, Event{Content: content}) state.pending.Reset() } return events } -func splitSafeContentForToolDetection(state *toolStreamSieveState, s string) (safe, hold string) { +func splitSafeContentForToolDetection(state *State, s string) (safe, hold string) { if s == "" { return "", "" } @@ -150,7 +150,7 @@ func splitSafeContentForToolDetection(state *toolStreamSieveState, s string) (sa return s, "" } -func findToolSegmentStart(state *toolStreamSieveState, s string) int { +func findToolSegmentStart(state *State, s string) int { if s == "" { return -1 } @@ -179,7 +179,7 @@ func findToolSegmentStart(state *toolStreamSieveState, s string) int { } } -func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) { +func consumeToolCapture(state *State, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) { captured := state.capture.String() if captured == "" { return "", nil, "", false diff --git a/internal/adapter/openai/tool_sieve_jsonscan.go b/internal/toolstream/tool_sieve_jsonscan.go similarity index 97% rename from internal/adapter/openai/tool_sieve_jsonscan.go rename to internal/toolstream/tool_sieve_jsonscan.go index 6568721..d9e9593 100644 --- a/internal/adapter/openai/tool_sieve_jsonscan.go +++ b/internal/toolstream/tool_sieve_jsonscan.go @@ -1,4 +1,4 @@ -package openai +package toolstream import "strings" diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/toolstream/tool_sieve_state.go similarity index 87% rename from internal/adapter/openai/tool_sieve_state.go rename to internal/toolstream/tool_sieve_state.go index 8128f8c..1d709bd 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/toolstream/tool_sieve_state.go @@ -1,11 +1,11 @@ -package openai +package toolstream import ( "ds2api/internal/toolcall" "strings" ) -type toolStreamSieveState struct { +type State struct { pending strings.Builder capture strings.Builder capturing bool @@ -23,19 +23,19 @@ type toolStreamSieveState struct { toolArgsDone bool } -type toolStreamEvent struct { +type Event struct { Content string ToolCalls []toolcall.ParsedToolCall - ToolCallDeltas []toolCallDelta + ToolCallDeltas []ToolCallDelta } -type toolCallDelta struct { +type ToolCallDelta struct { Index int Name string Arguments string } -func (s *toolStreamSieveState) resetIncrementalToolState() { +func (s *State) resetIncrementalToolState() { s.disableDeltas = false s.toolNameSent = false s.toolName = "" @@ -45,7 +45,7 @@ func (s *toolStreamSieveState) resetIncrementalToolState() { s.toolArgsDone = false } -func (s *toolStreamSieveState) noteText(content string) { +func (s *State) noteText(content string) { if !hasMeaningfulText(content) { return } @@ -56,7 +56,7 @@ func hasMeaningfulText(text string) bool { return strings.TrimSpace(text) != "" } -func insideCodeFenceWithState(state *toolStreamSieveState, text string) bool { +func insideCodeFenceWithState(state *State, text string) bool { if state == nil { return insideCodeFence(text) } @@ -76,7 +76,7 @@ func insideCodeFence(text string) bool { return len(simulateCodeFenceState(nil, 0, true, text).stack) > 0 } -func updateCodeFenceState(state *toolStreamSieveState, text string) { +func updateCodeFenceState(state *State, text string) { if state == nil || !hasMeaningfulText(text) { return } diff --git a/internal/adapter/openai/tool_sieve_xml.go b/internal/toolstream/tool_sieve_xml.go similarity index 99% rename from internal/adapter/openai/tool_sieve_xml.go rename to internal/toolstream/tool_sieve_xml.go index 3a8ee4c..87fb075 100644 --- a/internal/adapter/openai/tool_sieve_xml.go +++ b/internal/toolstream/tool_sieve_xml.go @@ -1,4 +1,4 @@ -package openai +package toolstream import ( "ds2api/internal/toolcall" diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/toolstream/tool_sieve_xml_test.go similarity index 81% rename from internal/adapter/openai/tool_sieve_xml_test.go rename to internal/toolstream/tool_sieve_xml_test.go index c134982..4b06bc3 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/toolstream/tool_sieve_xml_test.go @@ -1,4 +1,4 @@ -package openai +package toolstream import ( "strings" @@ -6,7 +6,7 @@ import ( ) func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) { - var state toolStreamSieveState + var state State // Simulate a model producing XML tool call output chunk by chunk. chunks := []string{ "\n", @@ -15,11 +15,11 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) { " \n", "", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...) + events = append(events, ProcessChunk(&state, c, []string{"read_file"})...) } - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent string var toolCalls int @@ -42,7 +42,7 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) { } func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) { - var state toolStreamSieveState + var state State const toolName = "write_to_file" payload := strings.Repeat("x", 4096) splitAt := len(payload) / 2 @@ -53,11 +53,11 @@ func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) { "]]>\n \n", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{toolName})...) + events = append(events, ProcessChunk(&state, c, []string{toolName})...) } - events = append(events, flushToolSieve(&state, []string{toolName})...) + events = append(events, Flush(&state, []string{toolName})...) var textContent strings.Builder toolCalls := 0 @@ -85,18 +85,18 @@ func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) { } func TestProcessToolSieveXMLWithLeadingText(t *testing.T) { - var state toolStreamSieveState + var state State // Model outputs some prose then an XML tool call. chunks := []string{ "Let me check the file.\n", "\n \n", ` go.mod` + "\n \n", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...) + events = append(events, ProcessChunk(&state, c, []string{"read_file"})...) } - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent string var toolCalls int @@ -121,10 +121,10 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) { } func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) { - var state toolStreamSieveState + var state State chunk := `示例 XMLplain text xml payload` - events := processToolSieveChunk(&state, chunk, []string{"read_file"}) - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events := ProcessChunk(&state, chunk, []string{"read_file"}) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent strings.Builder toolCalls := 0 @@ -141,10 +141,10 @@ func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) { } func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) { - var state toolStreamSieveState + var state State chunk := `plain xmlREADME.MD` - events := processToolSieveChunk(&state, chunk, []string{"read_file"}) - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events := ProcessChunk(&state, chunk, []string{"read_file"}) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent strings.Builder toolCalls := 0 @@ -164,10 +164,10 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) { } func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) { - var state toolStreamSieveState + var state State chunk := `{"path":"README.md"}` - events := processToolSieveChunk(&state, chunk, []string{"read_file"}) - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events := ProcessChunk(&state, chunk, []string{"read_file"}) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent strings.Builder toolCalls := 0 @@ -185,7 +185,7 @@ func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) } func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) { - var state toolStreamSieveState + var state State input := strings.Join([]string{ "Before first example.\n```", "xml\nREADME.md\n```\n", @@ -202,11 +202,11 @@ func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) { "```\nAfter examples.", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"read_file", "search"})...) + events = append(events, ProcessChunk(&state, c, []string{"read_file", "search"})...) } - events = append(events, flushToolSieve(&state, []string{"read_file", "search"})...) + events = append(events, Flush(&state, []string{"read_file", "search"})...) var textContent strings.Builder toolCalls := 0 @@ -226,7 +226,7 @@ func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) { } func TestProcessToolSieveKeepsPartialXMLTagInsideFencedExample(t *testing.T) { - var state toolStreamSieveState + var state State input := strings.Join([]string{ "Example:\n```xml\nREADME.md\n```\n", @@ -239,11 +239,11 @@ func TestProcessToolSieveKeepsPartialXMLTagInsideFencedExample(t *testing.T) { "Done.", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...) + events = append(events, ProcessChunk(&state, c, []string{"read_file"})...) } - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent strings.Builder toolCalls := 0 @@ -263,9 +263,9 @@ func TestProcessToolSieveKeepsPartialXMLTagInsideFencedExample(t *testing.T) { } func TestProcessToolSievePartialXMLTagHeldBack(t *testing.T) { - var state toolStreamSieveState + var state State // Chunk ends with a partial XML tool tag. - events := processToolSieveChunk(&state, "Hello tag arrives in small pieces. func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) { - var state toolStreamSieveState + var state State // Simulate DeepSeek model generating tokens one at a time. chunks := []string{ "<", @@ -364,11 +364,11 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) { "tool_calls", ">", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...) + events = append(events, ProcessChunk(&state, c, []string{"read_file"})...) } - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent string var toolCalls int @@ -393,20 +393,20 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) { } } -// Test that flushToolSieve on incomplete XML falls back to raw text. +// Test that Flush on incomplete XML falls back to raw text. func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) { - var state toolStreamSieveState + var state State // XML block starts but stream ends before completion. chunks := []string{ "\n", " \n", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...) + events = append(events, ProcessChunk(&state, c, []string{"read_file"})...) } // Stream ends abruptly - flush should NOT dump raw XML. - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent string for _, evt := range events { @@ -422,9 +422,9 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) { // Test that the opening tag "\n " is NOT emitted as text content. func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) { - var state toolStreamSieveState + var state State // First chunk is the opening tag - should be held, not emitted. - evts1 := processToolSieveChunk(&state, "\n ", []string{"read_file"}) + evts1 := ProcessChunk(&state, "\n ", []string{"read_file"}) for _, evt := range evts1 { if strings.Contains(evt.Content, "") { t.Fatalf("opening tag leaked on first chunk: %q", evt.Content) @@ -432,8 +432,8 @@ func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) { } // Remaining content arrives. - evts2 := processToolSieveChunk(&state, "\n README.MD\n \n", []string{"read_file"}) - evts2 = append(evts2, flushToolSieve(&state, []string{"read_file"})...) + evts2 := ProcessChunk(&state, "\n README.MD\n \n", []string{"read_file"}) + evts2 = append(evts2, Flush(&state, []string{"read_file"})...) var textContent string var toolCalls int @@ -454,7 +454,7 @@ func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) { } func TestProcessToolSieveFallsBackToRawAttemptCompletion(t *testing.T) { - var state toolStreamSieveState + var state State // Simulate an agent outputting attempt_completion XML tag. // If it does not parse as a tool call, it should fall back to raw text. chunks := []string{ @@ -463,11 +463,11 @@ func TestProcessToolSieveFallsBackToRawAttemptCompletion(t *testing.T) { " Here is the answer\n", "", } - var events []toolStreamEvent + var events []Event for _, c := range chunks { - events = append(events, processToolSieveChunk(&state, c, []string{"attempt_completion"})...) + events = append(events, ProcessChunk(&state, c, []string{"attempt_completion"})...) } - events = append(events, flushToolSieve(&state, []string{"attempt_completion"})...) + events = append(events, Flush(&state, []string{"attempt_completion"})...) var textContent string for _, evt := range events { @@ -486,10 +486,10 @@ func TestProcessToolSieveFallsBackToRawAttemptCompletion(t *testing.T) { } func TestProcessToolSievePassesThroughBareToolCallAsText(t *testing.T) { - var state toolStreamSieveState + var state State chunk := `README.md` - events := processToolSieveChunk(&state, chunk, []string{"read_file"}) - events = append(events, flushToolSieve(&state, []string{"read_file"})...) + events := ProcessChunk(&state, chunk, []string{"read_file"}) + events = append(events, Flush(&state, []string{"read_file"})...) var textContent strings.Builder toolCalls := 0 diff --git a/plans/refactor-line-gate-targets.txt b/plans/refactor-line-gate-targets.txt index e144b75..9cdbcbb 100644 --- a/plans/refactor-line-gate-targets.txt +++ b/plans/refactor-line-gate-targets.txt @@ -13,70 +13,70 @@ internal/config/store_index.go internal/config/store_accessors.go internal/config/account.go -internal/admin/handler_config_read.go -internal/admin/handler_config_write.go -internal/admin/handler_config_import.go -internal/admin/handler_settings_read.go -internal/admin/handler_settings_write.go -internal/admin/handler_settings_parse.go -internal/admin/handler_settings_runtime.go -internal/admin/handler_accounts_crud.go -internal/admin/handler_accounts_testing.go -internal/admin/handler_accounts_queue.go +internal/httpapi/admin/configmgmt/handler_config_read.go +internal/httpapi/admin/configmgmt/handler_config_write.go +internal/httpapi/admin/configmgmt/handler_config_import.go +internal/httpapi/admin/settings/handler_settings_read.go +internal/httpapi/admin/settings/handler_settings_write.go +internal/httpapi/admin/settings/handler_settings_parse.go +internal/httpapi/admin/settings/handler_settings_runtime.go +internal/httpapi/admin/accounts/handler_accounts_crud.go +internal/httpapi/admin/accounts/handler_accounts_testing.go +internal/httpapi/admin/accounts/handler_accounts_queue.go internal/account/pool_core.go internal/account/pool_acquire.go internal/account/pool_waiters.go internal/account/pool_limits.go -internal/deepseek/client_core.go -internal/deepseek/client_auth.go -internal/deepseek/client_completion.go -internal/deepseek/client_http_json.go -internal/deepseek/client_http_helpers.go +internal/deepseek/client/client_core.go +internal/deepseek/client/client_auth.go +internal/deepseek/client/client_completion.go +internal/deepseek/client/client_http_json.go +internal/deepseek/client/client_http_helpers.go internal/format/openai/render_chat.go internal/format/openai/render_responses.go internal/format/openai/render_stream_events.go internal/format/openai/render_usage.go -internal/adapter/openai/handler_routes.go -internal/adapter/openai/handler_chat.go -internal/adapter/openai/handler_errors.go -internal/adapter/openai/handler_toolcall_policy.go -internal/adapter/openai/handler_toolcall_format.go -internal/adapter/openai/responses_handler.go -internal/adapter/openai/responses_input_normalize.go -internal/adapter/openai/responses_input_items.go -internal/adapter/openai/responses_stream_runtime_core.go -internal/adapter/openai/responses_stream_runtime_events.go -internal/adapter/openai/responses_stream_runtime_toolcalls.go -internal/adapter/openai/tool_sieve_state.go -internal/adapter/openai/tool_sieve_core.go -internal/adapter/openai/tool_sieve_xml.go -internal/adapter/openai/tool_sieve_jsonscan.go +internal/httpapi/openai/shared/models.go +internal/httpapi/openai/chat/handler_chat.go +internal/httpapi/openai/shared/handler_errors.go +internal/httpapi/openai/shared/handler_toolcall_policy.go +internal/httpapi/openai/shared/handler_toolcall_format.go +internal/httpapi/openai/responses/responses_handler.go +internal/promptcompat/responses_input_normalize.go +internal/promptcompat/responses_input_items.go +internal/httpapi/openai/responses/responses_stream_runtime_core.go +internal/httpapi/openai/responses/responses_stream_runtime_events.go +internal/httpapi/openai/responses/responses_stream_runtime_toolcalls.go +internal/toolstream/tool_sieve_state.go +internal/toolstream/tool_sieve_core.go +internal/toolstream/tool_sieve_xml.go +internal/toolstream/tool_sieve_jsonscan.go internal/toolcall/toolcalls_parse.go internal/toolcall/toolcalls_candidates.go internal/toolcall/toolcalls_format.go -internal/adapter/claude/handler_routes.go -internal/adapter/claude/handler_messages.go -internal/adapter/claude/handler_tokens.go -internal/adapter/claude/handler_errors.go -internal/adapter/claude/handler_utils.go -internal/adapter/claude/stream_runtime_core.go -internal/adapter/claude/stream_runtime_emit.go -internal/adapter/claude/stream_runtime_finalize.go +internal/httpapi/claude/handler_routes.go +internal/httpapi/claude/handler_messages.go +internal/httpapi/claude/handler_tokens.go +internal/httpapi/claude/handler_errors.go +internal/httpapi/claude/handler_utils.go +internal/httpapi/claude/stream_runtime_core.go +internal/httpapi/claude/stream_runtime_emit.go +internal/httpapi/claude/stream_runtime_finalize.go -internal/adapter/gemini/handler_routes.go -internal/adapter/gemini/handler_generate.go -internal/adapter/gemini/handler_stream_runtime.go -internal/adapter/gemini/handler_errors.go -internal/adapter/gemini/convert_request.go -internal/adapter/gemini/convert_messages.go -internal/adapter/gemini/convert_tools.go -internal/adapter/gemini/convert_passthrough.go +internal/httpapi/gemini/handler_routes.go +internal/httpapi/gemini/handler_generate.go +internal/httpapi/gemini/handler_stream_runtime.go +internal/httpapi/gemini/handler_errors.go +internal/httpapi/gemini/convert_request.go +internal/httpapi/gemini/convert_messages.go +internal/httpapi/gemini/convert_tools.go +internal/httpapi/gemini/convert_passthrough.go internal/testsuite/runner_core.go internal/testsuite/runner_env.go diff --git a/scripts/lint.sh b/scripts/lint.sh index cf8e14a..32eea6a 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -8,6 +8,10 @@ LINT_BIN="${GOLANGCI_LINT_BIN:-golangci-lint}" BOOTSTRAP_VERSION="${GOLANGCI_LINT_VERSION:-v2.11.4}" BOOTSTRAP_BIN="${ROOT_DIR}/.tmp/golangci-lint-${BOOTSTRAP_VERSION}" +export GOCACHE="${GOCACHE:-${ROOT_DIR}/.tmp/go-build-cache}" +export GOLANGCI_LINT_CACHE="${GOLANGCI_LINT_CACHE:-${ROOT_DIR}/.tmp/golangci-lint-cache}" +mkdir -p "$GOCACHE" "$GOLANGCI_LINT_CACHE" + bootstrap_golangci_lint() { local version_no_v os arch artifact archive_url tmp_dir version_no_v="${BOOTSTRAP_VERSION#v}" @@ -49,9 +53,9 @@ bootstrap_golangci_lint() { run_lint() { local bin="$1" if [[ "$bin" == *" "* ]]; then - eval "$bin fmt --diff -c .golangci.yml" && eval "$bin run -c .golangci.yml" + eval "$bin fmt --diff -c .golangci.yml" && eval "$bin run -c .golangci.yml ./..." else - "$bin" fmt --diff -c .golangci.yml && "$bin" run -c .golangci.yml + "$bin" fmt --diff -c .golangci.yml && "$bin" run -c .golangci.yml ./... fi } diff --git a/tests/scripts/run-unit-go.sh b/tests/scripts/run-unit-go.sh index 38a11b8..c9ae5b9 100755 --- a/tests/scripts/run-unit-go.sh +++ b/tests/scripts/run-unit-go.sh @@ -4,4 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT_DIR" +export GOCACHE="${GOCACHE:-${ROOT_DIR}/.tmp/go-build-cache}" +mkdir -p "$GOCACHE" + go test ./... "$@"