diff --git a/AGENTS.md b/AGENTS.md index 77991bc..ff2006e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,8 @@ These rules apply to all agent-made changes in this repository. - Keep changes additive and tightly scoped to the requested feature or bugfix. - Do not mix unrelated refactors into feature PRs unless they are required to make the change pass gates. + +## Documentation Sync + +- `docs/prompt-compatibility.md` is the source-of-truth document for the “API -> pure-text web-chat context” compatibility flow. +- If a change affects message normalization, tool prompt injection, prompt-visible tool history, file/reference handling, history split, or completion payload assembly, update `docs/prompt-compatibility.md` in the same change. diff --git a/API.en.md b/API.en.md index a982b0e..15b491c 100644 --- a/API.en.md +++ b/API.en.md @@ -217,9 +217,9 @@ For `chat` / `responses` / `embeddings`, DS2API follows a wide-input/strict-outp Current built-in default aliases (excerpt): -- OpenAI: `gpt-4o`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-5`, `gpt-5-mini`, `gpt-5-codex` -- OpenAI reasoning: `o1`, `o1-mini`, `o3`, `o3-mini` -- Claude: `claude-sonnet-4-5`, `claude-haiku-4-5`, `claude-opus-4-6` (plus compatibility aliases `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus`) +- OpenAI: `gpt-4o`, `gpt-4.1`, `gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex` +- OpenAI reasoning: `o1`, `o1-mini`, `o3`, `o4-mini` +- Claude: `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` (plus compatibility aliases `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus`) - Gemini: `gemini-2.5-pro`, `gemini-2.5-flash` ### `POST /v1/chat/completions` @@ -235,7 +235,7 @@ Content-Type: application/json | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-5`, `gpt-5-mini`, `gpt-5-codex`, `o3`, `claude-opus-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash`, etc.) | +| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex`, `o3`, `claude-opus-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash`, etc.) | | `messages` | array | ✅ | OpenAI-style messages | | `stream` | boolean | ❌ | Default `false` | | `tools` | array | ❌ | Function calling schema | @@ -442,17 +442,17 @@ No auth required. { "object": "list", "data": [ - {"id": "claude-sonnet-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-haiku-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"} ], "first_id": "claude-opus-4-6", - "last_id": "claude-instant-1.0", + "last_id": "claude-3-haiku-20240307", "has_more": false } ``` -> Note: the example is partial; besides the current primary aliases, the real response also includes Claude 4.x snapshots plus historical 3.x / 2.x / 1.x IDs and common aliases. +> Note: the example is partial; besides the current primary aliases, the real response also includes Claude 4.x snapshots plus historical 3.x IDs and common aliases. ### `POST /anthropic/v1/messages` @@ -470,7 +470,7 @@ anthropic-version: 2023-06-01 | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `model` | string | ✅ | For example `claude-sonnet-4-5` / `claude-opus-4-6` / `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`), plus historical Claude model IDs | +| `model` | string | ✅ | For example `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`), plus historical Claude model IDs | | `messages` | array | ✅ | Claude-style messages | | `max_tokens` | number | ❌ | Auto-filled to `8192` when omitted; not strictly enforced by upstream bridge | | `stream` | boolean | ❌ | Default `false` | @@ -484,7 +484,7 @@ anthropic-version: 2023-06-01 "id": "msg_1738400000000000000", "type": "message", "role": "assistant", - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "content": [ {"type": "text", "text": "response"} ], @@ -538,7 +538,7 @@ data: {"type":"message_stop"} ```json { - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "messages": [ {"role": "user", "content": "Hello"} ] @@ -666,16 +666,16 @@ Returns sanitized config, including both `keys` and `api_keys`. "token_preview": "abcde..." } ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` ### `POST /admin/config` -Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`. +Only updates `keys`, `api_keys`, `accounts`, and `model_aliases`. If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback. **Request**: @@ -690,9 +690,9 @@ If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` @@ -707,7 +707,7 @@ Reads runtime settings and status, including: - `compat` (`wide_input_strict_output`, `strip_reference_markers`) - `responses` / `embeddings` - `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`) -- `claude_mapping` / `model_aliases` +- `model_aliases` - `env_backed`, `needs_vercel_sync` - `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings @@ -721,7 +721,6 @@ Hot-updates runtime settings. Supported fields: - `responses.store_ttl_seconds` - `embeddings.provider` - `auto_delete.mode` -- `claude_mapping` - `model_aliases` - `toolcall` policy is fixed and is no longer writable through settings @@ -746,7 +745,7 @@ Imports full config with: The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. Query params `?mode=merge` / `?mode=replace` are also supported. -Import accepts `keys`, `api_keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. +Import accepts `keys`, `api_keys`, `accounts`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. > `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`. @@ -1338,7 +1337,7 @@ curl http://localhost:5001/anthropic/v1/messages \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{ - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }' diff --git a/API.md b/API.md index 0fa81d3..e309eff 100644 --- a/API.md +++ b/API.md @@ -217,11 +217,13 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` 当前内置默认 alias(节选): -- OpenAI:`gpt-4o`、`gpt-4.1`、`gpt-4.1-mini`、`gpt-4.1-nano`、`gpt-5`、`gpt-5-mini`、`gpt-5-codex` +- OpenAI:`gpt-4o`、`gpt-4.1`、`gpt-4.1-mini`、`gpt-4.1-nano`、`gpt-5`、`gpt-5.4`、`gpt-5.5`、`gpt-5-mini`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5.5-pro`、`gpt-5-codex`、`gpt-5.3-codex` - OpenAI Reasoning:`o1`、`o1-mini`、`o3`、`o3-mini` -- Claude:`claude-sonnet-4-5`、`claude-haiku-4-5`、`claude-opus-4-6`(及 `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus` 兼容别名) +- Claude:`claude-sonnet-4-6`、`claude-haiku-4-5`、`claude-opus-4-6`(及 `claude-sonnet-4-5` / `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus` 兼容别名) - Gemini:`gemini-2.5-pro`、`gemini-2.5-flash` +> 截至 2026-04-26:OpenAI 开发者模型页当前推荐 `gpt-5.5` 作为旗舰 API 模型;ChatGPT Help Center 当前主打 `GPT-5.3 Instant / GPT-5.5 Thinking / GPT-5.5 Pro`;Anthropic 官方模型页当前主推 `claude-opus-4-6`、`claude-sonnet-4-6`、`claude-haiku-4-5`。 + ### `POST /v1/chat/completions` **请求头**: @@ -235,7 +237,7 @@ Content-Type: application/json | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias(如 `gpt-5`、`gpt-5-mini`、`gpt-5-codex`、`o3`、`claude-opus-4-6`、`gemini-2.5-pro`、`gemini-2.5-flash` 等) | +| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias(如 `gpt-5.5`、`gpt-5.4-mini`、`gpt-5.3-codex`、`o3`、`claude-opus-4-6`、`claude-sonnet-4-6`、`gemini-2.5-pro`、`gemini-2.5-flash` 等) | | `messages` | array | ✅ | OpenAI 风格消息数组 | | `stream` | boolean | ❌ | 默认 `false` | | `tools` | array | ❌ | Function Calling 定义 | @@ -443,17 +445,17 @@ data: [DONE] { "object": "list", "data": [ - {"id": "claude-sonnet-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-haiku-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"} ], "first_id": "claude-opus-4-6", - "last_id": "claude-instant-1.0", + "last_id": "claude-3-haiku-20240307", "has_more": false } ``` -> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots,以及 3.x / 2.x / 1.x 历史模型 ID 与常见别名。 +> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots,以及 3.x 历史模型 ID 与常见别名。 ### `POST /anthropic/v1/messages` @@ -471,7 +473,7 @@ anthropic-version: 2023-06-01 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `model` | string | ✅ | 例如 `claude-sonnet-4-5` / `claude-opus-4-6` / `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest`),并支持历史 Claude 模型 ID | +| `model` | string | ✅ | 例如 `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5`(兼容 `claude-sonnet-4-5`、`claude-3-5-haiku-latest`),并支持历史 Claude 模型 ID | | `messages` | array | ✅ | Claude 风格消息数组 | | `max_tokens` | number | ❌ | 缺省自动补 `8192`;当前实现不会硬性截断上游输出 | | `stream` | boolean | ❌ | 默认 `false` | @@ -485,7 +487,7 @@ anthropic-version: 2023-06-01 "id": "msg_1738400000000000000", "type": "message", "role": "assistant", - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "content": [ {"type": "text", "text": "回复内容"} ], @@ -539,7 +541,7 @@ data: {"type":"message_stop"} ```json { - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "messages": [ {"role": "user", "content": "你好"} ] @@ -667,16 +669,16 @@ data: {"type":"message_stop"} "token_preview": "abcde..." } ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` ### `POST /admin/config` -只更新 `keys`、`api_keys`、`accounts`、`claude_mapping`。 +只更新 `keys`、`api_keys`、`accounts`、`model_aliases`。 如果同时发送 `api_keys` 与 `keys`,优先保留 `api_keys` 中的结构化 `name` / `remark`;`keys` 仅作为旧格式兼容回退。 **请求**: @@ -691,9 +693,9 @@ data: {"type":"message_stop"} "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` @@ -708,7 +710,7 @@ data: {"type":"message_stop"} - `compat`(`wide_input_strict_output`、`strip_reference_markers`) - `responses` / `embeddings` - `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理) -- `claude_mapping` / `model_aliases` +- `model_aliases` - `env_backed`、`needs_vercel_sync` - `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改 @@ -722,7 +724,6 @@ data: {"type":"message_stop"} - `responses.store_ttl_seconds` - `embeddings.provider` - `auto_delete.mode` -- `claude_mapping` - `model_aliases` - `toolcall` 策略已固定,不再作为可写入字段 @@ -747,7 +748,7 @@ data: {"type":"message_stop"} 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 -导入时会接受 `keys`、`api_keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 +导入时会接受 `keys`、`api_keys`、`accounts`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 > `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。 @@ -1242,7 +1243,7 @@ curl http://localhost:5001/v1/responses \ -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt-5-codex", + "model": "gpt-5.3-codex", "input": "写一个 golang 的 hello world", "stream": true }' @@ -1341,7 +1342,7 @@ curl http://localhost:5001/anthropic/v1/messages \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{ - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "你好"}] }' diff --git a/README.MD b/README.MD index c8686bc..2e78110 100644 --- a/README.MD +++ b/README.MD @@ -122,18 +122,20 @@ flowchart LR | vision | `deepseek-v4-vision` | 默认开启,可由请求参数控制 | ❌ | | vision | `deepseek-v4-vision-search` | 默认开启,可由请求参数控制 | ✅ | -除原生模型外,也支持常见 alias 输入(如 `gpt-5`、`gpt-5-mini`、`gpt-5-codex`、`gpt-4.1`、`o3`、`claude-opus-4-6`、`claude-sonnet-4-5`、`gemini-2.5-pro`、`gemini-2.5-flash` 等),但 `/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID。 +除原生模型外,也支持常见 alias 输入(如 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.3-codex`、`gpt-4.1`、`o3`、`claude-opus-4-6`、`claude-sonnet-4-6`、`gemini-2.5-pro`、`gemini-2.5-flash` 等),但 `/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID。 ### Claude 接口(`GET /anthropic/v1/models`) | 当前常用模型 | 默认映射 | | --- | --- | -| `claude-sonnet-4-5` | `deepseek-v4-flash` | +| `claude-sonnet-4-6` | `deepseek-v4-flash` | | `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest`) | `deepseek-v4-flash` | | `claude-opus-4-6` | `deepseek-v4-pro` | -可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射关系。 -`/anthropic/v1/models` 除上述当前主别名外,还会返回 Claude 4.x snapshots,以及 3.x / 2.x / 1.x 历史模型 ID 与常见 alias,便于旧客户端直接兼容。 +可通过配置中的 `model_aliases` 覆盖映射关系。 +`/anthropic/v1/models` 除上述当前主别名外,还会返回 Claude 4.x snapshots,以及 3.x 历史模型 ID 与常见 alias,便于旧客户端直接兼容。 + +> 截至 2026-04-26:Anthropic 官方模型页当前主推 `claude-opus-4-6`、`claude-sonnet-4-6`、`claude-haiku-4-5`;OpenAI 官方开发者模型页当前推荐从 `gpt-5.5` 开始,ChatGPT Help Center 当前主打 `GPT-5.3 Instant / GPT-5.5 Thinking / GPT-5.5 Pro`。本文档中的 alias 示例按“兼容客户端会传来的最新官方模型 ID”维护。 #### Claude Code 接入避坑(实测) @@ -289,9 +291,9 @@ go run ./cmd/ds2api ], "model_aliases": { "gpt-4o": "deepseek-v4-flash", - "gpt-5": "deepseek-v4-flash", - "gpt-5-mini": "deepseek-v4-flash", - "gpt-5-codex": "deepseek-v4-pro", + "gpt-5.5": "deepseek-v4-flash", + "gpt-5.4-mini": "deepseek-v4-flash", + "gpt-5.3-codex": "deepseek-v4-pro", "o3": "deepseek-v4-pro", "claude-opus-4-6": "deepseek-v4-pro", "gemini-2.5-flash": "deepseek-v4-flash" @@ -306,10 +308,6 @@ go run ./cmd/ds2api "embeddings": { "provider": "deterministic" }, - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" - }, "admin": { "jwt_expire_hours": 24 }, @@ -335,7 +333,7 @@ go run ./cmd/ds2api - `toolcall`:旧字段,当前实现已固定为特征匹配 + 高置信早发;即使保留在配置里也会被忽略 - `responses.store_ttl_seconds`:`/v1/responses/{id}` 的内存缓存 TTL - `embeddings.provider`:embedding 提供方(当前内置 `deterministic/mock/builtin`) -- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`) +- `model_aliases`:全局统一模型映射表,OpenAI / Claude / Gemini 共用;项目内只维护这一套映射入口 - `admin`:管理后台设置(JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新 - `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算,`token_refresh_interval_hours=6` 为默认强制重登间隔 - `auto_delete.mode`:请求结束后如何清理 DeepSeek 远端聊天记录,支持 `none`(默认,不删除)、`single`(仅删除当前会话)、`all`(清空全部会话);旧配置里的 `auto_delete.sessions=true` 仍会被视为 `all` diff --git a/README.en.md b/README.en.md index 91c0bfe..f2e77cb 100644 --- a/README.en.md +++ b/README.en.md @@ -120,18 +120,18 @@ For the full module-by-module architecture and directory responsibilities, see [ | vision | `deepseek-v4-vision` | enabled by default, request-controlled | ❌ | | vision | `deepseek-v4-vision-search` | enabled by default, request-controlled | ✅ | -Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-5`, `gpt-5-mini`, `gpt-5-codex`, `gpt-4.1`, `o3`, `claude-opus-4-6`, `claude-sonnet-4-5`, `gemini-2.5-pro`, `gemini-2.5-flash`), but `/v1/models` returns normalized DeepSeek native model IDs. +Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-4.1`, `o3`, `claude-opus-4-6`, `claude-sonnet-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash`), but `/v1/models` returns normalized DeepSeek native model IDs. ### Claude Endpoint (`GET /anthropic/v1/models`) | Current common model | Default Mapping | | --- | --- | -| `claude-sonnet-4-5` | `deepseek-v4-flash` | +| `claude-sonnet-4-6` | `deepseek-v4-flash` | | `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`) | `deepseek-v4-flash` | | `claude-opus-4-6` | `deepseek-v4-pro` | -Override mapping via `claude_mapping` or `claude_model_mapping` in config. -Besides the current primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x / 2.x / 1.x IDs and common aliases for legacy client compatibility. +Override mapping via the global `model_aliases` config. +Besides the current primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x IDs and common aliases for legacy client compatibility. #### Claude Code integration pitfalls (validated) @@ -295,10 +295,6 @@ The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usua "embeddings": { "provider": "deterministic" }, - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" - }, "admin": { "jwt_expire_hours": 24 }, @@ -317,13 +313,12 @@ The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usua - `keys`: API access keys; clients authenticate via `Authorization: Bearer ` - `accounts`: DeepSeek account list, supports `email` or `mobile` login - `token`: Even if set in `config.json`, it is cleared during load (DS2API does not read persisted tokens from config); runtime tokens are maintained/refreshed in memory only -- `model_aliases`: Map common model names (GPT/Codex/Claude) to DeepSeek models +- `model_aliases`: Single global alias map shared by OpenAI / Claude / Gemini model names - `compat.wide_input_strict_output`: Keep `true` (current default policy) - `compat.strip_reference_markers`: Keep `true`; it strips reference markers from visible output - `toolcall`: Legacy field; the current behavior is fixed to feature matching + high-confidence early emit, and any config value is ignored - `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}` - `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in) -- `claude_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models (still compatible with `claude_model_mapping`) - `admin`: Admin panel settings (JWT expiry, password hash, etc.), hot-reloadable via Admin Settings API - `runtime`: Runtime parameters (concurrency limits, queue sizes, managed token refresh interval), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values, `token_refresh_interval_hours=6` is the default forced re-login interval - `auto_delete.mode`: How to clean up DeepSeek remote chat records after each request completes. Supported values: `none` (default, no deletion), `single` (delete only the current session), `all` (delete all sessions); legacy `auto_delete.sessions=true` is still treated as `all` diff --git a/config.example.json b/config.example.json index ce3d902..f93a2c3 100644 --- a/config.example.json +++ b/config.example.json @@ -39,7 +39,8 @@ ], "model_aliases": { "gpt-4o": "deepseek-v4-flash", - "gpt-5-codex": "deepseek-v4-pro", + "gpt-5.5": "deepseek-v4-flash", + "gpt-5.3-codex": "deepseek-v4-pro", "o3": "deepseek-v4-pro" }, "compat": { @@ -56,10 +57,6 @@ "embeddings": { "provider": "deterministic" }, - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" - }, "admin": { "jwt_expire_hours": 24 }, diff --git a/docs/README.md b/docs/README.md index f8b5d8d..837fc87 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ ### 专题文档 +- [API -> 网页对话纯文本兼容主链路说明](./prompt-compatibility.md) - [Tool Calling 统一语义](./toolcall-semantics.md) - [DeepSeek SSE 行为结构说明(逆向观察)](./DeepSeekSSE行为结构说明-2026-04-05.md) @@ -23,6 +24,7 @@ - `README.MD` / `README.en.md`:面向首次接触用户,保留“是什么 + 怎么快速跑起来”。 - `docs/ARCHITECTURE*.md`:面向开发者,集中维护项目结构、模块职责与调用链。 - `API*.md`:面向客户端接入者,聚焦接口行为、鉴权和示例。 +- `docs/prompt-compatibility.md`:面向维护者,集中维护“API -> 网页对话纯文本上下文”的统一兼容语义;相关行为修改时必须同步更新。 - 其他 `docs/*.md`:主题化说明,避免在多个文档重复粘贴同一段内容。 --- @@ -42,6 +44,7 @@ Recommended reading order: ### Topical docs +- [API -> pure-text web-chat compatibility pipeline](./prompt-compatibility.md) - [Tool-calling unified semantics](./toolcall-semantics.md) - [DeepSeek SSE behavior notes (reverse-engineered)](./DeepSeekSSE行为结构说明-2026-04-05.md) @@ -50,4 +53,5 @@ Recommended reading order: - `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. +- `docs/prompt-compatibility.md`: maintainer-oriented source of truth for the “API -> pure-text web-chat context” compatibility flow; update it whenever related behavior changes. - Other `docs/*.md`: focused topics, avoid copy-pasting the same section into multiple files. diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md new file mode 100644 index 0000000..1b03ca0 --- /dev/null +++ b/docs/prompt-compatibility.md @@ -0,0 +1,391 @@ +# API -> 网页对话纯文本兼容主链路说明 + +文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [接口文档](../API.md) / [测试指南](./TESTING.md) + +> 本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。 +> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、history split、下游 completion payload 组装等行为,都必须同步更新本文档。 + +## 1. 核心结论 + +DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`、`attachments` 原样转发给下游。 + +而是把这些高层 API 语义,统一压缩成 DeepSeek 网页对话更容易理解的三类输入: + +1. `prompt` + 一个单字符串,里面带有角色标记、system 指令、历史消息、assistant reasoning 标签、历史 tool call XML 等。 +2. `ref_file_ids` + 一个文件引用数组,承载附件、inline 上传文件,以及必要时被拆出去的历史文件。 +3. 控制位 + 例如 `thinking_enabled`、`search_enabled`、部分 passthrough 参数。 + +也就是说,项目最重要的兼容动作,是把“结构化 API 会话”翻译成“网页对话纯文本上下文 + 文件引用”。 + +## 2. 为什么这是核心产物 + +因为对下游来说,真正稳定的输入面不是 OpenAI/Claude/Gemini 的原生 schema,而是: + +- 一段连续的对话 prompt +- 一组可引用文件 +- 少量开关位 + +这也是为什么很多表面上看像“协议兼容”的代码,最终都会收敛到同一类逻辑: + +- 先把不同协议的消息统一成内部消息序列 +- 再把工具声明改写成 system prompt 文本 +- 再把历史 tool call / tool result 改写成 prompt 可见内容 +- 最后输出成 DeepSeek completion payload + +## 3. 统一心智模型 + +当前主链路可以这样理解: + +```text +客户端请求 + -> 协议适配层(OpenAI / Claude / Gemini) + -> 统一消息标准化 + -> tool prompt 注入 + -> DeepSeek 风格 prompt 拼装 + -> 文件收集 / inline 上传 / history split + -> completion payload + -> 下游网页对话接口 +``` + +对应的关键代码入口: + +- OpenAI Chat / Responses: + [internal/adapter/openai/standard_request.go](../internal/adapter/openai/standard_request.go) +- OpenAI prompt 组装: + [internal/adapter/openai/prompt_build.go](../internal/adapter/openai/prompt_build.go) +- OpenAI 消息标准化: + [internal/adapter/openai/message_normalize.go](../internal/adapter/openai/message_normalize.go) +- Claude 标准化: + [internal/adapter/claude/standard_request.go](../internal/adapter/claude/standard_request.go) +- Claude 消息与 tool_use/tool_result 归一: + [internal/adapter/claude/handler_utils.go](../internal/adapter/claude/handler_utils.go) +- Gemini 复用 OpenAI prompt builder: + [internal/adapter/gemini/convert_request.go](../internal/adapter/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) + +## 4. 下游真正收到的东西 + +在“完成标准化后”,下游 completion payload 的核心形态是: + +```json +{ + "chat_session_id": "session-id", + "model_type": "default", + "parent_message_id": null, + "prompt": "<|begin▁of▁sentence|>...", + "ref_file_ids": [ + "file-history", + "file-systemprompt", + "file-other-attachment" + ], + "thinking_enabled": true, + "search_enabled": false +} +``` + +重点是: + +- `prompt` 才是对话上下文主载体。 +- `ref_file_ids` 只承载文件引用,不承载普通文本消息。 +- `tools` 不会作为“原生工具 schema”直接下发给下游,而是被改写进 `prompt`。 + +## 5. prompt 是怎么拼出来的 + +### 5.1 角色标记 + +最终 prompt 使用 DeepSeek 风格角色标记: + +- `<|begin▁of▁sentence|>` +- `<|System|>` +- `<|User|>` +- `<|Assistant|>` +- `<|Tool|>` +- `<|end▁of▁instructions|>` +- `<|end▁of▁sentence|>` +- `<|end▁of▁toolresults|>` + +实现位置: +[internal/prompt/messages.go](../internal/prompt/messages.go) + +### 5.2 thinking continuity 说明 + +如果启用了 thinking,会在最前面额外插入一个 system block,提醒模型: + +- 继续既有会话,不要重开 +- earlier messages 是 binding context +- 不要把最终回答只留在 reasoning 里 + +这部分不是客户端原始消息,而是兼容层主动补进去的连续性契约。 + +### 5.3 相邻同角色消息会合并 + +在最终 `MessagesPrepareWithThinking` 中,相邻同 role 的消息会被合并成一个块,中间插入空行。 + +这意味着: + +- prompt 中看到的是“合并后的 role block” +- 不是客户端传来的逐条 message 原样排列 + +## 6. tools 为什么是“文本注入”,不是原生下发 + +当前项目把工具能力视为“prompt 约束的一部分”。 + +具体做法: + +1. 把每个 tool 的名称、描述、参数 schema 序列化成文本。 +2. 拼成 `You have access to these tools:` 大段说明。 +3. 再附上统一的 XML tool call 格式约束。 +4. 把这整段内容并入 system prompt。 + +OpenAI 路径实现: +[internal/adapter/openai/handler_toolcall_format.go](../internal/adapter/openai/handler_toolcall_format.go) + +Claude 路径实现: +[internal/adapter/claude/handler_utils.go](../internal/adapter/claude/handler_utils.go) + +统一工具调用格式模板: +[internal/toolcall/tool_prompt.go](../internal/toolcall/tool_prompt.go) + +这也是项目“网页对话纯文本兼容”的关键设计: + +- tools 对下游来说,本质上是 prompt 内规则 +- 不是 native tool schema transport + +## 7. assistant 的 tool_calls / reasoning 如何保留 + +### 7.1 reasoning 保留方式 + +assistant 的 reasoning 会变成一个显式标签块: + +```text +[reasoning_content] +... +[/reasoning_content] +``` + +然后再接可见回答正文。 + +### 7.2 历史 tool_calls 保留方式 + +assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON,而会转成 prompt 可见的 XML: + +```xml + + + read_file + + + + + +``` + +这件事很重要,因为它决定了: + +- 历史工具调用在 prompt 中是“可见文本历史” +- 不是“隐藏结构化元数据” + +实现位置: +[internal/prompt/tool_calls.go](../internal/prompt/tool_calls.go) + +### 7.3 tool result 保留方式 + +tool / function role 的结果会作为 `<|Tool|>...<|end▁of▁toolresults|>` 进入 prompt。 + +如果 tool content 为空,当前会补成字符串 `"null"`,避免整个 tool turn 丢失。 + +## 8. files、附件、systemprompt 文件的实际语义 + +这里要明确区分两类东西: + +1. 文本型 system prompt + 例如 OpenAI `developer` / `system` / Responses `instructions` / Claude top-level `system` + 这类会进入 `prompt`。 +2. 文件型 systemprompt + 例如通过附件、`input_file`、base64、data URL 上传的文件 + 这类不会直接内联进 `prompt`,而是进入 `ref_file_ids`。 + +OpenAI 文件相关实现: + +- inline/base64/data URL 上传: + [internal/adapter/openai/file_inline_upload.go](../internal/adapter/openai/file_inline_upload.go) +- 文件 ID 收集: + [internal/adapter/openai/file_refs.go](../internal/adapter/openai/file_refs.go) + +结论: + +- “systemprompt 文字”在 prompt 里 +- “systemprompt 文件”通常只在 `ref_file_ids` 里 + +除非调用方自己把文件内容展开后再塞进 system/developer 文本,否则文件内容不会自动出现在 prompt 正文。 + +## 9. 多轮历史为什么不会一直完整内联在 prompt + +默认情况下,history split 是开启的,且默认从第 2 个 user turn 起就可能触发。 + +相关实现: + +- 配置访问器: + [internal/config/store_accessors.go](../internal/config/store_accessors.go) +- 历史拆分: + [internal/adapter/openai/history_split.go](../internal/adapter/openai/history_split.go) + +触发后行为: + +1. 旧历史消息被切出去。 +2. 旧历史会被重新序列化成一个文本文件。 +3. 文件名固定是 `IGNORE`。 +4. 该文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面。 +5. live prompt 只保留: + - system / developer + - 最新 user turn 起的上下文 + +历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript: + +```text +[file content end] + +<|begin▁of▁sentence|><|User|>...<|Assistant|>...<|Tool|>... + +[file name]: IGNORE +[file content begin] +``` + +所以“完整上下文”在当前实现里,其实通常分散在两处: + +- `prompt` 里的 live context +- `ref_file_ids` 指向的 history transcript file + +## 10. 各协议入口的差异 + +### 10.1 OpenAI Chat / Responses + +特点: + +- `developer` 会映射到 `system` +- Responses `instructions` 会 prepend 为 system message +- `tools` 会注入 system prompt +- `attachments` / `input_file` / inline 文件会进入 `ref_file_ids` +- history split 主要在这条链路里生效 + +### 10.2 Claude Messages + +特点: + +- top-level `system` 优先作为系统提示 +- `tool_use` / `tool_result` 会被转换成统一的 assistant/tool 历史语义 +- `tools` 同样会被并进 system prompt +- 当前代码里没有像 OpenAI 那样完整的 `ref_file_ids` 附件链路 + +### 10.3 Gemini + +特点: + +- `systemInstruction`、`contents.parts`、`functionCall`、`functionResponse` 会先归一 +- tools 会转成 OpenAI 风格 function schema +- prompt 构建复用 OpenAI 的 `BuildPromptForAdapter` + +也就是说,Gemini 在“最终 prompt 语义”上,尽量和 OpenAI 保持一致。 + +## 11. 一份贴近真实的最终上下文示意 + +假设用户发来一个多轮请求: + +- 有 system/developer 文本 +- 有 tools +- 有一个文件型 systemprompt 附件 +- 有历史 assistant tool call / tool result +- history split 已触发 + +那么最终上下文更接近: + +```json +{ + "prompt": "<|begin▁of▁sentence|><|System|>continuity instructions...\\n\\n原 system / developer\\n\\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>最新问题<|Assistant|>", + "ref_file_ids": [ + "file-history-ignore", + "file-systemprompt", + "file-other-attachment" + ], + "thinking_enabled": true, + "search_enabled": false +} +``` + +这正是“API 转网页对话纯文本”的核心成果: + +- 大部分结构化语义被压进 `prompt` +- 文件保持文件 +- 历史必要时拆文件 + +## 12. 修改时必须同步本文档的场景 + +只要触碰以下任一类行为,就必须在同一提交或同一 PR 中更新本文档: + +- 角色映射变更 +- system / developer / instructions 合并规则变更 +- assistant reasoning 保留格式变更 +- assistant 历史 `tool_calls` 的 XML 呈现方式变更 +- tool result 注入方式变更 +- tool prompt 模板或 tool_choice 约束变更 +- inline 文件上传 / 文件引用收集规则变更 +- history split 触发条件、上传格式、`IGNORE` 包装格式变更 +- completion payload 字段语义变更 +- Claude / Gemini 对这套统一语义的复用关系变更 + +优先检查这些文件: + +- `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/prompt/messages.go` +- `internal/prompt/tool_calls.go` +- `internal/util/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/util/...` + +如果改的是 tool call 相关兼容语义,还应同时检查: + +- `go test ./internal/toolcall/...` +- `node --test tests/node/stream-tool-sieve.test.js` + +## 14. 文档同步约定 + +本文档是这条兼容链路的专项说明。 + +如果外部接口行为也变了,还应同步检查: + +- [API.md](../API.md) +- [API.en.md](../API.en.md) +- [docs/toolcall-semantics.md](./toolcall-semantics.md) + +原则是: + +- 内部主链路变化,至少更新本文档 +- 外部可见契约变化,再同步更新 API 文档 diff --git a/internal/adapter/claude/convert.go b/internal/adapter/claude/convert.go index dbb5e1a..2233a65 100644 --- a/internal/adapter/claude/convert.go +++ b/internal/adapter/claude/convert.go @@ -4,7 +4,7 @@ import ( "ds2api/internal/claudeconv" ) -const defaultClaudeModel = "claude-sonnet-4-5" +const defaultClaudeModel = "claude-sonnet-4-6" func convertClaudeToDeepSeek(claudeReq map[string]any, store ConfigReader) map[string]any { return claudeconv.ConvertClaudeToDeepSeek(claudeReq, store, defaultClaudeModel) diff --git a/internal/adapter/claude/deps.go b/internal/adapter/claude/deps.go index 0088e81..7f82ba8 100644 --- a/internal/adapter/claude/deps.go +++ b/internal/adapter/claude/deps.go @@ -21,7 +21,7 @@ type DeepSeekCaller interface { } type ConfigReader interface { - ClaudeMapping() map[string]string + ModelAliases() map[string]string CompatStripReferenceMarkers() bool } diff --git a/internal/adapter/claude/deps_injection_test.go b/internal/adapter/claude/deps_injection_test.go index c585b36..c880dc4 100644 --- a/internal/adapter/claude/deps_injection_test.go +++ b/internal/adapter/claude/deps_injection_test.go @@ -3,13 +3,13 @@ package claude import "testing" type mockClaudeConfig struct { - m map[string]string + aliases map[string]string } -func (m mockClaudeConfig) ClaudeMapping() map[string]string { return m.m } -func (mockClaudeConfig) CompatStripReferenceMarkers() bool { return true } +func (m mockClaudeConfig) ModelAliases() map[string]string { return m.aliases } +func (mockClaudeConfig) CompatStripReferenceMarkers() bool { return true } -func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { +func TestNormalizeClaudeRequestUsesGlobalAliasMapping(t *testing.T) { req := map[string]any{ "model": "claude-opus-4-6", "messages": []any{ @@ -17,9 +17,8 @@ func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { }, } out, err := normalizeClaudeRequest(mockClaudeConfig{ - m: map[string]string{ - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro-search", + aliases: map[string]string{ + "claude-opus-4-6": "deepseek-v4-pro-search", }, }, req) if err != nil { @@ -32,3 +31,23 @@ func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { t.Fatalf("unexpected flags: thinking=%v search=%v", out.Standard.Thinking, out.Standard.Search) } } + +func TestNormalizeClaudeRequestPrefersGlobalAliasMapping(t *testing.T) { + req := map[string]any{ + "model": "claude-sonnet-4-6", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + out, err := normalizeClaudeRequest(mockClaudeConfig{ + aliases: map[string]string{ + "claude-sonnet-4-6": "deepseek-v4-flash", + }, + }, req) + if err != nil { + t.Fatalf("normalizeClaudeRequest error: %v", err) + } + if out.Standard.ResolvedModel != "deepseek-v4-flash" { + t.Fatalf("expected global alias to win for explicit model, got=%q", out.Standard.ResolvedModel) + } +} diff --git a/internal/adapter/claude/handler_messages.go b/internal/adapter/claude/handler_messages.go index 526d316..6ae23ab 100644 --- a/internal/adapter/claude/handler_messages.go +++ b/internal/adapter/claude/handler_messages.go @@ -44,7 +44,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C model, _ := req["model"].(string) stream := util.ToBool(req["stream"]) - // Preserve claude_mapping (fast/slow/opus routing) while proxying via OpenAI. + // Use the shared global model resolver so Claude/OpenAI/Gemini stay consistent. translateModel := model if store != nil { if norm, normErr := normalizeClaudeRequest(store, cloneMap(req)); normErr == nil && strings.TrimSpace(norm.Standard.ResolvedModel) != "" { diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/adapter/claude/proxy_vercel_test.go index 56ff708..67e62de 100644 --- a/internal/adapter/claude/proxy_vercel_test.go +++ b/internal/adapter/claude/proxy_vercel_test.go @@ -9,12 +9,10 @@ import ( ) type claudeProxyStoreStub struct { - mapping map[string]string + aliases map[string]string } -func (s claudeProxyStoreStub) ClaudeMapping() map[string]string { - return s.mapping -} +func (s claudeProxyStoreStub) ModelAliases() map[string]string { return s.aliases } func (claudeProxyStoreStub) CompatStripReferenceMarkers() bool { return true } @@ -23,6 +21,27 @@ type openAIProxyStub struct { body string } +func TestClaudeProxyViaOpenAIPrefersGlobalAliasMapping(t *testing.T) { + openAI := &openAIProxyCaptureStub{} + h := &Handler{ + Store: claudeProxyStoreStub{ + aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}, + }, + OpenAI: openAI, + } + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}],"stream":false}`)) + rec := httptest.NewRecorder() + + h.Messages(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + if got := strings.TrimSpace(openAI.seenModel); got != "deepseek-v4-flash" { + t.Fatalf("expected global alias mapped proxy model deepseek-v4-flash, got %q", got) + } +} + func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) { if s.status == 0 { s.status = http.StatusOK @@ -68,10 +87,10 @@ func TestClaudeProxyViaOpenAIVercelPreparePassthrough(t *testing.T) { } } -func TestClaudeProxyViaOpenAIPreservesClaudeMapping(t *testing.T) { +func TestClaudeProxyViaOpenAIUsesGlobalAliasMapping(t *testing.T) { openAI := &openAIProxyCaptureStub{} h := &Handler{ - Store: claudeProxyStoreStub{mapping: map[string]string{"fast": "deepseek-v4-flash", "slow": "deepseek-v4-pro"}}, + Store: claudeProxyStoreStub{aliases: map[string]string{"claude-3-opus": "deepseek-v4-pro"}}, OpenAI: openAI, } req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-3-opus","messages":[{"role":"user","content":"hi"}],"stream":false}`)) diff --git a/internal/adapter/claude/stream_status_test.go b/internal/adapter/claude/stream_status_test.go index a3d4633..2a2586f 100644 --- a/internal/adapter/claude/stream_status_test.go +++ b/internal/adapter/claude/stream_status_test.go @@ -21,12 +21,7 @@ func (streamStatusClaudeOpenAIStub) ChatCompletions(w http.ResponseWriter, _ *ht type streamStatusClaudeStoreStub struct{} -func (streamStatusClaudeStoreStub) ClaudeMapping() map[string]string { - return map[string]string{ - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro", - } -} +func (streamStatusClaudeStoreStub) ModelAliases() map[string]string { return nil } func (streamStatusClaudeStoreStub) CompatStripReferenceMarkers() bool { return true } diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index 7decbde..fe5faff 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -82,23 +82,6 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { importedAccounts++ } - if len(incoming.ClaudeMapping) > 0 { - if next.ClaudeMapping == nil { - next.ClaudeMapping = map[string]string{} - } - for k, v := range incoming.ClaudeMapping { - next.ClaudeMapping[k] = v - } - } - if len(incoming.ClaudeModelMap) > 0 { - if next.ClaudeModelMap == nil { - next.ClaudeModelMap = map[string]string{} - } - for k, v := range incoming.ClaudeModelMap { - next.ClaudeModelMap[k] = v - } - } - if len(incoming.ModelAliases) > 0 { if next.ModelAliases == nil { next.ModelAliases = map[string]string{} diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index 20e5b1d..9fc876f 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -18,12 +18,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { "env_source_present": h.Store.HasEnvConfigSource(), "env_writeback_enabled": h.Store.IsEnvWritebackEnabled(), "config_path": h.Store.ConfigPath(), - "claude_mapping": func() map[string]string { - if len(snap.ClaudeMapping) > 0 { - return snap.ClaudeMapping - } - return snap.ClaudeModelMap - }(), + "model_aliases": snap.ModelAliases, } accounts := make([]map[string]any, 0, len(snap.Accounts)) for _, acc := range snap.Accounts { diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index 1929f26..7f6afb8 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -58,12 +58,12 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { } c.Accounts = accounts } - if m, ok := req["claude_mapping"].(map[string]any); ok { - newMap := map[string]string{} + if m, ok := req["model_aliases"].(map[string]any); ok { + aliases := make(map[string]string, len(m)) for k, v := range m { - newMap[k] = fmt.Sprintf("%v", v) + aliases[k] = fmt.Sprintf("%v", v) } - c.ClaudeMapping = newMap + c.ModelAliases = aliases } return nil }) diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index c02d421..0cc297e 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -21,7 +21,7 @@ func boolFrom(v any) bool { } } -func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, map[string]string, error) { +func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, error) { var ( adminCfg *config.AdminConfig runtimeCfg *config.RuntimeConfig @@ -30,7 +30,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi embCfg *config.EmbeddingsConfig autoDeleteCfg *config.AutoDeleteConfig historySplitCfg *config.HistorySplitConfig - claudeMap map[string]string aliasMap map[string]string ) @@ -39,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["jwt_expire_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.JWTExpireHours = n } @@ -51,33 +50,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["account_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxInflight = n } if v, exists := raw["account_max_queue"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxQueue = n } if v, exists := raw["global_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.GlobalMaxInflight = n } if v, exists := raw["token_refresh_interval_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TokenRefreshIntervalHours = n } if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } runtimeCfg = cfg } @@ -100,7 +99,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["store_ttl_seconds"]; exists { n := intFrom(v) if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.StoreTTLSeconds = n } @@ -112,27 +111,17 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["provider"]; exists { p := strings.TrimSpace(fmt.Sprintf("%v", v)) if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.Provider = p } embCfg = cfg } - if raw, ok := req["claude_mapping"].(map[string]any); ok { - claudeMap = map[string]string{} - for k, v := range raw { - key := strings.TrimSpace(k) - val := strings.TrimSpace(fmt.Sprintf("%v", v)) - if key == "" || val == "" { - continue - } - claudeMap[key] = val - } - } - if raw, ok := req["model_aliases"].(map[string]any); ok { - aliasMap = map[string]string{} + if aliasMap == nil { + aliasMap = map[string]string{} + } for k, v := range raw { key := strings.TrimSpace(k) val := strings.TrimSpace(fmt.Sprintf("%v", v)) @@ -148,7 +137,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["mode"]; exists { mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) if err := config.ValidateAutoDeleteMode(mode); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } if mode == "" { mode = "none" @@ -170,15 +159,15 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["trigger_after_turns"]; exists { n := intFrom(v) if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TriggerAfterTurns = &n } if err := config.ValidateHistorySplitConfig(*cfg); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } historySplitCfg = cfg } - return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, nil + return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, aliasMap, nil } diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go index dc060a8..2f9e7d2 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/admin/handler_settings_read.go @@ -34,7 +34,6 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "enabled": h.Store.HistorySplitEnabled(), "trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(), }, - "claude_mapping": settingsClaudeMapping(snap), "model_aliases": snap.ModelAliases, "env_backed": h.Store.IsEnvBacked(), "needs_vercel_sync": needsSync, diff --git a/internal/admin/handler_settings_runtime.go b/internal/admin/handler_settings_runtime.go index b090c38..a713c08 100644 --- a/internal/admin/handler_settings_runtime.go +++ b/internal/admin/handler_settings_runtime.go @@ -42,13 +42,3 @@ func defaultRuntimeRecommended(accountCount, maxPer int) int { } return accountCount * maxPer } - -func settingsClaudeMapping(c config.Config) map[string]string { - if len(c.ClaudeMapping) > 0 { - return c.ClaudeMapping - } - if len(c.ClaudeModelMap) > 0 { - return c.ClaudeModelMap - } - return map[string]string{"fast": "deepseek-v4-flash", "slow": "deepseek-v4-pro"} -} diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index 4300cfe..e231739 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -346,6 +346,34 @@ func TestUpdateConfigLegacyKeysPreserveStructuredMetadata(t *testing.T) { } } +func TestUpdateConfigReplacesModelAliases(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["k1"], + "model_aliases":{"claude-sonnet-4-6":"deepseek-v4-flash"} + }`) + + payload := map[string]any{ + "model_aliases": map[string]any{ + "gpt-5.5": "deepseek-v4-pro", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.ModelAliases) != 1 { + t.Fatalf("expected aliases to be replaced, got %#v", snap.ModelAliases) + } + if snap.ModelAliases["gpt-5.5"] != "deepseek-v4-pro" { + t.Fatalf("expected updated alias, got %#v", snap.ModelAliases) + } +} + func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) { hash := authn.HashAdminPassword("old-password") h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index ee4105a..2510d01 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) + adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, aliasMap, err := parseSettingsUpdateRequest(req) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -75,10 +75,6 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns } } - if claudeMap != nil { - c.ClaudeMapping = claudeMap - c.ClaudeModelMap = nil - } if aliasMap != nil { c.ModelAliases = aliasMap } diff --git a/internal/claudeconv/convert.go b/internal/claudeconv/convert.go index aa64e5a..cd6e156 100644 --- a/internal/claudeconv/convert.go +++ b/internal/claudeconv/convert.go @@ -1,34 +1,23 @@ package claudeconv -import "strings" +import ( + "strings" -type ClaudeMappingProvider interface { - ClaudeMapping() map[string]string -} + "ds2api/internal/config" +) -func ConvertClaudeToDeepSeek(claudeReq map[string]any, mappingProvider ClaudeMappingProvider, defaultClaudeModel string) map[string]any { +func ConvertClaudeToDeepSeek(claudeReq map[string]any, aliasProvider config.ModelAliasReader, defaultClaudeModel string) map[string]any { messages, _ := claudeReq["messages"].([]any) model, _ := claudeReq["model"].(string) if model == "" { model = defaultClaudeModel } - mapping := map[string]string{} - if mappingProvider != nil { - mapping = mappingProvider.ClaudeMapping() - } - dsModel := mapping["fast"] - if dsModel == "" { + dsModel, ok := config.ResolveModel(aliasProvider, model) + if !ok || strings.TrimSpace(dsModel) == "" { dsModel = "deepseek-v4-flash" } - modelLower := strings.ToLower(model) - if strings.Contains(modelLower, "opus") || strings.Contains(modelLower, "reasoner") || strings.Contains(modelLower, "slow") { - if slow := mapping["slow"]; slow != "" { - dsModel = slow - } - } - convertedMessages := make([]any, 0, len(messages)+1) if system, ok := claudeReq["system"].(string); ok && system != "" { convertedMessages = append(convertedMessages, map[string]any{"role": "system", "content": system}) diff --git a/internal/config/codec.go b/internal/config/codec.go index 11bf1d6..246df9b 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -26,12 +26,6 @@ func (c Config) MarshalJSON() ([]byte, error) { if len(c.Proxies) > 0 { m["proxies"] = c.Proxies } - if len(c.ClaudeMapping) > 0 { - m["claude_mapping"] = c.ClaudeMapping - } - if len(c.ClaudeModelMap) > 0 { - m["claude_model_mapping"] = c.ClaudeModelMap - } if len(c.ModelAliases) > 0 { m["model_aliases"] = c.ModelAliases } @@ -88,13 +82,8 @@ func (c *Config) UnmarshalJSON(b []byte) error { return fmt.Errorf("invalid field %q: %w", k, err) } case "claude_mapping": - if err := json.Unmarshal(v, &c.ClaudeMapping); err != nil { - return fmt.Errorf("invalid field %q: %w", k, err) - } case "claude_model_mapping": - if err := json.Unmarshal(v, &c.ClaudeModelMap); err != nil { - return fmt.Errorf("invalid field %q: %w", k, err) - } + // Removed legacy mapping fields are ignored instead of persisted. case "model_aliases": if err := json.Unmarshal(v, &c.ModelAliases); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -150,15 +139,13 @@ func (c *Config) UnmarshalJSON(b []byte) error { func (c Config) Clone() Config { clone := Config{ - Keys: slices.Clone(c.Keys), - APIKeys: slices.Clone(c.APIKeys), - Accounts: slices.Clone(c.Accounts), - Proxies: slices.Clone(c.Proxies), - ClaudeMapping: cloneStringMap(c.ClaudeMapping), - ClaudeModelMap: cloneStringMap(c.ClaudeModelMap), - ModelAliases: cloneStringMap(c.ModelAliases), - Admin: c.Admin, - Runtime: c.Runtime, + Keys: slices.Clone(c.Keys), + APIKeys: slices.Clone(c.APIKeys), + Accounts: slices.Clone(c.Accounts), + Proxies: slices.Clone(c.Proxies), + ModelAliases: cloneStringMap(c.ModelAliases), + Admin: c.Admin, + Runtime: c.Runtime, Compat: CompatConfig{ WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput), StripReferenceMarkers: cloneBoolPtr(c.Compat.StripReferenceMarkers), diff --git a/internal/config/config.go b/internal/config/config.go index dd1d5df..43856c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,8 +12,6 @@ type Config struct { APIKeys []APIKey `json:"api_keys,omitempty"` Accounts []Account `json:"accounts,omitempty"` Proxies []Proxy `json:"proxies,omitempty"` - ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` - ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"` ModelAliases map[string]string `json:"model_aliases,omitempty"` Admin AdminConfig `json:"admin,omitempty"` Runtime RuntimeConfig `json:"runtime,omitempty"` @@ -100,6 +98,8 @@ func (c *Config) NormalizeCredentials() { c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name) c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark) } + + c.normalizeModelAliases() } // DropInvalidAccounts removes accounts that cannot be addressed by admin APIs @@ -119,6 +119,27 @@ func (c *Config) DropInvalidAccounts() { c.Accounts = kept } +func (c *Config) normalizeModelAliases() { + if c == nil { + return + } + + aliases := map[string]string{} + for k, v := range c.ModelAliases { + key := strings.TrimSpace(lower(k)) + val := strings.TrimSpace(lower(v)) + if key == "" || val == "" { + continue + } + aliases[key] = val + } + if len(aliases) == 0 { + c.ModelAliases = nil + } else { + c.ModelAliases = aliases + } +} + type CompatConfig struct { WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"` StripReferenceMarkers *bool `json:"strip_reference_markers,omitempty"` diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index f1658ef..7741777 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -145,12 +145,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { trueVal := true falseVal := false cfg := Config{ - Keys: []string{"key1", "key2"}, - Accounts: []Account{{Email: "user@example.com", Password: "pass", Token: "tok"}}, - ClaudeMapping: map[string]string{ - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro", - }, + Keys: []string{"key1", "key2"}, + Accounts: []Account{{Email: "user@example.com", Password: "pass", Token: "tok"}}, + ModelAliases: map[string]string{"Claude-Sonnet-4-6": "DeepSeek-V4-Flash"}, AutoDelete: AutoDeleteConfig{ Mode: "single", }, @@ -188,8 +185,8 @@ func TestConfigJSONRoundtrip(t *testing.T) { if len(decoded.Accounts) != 1 || decoded.Accounts[0].Email != "user@example.com" { t.Fatalf("unexpected accounts: %#v", decoded.Accounts) } - if decoded.ClaudeMapping["fast"] != "deepseek-v4-flash" { - t.Fatalf("unexpected claude mapping: %#v", decoded.ClaudeMapping) + if decoded.ModelAliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("unexpected normalized model aliases: %#v", decoded.ModelAliases) } if decoded.Runtime.TokenRefreshIntervalHours != 12 { t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours) @@ -255,6 +252,23 @@ func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { } } +func TestConfigUnmarshalJSONIgnoresRemovedLegacyModelMappings(t *testing.T) { + raw := `{"keys":["k1"],"accounts":[],"claude_mapping":{"fast":"deepseek-v4-pro"},"claude_model_mapping":{"slow":"deepseek-v4-pro"}}` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(cfg.ModelAliases) != 0 { + t.Fatalf("expected removed legacy mappings to be ignored, got %#v", cfg.ModelAliases) + } + if _, ok := cfg.AdditionalFields["claude_mapping"]; ok { + t.Fatalf("expected removed legacy field not to persist in additional fields: %#v", cfg.AdditionalFields) + } + if _, ok := cfg.AdditionalFields["claude_model_mapping"]; ok { + t.Fatalf("expected removed legacy field not to persist in additional fields: %#v", cfg.AdditionalFields) + } +} + // ─── Config.Clone ──────────────────────────────────────────────────── func TestConfigCloneIsDeepCopy(t *testing.T) { @@ -262,11 +276,9 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { trueVal := true turns := 2 cfg := Config{ - Keys: []string{"key1"}, - Accounts: []Account{{Email: "user@test.com", Token: "token"}}, - ClaudeMapping: map[string]string{ - "fast": "deepseek-v4-flash", - }, + Keys: []string{"key1"}, + Accounts: []Account{{Email: "user@test.com", Token: "token"}}, + ModelAliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}, Compat: CompatConfig{ StripReferenceMarkers: &falseVal, }, @@ -282,7 +294,7 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { // Modify original cfg.Keys[0] = "modified" cfg.Accounts[0].Email = "modified@test.com" - cfg.ClaudeMapping["fast"] = "modified-model" + cfg.ModelAliases["claude-sonnet-4-6"] = "modified-model" if cfg.Compat.StripReferenceMarkers != nil { *cfg.Compat.StripReferenceMarkers = true } @@ -300,8 +312,8 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cloned.Accounts[0].Email != "user@test.com" { t.Fatalf("clone accounts was affected: %#v", cloned.Accounts) } - if cloned.ClaudeMapping["fast"] != "deepseek-v4-flash" { - t.Fatalf("clone claude mapping was affected: %#v", cloned.ClaudeMapping) + if cloned.ModelAliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("clone model aliases was affected: %#v", cloned.ModelAliases) } if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers { t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers) @@ -652,25 +664,27 @@ func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) { } } -func TestStoreClaudeMapping(t *testing.T) { - t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-v4-flash","slow":"deepseek-v4-pro"}}`) +func TestStoreModelAliasesIncludesDefaultsAndOverrides(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"model_aliases":{"claude-opus-4-6":"deepseek-v4-pro-search"}}`) store := LoadStore() - mapping := store.ClaudeMapping() - if mapping["fast"] != "deepseek-v4-flash" { - t.Fatalf("unexpected fast mapping: %q", mapping["fast"]) + aliases := store.ModelAliases() + if aliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("expected default alias to remain available, got %q", aliases["claude-sonnet-4-6"]) } - if mapping["slow"] != "deepseek-v4-pro" { - t.Fatalf("unexpected slow mapping: %q", mapping["slow"]) + if aliases["claude-opus-4-6"] != "deepseek-v4-pro-search" { + t.Fatalf("expected custom alias override, got %q", aliases["claude-opus-4-6"]) } } -func TestStoreClaudeMappingEmpty(t *testing.T) { +func TestStoreModelAliasesDefault(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[]}`) store := LoadStore() - mapping := store.ClaudeMapping() - // Even without config mapping, there are defaults - if mapping == nil { - t.Fatal("expected non-nil mapping (may contain defaults)") + aliases := store.ModelAliases() + if aliases == nil { + t.Fatal("expected non-nil aliases") + } + if aliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("expected built-in alias, got %q", aliases["claude-sonnet-4-6"]) } } diff --git a/internal/config/model_alias_test.go b/internal/config/model_alias_test.go index c00aed6..f537b21 100644 --- a/internal/config/model_alias_test.go +++ b/internal/config/model_alias_test.go @@ -20,6 +20,47 @@ func TestResolveModelAlias(t *testing.T) { } } +func TestResolveLatestOpenAIAlias(t *testing.T) { + got, ok := ResolveModel(nil, "gpt-5.5") + if !ok || got != "deepseek-v4-flash" { + t.Fatalf("expected alias gpt-5.5 -> deepseek-v4-flash, got ok=%v model=%q", ok, got) + } +} + +func TestResolveLatestClaudeAlias(t *testing.T) { + got, ok := ResolveModel(nil, "claude-sonnet-4-6") + if !ok || got != "deepseek-v4-flash" { + t.Fatalf("expected alias claude-sonnet-4-6 -> deepseek-v4-flash, got ok=%v model=%q", ok, got) + } +} + +func TestResolveExpandedHistoricalAliases(t *testing.T) { + cases := []struct { + name string + model string + want string + }{ + {name: "openai old chatgpt", model: "chatgpt-4o", want: "deepseek-v4-flash"}, + {name: "openai codex max", model: "gpt-5.1-codex-max", want: "deepseek-v4-pro"}, + {name: "openai deep research", model: "o3-deep-research", want: "deepseek-v4-pro-search"}, + {name: "openai historical reasoning", model: "o1-preview", want: "deepseek-v4-pro"}, + {name: "claude latest historical", model: "claude-3-5-sonnet-latest", want: "deepseek-v4-flash"}, + {name: "claude historical opus", model: "claude-3-opus-20240229", want: "deepseek-v4-pro"}, + {name: "claude historical haiku", model: "claude-3-haiku-20240307", want: "deepseek-v4-flash"}, + {name: "gemini latest alias", model: "gemini-flash-latest", want: "deepseek-v4-flash"}, + {name: "gemini historical pro", model: "gemini-1.5-pro", want: "deepseek-v4-pro"}, + {name: "gemini vision legacy", model: "gemini-pro-vision", want: "deepseek-v4-vision"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ResolveModel(nil, tc.model) + if !ok || got != tc.want { + t.Fatalf("expected alias %s -> %s, got ok=%v model=%q", tc.model, tc.want, ok, got) + } + }) + } +} + func TestResolveModelHeuristicReasoner(t *testing.T) { got, ok := ResolveModel(nil, "o3-super") if !ok || got != "deepseek-v4-pro" { @@ -51,6 +92,19 @@ func TestResolveModelRejectsLegacyDeepSeekIDs(t *testing.T) { } } +func TestResolveModelRejectsRetiredHistoricalModels(t *testing.T) { + retiredModels := []string{ + "claude-2.1", + "claude-instant-1.2", + "gpt-3.5-turbo", + } + for _, model := range retiredModels { + if got, ok := ResolveModel(nil, model); ok { + t.Fatalf("expected retired model %q to be rejected, got %q", model, got) + } + } +} + func TestResolveModelDirectDeepSeekExpert(t *testing.T) { got, ok := ResolveModel(nil, "deepseek-v4-pro") if !ok || got != "deepseek-v4-pro" { diff --git a/internal/config/models.go b/internal/config/models.go index d4d1afa..7b28ec3 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -26,11 +26,11 @@ var DeepSeekModels = []ModelInfo{ var ClaudeModels = []ModelInfo{ // Current aliases {ID: "claude-opus-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-sonnet-4-5", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, + {ID: "claude-sonnet-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-haiku-4-5", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - // Current snapshots - {ID: "claude-opus-4-5-20251101", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, + // Claude 4.x snapshots and prior aliases kept for compatibility + {ID: "claude-sonnet-4-5", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-1-20250805", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, @@ -51,17 +51,6 @@ var ClaudeModels = []ModelInfo{ {ID: "claude-3-5-haiku-latest", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-3-5-haiku-20241022", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-3-haiku-20240307", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - - // Claude 2.x and 1.x (retired but accepted for compatibility) - {ID: "claude-2.1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-2.0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.3", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.2", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-instant-1.2", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-instant-1.1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-instant-1.0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, } func GetModelConfig(model string) (thinking bool, search bool, ok bool) { @@ -95,25 +84,103 @@ func IsSupportedDeepSeekModel(model string) bool { func DefaultModelAliases() map[string]string { return map[string]string{ - "gpt-4o": "deepseek-v4-flash", - "gpt-4.1": "deepseek-v4-flash", - "gpt-4.1-mini": "deepseek-v4-flash", - "gpt-4.1-nano": "deepseek-v4-flash", - "gpt-5": "deepseek-v4-flash", - "gpt-5-mini": "deepseek-v4-flash", - "gpt-5-codex": "deepseek-v4-pro", - "o1": "deepseek-v4-pro", - "o1-mini": "deepseek-v4-pro", - "o3": "deepseek-v4-pro", - "o3-mini": "deepseek-v4-pro", - "claude-sonnet-4-5": "deepseek-v4-flash", - "claude-haiku-4-5": "deepseek-v4-flash", - "claude-opus-4-6": "deepseek-v4-pro", - "claude-3-5-sonnet": "deepseek-v4-flash", - "claude-3-5-haiku": "deepseek-v4-flash", - "claude-3-opus": "deepseek-v4-pro", - "gemini-2.5-pro": "deepseek-v4-pro", - "gemini-2.5-flash": "deepseek-v4-flash", + // OpenAI GPT / ChatGPT families + "chatgpt-4o": "deepseek-v4-flash", + "gpt-4": "deepseek-v4-flash", + "gpt-4-turbo": "deepseek-v4-flash", + "gpt-4-turbo-preview": "deepseek-v4-flash", + "gpt-4.5-preview": "deepseek-v4-flash", + "gpt-4o": "deepseek-v4-flash", + "gpt-4o-mini": "deepseek-v4-flash", + "gpt-4.1": "deepseek-v4-flash", + "gpt-4.1-mini": "deepseek-v4-flash", + "gpt-4.1-nano": "deepseek-v4-flash", + "gpt-5": "deepseek-v4-flash", + "gpt-5-chat": "deepseek-v4-flash", + "gpt-5.1": "deepseek-v4-flash", + "gpt-5.1-chat": "deepseek-v4-flash", + "gpt-5.2": "deepseek-v4-flash", + "gpt-5.2-chat": "deepseek-v4-flash", + "gpt-5.3-chat": "deepseek-v4-flash", + "gpt-5.4": "deepseek-v4-flash", + "gpt-5.5": "deepseek-v4-flash", + "gpt-5-mini": "deepseek-v4-flash", + "gpt-5-nano": "deepseek-v4-flash", + "gpt-5.4-mini": "deepseek-v4-flash", + "gpt-5.4-nano": "deepseek-v4-flash", + "gpt-5-pro": "deepseek-v4-pro", + "gpt-5.2-pro": "deepseek-v4-pro", + "gpt-5.4-pro": "deepseek-v4-pro", + "gpt-5.5-pro": "deepseek-v4-pro", + "gpt-5-codex": "deepseek-v4-pro", + "gpt-5.1-codex": "deepseek-v4-pro", + "gpt-5.1-codex-mini": "deepseek-v4-pro", + "gpt-5.1-codex-max": "deepseek-v4-pro", + "gpt-5.2-codex": "deepseek-v4-pro", + "gpt-5.3-codex": "deepseek-v4-pro", + "codex-mini-latest": "deepseek-v4-pro", + + // OpenAI reasoning / research families + "o1": "deepseek-v4-pro", + "o1-preview": "deepseek-v4-pro", + "o1-mini": "deepseek-v4-pro", + "o1-pro": "deepseek-v4-pro", + "o3": "deepseek-v4-pro", + "o3-mini": "deepseek-v4-pro", + "o3-pro": "deepseek-v4-pro", + "o3-deep-research": "deepseek-v4-pro-search", + "o4-mini": "deepseek-v4-pro", + "o4-mini-deep-research": "deepseek-v4-pro-search", + + // Claude current and historical aliases + "claude-opus-4-6": "deepseek-v4-pro", + "claude-opus-4-1": "deepseek-v4-pro", + "claude-opus-4-1-20250805": "deepseek-v4-pro", + "claude-opus-4-0": "deepseek-v4-pro", + "claude-opus-4-20250514": "deepseek-v4-pro", + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-sonnet-4-5": "deepseek-v4-flash", + "claude-sonnet-4-5-20250929": "deepseek-v4-flash", + "claude-sonnet-4-0": "deepseek-v4-flash", + "claude-sonnet-4-20250514": "deepseek-v4-flash", + "claude-haiku-4-5": "deepseek-v4-flash", + "claude-haiku-4-5-20251001": "deepseek-v4-flash", + "claude-3-7-sonnet": "deepseek-v4-flash", + "claude-3-7-sonnet-latest": "deepseek-v4-flash", + "claude-3-7-sonnet-20250219": "deepseek-v4-flash", + "claude-3-5-sonnet": "deepseek-v4-flash", + "claude-3-5-sonnet-latest": "deepseek-v4-flash", + "claude-3-5-sonnet-20240620": "deepseek-v4-flash", + "claude-3-5-sonnet-20241022": "deepseek-v4-flash", + "claude-3-5-haiku": "deepseek-v4-flash", + "claude-3-5-haiku-latest": "deepseek-v4-flash", + "claude-3-5-haiku-20241022": "deepseek-v4-flash", + "claude-3-opus": "deepseek-v4-pro", + "claude-3-opus-20240229": "deepseek-v4-pro", + "claude-3-sonnet": "deepseek-v4-flash", + "claude-3-sonnet-20240229": "deepseek-v4-flash", + "claude-3-haiku": "deepseek-v4-flash", + "claude-3-haiku-20240307": "deepseek-v4-flash", + + // Gemini current and historical text / multimodal models + "gemini-pro": "deepseek-v4-pro", + "gemini-pro-vision": "deepseek-v4-vision", + "gemini-pro-latest": "deepseek-v4-pro", + "gemini-flash-latest": "deepseek-v4-flash", + "gemini-1.5-pro": "deepseek-v4-pro", + "gemini-1.5-flash": "deepseek-v4-flash", + "gemini-1.5-flash-8b": "deepseek-v4-flash", + "gemini-2.0-flash": "deepseek-v4-flash", + "gemini-2.0-flash-lite": "deepseek-v4-flash", + "gemini-2.5-pro": "deepseek-v4-pro", + "gemini-2.5-flash": "deepseek-v4-flash", + "gemini-2.5-flash-lite": "deepseek-v4-flash", + "gemini-3.1-pro": "deepseek-v4-pro", + "gemini-3-pro": "deepseek-v4-pro", + "gemini-3-flash": "deepseek-v4-flash", + "gemini-3.1-flash": "deepseek-v4-flash", + "gemini-3.1-flash-lite": "deepseek-v4-flash", + "llama-3.1-70b-instruct": "deepseek-v4-flash", "qwen-max": "deepseek-v4-flash", } @@ -124,6 +191,9 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) { if model == "" { return "", false } + if isRetiredHistoricalModel(model) { + return "", false + } if IsSupportedDeepSeekModel(model) { return model, true } @@ -179,6 +249,21 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) { } } +func isRetiredHistoricalModel(model string) bool { + switch { + case strings.HasPrefix(model, "claude-1."): + return true + case strings.HasPrefix(model, "claude-2."): + return true + case strings.HasPrefix(model, "claude-instant-"): + return true + case strings.HasPrefix(model, "gpt-3.5"): + return true + default: + return false + } +} + func lower(s string) string { b := []byte(s) for i, c := range b { diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index b0a0f31..6849b85 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -6,18 +6,6 @@ import ( "strings" ) -func (s *Store) ClaudeMapping() map[string]string { - s.mu.RLock() - defer s.mu.RUnlock() - if len(s.cfg.ClaudeModelMap) > 0 { - return cloneStringMap(s.cfg.ClaudeModelMap) - } - if len(s.cfg.ClaudeMapping) > 0 { - return cloneStringMap(s.cfg.ClaudeMapping) - } - return map[string]string{"fast": "deepseek-v4-flash", "slow": "deepseek-v4-pro"} -} - func (s *Store) ModelAliases() map[string]string { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/util/messages.go b/internal/util/messages.go index b6920c0..3a43f24 100644 --- a/internal/util/messages.go +++ b/internal/util/messages.go @@ -6,7 +6,7 @@ import ( "ds2api/internal/prompt" ) -const ClaudeDefaultModel = "claude-sonnet-4-5" +const ClaudeDefaultModel = "claude-sonnet-4-6" type Message struct { Role string `json:"role"` diff --git a/internal/util/messages_test.go b/internal/util/messages_test.go index e7fd822..077e903 100644 --- a/internal/util/messages_test.go +++ b/internal/util/messages_test.go @@ -104,6 +104,18 @@ func TestConvertClaudeToDeepSeek(t *testing.T) { } } +func TestConvertClaudeToDeepSeekUsesGlobalAliasResolution(t *testing.T) { + store := config.LoadStore() + req := map[string]any{ + "model": "claude-3-5-sonnet-latest", + "messages": []any{map[string]any{"role": "user", "content": "Hi"}}, + } + out := ConvertClaudeToDeepSeek(req, store) + if out["model"] != "deepseek-v4-flash" { + t.Fatalf("expected global alias resolution, got model=%q", out["model"]) + } +} + func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(sub) == 0 || (len(s) > 0 && (indexOf(s, sub) >= 0))) } diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index d168fdc..6084d9c 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -348,8 +348,7 @@ func TestConvertClaudeToDeepSeekNoSystem(t *testing.T) { } } -func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) { - t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-v4-flash","slow":"deepseek-v4-pro"}}`) +func TestConvertClaudeToDeepSeekOpusUsesGlobalAlias(t *testing.T) { store := config.LoadStore() req := map[string]any{ "model": "claude-opus-4-6", @@ -357,6 +356,19 @@ func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) { } out := ConvertClaudeToDeepSeek(req, store) if out["model"] != "deepseek-v4-pro" { - t.Fatalf("expected opus to use slow mapping, got %q", out["model"]) + t.Fatalf("expected opus to use global alias, got %q", out["model"]) + } +} + +func TestConvertClaudeToDeepSeekUsesExplicitModelAlias(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"model_aliases":{"claude-sonnet-4-6":"deepseek-v4-pro-search"}}`) + store := config.LoadStore() + req := map[string]any{ + "model": "claude-sonnet-4-6", + "messages": []any{map[string]any{"role": "user", "content": "Hi"}}, + } + out := ConvertClaudeToDeepSeek(req, store) + if out["model"] != "deepseek-v4-pro-search" { + t.Fatalf("expected explicit alias override, got %q", out["model"]) } } diff --git a/webui/src/features/settings/ModelSection.jsx b/webui/src/features/settings/ModelSection.jsx index b1a220e..d377ac5 100644 --- a/webui/src/features/settings/ModelSection.jsx +++ b/webui/src/features/settings/ModelSection.jsx @@ -2,26 +2,15 @@ export default function ModelSection({ t, form, setForm }) { return (

{t('settings.modelTitle')}

-
-