refactor backend API structure

This commit is contained in:
CJACK
2026-04-26 06:58:20 +08:00
parent 8a91fef6ab
commit abc96a37d8
207 changed files with 2675 additions and 1344 deletions

View File

@@ -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.

View File

@@ -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 surfaceOpenAI/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 surfacechatresponses、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`:托管账号池、并发槽位、等待队列。

View File

@@ -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.

View File

@@ -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

View File

@@ -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 相关兼容语义,还应同时检查:

View File

@@ -1,6 +1,6 @@
# Tool call parsing semanticsGo/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
```

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -1,9 +0,0 @@
package openai
func (h *Handler) toolcallFeatureMatchEnabled() bool {
return true
}
func (h *Handler) toolcallEarlyEmitHighConfidence() bool {
return true
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import "testing"

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import "testing"

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import "testing"

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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))

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"context"

View File

@@ -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)

View File

@@ -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
}

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"context"

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 = ""

View File

@@ -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

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"context"

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"errors"

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"context"

View File

@@ -1,4 +1,4 @@
package deepseek
package client
import (
"context"

View File

@@ -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 == "" {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -1,4 +1,4 @@
package deepseek
package protocol
import (
_ "embed"

View File

@@ -1,4 +1,4 @@
package deepseek
package protocol
import "testing"

View 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
}

View 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)
}

View File

@@ -1,4 +1,4 @@
package admin
package accounts
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package admin
package accounts
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package admin
package accounts
import "net/http"

View File

@@ -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)

View File

@@ -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) {

View 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)
}

View 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
}

View 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) }

View File

@@ -1,4 +1,4 @@
package admin
package auth
import (
"encoding/json"

View 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)
}

View 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)
}

View File

@@ -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)
}

View File

@@ -1,4 +1,4 @@
package admin
package configmgmt
import (
"net/http"

View File

@@ -1,4 +1,4 @@
package admin
package configmgmt
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package admin
package configmgmt
import (
"bytes"

View 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) }

View 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),
}
}

View 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

View File

@@ -1,4 +1,4 @@
package admin
package devcapture
import (
"net/http"

View File

@@ -1,4 +1,4 @@
package admin
package devcapture
import (
"encoding/json"

View 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)
}

View 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
}

View 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

View File

@@ -1,4 +1,4 @@
package admin
package history
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package admin
package history
import (
"bytes"

View 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)
}

View 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)
}

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
package admin
package proxies
import (
"bytes"

View 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)
}

View 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
}

View 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)
}

View File

@@ -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"))

View File

@@ -1,4 +1,4 @@
package admin
package rawsamples
import (
"bytes"

View 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)
}

View 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)
}

View File

@@ -1,4 +1,4 @@
package admin
package settings
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package admin
package settings
import (
"net/http"

View File

@@ -1,4 +1,4 @@
package admin
package settings
import "ds2api/internal/config"

View File

@@ -1,4 +1,4 @@
package admin
package settings
import (
"encoding/json"

View 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) }

View File

@@ -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)

View File

@@ -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]

View File

@@ -1,4 +1,4 @@
package admin
package shared
import (
"net/http"

View File

@@ -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)
}

View File

@@ -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)
}

View 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)
}

View File

@@ -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()

View 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)
}

View File

@@ -1,4 +1,4 @@
package admin
package vercel
import (
"bytes"

View 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)
}

View 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

View File

@@ -1,4 +1,4 @@
package admin
package version
import (
"encoding/json"

View 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)
}

View File

@@ -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