mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
refactor backend API structure
This commit is contained in:
@@ -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 `<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`).
|
||||
- `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 `<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`).
|
||||
- `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.
|
||||
|
||||
@@ -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 工具调用解析与防泄漏筛分(唯一可执行格式:`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)。
|
||||
- `internal/admin`:配置管理、账号管理、Vercel 同步、版本检查、开发抓包。
|
||||
- `internal/toolcall` + `internal/toolstream`:canonical XML 工具调用解析与防泄漏筛分(唯一可执行格式:`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)。
|
||||
- `internal/httpapi/admin/*`:Admin API 根装配与 auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version 等资源子包。
|
||||
- `internal/chathistory`:服务器端对话记录持久化、分页、单条详情和保留策略。
|
||||
- `internal/config`:配置加载、校验、运行时 settings 热更新。
|
||||
- `internal/account`:托管账号池、并发槽位、等待队列。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 相关兼容语义,还应同时检查:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package openai
|
||||
|
||||
func (h *Handler) toolcallFeatureMatchEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) toolcallEarlyEmitHighConfidence() bool {
|
||||
return true
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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 == "" {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package protocol
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
@@ -1,4 +1,4 @@
|
||||
package deepseek
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
21
internal/deepseek/protocol/sse.go
Normal file
21
internal/deepseek/protocol/sse.go
Normal file
@@ -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
|
||||
}
|
||||
46
internal/httpapi/admin/accounts/deps.go
Normal file
46
internal/httpapi/admin/accounts/deps.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package accounts
|
||||
|
||||
import "net/http"
|
||||
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
38
internal/httpapi/admin/accounts/routes.go
Normal file
38
internal/httpapi/admin/accounts/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
35
internal/httpapi/admin/accounts/test_http_helpers_test.go
Normal file
35
internal/httpapi/admin/accounts/test_http_helpers_test.go
Normal file
@@ -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
|
||||
}
|
||||
19
internal/httpapi/admin/auth/deps.go
Normal file
19
internal/httpapi/admin/auth/deps.go
Normal file
@@ -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) }
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
20
internal/httpapi/admin/auth/routes.go
Normal file
20
internal/httpapi/admin/auth/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
50
internal/httpapi/admin/configmgmt/deps.go
Normal file
50
internal/httpapi/admin/configmgmt/deps.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package configmgmt
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package configmgmt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package configmgmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
27
internal/httpapi/admin/configmgmt/routes.go
Normal file
27
internal/httpapi/admin/configmgmt/routes.go
Normal file
@@ -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) }
|
||||
18
internal/httpapi/admin/configmgmt/test_helpers_test.go
Normal file
18
internal/httpapi/admin/configmgmt/test_helpers_test.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
16
internal/httpapi/admin/devcapture/deps.go
Normal file
16
internal/httpapi/admin/devcapture/deps.go
Normal file
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package devcapture
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package devcapture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
8
internal/httpapi/admin/devcapture/routes.go
Normal file
8
internal/httpapi/admin/devcapture/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
70
internal/httpapi/admin/handler.go
Normal file
70
internal/httpapi/admin/handler.go
Normal file
@@ -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
|
||||
}
|
||||
16
internal/httpapi/admin/history/deps.go
Normal file
16
internal/httpapi/admin/history/deps.go
Normal file
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package history
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package history
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
11
internal/httpapi/admin/history/routes.go
Normal file
11
internal/httpapi/admin/history/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
32
internal/httpapi/admin/proxies/deps.go
Normal file
32
internal/httpapi/admin/proxies/deps.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package proxies
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
24
internal/httpapi/admin/proxies/routes.go
Normal file
24
internal/httpapi/admin/proxies/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
57
internal/httpapi/admin/proxies/test_http_helpers_test.go
Normal file
57
internal/httpapi/admin/proxies/test_http_helpers_test.go
Normal file
@@ -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
|
||||
}
|
||||
27
internal/httpapi/admin/rawsamples/deps.go
Normal file
27
internal/httpapi/admin/rawsamples/deps.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package rawsamples
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
9
internal/httpapi/admin/rawsamples/routes.go
Normal file
9
internal/httpapi/admin/rawsamples/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
29
internal/httpapi/admin/settings/deps.go
Normal file
29
internal/httpapi/admin/settings/deps.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package settings
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package settings
|
||||
|
||||
import "ds2api/internal/config"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
20
internal/httpapi/admin/settings/routes.go
Normal file
20
internal/httpapi/admin/settings/routes.go
Normal file
@@ -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) }
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
123
internal/httpapi/admin/test_bridge_test.go
Normal file
123
internal/httpapi/admin/test_bridge_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
24
internal/httpapi/admin/vercel/deps.go
Normal file
24
internal/httpapi/admin/vercel/deps.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package vercel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
9
internal/httpapi/admin/vercel/routes.go
Normal file
9
internal/httpapi/admin/vercel/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
16
internal/httpapi/admin/version/deps.go
Normal file
16
internal/httpapi/admin/version/deps.go
Normal file
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package version
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
7
internal/httpapi/admin/version/routes.go
Normal file
7
internal/httpapi/admin/version/routes.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user