mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
全局统一映射
This commit is contained in:
@@ -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.
|
||||
|
||||
41
API.en.md
41
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"}]
|
||||
}'
|
||||
|
||||
43
API.md
43
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": "你好"}]
|
||||
}'
|
||||
|
||||
22
README.MD
22
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`
|
||||
|
||||
15
README.en.md
15
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 <key>`
|
||||
- `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`
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
391
docs/prompt-compatibility.md
Normal file
391
docs/prompt-compatibility.md
Normal file
@@ -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
|
||||
<tools>
|
||||
<tool_call>
|
||||
<tool_name>read_file</tool_name>
|
||||
<param>
|
||||
<path><![CDATA[src/main.go]]></path>
|
||||
</param>
|
||||
</tool_call>
|
||||
</tools>
|
||||
```
|
||||
|
||||
这件事很重要,因为它决定了:
|
||||
|
||||
- 历史工具调用在 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 文档
|
||||
@@ -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)
|
||||
|
||||
@@ -21,7 +21,7 @@ type DeepSeekCaller interface {
|
||||
}
|
||||
|
||||
type ConfigReader interface {
|
||||
ClaudeMapping() map[string]string
|
||||
ModelAliases() map[string]string
|
||||
CompatStripReferenceMarkers() bool
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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+`"}}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,15 @@ export default function ModelSection({ t, form, setForm }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||
<h3 className="font-semibold">{t('settings.modelTitle')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="text-sm space-y-2">
|
||||
<span className="text-muted-foreground">{t('settings.claudeMapping')}</span>
|
||||
<textarea
|
||||
value={form.claude_mapping_text}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, claude_mapping_text: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm space-y-2">
|
||||
<span className="text-muted-foreground">{t('settings.modelAliases')}</span>
|
||||
<textarea
|
||||
value={form.model_aliases_text}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, model_aliases_text: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="text-sm space-y-2 block">
|
||||
<span className="text-muted-foreground">{t('settings.modelAliases')}</span>
|
||||
<textarea
|
||||
value={form.model_aliases_text}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, model_aliases_text: e.target.value }))}
|
||||
rows={12}
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ const DEFAULT_FORM = {
|
||||
embeddings: { provider: '' },
|
||||
auto_delete: { mode: 'none' },
|
||||
history_split: { enabled: true, trigger_after_turns: 1 },
|
||||
claude_mapping_text: '{\n "fast": "deepseek-v4-flash",\n "slow": "deepseek-v4-pro"\n}',
|
||||
model_aliases_text: '{}',
|
||||
}
|
||||
|
||||
@@ -75,7 +74,6 @@ function fromServerForm(data) {
|
||||
enabled: data.history_split?.enabled ?? true,
|
||||
trigger_after_turns: Number(data.history_split?.trigger_after_turns || 1),
|
||||
},
|
||||
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
|
||||
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
|
||||
}
|
||||
}
|
||||
@@ -176,10 +174,8 @@ export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogo
|
||||
}, [loadSettings])
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
let claudeMapping = {}
|
||||
let modelAliases = {}
|
||||
try {
|
||||
claudeMapping = parseJSONMap(form.claude_mapping_text, 'claude_mapping', t)
|
||||
modelAliases = parseJSONMap(form.model_aliases_text, 'model_aliases', t)
|
||||
} catch (e) {
|
||||
onMessage('error', e.message)
|
||||
@@ -188,7 +184,6 @@ export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogo
|
||||
|
||||
const payload = {
|
||||
...toServerPayload(form),
|
||||
claude_mapping: claudeMapping,
|
||||
model_aliases: modelAliases,
|
||||
}
|
||||
|
||||
|
||||
@@ -383,8 +383,7 @@
|
||||
"compatibilityDesc": "Compatibility controls that keep stream output closer to the wire format or safer for the web UI.",
|
||||
"stripReferenceMarkers": "Strip [reference:N] markers",
|
||||
"modelTitle": "Model mapping",
|
||||
"claudeMapping": "Claude mapping (JSON)",
|
||||
"modelAliases": "Model aliases (JSON)",
|
||||
"modelAliases": "Global model aliases (JSON)",
|
||||
"autoDeleteTitle": "Session Cleanup Policy",
|
||||
"autoDeleteDesc": "Choose how DeepSeek remote chat records are cleaned up after each request completes.",
|
||||
"autoDeleteMode": "Deletion mode",
|
||||
|
||||
@@ -383,8 +383,7 @@
|
||||
"compatibilityDesc": "用于控制输出格式兼容性,避免把模型原始流里的标记直接暴露到前端。",
|
||||
"stripReferenceMarkers": "移除 [reference:N] 标记",
|
||||
"modelTitle": "模型映射",
|
||||
"claudeMapping": "Claude 映射(JSON)",
|
||||
"modelAliases": "模型别名(JSON)",
|
||||
"modelAliases": "全局模型映射(JSON)",
|
||||
"autoDeleteTitle": "会话删除策略",
|
||||
"autoDeleteDesc": "选择每次请求完成后如何清理 DeepSeek 远端聊天记录。",
|
||||
"autoDeleteMode": "删除模式",
|
||||
|
||||
Reference in New Issue
Block a user