luckfun233 commited on
Commit ·
0ed5879
1
Parent(s): ce5a019
feat: 收敛 flash 模型系列,新增纯文本技能库与工具调用开关
Browse files- 模型:对外只保留 deepseek-v4-flash 系列(含 search/nothinking 变体),
pro/vision 旧名静默映射到 flash,不再出现在模型列表
- 思考/联网:适配 kelivo 思维强度(reasoning_effort 含 max、thinking.type、
reasoning.effort),显式联网信号(enable_search / 原生 web_search 工具)自动跟随
- 提示词:重写上下文衔接指令与对话记录格式(# 对话记录 + [role=xxx]),
禁止提及附件/文件/上下文;恢复并优化思考格式注入默认提示词
- 提示词:新增按 API Key 覆盖思考格式注入(custom/enabled/prompt)
- 工具调用:改为默认关闭(上游 DSML 风控/封号风险),新增 tools.enabled 开关,
关闭时忽略 tools、不注入工具说明、不解析 tool_calls
- 技能:新增纯文本技能库(实验性、默认关闭),技能正文以 System 段前置注入,
支持 skills.max_chars 截断与单 Key 覆盖(custom/enabled/ids)
- 日志:按 API Key 筛选、自定义保留条数(0-500)、json/markdown 导出
- 管理台:响应记录筛选栏/导出、工具开关、技能库编辑、Key 级注入与技能配置
- 文档:新增 docs/prompt-compatibility.md,同步 API.md/API.en.md/README/示例配置
This view is limited to 50 files because it contains too many changes. See raw diff
- API.en.md +303 -25
- API.md +302 -25
- README.en.md +26 -9
- README.md +10 -0
- config.example.json +17 -4
- docs/prompt-compatibility.md +362 -0
- internal/auth/request.go +80 -5
- internal/auth/request_test.go +147 -0
- internal/chathistory/store.go +24 -21
- internal/chathistory/store_test.go +127 -0
- internal/compat/go_compat_test.go +16 -0
- internal/completionruntime/nonstream_test.go +1 -1
- internal/config/api_key_injection_test.go +70 -0
- internal/config/codec.go +42 -1
- internal/config/config.go +249 -0
- internal/config/config_edge_test.go +23 -20
- internal/config/credentials.go +54 -10
- internal/config/model_alias_test.go +16 -12
- internal/config/models.go +86 -57
- internal/config/skills_test.go +242 -0
- internal/config/store_accessors.go +194 -1
- internal/config/store_accessors_test.go +200 -0
- internal/deepseek/client/client_core.go +10 -6
- internal/deepseek/client/client_upload_test.go +26 -6
- internal/httpapi/admin/accounts/handler_accounts_testing_test.go +8 -6
- internal/httpapi/admin/configmgmt/deps.go +3 -0
- internal/httpapi/admin/configmgmt/handler_config_write.go +12 -1
- internal/httpapi/admin/configmgmt/handler_keys_test.go +54 -0
- internal/httpapi/admin/handler.go +3 -0
- internal/httpapi/admin/handler_settings_test.go +92 -0
- internal/httpapi/admin/history/chat_history_filter.go +229 -0
- internal/httpapi/admin/history/handler_chat_history.go +16 -8
- internal/httpapi/admin/history/handler_chat_history_export.go +214 -0
- internal/httpapi/admin/history/handler_chat_history_filters_test.go +440 -0
- internal/httpapi/admin/history/handler_chat_history_test.go +8 -1
- internal/httpapi/admin/history/routes.go +2 -0
- internal/httpapi/admin/settings/handler_settings_parse.go +51 -37
- internal/httpapi/admin/settings/handler_settings_read.go +17 -0
- internal/httpapi/admin/settings/handler_settings_write.go +32 -27
- internal/httpapi/admin/shared/deps.go +3 -0
- internal/httpapi/admin/shared/helpers.go +70 -12
- internal/httpapi/admin/skills/deps.go +16 -0
- internal/httpapi/admin/skills/handler_skills.go +91 -0
- internal/httpapi/admin/skills/handler_skills_test.go +238 -0
- internal/httpapi/admin/skills/routes.go +8 -0
- internal/httpapi/claude/current_input_file_test.go +3 -1
- internal/httpapi/claude/deps_injection_test.go +2 -2
- internal/httpapi/claude/handler_messages.go +2 -0
- internal/httpapi/claude/proxy_vercel_test.go +2 -2
- internal/httpapi/claude/standard_request.go +1 -0
API.en.md
CHANGED
|
@@ -4,7 +4,7 @@ Language: [中文](API.md) | [English](API.en.md)
|
|
| 4 |
|
| 5 |
This document describes the actual behavior of the current Go codebase.
|
| 6 |
|
| 7 |
-
Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Deployment](docs/DEPLOY.en.md) / [Testing](docs/TESTING.md)
|
| 8 |
|
| 9 |
---
|
| 10 |
|
|
@@ -40,6 +40,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
|
|
| 40 |
|
| 41 |
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
|
| 42 |
- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths.
|
|
|
|
| 43 |
- Tool-calling semantics are aligned between Go and Node runtime: models should output the halfwidth-pipe DSML shell `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; DS2API also accepts DSML wrapper aliases such as `<dsml|tool_calls>` and `<|tool_calls>`, common DSML separator drift such as `<|DSML tool_calls>`, collapsed DSML local names such as `<DSMLtool_calls>`, control-separator drift such as `<DSML␂tool_calls>` / raw STX `\x02`, CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift such as `<DSM|parameter name="command"|>...〈/DSM|parameter〉`, `<!DSML!invoke name=“Bash”>`, `<、DSML、tool_calls>`, `<DSmartToolCalls>`, or `<DSMLtool_calls※>`, arbitrary protocol prefixes such as `<proto💥tool_calls>`, and legacy canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`. The scanner normalizes fixed local names (`tool_calls` / `invoke` / `parameter`) with non-structural separators before or after them back to XML before parsing, and also tolerates CDATA opener drift such as `<![CDATA[` / `<、[CDATA[`; only wrapped tool blocks or the narrow missing-opening-wrapper repair path enter the tool path, while bare `<invoke>` does not count as supported syntax. JSON literal parameter bodies are preserved as structured values, explicit empty or whitespace-only parameters are preserved as empty strings, malformed complete wrappers are released as plain text, and loose CDATA is narrowly repaired at final parse/flush when it can preserve a complete outer tool call.
|
| 44 |
- `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior.
|
| 45 |
- When upstream returns a thinking-only response with no visible text, the Go main path and the Vercel Node streaming path retry once in the same DeepSeek session: it appends the prompt suffix `"Please provide a non-empty final answer or tool call."` and sets `parent_message_id`. If that same-account retry would still end as `429 upstream_empty_output`, managed-account mode switches to the next available account, creates a fresh session, and retries the original payload once before returning 429.
|
|
@@ -137,6 +138,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
|
|
| 137 |
| GET | `/admin/settings` | Admin | Read runtime settings |
|
| 138 |
| PUT | `/admin/settings` | Admin | Update runtime settings (hot reload) |
|
| 139 |
| POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs |
|
|
|
|
|
|
|
| 140 |
| POST | `/admin/config/import` | Admin | Import config (merge/replace) |
|
| 141 |
| GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) |
|
| 142 |
| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) |
|
|
@@ -167,15 +170,18 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
|
|
| 167 |
| GET | `/admin/export` | Admin | Export config JSON/Base64 |
|
| 168 |
| GET | `/admin/dev/captures` | Admin | Read local packet-capture entries |
|
| 169 |
| DELETE | `/admin/dev/captures` | Admin | Clear local packet-capture entries |
|
| 170 |
-
| GET | `/admin/chat-history` | Admin | Read server-side conversation history |
|
| 171 |
| DELETE | `/admin/chat-history` | Admin | Clear server-side conversation history |
|
| 172 |
| GET | `/admin/chat-history/{id}` | Admin | Read one server-side conversation entry |
|
| 173 |
| DELETE | `/admin/chat-history/{id}` | Admin | Delete one server-side conversation entry |
|
| 174 |
-
| PUT | `/admin/chat-history/settings` | Admin | Update conversation history retention limit |
|
|
|
|
| 175 |
| GET | `/admin/version` | Admin | Check current version and latest Release |
|
| 176 |
|
| 177 |
OpenAI `/v1/*` paths are canonical. For clients configured with the bare DS2API service URL, the same OpenAI handlers are also exposed through root shortcuts: `/models`, `/models/{id}`, `/chat/completions`, `/responses`, `/responses/{response_id}`, `/embeddings`, `/files`, and `/files/{file_id}`.
|
| 178 |
|
|
|
|
|
|
|
| 179 |
---
|
| 180 |
|
| 181 |
## Health Endpoints
|
|
@@ -208,19 +214,15 @@ No auth required. Returns the currently supported DeepSeek native model list.
|
|
| 208 |
"data": [
|
| 209 |
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 210 |
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 211 |
-
{"id": "deepseek-v4-pro", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 212 |
-
{"id": "deepseek-v4-pro-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 213 |
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 214 |
-
{"id": "deepseek-v4-flash-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
| 215 |
-
{"id": "deepseek-v4-pro-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 216 |
-
{"id": "deepseek-v4-pro-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 217 |
-
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 218 |
-
{"id": "deepseek-v4-vision-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
| 219 |
]
|
| 220 |
}
|
| 221 |
```
|
| 222 |
|
| 223 |
-
> Note: `/v1/models` returns normalized DeepSeek native model IDs. Common aliases are accepted only as request input and are not expanded as separate items in this endpoint.
|
|
|
|
|
|
|
| 224 |
|
| 225 |
### Model Alias Resolution
|
| 226 |
|
|
@@ -231,20 +233,62 @@ For `chat` / `responses` / `embeddings`, DS2API follows a wide-input/strict-outp
|
|
| 231 |
3. If the request name ends with `-nothinking`, resolve the base alias and append the corresponding no-thinking variant.
|
| 232 |
4. If still unmatched, return `invalid_request_error`. Unknown model families are not guessed heuristically; add explicit compatibility names through `model_aliases`.
|
| 233 |
|
| 234 |
-
Built-in aliases come from `internal/config/models.go`; `config.model_aliases` can override or add mappings at runtime. Excerpt:
|
| 235 |
|
| 236 |
-
- OpenAI / Codex: `gpt-4o`, `gpt-4.1`, `gpt-5`, `gpt-5.5`, `gpt-5-codex`, `gpt-5.3-codex`, `codex-mini-latest`
|
| 237 |
-
- OpenAI reasoning: `o1`, `o3`, `o3-deep-research`, `o4-mini`
|
| 238 |
-
- Claude: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-3-5-sonnet-latest`
|
| 239 |
-
- Gemini: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3.1-pro`, `gemini-3-pro`, `gemini-3-flash`, `gemini-3.1-flash-lite`, `gemini-pro-vision`
|
| 240 |
-
- Other exact built-in aliases: `llama-3.1-70b-instruct`, `qwen-max`
|
| 241 |
|
| 242 |
Aliases with a `-nothinking` suffix also map to the corresponding forced no-thinking DeepSeek model.
|
| 243 |
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
Retired historical families such as `claude-1.*`, `claude-2.*`, `claude-instant-*`, and `gpt-3.5*` are explicitly rejected.
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
### `POST /v1/chat/completions`
|
| 249 |
|
| 250 |
> Path note: besides the canonical `/v1/chat/completions`, DS2API also accepts the root shortcut `/chat/completions`. On Vercel Runtime, `vercel.json` rewrites only the canonical `/v1/chat/completions` path to the Node streaming bridge; the root shortcut stays on the Go primary path. Use `/v1/chat/completions` on Vercel when real-time streaming is required.
|
|
@@ -264,8 +308,13 @@ Content-Type: application/json
|
|
| 264 |
| `messages` | array | ✅ | OpenAI-style messages |
|
| 265 |
| `stream` | boolean | ❌ | Default `false` |
|
| 266 |
| `tools` | array | ❌ | Function calling schema |
|
|
|
|
|
|
|
|
|
|
| 267 |
| `temperature`, etc. | any | ❌ | Accepted but final behavior depends on upstream |
|
| 268 |
|
|
|
|
|
|
|
| 269 |
#### Non-Stream Response
|
| 270 |
|
| 271 |
```json
|
|
@@ -273,7 +322,7 @@ Content-Type: application/json
|
|
| 273 |
"id": "<chat_session_id>",
|
| 274 |
"object": "chat.completion",
|
| 275 |
"created": 1738400000,
|
| 276 |
-
"model": "deepseek-v4-
|
| 277 |
"choices": [
|
| 278 |
{
|
| 279 |
"index": 0,
|
|
@@ -322,6 +371,8 @@ data: [DONE]
|
|
| 322 |
|
| 323 |
#### Tool Calls
|
| 324 |
|
|
|
|
|
|
|
| 325 |
When `tools` is present, DS2API performs anti-leak handling:
|
| 326 |
|
| 327 |
**Non-stream**: If detected, returns `message.tool_calls`, `finish_reason=tool_calls`, `message.content=null`.
|
|
@@ -513,7 +564,7 @@ anthropic-version: 2023-06-01
|
|
| 513 |
| `stop_sequences` | array | ❌ | Passed through as downstream stop sequences |
|
| 514 |
| `tool_choice` | string/object | ❌ | Supports `auto` / `none` / `required` / `{"type":"function","name":"..."}` and is translated to downstream tool choice |
|
| 515 |
|
| 516 |
-
> Note: `thinking`, `temperature`, `top_p`, `stop_sequences`, and `tool_choice` are translated through the compatibility bridge. Final behavior still depends on the selected model and upstream support. When both `temperature` and `top_p` are present, `temperature` takes precedence.
|
| 517 |
|
| 518 |
#### Non-Stream Response
|
| 519 |
|
|
@@ -607,7 +658,7 @@ Implementation-wise this path is unified on the OpenAI Chat Completions parse-an
|
|
| 607 |
|
| 608 |
### `POST /v1beta/models/{model}:generateContent`
|
| 609 |
|
| 610 |
-
Request body accepts Gemini-style `contents` / `tools`. Model names can use aliases and are mapped to DeepSeek models.
|
| 611 |
|
| 612 |
Response uses Gemini-compatible fields, including:
|
| 613 |
|
|
@@ -631,6 +682,7 @@ Returns SSE (`text/event-stream`), each chunk as `data: <json>`:
|
|
| 631 |
## Ollama API
|
| 632 |
|
| 633 |
- `POST /api/show` request body: `{"model":"<model-id>"}`.
|
|
|
|
| 634 |
- Response uses lowercase `id` (not `ID`) and includes `capabilities` for Ollama-style clients and strict schemas.
|
| 635 |
|
| 636 |
Example response:
|
|
@@ -730,7 +782,7 @@ Returns sanitized config, including both `keys` and `api_keys`.
|
|
| 730 |
],
|
| 731 |
"model_aliases": {
|
| 732 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 733 |
-
"claude-opus-4-6": "deepseek-v4-
|
| 734 |
}
|
| 735 |
}
|
| 736 |
```
|
|
@@ -754,11 +806,69 @@ If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so
|
|
| 754 |
],
|
| 755 |
"model_aliases": {
|
| 756 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 757 |
-
"claude-opus-4-6": "deepseek-v4-
|
| 758 |
}
|
| 759 |
}
|
| 760 |
```
|
| 761 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
### `GET /admin/settings`
|
| 763 |
|
| 764 |
Reads runtime settings and status, including:
|
|
@@ -771,8 +881,9 @@ Reads runtime settings and status, including:
|
|
| 771 |
- `responses` / `embeddings`
|
| 772 |
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
|
| 773 |
- `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
|
| 774 |
-
- `thinking_injection` (`enabled` defaults to `true`, `prompt`
|
| 775 |
- `model_aliases`
|
|
|
|
| 776 |
- `env_backed`, `needs_vercel_sync`
|
| 777 |
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
|
| 778 |
|
|
@@ -787,9 +898,18 @@ Hot-updates runtime settings. Supported fields:
|
|
| 787 |
- `auto_delete.mode`
|
| 788 |
- `current_input_file.enabled` / `current_input_file.min_chars`
|
| 789 |
- `thinking_injection.enabled` / `thinking_injection.prompt`
|
|
|
|
| 790 |
- `model_aliases`
|
| 791 |
- `toolcall` policy is fixed and is no longer writable through settings
|
| 792 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
### `POST /admin/settings/password`
|
| 794 |
|
| 795 |
Updates admin password and invalidates existing JWTs.
|
|
@@ -802,6 +922,65 @@ Request example:
|
|
| 802 |
|
| 803 |
It also accepts `{"password":"your-new-password"}`.
|
| 804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
### `POST /admin/config/import`
|
| 806 |
|
| 807 |
Imports full config with:
|
|
@@ -1230,6 +1409,105 @@ Clears packet-capture entries:
|
|
| 1230 |
{"success":true,"detail":"capture logs cleared"}
|
| 1231 |
```
|
| 1232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1233 |
---
|
| 1234 |
|
| 1235 |
## Error Payloads
|
|
@@ -1295,7 +1573,7 @@ curl http://localhost:5001/v1/chat/completions \
|
|
| 1295 |
-H "Authorization: Bearer your-api-key" \
|
| 1296 |
-H "Content-Type: application/json" \
|
| 1297 |
-d '{
|
| 1298 |
-
"model": "deepseek-v4-
|
| 1299 |
"messages": [{"role": "user", "content": "Explain quantum entanglement"}],
|
| 1300 |
"stream": true
|
| 1301 |
}'
|
|
|
|
| 4 |
|
| 5 |
This document describes the actual behavior of the current Go codebase.
|
| 6 |
|
| 7 |
+
Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Deployment](docs/DEPLOY.en.md) / [Testing](docs/TESTING.md) / [Prompt compatibility flow](docs/prompt-compatibility.md)
|
| 8 |
|
| 9 |
---
|
| 10 |
|
|
|
|
| 40 |
|
| 41 |
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
|
| 42 |
- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths.
|
| 43 |
+
- The full path from a protocol request to the pure-text web-chat prompt (normalized standard request, thinking-format injection, history split, completion payload assembly, response archiving) is documented in [docs/prompt-compatibility.md](docs/prompt-compatibility.md), which is the source-of-truth document for that flow; this document only describes the visible fields and response shapes of each endpoint.
|
| 44 |
- Tool-calling semantics are aligned between Go and Node runtime: models should output the halfwidth-pipe DSML shell `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; DS2API also accepts DSML wrapper aliases such as `<dsml|tool_calls>` and `<|tool_calls>`, common DSML separator drift such as `<|DSML tool_calls>`, collapsed DSML local names such as `<DSMLtool_calls>`, control-separator drift such as `<DSML␂tool_calls>` / raw STX `\x02`, CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift such as `<DSM|parameter name="command"|>...〈/DSM|parameter〉`, `<!DSML!invoke name=“Bash”>`, `<、DSML、tool_calls>`, `<DSmartToolCalls>`, or `<DSMLtool_calls※>`, arbitrary protocol prefixes such as `<proto💥tool_calls>`, and legacy canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`. The scanner normalizes fixed local names (`tool_calls` / `invoke` / `parameter`) with non-structural separators before or after them back to XML before parsing, and also tolerates CDATA opener drift such as `<).
|
| 184 |
+
|
| 185 |
---
|
| 186 |
|
| 187 |
## Health Endpoints
|
|
|
|
| 214 |
"data": [
|
| 215 |
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 216 |
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
|
|
|
|
|
|
| 217 |
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 218 |
+
{"id": "deepseek-v4-flash-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
]
|
| 220 |
}
|
| 221 |
```
|
| 222 |
|
| 223 |
+
> Note: `/v1/models` returns normalized DeepSeek native model IDs. Common aliases are accepted only as request input and are not expanded as separate items in this endpoint. A `-nothinking` suffix means thinking/reasoning is always off, regardless of what the request asks for.
|
| 224 |
+
>
|
| 225 |
+
> The model line has collapsed to the four flash variants above: `deepseek-v4-pro` / `deepseek-v4-pro-search` / `deepseek-v4-vision` and their `-nothinking` forms are retired and no longer appear in this endpoint, Ollama `/api/tags`, or `/anthropic/v1/models`. Old names still work as request input and are silently mapped to the matching flash model (see the next section).
|
| 226 |
|
| 227 |
### Model Alias Resolution
|
| 228 |
|
|
|
|
| 233 |
3. If the request name ends with `-nothinking`, resolve the base alias and append the corresponding no-thinking variant.
|
| 234 |
4. If still unmatched, return `invalid_request_error`. Unknown model families are not guessed heuristically; add explicit compatibility names through `model_aliases`.
|
| 235 |
|
| 236 |
+
Built-in aliases come from `internal/config/models.go`; `config.model_aliases` can override or add mappings at runtime. Excerpt (all pointing at the flash models):
|
| 237 |
|
| 238 |
+
- OpenAI / Codex: `gpt-4o`, `gpt-4.1`, `gpt-5`, `gpt-5.5`, `gpt-5-codex`, `gpt-5.3-codex`, `codex-mini-latest` → `deepseek-v4-flash`
|
| 239 |
+
- OpenAI reasoning: `o1`, `o3`, `o4-mini` → `deepseek-v4-flash`; `o3-deep-research`, `o4-mini-deep-research` → `deepseek-v4-flash-search`
|
| 240 |
+
- Claude: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-3-5-sonnet-latest` → `deepseek-v4-flash`
|
| 241 |
+
- Gemini: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3.1-pro`, `gemini-3-pro`, `gemini-3-flash`, `gemini-3.1-flash-lite`, `gemini-pro-vision` → `deepseek-v4-flash`
|
| 242 |
+
- Other exact built-in aliases: `llama-3.1-70b-instruct`, `qwen-max` → `deepseek-v4-flash`
|
| 243 |
|
| 244 |
Aliases with a `-nothinking` suffix also map to the corresponding forced no-thinking DeepSeek model.
|
| 245 |
|
| 246 |
+
Retired DeepSeek model names keep old clients working; they are normalized silently at parse time:
|
| 247 |
+
|
| 248 |
+
| Requested model | Resolved to |
|
| 249 |
+
| --- | --- |
|
| 250 |
+
| `deepseek-v4-pro`, `deepseek-v4-vision` | `deepseek-v4-flash` |
|
| 251 |
+
| `deepseek-v4-pro-search` | `deepseek-v4-flash-search` |
|
| 252 |
+
| `deepseek-v4-pro-nothinking`, `deepseek-v4-vision-nothinking` | `deepseek-v4-flash-nothinking` |
|
| 253 |
+
| `deepseek-v4-pro-search-nothinking` | `deepseek-v4-flash-search-nothinking` |
|
| 254 |
+
|
| 255 |
+
The upstream `model_type` is now only `default` (a legacy `expert` / `vision` value is treated as `default`), so there is no separate vision or expert lane.
|
| 256 |
|
| 257 |
Retired historical families such as `claude-1.*`, `claude-2.*`, `claude-instant-*`, and `gpt-3.5*` are explicitly rejected.
|
| 258 |
|
| 259 |
+
### Thinking and search resolution
|
| 260 |
+
|
| 261 |
+
The same rules apply to `/v1/chat/completions`, `/v1/responses`, `/anthropic/v1/messages`, and Gemini `generateContent`; they live in `internal/util/thinking.go` and `internal/util/search.go`, not in the protocol adapters.
|
| 262 |
+
|
| 263 |
+
**Thinking**, lowest priority first:
|
| 264 |
+
|
| 265 |
+
| Order | Source | Notes |
|
| 266 |
+
| --- | --- | --- |
|
| 267 |
+
| 1 | Model default | `-nothinking` variants default off; other flash models default on |
|
| 268 |
+
| 2 | Explicit client override | Any matching field wins |
|
| 269 |
+
| 3 | `-nothinking` model | Forces thinking off and outranks the client; a client cannot turn it back on |
|
| 270 |
+
|
| 271 |
+
Client override fields (the same keys inside `extra_body` are read too):
|
| 272 |
+
|
| 273 |
+
| Field | Accepted forms |
|
| 274 |
+
| --- | --- |
|
| 275 |
+
| `thinking` | bool; `"enabled"` / `"disabled"` (also `on` / `off` / `true` / `false` / `none`); `{"type": ...}` |
|
| 276 |
+
| `reasoning` | `{"effort": ...}`, `{"type": ...}`, `{"enabled": ...}`; a plain string is parsed as an effort value |
|
| 277 |
+
| `reasoning_effort` | `minimal` / `low` / `medium` / `high` / `xhigh` / `max` = on; `none` / `disabled` / `disable` / `off` / `false` = off |
|
| 278 |
+
|
| 279 |
+
Clients such as Kelivo translate their "thinking intensity" UI into `reasoning_effort`: omitting it follows the default (thinking on), sending `none` turns thinking off. DS2API only models on/off; the intensity tier itself is not forwarded.
|
| 280 |
+
|
| 281 |
+
**Search**, resolved as "model default → explicit client signal wins":
|
| 282 |
+
|
| 283 |
+
| Signal | Notes |
|
| 284 |
+
| --- | --- |
|
| 285 |
+
| `enable_search` | bool, or strings such as `"true"` / `"false"` / `"on"` / `"off"` / `"enabled"` / `"disabled"` |
|
| 286 |
+
| `web_search_options` | Presence alone counts as on |
|
| 287 |
+
| `search_options` | Presence alone counts as on |
|
| 288 |
+
| Native search tool in `tools` | `type` starting with `web_search`, `type=google_search`, or a tool object carrying `googleSearch` / `google_search` / `web_search` |
|
| 289 |
+
|
| 290 |
+
`-search` variants enable native search by default; an explicit `enable_search=false` turns it off for that request. Ordinary function tools (for example Kelivo's own `search_web`) are not search signals, so upstream search stays off by default — pick a `-search` model when you need it. Once a client starts sending the native signals above, DS2API follows automatically with no configuration change.
|
| 291 |
+
|
| 292 |
### `POST /v1/chat/completions`
|
| 293 |
|
| 294 |
> Path note: besides the canonical `/v1/chat/completions`, DS2API also accepts the root shortcut `/chat/completions`. On Vercel Runtime, `vercel.json` rewrites only the canonical `/v1/chat/completions` path to the Node streaming bridge; the root shortcut stays on the Go primary path. Use `/v1/chat/completions` on Vercel when real-time streaming is required.
|
|
|
|
| 308 |
| `messages` | array | ✅ | OpenAI-style messages |
|
| 309 |
| `stream` | boolean | ❌ | Default `false` |
|
| 310 |
| `tools` | array | ❌ | Function calling schema |
|
| 311 |
+
| `thinking` | bool / string / object | ❌ | Explicit thinking switch that overrides the model default; ignored by `-nothinking` models |
|
| 312 |
+
| `reasoning` / `reasoning_effort` | object / string | ❌ | Alternative spelling of `thinking`; `reasoning_effort=none` turns thinking off, `minimal`..`max` turns it on |
|
| 313 |
+
| `enable_search` | bool / string | ❌ | Explicit search switch that overrides the model default (e.g. turn search off on a `-search` model) |
|
| 314 |
| `temperature`, etc. | any | ❌ | Accepted but final behavior depends on upstream |
|
| 315 |
|
| 316 |
+
> Full precedence for thinking and search: [Thinking and search resolution](#thinking-and-search-resolution).
|
| 317 |
+
|
| 318 |
#### Non-Stream Response
|
| 319 |
|
| 320 |
```json
|
|
|
|
| 322 |
"id": "<chat_session_id>",
|
| 323 |
"object": "chat.completion",
|
| 324 |
"created": 1738400000,
|
| 325 |
+
"model": "deepseek-v4-flash",
|
| 326 |
"choices": [
|
| 327 |
{
|
| 328 |
"index": 0,
|
|
|
|
| 371 |
|
| 372 |
#### Tool Calls
|
| 373 |
|
| 374 |
+
> If `tools.enabled=false` (the default), the `tools` in the request is ignored and the response never contains `tool_calls`. The behavior below only applies once tool calling is enabled; see [docs/prompt-compatibility.md](docs/prompt-compatibility.md) for the risk.
|
| 375 |
+
|
| 376 |
When `tools` is present, DS2API performs anti-leak handling:
|
| 377 |
|
| 378 |
**Non-stream**: If detected, returns `message.tool_calls`, `finish_reason=tool_calls`, `message.content=null`.
|
|
|
|
| 564 |
| `stop_sequences` | array | ❌ | Passed through as downstream stop sequences |
|
| 565 |
| `tool_choice` | string/object | ❌ | Supports `auto` / `none` / `required` / `{"type":"function","name":"..."}` and is translated to downstream tool choice |
|
| 566 |
|
| 567 |
+
> Note: `thinking`, `temperature`, `top_p`, `stop_sequences`, and `tool_choice` are translated through the compatibility bridge. Final behavior still depends on the selected model and upstream support. When both `temperature` and `top_p` are present, `temperature` takes precedence. Thinking and search precedence is identical to the OpenAI endpoints; see [Thinking and search resolution](#thinking-and-search-resolution). If `tools.enabled=false` (the default), the `tools` in the request is ignored and the response never contains `tool_calls` (a `tool_use` block on the Claude side).
|
| 568 |
|
| 569 |
#### Non-Stream Response
|
| 570 |
|
|
|
|
| 658 |
|
| 659 |
### `POST /v1beta/models/{model}:generateContent`
|
| 660 |
|
| 661 |
+
Request body accepts Gemini-style `contents` / `tools`. Model names can use aliases and are mapped to DeepSeek models; a `-nothinking` suffix in the path model name resolves to the matching forced no-thinking model. Thinking and search precedence is identical to the OpenAI endpoints; see [Thinking and search resolution](#thinking-and-search-resolution). If `tools.enabled=false` (the default), the `tools` in the request is ignored and the response never contains `tool_calls` (a `functionCall` part on the Gemini side).
|
| 662 |
|
| 663 |
Response uses Gemini-compatible fields, including:
|
| 664 |
|
|
|
|
| 682 |
## Ollama API
|
| 683 |
|
| 684 |
- `POST /api/show` request body: `{"model":"<model-id>"}`.
|
| 685 |
+
- `GET /api/tags` lists only the four flash model IDs (`deepseek-v4-flash`, `deepseek-v4-flash-search`, plus their `-nothinking` variants), matching `/v1/models`. Retired pro / vision names no longer appear, but they are still accepted as a `model` input.
|
| 686 |
- Response uses lowercase `id` (not `ID`) and includes `capabilities` for Ollama-style clients and strict schemas.
|
| 687 |
|
| 688 |
Example response:
|
|
|
|
| 782 |
],
|
| 783 |
"model_aliases": {
|
| 784 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 785 |
+
"claude-opus-4-6": "deepseek-v4-flash"
|
| 786 |
}
|
| 787 |
}
|
| 788 |
```
|
|
|
|
| 806 |
],
|
| 807 |
"model_aliases": {
|
| 808 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 809 |
+
"claude-opus-4-6": "deepseek-v4-flash"
|
| 810 |
}
|
| 811 |
}
|
| 812 |
```
|
| 813 |
|
| 814 |
+
### `api_keys[].thinking_injection` (per-key override for thinking-format injection)
|
| 815 |
+
|
| 816 |
+
Every `api_keys` entry may carry `thinking_injection` to give that single API key its own injection prompt; the legacy `keys` form does not support the field.
|
| 817 |
+
|
| 818 |
+
```json
|
| 819 |
+
{
|
| 820 |
+
"api_keys": [
|
| 821 |
+
{
|
| 822 |
+
"key": "k1",
|
| 823 |
+
"name": "Primary",
|
| 824 |
+
"remark": "Production",
|
| 825 |
+
"thinking_injection": {
|
| 826 |
+
"custom": true,
|
| 827 |
+
"enabled": true,
|
| 828 |
+
"prompt": "List the known constraints before answering, then give the conclusion."
|
| 829 |
+
}
|
| 830 |
+
},
|
| 831 |
+
{"key": "k2", "name": "Backup"}
|
| 832 |
+
]
|
| 833 |
+
}
|
| 834 |
+
```
|
| 835 |
+
|
| 836 |
+
| Field | Notes |
|
| 837 |
+
| --- | --- |
|
| 838 |
+
| `custom` | Defaults to `false`. When `false` (or the whole block is omitted) the key fully follows the global `thinking_injection` |
|
| 839 |
+
| `enabled` | Only used when `custom=true`; omitting it means "keep following the global switch" |
|
| 840 |
+
| `prompt` | Only used when `custom=true`; empty or whitespace-only means "keep following the global prompt" |
|
| 841 |
+
|
| 842 |
+
For the global switch and default prompt see `thinking_injection` under [`GET /admin/settings`](#get-adminsettings); for the injection position and the built-in default prompt text see [docs/prompt-compatibility.md](docs/prompt-compatibility.md).
|
| 843 |
+
|
| 844 |
+
### `api_keys[].skills` (per-key skill injection)
|
| 845 |
+
|
| 846 |
+
Every `api_keys` entry may carry `skills` to choose which skills that single API key injects. It sits next to `thinking_injection`; the legacy `keys` form does not support the field.
|
| 847 |
+
|
| 848 |
+
```json
|
| 849 |
+
{
|
| 850 |
+
"api_keys": [
|
| 851 |
+
{"key": "k1", "name": "Primary", "skills": {"custom": true, "enabled": true, "ids": ["sql-report"]}},
|
| 852 |
+
{"key": "k2", "name": "Backup"}
|
| 853 |
+
]
|
| 854 |
+
}
|
| 855 |
+
```
|
| 856 |
+
|
| 857 |
+
| Field | Notes |
|
| 858 |
+
| --- | --- |
|
| 859 |
+
| `custom` | Defaults to `false`; when `false` or omitted the key uses every entry in `skills.items` |
|
| 860 |
+
| `enabled` | Only used when `custom=true`; `false` means this key injects nothing |
|
| 861 |
+
| `ids` | Only used when `custom=true`; non-empty means only the matching skills, empty means this key injects nothing |
|
| 862 |
+
|
| 863 |
+
| `custom` | `enabled` | `ids` | Result |
|
| 864 |
+
| --- | --- | --- | --- |
|
| 865 |
+
| omitted or `false` | any | any | Uses all `items` |
|
| 866 |
+
| `true` | `false` | any | Nothing injected |
|
| 867 |
+
| `true` | omitted or `true` | non-empty | Only the matching `items` |
|
| 868 |
+
| `true` | omitted or `true` | empty | Nothing injected |
|
| 869 |
+
|
| 870 |
+
When the global `skills.enabled=false`, the whole table is moot and no key injects anything. For skill bodies and the admin endpoint see [`GET` / `PUT /admin/skills`](#get--put-adminskills).
|
| 871 |
+
|
| 872 |
### `GET /admin/settings`
|
| 873 |
|
| 874 |
Reads runtime settings and status, including:
|
|
|
|
| 881 |
- `responses` / `embeddings`
|
| 882 |
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
|
| 883 |
- `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
|
| 884 |
+
- `thinking_injection` (`enabled` defaults to `true`, plus `prompt` and `default_prompt`); an empty `prompt` uses the built-in default, and a single key can override it through `api_keys[].thinking_injection`
|
| 885 |
- `model_aliases`
|
| 886 |
+
- `tools` (`enabled`: master switch for tool calling, defaults to `false`; `default_enabled`: always `false`, used by the panel to show the default)
|
| 887 |
- `env_backed`, `needs_vercel_sync`
|
| 888 |
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
|
| 889 |
|
|
|
|
| 898 |
- `auto_delete.mode`
|
| 899 |
- `current_input_file.enabled` / `current_input_file.min_chars`
|
| 900 |
- `thinking_injection.enabled` / `thinking_injection.prompt`
|
| 901 |
+
- `tools.enabled`
|
| 902 |
- `model_aliases`
|
| 903 |
- `toolcall` policy is fixed and is no longer writable through settings
|
| 904 |
|
| 905 |
+
`tools.enabled` only changes the field you actually send; the existing semantics of every other setting stay the same:
|
| 906 |
+
|
| 907 |
+
```json
|
| 908 |
+
{"tools": {"enabled": true}}
|
| 909 |
+
```
|
| 910 |
+
|
| 911 |
+
When it is off (the default), requests that carry `tools` are handled as plain chat. DSML tool calling can trip upstream risk control and get an account banned, so enable it deliberately; the risk notes live in [docs/prompt-compatibility.md](docs/prompt-compatibility.md).
|
| 912 |
+
|
| 913 |
### `POST /admin/settings/password`
|
| 914 |
|
| 915 |
Updates admin password and invalidates existing JWTs.
|
|
|
|
| 922 |
|
| 923 |
It also accepts `{"password":"your-new-password"}`.
|
| 924 |
|
| 925 |
+
### `GET` / `PUT /admin/skills`
|
| 926 |
+
|
| 927 |
+
A plain-text skill library, experimental and off by default (`skills.enabled=false`). The server concatenates skill bodies into the prompt, so the model follows a fixed routine without ever emitting a tool call.
|
| 928 |
+
|
| 929 |
+
- Global config: `skills.enabled` (defaults to `false`), `skills.max_chars` (defaults to `8000`), `skills.items`
|
| 930 |
+
- Per-key override: `api_keys[].skills`, semantics in `api_keys[].skills` above
|
| 931 |
+
- Injection shape, injection order, and "why plain text instead of tool calling": the skills section of [docs/prompt-compatibility.md](docs/prompt-compatibility.md) (Chinese)
|
| 932 |
+
|
| 933 |
+
**GET response**:
|
| 934 |
+
|
| 935 |
+
```json
|
| 936 |
+
{
|
| 937 |
+
"enabled": false,
|
| 938 |
+
"max_chars": 8000,
|
| 939 |
+
"items": [
|
| 940 |
+
{
|
| 941 |
+
"id": "sql-report",
|
| 942 |
+
"name": "SQL report",
|
| 943 |
+
"description": "Write SQL against a fixed metric definition",
|
| 944 |
+
"body": "# Skill body markdown\n..."
|
| 945 |
+
}
|
| 946 |
+
]
|
| 947 |
+
}
|
| 948 |
+
```
|
| 949 |
+
|
| 950 |
+
**PUT request**: same shape as GET; it replaces the whole library, no incremental merge.
|
| 951 |
+
|
| 952 |
+
```json
|
| 953 |
+
{
|
| 954 |
+
"enabled": true,
|
| 955 |
+
"max_chars": 8000,
|
| 956 |
+
"items": [
|
| 957 |
+
{
|
| 958 |
+
"id": "sql-report",
|
| 959 |
+
"name": "SQL report",
|
| 960 |
+
"description": "Write SQL against a fixed metric definition",
|
| 961 |
+
"body": "# Reporting rules\nAggregate by day first, then split by channel."
|
| 962 |
+
}
|
| 963 |
+
]
|
| 964 |
+
}
|
| 965 |
+
```
|
| 966 |
+
|
| 967 |
+
**PUT success response**:
|
| 968 |
+
|
| 969 |
+
```json
|
| 970 |
+
{"success": true, "count": 1}
|
| 971 |
+
```
|
| 972 |
+
|
| 973 |
+
**Validation rules** (any violation returns `400` with `{"detail": "..."}`):
|
| 974 |
+
|
| 975 |
+
| Field | Rule |
|
| 976 |
+
| --- | --- |
|
| 977 |
+
| `items` | At most 50 entries |
|
| 978 |
+
| `id` | Required, unique, character set `[a-z0-9_-]` |
|
| 979 |
+
| `body` | Required; at most 100000 characters per entry and 500000 characters in total |
|
| 980 |
+
| `max_chars` | Defaults to `8000`; truncates by character (rune) and appends `(技能内容已截断)` when truncated; `0` means unlimited |
|
| 981 |
+
|
| 982 |
+
Validation runs on write and is independent of `enabled`, so you can author and fix the content with the switch still off, then turn it on. Over-limit bodies, duplicate ids, and invalid ids are never silently fixed.
|
| 983 |
+
|
| 984 |
### `POST /admin/config/import`
|
| 985 |
|
| 986 |
Imports full config with:
|
|
|
|
| 1409 |
{"success":true,"detail":"capture logs cleared"}
|
| 1410 |
```
|
| 1411 |
|
| 1412 |
+
### `GET /admin/chat-history`
|
| 1413 |
+
|
| 1414 |
+
Reads server-side conversation history with filtering and paging.
|
| 1415 |
+
|
| 1416 |
+
**Query parameters**:
|
| 1417 |
+
|
| 1418 |
+
| Parameter | Default | Matching rule |
|
| 1419 |
+
| --- | --- | --- |
|
| 1420 |
+
| `caller` | empty | Exact match on `caller_id` (use `callers[].caller_id` from the response, not the key name) |
|
| 1421 |
+
| `status` | empty | Case-insensitive exact match: `streaming` / `success` / `error` / `stopped` |
|
| 1422 |
+
| `surface` | empty | Case-insensitive exact match, e.g. `openai.chat_completions`, `openai.responses`, `claude.messages`, `gemini.generate_content` |
|
| 1423 |
+
| `q` | empty | Case-insensitive substring match over `user_input` / `preview` / `model` / `caller_label` / `error` |
|
| 1424 |
+
| `offset` | `0` | Paging offset; `offset` and `limit` both `0` means "no paging" |
|
| 1425 |
+
| `limit` | `0` | Page size; `0` means "no limit" (the settings retention limit is not used as a page size) |
|
| 1426 |
+
|
| 1427 |
+
Invalid or negative `offset` / `limit` values are treated as `0`.
|
| 1428 |
+
|
| 1429 |
+
**Response**:
|
| 1430 |
+
|
| 1431 |
+
```json
|
| 1432 |
+
{
|
| 1433 |
+
"version": 2,
|
| 1434 |
+
"limit": 20,
|
| 1435 |
+
"revision": 128,
|
| 1436 |
+
"total": 137,
|
| 1437 |
+
"callers": [
|
| 1438 |
+
{"caller_id": "caller:9f2c1a2b3c4d5e6f", "label": "Primary", "configured": true, "count": 96},
|
| 1439 |
+
{"caller_id": "caller:41ab0c9d8e7f6a5b", "label": "Backup", "configured": true, "count": 0}
|
| 1440 |
+
],
|
| 1441 |
+
"items": [
|
| 1442 |
+
{
|
| 1443 |
+
"id": "chat_20260418_093012_ab12",
|
| 1444 |
+
"status": "success",
|
| 1445 |
+
"caller_id": "caller:9f2c1a2b3c4d5e6f",
|
| 1446 |
+
"caller_label": "Primary",
|
| 1447 |
+
"account_id": "user@example.com",
|
| 1448 |
+
"surface": "openai.chat_completions",
|
| 1449 |
+
"model": "deepseek-v4-flash",
|
| 1450 |
+
"stream": true,
|
| 1451 |
+
"user_input": "today's news",
|
| 1452 |
+
"preview": "……",
|
| 1453 |
+
"status_code": 200,
|
| 1454 |
+
"elapsed_ms": 4210,
|
| 1455 |
+
"finish_reason": "stop",
|
| 1456 |
+
"created_at": 1777000000000,
|
| 1457 |
+
"updated_at": 1777000004000,
|
| 1458 |
+
"detail_revision": 3
|
| 1459 |
+
}
|
| 1460 |
+
],
|
| 1461 |
+
"path": "/data/chat_history.json"
|
| 1462 |
+
}
|
| 1463 |
+
```
|
| 1464 |
+
|
| 1465 |
+
- `total` is the filtered count, `items` is the current page; without any filter or paging parameter `items` still holds all summaries.
|
| 1466 |
+
- `caller_label` is a readable label for the calling API key (name / remark / masked value); keys without a remark fall back to the masked value so the panel never shows a bare hash.
|
| 1467 |
+
- `callers` aggregates the `caller_id`s found in records and adds keys that are configured but have no records yet (`count` is `0`, `configured=true`), sorted by `count` descending and `label` ascending.
|
| 1468 |
+
- Timestamp fields (`created_at` / `updated_at` / `completed_at`) are milliseconds.
|
| 1469 |
+
- `If-None-Match` + `ETag` revalidation (`304`) only applies when **no filter or paging parameter** is set, because a filtered response body changes with the query.
|
| 1470 |
+
|
| 1471 |
+
### `GET /admin/chat-history/{id}`
|
| 1472 |
+
|
| 1473 |
+
Reads one full record as `{"item": {...}}`. Besides the summary fields it carries `messages`, `history_text` (the full conversation transcript uploaded by the history split), `final_prompt`, `reasoning_content`, `content`, `error`, `usage`, and `file_infos` (uploaded file IDs, audit result, source). `ETag` / `304` is supported here too.
|
| 1474 |
+
|
| 1475 |
+
### `DELETE /admin/chat-history`
|
| 1476 |
+
|
| 1477 |
+
Clears every server-side record and returns `{"success": true}`.
|
| 1478 |
+
|
| 1479 |
+
### `DELETE /admin/chat-history/{id}`
|
| 1480 |
+
|
| 1481 |
+
Deletes one record and returns `{"success": true}`; a missing record returns `404`.
|
| 1482 |
+
|
| 1483 |
+
### `PUT /admin/chat-history/settings`
|
| 1484 |
+
|
| 1485 |
+
Updates the retention limit. `limit` accepts any integer in `0`..`500`; there are no fixed tiers anymore.
|
| 1486 |
+
|
| 1487 |
+
```json
|
| 1488 |
+
{"limit": 120}
|
| 1489 |
+
```
|
| 1490 |
+
|
| 1491 |
+
| Value | Behavior |
|
| 1492 |
+
| --- | --- |
|
| 1493 |
+
| `0` | Recording is off: no new records are written |
|
| 1494 |
+
| `1`..`500` | Keep the newest N records and evict the rest by creation time |
|
| 1495 |
+
| Anything else | `400` with `detail` = `unsupported chat history limit: <n>` |
|
| 1496 |
+
|
| 1497 |
+
The response carries `success`, `limit`, `revision`, and the trimmed `items`.
|
| 1498 |
+
|
| 1499 |
+
### `GET /admin/chat-history/export`
|
| 1500 |
+
|
| 1501 |
+
Exports records with the same filter and paging parameters as the list endpoint (`caller` / `status` / `surface` / `q` / `offset` / `limit`); the response is a file download.
|
| 1502 |
+
|
| 1503 |
+
| Parameter | Notes |
|
| 1504 |
+
| --- | --- |
|
| 1505 |
+
| `format=json` (default) | JSON attachment shaped as `{exported_at, count, filters, items}`; `exported_at` is RFC3339, `filters` echoes the applied filters, and `items` holds full records including large fields such as `messages` / `final_prompt` / `history_text` |
|
| 1506 |
+
| `format=markdown` | Markdown attachment titled `# ds2api 响应记录导出`, one `## N. <record id>` section per record, with time, surface, model, account, caller, status, elapsed time, finish reason, plus `UserInput` / `FinalPrompt` / `ReasoningContent` / `Content` / `Error` code blocks |
|
| 1507 |
+
| Any other value | `400` with `detail` = `unsupported export format: <format>` |
|
| 1508 |
+
|
| 1509 |
+
The response also sets `Content-Disposition: attachment` (filename `ds2api-logs-<YYYYMMDD-HHMMSS>.json|.md`) and `Cache-Control: no-store`. If a record is deleted mid-export it is skipped, and `count` reflects what was actually exported.
|
| 1510 |
+
|
| 1511 |
---
|
| 1512 |
|
| 1513 |
## Error Payloads
|
|
|
|
| 1573 |
-H "Authorization: Bearer your-api-key" \
|
| 1574 |
-H "Content-Type: application/json" \
|
| 1575 |
-d '{
|
| 1576 |
+
"model": "deepseek-v4-flash",
|
| 1577 |
"messages": [{"role": "user", "content": "Explain quantum entanglement"}],
|
| 1578 |
"stream": true
|
| 1579 |
}'
|
API.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
|
| 5 |
本文档描述当前 Go 代码库的实际 API 行为。
|
| 6 |
|
| 7 |
-
文档导航:[总览](README.MD) / [架构说明](docs/ARCHITECTURE.md) / [部署指南](docs/DEPLOY.md) / [测试指南](docs/TESTING.md)
|
| 8 |
|
| 9 |
---
|
| 10 |
|
|
@@ -40,6 +40,7 @@
|
|
| 40 |
|
| 41 |
- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。
|
| 42 |
- 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。
|
|
|
|
| 43 |
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出半角管道符 DSML 外壳 `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容层也接受 DSML wrapper 别名 `<dsml|tool_calls>`、`<|tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo(如 `<DSMLtool_calls>`)、控制分隔符漂移(如 `<DSML␂tool_calls>` / 原始 STX `\x02`)、CJK 尖括号、全角感叹号、顿号、PascalCase 本地名、弯引号属性值与属性尾部分隔符漂移(如 `<DSM|parameter name="command"|>...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `<DSmartToolCalls>` / `<DSMLtool_calls※>`)、任意协议前缀壳(如 `<proto💥tool_calls>`),以及旧式 canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`。实现上采用结构扫描:只要固定本地标签名是 `tool_calls` / `invoke` / `parameter`,标签名前或标签名后的非结构性分隔符会在解析入口归一化;CDATA 开头也会容错 `<![CDATA[` / `<、[CDATA[` 这类分隔符漂移;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `<invoke>` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123`、`true`、`null`、数组或对象),会按结构化值输出,不再一律当作字符串;显式空字符串和纯空白参数会结构化保留为空字符串,是否拒绝缺参由工具执行侧决定;完整但 malformed 的 wrapper 会作为普通文本释放,不会吞掉或伪造成工具调用;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。
|
| 44 |
- `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。
|
| 45 |
- 当上游返回 thinking-only 响应(模型输出了推理链但无可见文本)时,Go 主路径与 Vercel Node 流式路径都会先自动重试一次:以多轮对话 follow-up 方式追加 prompt 后缀 `"Please provide a non-empty final answer or tool call."` 并设置 `parent_message_id` 在同一 DeepSeek session 内让模型重新输出;同账号重试最大 1 次。若同账号重试后仍即将返回 `429 upstream_empty_output`,托管账号模式会在返回 429 前自动切换到下一个可用账号,新建 session,用原始 payload 再 fresh retry 一次。
|
|
@@ -137,6 +138,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
|
|
| 137 |
| GET | `/admin/settings` | Admin | 读取运行时设置 |
|
| 138 |
| PUT | `/admin/settings` | Admin | 更新运行时设置(热更新) |
|
| 139 |
| POST | `/admin/settings/password` | Admin | 更新 Admin 密码并使旧 JWT 失效 |
|
|
|
|
|
|
|
| 140 |
| POST | `/admin/config/import` | Admin | 导入配置(merge/replace) |
|
| 141 |
| GET | `/admin/config/export` | Admin | 导出完整配置(含 `config`/`json`/`base64`) |
|
| 142 |
| POST | `/admin/keys` | Admin | 添加 API key(可附 name/remark) |
|
|
@@ -167,16 +170,17 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
|
|
| 167 |
| GET | `/admin/export` | Admin | 导出配置 JSON/Base64 |
|
| 168 |
| GET | `/admin/dev/captures` | Admin | 查看本地抓包记录 |
|
| 169 |
| DELETE | `/admin/dev/captures` | Admin | 清空本地抓包记录 |
|
| 170 |
-
| GET | `/admin/chat-history` | Admin | 查看服务器端对话记录 |
|
| 171 |
| DELETE | `/admin/chat-history` | Admin | 清空服务器端对话记录 |
|
| 172 |
| GET | `/admin/chat-history/{id}` | Admin | 查看单条服务器端对话���录 |
|
| 173 |
| DELETE | `/admin/chat-history/{id}` | Admin | 删除单条服务器端对话记录 |
|
| 174 |
-
| PUT | `/admin/chat-history/settings` | Admin | 更新对话记录保留条数 |
|
|
|
|
| 175 |
| GET | `/admin/version` | Admin | 查询当前版本与最新 Release |
|
| 176 |
|
| 177 |
OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端,同一套 OpenAI handler 也通过根路径快捷路由暴露:`/models`、`/models/{id}`、`/chat/completions`、`/responses`、`/responses/{response_id}`、`/embeddings`、`/files`、`/files/{file_id}`。
|
| 178 |
|
| 179 |
-
服务器端记录本质上是 DeepSeek 上游响应归档:OpenAI Chat、OpenAI Responses、Claude Messages、Gemini GenerateContent 等直连 DeepSeek 的生成接口,在收到上游响应后会于各协议回译/裁剪前写入记录;列表按请求创建时间倒序展示,流式请求会在生成过程中持续刷新状态与详情。WebUI「API 测试」发出的请求也会进入该记录。
|
| 180 |
|
| 181 |
---
|
| 182 |
|
|
@@ -210,19 +214,15 @@ OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端
|
|
| 210 |
"data": [
|
| 211 |
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 212 |
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 213 |
-
{"id": "deepseek-v4-pro", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 214 |
-
{"id": "deepseek-v4-pro-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 215 |
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 216 |
-
{"id": "deepseek-v4-flash-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
| 217 |
-
{"id": "deepseek-v4-pro-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 218 |
-
{"id": "deepseek-v4-pro-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 219 |
-
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 220 |
-
{"id": "deepseek-v4-vision-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
| 221 |
]
|
| 222 |
}
|
| 223 |
```
|
| 224 |
|
| 225 |
> 说明:`/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID;常见 alias 仅用于请求入参解析,不会在该接口中单独展开返回。带 `-nothinking` 后缀的模型表示无论请求里是否显式开启 thinking / reasoning,都会强制关闭思考输出。
|
|
|
|
|
|
|
| 226 |
|
| 227 |
### 模型 alias 解析策略
|
| 228 |
|
|
@@ -233,19 +233,62 @@ OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端
|
|
| 233 |
3. 如果请求名以 `-nothinking` 结尾,则在最终解析出的规范模型上追加对应的无思考变体。
|
| 234 |
4. 仍未命中则返回 `invalid_request_error`。当前不会按未知模型家族做启发式兜底;需要新增兼容名时请通过 `model_aliases` 明确配置。
|
| 235 |
|
| 236 |
-
当前内置默认 alias 来自 `internal/config/models.go`,`config.model_aliases` 会在运行时覆盖或补充同名映射。节选:
|
| 237 |
|
| 238 |
-
- OpenAI / Codex:`gpt-4o`、`gpt-4.1`、`gpt-5`、`gpt-5.5`、`gpt-5-codex`、`gpt-5.3-codex`、`codex-mini-latest`
|
| 239 |
-
- OpenAI reasoning:`o1`、`o3`、`o3-deep-research`、`o4-mini`
|
| 240 |
-
- Claude:`claude-opus-4-6`、`claude-sonnet-4-6`、`claude-haiku-4-5`、`claude-3-5-sonnet-latest`
|
| 241 |
-
- Gemini:`gemini-2.5-pro`、`gemini-2.5-flash`、`gemini-3.1-pro`、`gemini-3-pro`、`gemini-3-flash`、`gemini-3.1-flash-lite`、`gemini-pro-vision`
|
| 242 |
-
- 其他内置精确 alias:`llama-3.1-70b-instruct`、`qwen-max`
|
| 243 |
|
| 244 |
上述 alias 若在请求名后追加 `-nothinking` 后缀,也会映射到对应的强制关闭 thinking 版本。
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
退役历史模型(如 `claude-1.*`、`claude-2.*`、`claude-instant-*`、`gpt-3.5*`)会被显式拒绝。
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
### `POST /v1/chat/completions`
|
| 250 |
|
| 251 |
> 路径说明:除规范路径 `/v1/chat/completions` 外,也支持根路径快捷别名 `/chat/completions`。在 Vercel Runtime 上,`vercel.json` 仅把规范路径 `/v1/chat/completions` 重写到 Node 流式桥接;根路径快捷别名仍走 Go 主链路。因此 Vercel 上需要实时流式时请使用 `/v1/chat/completions`。
|
|
@@ -265,8 +308,13 @@ Content-Type: application/json
|
|
| 265 |
| `messages` | array | ✅ | OpenAI 风格消息数组 |
|
| 266 |
| `stream` | boolean | ❌ | 默认 `false` |
|
| 267 |
| `tools` | array | ❌ | Function Calling 定义 |
|
|
|
|
|
|
|
|
|
|
| 268 |
| `temperature` 等 | any | ❌ | 兼容透传字段(最终效果由上游决定) |
|
| 269 |
|
|
|
|
|
|
|
| 270 |
#### 非流式响应
|
| 271 |
|
| 272 |
```json
|
|
@@ -274,7 +322,7 @@ Content-Type: application/json
|
|
| 274 |
"id": "<chat_session_id>",
|
| 275 |
"object": "chat.completion",
|
| 276 |
"created": 1738400000,
|
| 277 |
-
"model": "deepseek-v4-
|
| 278 |
"choices": [
|
| 279 |
{
|
| 280 |
"index": 0,
|
|
@@ -323,6 +371,8 @@ data: [DONE]
|
|
| 323 |
|
| 324 |
#### Tool Calls
|
| 325 |
|
|
|
|
|
|
|
| 326 |
当请求中含 `tools` 时,DS2API 做防泄漏处理:
|
| 327 |
|
| 328 |
**非流式**:识别到工具调用时,返回 `message.tool_calls`,设置 `finish_reason=tool_calls`,`message.content=null`。
|
|
@@ -518,7 +568,7 @@ anthropic-version: 2023-06-01
|
|
| 518 |
| `stop_sequences` | array | ❌ | 透传到下游停用序列 |
|
| 519 |
| `tool_choice` | string/object | ❌ | 支持 `auto` / `none` / `required` / `{"type":"function","name":"..."}`,并会转译为下游工具选择 |
|
| 520 |
|
| 521 |
-
> 说明:上述 `thinking`、`temperature`、`top_p`、`stop_sequences`、`tool_choice` 都会走兼容层转译;最终是否生效仍取决于当前模型和上游能力。`temperature` 与 `top_p` 同时存在时,`temperature` 优先。
|
| 522 |
|
| 523 |
#### 非流式响应
|
| 524 |
|
|
@@ -613,7 +663,7 @@ data: {"type":"message_stop"}
|
|
| 613 |
|
| 614 |
### `POST /v1beta/models/{model}:generateContent`
|
| 615 |
|
| 616 |
-
请求体兼容 Gemini `contents` / `tools` 字段,模型名可用 alias 自动映射到 DeepSeek 模型;若路径中的模型名带 `-nothinking` 后缀,则最终会映射到对应的无思考模型。
|
| 617 |
|
| 618 |
响应为 Gemini 兼容结构,核心字段包括:
|
| 619 |
|
|
@@ -637,6 +687,7 @@ data: {"type":"message_stop"}
|
|
| 637 |
## Ollama 兼容接口
|
| 638 |
|
| 639 |
- `POST /api/show` 请求体:`{"model":"<model-id>"}`。
|
|
|
|
| 640 |
- 响应字段使用小写 `id`(不是 `ID`),并返回 `capabilities` 数组,便于与 Ollama 风格客户端/严格 schema 对齐。
|
| 641 |
|
| 642 |
示例响应:
|
|
@@ -736,7 +787,7 @@ data: {"type":"message_stop"}
|
|
| 736 |
],
|
| 737 |
"model_aliases": {
|
| 738 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 739 |
-
"claude-opus-4-6": "deepseek-v4-
|
| 740 |
}
|
| 741 |
}
|
| 742 |
```
|
|
@@ -760,11 +811,69 @@ data: {"type":"message_stop"}
|
|
| 760 |
],
|
| 761 |
"model_aliases": {
|
| 762 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 763 |
-
"claude-opus-4-6": "deepseek-v4-
|
| 764 |
}
|
| 765 |
}
|
| 766 |
```
|
| 767 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
### `GET /admin/settings`
|
| 769 |
|
| 770 |
读取运行时设置与状态,返回:
|
|
@@ -777,8 +886,9 @@ data: {"type":"message_stop"}
|
|
| 777 |
- `responses` / `embeddings`
|
| 778 |
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
|
| 779 |
- `current_input_file`(`enabled` 默认返回 `true`、`min_chars`)
|
| 780 |
-
- `thinking_injection`(`enabled`
|
| 781 |
- `model_aliases`
|
|
|
|
| 782 |
- `env_backed`、`needs_vercel_sync`
|
| 783 |
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
|
| 784 |
|
|
@@ -793,9 +903,18 @@ data: {"type":"message_stop"}
|
|
| 793 |
- `auto_delete.mode`
|
| 794 |
- `current_input_file.enabled` / `current_input_file.min_chars`
|
| 795 |
- `thinking_injection.enabled` / `thinking_injection.prompt`
|
|
|
|
| 796 |
- `model_aliases`
|
| 797 |
- `toolcall` 策略已固定,不再作为可写入字段
|
| 798 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
### `POST /admin/settings/password`
|
| 800 |
|
| 801 |
更新管理密码并使旧 JWT 失效。
|
|
@@ -808,6 +927,65 @@ data: {"type":"message_stop"}
|
|
| 808 |
|
| 809 |
也兼容 `{"password":"your-new-password"}`。
|
| 810 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 811 |
### `POST /admin/config/import`
|
| 812 |
|
| 813 |
导入完整配置,支持:
|
|
@@ -1240,6 +1418,105 @@ data: {"type":"message_stop"}
|
|
| 1240 |
{"success":true,"detail":"capture logs cleared"}
|
| 1241 |
```
|
| 1242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
---
|
| 1244 |
|
| 1245 |
## 错误响应格式
|
|
@@ -1305,7 +1582,7 @@ curl http://localhost:5001/v1/chat/completions \
|
|
| 1305 |
-H "Authorization: Bearer your-api-key" \
|
| 1306 |
-H "Content-Type: application/json" \
|
| 1307 |
-d '{
|
| 1308 |
-
"model": "deepseek-v4-
|
| 1309 |
"messages": [{"role": "user", "content": "解释一下量子纠缠"}],
|
| 1310 |
"stream": true
|
| 1311 |
}'
|
|
|
|
| 4 |
|
| 5 |
本文档描述当前 Go 代码库的实际 API 行为。
|
| 6 |
|
| 7 |
+
文档导航:[总览](README.MD) / [架构说明](docs/ARCHITECTURE.md) / [部署指南](docs/DEPLOY.md) / [测试指南](docs/TESTING.md) / [上下文兼容流程](docs/prompt-compatibility.md)
|
| 8 |
|
| 9 |
---
|
| 10 |
|
|
|
|
| 40 |
|
| 41 |
- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。
|
| 42 |
- 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。
|
| 43 |
+
- 从协议请求到纯文本 web-chat prompt 的完整链路(统一标准请求、思考格式注入、历史拆分、完成载荷组装、响应归档)见 [docs/prompt-compatibility.md](docs/prompt-compatibility.md),该文是这条流程的源文档;本文只描述各接口的可见字段与响应形态。
|
| 44 |
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出半角管道符 DSML 外壳 `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容层也接受 DSML wrapper 别名 `<dsml|tool_calls>`、`<|tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo(如 `<DSMLtool_calls>`)、控制分隔符漂移(如 `<DSML␂tool_calls>` / 原始 STX `\x02`)、CJK 尖括号、全角感叹号、顿号、PascalCase 本地名、弯引号属性值与属性尾部分隔符漂移(如 `<DSM|parameter name="command"|>...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `<DSmartToolCalls>` / `<DSMLtool_calls※>`)、任意协议前缀壳(如 `<proto💥tool_calls>`),以及旧式 canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`。实现上采用结构扫描:只要固定本地标签名是 `tool_calls` / `invoke` / `parameter`,标签名前或标签名后的非结构性分隔符会在解析入口归一化;CDATA 开头也会容错 `<。
|
| 184 |
|
| 185 |
---
|
| 186 |
|
|
|
|
| 214 |
"data": [
|
| 215 |
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 216 |
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
|
|
|
|
|
|
| 217 |
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
| 218 |
+
{"id": "deepseek-v4-flash-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
]
|
| 220 |
}
|
| 221 |
```
|
| 222 |
|
| 223 |
> 说明:`/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID;常见 alias 仅用于请求入参解析,不会在该接口中单独展开返回。带 `-nothinking` 后缀的模型表示无论请求里是否显式开启 thinking / reasoning,都会强制关闭思考输出。
|
| 224 |
+
>
|
| 225 |
+
> 模型系列已收敛到上表 4 个 flash 变体:`deepseek-v4-pro` / `deepseek-v4-pro-search` / `deepseek-v4-vision` 及其 `-nothinking` 形式已下线,不再出现在本接口、Ollama `/api/tags` 与 `/anthropic/v1/models` 中,但旧名字仍可被请求,解析时静默映射到 flash 对应项(见下一节)。
|
| 226 |
|
| 227 |
### 模型 alias 解析策略
|
| 228 |
|
|
|
|
| 233 |
3. 如果请求名以 `-nothinking` 结尾,则在最终解析出的规范模型上追加对应的无思考变体。
|
| 234 |
4. 仍未命中则返回 `invalid_request_error`。当前不会按未知模型家族做启发式兜底;需要新增兼容名时请通过 `model_aliases` 明确配置。
|
| 235 |
|
| 236 |
+
当前内置默认 alias 来自 `internal/config/models.go`,`config.model_aliases` 会在运行时覆盖或补充同名映射。节选(全部指向 flash 系列):
|
| 237 |
|
| 238 |
+
- OpenAI / Codex:`gpt-4o`、`gpt-4.1`、`gpt-5`、`gpt-5.5`、`gpt-5-codex`、`gpt-5.3-codex`、`codex-mini-latest` → `deepseek-v4-flash`
|
| 239 |
+
- OpenAI reasoning:`o1`、`o3`、`o4-mini` → `deepseek-v4-flash`;`o3-deep-research`、`o4-mini-deep-research` → `deepseek-v4-flash-search`
|
| 240 |
+
- Claude:`claude-opus-4-6`、`claude-sonnet-4-6`、`claude-haiku-4-5`、`claude-3-5-sonnet-latest` → `deepseek-v4-flash`
|
| 241 |
+
- Gemini:`gemini-2.5-pro`、`gemini-2.5-flash`、`gemini-3.1-pro`、`gemini-3-pro`、`gemini-3-flash`、`gemini-3.1-flash-lite`、`gemini-pro-vision` → `deepseek-v4-flash`
|
| 242 |
+
- 其他内置精确 alias:`llama-3.1-70b-instruct`、`qwen-max` → `deepseek-v4-flash`
|
| 243 |
|
| 244 |
上述 alias 若在请求名后追加 `-nothinking` 后缀,也会映射到对应的强制关闭 thinking 版本。
|
| 245 |
+
|
| 246 |
+
已下线的 DeepSeek 模型名不会让老客户端失效,解析时静默归一:
|
| 247 |
+
|
| 248 |
+
| 请求模型名 | 实际解析结果 |
|
| 249 |
+
| --- | --- |
|
| 250 |
+
| `deepseek-v4-pro` / `deepseek-v4-vision` | `deepseek-v4-flash` |
|
| 251 |
+
| `deepseek-v4-pro-search` | `deepseek-v4-flash-search` |
|
| 252 |
+
| `deepseek-v4-pro-nothinking` / `deepseek-v4-vision-nothinking` | `deepseek-v4-flash-nothinking` |
|
| 253 |
+
| `deepseek-v4-pro-search-nothinking` | `deepseek-v4-flash-search-nothinking` |
|
| 254 |
+
|
| 255 |
+
上游 `model_type` 现在只剩 `default`(历史配置里出现的 `expert` / `vision` 按 `default` 处理),因此不存在独立的视觉或 expert 通道。
|
| 256 |
|
| 257 |
退役历史模型(如 `claude-1.*`、`claude-2.*`、`claude-instant-*`、`gpt-3.5*`)会被显式拒绝。
|
| 258 |
|
| 259 |
+
### 思考与联网搜索解析策略
|
| 260 |
+
|
| 261 |
+
同一套规则适用于 `/v1/chat/completions`、`/v1/responses`、`/anthropic/v1/messages` 与 Gemini `generateContent`,实现在 `internal/util/thinking.go`、`internal/util/search.go`,不在各协议适配器里。
|
| 262 |
+
|
| 263 |
+
**思考(thinking)**,优先级从低到高:
|
| 264 |
+
|
| 265 |
+
| 顺序 | 来源 | 说明 |
|
| 266 |
+
| --- | --- | --- |
|
| 267 |
+
| 1 | 模型默认值 | `-nothinking` 变体默认关;其余 flash 模型默认开 |
|
| 268 |
+
| 2 | 客户端显式覆盖 | 命中任一字段即生效 |
|
| 269 |
+
| 3 | `-nothinking` 模型 | 强制关闭,优先级最高,客户端无法打开 |
|
| 270 |
+
|
| 271 |
+
客户端覆盖字段(`extra_body` 里的同名键同样会被读取):
|
| 272 |
+
|
| 273 |
+
| 字段 | 接受写法 |
|
| 274 |
+
| --- | --- |
|
| 275 |
+
| `thinking` | bool;`"enabled"` / `"disabled"`(含 `on` / `off` / `true` / `false` / `none`);`{"type": ...}` |
|
| 276 |
+
| `reasoning` | `{"effort": ...}`、`{"type": ...}`、`{"enabled": ...}`;字符串按 effort 解析 |
|
| 277 |
+
| `reasoning_effort` | `minimal` / `low` / `medium` / `high` / `xhigh` / `max` = 开启;`none` / `disabled` / `disable` / `off` / `false` = 关闭 |
|
| 278 |
+
|
| 279 |
+
Kelivo 这类客户端的「思维强度」界面就是靠 `reasoning_effort` 传开关:不发该字段表示跟随默认(开启思考),传 `none` 表示关闭。本项目只区分开/关,不区分强度档位。
|
| 280 |
+
|
| 281 |
+
**联网搜索(search)**,优先级为「模型默认 → 客户端显式信号优先」:
|
| 282 |
+
|
| 283 |
+
| 信号 | 说明 |
|
| 284 |
+
| --- | --- |
|
| 285 |
+
| `enable_search` | bool,或 `"true"` / `"false"` / `"on"` / `"off"` / `"enabled"` / `"disabled"` 等字符串 |
|
| 286 |
+
| `web_search_options` | 字段出现即视为开启 |
|
| 287 |
+
| `search_options` | 字段出现即视为开启 |
|
| 288 |
+
| `tools` 原生搜索类型 | `type` 以 `web_search` 开头、`type=google_search`,或工具对象含 `googleSearch` / `google_search` / `web_search` 字段 |
|
| 289 |
+
|
| 290 |
+
`-search` 变体默认开启联网;显式 `enable_search=false` 可在该模型上临时关掉。普通 function 工具(例如 Kelivo 客户端自己执行的 `search_web`)不算联网信号,所以默认仍不开启上游联网——要联网请选 `-search` 模型;一旦客户端开始发送上述原生信号,服务端会自动跟随,无需改配置。
|
| 291 |
+
|
| 292 |
### `POST /v1/chat/completions`
|
| 293 |
|
| 294 |
> 路径说明:除规范路径 `/v1/chat/completions` 外,也支持根路径快捷别名 `/chat/completions`。在 Vercel Runtime 上,`vercel.json` 仅把规范路径 `/v1/chat/completions` 重写到 Node 流式桥接;根路径快捷别名仍走 Go 主链路。因此 Vercel 上需要实时流式时请使用 `/v1/chat/completions`。
|
|
|
|
| 308 |
| `messages` | array | ✅ | OpenAI 风格消息数组 |
|
| 309 |
| `stream` | boolean | ❌ | 默认 `false` |
|
| 310 |
| `tools` | array | ❌ | Function Calling 定义 |
|
| 311 |
+
| `thinking` | bool / string / object | ❌ | 显式思考开关,覆盖模型默认;`-nothinking` 模型会忽略 |
|
| 312 |
+
| `reasoning` / `reasoning_effort` | object / string | ❌ | 与 `thinking` 等价的另一种写法;`reasoning_effort=none` 关闭思考,`minimal`~`max` 开启 |
|
| 313 |
+
| `enable_search` | bool / string | ❌ | 显式联网开关,覆盖模型默认(用于在 `-search` 模型上临时关闭) |
|
| 314 |
| `temperature` 等 | any | ❌ | 兼容透传字段(最终效果由上游决定) |
|
| 315 |
|
| 316 |
+
> 思考与联网的完整解析优先级见 [思考与联网搜索解析策略](#思考与联网搜索解析策略)。
|
| 317 |
+
|
| 318 |
#### 非流式响应
|
| 319 |
|
| 320 |
```json
|
|
|
|
| 322 |
"id": "<chat_session_id>",
|
| 323 |
"object": "chat.completion",
|
| 324 |
"created": 1738400000,
|
| 325 |
+
"model": "deepseek-v4-flash",
|
| 326 |
"choices": [
|
| 327 |
{
|
| 328 |
"index": 0,
|
|
|
|
| 371 |
|
| 372 |
#### Tool Calls
|
| 373 |
|
| 374 |
+
> 若 `tools.enabled=false`(默认),请求里的 `tools` 会被忽略,响应不会包含 `tool_calls`。下面描述的行为只在开启工具调用后生效,开启风险见 [docs/prompt-compatibility.md](docs/prompt-compatibility.md)。
|
| 375 |
+
|
| 376 |
当请求中含 `tools` 时,DS2API 做防泄漏处理:
|
| 377 |
|
| 378 |
**非流式**:识别到工具调用时,返回 `message.tool_calls`,设置 `finish_reason=tool_calls`,`message.content=null`。
|
|
|
|
| 568 |
| `stop_sequences` | array | ❌ | 透传到下游停用序列 |
|
| 569 |
| `tool_choice` | string/object | ❌ | 支持 `auto` / `none` / `required` / `{"type":"function","name":"..."}`,并会转译为下游工具选择 |
|
| 570 |
|
| 571 |
+
> 说明:上述 `thinking`、`temperature`、`top_p`、`stop_sequences`、`tool_choice` 都会走兼容层转译;最终是否生效仍取决于当前模型和上游能力。`temperature` 与 `top_p` 同时存在时,`temperature` 优先。思考与联网的解析优先级与 OpenAI 接口完全一致,见 [思考与联网搜索解析策略](#思考与联网搜索解析策略)。若 `tools.enabled=false`(默认),请求里的 `tools` 会被忽略,响应不会包含 `tool_calls`(Claude 侧即 `tool_use` 块)。
|
| 572 |
|
| 573 |
#### 非流式响应
|
| 574 |
|
|
|
|
| 663 |
|
| 664 |
### `POST /v1beta/models/{model}:generateContent`
|
| 665 |
|
| 666 |
+
请求体兼容 Gemini `contents` / `tools` 字段,模型名可用 alias 自动映射到 DeepSeek 模型;若路径中的模型名带 `-nothinking` 后缀,则最终会映射到对应的无思考模型。思考与联网的解析优先级与 OpenAI 接口一致,见 [思考与联网搜索解析策略](#思考与联网搜索解析策略)。若 `tools.enabled=false`(默认),请求里的 `tools` 会被忽略,响应不会包含 `tool_calls`(Gemini 侧即 `functionCall`)。
|
| 667 |
|
| 668 |
响应为 Gemini 兼容结构,核心字段包括:
|
| 669 |
|
|
|
|
| 687 |
## Ollama 兼容接口
|
| 688 |
|
| 689 |
- `POST /api/show` 请求体:`{"model":"<model-id>"}`。
|
| 690 |
+
- `GET /api/tags` 只列出 flash 系列的 4 个模型 ID(`deepseek-v4-flash`、`deepseek-v4-flash-search` 及各自 `-nothinking` 变体),与 `/v1/models` 保持一致;已下线的 pro / vision 名字不再出现,但仍可作为 `model` 入参被请求。
|
| 691 |
- 响应字段使用小写 `id`(不是 `ID`),并返回 `capabilities` 数组,便于与 Ollama 风格客户端/严格 schema 对齐。
|
| 692 |
|
| 693 |
示例响应:
|
|
|
|
| 787 |
],
|
| 788 |
"model_aliases": {
|
| 789 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 790 |
+
"claude-opus-4-6": "deepseek-v4-flash"
|
| 791 |
}
|
| 792 |
}
|
| 793 |
```
|
|
|
|
| 811 |
],
|
| 812 |
"model_aliases": {
|
| 813 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 814 |
+
"claude-opus-4-6": "deepseek-v4-flash"
|
| 815 |
}
|
| 816 |
}
|
| 817 |
```
|
| 818 |
|
| 819 |
+
### `api_keys[].thinking_injection`(单 Key 覆盖思考格式注入)
|
| 820 |
+
|
| 821 |
+
`api_keys` 的每一项都可以带 `thinking_injection`,用于给单个 API Key 定制注入提示词;`keys` 旧格式不支持该字段。
|
| 822 |
+
|
| 823 |
+
```json
|
| 824 |
+
{
|
| 825 |
+
"api_keys": [
|
| 826 |
+
{
|
| 827 |
+
"key": "k1",
|
| 828 |
+
"name": "主 Key",
|
| 829 |
+
"remark": "生产流量",
|
| 830 |
+
"thinking_injection": {
|
| 831 |
+
"custom": true,
|
| 832 |
+
"enabled": true,
|
| 833 |
+
"prompt": "回答前先列清楚已知条件,再给结论。"
|
| 834 |
+
}
|
| 835 |
+
},
|
| 836 |
+
{"key": "k2", "name": "备用 Key"}
|
| 837 |
+
]
|
| 838 |
+
}
|
| 839 |
+
```
|
| 840 |
+
|
| 841 |
+
| 字段 | 说明 |
|
| 842 |
+
| --- | --- |
|
| 843 |
+
| `custom` | 默认 `false`。为 `false`(或整段省略)时完全跟随全局 `thinking_injection` |
|
| 844 |
+
| `enabled` | 仅在 `custom=true` 时生效;省略表示继续跟随全局开关 |
|
| 845 |
+
| `prompt` | 仅在 `custom=true` 时生效;为空或全空白表示继续跟随全局提示词 |
|
| 846 |
+
|
| 847 |
+
全局开关与默认提示词见 [`GET /admin/settings`](#get-adminsettings) 的 `thinking_injection`;注入位置与默认提示词内容见 [docs/prompt-compatibility.md](docs/prompt-compatibility.md)。
|
| 848 |
+
|
| 849 |
+
### `api_keys[].skills`(单 Key 覆盖技能注入)
|
| 850 |
+
|
| 851 |
+
`api_keys` 的每一项都可以带 `skills`,用来指定这个 API Key 注入哪些技能,与 `thinking_injection` 并列;`keys` 旧格式不支持该字段。
|
| 852 |
+
|
| 853 |
+
```json
|
| 854 |
+
{
|
| 855 |
+
"api_keys": [
|
| 856 |
+
{"key": "k1", "name": "主 Key", "skills": {"custom": true, "enabled": true, "ids": ["sql-report"]}},
|
| 857 |
+
{"key": "k2", "name": "备用 Key"}
|
| 858 |
+
]
|
| 859 |
+
}
|
| 860 |
+
```
|
| 861 |
+
|
| 862 |
+
| 字段 | 说明 |
|
| 863 |
+
| --- | --- |
|
| 864 |
+
| `custom` | 默认 `false`;为 `false` 或整段省略时使用全部 `skills.items` |
|
| 865 |
+
| `enabled` | 仅在 `custom=true` 时生效;为 `false` 表示该 Key 不注入 |
|
| 866 |
+
| `ids` | 仅在 `custom=true` 时生效;非空时只用命中的技能,为空时该 Key 不注入 |
|
| 867 |
+
|
| 868 |
+
| `custom` | `enabled` | `ids` | 结果 |
|
| 869 |
+
| --- | --- | --- | --- |
|
| 870 |
+
| 省略或 `false` | 任意 | 任意 | 使用全部 `items` |
|
| 871 |
+
| `true` | `false` | 任意 | 不注入 |
|
| 872 |
+
| `true` | 省略或 `true` | 非空 | 只用命中的 `items` |
|
| 873 |
+
| `true` | 省略或 `true` | 空 | 不注入 |
|
| 874 |
+
|
| 875 |
+
全局 `skills.enabled=false` 时上表全部不生效,任何 Key 都不注入。技能正文与管理接口见 [`GET` / `PUT /admin/skills`](#get--put-adminskills)。
|
| 876 |
+
|
| 877 |
### `GET /admin/settings`
|
| 878 |
|
| 879 |
读取运行时设置与状态,返回:
|
|
|
|
| 886 |
- `responses` / `embeddings`
|
| 887 |
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
|
| 888 |
- `current_input_file`(`enabled` 默认返回 `true`、`min_chars`)
|
| 889 |
+
- `thinking_injection`(`enabled` 缺省为 `true`、`prompt`、`default_prompt`);`prompt` 为空时使用内置默认提示词,单 Key 可用 `api_keys[].thinking_injection` 覆盖
|
| 890 |
- `model_aliases`
|
| 891 |
+
- `tools`(`enabled`:工具调用总开关,缺省 `false`;`default_enabled`:固定 `false`,供面板显示默认值)
|
| 892 |
- `env_backed`、`needs_vercel_sync`
|
| 893 |
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
|
| 894 |
|
|
|
|
| 903 |
- `auto_delete.mode`
|
| 904 |
- `current_input_file.enabled` / `current_input_file.min_chars`
|
| 905 |
- `thinking_injection.enabled` / `thinking_injection.prompt`
|
| 906 |
+
- `tools.enabled`
|
| 907 |
- `model_aliases`
|
| 908 |
- `toolcall` 策略已固定,不再作为可写入字段
|
| 909 |
|
| 910 |
+
`tools.enabled` 只改请求体里传了的字段,其它设置的现有语义不变:
|
| 911 |
+
|
| 912 |
+
```json
|
| 913 |
+
{"tools": {"enabled": true}}
|
| 914 |
+
```
|
| 915 |
+
|
| 916 |
+
关闭(默认)时带 `tools` 的请求按纯对话处理;DSML 工具调用可能触发上游风控导致账号封禁,是否开启请自行评估,风险说明见 [docs/prompt-compatibility.md](docs/prompt-compatibility.md)。
|
| 917 |
+
|
| 918 |
### `POST /admin/settings/password`
|
| 919 |
|
| 920 |
更新管理密码并使旧 JWT 失效。
|
|
|
|
| 927 |
|
| 928 |
也兼容 `{"password":"your-new-password"}`。
|
| 929 |
|
| 930 |
+
### `GET` / `PUT /admin/skills`
|
| 931 |
+
|
| 932 |
+
纯文本技能库,实验性功能,缺省关闭(`skills.enabled=false`)。技能正文由服务端拼进 prompt,模型不需要工具调用就能按固定套路工作。
|
| 933 |
+
|
| 934 |
+
- 全局配置:`skills.enabled`(缺省 `false`)、`skills.max_chars`(缺省 `8000`)、`skills.items`
|
| 935 |
+
- 单 Key 覆盖:`api_keys[].skills`,语义见上文「api_keys[].skills(单 Key 覆盖技能注入)」
|
| 936 |
+
- 注入形态、注入顺序与「为什么不用工具调用」:[docs/prompt-compatibility.md](docs/prompt-compatibility.md) 的「技能注入(skills)」
|
| 937 |
+
|
| 938 |
+
**GET 响应**:
|
| 939 |
+
|
| 940 |
+
```json
|
| 941 |
+
{
|
| 942 |
+
"enabled": false,
|
| 943 |
+
"max_chars": 8000,
|
| 944 |
+
"items": [
|
| 945 |
+
{
|
| 946 |
+
"id": "sql-report",
|
| 947 |
+
"name": "SQL 报表",
|
| 948 |
+
"description": "按固定口径写 SQL",
|
| 949 |
+
"body": "# 技能正文 markdown\n..."
|
| 950 |
+
}
|
| 951 |
+
]
|
| 952 |
+
}
|
| 953 |
+
```
|
| 954 |
+
|
| 955 |
+
**PUT 请求**:与 GET 同形状,整体替换,不做增量合并。
|
| 956 |
+
|
| 957 |
+
```json
|
| 958 |
+
{
|
| 959 |
+
"enabled": true,
|
| 960 |
+
"max_chars": 8000,
|
| 961 |
+
"items": [
|
| 962 |
+
{
|
| 963 |
+
"id": "sql-report",
|
| 964 |
+
"name": "SQL 报表",
|
| 965 |
+
"description": "按固定口径写 SQL",
|
| 966 |
+
"body": "# 报表口径\n先按天聚合,再按渠道拆分。"
|
| 967 |
+
}
|
| 968 |
+
]
|
| 969 |
+
}
|
| 970 |
+
```
|
| 971 |
+
|
| 972 |
+
**PUT 成功响应**:
|
| 973 |
+
|
| 974 |
+
```json
|
| 975 |
+
{"success": true, "count": 1}
|
| 976 |
+
```
|
| 977 |
+
|
| 978 |
+
**校验规则**(任一不满足返回 `400`,响应体为 `{"detail": "..."}`):
|
| 979 |
+
|
| 980 |
+
| 字段 | 规则 |
|
| 981 |
+
| --- | --- |
|
| 982 |
+
| `items` | 最多 50 条 |
|
| 983 |
+
| `id` | 必填、唯一,字符集 `[a-z0-9_-]` |
|
| 984 |
+
| `body` | 必填;单条 ≤ 100000 字符,全部条目合计 ≤ 500000 字符 |
|
| 985 |
+
| `max_chars` | 缺省 `8000`;按字符(rune)截断,截断时追加「(技能内容已截断)」;`0` 表示不限制 |
|
| 986 |
+
|
| 987 |
+
校验在写入时执行,与 `enabled` 无关:先关着开关把内容配好、改好,再打开即可。超限、重复 `id`、非法 `id` 都不会被静默修正。
|
| 988 |
+
|
| 989 |
### `POST /admin/config/import`
|
| 990 |
|
| 991 |
导入完整配置,支持:
|
|
|
|
| 1418 |
{"success":true,"detail":"capture logs cleared"}
|
| 1419 |
```
|
| 1420 |
|
| 1421 |
+
### `GET /admin/chat-history`
|
| 1422 |
+
|
| 1423 |
+
查询服务器端对话记录,支持筛选与分页。
|
| 1424 |
+
|
| 1425 |
+
**查询参数**:
|
| 1426 |
+
|
| 1427 |
+
| 参数 | 默认 | 匹配方式 |
|
| 1428 |
+
| --- | --- | --- |
|
| 1429 |
+
| `caller` | 空 | 与 `caller_id` 全等(取值用响应里的 `callers[].caller_id`,不是 Key 名称) |
|
| 1430 |
+
| `status` | 空 | 与记录状态不区分大小写全等:`streaming` / `success` / `error` / `stopped` |
|
| 1431 |
+
| `surface` | 空 | 与来源接口不区分大小写全等,如 `openai.chat_completions`、`openai.responses`、`claude.messages`、`gemini.generate_content` |
|
| 1432 |
+
| `q` | 空 | 不区分大小写包含匹配,命中 `user_input` / `preview` / `model` / `caller_label` / `error` |
|
| 1433 |
+
| `offset` | `0` | 分页偏移;`offset` 与 `limit` 都为 `0` 表示不分页 |
|
| 1434 |
+
| `limit` | `0` | 单页条数;`0` 表示不限制条数(不按 settings 的保留条数分页) |
|
| 1435 |
+
|
| 1436 |
+
非法或负数的 `offset` / `limit` 按 `0` 处理。
|
| 1437 |
+
|
| 1438 |
+
**响应**:
|
| 1439 |
+
|
| 1440 |
+
```json
|
| 1441 |
+
{
|
| 1442 |
+
"version": 2,
|
| 1443 |
+
"limit": 20,
|
| 1444 |
+
"revision": 128,
|
| 1445 |
+
"total": 137,
|
| 1446 |
+
"callers": [
|
| 1447 |
+
{"caller_id": "caller:9f2c1a2b3c4d5e6f", "label": "主 Key", "configured": true, "count": 96},
|
| 1448 |
+
{"caller_id": "caller:41ab0c9d8e7f6a5b", "label": "备用 Key", "configured": true, "count": 0}
|
| 1449 |
+
],
|
| 1450 |
+
"items": [
|
| 1451 |
+
{
|
| 1452 |
+
"id": "chat_20260418_093012_ab12",
|
| 1453 |
+
"status": "success",
|
| 1454 |
+
"caller_id": "caller:9f2c1a2b3c4d5e6f",
|
| 1455 |
+
"caller_label": "主 Key",
|
| 1456 |
+
"account_id": "user@example.com",
|
| 1457 |
+
"surface": "openai.chat_completions",
|
| 1458 |
+
"model": "deepseek-v4-flash",
|
| 1459 |
+
"stream": true,
|
| 1460 |
+
"user_input": "今天的新闻",
|
| 1461 |
+
"preview": "……",
|
| 1462 |
+
"status_code": 200,
|
| 1463 |
+
"elapsed_ms": 4210,
|
| 1464 |
+
"finish_reason": "stop",
|
| 1465 |
+
"created_at": 1777000000000,
|
| 1466 |
+
"updated_at": 1777000004000,
|
| 1467 |
+
"detail_revision": 3
|
| 1468 |
+
}
|
| 1469 |
+
],
|
| 1470 |
+
"path": "/data/chat_history.json"
|
| 1471 |
+
}
|
| 1472 |
+
```
|
| 1473 |
+
|
| 1474 |
+
- `total` 是筛选后的总条数,`items` 是分页后的当前页;不带任何筛选/分页参数时 `items` 仍是全部摘要。
|
| 1475 |
+
- `caller_label` 是发起方 API Key 的可读标签(名称 / 备注 / 脱敏 Key),没有备注时退回脱敏值,避免面板里只剩一串哈希。
|
| 1476 |
+
- `callers` 汇总记录里出现过的 `caller_id`,并补上配置里已登记但还没有记录的 Key(`count` 为 `0`,`configured=true`),按 `count` 倒序、`label` 升序排列。
|
| 1477 |
+
- 时间戳字段(`created_at` / `updated_at` / `completed_at`)单位是毫秒。
|
| 1478 |
+
- `If-None-Match` + `ETag` 的 `304` 协商只在**不带任何筛选/分页参数**时生效,因为带参数的响应内容本身随查询条件变化。
|
| 1479 |
+
|
| 1480 |
+
### `GET /admin/chat-history/{id}`
|
| 1481 |
+
|
| 1482 |
+
查看单条记录全文,响应为 `{"item": {...}}`。除摘要字段外还包含 `messages`、`history_text`(历史拆分上传的对话记录全文)、`final_prompt`、`reasoning_content`、`content`、`error`、`usage`、`file_infos`(上传文件 ID、审核结果、来源)等。同样支持 `ETag` / `304`。
|
| 1483 |
+
|
| 1484 |
+
### `DELETE /admin/chat-history`
|
| 1485 |
+
|
| 1486 |
+
清空全部服务器端记录,返回 `{"success": true}`。
|
| 1487 |
+
|
| 1488 |
+
### `DELETE /admin/chat-history/{id}`
|
| 1489 |
+
|
| 1490 |
+
删除单条记录,返回 `{"success": true}`;记录不存在返回 `404`。
|
| 1491 |
+
|
| 1492 |
+
### `PUT /admin/chat-history/settings`
|
| 1493 |
+
|
| 1494 |
+
更新保留条数。`limit` 接受 `0`..`500` 的任意整数,不再是固定档位。
|
| 1495 |
+
|
| 1496 |
+
```json
|
| 1497 |
+
{"limit": 120}
|
| 1498 |
+
```
|
| 1499 |
+
|
| 1500 |
+
| 取值 | 行为 |
|
| 1501 |
+
| --- | --- |
|
| 1502 |
+
| `0` | 关闭记录:不再写新记录 |
|
| 1503 |
+
| `1`..`500` | 保留最近该数量的记录,超出部分按创建时间淘汰 |
|
| 1504 |
+
| 其他 | 返回 `400`,`detail` 为 `unsupported chat history limit: <n>` |
|
| 1505 |
+
|
| 1506 |
+
响应带 `success`、`limit`、`revision` 与裁剪后的 `items`。
|
| 1507 |
+
|
| 1508 |
+
### `GET /admin/chat-history/export`
|
| 1509 |
+
|
| 1510 |
+
按与列表接口相同的筛选与分页参数(`caller` / `status` / `surface` / `q` / `offset` / `limit`)导出记录,响应是文件下载。
|
| 1511 |
+
|
| 1512 |
+
| 参数 | 说明 |
|
| 1513 |
+
| --- | --- |
|
| 1514 |
+
| `format=json`(默认) | JSON 附件,结构为 `{exported_at, count, filters, items}`;`exported_at` 为 RFC3339,`filters` 回显本次筛选条件,`items` 是完整记录(含 `messages` / `final_prompt` / `history_text` 等大字段) |
|
| 1515 |
+
| `format=markdown` | Markdown 附件,标题 `# ds2api 响应记录导出`,逐条记录 `## N. <记录 ID>` 分节,含时间、接口、模型、账号、调用方、状态、耗时、结束原因,以及 `UserInput` / `FinalPrompt` / `ReasoningContent` / `Content` / `Error` 代码块 |
|
| 1516 |
+
| 其他值 | 返回 `400`,`detail` 为 `unsupported export format: <format>` |
|
| 1517 |
+
|
| 1518 |
+
响应头带 `Content-Disposition: attachment`(文件名为 `ds2api-logs-<YYYYMMDD-HHMMSS>.json|.md`)与 `Cache-Control: no-store`。导出过程中若某条记录已被删除,该条会被跳过,`count` 反映实际导出的条数。
|
| 1519 |
+
|
| 1520 |
---
|
| 1521 |
|
| 1522 |
## 错误响应格式
|
|
|
|
| 1582 |
-H "Authorization: Bearer your-api-key" \
|
| 1583 |
-H "Content-Type: application/json" \
|
| 1584 |
-d '{
|
| 1585 |
+
"model": "deepseek-v4-flash",
|
| 1586 |
"messages": [{"role": "user", "content": "解释一下量子纠缠"}],
|
| 1587 |
"stream": true
|
| 1588 |
}'
|
README.en.md
CHANGED
|
@@ -136,7 +136,7 @@ For the full module-by-module architecture and directory responsibilities, see [
|
|
| 136 |
| Multi-account rotation | Auto token refresh, email/mobile dual login |
|
| 137 |
| Concurrency control | Per-account in-flight limit + waiting queue, dynamic recommended concurrency |
|
| 138 |
| DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response |
|
| 139 |
-
| Tool Calling |
|
| 140 |
| Admin API | Config management, runtime settings hot-reload, proxy management, account testing/batch test, session cleanup, import/export, Vercel sync, version check |
|
| 141 |
| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode, with server-side conversation history) |
|
| 142 |
| Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) |
|
|
@@ -161,13 +161,26 @@ OpenAI `/v1/*` routes remain canonical, and DS2API also accepts root shortcuts s
|
|
| 161 |
| Family | Model ID | thinking | search |
|
| 162 |
| --- | --- | --- | --- |
|
| 163 |
| default | `deepseek-v4-flash` | enabled by default, request-controlled | ❌ |
|
| 164 |
-
|
|
| 165 |
| default | `deepseek-v4-flash-search` | enabled by default, request-controlled | ✅ |
|
| 166 |
-
|
|
| 167 |
-
| vision | `deepseek-v4-vision` | enabled by default, request-controlled | ❌ |
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
### Claude Endpoint (`GET /anthropic/v1/models`)
|
| 173 |
|
|
@@ -175,7 +188,7 @@ Current upstream vision support exposes only the `vision` lane and does not prov
|
|
| 175 |
| --- | --- |
|
| 176 |
| `claude-sonnet-4-6` | `deepseek-v4-flash` |
|
| 177 |
| `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`) | `deepseek-v4-flash` |
|
| 178 |
-
| `claude-opus-4-6` | `deepseek-v4-
|
| 179 |
|
| 180 |
Override mapping via the global `model_aliases` config.
|
| 181 |
Besides the primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x IDs and common aliases for legacy client compatibility.
|
|
@@ -322,8 +335,11 @@ Common fields:
|
|
| 322 |
- `model_aliases`: one shared alias map for OpenAI / Claude / Gemini model names.
|
| 323 |
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
|
| 324 |
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
|
| 325 |
-
- `current_input_file`: the global context split/upload mode; it is enabled by default and uploads the full context as a randomly named context file (
|
| 326 |
- If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
For the full environment variable list, see [docs/DEPLOY.en.md](docs/DEPLOY.en.md). For auth behavior, see [API.en.md](API.en.md#authentication).
|
| 329 |
|
|
@@ -356,7 +372,7 @@ Queue limit = DS2API_ACCOUNT_MAX_QUEUE (default = recommended concurrency)
|
|
| 356 |
|
| 357 |
## Tool Call Adaptation
|
| 358 |
|
| 359 |
-
When `tools` is present in the request, DS2API performs anti-leak handling:
|
| 360 |
|
| 361 |
1. Toolcall feature matching is enabled only in **non-code-block context** (fenced examples are ignored)
|
| 362 |
2. The parser treats the halfwidth-pipe DSML shell as the recommended executable tool-calling syntax: `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; it also accepts legacy canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`, plus common DSML prefix/separator drift. DSML is a shell alias and internal parsing remains XML-based; legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text, and complete but malformed wrappers are released as plain text too
|
|
@@ -403,6 +419,7 @@ The save endpoint can target a chain by `query`, `chain_key`, or `capture_id`. E
|
|
| 403 |
| Document | Description |
|
| 404 |
| --- | --- |
|
| 405 |
| [API.md](API.md) / [API.en.md](API.en.md) | API reference with request/response examples |
|
|
|
|
| 406 |
| [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | Deployment guide (local/Docker/Vercel/systemd) |
|
| 407 |
| [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contributing guide |
|
| 408 |
| [TESTING.md](docs/TESTING.md) | Testsuite guide |
|
|
|
|
| 136 |
| Multi-account rotation | Auto token refresh, email/mobile dual login |
|
| 137 |
| Concurrency control | Per-account in-flight limit + waiting queue, dynamic recommended concurrency |
|
| 138 |
| DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response |
|
| 139 |
+
| Tool Calling | **Off by default** (`tools.enabled`); when enabled: anti-leak handling, non-code-block feature match, early `delta.tool_calls`, structured incremental output |
|
| 140 |
| Admin API | Config management, runtime settings hot-reload, proxy management, account testing/batch test, session cleanup, import/export, Vercel sync, version check |
|
| 141 |
| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode, with server-side conversation history) |
|
| 142 |
| Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) |
|
|
|
|
| 161 |
| Family | Model ID | thinking | search |
|
| 162 |
| --- | --- | --- | --- |
|
| 163 |
| default | `deepseek-v4-flash` | enabled by default, request-controlled | ❌ |
|
| 164 |
+
| default | `deepseek-v4-flash-nothinking` | forced off | ❌ |
|
| 165 |
| default | `deepseek-v4-flash-search` | enabled by default, request-controlled | ✅ |
|
| 166 |
+
| default | `deepseek-v4-flash-search-nothinking` | forced off | ✅ |
|
|
|
|
| 167 |
|
| 168 |
+
Upstream has merged the former pro / vision lanes into flash, so `/v1/models`, Ollama `/api/tags`, and `/anthropic/v1/models` now publish only the four flash IDs above. Old names keep working as request input but are no longer advertised:
|
| 169 |
+
|
| 170 |
+
| Legacy model ID | Resolved to |
|
| 171 |
+
| --- | --- |
|
| 172 |
+
| `deepseek-v4-pro`, `deepseek-v4-vision` | `deepseek-v4-flash` |
|
| 173 |
+
| `deepseek-v4-pro-search` | `deepseek-v4-flash-search` |
|
| 174 |
+
| `deepseek-v4-pro-nothinking`, `deepseek-v4-vision-nothinking` | `deepseek-v4-flash-nothinking` |
|
| 175 |
+
| `deepseek-v4-pro-search-nothinking` | `deepseek-v4-flash-search-nothinking` |
|
| 176 |
+
|
| 177 |
+
The upstream `model_type` is now only `default`; there is no separate expert or vision lane.
|
| 178 |
+
|
| 179 |
+
Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-4.1`, `gpt-5`, `gpt-5-codex`, `o3`, `claude-*`, `gemini-*`), but `/v1/models` returns normalized DeepSeek native model IDs. All built-in aliases point at the flash models; `o3-deep-research` and `o4-mini-deep-research` point at `deepseek-v4-flash-search`. The complete alias behavior is documented in [API.en.md](API.en.md#model-alias-resolution) and `config.example.json`.
|
| 180 |
+
|
| 181 |
+
Thinking and native search are resolved in one shared path: model default → explicit client signal (`thinking`, `reasoning`, `reasoning_effort`, `enable_search`, `web_search_options`) → `-nothinking` models always force thinking off. Full rules: [prompt compatibility doc](docs/prompt-compatibility.md) (Chinese) and [API.en.md](API.en.md).
|
| 182 |
+
|
| 183 |
+
Tool calling is **off by default**. With `tools.enabled=false` (a missing field counts as false) a request's `tools` is ignored and the response never contains `tool_calls`; DeepSeek risk control can ban an account over DSML tool output, so enable it only when you need it. For "make the model follow a fixed routine" needs that do not justify that risk, use the plain-text `skills` library instead.
|
| 184 |
|
| 185 |
### Claude Endpoint (`GET /anthropic/v1/models`)
|
| 186 |
|
|
|
|
| 188 |
| --- | --- |
|
| 189 |
| `claude-sonnet-4-6` | `deepseek-v4-flash` |
|
| 190 |
| `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`) | `deepseek-v4-flash` |
|
| 191 |
+
| `claude-opus-4-6` | `deepseek-v4-flash` |
|
| 192 |
|
| 193 |
Override mapping via the global `model_aliases` config.
|
| 194 |
Besides the primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x IDs and common aliases for legacy client compatibility.
|
|
|
|
| 335 |
- `model_aliases`: one shared alias map for OpenAI / Claude / Gemini model names.
|
| 336 |
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
|
| 337 |
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
|
| 338 |
+
- `current_input_file`: the global context split/upload mode; it is enabled by default and uploads the full context as a randomly named context file (`ctx-xxxxxxxx.txt`) once the character threshold is reached.
|
| 339 |
- If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
|
| 340 |
+
- `thinking_injection`: global thinking-format injection; `enabled` defaults to `true` and an empty `prompt` uses the built-in default. A single API key can override it with `api_keys[].thinking_injection` (`custom` / `enabled` / `prompt`), and `custom=false` fully follows the global setting.
|
| 341 |
+
- `tools.enabled`: master switch for tool calling, **off by default** (a missing field is treated as `false`). While it is off, requests carrying `tools` are handled as plain chat: no tool instructions are injected and no `tool_calls` are parsed or returned. Turn it on only if you accept the risk — DSML tool output can trigger upstream risk control and get the DeepSeek account banned. Safer alternatives: the plain-text skill library below, or a per-key custom injection prompt.
|
| 342 |
+
- `skills`: experimental plain-text skill library, **off by default** (`enabled: false`). Skill bodies are concatenated into the prompt as a leading system block, so the model needs no tool call at all. `max_chars` (default `8000`) truncates by character and appends a truncation marker; `0` means unlimited. At most 50 items, each with a unique `id` from `[a-z0-9_-]` and a required `body`. A single key can narrow the set with `api_keys[].skills` (`custom` / `enabled` / `ids`). Manage it through `GET/PUT /admin/skills` or `config.json`.
|
| 343 |
|
| 344 |
For the full environment variable list, see [docs/DEPLOY.en.md](docs/DEPLOY.en.md). For auth behavior, see [API.en.md](API.en.md#authentication).
|
| 345 |
|
|
|
|
| 372 |
|
| 373 |
## Tool Call Adaptation
|
| 374 |
|
| 375 |
+
When `tools` is present in the request **and `tools.enabled=true`**, DS2API performs anti-leak handling (with the switch off — the default — the field is ignored):
|
| 376 |
|
| 377 |
1. Toolcall feature matching is enabled only in **non-code-block context** (fenced examples are ignored)
|
| 378 |
2. The parser treats the halfwidth-pipe DSML shell as the recommended executable tool-calling syntax: `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; it also accepts legacy canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`, plus common DSML prefix/separator drift. DSML is a shell alias and internal parsing remains XML-based; legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text, and complete but malformed wrappers are released as plain text too
|
|
|
|
| 419 |
| Document | Description |
|
| 420 |
| --- | --- |
|
| 421 |
| [API.md](API.md) / [API.en.md](API.en.md) | API reference with request/response examples |
|
| 422 |
+
| [prompt-compatibility.md](docs/prompt-compatibility.md) | Source-of-truth doc for the “API → pure-text web-chat context” compatibility flow (Chinese) |
|
| 423 |
| [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | Deployment guide (local/Docker/Vercel/systemd) |
|
| 424 |
| [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contributing guide |
|
| 425 |
| [TESTING.md](docs/TESTING.md) | Testsuite guide |
|
README.md
CHANGED
|
@@ -15,3 +15,13 @@ DeepSeek web chat to OpenAI-compatible API adapter with multi-protocol support (
|
|
| 15 |
|
| 16 |
1. Visit /admin to configure your DeepSeek accounts and API keys
|
| 17 |
2. Use the OpenAI-compatible API at /v1/chat/completions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
1. Visit /admin to configure your DeepSeek accounts and API keys
|
| 17 |
2. Use the OpenAI-compatible API at /v1/chat/completions
|
| 18 |
+
|
| 19 |
+
## Documentation
|
| 20 |
+
|
| 21 |
+
| Document | Description |
|
| 22 |
+
| --- | --- |
|
| 23 |
+
| [API.md](API.md) / [API.en.md](API.en.md) | API reference (中文 / English) |
|
| 24 |
+
| [README.en.md](README.en.md) | Full English README (models, deployment, configuration) |
|
| 25 |
+
| [docs/prompt-compatibility.md](docs/prompt-compatibility.md) | 源文档:API 请求 → 纯文本 web-chat 上下文的兼容流程(中文) |
|
| 26 |
+
|
| 27 |
+
Models: only the `deepseek-v4-flash` family is published (`deepseek-v4-flash`, `deepseek-v4-flash-search`, plus `-nothinking` variants). The retired `deepseek-v4-pro` / `deepseek-v4-vision` names still resolve to their flash counterparts, so old clients keep working.
|
config.example.json
CHANGED
|
@@ -14,7 +14,12 @@
|
|
| 14 |
{
|
| 15 |
"key": "your-api-key-2",
|
| 16 |
"name": "备用 API Key",
|
| 17 |
-
"remark": "压测或临时调试"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
],
|
| 20 |
"accounts": [
|
|
@@ -40,8 +45,8 @@
|
|
| 40 |
"model_aliases": {
|
| 41 |
"gpt-4o": "deepseek-v4-flash",
|
| 42 |
"gpt-5.5": "deepseek-v4-flash",
|
| 43 |
-
"gpt-5.3-codex": "deepseek-v4-
|
| 44 |
-
"o3": "deepseek-v4-
|
| 45 |
},
|
| 46 |
"responses": {
|
| 47 |
"store_ttl_seconds": 900
|
|
@@ -51,9 +56,17 @@
|
|
| 51 |
"min_chars": 0
|
| 52 |
},
|
| 53 |
"thinking_injection": {
|
| 54 |
-
"enabled":
|
| 55 |
"prompt": ""
|
| 56 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
"embeddings": {
|
| 58 |
"provider": "deterministic"
|
| 59 |
},
|
|
|
|
| 14 |
{
|
| 15 |
"key": "your-api-key-2",
|
| 16 |
"name": "备用 API Key",
|
| 17 |
+
"remark": "压测或临时调试",
|
| 18 |
+
"thinking_injection": {
|
| 19 |
+
"custom": true,
|
| 20 |
+
"enabled": true,
|
| 21 |
+
"prompt": "(可选)这个 Key 专属的思考格式注入提示词;留空则跟随全局 prompt"
|
| 22 |
+
}
|
| 23 |
}
|
| 24 |
],
|
| 25 |
"accounts": [
|
|
|
|
| 45 |
"model_aliases": {
|
| 46 |
"gpt-4o": "deepseek-v4-flash",
|
| 47 |
"gpt-5.5": "deepseek-v4-flash",
|
| 48 |
+
"gpt-5.3-codex": "deepseek-v4-flash",
|
| 49 |
+
"o3": "deepseek-v4-flash"
|
| 50 |
},
|
| 51 |
"responses": {
|
| 52 |
"store_ttl_seconds": 900
|
|
|
|
| 56 |
"min_chars": 0
|
| 57 |
},
|
| 58 |
"thinking_injection": {
|
| 59 |
+
"enabled": true,
|
| 60 |
"prompt": ""
|
| 61 |
},
|
| 62 |
+
"tools": {
|
| 63 |
+
"enabled": false
|
| 64 |
+
},
|
| 65 |
+
"skills": {
|
| 66 |
+
"enabled": false,
|
| 67 |
+
"max_chars": 8000,
|
| 68 |
+
"items": []
|
| 69 |
+
},
|
| 70 |
"embeddings": {
|
| 71 |
"provider": "deterministic"
|
| 72 |
},
|
docs/prompt-compatibility.md
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API → 纯文本 web-chat 上下文兼容流程
|
| 2 |
+
|
| 3 |
+
本文是本仓库「把结构化 API 请求翻译成 DeepSeek web-chat 纯文本 prompt,再把上游回答翻译回目标协议」这条链路的源文档。AGENTS.md 规定:任何影响消息归一化、工具提示词注入、提示词可见的工具历史、文件/引用处理、历史拆分、完成载荷组装的改动,都要在同一变更里更新本文。
|
| 4 |
+
|
| 5 |
+
代码位置对应关系:
|
| 6 |
+
|
| 7 |
+
| 环节 | 主要代码 |
|
| 8 |
+
| --- | --- |
|
| 9 |
+
| 协议适配器 | `internal/httpapi/openai/`、`internal/httpapi/claude/`、`internal/httpapi/gemini/`、`internal/httpapi/ollama/` |
|
| 10 |
+
| 标准化与 prompt 拼装 | `internal/promptcompat/`、`internal/prompt/` |
|
| 11 |
+
| 思考/联网解析 | `internal/util/thinking.go`、`internal/util/search.go`、`internal/config/models.go` |
|
| 12 |
+
| 上下文拆分 | `internal/httpapi/openai/history/current_input_file.go` |
|
| 13 |
+
| 工具调用开关 / 技能注入 | `internal/promptcompat/`(提示词拼装)、Admin `/admin/skills` 处理器 |
|
| 14 |
+
| 完成载荷 | `internal/promptcompat/standard_request.go`(`CompletionPayload`) |
|
| 15 |
+
| 响应归档 | `internal/chathistory/`、`internal/httpapi/admin/history/` |
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 1. 端到端流程
|
| 20 |
+
|
| 21 |
+
```text
|
| 22 |
+
协议适配器(OpenAI Chat / OpenAI Responses / Claude / Gemini / Ollama)
|
| 23 |
+
│ 只做:解析协议请求 → 归一化消息 → 填 StandardRequest → 回写协议响应
|
| 24 |
+
▼
|
| 25 |
+
StandardRequest(messages、final_prompt、thinking、search、tool 策略、ref_file_ids …)
|
| 26 |
+
│
|
| 27 |
+
├─ 思考格式注入(thinking_injection)
|
| 28 |
+
│ 写入最新一条 user 消息,并落到 final_prompt 的助手起笔标记之前
|
| 29 |
+
▼
|
| 30 |
+
上下文衔接(current input file / 历史拆分)
|
| 31 |
+
│ 达到阈值时上传「对话记录」文件 + 「工具说明」文件,改写 prompt 只留指令
|
| 32 |
+
│ tools.enabled=false 时不注入工具说明(也不上传工具说明文件),不解析 tool_calls
|
| 33 |
+
▼
|
| 34 |
+
技能注入(skills,实验性,缺省关闭)
|
| 35 |
+
│ 把命中的技能正文拼成 System 段前置到 prompt 最前面
|
| 36 |
+
│ 只改 prompt / PromptTokenText,不改 messages
|
| 37 |
+
▼
|
| 38 |
+
组装 DeepSeek completion payload
|
| 39 |
+
│ thinking_enabled / search_enabled / model_type / prompt / ref_file_ids
|
| 40 |
+
▼
|
| 41 |
+
上游 SSE → 协议回写(含空输出重试、工具调用解析、引用标记处理)→ 响应记录
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
| 阶段 | 输入 | 输出 | 共享性 |
|
| 45 |
+
| --- | --- | --- | --- |
|
| 46 |
+
| 协议归一化 | HTTP 请求体 | `StandardRequest` | 每个适配器一份,属边界代码 |
|
| 47 |
+
| 模型/思考/联网解析 | 请求字段 + 模型名 | `Thinking`、`Search`、`ModelType` | 全协议共享,只有一处 |
|
| 48 |
+
| 思考格式注入 | `StandardRequest` + API Key 注入设置 | 注入后的消息与 prompt | 全协议共享 |
|
| 49 |
+
| 历史拆分 | 注入后的消息 | 上下文文件 + 精简 prompt | 全协议共享 |
|
| 50 |
+
| 技能注入 | 拆分后的 prompt | 前置了 System 技能段的 prompt | 全协议共享,只改 prompt 词串 |
|
| 51 |
+
| 载荷组装 | `StandardRequest` | `map[string]any` payload | 全协议共享 |
|
| 52 |
+
| 响应回写 | 上游 SSE / JSON | 协议响应 + 记录 | 适配器渲染 + 共享归档 |
|
| 53 |
+
|
| 54 |
+
四个会产生对话的入口(`/v1/chat/completions`、`/v1/responses`、`/v1/messages`、Gemini `generateContent`)都调用同一个 `history.Service.ApplyCurrentInputFile`,没有第二套历史拆分实现。Ollama 只提供 `/api/version`、`/api/tags`、`/api/show`,不参与对话链路。
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## 2. 统一标准请求
|
| 59 |
+
|
| 60 |
+
`promptcompat.StandardRequest` 是跨协议唯一的数据形状。适配器填它,共享逻辑读它,渲染层再据此回写。
|
| 61 |
+
|
| 62 |
+
| 字段 | 含义 |
|
| 63 |
+
| --- | --- |
|
| 64 |
+
| `Surface` | 来源接口,如 `openai_chat`、`openai_responses`、`anthropic_messages`、`google_gemini` |
|
| 65 |
+
| `RequestedModel` / `ResolvedModel` / `ResponseModel` | 客户端原始模型名 / 解析后的规范模型 / 响应里回显的模型名 |
|
| 66 |
+
| `Messages` | 归一化后的消息数组,历史拆分与注入都作用于它 |
|
| 67 |
+
| `FinalPrompt` | 拼装完成的纯文本 prompt |
|
| 68 |
+
| `PromptTokenText` | 用于 token 估算的文本,通常与 `FinalPrompt` 同步改写 |
|
| 69 |
+
| `ToolsRaw` / `ToolNames` / `ToolChoice` | 工具定义、可用工具名、`auto`/`none`/`required`/`forced` 策略 |
|
| 70 |
+
| `Thinking` / `Search` | 本次请求实际生效的思考、联网开关 |
|
| 71 |
+
| `RefFileIDs` / `FileInfos` | 送上上游的文件引用 ID 与归档用的文件元信息 |
|
| 72 |
+
| `CurrentInputFileApplied` / `CurrentInputFileID` / `CurrentInputFilename` / `CurrentToolsFileID` | 历史拆分产生的状态,用于切号重传时替换旧文件 |
|
| 73 |
+
| `PassThrough` | 需要透传的采样参数(`temperature`、`top_p`、`max_tokens` 等) |
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## 3. 纯文本 web-chat 上下文约定
|
| 78 |
+
|
| 79 |
+
DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有 role 数组。角色信息只能用文本标记表达:
|
| 80 |
+
|
| 81 |
+
| 标记 | 对应角色 | 说明 |
|
| 82 |
+
| --- | --- | --- |
|
| 83 |
+
| `System: ` | system / developer | `developer` 归一化为 `system` |
|
| 84 |
+
| `User: ` | user | 用户轮次 |
|
| 85 |
+
| `Assistant: ` | assistant | 助手轮次;同时也是 prompt 末尾的起笔标记 |
|
| 86 |
+
| `Tool: ` | tool / function | 工具返回,空的工具结果不渲染 |
|
| 87 |
+
|
| 88 |
+
拼装规则:
|
| 89 |
+
|
| 90 |
+
1. 相邻同角色消息先合并,多段之间用空行连接。
|
| 91 |
+
2. 每条消息渲染为「标记 + 正文」,标记后不留空格。
|
| 92 |
+
3. 最后一个角色不是 assistant 时,在末尾补 `Assistant: ` 起笔标记,让模型接着写。
|
| 93 |
+
4. Markdown 图片语法 `` 改写成 `[alt](url)`,避免上游把它当附件引用。
|
| 94 |
+
5. 需要往「最后一轮用户消息」里追加内容时,一律用 `prompt.AppendTurnSuffix`,锚点是这个 `Assistant: ` 标记;直接追加到字符串末尾会被模型当成助手已经开口。
|
| 95 |
+
|
| 96 |
+
### 对话记录文件格式
|
| 97 |
+
|
| 98 |
+
历史拆分上传的上下文文件是可读转写,不是 JSON,也不是带文件名的文档:
|
| 99 |
+
|
| 100 |
+
```text
|
| 101 |
+
# 对话记录
|
| 102 |
+
|
| 103 |
+
以下是本会话此前的完整对话,按时间顺序排列;[role=xxx] 标注每条消息的发言方(system=系统设定,user=用户,assistant=助手,tool=工具返回)。最后一条 user 消息就是本轮需要回答的内容。
|
| 104 |
+
|
| 105 |
+
=== 1 ===
|
| 106 |
+
[role=system]
|
| 107 |
+
你是一个严谨的助手。
|
| 108 |
+
|
| 109 |
+
=== 2 ===
|
| 110 |
+
[role=user]
|
| 111 |
+
把昨天的结论整理成表。
|
| 112 |
+
|
| 113 |
+
=== 3 ===
|
| 114 |
+
[role=assistant]
|
| 115 |
+
好的,先列字段。
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
工具返回条目在正文前多一行 `[name=<工具名> tool_call_id=<调用 ID>]`,`tool` / `function` 统一写成 `[role=tool]`。
|
| 119 |
+
|
| 120 |
+
标题刻意不用文件名形式(历史版本是 `# context_context.txt`),避免模型把它当成一份待分析文档。旧格式里 `[r=0]`…`[r=9]` 的数字角色行不再生成;管理台读旧记录时仍能识别这种数字角色行(兼容读取),后端只把它当普通正文,不报错。
|
| 121 |
+
|
| 122 |
+
### 工具说明文件格式
|
| 123 |
+
|
| 124 |
+
工具定义单独上传为 `context_tools.txt`,标题是 `# 可用工具说明`,正文按工具逐条列出名称、描述、参数 JSON Schema。prompt 里只保留一句「本轮可用的工具与参数说明已附加在 context_tools.txt 中,请只使用该文件列出的工具与参数」,并附带调用格式要求。
|
| 125 |
+
|
| 126 |
+
两个文件分工明确:对话记录是「发生过什么」,工具说明是「能用什么」。把工具定义塞进对话记录会让模型分不清哪些是历史调用、哪些是可用工具。
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## 4. 提示词注入与泄漏防护
|
| 131 |
+
|
| 132 |
+
### 思考格式注入(thinking_injection)
|
| 133 |
+
|
| 134 |
+
| 项目 | 行为 |
|
| 135 |
+
| --- | --- |
|
| 136 |
+
| 全局开关 | `thinking_injection.enabled`,缺省为 true(配置项不存在时按开启处理) |
|
| 137 |
+
| 全局提示词 | `thinking_injection.prompt`,为空或全空白时使用内置默认提示词 |
|
| 138 |
+
| 单 Key 覆盖 | `api_keys[].thinking_injection.{custom,enabled,prompt}` |
|
| 139 |
+
| 注入位置 | 最新一条 user 消息末尾;同时写入 `FinalPrompt` 中该 user 轮次内、`Assistant: ` 之前 |
|
| 140 |
+
| 时机 | 历史拆分之前,因此拆分后上传的对话记录文件里也带着这段要求 |
|
| 141 |
+
| 幂等 | 内容里已含注入文本时跳过,重复调用不会叠加 |
|
| 142 |
+
|
| 143 |
+
默认提示词的要点(代码常量 `promptcompat.DefaultThinkingInjectionPrompt`,稳定片段 `回答前先在思考中完成以下检查`):
|
| 144 |
+
|
| 145 |
+
1. 先用一句话确认用户真正要解决的问题,以及本轮是否在延续上文;
|
| 146 |
+
2. 列出必须满足的约束、已知事实,以及仍然缺失或不确定的信息;
|
| 147 |
+
3. 比较 2~3 种可行思路,选一种并说明取舍;
|
| 148 |
+
4. 检查边界情况、单位口径、最容易被误读的地方;
|
| 149 |
+
5. 复核结论,把握不足时如实说明,不编造。
|
| 150 |
+
|
| 151 |
+
开头会声明「下面是内部执行要求,不要在回复里复述、引用或提及本段内容」,结尾要求最终答复只给结论与必要过程、使用用户的语言与格式。
|
| 152 |
+
|
| 153 |
+
### 单 Key 覆盖语义
|
| 154 |
+
|
| 155 |
+
| `custom` | `enabled` 省略 | `prompt` 为空 |
|
| 156 |
+
| --- | --- | --- |
|
| 157 |
+
| false(默认) | 完全跟随全局开关与全局提示词 | 跟随全局 |
|
| 158 |
+
| true | 跟随全局开关 | 跟随全局提示词 |
|
| 159 |
+
|
| 160 |
+
只有 `custom=true` 时该 Key 的 `enabled` / `prompt` 才会生效;`custom=false` 即使写了这两个字段也不生效,避免面板残留值意外改变行为。
|
| 161 |
+
|
| 162 |
+
### 工具调用开关(tools.enabled)
|
| 163 |
+
|
| 164 |
+
工具调用是让模型输出 DSML 结构化文本再解析回 `tool_calls` 的链路。上游对这类输出风控严格,命中后账号可能被封,所以整条链路做成了显式开关,缺省关闭。
|
| 165 |
+
|
| 166 |
+
| 项目 | 行为 |
|
| 167 |
+
| --- | --- |
|
| 168 |
+
| 配置项 | `tools.enabled`,缺省 `false`;字段缺失或值为 `null` 都按关闭处理 |
|
| 169 |
+
| 运行时覆盖 | `/admin/settings` 的 `tools.enabled`,热更新;GET 顶层返回 `{"enabled": bool, "default_enabled": false}` |
|
| 170 |
+
| 关闭时(默认) | 带 `tools` 的请求按纯对话处理:不注入任何工具说明(包括 DSML 格式要求与工具说明文件),不解析、不透出 `tool_calls`,模型输出按普通文本回写 |
|
| 171 |
+
| 开启时 | 恢复原有行为:prompt 带上 DSML 调用格式要求,历史拆分额外上传工具说明文件,响应侧恢复 `tool_calls` 解析 |
|
| 172 |
+
|
| 173 |
+
关闭时同一请求里的 `tool_choice` 也失去意义:没有工具定义,就没有可选的调用策略。
|
| 174 |
+
|
| 175 |
+
风险说明:开启后模型会按要求输出 DSML 工具块,这是上游重点监控的行为,可能触发风控乃至账号封禁。需要工具能力时自行开启并承担风险;既想要「按固定套路干活」又想避开这个风险,用下面的技能注入,或者给单个 Key 自定义注入提示词。
|
| 176 |
+
|
| 177 |
+
### 技能注入(skills)
|
| 178 |
+
|
| 179 |
+
技能是一段服务端配置的纯文本 markdown,直接拼进 prompt。模型全程不需要输出工具调用,看到的只是普通上下文,因此不会碰到 DSML 风控。
|
| 180 |
+
|
| 181 |
+
| 项目 | 行为 |
|
| 182 |
+
| --- | --- |
|
| 183 |
+
| 全局开关 | `skills.enabled`,缺省 `false`(实验性,默认不注入) |
|
| 184 |
+
| 截断长度 | `max_chars`,缺省 `8000`,按字符(rune)统计;截断时在末尾追加「(技能内容已截断)」;`0` 表示不限制 |
|
| 185 |
+
| 条目数 | `items` 上限 50 条 |
|
| 186 |
+
| `id` | 必填、唯一,字符集 `[a-z0-9_-]` |
|
| 187 |
+
| `body` | 必填,单条上限 100000 字符,全部条目合计上限 500000 字符 |
|
| 188 |
+
| 管理接口 | `GET/PUT /admin/skills`,需要 Admin 鉴权,PUT 为整体替换 |
|
| 189 |
+
| 校验失败 | 超限、重复 `id`、非法 `id` 一律 400 |
|
| 190 |
+
|
| 191 |
+
单 Key 覆盖(`api_keys[].skills = {"custom": ..., "enabled"?: ..., "ids"?: [...]}`):
|
| 192 |
+
|
| 193 |
+
| `custom` | `enabled` | `ids` | 结果 |
|
| 194 |
+
| --- | --- | --- | --- |
|
| 195 |
+
| 省略或 `false` | 任意 | 任意 | 使用全部 `items` |
|
| 196 |
+
| `true` | `false` | 任意 | 该 Key 不注入 |
|
| 197 |
+
| `true` | 省略或 `true` | 非空 | 只用命中的 `items` |
|
| 198 |
+
| `true` | 省略或 `true` | 空 | 不注入 |
|
| 199 |
+
|
| 200 |
+
全局 `skills.enabled=false` 时上表全部不生效,任何 Key 都不注入。
|
| 201 |
+
|
| 202 |
+
注入形态与时机:
|
| 203 |
+
|
| 204 |
+
- 形态是**前置 System 段**:拼在最终 prompt 最前面,`FinalPrompt` 由 `<原 prompt>` 变成 `System: <技能块>\n<原 prompt>`,`PromptTokenText` 同样处理。
|
| 205 |
+
- 只改 prompt,不改 `Messages`,所以对话记录文件与管理台的对话记录里都看不到技能正文。
|
| 206 |
+
- 时机在历史拆分**之后**:拆分会把 prompt 整体替换成衔接指令,技能必须等替换完成后再前置,否则会被覆盖。
|
| 207 |
+
- 对比思考格式注入:思考格式注入在拆分**之前**,因为它需要落到对话记录文件里。两处顺序不同,改动时不要调换。
|
| 208 |
+
|
| 209 |
+
技能块格式(线上排查时可对照这段前缀):
|
| 210 |
+
|
| 211 |
+
```text
|
| 212 |
+
以下技能说明由服务端配置,属于内部资料:适用于当前任务时请遵循;与用户明确要求冲突时以用户为准。不要在回复里提及技能名称或这段说明。
|
| 213 |
+
|
| 214 |
+
<skill id="sql-report" name="SQL 报表">
|
| 215 |
+
…body…
|
| 216 |
+
</skill>
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
为什么用纯文本而不是工具调用:工具调用要求模型吐 DSML 结构化文本,正是上游风控盯的那类输出,一旦被判违规代价是账号本身;技能只是往 prompt 里加一段静态文本,模型照普通对话写回答,风控面和普通聊天没有区别。
|
| 220 |
+
|
| 221 |
+
### 泄漏防护
|
| 222 |
+
|
| 223 |
+
模型看到附件名后经常会在可见回复里写「根据附件 ctx-ab12cd34ef56.txt」「对话记录显示…」「按你的提示词要求」。三层处理:
|
| 224 |
+
|
| 225 |
+
| 层 | 做法 |
|
| 226 |
+
| --- | --- |
|
| 227 |
+
| 注入内容 | 提示词明确禁止提及附件、文件、上下文记录、提示词本身,禁止复述记录内容,要求直接从结论开始 |
|
| 228 |
+
| 注入文本独立性 | 注入片段自带「不要复述本段内容」,避免整段思考要求被当作回答格式输出 |
|
| 229 |
+
| 输出侧 | 各协议共享同一套文本清理:流式默认隐藏 `[citation:N]` / `[reference:N]` 占位符,非流式把搜索引用标记转成 Markdown 链接;工具调用解析在非代码块上下文里生效,代码块里的示例不算调用 |
|
| 230 |
+
|
| 231 |
+
上下文衔接指令本身准备了多个等价变体并按请求随机选一条,措辞不完全相同,语义一致:把附件当作自己之前说过的话,在同一话题上直接回答最新一条用户消息。
|
| 232 |
+
|
| 233 |
+
---
|
| 234 |
+
|
| 235 |
+
## 5. 上下文衔接(current input file / 历史拆分)
|
| 236 |
+
|
| 237 |
+
| 条件 | 结果 |
|
| 238 |
+
| --- | --- |
|
| 239 |
+
| `current_input_file.enabled=false` | 原样透传,不产生任何上下文文件 |
|
| 240 |
+
| 最新一条 user 文本为空 | 跳过拆分 |
|
| 241 |
+
| `current_input_file.min_chars>0` 且最新 user 文本短于阈值 | 跳过拆分 |
|
| 242 |
+
| 模型 `model_type` 不是 `default` | 跳过拆分(当前模型系列收敛后只剩 `default`,该分支保留兜底) |
|
| 243 |
+
| 上传失败 | 请求失败,不做静默降级,避免出现「上下文已丢失但看起来成功」的回复 |
|
| 244 |
+
|
| 245 |
+
满足条件时:
|
| 246 |
+
|
| 247 |
+
1. 按第 3 节的转写格式生成对话记录,写入 `ctx-<12 位小写字母数字>.txt`。文件名随机,不含 `history` 之类语义词,减少模型把它当文档分析的倾向。
|
| 248 |
+
2. 若请求带可用工具(且 `tool_choice` 不是 `none`,且 `tools.enabled=true`),额外上传 `context_tools.txt`。
|
| 249 |
+
3. prompt 改写成两段:工具说明用 system 消息给出文件分工,user 消息给出衔接指令(含文件名)。原始长对话不再进 prompt。
|
| 250 |
+
4. 文件 ID 前置插入 `RefFileIDs`,与客户端自带的 `ref_file_ids` 去重后一起送上游。
|
| 251 |
+
5. `PromptTokenText` 保留「对话记录 + 工具说明」全文,用于 token 估算,与实际 prompt 长度解耦。
|
| 252 |
+
|
| 253 |
+
切号重试时(空输出 429 换账号)会调用 `ReuploadAppliedCurrentInputFile`:用同一份 `HistoryText` 重新上传,并把旧的历史文件 ID、工具文件 ID 从 `RefFileIDs` 里替换掉,避免新账号引用到别的账号名下的文件。
|
| 254 |
+
|
| 255 |
+
上游流式生成期间若再次需要拆分(例如重试),沿用已记录的 `CurrentInputFilename`,同一轮对话不会出现两个名字不同的上下文文件。
|
| 256 |
+
|
| 257 |
+
---
|
| 258 |
+
|
| 259 |
+
## 6. 模型 / 思考 / 联网解析优先级
|
| 260 |
+
|
| 261 |
+
### 模型
|
| 262 |
+
|
| 263 |
+
`/v1/models`、`/api/tags`(Ollama)、`/anthropic/v1/models` 只公布 flash 系列:
|
| 264 |
+
|
| 265 |
+
| 对外模型 ID | 默认思考 | 默认联网 |
|
| 266 |
+
| --- | --- | --- |
|
| 267 |
+
| `deepseek-v4-flash` | 开 | 关 |
|
| 268 |
+
| `deepseek-v4-flash-search` | 开 | 开 |
|
| 269 |
+
| `deepseek-v4-flash-nothinking` | 强制关 | 关 |
|
| 270 |
+
| `deepseek-v4-flash-search-nothinking` | 强制关 | 开 |
|
| 271 |
+
|
| 272 |
+
上游已把 v4-pro / v4-vision 合并进 v4-flash,对外不再公布:
|
| 273 |
+
|
| 274 |
+
| 旧模型名 | 解析结果 |
|
| 275 |
+
| --- | --- |
|
| 276 |
+
| `deepseek-v4-pro` | `deepseek-v4-flash` |
|
| 277 |
+
| `deepseek-v4-pro-search` | `deepseek-v4-flash-search` |
|
| 278 |
+
| `deepseek-v4-vision` | `deepseek-v4-flash` |
|
| 279 |
+
|
| 280 |
+
静默映射,保留 `-nothinking` 后缀:`deepseek-v4-pro-nothinking` → `deepseek-v4-flash-nothinking`。老客户端保存过的模型名继续可用,只是不再出现在列表里。
|
| 281 |
+
|
| 282 |
+
上游 `model_type` 只剩 `default`。内置别名(`gpt-*`、`o*`、`claude-*`、`gemini-*`)全部指向 flash 系列,其中 `o3-deep-research`、`o4-mini-deep-research` 指向 `deepseek-v4-flash-search`。`config.model_aliases` 可覆盖或补充同名映射。
|
| 283 |
+
|
| 284 |
+
### 思考
|
| 285 |
+
|
| 286 |
+
优先级从低到高:
|
| 287 |
+
|
| 288 |
+
1. 模型默认值(`-nothinking` 变体默认关,其余 flash 模型默认开);
|
| 289 |
+
2. 客户端显式覆盖;
|
| 290 |
+
3. `-nothinking` 模型强制关闭(最高)。
|
| 291 |
+
|
| 292 |
+
客户端覆盖按以下顺序查找,命中即返回,`extra_body` 里的同名键也会被读取:
|
| 293 |
+
|
| 294 |
+
| 来源 | 接受写法 |
|
| 295 |
+
| --- | --- |
|
| 296 |
+
| `thinking` | bool;`"enabled"` / `"disabled"`(含 `on/off/true/false/none`);`{"type": ...}` |
|
| 297 |
+
| `reasoning` | `{"effort": ...}`、`{"type": ...}`、`{"enabled": ...}`;字符串按 effort 解析 |
|
| 298 |
+
| `reasoning_effort` | `minimal` / `low` / `medium` / `high` / `xhigh` / `max` = 开启;`none` / `disabled` / `disable` / `off` / `false` = 关闭 |
|
| 299 |
+
|
| 300 |
+
Kelivo 这类客户端把界面上的「思维强度」翻译成 `reasoning_effort`:没有这个字段表示跟随默认(即开启思考),传 `none` 表示关闭。`minimal` 到 `max` 都只表示「要思考」,本项目不区分强度档位——上游只有开/关两态。
|
| 301 |
+
|
| 302 |
+
### 联网搜索
|
| 303 |
+
|
| 304 |
+
优先级:模型默认(`-search` 变体为开)→ 客户端显式信号优先。
|
| 305 |
+
|
| 306 |
+
以下任一命中即视为客户端显式开启:`enable_search`(bool 或 `"true"`/`"false"`)、`web_search_options` 出现、`search_options` 出现、`tools` 里带原生搜索类型(`type` 以 `web_search` 开头、`type=google_search`、或工具对象里有 `googleSearch` / `google_search` / `web_search` 字段)。`enable_search` 显式 `false` 也会生效,用于在 `-search` 模型上临时关掉联网。
|
| 307 |
+
|
| 308 |
+
普通 function 工具不算信号。像 Kelivo 客户端自己执行的 `search_web` 始终是普通 function tool,默认仍不开启上游联网。
|
| 309 |
+
|
| 310 |
+
现状说明:Kelivo 对「自定义 OpenAI 兼容服务」不会发送原生搜索标记,所以要用联网请选 `-search` 模型;一旦客户端开始发送上述显式信号,服务端会自动跟随,不需要改配置。
|
| 311 |
+
|
| 312 |
+
---
|
| 313 |
+
|
| 314 |
+
## 7. 协议适配器边界
|
| 315 |
+
|
| 316 |
+
适配器只允许做这些事:
|
| 317 |
+
|
| 318 |
+
| 允许 | 说明 |
|
| 319 |
+
| --- | --- |
|
| 320 |
+
| 解析协议请求体 | OpenAI、Anthropic、Gemini 各自的字段形状差异 |
|
| 321 |
+
| 消息归一化 | 把 `content` 数组、图片块、`tool_use` / `tool_result`、`functionDeclarations` 等翻译成统一消息 |
|
| 322 |
+
| 填 `StandardRequest` | 含 `Surface`、模型名、工具、透传参数 |
|
| 323 |
+
| 渲染协议响应 | 非流式结构、SSE 事件名与顺序、`finish_reason` / `stop_reason` / `finishReason` 映射 |
|
| 324 |
+
| 协议专属错误形态 | 错误对象字段名、HTTP 状态码对齐 |
|
| 325 |
+
|
| 326 |
+
不允许在适配器里实现(必须放共享路径):
|
| 327 |
+
|
| 328 |
+
| 不允许 | 共享实现位置 |
|
| 329 |
+
| --- | --- |
|
| 330 |
+
| 空输出重试与切号 | `internal/completionruntime/`、各协议的 `empty_retry_runtime` 共用同一策略 |
|
| 331 |
+
| 思考/联网解析 | `internal/util/thinking.go`、`internal/util/search.go` |
|
| 332 |
+
| 工具调用检测与策略 | `internal/toolcall/`、`internal/httpapi/openai/shared/` |
|
| 333 |
+
| 思考格式注入 | `promptcompat.ApplyThinkingInjectionToRequest` |
|
| 334 |
+
| ��史拆分与文件上传 | `internal/httpapi/openai/history/` |
|
| 335 |
+
| 完成载荷组装 | `StandardRequest.CompletionPayload` |
|
| 336 |
+
| 响应记录 | `internal/chathistory/` |
|
| 337 |
+
|
| 338 |
+
确有必须按协议不同的行为(例如 Claude 输出 `thinking` block 而 Gemini 输出 `parts[].thought=true`),保持在渲染层,并在代码注释里写明为什么不能进共享路径。
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
## 8. 变更记录与兼容性
|
| 343 |
+
|
| 344 |
+
| 变更 | 兼容策略 |
|
| 345 |
+
| --- | --- |
|
| 346 |
+
| 模型系列收敛到 flash 4 个 ID | pro / vision 老名字静默映射到 flash 对应项,`-nothinking` 后缀保留;不再对外公布 |
|
| 347 |
+
| `model_type` 只剩 `default` | 旧配置里出现 `expert` / `vision` 也不会让请求失败,解析时按 `default` 处理 |
|
| 348 |
+
| 上下文文件标题从 `# context_context.txt` 改为 `# 对话记录` | 旧文件是纯文本,读回不报错;新文件不再使用文件名式标题 |
|
| 349 |
+
| 对话记录角色行从 `[r=0]`…`[r=9]` 改为 `[role=user]` 等 | 管理台解析同时接受 `[role=...]` 与旧 `[r=N]`;后端只把记录当纯文本 |
|
| 350 |
+
| 工具说明文件标题改为 `# 可用工具说明` | 文件名仍是 `context_tools.txt`,prompt 里的引用文案不变 |
|
| 351 |
+
| 思考格式注入从 `internal/httpapi/openai/shared` 移到 `internal/promptcompat` | 四个对话接口共用同一实现,配置项与字段名不变 |
|
| 352 |
+
| 联网信号改为「模型默认 + 客户端显式覆盖」 | 只在客户端发显式信号时改变行为;不发信号的客户端表现与之前一致 |
|
| 353 |
+
| 新增 `tools.enabled`,缺省 `false` | 默认行为变化:未显式开启时不再注入工具说明、不再返回 `tool_calls`;老客户端带 `tools` 的请求会退化为纯对话,需要工具能力请在 `/admin/settings` 或配置文件显式开启 |
|
| 354 |
+
| 新增 `skills` 纯文本技能库(实验性,缺省关闭) | 默认不注入,不改变现有请求的 prompt;开启后只在 `FinalPrompt` / `PromptTokenText` 最前面加一个 System 技能段,`Messages` 与对话记录不受影响 |
|
| 355 |
+
|
| 356 |
+
---
|
| 357 |
+
|
| 358 |
+
## 9. 相关文档
|
| 359 |
+
|
| 360 |
+
- [API.md](../API.md) / [API.en.md](../API.en.md):请求字段、响应结构、Admin 接口
|
| 361 |
+
- [README.en.md](../README.en.md):模型列表与部署入口
|
| 362 |
+
- [AGENTS.md](../AGENTS.md):适配器边界与文档同步要求
|
internal/auth/request.go
CHANGED
|
@@ -36,11 +36,23 @@ type RequestAuth struct {
|
|
| 36 |
UseConfigToken bool
|
| 37 |
DeepSeekToken string
|
| 38 |
CallerID string
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
type LoginFunc func(ctx context.Context, acc config.Account) (string, error)
|
|
@@ -77,12 +89,20 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
|
|
| 77 |
return nil, ErrUnauthorized
|
| 78 |
}
|
| 79 |
callerID := callerTokenID(callerKey)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
ctx := req.Context()
|
| 81 |
if !r.Store.HasAPIKey(callerKey) {
|
| 82 |
return &RequestAuth{
|
| 83 |
UseConfigToken: false,
|
| 84 |
DeepSeekToken: callerKey,
|
| 85 |
CallerID: callerID,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
resolver: r,
|
| 87 |
TriedAccounts: map[string]bool{},
|
| 88 |
}, nil
|
|
@@ -92,9 +112,55 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
|
|
| 92 |
if err != nil {
|
| 93 |
return nil, err
|
| 94 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
return a, nil
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, target string) (*RequestAuth, error) {
|
| 99 |
tried := map[string]bool{}
|
| 100 |
var lastEnsureErr error
|
|
@@ -147,6 +213,10 @@ func (r *Resolver) DetermineCaller(req *http.Request) (*RequestAuth, error) {
|
|
| 147 |
a := &RequestAuth{
|
| 148 |
UseConfigToken: false,
|
| 149 |
CallerID: callerID,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
resolver: r,
|
| 151 |
TriedAccounts: map[string]bool{},
|
| 152 |
}
|
|
@@ -375,6 +445,11 @@ func callerTokenID(token string) string {
|
|
| 375 |
return "caller:" + hex.EncodeToString(sum[:8])
|
| 376 |
}
|
| 377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error {
|
| 379 |
if strings.TrimSpace(a.Account.Token) == "" {
|
| 380 |
return r.loginAndPersist(ctx, a)
|
|
|
|
| 36 |
UseConfigToken bool
|
| 37 |
DeepSeekToken string
|
| 38 |
CallerID string
|
| 39 |
+
// CallerLabel 是发起方 API Key 的可读标签(名称/备注/脱敏 Key),
|
| 40 |
+
// 只用于日志展示;未命中已配置 Key 时为空。
|
| 41 |
+
CallerLabel string
|
| 42 |
+
// Injection 是本次请求生效的「思考格式注入」设置:全局设置叠加该 API Key
|
| 43 |
+
// 的自定义覆盖后得到的最终值。
|
| 44 |
+
Injection config.ThinkingInjectionSettings
|
| 45 |
+
// Skills 是本次请求生效的「纯文本技能库」设置:全局设置叠加该 API Key
|
| 46 |
+
// 的自定义覆盖后得到的最终值(Items 已按 Key 过滤)。
|
| 47 |
+
Skills config.SkillSettings
|
| 48 |
+
// ToolsEnabled 是本次请求生效的「工具调用」总开关,由全局配置决定
|
| 49 |
+
// (未配置时按 false 处理,即默认纯文本对话)。
|
| 50 |
+
ToolsEnabled bool
|
| 51 |
+
AccountID string
|
| 52 |
+
TargetAccount string
|
| 53 |
+
Account config.Account
|
| 54 |
+
TriedAccounts map[string]bool
|
| 55 |
+
resolver *Resolver
|
| 56 |
}
|
| 57 |
|
| 58 |
type LoginFunc func(ctx context.Context, acc config.Account) (string, error)
|
|
|
|
| 89 |
return nil, ErrUnauthorized
|
| 90 |
}
|
| 91 |
callerID := callerTokenID(callerKey)
|
| 92 |
+
callerLabel := r.callerLabel(callerKey)
|
| 93 |
+
injection := r.resolveInjection(callerKey)
|
| 94 |
+
skills := r.resolveSkills(callerKey)
|
| 95 |
+
toolsEnabled := r.toolsEnabled()
|
| 96 |
ctx := req.Context()
|
| 97 |
if !r.Store.HasAPIKey(callerKey) {
|
| 98 |
return &RequestAuth{
|
| 99 |
UseConfigToken: false,
|
| 100 |
DeepSeekToken: callerKey,
|
| 101 |
CallerID: callerID,
|
| 102 |
+
CallerLabel: callerLabel,
|
| 103 |
+
Injection: injection,
|
| 104 |
+
Skills: skills,
|
| 105 |
+
ToolsEnabled: toolsEnabled,
|
| 106 |
resolver: r,
|
| 107 |
TriedAccounts: map[string]bool{},
|
| 108 |
}, nil
|
|
|
|
| 112 |
if err != nil {
|
| 113 |
return nil, err
|
| 114 |
}
|
| 115 |
+
a.CallerLabel = callerLabel
|
| 116 |
+
a.Injection = injection
|
| 117 |
+
a.Skills = skills
|
| 118 |
+
a.ToolsEnabled = toolsEnabled
|
| 119 |
return a, nil
|
| 120 |
}
|
| 121 |
|
| 122 |
+
// callerLabel 解析发起方 Key 的展示标签;未配置时返回空串。
|
| 123 |
+
func (r *Resolver) callerLabel(callerKey string) string {
|
| 124 |
+
if r == nil || r.Store == nil {
|
| 125 |
+
return ""
|
| 126 |
+
}
|
| 127 |
+
item, ok := r.Store.FindAPIKey(callerKey)
|
| 128 |
+
if !ok {
|
| 129 |
+
return ""
|
| 130 |
+
}
|
| 131 |
+
if name := strings.TrimSpace(item.Name); name != "" {
|
| 132 |
+
return name
|
| 133 |
+
}
|
| 134 |
+
if remark := strings.TrimSpace(item.Remark); remark != "" {
|
| 135 |
+
return remark
|
| 136 |
+
}
|
| 137 |
+
return config.MaskAPIKeyPreview(item.Key)
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// resolveInjection 解析全局 + 单 Key 自定义后的注入设置。
|
| 141 |
+
func (r *Resolver) resolveInjection(callerKey string) config.ThinkingInjectionSettings {
|
| 142 |
+
if r == nil || r.Store == nil {
|
| 143 |
+
return config.ThinkingInjectionSettings{}
|
| 144 |
+
}
|
| 145 |
+
return r.Store.ResolveThinkingInjection(callerKey)
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// resolveSkills 解析全局 + 单 Key 自定义后的技能设置。
|
| 149 |
+
func (r *Resolver) resolveSkills(callerKey string) config.SkillSettings {
|
| 150 |
+
if r == nil || r.Store == nil {
|
| 151 |
+
return config.SkillSettings{}
|
| 152 |
+
}
|
| 153 |
+
return r.Store.ResolveSkills(callerKey)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// toolsEnabled 返回工具调用总开关(未配置时为 false)。
|
| 157 |
+
func (r *Resolver) toolsEnabled() bool {
|
| 158 |
+
if r == nil || r.Store == nil {
|
| 159 |
+
return false
|
| 160 |
+
}
|
| 161 |
+
return r.Store.ToolsEnabled()
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, target string) (*RequestAuth, error) {
|
| 165 |
tried := map[string]bool{}
|
| 166 |
var lastEnsureErr error
|
|
|
|
| 213 |
a := &RequestAuth{
|
| 214 |
UseConfigToken: false,
|
| 215 |
CallerID: callerID,
|
| 216 |
+
CallerLabel: r.callerLabel(callerKey),
|
| 217 |
+
Injection: r.resolveInjection(callerKey),
|
| 218 |
+
Skills: r.resolveSkills(callerKey),
|
| 219 |
+
ToolsEnabled: r.toolsEnabled(),
|
| 220 |
resolver: r,
|
| 221 |
TriedAccounts: map[string]bool{},
|
| 222 |
}
|
|
|
|
| 445 |
return "caller:" + hex.EncodeToString(sum[:8])
|
| 446 |
}
|
| 447 |
|
| 448 |
+
// CallerTokenID 暴露 CallerID 计算方式,供后台按 API Key 过滤响应记录。
|
| 449 |
+
func CallerTokenID(token string) string {
|
| 450 |
+
return callerTokenID(token)
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error {
|
| 454 |
if strings.TrimSpace(a.Account.Token) == "" {
|
| 455 |
return r.loginAndPersist(ctx, a)
|
internal/auth/request_test.go
CHANGED
|
@@ -395,3 +395,150 @@ func TestDetermineManagedAccountReturnsLastEnsureErrorWhenAllFail(t *testing.T)
|
|
| 395 |
t.Fatalf("expected auth-style ensure error, got ErrNoAccount")
|
| 396 |
}
|
| 397 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
t.Fatalf("expected auth-style ensure error, got ErrNoAccount")
|
| 396 |
}
|
| 397 |
}
|
| 398 |
+
|
| 399 |
+
func TestDetermineResolvesCallerLabelAndInjection(t *testing.T) {
|
| 400 |
+
t.Setenv("DS2API_CONFIG_JSON", `{
|
| 401 |
+
"accounts":[{"email":"acc@example.com","password":"pwd","token":"account-token"}],
|
| 402 |
+
"api_keys":[
|
| 403 |
+
{"key":"managed-key","name":"主账号"},
|
| 404 |
+
{"key":"custom-key","name":"自定义","thinking_injection":{"custom":true,"enabled":false,"prompt":" key prompt "}}
|
| 405 |
+
],
|
| 406 |
+
"thinking_injection":{"enabled":true,"prompt":"global prompt"}
|
| 407 |
+
}`)
|
| 408 |
+
store := config.LoadStore()
|
| 409 |
+
pool := account.NewPool(store)
|
| 410 |
+
r := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
|
| 411 |
+
return "fresh-token", nil
|
| 412 |
+
})
|
| 413 |
+
|
| 414 |
+
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
| 415 |
+
req.Header.Set("Authorization", "Bearer managed-key")
|
| 416 |
+
a, err := r.Determine(req)
|
| 417 |
+
if err != nil {
|
| 418 |
+
t.Fatalf("determine failed: %v", err)
|
| 419 |
+
}
|
| 420 |
+
defer r.Release(a)
|
| 421 |
+
if a.CallerLabel != "主账号" {
|
| 422 |
+
t.Fatalf("expected caller label from api key name, got %q", a.CallerLabel)
|
| 423 |
+
}
|
| 424 |
+
if !a.Injection.Enabled || a.Injection.Prompt != "global prompt" {
|
| 425 |
+
t.Fatalf("expected global injection settings, got %#v", a.Injection)
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
req2, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
| 429 |
+
req2.Header.Set("Authorization", "Bearer custom-key")
|
| 430 |
+
b, err := r.Determine(req2)
|
| 431 |
+
if err != nil {
|
| 432 |
+
t.Fatalf("determine failed: %v", err)
|
| 433 |
+
}
|
| 434 |
+
defer r.Release(b)
|
| 435 |
+
if b.Injection.Enabled {
|
| 436 |
+
t.Fatalf("expected per-key injection disable, got %#v", b.Injection)
|
| 437 |
+
}
|
| 438 |
+
if b.Injection.Prompt != "key prompt" {
|
| 439 |
+
t.Fatalf("expected per-key prompt, got %#v", b.Injection)
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
req3, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
| 443 |
+
req3.Header.Set("Authorization", "Bearer direct-token")
|
| 444 |
+
c, err := r.Determine(req3)
|
| 445 |
+
if err != nil {
|
| 446 |
+
t.Fatalf("determine failed: %v", err)
|
| 447 |
+
}
|
| 448 |
+
if c.CallerLabel != "" {
|
| 449 |
+
t.Fatalf("expected no caller label for unknown key, got %q", c.CallerLabel)
|
| 450 |
+
}
|
| 451 |
+
if !c.Injection.Enabled || c.Injection.Prompt != "global prompt" {
|
| 452 |
+
t.Fatalf("expected unknown key to follow global injection, got %#v", c.Injection)
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
func TestDetermineResolvesSkillsAndTools(t *testing.T) {
|
| 457 |
+
t.Setenv("DS2API_CONFIG_JSON", `{
|
| 458 |
+
"accounts":[{"email":"acc@example.com","password":"pwd","token":"account-token"}],
|
| 459 |
+
"api_keys":[
|
| 460 |
+
{"key":"managed-key","name":"主账号"},
|
| 461 |
+
{"key":"custom-key","name":"只用一个技能","skills":{"custom":true,"enabled":true,"ids":["sql-report"]}},
|
| 462 |
+
{"key":"off-key","name":"关闭","skills":{"custom":true,"enabled":false}}
|
| 463 |
+
],
|
| 464 |
+
"skills":{
|
| 465 |
+
"enabled":true,
|
| 466 |
+
"max_chars":256,
|
| 467 |
+
"items":[
|
| 468 |
+
{"id":"sql-report","name":"SQL 报表","body":"sql body"},
|
| 469 |
+
{"id":"review","name":"评审","body":"review body"}
|
| 470 |
+
]
|
| 471 |
+
},
|
| 472 |
+
"tools":{"enabled":true}
|
| 473 |
+
}`)
|
| 474 |
+
store := config.LoadStore()
|
| 475 |
+
pool := account.NewPool(store)
|
| 476 |
+
r := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
|
| 477 |
+
return "fresh-token", nil
|
| 478 |
+
})
|
| 479 |
+
|
| 480 |
+
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
| 481 |
+
req.Header.Set("Authorization", "Bearer managed-key")
|
| 482 |
+
a, err := r.Determine(req)
|
| 483 |
+
if err != nil {
|
| 484 |
+
t.Fatalf("determine failed: %v", err)
|
| 485 |
+
}
|
| 486 |
+
if !a.ToolsEnabled {
|
| 487 |
+
t.Fatal("expected tools enabled from config")
|
| 488 |
+
}
|
| 489 |
+
if !a.Skills.Enabled || len(a.Skills.Items) != 2 {
|
| 490 |
+
t.Fatalf("expected all global skills, got %#v", a.Skills)
|
| 491 |
+
}
|
| 492 |
+
if a.Skills.MaxChars != 256 {
|
| 493 |
+
t.Fatalf("skills max_chars=%d want=256", a.Skills.MaxChars)
|
| 494 |
+
}
|
| 495 |
+
// 账号池默认每账号并发有限,先释放再取下一个 Key,避免 Determine 阻塞等待。
|
| 496 |
+
r.Release(a)
|
| 497 |
+
|
| 498 |
+
req2, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
| 499 |
+
req2.Header.Set("Authorization", "Bearer custom-key")
|
| 500 |
+
b, err := r.Determine(req2)
|
| 501 |
+
if err != nil {
|
| 502 |
+
t.Fatalf("determine failed: %v", err)
|
| 503 |
+
}
|
| 504 |
+
if !b.Skills.Enabled || len(b.Skills.Items) != 1 || b.Skills.Items[0].ID != "sql-report" {
|
| 505 |
+
t.Fatalf("expected per-key skill filter, got %#v", b.Skills)
|
| 506 |
+
}
|
| 507 |
+
r.Release(b)
|
| 508 |
+
|
| 509 |
+
req3, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
| 510 |
+
req3.Header.Set("Authorization", "Bearer off-key")
|
| 511 |
+
off, err := r.Determine(req3)
|
| 512 |
+
if err != nil {
|
| 513 |
+
t.Fatalf("determine failed: %v", err)
|
| 514 |
+
}
|
| 515 |
+
if off.Skills.Enabled || len(off.Skills.Items) != 0 {
|
| 516 |
+
t.Fatalf("expected per-key skills disabled, got %#v", off.Skills)
|
| 517 |
+
}
|
| 518 |
+
r.Release(off)
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
func TestDetermineCallerResolvesSkillsAndTools(t *testing.T) {
|
| 522 |
+
t.Setenv("DS2API_CONFIG_JSON", `{
|
| 523 |
+
"keys":["k1"],
|
| 524 |
+
"skills":{"enabled":true,"items":[{"id":"sql-report","body":"sql body"}]}
|
| 525 |
+
}`)
|
| 526 |
+
store := config.LoadStore()
|
| 527 |
+
r := NewResolver(store, nil, nil)
|
| 528 |
+
|
| 529 |
+
req, _ := http.NewRequest(http.MethodPost, "/v1/responses", nil)
|
| 530 |
+
req.Header.Set("Authorization", "Bearer k1")
|
| 531 |
+
a, err := r.DetermineCaller(req)
|
| 532 |
+
if err != nil {
|
| 533 |
+
t.Fatalf("determine caller failed: %v", err)
|
| 534 |
+
}
|
| 535 |
+
if !a.Skills.Enabled || len(a.Skills.Items) != 1 {
|
| 536 |
+
t.Fatalf("expected skills resolved for caller, got %#v", a.Skills)
|
| 537 |
+
}
|
| 538 |
+
if a.Skills.MaxChars != config.DefaultSkillsMaxChars {
|
| 539 |
+
t.Fatalf("skills max_chars=%d want default", a.Skills.MaxChars)
|
| 540 |
+
}
|
| 541 |
+
if a.ToolsEnabled {
|
| 542 |
+
t.Fatal("expected tools disabled by default (nil pointer)")
|
| 543 |
+
}
|
| 544 |
+
}
|
internal/chathistory/store.go
CHANGED
|
@@ -18,10 +18,12 @@ import (
|
|
| 18 |
)
|
| 19 |
|
| 20 |
const (
|
| 21 |
-
FileVersion
|
| 22 |
-
DisabledLimit
|
| 23 |
-
DefaultLimit
|
| 24 |
-
MaxLimit
|
|
|
|
|
|
|
| 25 |
defaultPreviewAt = 160
|
| 26 |
|
| 27 |
// asyncDebounce 是流式进度落盘的合并窗口:窗口内的多次进度更新只会产生
|
|
@@ -33,23 +35,19 @@ const (
|
|
| 33 |
asyncRetryDelay = 5 * time.Second
|
| 34 |
)
|
| 35 |
|
| 36 |
-
var allowedLimits = map[int]struct{}{
|
| 37 |
-
DisabledLimit: {},
|
| 38 |
-
10: {},
|
| 39 |
-
20: {},
|
| 40 |
-
50: {},
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
var ErrDisabled = errors.New("chat history disabled")
|
| 44 |
|
| 45 |
type Entry struct {
|
| 46 |
-
ID
|
| 47 |
-
Revision
|
| 48 |
-
CreatedAt
|
| 49 |
-
UpdatedAt
|
| 50 |
-
CompletedAt
|
| 51 |
-
Status
|
| 52 |
-
CallerID
|
|
|
|
|
|
|
|
|
|
| 53 |
AccountID string `json:"account_id,omitempty"`
|
| 54 |
Surface string `json:"surface,omitempty"`
|
| 55 |
Model string `json:"model,omitempty"`
|
|
@@ -97,6 +95,7 @@ type SummaryEntry struct {
|
|
| 97 |
CompletedAt int64 `json:"completed_at,omitempty"`
|
| 98 |
Status string `json:"status"`
|
| 99 |
CallerID string `json:"caller_id,omitempty"`
|
|
|
|
| 100 |
AccountID string `json:"account_id,omitempty"`
|
| 101 |
Surface string `json:"surface,omitempty"`
|
| 102 |
Model string `json:"model,omitempty"`
|
|
@@ -118,6 +117,7 @@ type File struct {
|
|
| 118 |
|
| 119 |
type StartParams struct {
|
| 120 |
CallerID string
|
|
|
|
| 121 |
AccountID string
|
| 122 |
Surface string
|
| 123 |
Model string
|
|
@@ -348,6 +348,7 @@ func (s *Store) Start(params StartParams) (Entry, error) {
|
|
| 348 |
UpdatedAt: now,
|
| 349 |
Status: "streaming",
|
| 350 |
CallerID: strings.TrimSpace(params.CallerID),
|
|
|
|
| 351 |
AccountID: strings.TrimSpace(params.AccountID),
|
| 352 |
Surface: strings.TrimSpace(params.Surface),
|
| 353 |
Model: strings.TrimSpace(params.Model),
|
|
@@ -823,7 +824,7 @@ func (s *Store) rebuildIndexLocked() {
|
|
| 823 |
}
|
| 824 |
return summaries[i].CreatedAt > summaries[j].CreatedAt
|
| 825 |
})
|
| 826 |
-
if
|
| 827 |
s.state.Limit = DefaultLimit
|
| 828 |
}
|
| 829 |
if s.state.Limit == DisabledLimit {
|
|
@@ -865,6 +866,7 @@ func summaryFromEntry(item Entry) SummaryEntry {
|
|
| 865 |
CompletedAt: item.CompletedAt,
|
| 866 |
Status: item.Status,
|
| 867 |
CallerID: item.CallerID,
|
|
|
|
| 868 |
AccountID: item.AccountID,
|
| 869 |
Surface: item.Surface,
|
| 870 |
Model: item.Model,
|
|
@@ -1038,9 +1040,10 @@ func DetailETag(id string, revision int64) string {
|
|
| 1038 |
return fmt.Sprintf(`W/"chat-history-detail-%s-%d"`, strings.TrimSpace(id), revision)
|
| 1039 |
}
|
| 1040 |
|
|
|
|
|
|
|
| 1041 |
func isAllowedLimit(limit int) bool {
|
| 1042 |
-
|
| 1043 |
-
return ok
|
| 1044 |
}
|
| 1045 |
|
| 1046 |
func (s *Store) markDetailDirtyLocked(id string) {
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
const (
|
| 21 |
+
FileVersion = 2
|
| 22 |
+
DisabledLimit = 0
|
| 23 |
+
DefaultLimit = 20
|
| 24 |
+
// MaxLimit 是保留条数的上限。DisabledLimit(0) 表示关闭记录,
|
| 25 |
+
// 1..MaxLimit 之间的任意值都合法,后台可以按需自由设置。
|
| 26 |
+
MaxLimit = 500
|
| 27 |
defaultPreviewAt = 160
|
| 28 |
|
| 29 |
// asyncDebounce 是流式进度落盘的合并窗口:窗口内的多次进度更新只会产生
|
|
|
|
| 35 |
asyncRetryDelay = 5 * time.Second
|
| 36 |
)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
var ErrDisabled = errors.New("chat history disabled")
|
| 39 |
|
| 40 |
type Entry struct {
|
| 41 |
+
ID string `json:"id"`
|
| 42 |
+
Revision int64 `json:"revision"`
|
| 43 |
+
CreatedAt int64 `json:"created_at"`
|
| 44 |
+
UpdatedAt int64 `json:"updated_at"`
|
| 45 |
+
CompletedAt int64 `json:"completed_at,omitempty"`
|
| 46 |
+
Status string `json:"status"`
|
| 47 |
+
CallerID string `json:"caller_id,omitempty"`
|
| 48 |
+
// CallerLabel 是发起方 API Key 的可读标签(名称/备注/脱敏 Key),
|
| 49 |
+
// 只用于后台展示与筛选,避免面板里只剩一串哈希。
|
| 50 |
+
CallerLabel string `json:"caller_label,omitempty"`
|
| 51 |
AccountID string `json:"account_id,omitempty"`
|
| 52 |
Surface string `json:"surface,omitempty"`
|
| 53 |
Model string `json:"model,omitempty"`
|
|
|
|
| 95 |
CompletedAt int64 `json:"completed_at,omitempty"`
|
| 96 |
Status string `json:"status"`
|
| 97 |
CallerID string `json:"caller_id,omitempty"`
|
| 98 |
+
CallerLabel string `json:"caller_label,omitempty"`
|
| 99 |
AccountID string `json:"account_id,omitempty"`
|
| 100 |
Surface string `json:"surface,omitempty"`
|
| 101 |
Model string `json:"model,omitempty"`
|
|
|
|
| 117 |
|
| 118 |
type StartParams struct {
|
| 119 |
CallerID string
|
| 120 |
+
CallerLabel string
|
| 121 |
AccountID string
|
| 122 |
Surface string
|
| 123 |
Model string
|
|
|
|
| 348 |
UpdatedAt: now,
|
| 349 |
Status: "streaming",
|
| 350 |
CallerID: strings.TrimSpace(params.CallerID),
|
| 351 |
+
CallerLabel: strings.TrimSpace(params.CallerLabel),
|
| 352 |
AccountID: strings.TrimSpace(params.AccountID),
|
| 353 |
Surface: strings.TrimSpace(params.Surface),
|
| 354 |
Model: strings.TrimSpace(params.Model),
|
|
|
|
| 824 |
}
|
| 825 |
return summaries[i].CreatedAt > summaries[j].CreatedAt
|
| 826 |
})
|
| 827 |
+
if !isAllowedLimit(s.state.Limit) {
|
| 828 |
s.state.Limit = DefaultLimit
|
| 829 |
}
|
| 830 |
if s.state.Limit == DisabledLimit {
|
|
|
|
| 866 |
CompletedAt: item.CompletedAt,
|
| 867 |
Status: item.Status,
|
| 868 |
CallerID: item.CallerID,
|
| 869 |
+
CallerLabel: item.CallerLabel,
|
| 870 |
AccountID: item.AccountID,
|
| 871 |
Surface: item.Surface,
|
| 872 |
Model: item.Model,
|
|
|
|
| 1040 |
return fmt.Sprintf(`W/"chat-history-detail-%s-%d"`, strings.TrimSpace(id), revision)
|
| 1041 |
}
|
| 1042 |
|
| 1043 |
+
// isAllowedLimit 判定保留条数是否合法:DisabledLimit(0) 表示关闭记录,
|
| 1044 |
+
// 1..MaxLimit 之间的任意值都合法,越界值一律视为非法。
|
| 1045 |
func isAllowedLimit(limit int) bool {
|
| 1046 |
+
return limit >= DisabledLimit && limit <= MaxLimit
|
|
|
|
| 1047 |
}
|
| 1048 |
|
| 1049 |
func (s *Store) markDetailDirtyLocked(id string) {
|
internal/chathistory/store_test.go
CHANGED
|
@@ -794,3 +794,130 @@ func TestStoreBackgroundProgressDoesNotSurfaceWriteFailure(t *testing.T) {
|
|
| 794 |
t.Fatalf("expected in-memory state to stay readable, got %#v", snapshot.Items)
|
| 795 |
}
|
| 796 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
t.Fatalf("expected in-memory state to stay readable, got %#v", snapshot.Items)
|
| 795 |
}
|
| 796 |
}
|
| 797 |
+
|
| 798 |
+
// 保留条数不再是固定档位:0 表示关闭,1..MaxLimit 都合法,越界必须报错。
|
| 799 |
+
func TestSetLimitAcceptsAnyValueInRange(t *testing.T) {
|
| 800 |
+
path := filepath.Join(t.TempDir(), "chat_history.json")
|
| 801 |
+
store := New(path)
|
| 802 |
+
t.Cleanup(store.Close)
|
| 803 |
+
|
| 804 |
+
for _, limit := range []int{1, 7, 20, 137, MaxLimit} {
|
| 805 |
+
snapshot, err := store.SetLimit(limit)
|
| 806 |
+
if err != nil {
|
| 807 |
+
t.Fatalf("SetLimit(%d) failed: %v", limit, err)
|
| 808 |
+
}
|
| 809 |
+
if snapshot.Limit != limit {
|
| 810 |
+
t.Fatalf("expected limit %d, got %d", limit, snapshot.Limit)
|
| 811 |
+
}
|
| 812 |
+
if !store.Enabled() {
|
| 813 |
+
t.Fatalf("expected store to stay enabled for limit %d", limit)
|
| 814 |
+
}
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
for _, limit := range []int{MaxLimit + 1, 9999, -1} {
|
| 818 |
+
if _, err := store.SetLimit(limit); err == nil {
|
| 819 |
+
t.Fatalf("expected SetLimit(%d) to fail", limit)
|
| 820 |
+
} else if !strings.Contains(err.Error(), "unsupported chat history limit") {
|
| 821 |
+
t.Fatalf("unexpected error for SetLimit(%d): %v", limit, err)
|
| 822 |
+
}
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
// 关闭仍然是一次合法设置:Start 返回 ErrDisabled,但已有记录保留。
|
| 826 |
+
snapshot, err := store.SetLimit(DisabledLimit)
|
| 827 |
+
if err != nil {
|
| 828 |
+
t.Fatalf("disable failed: %v", err)
|
| 829 |
+
}
|
| 830 |
+
if snapshot.Limit != DisabledLimit || store.Enabled() {
|
| 831 |
+
t.Fatalf("expected disabled store, got limit=%d", snapshot.Limit)
|
| 832 |
+
}
|
| 833 |
+
if _, err := store.Start(StartParams{UserInput: "later"}); !errors.Is(err, ErrDisabled) {
|
| 834 |
+
t.Fatalf("expected ErrDisabled, got %v", err)
|
| 835 |
+
}
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
// 定制档位必须真正生效:裁剪到 37 条而不是固定档位。
|
| 839 |
+
func TestSetLimitTrimsToCustomValue(t *testing.T) {
|
| 840 |
+
path := filepath.Join(t.TempDir(), "chat_history.json")
|
| 841 |
+
store := New(path)
|
| 842 |
+
t.Cleanup(store.Close)
|
| 843 |
+
if _, err := store.SetLimit(37); err != nil {
|
| 844 |
+
t.Fatalf("set limit failed: %v", err)
|
| 845 |
+
}
|
| 846 |
+
for i := 0; i < 40; i++ {
|
| 847 |
+
if _, err := store.Start(StartParams{UserInput: "msg"}); err != nil {
|
| 848 |
+
t.Fatalf("start %d failed: %v", i, err)
|
| 849 |
+
}
|
| 850 |
+
}
|
| 851 |
+
snapshot, err := store.Snapshot()
|
| 852 |
+
if err != nil {
|
| 853 |
+
t.Fatalf("snapshot failed: %v", err)
|
| 854 |
+
}
|
| 855 |
+
if len(snapshot.Items) != 37 {
|
| 856 |
+
t.Fatalf("expected 37 items, got %d", len(snapshot.Items))
|
| 857 |
+
}
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
// 索引里读到越界的保留条数时必须回落到 DefaultLimit,而不是照单全收。
|
| 861 |
+
func TestLoadFallsBackToDefaultLimitForOutOfRangeValue(t *testing.T) {
|
| 862 |
+
path := filepath.Join(t.TempDir(), "chat_history.json")
|
| 863 |
+
raw, err := json.Marshal(File{
|
| 864 |
+
Version: FileVersion,
|
| 865 |
+
Limit: MaxLimit + 1,
|
| 866 |
+
Revision: 1,
|
| 867 |
+
Items: []SummaryEntry{},
|
| 868 |
+
})
|
| 869 |
+
if err != nil {
|
| 870 |
+
t.Fatalf("encode index failed: %v", err)
|
| 871 |
+
}
|
| 872 |
+
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
| 873 |
+
t.Fatalf("write index failed: %v", err)
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
store := New(path)
|
| 877 |
+
t.Cleanup(store.Close)
|
| 878 |
+
snapshot, err := store.Snapshot()
|
| 879 |
+
if err != nil {
|
| 880 |
+
t.Fatalf("snapshot failed: %v", err)
|
| 881 |
+
}
|
| 882 |
+
if snapshot.Limit != DefaultLimit {
|
| 883 |
+
t.Fatalf("expected fallback limit %d, got %d", DefaultLimit, snapshot.Limit)
|
| 884 |
+
}
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
// CallerLabel 随记录落盘,并在索引摘要里带上,供后台按 API Key 展示/筛选。
|
| 888 |
+
func TestStorePersistsCallerLabel(t *testing.T) {
|
| 889 |
+
path := filepath.Join(t.TempDir(), "chat_history.json")
|
| 890 |
+
store := New(path)
|
| 891 |
+
t.Cleanup(store.Close)
|
| 892 |
+
|
| 893 |
+
started, err := store.Start(StartParams{
|
| 894 |
+
CallerID: "caller:abc",
|
| 895 |
+
CallerLabel: " 小张 ",
|
| 896 |
+
Model: "deepseek-v4-flash",
|
| 897 |
+
UserInput: "hello",
|
| 898 |
+
})
|
| 899 |
+
if err != nil {
|
| 900 |
+
t.Fatalf("start failed: %v", err)
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
snapshot, err := store.Snapshot()
|
| 904 |
+
if err != nil {
|
| 905 |
+
t.Fatalf("snapshot failed: %v", err)
|
| 906 |
+
}
|
| 907 |
+
if len(snapshot.Items) != 1 {
|
| 908 |
+
t.Fatalf("expected one item, got %d", len(snapshot.Items))
|
| 909 |
+
}
|
| 910 |
+
if snapshot.Items[0].CallerLabel != "小张" {
|
| 911 |
+
t.Fatalf("expected trimmed caller label in summary, got %q", snapshot.Items[0].CallerLabel)
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
reloaded := New(path)
|
| 915 |
+
t.Cleanup(reloaded.Close)
|
| 916 |
+
full, err := reloaded.Get(started.ID)
|
| 917 |
+
if err != nil {
|
| 918 |
+
t.Fatalf("get after reload failed: %v", err)
|
| 919 |
+
}
|
| 920 |
+
if full.CallerLabel != "小张" || full.CallerID != "caller:abc" {
|
| 921 |
+
t.Fatalf("expected caller identity to survive reload, got %#v", full)
|
| 922 |
+
}
|
| 923 |
+
}
|
internal/compat/go_compat_test.go
CHANGED
|
@@ -12,6 +12,7 @@ import (
|
|
| 12 |
)
|
| 13 |
|
| 14 |
func TestGoCompatSSEFixtures(t *testing.T) {
|
|
|
|
| 15 |
files, err := filepath.Glob(compatPath("fixtures", "sse_chunks", "*.json"))
|
| 16 |
if err != nil {
|
| 17 |
t.Fatalf("glob fixtures failed: %v", err)
|
|
@@ -64,6 +65,7 @@ func TestGoCompatSSEFixtures(t *testing.T) {
|
|
| 64 |
}
|
| 65 |
|
| 66 |
func TestGoCompatTokenFixtures(t *testing.T) {
|
|
|
|
| 67 |
var fixture struct {
|
| 68 |
Cases []struct {
|
| 69 |
Name string `json:"name"`
|
|
@@ -96,6 +98,20 @@ func TestGoCompatTokenFixtures(t *testing.T) {
|
|
| 96 |
}
|
| 97 |
}
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
func mustLoadJSON(t *testing.T, path string, out any) {
|
| 100 |
t.Helper()
|
| 101 |
b, err := os.ReadFile(path)
|
|
|
|
| 12 |
)
|
| 13 |
|
| 14 |
func TestGoCompatSSEFixtures(t *testing.T) {
|
| 15 |
+
requireCompatFixtures(t, compatPath("fixtures", "sse_chunks"), compatPath("expected"))
|
| 16 |
files, err := filepath.Glob(compatPath("fixtures", "sse_chunks", "*.json"))
|
| 17 |
if err != nil {
|
| 18 |
t.Fatalf("glob fixtures failed: %v", err)
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
func TestGoCompatTokenFixtures(t *testing.T) {
|
| 68 |
+
requireCompatFixtures(t, compatPath("fixtures", "token_cases.json"), compatPath("expected", "token_cases.json"))
|
| 69 |
var fixture struct {
|
| 70 |
Cases []struct {
|
| 71 |
Name string `json:"name"`
|
|
|
|
| 98 |
}
|
| 99 |
}
|
| 100 |
|
| 101 |
+
// requireCompatFixtures 在 tests/compat 对照数据缺失时跳过用例。
|
| 102 |
+
//
|
| 103 |
+
// 这些 fixture/expected 数据没有随本仓库快照提供(tests/ 整个目录都不在仓库里),
|
| 104 |
+
// 用例无从校验;也不应该在测试里凭空造一份期望值——那只会让测试自证。
|
| 105 |
+
// 数据补齐后本检查自动放行,用例恢复真实比对。
|
| 106 |
+
func requireCompatFixtures(t *testing.T, paths ...string) {
|
| 107 |
+
t.Helper()
|
| 108 |
+
for _, path := range paths {
|
| 109 |
+
if _, err := os.Stat(path); err != nil {
|
| 110 |
+
t.Skipf("compat fixtures 未随仓库提供,跳过:%s 不存在", path)
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
func mustLoadJSON(t *testing.T, path string, out any) {
|
| 116 |
t.Helper()
|
| 117 |
b, err := os.ReadFile(path)
|
internal/completionruntime/nonstream_test.go
CHANGED
|
@@ -315,7 +315,7 @@ func TestStartCompletionAppliesCurrentInputFileGlobally(t *testing.T) {
|
|
| 315 |
if !strings.Contains(prompt, ds.uploads[0].Filename) {
|
| 316 |
t.Fatalf("expected continuation prompt, got %q", prompt)
|
| 317 |
}
|
| 318 |
-
if !start.Request.CurrentInputFileApplied || !strings.Contains(start.Request.PromptTokenText, "#
|
| 319 |
t.Fatalf("expected prepared request to carry current input file state, got %#v", start.Request)
|
| 320 |
}
|
| 321 |
}
|
|
|
|
| 315 |
if !strings.Contains(prompt, ds.uploads[0].Filename) {
|
| 316 |
t.Fatalf("expected continuation prompt, got %q", prompt)
|
| 317 |
}
|
| 318 |
+
if !start.Request.CurrentInputFileApplied || !strings.Contains(start.Request.PromptTokenText, "# 对话记录") {
|
| 319 |
t.Fatalf("expected prepared request to carry current input file state, got %#v", start.Request)
|
| 320 |
}
|
| 321 |
}
|
internal/config/api_key_injection_test.go
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"testing"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
func TestConfigAPIKeyInjectionJSONRoundTrip(t *testing.T) {
|
| 9 |
+
enabled := true
|
| 10 |
+
cfg := Config{APIKeys: []APIKey{
|
| 11 |
+
{Key: "sk-plain"},
|
| 12 |
+
{
|
| 13 |
+
Key: "sk-custom",
|
| 14 |
+
Name: "自定义",
|
| 15 |
+
ThinkingInjection: &APIKeyInjectionConfig{
|
| 16 |
+
Custom: true,
|
| 17 |
+
Enabled: &enabled,
|
| 18 |
+
Prompt: " key prompt ",
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
}}
|
| 22 |
+
cfg.NormalizeCredentials()
|
| 23 |
+
|
| 24 |
+
raw, err := json.Marshal(cfg)
|
| 25 |
+
if err != nil {
|
| 26 |
+
t.Fatalf("marshal config: %v", err)
|
| 27 |
+
}
|
| 28 |
+
var decoded Config
|
| 29 |
+
if err := json.Unmarshal(raw, &decoded); err != nil {
|
| 30 |
+
t.Fatalf("unmarshal config: %v", err)
|
| 31 |
+
}
|
| 32 |
+
decoded.NormalizeCredentials()
|
| 33 |
+
|
| 34 |
+
if len(decoded.APIKeys) != 2 {
|
| 35 |
+
t.Fatalf("expected two api keys, got %#v", decoded.APIKeys)
|
| 36 |
+
}
|
| 37 |
+
if decoded.APIKeys[0].ThinkingInjection != nil {
|
| 38 |
+
t.Fatalf("expected plain key without injection, got %#v", decoded.APIKeys[0].ThinkingInjection)
|
| 39 |
+
}
|
| 40 |
+
injection := decoded.APIKeys[1].ThinkingInjection
|
| 41 |
+
if injection == nil || !injection.Custom || injection.Enabled == nil || !*injection.Enabled {
|
| 42 |
+
t.Fatalf("unexpected injection config: %#v", injection)
|
| 43 |
+
}
|
| 44 |
+
if injection.Prompt != "key prompt" {
|
| 45 |
+
t.Fatalf("expected trimmed prompt, got %q", injection.Prompt)
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func TestAPIKeyInjectionNormalizeDropsEmptyObject(t *testing.T) {
|
| 50 |
+
if got := (&APIKeyInjectionConfig{}).Normalize(); got != nil {
|
| 51 |
+
t.Fatalf("expected empty injection config to normalize to nil, got %#v", got)
|
| 52 |
+
}
|
| 53 |
+
enabled := false
|
| 54 |
+
if got := (&APIKeyInjectionConfig{Custom: true, Enabled: &enabled}).Normalize(); got == nil || got.Enabled == nil {
|
| 55 |
+
t.Fatalf("expected custom config to be kept, got %#v", got)
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func TestConfigCloneIsolatesAPIKeyInjection(t *testing.T) {
|
| 60 |
+
enabled := true
|
| 61 |
+
cfg := Config{APIKeys: []APIKey{{
|
| 62 |
+
Key: "sk-custom",
|
| 63 |
+
ThinkingInjection: &APIKeyInjectionConfig{Custom: true, Enabled: &enabled, Prompt: "p"},
|
| 64 |
+
}}}
|
| 65 |
+
clone := cfg.Clone()
|
| 66 |
+
*clone.APIKeys[0].ThinkingInjection.Enabled = false
|
| 67 |
+
if !enabled {
|
| 68 |
+
t.Fatal("expected clone to own a copy of the injection settings")
|
| 69 |
+
}
|
| 70 |
+
}
|
internal/config/codec.go
CHANGED
|
@@ -51,6 +51,12 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
|
| 51 |
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
| 52 |
m["thinking_injection"] = c.ThinkingInjection
|
| 53 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
|
| 55 |
m["vercel"] = NormalizeVercelConfig(c.Vercel)
|
| 56 |
}
|
|
@@ -138,6 +144,14 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
|
| 138 |
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
|
| 139 |
return fmt.Errorf("invalid field %q: %w", k, err)
|
| 140 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
case "vercel":
|
| 142 |
if err := json.Unmarshal(v, &c.Vercel); err != nil {
|
| 143 |
return fmt.Errorf("invalid field %q: %w", k, err)
|
|
@@ -168,7 +182,7 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
|
| 168 |
func (c Config) Clone() Config {
|
| 169 |
clone := Config{
|
| 170 |
Keys: slices.Clone(c.Keys),
|
| 171 |
-
APIKeys:
|
| 172 |
Accounts: slices.Clone(c.Accounts),
|
| 173 |
Proxies: slices.Clone(c.Proxies),
|
| 174 |
ModelAliases: cloneStringMap(c.ModelAliases),
|
|
@@ -188,6 +202,8 @@ func (c Config) Clone() Config {
|
|
| 188 |
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
| 189 |
Prompt: c.ThinkingInjection.Prompt,
|
| 190 |
},
|
|
|
|
|
|
|
| 191 |
Vercel: c.Vercel,
|
| 192 |
Platform: c.Platform,
|
| 193 |
VercelSyncHash: c.VercelSyncHash,
|
|
@@ -200,6 +216,23 @@ func (c Config) Clone() Config {
|
|
| 200 |
return clone
|
| 201 |
}
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
func cloneStringMap(in map[string]string) map[string]string {
|
| 204 |
if len(in) == 0 {
|
| 205 |
return nil
|
|
@@ -219,6 +252,14 @@ func cloneBoolPtr(in *bool) *bool {
|
|
| 219 |
return &v
|
| 220 |
}
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
func parseConfigString(raw string) (Config, error) {
|
| 223 |
var cfg Config
|
| 224 |
candidates := []string{raw}
|
|
|
|
| 51 |
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
| 52 |
m["thinking_injection"] = c.ThinkingInjection
|
| 53 |
}
|
| 54 |
+
if c.Skills.Enabled != nil || c.Skills.MaxChars != nil || len(c.Skills.Items) > 0 {
|
| 55 |
+
m["skills"] = c.Skills
|
| 56 |
+
}
|
| 57 |
+
if c.Tools.Enabled != nil {
|
| 58 |
+
m["tools"] = c.Tools
|
| 59 |
+
}
|
| 60 |
if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
|
| 61 |
m["vercel"] = NormalizeVercelConfig(c.Vercel)
|
| 62 |
}
|
|
|
|
| 144 |
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
|
| 145 |
return fmt.Errorf("invalid field %q: %w", k, err)
|
| 146 |
}
|
| 147 |
+
case "skills":
|
| 148 |
+
if err := json.Unmarshal(v, &c.Skills); err != nil {
|
| 149 |
+
return fmt.Errorf("invalid field %q: %w", k, err)
|
| 150 |
+
}
|
| 151 |
+
case "tools":
|
| 152 |
+
if err := json.Unmarshal(v, &c.Tools); err != nil {
|
| 153 |
+
return fmt.Errorf("invalid field %q: %w", k, err)
|
| 154 |
+
}
|
| 155 |
case "vercel":
|
| 156 |
if err := json.Unmarshal(v, &c.Vercel); err != nil {
|
| 157 |
return fmt.Errorf("invalid field %q: %w", k, err)
|
|
|
|
| 182 |
func (c Config) Clone() Config {
|
| 183 |
clone := Config{
|
| 184 |
Keys: slices.Clone(c.Keys),
|
| 185 |
+
APIKeys: cloneAPIKeys(c.APIKeys),
|
| 186 |
Accounts: slices.Clone(c.Accounts),
|
| 187 |
Proxies: slices.Clone(c.Proxies),
|
| 188 |
ModelAliases: cloneStringMap(c.ModelAliases),
|
|
|
|
| 202 |
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
| 203 |
Prompt: c.ThinkingInjection.Prompt,
|
| 204 |
},
|
| 205 |
+
Skills: c.Skills.Clone(),
|
| 206 |
+
Tools: c.Tools.Clone(),
|
| 207 |
Vercel: c.Vercel,
|
| 208 |
Platform: c.Platform,
|
| 209 |
VercelSyncHash: c.VercelSyncHash,
|
|
|
|
| 216 |
return clone
|
| 217 |
}
|
| 218 |
|
| 219 |
+
func cloneAPIKeys(in []APIKey) []APIKey {
|
| 220 |
+
if len(in) == 0 {
|
| 221 |
+
return nil
|
| 222 |
+
}
|
| 223 |
+
out := make([]APIKey, len(in))
|
| 224 |
+
for i, item := range in {
|
| 225 |
+
out[i] = APIKey{
|
| 226 |
+
Key: item.Key,
|
| 227 |
+
Name: item.Name,
|
| 228 |
+
Remark: item.Remark,
|
| 229 |
+
ThinkingInjection: item.ThinkingInjection.Clone(),
|
| 230 |
+
Skills: item.Skills.Clone(),
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
return out
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
func cloneStringMap(in map[string]string) map[string]string {
|
| 237 |
if len(in) == 0 {
|
| 238 |
return nil
|
|
|
|
| 252 |
return &v
|
| 253 |
}
|
| 254 |
|
| 255 |
+
func cloneIntPtr(in *int) *int {
|
| 256 |
+
if in == nil {
|
| 257 |
+
return nil
|
| 258 |
+
}
|
| 259 |
+
v := *in
|
| 260 |
+
return &v
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
func parseConfigString(raw string) (Config, error) {
|
| 264 |
var cfg Config
|
| 265 |
candidates := []string{raw}
|
internal/config/config.go
CHANGED
|
@@ -5,6 +5,7 @@ import (
|
|
| 5 |
"encoding/hex"
|
| 6 |
"fmt"
|
| 7 |
"strings"
|
|
|
|
| 8 |
)
|
| 9 |
|
| 10 |
type Config struct {
|
|
@@ -21,6 +22,8 @@ type Config struct {
|
|
| 21 |
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
|
| 22 |
FileAudit FileAuditConfig `json:"file_audit,omitempty"`
|
| 23 |
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
|
|
|
|
|
|
|
| 24 |
Vercel VercelConfig `json:"vercel,omitempty"`
|
| 25 |
Platform PlatformConfig `json:"platform,omitempty"`
|
| 26 |
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
|
@@ -46,6 +49,252 @@ type APIKey struct {
|
|
| 46 |
Key string `json:"key"`
|
| 47 |
Name string `json:"name,omitempty"`
|
| 48 |
Remark string `json:"remark,omitempty"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
type Proxy struct {
|
|
|
|
| 5 |
"encoding/hex"
|
| 6 |
"fmt"
|
| 7 |
"strings"
|
| 8 |
+
"unicode/utf8"
|
| 9 |
)
|
| 10 |
|
| 11 |
type Config struct {
|
|
|
|
| 22 |
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
|
| 23 |
FileAudit FileAuditConfig `json:"file_audit,omitempty"`
|
| 24 |
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
|
| 25 |
+
Skills SkillsConfig `json:"skills,omitempty"`
|
| 26 |
+
Tools ToolsConfig `json:"tools,omitempty"`
|
| 27 |
Vercel VercelConfig `json:"vercel,omitempty"`
|
| 28 |
Platform PlatformConfig `json:"platform,omitempty"`
|
| 29 |
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
|
|
|
| 49 |
Key string `json:"key"`
|
| 50 |
Name string `json:"name,omitempty"`
|
| 51 |
Remark string `json:"remark,omitempty"`
|
| 52 |
+
// ThinkingInjection 是单个 API Key 对全局「思考格式注入」的覆盖设置。
|
| 53 |
+
// 未配置或 Custom=false 时完全跟随全局开关与全局提示词。
|
| 54 |
+
ThinkingInjection *APIKeyInjectionConfig `json:"thinking_injection,omitempty"`
|
| 55 |
+
// Skills 是单个 API Key 对全局「纯文本技能库」的覆盖设置。
|
| 56 |
+
// 未配置或 Custom=false 时使用全部全局技能。
|
| 57 |
+
Skills *APIKeySkillsConfig `json:"skills,omitempty"`
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// SkillsConfig 是「纯文本技能库」的全局配置。
|
| 61 |
+
// 技能正文会被直接拼进 prompt,模型不需要任何工具调用能力。
|
| 62 |
+
type SkillsConfig struct {
|
| 63 |
+
// Enabled 是实验性总开关。未配置(nil)按 false 处理:默认不注入任何技能。
|
| 64 |
+
// 全局为 false 时,任何 API Key 的自定义设置都不会生效。
|
| 65 |
+
Enabled *bool `json:"enabled,omitempty"`
|
| 66 |
+
// MaxChars 是单次注入的字符上限(按 rune 计)。未配置时取
|
| 67 |
+
// DefaultSkillsMaxChars(8000);显式配置 0 或负数表示不限制。
|
| 68 |
+
MaxChars *int `json:"max_chars,omitempty"`
|
| 69 |
+
// Items 是待注入的技能条目,最多 MaxSkillItems 条。
|
| 70 |
+
Items []SkillConfig `json:"items,omitempty"`
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// SkillConfig 是一条纯文本技能。ID 必填且唯一,字符集为 [a-z0-9_-]。
|
| 74 |
+
type SkillConfig struct {
|
| 75 |
+
ID string `json:"id"`
|
| 76 |
+
Name string `json:"name,omitempty"`
|
| 77 |
+
Description string `json:"description,omitempty"`
|
| 78 |
+
Body string `json:"body"`
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// APIKeySkillsConfig 描述单个 API Key 的技能选择。
|
| 82 |
+
// - 未配置或 Custom=false:使用全部全局技能;
|
| 83 |
+
// - Custom=true 且 Enabled=false:该 Key 不注入任何技能;
|
| 84 |
+
// - Custom=true 且 IDs 非空:只注入命中 ID 的技能;
|
| 85 |
+
// - Custom=true 且 IDs 为空:该 Key 不注入任何技能。
|
| 86 |
+
type APIKeySkillsConfig struct {
|
| 87 |
+
Custom bool `json:"custom,omitempty"`
|
| 88 |
+
Enabled *bool `json:"enabled,omitempty"`
|
| 89 |
+
IDs []string `json:"ids,omitempty"`
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ToolsConfig 是「工具调用」的全局开关配置。
|
| 93 |
+
// 未配置(nil)按 false 处理:默认关闭工具调用,避免上游风控封号。
|
| 94 |
+
type ToolsConfig struct {
|
| 95 |
+
Enabled *bool `json:"enabled,omitempty"`
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const (
|
| 99 |
+
// DefaultSkillsMaxChars 是未配置 skills.max_chars 时单次注入的字符上限。
|
| 100 |
+
DefaultSkillsMaxChars = 8000
|
| 101 |
+
// MaxSkillItems 是技能条目的数量上限。
|
| 102 |
+
MaxSkillItems = 50
|
| 103 |
+
// MaxSkillBodyChars 是单条技能正文的字符上限(按 rune 计)。
|
| 104 |
+
MaxSkillBodyChars = 100_000
|
| 105 |
+
// MaxSkillsTotalBodyChars 是全部技能正文的字符上限(按 rune 计)。
|
| 106 |
+
MaxSkillsTotalBodyChars = 500_000
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
// IsEnabled 返回技能库是否启用;未配置按 false 处理。
|
| 110 |
+
func (c SkillsConfig) IsEnabled() bool {
|
| 111 |
+
if c.Enabled == nil {
|
| 112 |
+
return false
|
| 113 |
+
}
|
| 114 |
+
return *c.Enabled
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// EffectiveMaxChars 返回生效的单次注入字符上限。
|
| 118 |
+
// 未配置时使用 DefaultSkillsMaxChars;显式配置 0 或负数表示不限制。
|
| 119 |
+
func (c SkillsConfig) EffectiveMaxChars() int {
|
| 120 |
+
if c.MaxChars == nil {
|
| 121 |
+
return DefaultSkillsMaxChars
|
| 122 |
+
}
|
| 123 |
+
return *c.MaxChars
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Normalize 归一化技能条目:去空白、丢弃空 ID、按 ID 去重。
|
| 127 |
+
// 它不做长度校验,长度限制由 ValidateSkillsConfig 负责。
|
| 128 |
+
func (c SkillsConfig) Normalize() SkillsConfig {
|
| 129 |
+
out := SkillsConfig{
|
| 130 |
+
Enabled: cloneBoolPtr(c.Enabled),
|
| 131 |
+
MaxChars: cloneIntPtr(c.MaxChars),
|
| 132 |
+
}
|
| 133 |
+
if len(c.Items) == 0 {
|
| 134 |
+
return out
|
| 135 |
+
}
|
| 136 |
+
seen := make(map[string]struct{}, len(c.Items))
|
| 137 |
+
items := make([]SkillConfig, 0, len(c.Items))
|
| 138 |
+
for _, item := range c.Items {
|
| 139 |
+
id := strings.TrimSpace(item.ID)
|
| 140 |
+
if id == "" {
|
| 141 |
+
continue
|
| 142 |
+
}
|
| 143 |
+
if _, ok := seen[id]; ok {
|
| 144 |
+
continue
|
| 145 |
+
}
|
| 146 |
+
seen[id] = struct{}{}
|
| 147 |
+
items = append(items, SkillConfig{
|
| 148 |
+
ID: id,
|
| 149 |
+
Name: strings.TrimSpace(item.Name),
|
| 150 |
+
Description: strings.TrimSpace(item.Description),
|
| 151 |
+
Body: item.Body,
|
| 152 |
+
})
|
| 153 |
+
}
|
| 154 |
+
if len(items) > 0 {
|
| 155 |
+
out.Items = items
|
| 156 |
+
}
|
| 157 |
+
return out
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Clone 深拷贝技能库配置。
|
| 161 |
+
func (c SkillsConfig) Clone() SkillsConfig {
|
| 162 |
+
out := SkillsConfig{
|
| 163 |
+
Enabled: cloneBoolPtr(c.Enabled),
|
| 164 |
+
MaxChars: cloneIntPtr(c.MaxChars),
|
| 165 |
+
}
|
| 166 |
+
if len(c.Items) > 0 {
|
| 167 |
+
out.Items = make([]SkillConfig, len(c.Items))
|
| 168 |
+
copy(out.Items, c.Items)
|
| 169 |
+
}
|
| 170 |
+
return out
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// ValidateSkillsConfig 校验技能库配置:
|
| 174 |
+
// 条目数量、ID 必填/唯一/字符集、单条与整体正文字符上限。
|
| 175 |
+
// max_chars 允许为 0 或负数(表示不限制)。
|
| 176 |
+
func ValidateSkillsConfig(c SkillsConfig) error {
|
| 177 |
+
if len(c.Items) > MaxSkillItems {
|
| 178 |
+
return fmt.Errorf("skills.items cannot exceed %d entries", MaxSkillItems)
|
| 179 |
+
}
|
| 180 |
+
seen := make(map[string]struct{}, len(c.Items))
|
| 181 |
+
total := 0
|
| 182 |
+
for i, item := range c.Items {
|
| 183 |
+
id := strings.TrimSpace(item.ID)
|
| 184 |
+
if id == "" {
|
| 185 |
+
return fmt.Errorf("skills.items[%d].id cannot be empty", i)
|
| 186 |
+
}
|
| 187 |
+
if !validSkillID(id) {
|
| 188 |
+
return fmt.Errorf("skills.items[%d].id %q must match [a-z0-9_-]", i, id)
|
| 189 |
+
}
|
| 190 |
+
if _, ok := seen[id]; ok {
|
| 191 |
+
return fmt.Errorf("duplicate skill id: %s", id)
|
| 192 |
+
}
|
| 193 |
+
seen[id] = struct{}{}
|
| 194 |
+
if strings.TrimSpace(item.Body) == "" {
|
| 195 |
+
return fmt.Errorf("skills.items[%d].body cannot be empty", i)
|
| 196 |
+
}
|
| 197 |
+
bodyChars := utf8.RuneCountInString(item.Body)
|
| 198 |
+
if bodyChars > MaxSkillBodyChars {
|
| 199 |
+
return fmt.Errorf("skills.items[%d].body cannot exceed %d characters", i, MaxSkillBodyChars)
|
| 200 |
+
}
|
| 201 |
+
total += bodyChars
|
| 202 |
+
if total > MaxSkillsTotalBodyChars {
|
| 203 |
+
return fmt.Errorf("skills.items total body cannot exceed %d characters", MaxSkillsTotalBodyChars)
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
return nil
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
func validSkillID(id string) bool {
|
| 210 |
+
if id == "" {
|
| 211 |
+
return false
|
| 212 |
+
}
|
| 213 |
+
for _, r := range id {
|
| 214 |
+
switch {
|
| 215 |
+
case r >= 'a' && r <= 'z':
|
| 216 |
+
case r >= '0' && r <= '9':
|
| 217 |
+
case r == '_' || r == '-':
|
| 218 |
+
default:
|
| 219 |
+
return false
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
return true
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Normalize 归一化单个 Key 的技能覆盖设置,去掉空白并按需丢弃空对象。
|
| 226 |
+
func (c *APIKeySkillsConfig) Normalize() *APIKeySkillsConfig {
|
| 227 |
+
if c == nil {
|
| 228 |
+
return nil
|
| 229 |
+
}
|
| 230 |
+
out := APIKeySkillsConfig{
|
| 231 |
+
Custom: c.Custom,
|
| 232 |
+
Enabled: cloneBoolPtr(c.Enabled),
|
| 233 |
+
}
|
| 234 |
+
seen := make(map[string]struct{}, len(c.IDs))
|
| 235 |
+
for _, id := range c.IDs {
|
| 236 |
+
id = strings.TrimSpace(id)
|
| 237 |
+
if id == "" {
|
| 238 |
+
continue
|
| 239 |
+
}
|
| 240 |
+
if _, ok := seen[id]; ok {
|
| 241 |
+
continue
|
| 242 |
+
}
|
| 243 |
+
seen[id] = struct{}{}
|
| 244 |
+
out.IDs = append(out.IDs, id)
|
| 245 |
+
}
|
| 246 |
+
if !out.Custom && out.Enabled == nil && len(out.IDs) == 0 {
|
| 247 |
+
return nil
|
| 248 |
+
}
|
| 249 |
+
return &out
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Clone 深拷贝单个 Key 的技能覆盖设置。
|
| 253 |
+
func (c *APIKeySkillsConfig) Clone() *APIKeySkillsConfig {
|
| 254 |
+
return c.Normalize()
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// IsEnabled 返回工具调用是否启用;未配置(nil)按 false 处理。
|
| 258 |
+
func (t ToolsConfig) IsEnabled() bool {
|
| 259 |
+
if t.Enabled == nil {
|
| 260 |
+
return false
|
| 261 |
+
}
|
| 262 |
+
return *t.Enabled
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Clone 深拷贝工具调用开关。
|
| 266 |
+
func (t ToolsConfig) Clone() ToolsConfig {
|
| 267 |
+
return ToolsConfig{Enabled: cloneBoolPtr(t.Enabled)}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// APIKeyInjectionConfig 描述单个 API Key 的自定义注入提示词。
|
| 271 |
+
type APIKeyInjectionConfig struct {
|
| 272 |
+
// Custom 为 true 时该 Key 启用自定义注入设置;此时 Enabled 为空表示
|
| 273 |
+
// 继续跟随全局开关,Prompt 为空表示继续跟随全局提示词。
|
| 274 |
+
Custom bool `json:"custom,omitempty"`
|
| 275 |
+
Enabled *bool `json:"enabled,omitempty"`
|
| 276 |
+
Prompt string `json:"prompt,omitempty"`
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// Normalize 归一化单条注入设置,去掉空白并按需丢弃空对象。
|
| 280 |
+
func (c *APIKeyInjectionConfig) Normalize() *APIKeyInjectionConfig {
|
| 281 |
+
if c == nil {
|
| 282 |
+
return nil
|
| 283 |
+
}
|
| 284 |
+
out := APIKeyInjectionConfig{
|
| 285 |
+
Custom: c.Custom,
|
| 286 |
+
Enabled: cloneBoolPtr(c.Enabled),
|
| 287 |
+
Prompt: strings.TrimSpace(c.Prompt),
|
| 288 |
+
}
|
| 289 |
+
if !out.Custom && out.Enabled == nil && out.Prompt == "" {
|
| 290 |
+
return nil
|
| 291 |
+
}
|
| 292 |
+
return &out
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
// Clone 深拷贝注入设置。
|
| 296 |
+
func (c *APIKeyInjectionConfig) Clone() *APIKeyInjectionConfig {
|
| 297 |
+
return c.Normalize()
|
| 298 |
}
|
| 299 |
|
| 300 |
type Proxy struct {
|
internal/config/config_edge_test.go
CHANGED
|
@@ -30,9 +30,9 @@ func TestGetModelConfigDeepSeekChatNoThinking(t *testing.T) {
|
|
| 30 |
}
|
| 31 |
|
| 32 |
func TestGetModelConfigDeepSeekReasoner(t *testing.T) {
|
| 33 |
-
thinking, search, ok := GetModelConfig("deepseek-v4-
|
| 34 |
if !ok {
|
| 35 |
-
t.Fatal("expected ok for deepseek-v4-
|
| 36 |
}
|
| 37 |
if !thinking || search {
|
| 38 |
t.Fatalf("expected thinking=true search=false, got thinking=%v search=%v", thinking, search)
|
|
@@ -52,7 +52,7 @@ func TestGetModelConfigDeepSeekChatSearch(t *testing.T) {
|
|
| 52 |
func TestGetModelConfigDeepSeekReasonerSearch(t *testing.T) {
|
| 53 |
thinking, search, ok := GetModelConfig("deepseek-v4-pro-search")
|
| 54 |
if !ok {
|
| 55 |
-
t.Fatal("expected ok for deepseek-v4-pro-search")
|
| 56 |
}
|
| 57 |
if !thinking || !search {
|
| 58 |
t.Fatalf("expected both true, got thinking=%v search=%v", thinking, search)
|
|
@@ -62,27 +62,27 @@ func TestGetModelConfigDeepSeekReasonerSearch(t *testing.T) {
|
|
| 62 |
func TestGetModelConfigDeepSeekExpertChat(t *testing.T) {
|
| 63 |
thinking, search, ok := GetModelConfig("deepseek-v4-pro")
|
| 64 |
if !ok {
|
| 65 |
-
t.Fatal("expected ok for deepseek-v4-pro")
|
| 66 |
}
|
| 67 |
if !thinking || search {
|
| 68 |
-
t.Fatalf("expected thinking=true search=false for deepseek-v4-pro, got thinking=%v search=%v", thinking, search)
|
| 69 |
}
|
| 70 |
}
|
| 71 |
|
| 72 |
func TestGetModelConfigDeepSeekExpertReasonerSearch(t *testing.T) {
|
| 73 |
-
thinking, search, ok := GetModelConfig("deepseek-v4-pro-search")
|
| 74 |
if !ok {
|
| 75 |
-
t.Fatal("expected ok for deepseek-v4-pro-search")
|
| 76 |
}
|
| 77 |
-
if
|
| 78 |
-
t.Fatalf("expected
|
| 79 |
}
|
| 80 |
}
|
| 81 |
|
| 82 |
func TestGetModelConfigDeepSeekVision(t *testing.T) {
|
| 83 |
thinking, search, ok := GetModelConfig("deepseek-v4-vision")
|
| 84 |
if !ok {
|
| 85 |
-
t.Fatal("expected ok for deepseek-v4-vision")
|
| 86 |
}
|
| 87 |
if !thinking || search {
|
| 88 |
t.Fatalf("expected thinking=true search=false, got thinking=%v search=%v", thinking, search)
|
|
@@ -105,13 +105,14 @@ func TestGetModelTypeDefaultExpertAndVision(t *testing.T) {
|
|
| 105 |
if !ok || defaultNoThinkingType != "default" {
|
| 106 |
t.Fatalf("expected default model_type for nothinking, got ok=%v model_type=%q", ok, defaultNoThinkingType)
|
| 107 |
}
|
|
|
|
| 108 |
expertType, ok := GetModelType("deepseek-v4-pro")
|
| 109 |
-
if !ok || expertType != "
|
| 110 |
-
t.Fatalf("expected expert model_type, got ok=%v model_type=%q", ok, expertType)
|
| 111 |
}
|
| 112 |
visionType, ok := GetModelType("deepseek-v4-vision")
|
| 113 |
-
if !ok || visionType != "
|
| 114 |
-
t.Fatalf("expected vision model_type, got ok=%v model_type=%q", ok, visionType)
|
| 115 |
}
|
| 116 |
}
|
| 117 |
|
|
@@ -685,16 +686,18 @@ func TestOpenAIModelsResponse(t *testing.T) {
|
|
| 685 |
expected := map[string]bool{
|
| 686 |
"deepseek-v4-flash": false,
|
| 687 |
"deepseek-v4-flash-nothinking": false,
|
| 688 |
-
"deepseek-v4-pro": false,
|
| 689 |
-
"deepseek-v4-pro-nothinking": false,
|
| 690 |
"deepseek-v4-flash-search": false,
|
| 691 |
"deepseek-v4-flash-search-nothinking": false,
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
"deepseek-v4-
|
| 695 |
-
"deepseek-v4-
|
|
|
|
| 696 |
}
|
| 697 |
for _, model := range data {
|
|
|
|
|
|
|
|
|
|
| 698 |
if _, ok := expected[model.ID]; ok {
|
| 699 |
expected[model.ID] = true
|
| 700 |
}
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
func TestGetModelConfigDeepSeekReasoner(t *testing.T) {
|
| 33 |
+
thinking, search, ok := GetModelConfig("deepseek-v4-flash")
|
| 34 |
if !ok {
|
| 35 |
+
t.Fatal("expected ok for deepseek-v4-flash")
|
| 36 |
}
|
| 37 |
if !thinking || search {
|
| 38 |
t.Fatalf("expected thinking=true search=false, got thinking=%v search=%v", thinking, search)
|
|
|
|
| 52 |
func TestGetModelConfigDeepSeekReasonerSearch(t *testing.T) {
|
| 53 |
thinking, search, ok := GetModelConfig("deepseek-v4-pro-search")
|
| 54 |
if !ok {
|
| 55 |
+
t.Fatal("expected ok for legacy deepseek-v4-pro-search")
|
| 56 |
}
|
| 57 |
if !thinking || !search {
|
| 58 |
t.Fatalf("expected both true, got thinking=%v search=%v", thinking, search)
|
|
|
|
| 62 |
func TestGetModelConfigDeepSeekExpertChat(t *testing.T) {
|
| 63 |
thinking, search, ok := GetModelConfig("deepseek-v4-pro")
|
| 64 |
if !ok {
|
| 65 |
+
t.Fatal("expected ok for legacy deepseek-v4-pro")
|
| 66 |
}
|
| 67 |
if !thinking || search {
|
| 68 |
+
t.Fatalf("expected thinking=true search=false for legacy deepseek-v4-pro, got thinking=%v search=%v", thinking, search)
|
| 69 |
}
|
| 70 |
}
|
| 71 |
|
| 72 |
func TestGetModelConfigDeepSeekExpertReasonerSearch(t *testing.T) {
|
| 73 |
+
thinking, search, ok := GetModelConfig("deepseek-v4-pro-search-nothinking")
|
| 74 |
if !ok {
|
| 75 |
+
t.Fatal("expected ok for legacy deepseek-v4-pro-search-nothinking")
|
| 76 |
}
|
| 77 |
+
if thinking || !search {
|
| 78 |
+
t.Fatalf("expected thinking=false search=true, got thinking=%v search=%v", thinking, search)
|
| 79 |
}
|
| 80 |
}
|
| 81 |
|
| 82 |
func TestGetModelConfigDeepSeekVision(t *testing.T) {
|
| 83 |
thinking, search, ok := GetModelConfig("deepseek-v4-vision")
|
| 84 |
if !ok {
|
| 85 |
+
t.Fatal("expected ok for legacy deepseek-v4-vision")
|
| 86 |
}
|
| 87 |
if !thinking || search {
|
| 88 |
t.Fatalf("expected thinking=true search=false, got thinking=%v search=%v", thinking, search)
|
|
|
|
| 105 |
if !ok || defaultNoThinkingType != "default" {
|
| 106 |
t.Fatalf("expected default model_type for nothinking, got ok=%v model_type=%q", ok, defaultNoThinkingType)
|
| 107 |
}
|
| 108 |
+
// pro / vision 系列已合并进 flash,旧名字统一归一为 default。
|
| 109 |
expertType, ok := GetModelType("deepseek-v4-pro")
|
| 110 |
+
if !ok || expertType != "default" {
|
| 111 |
+
t.Fatalf("expected legacy expert model_type default, got ok=%v model_type=%q", ok, expertType)
|
| 112 |
}
|
| 113 |
visionType, ok := GetModelType("deepseek-v4-vision")
|
| 114 |
+
if !ok || visionType != "default" {
|
| 115 |
+
t.Fatalf("expected legacy vision model_type default, got ok=%v model_type=%q", ok, visionType)
|
| 116 |
}
|
| 117 |
}
|
| 118 |
|
|
|
|
| 686 |
expected := map[string]bool{
|
| 687 |
"deepseek-v4-flash": false,
|
| 688 |
"deepseek-v4-flash-nothinking": false,
|
|
|
|
|
|
|
| 689 |
"deepseek-v4-flash-search": false,
|
| 690 |
"deepseek-v4-flash-search-nothinking": false,
|
| 691 |
+
}
|
| 692 |
+
removed := map[string]struct{}{
|
| 693 |
+
"deepseek-v4-pro": {},
|
| 694 |
+
"deepseek-v4-pro-search": {},
|
| 695 |
+
"deepseek-v4-vision": {},
|
| 696 |
}
|
| 697 |
for _, model := range data {
|
| 698 |
+
if _, ok := removed[model.ID]; ok {
|
| 699 |
+
t.Fatalf("expected removed model %s to be absent from model list", model.ID)
|
| 700 |
+
}
|
| 701 |
if _, ok := expected[model.ID]; ok {
|
| 702 |
expected[model.ID] = true
|
| 703 |
}
|
internal/config/credentials.go
CHANGED
|
@@ -64,9 +64,11 @@ func normalizeAPIKeys(items []APIKey) []APIKey {
|
|
| 64 |
}
|
| 65 |
seen[key] = struct{}{}
|
| 66 |
out = append(out, APIKey{
|
| 67 |
-
Key:
|
| 68 |
-
Name:
|
| 69 |
-
Remark:
|
|
|
|
|
|
|
| 70 |
})
|
| 71 |
}
|
| 72 |
if len(out) == 0 {
|
|
@@ -92,9 +94,11 @@ func apiKeysFromStrings(keys []string, meta map[string]APIKey) []APIKey {
|
|
| 92 |
seen[key] = struct{}{}
|
| 93 |
if item, ok := meta[key]; ok {
|
| 94 |
out = append(out, APIKey{
|
| 95 |
-
Key:
|
| 96 |
-
Name:
|
| 97 |
-
Remark:
|
|
|
|
|
|
|
| 98 |
})
|
| 99 |
continue
|
| 100 |
}
|
|
@@ -138,9 +142,11 @@ func apiKeyMap(items []APIKey) map[string]APIKey {
|
|
| 138 |
continue
|
| 139 |
}
|
| 140 |
out[key] = APIKey{
|
| 141 |
-
Key:
|
| 142 |
-
Name:
|
| 143 |
-
Remark:
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
}
|
| 146 |
return out
|
|
@@ -153,6 +159,44 @@ func equalAPIKeys(a, b []APIKey) bool {
|
|
| 153 |
return slices.EqualFunc(a, b, func(x, y APIKey) bool {
|
| 154 |
return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) &&
|
| 155 |
strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) &&
|
| 156 |
-
strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark)
|
|
|
|
|
|
|
| 157 |
})
|
| 158 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
seen[key] = struct{}{}
|
| 66 |
out = append(out, APIKey{
|
| 67 |
+
Key: key,
|
| 68 |
+
Name: strings.TrimSpace(item.Name),
|
| 69 |
+
Remark: strings.TrimSpace(item.Remark),
|
| 70 |
+
ThinkingInjection: item.ThinkingInjection.Normalize(),
|
| 71 |
+
Skills: item.Skills.Normalize(),
|
| 72 |
})
|
| 73 |
}
|
| 74 |
if len(out) == 0 {
|
|
|
|
| 94 |
seen[key] = struct{}{}
|
| 95 |
if item, ok := meta[key]; ok {
|
| 96 |
out = append(out, APIKey{
|
| 97 |
+
Key: key,
|
| 98 |
+
Name: strings.TrimSpace(item.Name),
|
| 99 |
+
Remark: strings.TrimSpace(item.Remark),
|
| 100 |
+
ThinkingInjection: item.ThinkingInjection.Normalize(),
|
| 101 |
+
Skills: item.Skills.Normalize(),
|
| 102 |
})
|
| 103 |
continue
|
| 104 |
}
|
|
|
|
| 142 |
continue
|
| 143 |
}
|
| 144 |
out[key] = APIKey{
|
| 145 |
+
Key: key,
|
| 146 |
+
Name: strings.TrimSpace(item.Name),
|
| 147 |
+
Remark: strings.TrimSpace(item.Remark),
|
| 148 |
+
ThinkingInjection: item.ThinkingInjection.Normalize(),
|
| 149 |
+
Skills: item.Skills.Normalize(),
|
| 150 |
}
|
| 151 |
}
|
| 152 |
return out
|
|
|
|
| 159 |
return slices.EqualFunc(a, b, func(x, y APIKey) bool {
|
| 160 |
return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) &&
|
| 161 |
strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) &&
|
| 162 |
+
strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark) &&
|
| 163 |
+
equalAPIKeyInjection(x.ThinkingInjection, y.ThinkingInjection) &&
|
| 164 |
+
equalAPIKeySkills(x.Skills, y.Skills)
|
| 165 |
})
|
| 166 |
}
|
| 167 |
+
|
| 168 |
+
func equalAPIKeySkills(a, b *APIKeySkillsConfig) bool {
|
| 169 |
+
left := a.Normalize()
|
| 170 |
+
right := b.Normalize()
|
| 171 |
+
if left == nil || right == nil {
|
| 172 |
+
return left == nil && right == nil
|
| 173 |
+
}
|
| 174 |
+
if left.Custom != right.Custom || !slices.Equal(left.IDs, right.IDs) {
|
| 175 |
+
return false
|
| 176 |
+
}
|
| 177 |
+
if (left.Enabled == nil) != (right.Enabled == nil) {
|
| 178 |
+
return false
|
| 179 |
+
}
|
| 180 |
+
if left.Enabled != nil && *left.Enabled != *right.Enabled {
|
| 181 |
+
return false
|
| 182 |
+
}
|
| 183 |
+
return true
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
func equalAPIKeyInjection(a, b *APIKeyInjectionConfig) bool {
|
| 187 |
+
left := a.Normalize()
|
| 188 |
+
right := b.Normalize()
|
| 189 |
+
if left == nil || right == nil {
|
| 190 |
+
return left == nil && right == nil
|
| 191 |
+
}
|
| 192 |
+
if left.Custom != right.Custom || left.Prompt != right.Prompt {
|
| 193 |
+
return false
|
| 194 |
+
}
|
| 195 |
+
if (left.Enabled == nil) != (right.Enabled == nil) {
|
| 196 |
+
return false
|
| 197 |
+
}
|
| 198 |
+
if left.Enabled != nil && *left.Enabled != *right.Enabled {
|
| 199 |
+
return false
|
| 200 |
+
}
|
| 201 |
+
return true
|
| 202 |
+
}
|
internal/config/model_alias_test.go
CHANGED
|
@@ -55,15 +55,15 @@ func TestResolveExpandedHistoricalAliases(t *testing.T) {
|
|
| 55 |
want string
|
| 56 |
}{
|
| 57 |
{name: "openai old chatgpt", model: "chatgpt-4o", want: "deepseek-v4-flash"},
|
| 58 |
-
{name: "openai codex max", model: "gpt-5.1-codex-max", want: "deepseek-v4-
|
| 59 |
-
{name: "openai deep research", model: "o3-deep-research", want: "deepseek-v4-
|
| 60 |
-
{name: "openai historical reasoning", model: "o1-preview", want: "deepseek-v4-
|
| 61 |
{name: "claude latest historical", model: "claude-3-5-sonnet-latest", want: "deepseek-v4-flash"},
|
| 62 |
-
{name: "claude historical opus", model: "claude-3-opus-20240229", want: "deepseek-v4-
|
| 63 |
{name: "claude historical haiku", model: "claude-3-haiku-20240307", want: "deepseek-v4-flash"},
|
| 64 |
{name: "gemini latest alias", model: "gemini-flash-latest", want: "deepseek-v4-flash"},
|
| 65 |
-
{name: "gemini historical pro", model: "gemini-1.5-pro", want: "deepseek-v4-
|
| 66 |
-
{name: "gemini vision legacy", model: "gemini-pro-vision", want: "deepseek-v4-
|
| 67 |
}
|
| 68 |
for _, tc := range cases {
|
| 69 |
t.Run(tc.name, func(t *testing.T) {
|
|
@@ -121,8 +121,12 @@ func TestResolveModelRejectsRetiredHistoricalModels(t *testing.T) {
|
|
| 121 |
|
| 122 |
func TestResolveModelDirectDeepSeekExpert(t *testing.T) {
|
| 123 |
got, ok := ResolveModel(nil, "deepseek-v4-pro")
|
| 124 |
-
if !ok || got != "deepseek-v4-
|
| 125 |
-
t.Fatalf("expected deepseek-v4-pro, got ok=%v model=%q", ok, got)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
}
|
| 127 |
}
|
| 128 |
|
|
@@ -130,8 +134,8 @@ func TestResolveModelCustomAliasToExpert(t *testing.T) {
|
|
| 130 |
got, ok := ResolveModel(mockModelAliasReader{
|
| 131 |
"my-expert-model": "deepseek-v4-pro-search",
|
| 132 |
}, "my-expert-model")
|
| 133 |
-
if !ok || got != "deepseek-v4-
|
| 134 |
-
t.Fatalf("expected alias -> deepseek-v4-
|
| 135 |
}
|
| 136 |
}
|
| 137 |
|
|
@@ -139,8 +143,8 @@ func TestResolveModelCustomAliasToVision(t *testing.T) {
|
|
| 139 |
got, ok := ResolveModel(mockModelAliasReader{
|
| 140 |
"my-vision-model": "deepseek-v4-vision",
|
| 141 |
}, "my-vision-model")
|
| 142 |
-
if !ok || got != "deepseek-v4-
|
| 143 |
-
t.Fatalf("expected alias -> deepseek-v4-
|
| 144 |
}
|
| 145 |
}
|
| 146 |
|
|
|
|
| 55 |
want string
|
| 56 |
}{
|
| 57 |
{name: "openai old chatgpt", model: "chatgpt-4o", want: "deepseek-v4-flash"},
|
| 58 |
+
{name: "openai codex max", model: "gpt-5.1-codex-max", want: "deepseek-v4-flash"},
|
| 59 |
+
{name: "openai deep research", model: "o3-deep-research", want: "deepseek-v4-flash-search"},
|
| 60 |
+
{name: "openai historical reasoning", model: "o1-preview", want: "deepseek-v4-flash"},
|
| 61 |
{name: "claude latest historical", model: "claude-3-5-sonnet-latest", want: "deepseek-v4-flash"},
|
| 62 |
+
{name: "claude historical opus", model: "claude-3-opus-20240229", want: "deepseek-v4-flash"},
|
| 63 |
{name: "claude historical haiku", model: "claude-3-haiku-20240307", want: "deepseek-v4-flash"},
|
| 64 |
{name: "gemini latest alias", model: "gemini-flash-latest", want: "deepseek-v4-flash"},
|
| 65 |
+
{name: "gemini historical pro", model: "gemini-1.5-pro", want: "deepseek-v4-flash"},
|
| 66 |
+
{name: "gemini vision legacy", model: "gemini-pro-vision", want: "deepseek-v4-flash"},
|
| 67 |
}
|
| 68 |
for _, tc := range cases {
|
| 69 |
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
| 121 |
|
| 122 |
func TestResolveModelDirectDeepSeekExpert(t *testing.T) {
|
| 123 |
got, ok := ResolveModel(nil, "deepseek-v4-pro")
|
| 124 |
+
if !ok || got != "deepseek-v4-flash" {
|
| 125 |
+
t.Fatalf("expected legacy deepseek-v4-pro -> deepseek-v4-flash, got ok=%v model=%q", ok, got)
|
| 126 |
+
}
|
| 127 |
+
notThinking, ok := ResolveModel(nil, "deepseek-v4-pro-nothinking")
|
| 128 |
+
if !ok || notThinking != "deepseek-v4-flash-nothinking" {
|
| 129 |
+
t.Fatalf("expected legacy deepseek-v4-pro-nothinking -> deepseek-v4-flash-nothinking, got ok=%v model=%q", ok, notThinking)
|
| 130 |
}
|
| 131 |
}
|
| 132 |
|
|
|
|
| 134 |
got, ok := ResolveModel(mockModelAliasReader{
|
| 135 |
"my-expert-model": "deepseek-v4-pro-search",
|
| 136 |
}, "my-expert-model")
|
| 137 |
+
if !ok || got != "deepseek-v4-flash-search" {
|
| 138 |
+
t.Fatalf("expected alias -> deepseek-v4-flash-search, got ok=%v model=%q", ok, got)
|
| 139 |
}
|
| 140 |
}
|
| 141 |
|
|
|
|
| 143 |
got, ok := ResolveModel(mockModelAliasReader{
|
| 144 |
"my-vision-model": "deepseek-v4-vision",
|
| 145 |
}, "my-vision-model")
|
| 146 |
+
if !ok || got != "deepseek-v4-flash" {
|
| 147 |
+
t.Fatalf("expected alias -> deepseek-v4-flash, got ok=%v model=%q", ok, got)
|
| 148 |
}
|
| 149 |
}
|
| 150 |
|
internal/config/models.go
CHANGED
|
@@ -29,25 +29,21 @@ type ModelAliasReader interface {
|
|
| 29 |
|
| 30 |
const noThinkingModelSuffix = "-nothinking"
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
var deepSeekBaseModels = []ModelInfo{
|
| 33 |
{ID: "deepseek-v4-flash", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
| 34 |
-
{ID: "deepseek-v4-pro", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
| 35 |
{ID: "deepseek-v4-flash-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
| 36 |
-
{ID: "deepseek-v4-pro-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
| 37 |
-
{ID: "deepseek-v4-vision", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
| 38 |
}
|
| 39 |
|
| 40 |
var OllamaCapabilitiesModels = []OllamaCapabilitiesModelInfo{
|
| 41 |
{ID: "deepseek-v4-flash", Capabilities: []string{"tools", "thinking"}},
|
| 42 |
-
{ID: "deepseek-v4-pro", Capabilities: []string{"tools", "thinking"}},
|
| 43 |
{ID: "deepseek-v4-flash-search", Capabilities: []string{"tools", "thinking"}},
|
| 44 |
-
{ID: "deepseek-v4-pro-search", Capabilities: []string{"tools", "thinking"}},
|
| 45 |
-
{ID: "deepseek-v4-vision", Capabilities: []string{"tools", "thinking", "vision"}},
|
| 46 |
{ID: "deepseek-v4-flash-nothinking", Capabilities: []string{"tools"}},
|
| 47 |
-
{ID: "deepseek-v4-pro-nothinking", Capabilities: []string{"tools"}},
|
| 48 |
{ID: "deepseek-v4-flash-search-nothinking", Capabilities: []string{"tools"}},
|
| 49 |
-
{ID: "deepseek-v4-pro-search-nothinking", Capabilities: []string{"tools"}},
|
| 50 |
-
{ID: "deepseek-v4-vision-nothinking", Capabilities: []string{"tools", "vision"}},
|
| 51 |
}
|
| 52 |
|
| 53 |
var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels)
|
|
@@ -84,30 +80,59 @@ var claudeBaseModels = []ModelInfo{
|
|
| 84 |
|
| 85 |
var ClaudeModels = appendNoThinkingVariants(claudeBaseModels)
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
func GetModelConfig(model string) (thinking bool, search bool, ok bool) {
|
| 88 |
baseModel, noThinking := splitNoThinkingModel(model)
|
| 89 |
if baseModel == "" {
|
| 90 |
return false, false, false
|
| 91 |
}
|
|
|
|
|
|
|
|
|
|
| 92 |
switch baseModel {
|
| 93 |
-
case "deepseek-v4-flash"
|
| 94 |
return !noThinking, false, true
|
| 95 |
-
case "deepseek-v4-flash-search"
|
| 96 |
return !noThinking, true, true
|
| 97 |
default:
|
| 98 |
return false, false, false
|
| 99 |
}
|
| 100 |
}
|
| 101 |
|
|
|
|
| 102 |
func GetModelType(model string) (modelType string, ok bool) {
|
| 103 |
baseModel, _ := splitNoThinkingModel(model)
|
|
|
|
|
|
|
|
|
|
| 104 |
switch baseModel {
|
| 105 |
case "deepseek-v4-flash", "deepseek-v4-flash-search":
|
| 106 |
return "default", true
|
| 107 |
-
|
| 108 |
-
return "
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
default:
|
| 112 |
return "", false
|
| 113 |
}
|
|
@@ -149,36 +174,36 @@ func DefaultModelAliases() map[string]string {
|
|
| 149 |
"gpt-5-nano": "deepseek-v4-flash",
|
| 150 |
"gpt-5.4-mini": "deepseek-v4-flash",
|
| 151 |
"gpt-5.4-nano": "deepseek-v4-flash",
|
| 152 |
-
"gpt-5-pro": "deepseek-v4-
|
| 153 |
-
"gpt-5.2-pro": "deepseek-v4-
|
| 154 |
-
"gpt-5.4-pro": "deepseek-v4-
|
| 155 |
-
"gpt-5.5-pro": "deepseek-v4-
|
| 156 |
-
"gpt-5-codex": "deepseek-v4-
|
| 157 |
-
"gpt-5.1-codex": "deepseek-v4-
|
| 158 |
-
"gpt-5.1-codex-mini": "deepseek-v4-
|
| 159 |
-
"gpt-5.1-codex-max": "deepseek-v4-
|
| 160 |
-
"gpt-5.2-codex": "deepseek-v4-
|
| 161 |
-
"gpt-5.3-codex": "deepseek-v4-
|
| 162 |
-
"codex-mini-latest": "deepseek-v4-
|
| 163 |
|
| 164 |
// OpenAI reasoning / research families
|
| 165 |
-
"o1": "deepseek-v4-
|
| 166 |
-
"o1-preview": "deepseek-v4-
|
| 167 |
-
"o1-mini": "deepseek-v4-
|
| 168 |
-
"o1-pro": "deepseek-v4-
|
| 169 |
-
"o3": "deepseek-v4-
|
| 170 |
-
"o3-mini": "deepseek-v4-
|
| 171 |
-
"o3-pro": "deepseek-v4-
|
| 172 |
-
"o3-deep-research": "deepseek-v4-
|
| 173 |
-
"o4-mini": "deepseek-v4-
|
| 174 |
-
"o4-mini-deep-research": "deepseek-v4-
|
| 175 |
|
| 176 |
// Claude current and historical aliases
|
| 177 |
-
"claude-opus-4-6": "deepseek-v4-
|
| 178 |
-
"claude-opus-4-1": "deepseek-v4-
|
| 179 |
-
"claude-opus-4-1-20250805": "deepseek-v4-
|
| 180 |
-
"claude-opus-4-0": "deepseek-v4-
|
| 181 |
-
"claude-opus-4-20250514": "deepseek-v4-
|
| 182 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 183 |
"claude-sonnet-4-5": "deepseek-v4-flash",
|
| 184 |
"claude-sonnet-4-5-20250929": "deepseek-v4-flash",
|
|
@@ -196,28 +221,28 @@ func DefaultModelAliases() map[string]string {
|
|
| 196 |
"claude-3-5-haiku": "deepseek-v4-flash",
|
| 197 |
"claude-3-5-haiku-latest": "deepseek-v4-flash",
|
| 198 |
"claude-3-5-haiku-20241022": "deepseek-v4-flash",
|
| 199 |
-
"claude-3-opus": "deepseek-v4-
|
| 200 |
-
"claude-3-opus-20240229": "deepseek-v4-
|
| 201 |
"claude-3-sonnet": "deepseek-v4-flash",
|
| 202 |
"claude-3-sonnet-20240229": "deepseek-v4-flash",
|
| 203 |
"claude-3-haiku": "deepseek-v4-flash",
|
| 204 |
"claude-3-haiku-20240307": "deepseek-v4-flash",
|
| 205 |
|
| 206 |
// Gemini current and historical text / multimodal models
|
| 207 |
-
"gemini-pro": "deepseek-v4-
|
| 208 |
-
"gemini-pro-vision": "deepseek-v4-
|
| 209 |
-
"gemini-pro-latest": "deepseek-v4-
|
| 210 |
"gemini-flash-latest": "deepseek-v4-flash",
|
| 211 |
-
"gemini-1.5-pro": "deepseek-v4-
|
| 212 |
"gemini-1.5-flash": "deepseek-v4-flash",
|
| 213 |
"gemini-1.5-flash-8b": "deepseek-v4-flash",
|
| 214 |
"gemini-2.0-flash": "deepseek-v4-flash",
|
| 215 |
"gemini-2.0-flash-lite": "deepseek-v4-flash",
|
| 216 |
-
"gemini-2.5-pro": "deepseek-v4-
|
| 217 |
"gemini-2.5-flash": "deepseek-v4-flash",
|
| 218 |
"gemini-2.5-flash-lite": "deepseek-v4-flash",
|
| 219 |
-
"gemini-3.1-pro": "deepseek-v4-
|
| 220 |
-
"gemini-3-pro": "deepseek-v4-
|
| 221 |
"gemini-3-flash": "deepseek-v4-flash",
|
| 222 |
"gemini-3.1-flash": "deepseek-v4-flash",
|
| 223 |
"gemini-3.1-flash-lite": "deepseek-v4-flash",
|
|
@@ -232,16 +257,20 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) {
|
|
| 232 |
if model == "" {
|
| 233 |
return "", false
|
| 234 |
}
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
return model, true
|
| 238 |
}
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
baseModel, noThinking := splitNoThinkingModel(model)
|
| 243 |
-
if mapped, ok := aliases[baseModel]; ok
|
| 244 |
-
|
|
|
|
|
|
|
| 245 |
}
|
| 246 |
return "", false
|
| 247 |
}
|
|
|
|
| 29 |
|
| 30 |
const noThinkingModelSuffix = "-nothinking"
|
| 31 |
|
| 32 |
+
// 上游已把 v4-pro / v4-vision 合并进 v4-flash,对外只保留 flash 系列:
|
| 33 |
+
// - deepseek-v4-flash 默认思考
|
| 34 |
+
// - deepseek-v4-flash-search 默认思考 + 原生联网搜索
|
| 35 |
+
// - deepseek-v4-flash-nothinking 强制关闭思考
|
| 36 |
+
// - deepseek-v4-flash-search-nothinking 强制关闭思考 + 联网搜索
|
| 37 |
var deepSeekBaseModels = []ModelInfo{
|
| 38 |
{ID: "deepseek-v4-flash", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
|
|
|
| 39 |
{ID: "deepseek-v4-flash-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
var OllamaCapabilitiesModels = []OllamaCapabilitiesModelInfo{
|
| 43 |
{ID: "deepseek-v4-flash", Capabilities: []string{"tools", "thinking"}},
|
|
|
|
| 44 |
{ID: "deepseek-v4-flash-search", Capabilities: []string{"tools", "thinking"}},
|
|
|
|
|
|
|
| 45 |
{ID: "deepseek-v4-flash-nothinking", Capabilities: []string{"tools"}},
|
|
|
|
| 46 |
{ID: "deepseek-v4-flash-search-nothinking", Capabilities: []string{"tools"}},
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels)
|
|
|
|
| 80 |
|
| 81 |
var ClaudeModels = appendNoThinkingVariants(claudeBaseModels)
|
| 82 |
|
| 83 |
+
// deprecatedModelAliases 让老客户端保存过的 pro / vision 模型名依旧能用,
|
| 84 |
+
// 但它们不会再出现在 /v1/models 列表里。
|
| 85 |
+
var deprecatedModelAliases = map[string]string{
|
| 86 |
+
"deepseek-v4-pro": "deepseek-v4-flash",
|
| 87 |
+
"deepseek-v4-pro-search": "deepseek-v4-flash-search",
|
| 88 |
+
"deepseek-v4-vision": "deepseek-v4-flash",
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
func GetModelConfig(model string) (thinking bool, search bool, ok bool) {
|
| 92 |
baseModel, noThinking := splitNoThinkingModel(model)
|
| 93 |
if baseModel == "" {
|
| 94 |
return false, false, false
|
| 95 |
}
|
| 96 |
+
if mapped, hit := deprecatedModelAliases[baseModel]; hit {
|
| 97 |
+
baseModel = mapped
|
| 98 |
+
}
|
| 99 |
switch baseModel {
|
| 100 |
+
case "deepseek-v4-flash":
|
| 101 |
return !noThinking, false, true
|
| 102 |
+
case "deepseek-v4-flash-search":
|
| 103 |
return !noThinking, true, true
|
| 104 |
default:
|
| 105 |
return false, false, false
|
| 106 |
}
|
| 107 |
}
|
| 108 |
|
| 109 |
+
// GetModelType 返回上游 model_type。v4 系列合并后只剩 default 一种。
|
| 110 |
func GetModelType(model string) (modelType string, ok bool) {
|
| 111 |
baseModel, _ := splitNoThinkingModel(model)
|
| 112 |
+
if mapped, hit := deprecatedModelAliases[baseModel]; hit {
|
| 113 |
+
baseModel = mapped
|
| 114 |
+
}
|
| 115 |
switch baseModel {
|
| 116 |
case "deepseek-v4-flash", "deepseek-v4-flash-search":
|
| 117 |
return "default", true
|
| 118 |
+
default:
|
| 119 |
+
return "", false
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// resolveSupportedModel 解析单个模型名(不做别名表查询),把已下线的
|
| 124 |
+
// pro / vision 名字归一到 flash 系列,并保留 -nothinking 变体。
|
| 125 |
+
func resolveSupportedModel(model string) (string, bool) {
|
| 126 |
+
baseModel, noThinking := splitNoThinkingModel(model)
|
| 127 |
+
if baseModel == "" {
|
| 128 |
+
return "", false
|
| 129 |
+
}
|
| 130 |
+
if mapped, hit := deprecatedModelAliases[baseModel]; hit {
|
| 131 |
+
baseModel = mapped
|
| 132 |
+
}
|
| 133 |
+
switch baseModel {
|
| 134 |
+
case "deepseek-v4-flash", "deepseek-v4-flash-search":
|
| 135 |
+
return withNoThinkingVariant(baseModel, noThinking), true
|
| 136 |
default:
|
| 137 |
return "", false
|
| 138 |
}
|
|
|
|
| 174 |
"gpt-5-nano": "deepseek-v4-flash",
|
| 175 |
"gpt-5.4-mini": "deepseek-v4-flash",
|
| 176 |
"gpt-5.4-nano": "deepseek-v4-flash",
|
| 177 |
+
"gpt-5-pro": "deepseek-v4-flash",
|
| 178 |
+
"gpt-5.2-pro": "deepseek-v4-flash",
|
| 179 |
+
"gpt-5.4-pro": "deepseek-v4-flash",
|
| 180 |
+
"gpt-5.5-pro": "deepseek-v4-flash",
|
| 181 |
+
"gpt-5-codex": "deepseek-v4-flash",
|
| 182 |
+
"gpt-5.1-codex": "deepseek-v4-flash",
|
| 183 |
+
"gpt-5.1-codex-mini": "deepseek-v4-flash",
|
| 184 |
+
"gpt-5.1-codex-max": "deepseek-v4-flash",
|
| 185 |
+
"gpt-5.2-codex": "deepseek-v4-flash",
|
| 186 |
+
"gpt-5.3-codex": "deepseek-v4-flash",
|
| 187 |
+
"codex-mini-latest": "deepseek-v4-flash",
|
| 188 |
|
| 189 |
// OpenAI reasoning / research families
|
| 190 |
+
"o1": "deepseek-v4-flash",
|
| 191 |
+
"o1-preview": "deepseek-v4-flash",
|
| 192 |
+
"o1-mini": "deepseek-v4-flash",
|
| 193 |
+
"o1-pro": "deepseek-v4-flash",
|
| 194 |
+
"o3": "deepseek-v4-flash",
|
| 195 |
+
"o3-mini": "deepseek-v4-flash",
|
| 196 |
+
"o3-pro": "deepseek-v4-flash",
|
| 197 |
+
"o3-deep-research": "deepseek-v4-flash-search",
|
| 198 |
+
"o4-mini": "deepseek-v4-flash",
|
| 199 |
+
"o4-mini-deep-research": "deepseek-v4-flash-search",
|
| 200 |
|
| 201 |
// Claude current and historical aliases
|
| 202 |
+
"claude-opus-4-6": "deepseek-v4-flash",
|
| 203 |
+
"claude-opus-4-1": "deepseek-v4-flash",
|
| 204 |
+
"claude-opus-4-1-20250805": "deepseek-v4-flash",
|
| 205 |
+
"claude-opus-4-0": "deepseek-v4-flash",
|
| 206 |
+
"claude-opus-4-20250514": "deepseek-v4-flash",
|
| 207 |
"claude-sonnet-4-6": "deepseek-v4-flash",
|
| 208 |
"claude-sonnet-4-5": "deepseek-v4-flash",
|
| 209 |
"claude-sonnet-4-5-20250929": "deepseek-v4-flash",
|
|
|
|
| 221 |
"claude-3-5-haiku": "deepseek-v4-flash",
|
| 222 |
"claude-3-5-haiku-latest": "deepseek-v4-flash",
|
| 223 |
"claude-3-5-haiku-20241022": "deepseek-v4-flash",
|
| 224 |
+
"claude-3-opus": "deepseek-v4-flash",
|
| 225 |
+
"claude-3-opus-20240229": "deepseek-v4-flash",
|
| 226 |
"claude-3-sonnet": "deepseek-v4-flash",
|
| 227 |
"claude-3-sonnet-20240229": "deepseek-v4-flash",
|
| 228 |
"claude-3-haiku": "deepseek-v4-flash",
|
| 229 |
"claude-3-haiku-20240307": "deepseek-v4-flash",
|
| 230 |
|
| 231 |
// Gemini current and historical text / multimodal models
|
| 232 |
+
"gemini-pro": "deepseek-v4-flash",
|
| 233 |
+
"gemini-pro-vision": "deepseek-v4-flash",
|
| 234 |
+
"gemini-pro-latest": "deepseek-v4-flash",
|
| 235 |
"gemini-flash-latest": "deepseek-v4-flash",
|
| 236 |
+
"gemini-1.5-pro": "deepseek-v4-flash",
|
| 237 |
"gemini-1.5-flash": "deepseek-v4-flash",
|
| 238 |
"gemini-1.5-flash-8b": "deepseek-v4-flash",
|
| 239 |
"gemini-2.0-flash": "deepseek-v4-flash",
|
| 240 |
"gemini-2.0-flash-lite": "deepseek-v4-flash",
|
| 241 |
+
"gemini-2.5-pro": "deepseek-v4-flash",
|
| 242 |
"gemini-2.5-flash": "deepseek-v4-flash",
|
| 243 |
"gemini-2.5-flash-lite": "deepseek-v4-flash",
|
| 244 |
+
"gemini-3.1-pro": "deepseek-v4-flash",
|
| 245 |
+
"gemini-3-pro": "deepseek-v4-flash",
|
| 246 |
"gemini-3-flash": "deepseek-v4-flash",
|
| 247 |
"gemini-3.1-flash": "deepseek-v4-flash",
|
| 248 |
"gemini-3.1-flash-lite": "deepseek-v4-flash",
|
|
|
|
| 257 |
if model == "" {
|
| 258 |
return "", false
|
| 259 |
}
|
| 260 |
+
if resolved, ok := resolveSupportedModel(model); ok {
|
| 261 |
+
return resolved, true
|
|
|
|
| 262 |
}
|
| 263 |
+
aliases := loadModelAliases(store)
|
| 264 |
+
if mapped, ok := aliases[model]; ok {
|
| 265 |
+
if resolved, ok := resolveSupportedModel(mapped); ok {
|
| 266 |
+
return resolved, true
|
| 267 |
+
}
|
| 268 |
}
|
| 269 |
baseModel, noThinking := splitNoThinkingModel(model)
|
| 270 |
+
if mapped, ok := aliases[baseModel]; ok {
|
| 271 |
+
if resolved, ok := resolveSupportedModel(withNoThinkingVariant(mapped, noThinking)); ok {
|
| 272 |
+
return resolved, true
|
| 273 |
+
}
|
| 274 |
}
|
| 275 |
return "", false
|
| 276 |
}
|
internal/config/skills_test.go
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"strings"
|
| 6 |
+
"testing"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestSkillsConfigJSONRoundTrip(t *testing.T) {
|
| 10 |
+
raw := `{
|
| 11 |
+
"keys":["k1"],
|
| 12 |
+
"skills":{
|
| 13 |
+
"enabled":true,
|
| 14 |
+
"max_chars":120,
|
| 15 |
+
"items":[{"id":"sql-report","name":"SQL 报表","description":"按固定口径写 SQL","body":"# 说明\n先对齐口径"}]
|
| 16 |
+
},
|
| 17 |
+
"tools":{"enabled":true}
|
| 18 |
+
}`
|
| 19 |
+
var cfg Config
|
| 20 |
+
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
| 21 |
+
t.Fatalf("unmarshal config: %v", err)
|
| 22 |
+
}
|
| 23 |
+
if !cfg.Skills.IsEnabled() {
|
| 24 |
+
t.Fatal("expected skills enabled from config")
|
| 25 |
+
}
|
| 26 |
+
if got := cfg.Skills.EffectiveMaxChars(); got != 120 {
|
| 27 |
+
t.Fatalf("max_chars=%d want=120", got)
|
| 28 |
+
}
|
| 29 |
+
if len(cfg.Skills.Items) != 1 {
|
| 30 |
+
t.Fatalf("expected one skill item, got %#v", cfg.Skills.Items)
|
| 31 |
+
}
|
| 32 |
+
item := cfg.Skills.Items[0]
|
| 33 |
+
if item.ID != "sql-report" || item.Name != "SQL 报表" || item.Description != "按固定口径写 SQL" {
|
| 34 |
+
t.Fatalf("unexpected skill metadata: %#v", item)
|
| 35 |
+
}
|
| 36 |
+
if !strings.Contains(item.Body, "先对齐口径") {
|
| 37 |
+
t.Fatalf("unexpected skill body: %q", item.Body)
|
| 38 |
+
}
|
| 39 |
+
if !cfg.Tools.IsEnabled() {
|
| 40 |
+
t.Fatal("expected tools enabled from config")
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// 编解码往返不能丢字段(codec.go 是手写映射)。
|
| 44 |
+
encoded, err := json.Marshal(cfg)
|
| 45 |
+
if err != nil {
|
| 46 |
+
t.Fatalf("marshal config: %v", err)
|
| 47 |
+
}
|
| 48 |
+
var decoded Config
|
| 49 |
+
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
| 50 |
+
t.Fatalf("unmarshal round trip: %v", err)
|
| 51 |
+
}
|
| 52 |
+
if !decoded.Skills.IsEnabled() || decoded.Skills.EffectiveMaxChars() != 120 {
|
| 53 |
+
t.Fatalf("skills lost in round trip: %#v", decoded.Skills)
|
| 54 |
+
}
|
| 55 |
+
if len(decoded.Skills.Items) != 1 || decoded.Skills.Items[0].ID != "sql-report" {
|
| 56 |
+
t.Fatalf("skill items lost in round trip: %#v", decoded.Skills.Items)
|
| 57 |
+
}
|
| 58 |
+
if decoded.Tools.Enabled == nil || !*decoded.Tools.Enabled {
|
| 59 |
+
t.Fatalf("tools flag lost in round trip: %#v", decoded.Tools)
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
func TestSkillsAndToolsDefaultOff(t *testing.T) {
|
| 64 |
+
var cfg Config
|
| 65 |
+
if err := json.Unmarshal([]byte(`{"keys":["k1"]}`), &cfg); err != nil {
|
| 66 |
+
t.Fatalf("unmarshal config: %v", err)
|
| 67 |
+
}
|
| 68 |
+
if cfg.Skills.IsEnabled() {
|
| 69 |
+
t.Fatal("expected skills disabled by default")
|
| 70 |
+
}
|
| 71 |
+
if got := cfg.Skills.EffectiveMaxChars(); got != DefaultSkillsMaxChars {
|
| 72 |
+
t.Fatalf("default max_chars=%d want=%d", got, DefaultSkillsMaxChars)
|
| 73 |
+
}
|
| 74 |
+
if cfg.Tools.IsEnabled() {
|
| 75 |
+
t.Fatal("expected tools disabled by default (nil pointer)")
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
func TestSkillsMaxCharsZeroMeansUnlimited(t *testing.T) {
|
| 80 |
+
var cfg Config
|
| 81 |
+
if err := json.Unmarshal([]byte(`{"skills":{"max_chars":0}}`), &cfg); err != nil {
|
| 82 |
+
t.Fatalf("unmarshal config: %v", err)
|
| 83 |
+
}
|
| 84 |
+
if cfg.Skills.MaxChars == nil {
|
| 85 |
+
t.Fatal("expected explicit max_chars to be kept as pointer")
|
| 86 |
+
}
|
| 87 |
+
if got := cfg.Skills.EffectiveMaxChars(); got != 0 {
|
| 88 |
+
t.Fatalf("explicit max_chars=0 should stay unlimited, got %d", got)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if err := json.Unmarshal([]byte(`{"skills":{"max_chars":-1}}`), &cfg); err != nil {
|
| 92 |
+
t.Fatalf("unmarshal config: %v", err)
|
| 93 |
+
}
|
| 94 |
+
if got := cfg.Skills.EffectiveMaxChars(); got != -1 {
|
| 95 |
+
t.Fatalf("negative max_chars should mean unlimited, got %d", got)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
func TestConfigCloneDeepCopiesSkillsAndTools(t *testing.T) {
|
| 100 |
+
enabled := true
|
| 101 |
+
maxChars := 100
|
| 102 |
+
cfg := Config{
|
| 103 |
+
Skills: SkillsConfig{
|
| 104 |
+
Enabled: &enabled,
|
| 105 |
+
MaxChars: &maxChars,
|
| 106 |
+
Items: []SkillConfig{{ID: "a", Body: "body a"}},
|
| 107 |
+
},
|
| 108 |
+
Tools: ToolsConfig{Enabled: &enabled},
|
| 109 |
+
}
|
| 110 |
+
clone := cfg.Clone()
|
| 111 |
+
*clone.Skills.Enabled = false
|
| 112 |
+
*clone.Skills.MaxChars = 1
|
| 113 |
+
clone.Skills.Items[0].Body = "changed"
|
| 114 |
+
*clone.Tools.Enabled = false
|
| 115 |
+
|
| 116 |
+
if !enabled || maxChars != 100 {
|
| 117 |
+
t.Fatal("expected clone to own copies of skills pointers")
|
| 118 |
+
}
|
| 119 |
+
if cfg.Skills.Items[0].Body != "body a" {
|
| 120 |
+
t.Fatal("expected clone to own a copy of skills items")
|
| 121 |
+
}
|
| 122 |
+
if cfg.Tools.Enabled == nil || !*cfg.Tools.Enabled {
|
| 123 |
+
t.Fatal("expected clone to own a copy of tools flag")
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
func TestConfigCloneDeepCopiesAPIKeySkills(t *testing.T) {
|
| 128 |
+
customEnabled := true
|
| 129 |
+
cfg := Config{APIKeys: []APIKey{{
|
| 130 |
+
Key: "sk-custom",
|
| 131 |
+
Skills: &APIKeySkillsConfig{Custom: true, Enabled: &customEnabled, IDs: []string{"a"}},
|
| 132 |
+
}}}
|
| 133 |
+
clone := cfg.Clone()
|
| 134 |
+
*clone.APIKeys[0].Skills.Enabled = false
|
| 135 |
+
clone.APIKeys[0].Skills.IDs[0] = "b"
|
| 136 |
+
|
| 137 |
+
if !customEnabled {
|
| 138 |
+
t.Fatal("expected clone to own a copy of per-key skills switch")
|
| 139 |
+
}
|
| 140 |
+
if got := cfg.APIKeys[0].Skills.IDs[0]; got != "a" {
|
| 141 |
+
t.Fatalf("expected clone to own a copy of per-key ids, got %q", got)
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
func TestNormalizeCredentialsKeepsAPIKeySkills(t *testing.T) {
|
| 146 |
+
customEnabled := true
|
| 147 |
+
cfg := Config{APIKeys: []APIKey{{
|
| 148 |
+
Key: "sk-custom",
|
| 149 |
+
Skills: &APIKeySkillsConfig{Custom: true, Enabled: &customEnabled, IDs: []string{" a ", "a", ""}},
|
| 150 |
+
}}}
|
| 151 |
+
cfg.NormalizeCredentials()
|
| 152 |
+
if len(cfg.APIKeys) != 1 || cfg.APIKeys[0].Skills == nil {
|
| 153 |
+
t.Fatalf("expected per-key skills to survive normalization, got %#v", cfg.APIKeys)
|
| 154 |
+
}
|
| 155 |
+
skills := cfg.APIKeys[0].Skills
|
| 156 |
+
if len(skills.IDs) != 1 || skills.IDs[0] != "a" {
|
| 157 |
+
t.Fatalf("expected trimmed deduped ids, got %#v", skills.IDs)
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
func TestValidateSkillsConfig(t *testing.T) {
|
| 162 |
+
valid := SkillsConfig{Items: []SkillConfig{
|
| 163 |
+
{ID: "sql-report", Name: "SQL 报表", Body: "# 说明"},
|
| 164 |
+
{ID: "review_v2", Body: "评审要点"},
|
| 165 |
+
}}
|
| 166 |
+
if err := ValidateSkillsConfig(valid); err != nil {
|
| 167 |
+
t.Fatalf("expected valid config, got %v", err)
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
cases := []struct {
|
| 171 |
+
name string
|
| 172 |
+
cfg SkillsConfig
|
| 173 |
+
want string
|
| 174 |
+
}{
|
| 175 |
+
{
|
| 176 |
+
name: "duplicate id",
|
| 177 |
+
cfg: SkillsConfig{Items: []SkillConfig{{ID: "a", Body: "x"}, {ID: "a", Body: "y"}}},
|
| 178 |
+
want: "duplicate skill id",
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
name: "invalid id charset",
|
| 182 |
+
cfg: SkillsConfig{Items: []SkillConfig{{ID: "SQL Report", Body: "x"}}},
|
| 183 |
+
want: "[a-z0-9_-]",
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
name: "empty id",
|
| 187 |
+
cfg: SkillsConfig{Items: []SkillConfig{{ID: " ", Body: "x"}}},
|
| 188 |
+
want: "id cannot be empty",
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
name: "empty body",
|
| 192 |
+
cfg: SkillsConfig{Items: []SkillConfig{{ID: "a", Body: " "}}},
|
| 193 |
+
want: "body cannot be empty",
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
name: "too many items",
|
| 197 |
+
cfg: SkillsConfig{Items: tooManySkills(MaxSkillItems + 1)},
|
| 198 |
+
want: "cannot exceed",
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
name: "body too long",
|
| 202 |
+
cfg: SkillsConfig{Items: []SkillConfig{{ID: "a", Body: strings.Repeat("字", MaxSkillBodyChars+1)}}},
|
| 203 |
+
want: "body cannot exceed",
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
name: "total body too long",
|
| 207 |
+
cfg: SkillsConfig{Items: []SkillConfig{
|
| 208 |
+
{ID: "a", Body: strings.Repeat("字", MaxSkillBodyChars)},
|
| 209 |
+
{ID: "b", Body: strings.Repeat("字", MaxSkillBodyChars)},
|
| 210 |
+
{ID: "c", Body: strings.Repeat("字", MaxSkillBodyChars)},
|
| 211 |
+
{ID: "d", Body: strings.Repeat("字", MaxSkillBodyChars)},
|
| 212 |
+
{ID: "e", Body: strings.Repeat("字", MaxSkillBodyChars)},
|
| 213 |
+
{ID: "f", Body: strings.Repeat("字", 1)},
|
| 214 |
+
}},
|
| 215 |
+
want: "total body cannot exceed",
|
| 216 |
+
},
|
| 217 |
+
}
|
| 218 |
+
for _, tc := range cases {
|
| 219 |
+
t.Run(tc.name, func(t *testing.T) {
|
| 220 |
+
err := ValidateSkillsConfig(tc.cfg)
|
| 221 |
+
if err == nil {
|
| 222 |
+
t.Fatalf("expected error for %s", tc.name)
|
| 223 |
+
}
|
| 224 |
+
if !strings.Contains(err.Error(), tc.want) {
|
| 225 |
+
t.Fatalf("error %q does not mention %q", err.Error(), tc.want)
|
| 226 |
+
}
|
| 227 |
+
})
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
func tooManySkills(n int) []SkillConfig {
|
| 232 |
+
items := make([]SkillConfig, 0, n)
|
| 233 |
+
for i := 0; i < n; i++ {
|
| 234 |
+
items = append(items, SkillConfig{ID: skillIDForIndex(i), Body: "x"})
|
| 235 |
+
}
|
| 236 |
+
return items
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
func skillIDForIndex(i int) string {
|
| 240 |
+
letters := "abcdefghijklmnopqrstuvwxyz"
|
| 241 |
+
return string(letters[i%len(letters)]) + string(letters[(i/len(letters))%len(letters)])
|
| 242 |
+
}
|
internal/config/store_accessors.go
CHANGED
|
@@ -178,7 +178,7 @@ func (s *Store) ThinkingInjectionEnabled() bool {
|
|
| 178 |
s.mu.RLock()
|
| 179 |
defer s.mu.RUnlock()
|
| 180 |
if s.cfg.ThinkingInjection.Enabled == nil {
|
| 181 |
-
return
|
| 182 |
}
|
| 183 |
return *s.cfg.ThinkingInjection.Enabled
|
| 184 |
}
|
|
@@ -189,6 +189,199 @@ func (s *Store) ThinkingInjectionPrompt() string {
|
|
| 189 |
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
func (s *Store) RuntimeBackupReleaseCount() int {
|
| 193 |
s.mu.RLock()
|
| 194 |
defer s.mu.RUnlock()
|
|
|
|
| 178 |
s.mu.RLock()
|
| 179 |
defer s.mu.RUnlock()
|
| 180 |
if s.cfg.ThinkingInjection.Enabled == nil {
|
| 181 |
+
return true
|
| 182 |
}
|
| 183 |
return *s.cfg.ThinkingInjection.Enabled
|
| 184 |
}
|
|
|
|
| 189 |
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
|
| 190 |
}
|
| 191 |
|
| 192 |
+
// ThinkingInjectionSettings 是「思考格式注入」对单次请求生效的设置。
|
| 193 |
+
// Prompt 为空表示调用方应回退到内置默认提示词。
|
| 194 |
+
type ThinkingInjectionSettings struct {
|
| 195 |
+
Enabled bool `json:"enabled"`
|
| 196 |
+
Prompt string `json:"prompt,omitempty"`
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// ResolveThinkingInjection 解析指定 API Key 最终生效的注入设置:
|
| 200 |
+
// 先取全局开关与提示词,再由该 Key 的自定义设置(Custom=true)覆盖。
|
| 201 |
+
// 未命中 Key(例如直接传 DeepSeek token)时完全跟随全局。
|
| 202 |
+
func (s *Store) ResolveThinkingInjection(key string) ThinkingInjectionSettings {
|
| 203 |
+
if s == nil {
|
| 204 |
+
return ThinkingInjectionSettings{}
|
| 205 |
+
}
|
| 206 |
+
settings := ThinkingInjectionSettings{
|
| 207 |
+
Enabled: s.ThinkingInjectionEnabled(),
|
| 208 |
+
Prompt: s.ThinkingInjectionPrompt(),
|
| 209 |
+
}
|
| 210 |
+
item, ok := s.FindAPIKey(key)
|
| 211 |
+
if !ok || item.ThinkingInjection == nil || !item.ThinkingInjection.Custom {
|
| 212 |
+
return settings
|
| 213 |
+
}
|
| 214 |
+
if item.ThinkingInjection.Enabled != nil {
|
| 215 |
+
settings.Enabled = *item.ThinkingInjection.Enabled
|
| 216 |
+
}
|
| 217 |
+
if custom := strings.TrimSpace(item.ThinkingInjection.Prompt); custom != "" {
|
| 218 |
+
settings.Prompt = custom
|
| 219 |
+
}
|
| 220 |
+
return settings
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// SkillsEnabled 返回「纯文本技能库」全局开关;未配置按 false 处理(默认不注入)。
|
| 224 |
+
func (s *Store) SkillsEnabled() bool {
|
| 225 |
+
s.mu.RLock()
|
| 226 |
+
defer s.mu.RUnlock()
|
| 227 |
+
return s.cfg.Skills.IsEnabled()
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// SkillsMaxChars 返回生效的单次注入字符上限。
|
| 231 |
+
// 未配置为 DefaultSkillsMaxChars;显式 0 或负数表示不限制。
|
| 232 |
+
func (s *Store) SkillsMaxChars() int {
|
| 233 |
+
s.mu.RLock()
|
| 234 |
+
defer s.mu.RUnlock()
|
| 235 |
+
return s.cfg.Skills.EffectiveMaxChars()
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// SkillsItems 返回全局技能条目副本。是否注入仍由 SkillsEnabled 决定。
|
| 239 |
+
func (s *Store) SkillsItems() []SkillConfig {
|
| 240 |
+
s.mu.RLock()
|
| 241 |
+
defer s.mu.RUnlock()
|
| 242 |
+
if len(s.cfg.Skills.Items) == 0 {
|
| 243 |
+
return nil
|
| 244 |
+
}
|
| 245 |
+
out := make([]SkillConfig, len(s.cfg.Skills.Items))
|
| 246 |
+
copy(out, s.cfg.Skills.Items)
|
| 247 |
+
return out
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// ToolsEnabled 返回「工具调用」总开关;未配置(nil)按 false 处理。
|
| 251 |
+
func (s *Store) ToolsEnabled() bool {
|
| 252 |
+
s.mu.RLock()
|
| 253 |
+
defer s.mu.RUnlock()
|
| 254 |
+
return s.cfg.Tools.IsEnabled()
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// SkillSettings 是「纯文本技能库」对单次请求生效的设置。
|
| 258 |
+
// Items 已按单 Key 覆盖完成过滤,长度截断由注入层(promptcompat)负责。
|
| 259 |
+
type SkillSettings struct {
|
| 260 |
+
Enabled bool
|
| 261 |
+
MaxChars int
|
| 262 |
+
Items []SkillConfig
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// ResolveSkills 解析指定 API Key 最终生效的技能设置:
|
| 266 |
+
// - 全局 skills.enabled=false → 任何 Key 都不注入;
|
| 267 |
+
// - 该 Key 未配置 skills 或 Custom=false → 使用全部全局技能;
|
| 268 |
+
// - Custom=true 且 Enabled=false → 该 Key 不注入;
|
| 269 |
+
// - Custom=true 且 IDs 非空 → 只注入命中的技能;
|
| 270 |
+
// - Custom=true 且 IDs 为空 → 该 Key 不注入。
|
| 271 |
+
//
|
| 272 |
+
// 未命中 Key(例如直接传 DeepSeek token)时完全跟随全局。
|
| 273 |
+
func (s *Store) ResolveSkills(key string) SkillSettings {
|
| 274 |
+
if s == nil {
|
| 275 |
+
return SkillSettings{}
|
| 276 |
+
}
|
| 277 |
+
s.mu.RLock()
|
| 278 |
+
global := s.cfg.Skills.Clone()
|
| 279 |
+
override := s.findAPIKeySkillsLocked(key)
|
| 280 |
+
s.mu.RUnlock()
|
| 281 |
+
|
| 282 |
+
settings := SkillSettings{MaxChars: global.EffectiveMaxChars()}
|
| 283 |
+
if !global.IsEnabled() {
|
| 284 |
+
return settings
|
| 285 |
+
}
|
| 286 |
+
if override == nil || !override.Custom {
|
| 287 |
+
settings.Enabled = true
|
| 288 |
+
settings.Items = global.Items
|
| 289 |
+
return settings
|
| 290 |
+
}
|
| 291 |
+
if override.Enabled != nil && !*override.Enabled {
|
| 292 |
+
return settings
|
| 293 |
+
}
|
| 294 |
+
if len(override.IDs) == 0 {
|
| 295 |
+
return settings
|
| 296 |
+
}
|
| 297 |
+
wanted := make(map[string]struct{}, len(override.IDs))
|
| 298 |
+
for _, id := range override.IDs {
|
| 299 |
+
wanted[id] = struct{}{}
|
| 300 |
+
}
|
| 301 |
+
items := make([]SkillConfig, 0, len(override.IDs))
|
| 302 |
+
for _, item := range global.Items {
|
| 303 |
+
if _, ok := wanted[item.ID]; !ok {
|
| 304 |
+
continue
|
| 305 |
+
}
|
| 306 |
+
items = append(items, item)
|
| 307 |
+
}
|
| 308 |
+
if len(items) == 0 {
|
| 309 |
+
return settings
|
| 310 |
+
}
|
| 311 |
+
settings.Enabled = true
|
| 312 |
+
settings.Items = items
|
| 313 |
+
return settings
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// findAPIKeySkillsLocked 返回指定 Key 的技能覆盖设置副本;调用方需持有读锁。
|
| 317 |
+
func (s *Store) findAPIKeySkillsLocked(key string) *APIKeySkillsConfig {
|
| 318 |
+
key = strings.TrimSpace(key)
|
| 319 |
+
if key == "" {
|
| 320 |
+
return nil
|
| 321 |
+
}
|
| 322 |
+
for _, item := range s.cfg.APIKeys {
|
| 323 |
+
if item.Key != key {
|
| 324 |
+
continue
|
| 325 |
+
}
|
| 326 |
+
return item.Skills.Clone()
|
| 327 |
+
}
|
| 328 |
+
return nil
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// FindAPIKey 返回指定 Key 的配置副本(含该 Key 的自定义注入设置)。
|
| 332 |
+
func (s *Store) FindAPIKey(key string) (APIKey, bool) {
|
| 333 |
+
if s == nil {
|
| 334 |
+
return APIKey{}, false
|
| 335 |
+
}
|
| 336 |
+
key = strings.TrimSpace(key)
|
| 337 |
+
if key == "" {
|
| 338 |
+
return APIKey{}, false
|
| 339 |
+
}
|
| 340 |
+
s.mu.RLock()
|
| 341 |
+
defer s.mu.RUnlock()
|
| 342 |
+
for _, item := range s.cfg.APIKeys {
|
| 343 |
+
if item.Key != key {
|
| 344 |
+
continue
|
| 345 |
+
}
|
| 346 |
+
return APIKey{
|
| 347 |
+
Key: item.Key,
|
| 348 |
+
Name: item.Name,
|
| 349 |
+
Remark: item.Remark,
|
| 350 |
+
ThinkingInjection: item.ThinkingInjection.Clone(),
|
| 351 |
+
Skills: item.Skills.Clone(),
|
| 352 |
+
}, true
|
| 353 |
+
}
|
| 354 |
+
return APIKey{}, false
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// APIKeyLabel 返回用于日志展示的 Key 标签:优先名称,其次备注,
|
| 358 |
+
// 都没有时退化成脱敏后的 Key 片段。未知 Key 返回空串。
|
| 359 |
+
func (s *Store) APIKeyLabel(key string) string {
|
| 360 |
+
item, ok := s.FindAPIKey(key)
|
| 361 |
+
if !ok {
|
| 362 |
+
return ""
|
| 363 |
+
}
|
| 364 |
+
if name := strings.TrimSpace(item.Name); name != "" {
|
| 365 |
+
return name
|
| 366 |
+
}
|
| 367 |
+
if remark := strings.TrimSpace(item.Remark); remark != "" {
|
| 368 |
+
return remark
|
| 369 |
+
}
|
| 370 |
+
return MaskAPIKeyPreview(item.Key)
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// MaskAPIKeyPreview 生成脱敏 Key 片段,避免把完整密钥写进响应记录。
|
| 374 |
+
func MaskAPIKeyPreview(key string) string {
|
| 375 |
+
key = strings.TrimSpace(key)
|
| 376 |
+
if key == "" {
|
| 377 |
+
return ""
|
| 378 |
+
}
|
| 379 |
+
if len(key) <= 8 {
|
| 380 |
+
return "****"
|
| 381 |
+
}
|
| 382 |
+
return key[:4] + "****" + key[len(key)-4:]
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
func (s *Store) RuntimeBackupReleaseCount() int {
|
| 386 |
s.mu.RLock()
|
| 387 |
defer s.mu.RUnlock()
|
internal/config/store_accessors_test.go
CHANGED
|
@@ -44,3 +44,203 @@ func TestStoreThinkingInjectionAccessors(t *testing.T) {
|
|
| 44 |
t.Fatalf("thinking injection prompt=%q want custom thinking prompt", got)
|
| 45 |
}
|
| 46 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
t.Fatalf("thinking injection prompt=%q want custom thinking prompt", got)
|
| 45 |
}
|
| 46 |
}
|
| 47 |
+
|
| 48 |
+
func TestStoreResolveThinkingInjection(t *testing.T) {
|
| 49 |
+
store := &Store{cfg: Config{}}
|
| 50 |
+
settings := store.ResolveThinkingInjection("sk-default")
|
| 51 |
+
if !settings.Enabled {
|
| 52 |
+
t.Fatal("expected global thinking injection enabled by default")
|
| 53 |
+
}
|
| 54 |
+
if settings.Prompt != "" {
|
| 55 |
+
t.Fatalf("expected empty global prompt (caller falls back to built-in), got %q", settings.Prompt)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
disabled := false
|
| 59 |
+
enabled := true
|
| 60 |
+
store.cfg.ThinkingInjection = ThinkingInjectionConfig{Prompt: "global prompt"}
|
| 61 |
+
store.cfg.APIKeys = []APIKey{
|
| 62 |
+
{Key: "sk-follow", Name: "follow"},
|
| 63 |
+
{
|
| 64 |
+
Key: "sk-off",
|
| 65 |
+
ThinkingInjection: &APIKeyInjectionConfig{Custom: true, Enabled: &disabled},
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
Key: "sk-on",
|
| 69 |
+
ThinkingInjection: &APIKeyInjectionConfig{Custom: true, Enabled: &enabled, Prompt: " key prompt "},
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
Key: "sk-notcustom",
|
| 73 |
+
ThinkingInjection: &APIKeyInjectionConfig{Custom: false, Prompt: "ignored"},
|
| 74 |
+
},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if got := store.ResolveThinkingInjection("sk-follow"); !got.Enabled || got.Prompt != "global prompt" {
|
| 78 |
+
t.Fatalf("expected follow-global settings, got %#v", got)
|
| 79 |
+
}
|
| 80 |
+
if got := store.ResolveThinkingInjection("sk-notcustom"); !got.Enabled || got.Prompt != "global prompt" {
|
| 81 |
+
t.Fatalf("expected custom=false to follow global, got %#v", got)
|
| 82 |
+
}
|
| 83 |
+
if got := store.ResolveThinkingInjection("sk-off"); got.Enabled {
|
| 84 |
+
t.Fatalf("expected per-key disable, got %#v", got)
|
| 85 |
+
}
|
| 86 |
+
if got := store.ResolveThinkingInjection("sk-on"); !got.Enabled || got.Prompt != "key prompt" {
|
| 87 |
+
t.Fatalf("expected per-key prompt, got %#v", got)
|
| 88 |
+
}
|
| 89 |
+
if got := store.ResolveThinkingInjection("sk-unknown"); !got.Enabled || got.Prompt != "global prompt" {
|
| 90 |
+
t.Fatalf("expected unknown key to follow global, got %#v", got)
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
func TestStoreAPIKeyLabel(t *testing.T) {
|
| 95 |
+
store := &Store{cfg: Config{APIKeys: []APIKey{
|
| 96 |
+
{Key: "sk-named", Name: "主账号"},
|
| 97 |
+
{Key: "sk-remark", Remark: "测试用"},
|
| 98 |
+
{Key: "sk-0123456789abcdef"},
|
| 99 |
+
}}}
|
| 100 |
+
if got := store.APIKeyLabel("sk-named"); got != "主账号" {
|
| 101 |
+
t.Fatalf("expected name label, got %q", got)
|
| 102 |
+
}
|
| 103 |
+
if got := store.APIKeyLabel("sk-remark"); got != "测试用" {
|
| 104 |
+
t.Fatalf("expected remark label, got %q", got)
|
| 105 |
+
}
|
| 106 |
+
if got := store.APIKeyLabel("sk-0123456789abcdef"); got != "sk-0****cdef" {
|
| 107 |
+
t.Fatalf("expected masked label, got %q", got)
|
| 108 |
+
}
|
| 109 |
+
if got := store.APIKeyLabel("sk-missing"); got != "" {
|
| 110 |
+
t.Fatalf("expected empty label for unknown key, got %q", got)
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
func TestStoreToolsEnabled(t *testing.T) {
|
| 115 |
+
store := &Store{cfg: Config{}}
|
| 116 |
+
if store.ToolsEnabled() {
|
| 117 |
+
t.Fatal("expected tools disabled by default (nil pointer)")
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
enabled := true
|
| 121 |
+
store.cfg.Tools = ToolsConfig{Enabled: &enabled}
|
| 122 |
+
if !store.ToolsEnabled() {
|
| 123 |
+
t.Fatal("expected tools enabled by explicit config")
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
disabled := false
|
| 127 |
+
store.cfg.Tools.Enabled = &disabled
|
| 128 |
+
if store.ToolsEnabled() {
|
| 129 |
+
t.Fatal("expected tools disabled by explicit config")
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
func TestStoreSkillsAccessors(t *testing.T) {
|
| 134 |
+
store := &Store{cfg: Config{}}
|
| 135 |
+
if store.SkillsEnabled() {
|
| 136 |
+
t.Fatal("expected skills disabled by default")
|
| 137 |
+
}
|
| 138 |
+
if got := store.SkillsMaxChars(); got != DefaultSkillsMaxChars {
|
| 139 |
+
t.Fatalf("default skills max_chars=%d want=%d", got, DefaultSkillsMaxChars)
|
| 140 |
+
}
|
| 141 |
+
if got := store.SkillsItems(); got != nil {
|
| 142 |
+
t.Fatalf("expected no skills items by default, got %#v", got)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
enabled := true
|
| 146 |
+
zero := 0
|
| 147 |
+
store.cfg.Skills = SkillsConfig{
|
| 148 |
+
Enabled: &enabled,
|
| 149 |
+
MaxChars: &zero,
|
| 150 |
+
Items: []SkillConfig{{ID: "a", Body: "body a"}},
|
| 151 |
+
}
|
| 152 |
+
if !store.SkillsEnabled() {
|
| 153 |
+
t.Fatal("expected skills enabled")
|
| 154 |
+
}
|
| 155 |
+
if got := store.SkillsMaxChars(); got != 0 {
|
| 156 |
+
t.Fatalf("explicit max_chars=0 should stay unlimited, got %d", got)
|
| 157 |
+
}
|
| 158 |
+
items := store.SkillsItems()
|
| 159 |
+
if len(items) != 1 || items[0].ID != "a" {
|
| 160 |
+
t.Fatalf("unexpected skills items: %#v", items)
|
| 161 |
+
}
|
| 162 |
+
items[0].Body = "mutated"
|
| 163 |
+
if store.SkillsItems()[0].Body != "body a" {
|
| 164 |
+
t.Fatal("expected SkillsItems to return a copy")
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// TestStoreResolveSkillsKeyOverrides 覆盖「全局开关 + 单 Key 覆盖」的全部组合语义。
|
| 169 |
+
func TestStoreResolveSkillsKeyOverrides(t *testing.T) {
|
| 170 |
+
enabled := true
|
| 171 |
+
disabled := false
|
| 172 |
+
store := &Store{cfg: Config{}}
|
| 173 |
+
store.cfg.Skills = SkillsConfig{
|
| 174 |
+
Enabled: &enabled,
|
| 175 |
+
MaxChars: nil,
|
| 176 |
+
Items: []SkillConfig{
|
| 177 |
+
{ID: "sql-report", Name: "SQL 报表", Body: "sql body"},
|
| 178 |
+
{ID: "review", Name: "评审", Body: "review body"},
|
| 179 |
+
},
|
| 180 |
+
}
|
| 181 |
+
store.cfg.APIKeys = []APIKey{
|
| 182 |
+
{Key: "sk-global"},
|
| 183 |
+
{Key: "sk-notcustom", Skills: &APIKeySkillsConfig{Custom: false, IDs: []string{"sql-report"}}},
|
| 184 |
+
{Key: "sk-off", Skills: &APIKeySkillsConfig{Custom: true, Enabled: &disabled}},
|
| 185 |
+
{Key: "sk-follow", Skills: &APIKeySkillsConfig{Custom: true, Enabled: &enabled}},
|
| 186 |
+
{Key: "sk-pick", Skills: &APIKeySkillsConfig{Custom: true, Enabled: &enabled, IDs: []string{"review"}}},
|
| 187 |
+
{Key: "sk-empty-ids", Skills: &APIKeySkillsConfig{Custom: true, Enabled: &enabled}},
|
| 188 |
+
{Key: "sk-unknown-ids", Skills: &APIKeySkillsConfig{Custom: true, IDs: []string{"nope"}}},
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
cases := []struct {
|
| 192 |
+
key string
|
| 193 |
+
enabled bool
|
| 194 |
+
ids []string
|
| 195 |
+
}{
|
| 196 |
+
{key: "sk-global", enabled: true, ids: []string{"sql-report", "review"}},
|
| 197 |
+
{key: "sk-notcustom", enabled: true, ids: []string{"sql-report", "review"}},
|
| 198 |
+
// Custom=true 但 IDs 为空按规格视为「不注入」。
|
| 199 |
+
{key: "sk-follow", enabled: false, ids: nil},
|
| 200 |
+
{key: "sk-off", enabled: false, ids: nil},
|
| 201 |
+
{key: "sk-pick", enabled: true, ids: []string{"review"}},
|
| 202 |
+
{key: "sk-empty-ids", enabled: false, ids: nil},
|
| 203 |
+
{key: "sk-unknown-ids", enabled: false, ids: nil},
|
| 204 |
+
{key: "sk-not-configured", enabled: true, ids: []string{"sql-report", "review"}},
|
| 205 |
+
}
|
| 206 |
+
for _, tc := range cases {
|
| 207 |
+
t.Run(tc.key, func(t *testing.T) {
|
| 208 |
+
settings := store.ResolveSkills(tc.key)
|
| 209 |
+
if settings.Enabled != tc.enabled {
|
| 210 |
+
t.Fatalf("enabled=%v want=%v", settings.Enabled, tc.enabled)
|
| 211 |
+
}
|
| 212 |
+
if got := len(settings.Items); got != len(tc.ids) {
|
| 213 |
+
t.Fatalf("items=%#v want ids=%#v", settings.Items, tc.ids)
|
| 214 |
+
}
|
| 215 |
+
for i, id := range tc.ids {
|
| 216 |
+
if settings.Items[i].ID != id {
|
| 217 |
+
t.Fatalf("items=%#v want ids=%#v", settings.Items, tc.ids)
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
if settings.MaxChars != DefaultSkillsMaxChars {
|
| 221 |
+
t.Fatalf("max_chars=%d want default %d", settings.MaxChars, DefaultSkillsMaxChars)
|
| 222 |
+
}
|
| 223 |
+
})
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// TestStoreResolveSkillsGlobalDisabledWins 验证总开关关闭时任何 Key 都不注入。
|
| 228 |
+
func TestStoreResolveSkillsGlobalDisabledWins(t *testing.T) {
|
| 229 |
+
disabled := false
|
| 230 |
+
enabled := true
|
| 231 |
+
store := &Store{cfg: Config{}}
|
| 232 |
+
store.cfg.Skills = SkillsConfig{
|
| 233 |
+
Enabled: &disabled,
|
| 234 |
+
MaxChars: nil,
|
| 235 |
+
Items: []SkillConfig{{ID: "sql-report", Body: "sql body"}},
|
| 236 |
+
}
|
| 237 |
+
store.cfg.APIKeys = []APIKey{
|
| 238 |
+
{Key: "sk-on", Skills: &APIKeySkillsConfig{Custom: true, Enabled: &enabled, IDs: []string{"sql-report"}}},
|
| 239 |
+
}
|
| 240 |
+
for _, key := range []string{"sk-on", "sk-global", "sk-unknown"} {
|
| 241 |
+
settings := store.ResolveSkills(key)
|
| 242 |
+
if settings.Enabled || len(settings.Items) > 0 {
|
| 243 |
+
t.Fatalf("expected no skills for key %q when global switch is off, got %#v", key, settings)
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
}
|
internal/deepseek/client/client_core.go
CHANGED
|
@@ -47,12 +47,16 @@ func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
|
|
| 47 |
platform = store.PlatformMode()
|
| 48 |
}
|
| 49 |
dsprotocol.ApplyPlatform(platform)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
snap.
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
c.regular = trans.NewWithPlatform(60*time.Second, platform)
|
| 57 |
c.stream = trans.NewWithPlatform(0, platform)
|
| 58 |
c.fallback = trans.NewFallbackClientWithPlatform(60*time.Second, nil, platform)
|
|
|
|
| 47 |
platform = store.PlatformMode()
|
| 48 |
}
|
| 49 |
dsprotocol.ApplyPlatform(platform)
|
| 50 |
+
// Store 允许为空(纯直通构造 / 单元测试里没有配置存储):
|
| 51 |
+
// 没有存储时跳过版本覆盖,沿用 protocol 包的默认值。
|
| 52 |
+
if store != nil {
|
| 53 |
+
snap := store.Snapshot()
|
| 54 |
+
dsprotocol.ApplyVersionOverrides(
|
| 55 |
+
snap.Platform.AndroidVersion,
|
| 56 |
+
snap.Platform.WebVersion,
|
| 57 |
+
snap.Platform.AndroidAPILevel,
|
| 58 |
+
)
|
| 59 |
+
}
|
| 60 |
c.regular = trans.NewWithPlatform(60*time.Second, platform)
|
| 61 |
c.stream = trans.NewWithPlatform(0, platform)
|
| 62 |
c.fallback = trans.NewFallbackClientWithPlatform(60*time.Second, nil, platform)
|
internal/deepseek/client/client_upload_test.go
CHANGED
|
@@ -3,14 +3,18 @@ package client
|
|
| 3 |
import (
|
| 4 |
"context"
|
| 5 |
dsprotocol "ds2api/internal/deepseek/protocol"
|
|
|
|
| 6 |
"errors"
|
|
|
|
| 7 |
"io"
|
| 8 |
"net/http"
|
|
|
|
| 9 |
"strings"
|
| 10 |
"testing"
|
| 11 |
"time"
|
| 12 |
|
| 13 |
"ds2api/internal/auth"
|
|
|
|
| 14 |
)
|
| 15 |
|
| 16 |
func TestBuildUploadMultipartBodyOmitsPurposeAndIncludesFilePart(t *testing.T) {
|
|
@@ -96,9 +100,25 @@ func TestExtractUploadFileResultSupportsNestedShapes(t *testing.T) {
|
|
| 96 |
}
|
| 97 |
}
|
| 98 |
|
| 99 |
-
// powChallengeResponse
|
| 100 |
-
//
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
func TestUploadFileUsesMultipartHeadersWithPow(t *testing.T) {
|
| 104 |
uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
|
|
@@ -113,7 +133,7 @@ func TestUploadFileUsesMultipartHeadersWithPow(t *testing.T) {
|
|
| 113 |
bodyBytes, _ := io.ReadAll(req.Body)
|
| 114 |
switch {
|
| 115 |
case strings.Contains(req.URL.Path, "create_pow_challenge"):
|
| 116 |
-
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse)), Request: req}, nil
|
| 117 |
case strings.Contains(req.URL.Path, "upload_file"):
|
| 118 |
uploadCalled = true
|
| 119 |
seenPow = req.Header.Get("x-ds-pow-response")
|
|
@@ -174,7 +194,7 @@ func TestUploadFileDefaultsModelTypeToDefaultWhenAbsent(t *testing.T) {
|
|
| 174 |
regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
| 175 |
switch {
|
| 176 |
case strings.Contains(req.URL.Path, "create_pow_challenge"):
|
| 177 |
-
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse)), Request: req}, nil
|
| 178 |
case strings.Contains(req.URL.Path, "upload_file"):
|
| 179 |
uploadCalled = true
|
| 180 |
seenModelType = req.Header.Get("x-model-type")
|
|
@@ -220,7 +240,7 @@ func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) {
|
|
| 220 |
call++
|
| 221 |
switch {
|
| 222 |
case strings.Contains(req.URL.Path, "create_pow_challenge"):
|
| 223 |
-
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse)), Request: req}, nil
|
| 224 |
case strings.Contains(req.URL.Path, "upload_file"):
|
| 225 |
// upload 必须携带 PoW(HAR 实测 target_path=/api/v0/file/upload_file)。
|
| 226 |
if req.Header.Get("x-ds-pow-response") == "" {
|
|
|
|
| 3 |
import (
|
| 4 |
"context"
|
| 5 |
dsprotocol "ds2api/internal/deepseek/protocol"
|
| 6 |
+
"encoding/hex"
|
| 7 |
"errors"
|
| 8 |
+
"fmt"
|
| 9 |
"io"
|
| 10 |
"net/http"
|
| 11 |
+
"strconv"
|
| 12 |
"strings"
|
| 13 |
"testing"
|
| 14 |
"time"
|
| 15 |
|
| 16 |
"ds2api/internal/auth"
|
| 17 |
+
"ds2api/pow"
|
| 18 |
)
|
| 19 |
|
| 20 |
func TestBuildUploadMultipartBodyOmitsPurposeAndIncludesFilePart(t *testing.T) {
|
|
|
|
| 100 |
}
|
| 101 |
}
|
| 102 |
|
| 103 |
+
// powChallengeResponse 构造 create_pow_challenge 的最小可用响应。
|
| 104 |
+
//
|
| 105 |
+
// challenge 必须是 DeepSeekHashV1(salt_expireAt_nonce) 的 32 字节摘要:
|
| 106 |
+
// SolvePow 在 [0, difficulty) 区间里搜 nonce,difficulty 是搜索上界而不是
|
| 107 |
+
// 「目标难度」,因此随手写一串 0 永远解不出来(ComputePow 会返回
|
| 108 |
+
// "pow: no solution within difficulty",UploadFile 只会看到 "get pow failed")。
|
| 109 |
+
// 这里用 answer=0 / difficulty=1 现算摘要,保证求解立即命中又不额外消耗 CPU。
|
| 110 |
+
func powChallengeResponse() string {
|
| 111 |
+
const (
|
| 112 |
+
salt = "salt"
|
| 113 |
+
expireAt = int64(9999999999)
|
| 114 |
+
answer = int64(0)
|
| 115 |
+
)
|
| 116 |
+
sum := pow.DeepSeekHashV1([]byte(pow.BuildPrefix(salt, expireAt) + strconv.FormatInt(answer, 10)))
|
| 117 |
+
return fmt.Sprintf(
|
| 118 |
+
`{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"%s","salt":"%s","difficulty":1,"expire_at":%d,"signature":"sig","target_path":"/api/v0/file/upload_file"}}}}`,
|
| 119 |
+
hex.EncodeToString(sum[:]), salt, expireAt,
|
| 120 |
+
)
|
| 121 |
+
}
|
| 122 |
|
| 123 |
func TestUploadFileUsesMultipartHeadersWithPow(t *testing.T) {
|
| 124 |
uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}`
|
|
|
|
| 133 |
bodyBytes, _ := io.ReadAll(req.Body)
|
| 134 |
switch {
|
| 135 |
case strings.Contains(req.URL.Path, "create_pow_challenge"):
|
| 136 |
+
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse())), Request: req}, nil
|
| 137 |
case strings.Contains(req.URL.Path, "upload_file"):
|
| 138 |
uploadCalled = true
|
| 139 |
seenPow = req.Header.Get("x-ds-pow-response")
|
|
|
|
| 194 |
regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
| 195 |
switch {
|
| 196 |
case strings.Contains(req.URL.Path, "create_pow_challenge"):
|
| 197 |
+
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse())), Request: req}, nil
|
| 198 |
case strings.Contains(req.URL.Path, "upload_file"):
|
| 199 |
uploadCalled = true
|
| 200 |
seenModelType = req.Header.Get("x-model-type")
|
|
|
|
| 240 |
call++
|
| 241 |
switch {
|
| 242 |
case strings.Contains(req.URL.Path, "create_pow_challenge"):
|
| 243 |
+
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powChallengeResponse())), Request: req}, nil
|
| 244 |
case strings.Contains(req.URL.Path, "upload_file"):
|
| 245 |
// upload 必须携带 PoW(HAR 实测 target_path=/api/v0/file/upload_file)。
|
| 246 |
if req.Header.Get("x-ds-pow-response") == "" {
|
internal/httpapi/admin/accounts/handler_accounts_testing_test.go
CHANGED
|
@@ -171,7 +171,9 @@ func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ s
|
|
| 171 |
|
| 172 |
func (m *completionPayloadDSMock) SetPlatform(_ string) {}
|
| 173 |
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
|
| 176 |
store := config.LoadStore()
|
| 177 |
ds := &completionPayloadDSMock{}
|
|
@@ -186,15 +188,15 @@ func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T)
|
|
| 186 |
if ok, _ := result["success"].(bool); !ok {
|
| 187 |
t.Fatalf("expected success=true, got %#v", result)
|
| 188 |
}
|
| 189 |
-
if got := ds.payload["model_type"]; got != "
|
| 190 |
-
t.Fatalf("expected model_type
|
| 191 |
}
|
| 192 |
if got := ds.payload["chat_session_id"]; got != "session-id" {
|
| 193 |
t.Fatalf("unexpected chat_session_id: %#v", got)
|
| 194 |
}
|
| 195 |
}
|
| 196 |
|
| 197 |
-
func
|
| 198 |
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
|
| 199 |
store := config.LoadStore()
|
| 200 |
ds := &completionPayloadDSMock{}
|
|
@@ -209,7 +211,7 @@ func TestTestAccount_MessageModeUsesVisionModelTypeForVisionModel(t *testing.T)
|
|
| 209 |
if ok, _ := result["success"].(bool); !ok {
|
| 210 |
t.Fatalf("expected success=true, got %#v", result)
|
| 211 |
}
|
| 212 |
-
if got := ds.payload["model_type"]; got != "
|
| 213 |
-
t.Fatalf("expected model_type vision, got %#v", got)
|
| 214 |
}
|
| 215 |
}
|
|
|
|
| 171 |
|
| 172 |
func (m *completionPayloadDSMock) SetPlatform(_ string) {}
|
| 173 |
|
| 174 |
+
// 上游已把 v4-pro / v4-vision 合并进 v4-flash,发给上游的 model_type 只剩 default 一种。
|
| 175 |
+
// 老模型名仍然可用(config.deprecatedModelAliases 负责归一),这里断言归一后的 model_type。
|
| 176 |
+
func TestTestAccount_MessageModeUsesDefaultModelTypeForDeprecatedExpertAlias(t *testing.T) {
|
| 177 |
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
|
| 178 |
store := config.LoadStore()
|
| 179 |
ds := &completionPayloadDSMock{}
|
|
|
|
| 188 |
if ok, _ := result["success"].(bool); !ok {
|
| 189 |
t.Fatalf("expected success=true, got %#v", result)
|
| 190 |
}
|
| 191 |
+
if got := ds.payload["model_type"]; got != "default" {
|
| 192 |
+
t.Fatalf("expected model_type default (v4-pro 已并入 v4-flash), got %#v", got)
|
| 193 |
}
|
| 194 |
if got := ds.payload["chat_session_id"]; got != "session-id" {
|
| 195 |
t.Fatalf("unexpected chat_session_id: %#v", got)
|
| 196 |
}
|
| 197 |
}
|
| 198 |
|
| 199 |
+
func TestTestAccount_MessageModeUsesDefaultModelTypeForDeprecatedVisionAlias(t *testing.T) {
|
| 200 |
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
|
| 201 |
store := config.LoadStore()
|
| 202 |
ds := &completionPayloadDSMock{}
|
|
|
|
| 211 |
if ok, _ := result["success"].(bool); !ok {
|
| 212 |
t.Fatalf("expected success=true, got %#v", result)
|
| 213 |
}
|
| 214 |
+
if got := ds.payload["model_type"]; got != "default" {
|
| 215 |
+
t.Fatalf("expected model_type default (v4-vision 已并入 v4-flash), got %#v", got)
|
| 216 |
}
|
| 217 |
}
|
internal/httpapi/admin/configmgmt/deps.go
CHANGED
|
@@ -24,6 +24,9 @@ func toAccount(m map[string]any) config.Account {
|
|
| 24 |
return adminshared.ToAccount(m)
|
| 25 |
}
|
| 26 |
func toAPIKeys(v any) ([]config.APIKey, bool) { return adminshared.ToAPIKeys(v) }
|
|
|
|
|
|
|
|
|
|
| 27 |
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
|
| 28 |
return adminshared.MergeAPIKeysPreferStructured(existing, incoming)
|
| 29 |
}
|
|
|
|
| 24 |
return adminshared.ToAccount(m)
|
| 25 |
}
|
| 26 |
func toAPIKeys(v any) ([]config.APIKey, bool) { return adminshared.ToAPIKeys(v) }
|
| 27 |
+
func toAPIKeyInjection(v any) *config.APIKeyInjectionConfig {
|
| 28 |
+
return adminshared.ToAPIKeyInjection(v)
|
| 29 |
+
}
|
| 30 |
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
|
| 31 |
return adminshared.MergeAPIKeysPreferStructured(existing, incoming)
|
| 32 |
}
|
internal/httpapi/admin/configmgmt/handler_config_write.go
CHANGED
|
@@ -82,6 +82,7 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
|
|
| 82 |
key = strings.TrimSpace(key)
|
| 83 |
name := fieldString(req, "name")
|
| 84 |
remark := fieldString(req, "remark")
|
|
|
|
| 85 |
if key == "" {
|
| 86 |
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"})
|
| 87 |
return
|
|
@@ -92,7 +93,12 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
|
|
| 92 |
return fmt.Errorf("key 已存在")
|
| 93 |
}
|
| 94 |
}
|
| 95 |
-
c.APIKeys = append(c.APIKeys, config.APIKey{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
return nil
|
| 97 |
})
|
| 98 |
if err != nil {
|
|
@@ -116,6 +122,7 @@ func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) {
|
|
| 116 |
}
|
| 117 |
name, nameOK := fieldStringOptional(req, "name")
|
| 118 |
remark, remarkOK := fieldStringOptional(req, "remark")
|
|
|
|
| 119 |
|
| 120 |
err := h.Store.Update(func(c *config.Config) error {
|
| 121 |
idx := -1
|
|
@@ -134,6 +141,10 @@ func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) {
|
|
| 134 |
if remarkOK {
|
| 135 |
c.APIKeys[idx].Remark = remark
|
| 136 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
return nil
|
| 138 |
})
|
| 139 |
if err != nil {
|
|
|
|
| 82 |
key = strings.TrimSpace(key)
|
| 83 |
name := fieldString(req, "name")
|
| 84 |
remark := fieldString(req, "remark")
|
| 85 |
+
injection := toAPIKeyInjection(req["thinking_injection"])
|
| 86 |
if key == "" {
|
| 87 |
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"})
|
| 88 |
return
|
|
|
|
| 93 |
return fmt.Errorf("key 已存在")
|
| 94 |
}
|
| 95 |
}
|
| 96 |
+
c.APIKeys = append(c.APIKeys, config.APIKey{
|
| 97 |
+
Key: key,
|
| 98 |
+
Name: name,
|
| 99 |
+
Remark: remark,
|
| 100 |
+
ThinkingInjection: injection,
|
| 101 |
+
})
|
| 102 |
return nil
|
| 103 |
})
|
| 104 |
if err != nil {
|
|
|
|
| 122 |
}
|
| 123 |
name, nameOK := fieldStringOptional(req, "name")
|
| 124 |
remark, remarkOK := fieldStringOptional(req, "remark")
|
| 125 |
+
injectionRaw, injectionOK := req["thinking_injection"]
|
| 126 |
|
| 127 |
err := h.Store.Update(func(c *config.Config) error {
|
| 128 |
idx := -1
|
|
|
|
| 141 |
if remarkOK {
|
| 142 |
c.APIKeys[idx].Remark = remark
|
| 143 |
}
|
| 144 |
+
if injectionOK {
|
| 145 |
+
// 显式传 null / 空对象表示清除自定义,回到跟随全局。
|
| 146 |
+
c.APIKeys[idx].ThinkingInjection = toAPIKeyInjection(injectionRaw)
|
| 147 |
+
}
|
| 148 |
return nil
|
| 149 |
})
|
| 150 |
if err != nil {
|
internal/httpapi/admin/configmgmt/handler_keys_test.go
CHANGED
|
@@ -74,3 +74,57 @@ func TestKeyEndpointsPreserveStructuredMetadata(t *testing.T) {
|
|
| 74 |
t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys)
|
| 75 |
}
|
| 76 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys)
|
| 75 |
}
|
| 76 |
}
|
| 77 |
+
|
| 78 |
+
func TestKeyEndpointsPersistThinkingInjectionOverride(t *testing.T) {
|
| 79 |
+
h := newAdminTestHandler(t, `{"api_keys":[{"key":"k1","name":"primary"}]}`)
|
| 80 |
+
|
| 81 |
+
r := chi.NewRouter()
|
| 82 |
+
r.Post("/admin/keys", h.addKey)
|
| 83 |
+
r.Put("/admin/keys/{key}", h.updateKey)
|
| 84 |
+
|
| 85 |
+
addBody := []byte(`{"key":"k2","thinking_injection":{"custom":true,"enabled":true,"prompt":" custom prompt "}}`)
|
| 86 |
+
addRec := httptest.NewRecorder()
|
| 87 |
+
r.ServeHTTP(addRec, httptest.NewRequest(http.MethodPost, "/admin/keys", bytes.NewReader(addBody)))
|
| 88 |
+
if addRec.Code != http.StatusOK {
|
| 89 |
+
t.Fatalf("add status=%d body=%s", addRec.Code, addRec.Body.String())
|
| 90 |
+
}
|
| 91 |
+
snap := h.Store.Snapshot()
|
| 92 |
+
if len(snap.APIKeys) != 2 {
|
| 93 |
+
t.Fatalf("unexpected api keys after add: %#v", snap.APIKeys)
|
| 94 |
+
}
|
| 95 |
+
injection := snap.APIKeys[1].ThinkingInjection
|
| 96 |
+
if injection == nil || !injection.Custom || injection.Enabled == nil || !*injection.Enabled || injection.Prompt != "custom prompt" {
|
| 97 |
+
t.Fatalf("unexpected injection after add: %#v", injection)
|
| 98 |
+
}
|
| 99 |
+
if snap.APIKeys[0].ThinkingInjection != nil {
|
| 100 |
+
t.Fatalf("expected existing key to stay without injection, got %#v", snap.APIKeys[0].ThinkingInjection)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// 只覆盖开关,提示词应保持原值。
|
| 104 |
+
updateBody := []byte(`{"thinking_injection":{"custom":true,"enabled":false}}`)
|
| 105 |
+
updateRec := httptest.NewRecorder()
|
| 106 |
+
r.ServeHTTP(updateRec, httptest.NewRequest(http.MethodPut, "/admin/keys/k2", bytes.NewReader(updateBody)))
|
| 107 |
+
if updateRec.Code != http.StatusOK {
|
| 108 |
+
t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String())
|
| 109 |
+
}
|
| 110 |
+
snap = h.Store.Snapshot()
|
| 111 |
+
injection = snap.APIKeys[1].ThinkingInjection
|
| 112 |
+
if injection == nil || injection.Enabled == nil || *injection.Enabled {
|
| 113 |
+
t.Fatalf("expected disabled injection after update: %#v", injection)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// 传 null 清除自定义,回到跟随全局。
|
| 117 |
+
clearBody := []byte(`{"thinking_injection":null}`)
|
| 118 |
+
clearRec := httptest.NewRecorder()
|
| 119 |
+
r.ServeHTTP(clearRec, httptest.NewRequest(http.MethodPut, "/admin/keys/k2", bytes.NewReader(clearBody)))
|
| 120 |
+
if clearRec.Code != http.StatusOK {
|
| 121 |
+
t.Fatalf("clear status=%d body=%s", clearRec.Code, clearRec.Body.String())
|
| 122 |
+
}
|
| 123 |
+
snap = h.Store.Snapshot()
|
| 124 |
+
if snap.APIKeys[1].ThinkingInjection != nil {
|
| 125 |
+
t.Fatalf("expected injection cleared, got %#v", snap.APIKeys[1].ThinkingInjection)
|
| 126 |
+
}
|
| 127 |
+
if snap.APIKeys[1].Name != "" {
|
| 128 |
+
t.Fatalf("expected name untouched by injection update, got %q", snap.APIKeys[1].Name)
|
| 129 |
+
}
|
| 130 |
+
}
|
internal/httpapi/admin/handler.go
CHANGED
|
@@ -13,6 +13,7 @@ import (
|
|
| 13 |
adminrawsamples "ds2api/internal/httpapi/admin/rawsamples"
|
| 14 |
adminsettings "ds2api/internal/httpapi/admin/settings"
|
| 15 |
adminshared "ds2api/internal/httpapi/admin/shared"
|
|
|
|
| 16 |
adminvercel "ds2api/internal/httpapi/admin/vercel"
|
| 17 |
adminversion "ds2api/internal/httpapi/admin/version"
|
| 18 |
"ds2api/internal/proxyhealth"
|
|
@@ -37,6 +38,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
|
| 37 |
rawSamplesHandler := &adminrawsamples.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 38 |
vercelHandler := &adminvercel.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 39 |
historyHandler := &adminhistory.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
|
|
|
| 40 |
devCaptureHandler := &admindevcapture.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 41 |
versionHandler := &adminversion.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 42 |
|
|
@@ -46,6 +48,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
|
| 46 |
adminauth.RegisterProtectedRoutes(pr, authHandler)
|
| 47 |
adminconfig.RegisterRoutes(pr, configHandler)
|
| 48 |
adminsettings.RegisterRoutes(pr, settingsHandler)
|
|
|
|
| 49 |
adminproxies.RegisterRoutes(pr, proxiesHandler)
|
| 50 |
adminaccounts.RegisterRoutes(pr, accountsHandler)
|
| 51 |
adminrawsamples.RegisterRoutes(pr, rawSamplesHandler)
|
|
|
|
| 13 |
adminrawsamples "ds2api/internal/httpapi/admin/rawsamples"
|
| 14 |
adminsettings "ds2api/internal/httpapi/admin/settings"
|
| 15 |
adminshared "ds2api/internal/httpapi/admin/shared"
|
| 16 |
+
adminskills "ds2api/internal/httpapi/admin/skills"
|
| 17 |
adminvercel "ds2api/internal/httpapi/admin/vercel"
|
| 18 |
adminversion "ds2api/internal/httpapi/admin/version"
|
| 19 |
"ds2api/internal/proxyhealth"
|
|
|
|
| 38 |
rawSamplesHandler := &adminrawsamples.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 39 |
vercelHandler := &adminvercel.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 40 |
historyHandler := &adminhistory.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 41 |
+
skillsHandler := &adminskills.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 42 |
devCaptureHandler := &admindevcapture.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 43 |
versionHandler := &adminversion.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
|
| 44 |
|
|
|
|
| 48 |
adminauth.RegisterProtectedRoutes(pr, authHandler)
|
| 49 |
adminconfig.RegisterRoutes(pr, configHandler)
|
| 50 |
adminsettings.RegisterRoutes(pr, settingsHandler)
|
| 51 |
+
adminskills.RegisterRoutes(pr, skillsHandler)
|
| 52 |
adminproxies.RegisterRoutes(pr, proxiesHandler)
|
| 53 |
adminaccounts.RegisterRoutes(pr, accountsHandler)
|
| 54 |
adminrawsamples.RegisterRoutes(pr, rawSamplesHandler)
|
internal/httpapi/admin/handler_settings_test.go
CHANGED
|
@@ -47,6 +47,98 @@ func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) {
|
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
func TestGetSettingsIncludesCurrentInputFileDefaults(t *testing.T) {
|
| 51 |
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
| 52 |
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
|
|
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
| 50 |
+
func TestGetSettingsIncludesToolsDefaults(t *testing.T) {
|
| 51 |
+
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
| 52 |
+
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
| 53 |
+
rec := httptest.NewRecorder()
|
| 54 |
+
h.getSettings(rec, req)
|
| 55 |
+
if rec.Code != http.StatusOK {
|
| 56 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 57 |
+
}
|
| 58 |
+
var body map[string]any
|
| 59 |
+
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
| 60 |
+
tools, ok := body["tools"].(map[string]any)
|
| 61 |
+
if !ok {
|
| 62 |
+
t.Fatalf("expected top-level tools section, body=%v", body)
|
| 63 |
+
}
|
| 64 |
+
if got := boolFrom(tools["enabled"]); got {
|
| 65 |
+
t.Fatalf("expected tools.enabled=false by default, body=%v", body)
|
| 66 |
+
}
|
| 67 |
+
if enabled, exists := tools["default_enabled"]; !exists || boolFrom(enabled) {
|
| 68 |
+
t.Fatalf("expected tools.default_enabled=false, body=%v", body)
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
func TestUpdateSettingsToolsEnabled(t *testing.T) {
|
| 73 |
+
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
| 74 |
+
if h.Store.ToolsEnabled() {
|
| 75 |
+
t.Fatal("expected tools disabled before update")
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
payload := map[string]any{"tools": map[string]any{"enabled": true}}
|
| 79 |
+
b, _ := json.Marshal(payload)
|
| 80 |
+
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
|
| 81 |
+
rec := httptest.NewRecorder()
|
| 82 |
+
h.updateSettings(rec, req)
|
| 83 |
+
if rec.Code != http.StatusOK {
|
| 84 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 85 |
+
}
|
| 86 |
+
snap := h.Store.Snapshot()
|
| 87 |
+
if snap.Tools.Enabled == nil || !*snap.Tools.Enabled {
|
| 88 |
+
t.Fatalf("expected tools.enabled=true persisted, got %#v", snap.Tools)
|
| 89 |
+
}
|
| 90 |
+
if !h.Store.ToolsEnabled() {
|
| 91 |
+
t.Fatal("expected tools accessor to reflect enabled config")
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// 关闭同样要能持久化。
|
| 95 |
+
payload = map[string]any{"tools": map[string]any{"enabled": false}}
|
| 96 |
+
b, _ = json.Marshal(payload)
|
| 97 |
+
req = httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
|
| 98 |
+
rec = httptest.NewRecorder()
|
| 99 |
+
h.updateSettings(rec, req)
|
| 100 |
+
if rec.Code != http.StatusOK {
|
| 101 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 102 |
+
}
|
| 103 |
+
if h.Store.ToolsEnabled() {
|
| 104 |
+
t.Fatal("expected tools disabled after explicit false")
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
func TestUpdateSettingsWithoutToolsKeepsToolsConfig(t *testing.T) {
|
| 109 |
+
h := newAdminTestHandler(t, `{"keys":["k1"],"tools":{"enabled":true}}`)
|
| 110 |
+
if !h.Store.ToolsEnabled() {
|
| 111 |
+
t.Fatal("expected tools enabled from config")
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
payload := map[string]any{"responses": map[string]any{"store_ttl_seconds": 600}}
|
| 115 |
+
b, _ := json.Marshal(payload)
|
| 116 |
+
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
|
| 117 |
+
rec := httptest.NewRecorder()
|
| 118 |
+
h.updateSettings(rec, req)
|
| 119 |
+
if rec.Code != http.StatusOK {
|
| 120 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 121 |
+
}
|
| 122 |
+
if !h.Store.ToolsEnabled() {
|
| 123 |
+
t.Fatal("expected tools config untouched when the request omits the section")
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
func TestUpdateSettingsEmptyToolsSectionKeepsValue(t *testing.T) {
|
| 128 |
+
h := newAdminTestHandler(t, `{"keys":["k1"],"tools":{"enabled":true}}`)
|
| 129 |
+
payload := map[string]any{"tools": map[string]any{}}
|
| 130 |
+
b, _ := json.Marshal(payload)
|
| 131 |
+
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
|
| 132 |
+
rec := httptest.NewRecorder()
|
| 133 |
+
h.updateSettings(rec, req)
|
| 134 |
+
if rec.Code != http.StatusOK {
|
| 135 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 136 |
+
}
|
| 137 |
+
if !h.Store.ToolsEnabled() {
|
| 138 |
+
t.Fatal("expected empty tools section to keep the existing value")
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
func TestGetSettingsIncludesCurrentInputFileDefaults(t *testing.T) {
|
| 143 |
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
| 144 |
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
internal/httpapi/admin/history/chat_history_filter.go
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package history
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"sort"
|
| 7 |
+
"strconv"
|
| 8 |
+
"strings"
|
| 9 |
+
|
| 10 |
+
"ds2api/internal/auth"
|
| 11 |
+
"ds2api/internal/chathistory"
|
| 12 |
+
"ds2api/internal/config"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// callerOption 是后台「按 API Key 筛选」下拉框里的一项。
|
| 16 |
+
// Configured 为 true 表示该 Key 当前还登记在配置里;记录已被删除但配置仍在时
|
| 17 |
+
// Count 为 0,便于运维区分「没用过」和「Key 已注销」。
|
| 18 |
+
type callerOption struct {
|
| 19 |
+
CallerID string `json:"caller_id"`
|
| 20 |
+
Label string `json:"label"`
|
| 21 |
+
Configured bool `json:"configured"`
|
| 22 |
+
Count int `json:"count"`
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// chatHistoryFilter 是 /admin/chat-history 列表与导出接口共用的筛选/分页参数。
|
| 26 |
+
// Offset 与 Limit 都为 0 时表示不分页;Limit<=0 表示不限制条数。
|
| 27 |
+
type chatHistoryFilter struct {
|
| 28 |
+
Caller string
|
| 29 |
+
Status string
|
| 30 |
+
Surface string
|
| 31 |
+
Query string
|
| 32 |
+
Offset int
|
| 33 |
+
Limit int
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func parseChatHistoryFilter(r *http.Request) chatHistoryFilter {
|
| 37 |
+
if r == nil || r.URL == nil {
|
| 38 |
+
return chatHistoryFilter{}
|
| 39 |
+
}
|
| 40 |
+
q := r.URL.Query()
|
| 41 |
+
return chatHistoryFilter{
|
| 42 |
+
Caller: strings.TrimSpace(q.Get("caller")),
|
| 43 |
+
Status: strings.TrimSpace(q.Get("status")),
|
| 44 |
+
Surface: strings.TrimSpace(q.Get("surface")),
|
| 45 |
+
Query: strings.TrimSpace(q.Get("q")),
|
| 46 |
+
Offset: nonNegativeInt(q.Get("offset")),
|
| 47 |
+
Limit: nonNegativeInt(q.Get("limit")),
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// nonNegativeInt 解析非负整数查询参数;非法或负值一律按 0 处理。
|
| 52 |
+
func nonNegativeInt(raw string) int {
|
| 53 |
+
raw = strings.TrimSpace(raw)
|
| 54 |
+
if raw == "" {
|
| 55 |
+
return 0
|
| 56 |
+
}
|
| 57 |
+
n, err := strconv.Atoi(raw)
|
| 58 |
+
if err != nil || n < 0 {
|
| 59 |
+
return 0
|
| 60 |
+
}
|
| 61 |
+
return n
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// active 表示请求带了筛选或分页参数。这类响应内容随查询条件变化,
|
| 65 |
+
// 不能再用「按 revision 生成」的列表 ETag,因此必须跳过 304 协商。
|
| 66 |
+
func (f chatHistoryFilter) active() bool {
|
| 67 |
+
return f.Caller != "" || f.Status != "" || f.Surface != "" || f.Query != "" || f.Offset > 0 || f.Limit > 0
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// describe 把筛选条件渲染成一行可读描述,供导出文件的元信息使用。
|
| 71 |
+
func (f chatHistoryFilter) describe() string {
|
| 72 |
+
parts := make([]string, 0, 6)
|
| 73 |
+
if f.Caller != "" {
|
| 74 |
+
parts = append(parts, "caller="+f.Caller)
|
| 75 |
+
}
|
| 76 |
+
if f.Status != "" {
|
| 77 |
+
parts = append(parts, "status="+f.Status)
|
| 78 |
+
}
|
| 79 |
+
if f.Surface != "" {
|
| 80 |
+
parts = append(parts, "surface="+f.Surface)
|
| 81 |
+
}
|
| 82 |
+
if f.Query != "" {
|
| 83 |
+
parts = append(parts, "q="+f.Query)
|
| 84 |
+
}
|
| 85 |
+
if f.Offset > 0 {
|
| 86 |
+
parts = append(parts, fmt.Sprintf("offset=%d", f.Offset))
|
| 87 |
+
}
|
| 88 |
+
if f.Limit > 0 {
|
| 89 |
+
parts = append(parts, fmt.Sprintf("limit=%d", f.Limit))
|
| 90 |
+
}
|
| 91 |
+
return strings.Join(parts, ", ")
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// filterChatHistory 按 caller/status/surface/q 过滤摘要列表,保持原有排序。
|
| 95 |
+
func filterChatHistory(store *chathistory.Store, items []chathistory.SummaryEntry, f chatHistoryFilter) []chathistory.SummaryEntry {
|
| 96 |
+
out := make([]chathistory.SummaryEntry, 0, len(items))
|
| 97 |
+
needle := strings.ToLower(strings.TrimSpace(f.Query))
|
| 98 |
+
for _, item := range items {
|
| 99 |
+
if f.Caller != "" && item.CallerID != f.Caller {
|
| 100 |
+
continue
|
| 101 |
+
}
|
| 102 |
+
if f.Status != "" && !strings.EqualFold(item.Status, f.Status) {
|
| 103 |
+
continue
|
| 104 |
+
}
|
| 105 |
+
if f.Surface != "" && !strings.EqualFold(item.Surface, f.Surface) {
|
| 106 |
+
continue
|
| 107 |
+
}
|
| 108 |
+
if needle != "" && !chatHistoryMatchesText(store, item, needle) {
|
| 109 |
+
continue
|
| 110 |
+
}
|
| 111 |
+
out = append(out, item)
|
| 112 |
+
}
|
| 113 |
+
return out
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// chatHistoryMatchesText 在 UserInput / Preview / Model / CallerLabel / Error 上做
|
| 117 |
+
// 不区分大小写的包含匹配。Error 不在摘要里,需要回查内存中的完整条目。
|
| 118 |
+
func chatHistoryMatchesText(store *chathistory.Store, item chathistory.SummaryEntry, needle string) bool {
|
| 119 |
+
for _, field := range []string{item.UserInput, item.Preview, item.Model, item.CallerLabel} {
|
| 120 |
+
if strings.Contains(strings.ToLower(field), needle) {
|
| 121 |
+
return true
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
if store == nil {
|
| 125 |
+
return false
|
| 126 |
+
}
|
| 127 |
+
entry, err := store.Get(item.ID)
|
| 128 |
+
if err != nil {
|
| 129 |
+
return false
|
| 130 |
+
}
|
| 131 |
+
return strings.Contains(strings.ToLower(entry.Error), needle)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// paginateChatHistory 对已过滤的结果应用 offset/limit;返回的切片永远非 nil。
|
| 135 |
+
func paginateChatHistory(items []chathistory.SummaryEntry, f chatHistoryFilter) []chathistory.SummaryEntry {
|
| 136 |
+
start := f.Offset
|
| 137 |
+
if start < 0 {
|
| 138 |
+
start = 0
|
| 139 |
+
}
|
| 140 |
+
if start > len(items) {
|
| 141 |
+
start = len(items)
|
| 142 |
+
}
|
| 143 |
+
end := len(items)
|
| 144 |
+
if f.Limit > 0 && start+f.Limit < end {
|
| 145 |
+
end = start + f.Limit
|
| 146 |
+
}
|
| 147 |
+
page := make([]chathistory.SummaryEntry, end-start)
|
| 148 |
+
copy(page, items[start:end])
|
| 149 |
+
return page
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// buildCallerOptions 汇总后台可筛选的 CallerID:
|
| 153 |
+
// 记录里出现过的 CallerID + 配置里已登记但还没有记录的 API Key。
|
| 154 |
+
// 排序为 count 倒序、label 升序,最后用 caller_id 兜底保证顺序稳定。
|
| 155 |
+
func buildCallerOptions(items []chathistory.SummaryEntry, keys []config.APIKey) []callerOption {
|
| 156 |
+
options := make([]callerOption, 0, len(items)+len(keys))
|
| 157 |
+
index := make(map[string]int, len(items)+len(keys))
|
| 158 |
+
for _, item := range items {
|
| 159 |
+
callerID := strings.TrimSpace(item.CallerID)
|
| 160 |
+
if callerID == "" {
|
| 161 |
+
continue
|
| 162 |
+
}
|
| 163 |
+
if pos, ok := index[callerID]; ok {
|
| 164 |
+
options[pos].Count++
|
| 165 |
+
if options[pos].Label == "" {
|
| 166 |
+
options[pos].Label = strings.TrimSpace(item.CallerLabel)
|
| 167 |
+
}
|
| 168 |
+
continue
|
| 169 |
+
}
|
| 170 |
+
index[callerID] = len(options)
|
| 171 |
+
options = append(options, callerOption{
|
| 172 |
+
CallerID: callerID,
|
| 173 |
+
Label: strings.TrimSpace(item.CallerLabel),
|
| 174 |
+
Count: 1,
|
| 175 |
+
})
|
| 176 |
+
}
|
| 177 |
+
for _, key := range keys {
|
| 178 |
+
if strings.TrimSpace(key.Key) == "" {
|
| 179 |
+
continue
|
| 180 |
+
}
|
| 181 |
+
callerID := auth.CallerTokenID(key.Key)
|
| 182 |
+
if callerID == "" {
|
| 183 |
+
continue
|
| 184 |
+
}
|
| 185 |
+
label := callerLabelFromConfig(key)
|
| 186 |
+
if pos, ok := index[callerID]; ok {
|
| 187 |
+
options[pos].Configured = true
|
| 188 |
+
if options[pos].Label == "" {
|
| 189 |
+
options[pos].Label = label
|
| 190 |
+
}
|
| 191 |
+
continue
|
| 192 |
+
}
|
| 193 |
+
index[callerID] = len(options)
|
| 194 |
+
options = append(options, callerOption{
|
| 195 |
+
CallerID: callerID,
|
| 196 |
+
Label: label,
|
| 197 |
+
Configured: true,
|
| 198 |
+
})
|
| 199 |
+
}
|
| 200 |
+
sort.Slice(options, func(i, j int) bool {
|
| 201 |
+
if options[i].Count != options[j].Count {
|
| 202 |
+
return options[i].Count > options[j].Count
|
| 203 |
+
}
|
| 204 |
+
if options[i].Label != options[j].Label {
|
| 205 |
+
return options[i].Label < options[j].Label
|
| 206 |
+
}
|
| 207 |
+
return options[i].CallerID < options[j].CallerID
|
| 208 |
+
})
|
| 209 |
+
return options
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// callerLabelFromConfig 与 auth 包的标签规则保持一致:名称 → 备注 → 脱敏 Key。
|
| 213 |
+
func callerLabelFromConfig(item config.APIKey) string {
|
| 214 |
+
if name := strings.TrimSpace(item.Name); name != "" {
|
| 215 |
+
return name
|
| 216 |
+
}
|
| 217 |
+
if remark := strings.TrimSpace(item.Remark); remark != "" {
|
| 218 |
+
return remark
|
| 219 |
+
}
|
| 220 |
+
return config.MaskAPIKeyPreview(item.Key)
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// configAPIKeys 返回配置里登记的 API Key;配置不可用时返回 nil。
|
| 224 |
+
func (h *Handler) configAPIKeys() []config.APIKey {
|
| 225 |
+
if h == nil || h.Store == nil {
|
| 226 |
+
return nil
|
| 227 |
+
}
|
| 228 |
+
return h.Store.Snapshot().APIKeys
|
| 229 |
+
}
|
internal/httpapi/admin/history/handler_chat_history.go
CHANGED
|
@@ -16,8 +16,11 @@ func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) {
|
|
| 16 |
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
|
| 17 |
return
|
| 18 |
}
|
|
|
|
|
|
|
|
|
|
| 19 |
ifNoneMatch := strings.TrimSpace(r.Header.Get("If-None-Match"))
|
| 20 |
-
if ifNoneMatch != "" {
|
| 21 |
revision, err := store.Revision()
|
| 22 |
if err != nil {
|
| 23 |
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
|
@@ -42,18 +45,23 @@ func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) {
|
|
| 42 |
})
|
| 43 |
return
|
| 44 |
}
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
}
|
|
|
|
| 52 |
writeJSON(w, http.StatusOK, map[string]any{
|
| 53 |
"version": snapshot.Version,
|
| 54 |
"limit": snapshot.Limit,
|
| 55 |
"revision": snapshot.Revision,
|
| 56 |
-
"items":
|
|
|
|
|
|
|
| 57 |
"path": store.Path(),
|
| 58 |
})
|
| 59 |
}
|
|
|
|
| 16 |
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
|
| 17 |
return
|
| 18 |
}
|
| 19 |
+
filter := parseChatHistoryFilter(r)
|
| 20 |
+
// 筛选/分页请求的响应内容随查询条件变化,不能复用按 revision 生成的 ETag。
|
| 21 |
+
useListETag := !filter.active()
|
| 22 |
ifNoneMatch := strings.TrimSpace(r.Header.Get("If-None-Match"))
|
| 23 |
+
if useListETag && ifNoneMatch != "" {
|
| 24 |
revision, err := store.Revision()
|
| 25 |
if err != nil {
|
| 26 |
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
|
|
|
| 45 |
})
|
| 46 |
return
|
| 47 |
}
|
| 48 |
+
if !filter.active() {
|
| 49 |
+
etag := chathistory.ListETag(snapshot.Revision)
|
| 50 |
+
w.Header().Set("ETag", etag)
|
| 51 |
+
w.Header().Set("Cache-Control", "no-cache")
|
| 52 |
+
if ifNoneMatch == etag {
|
| 53 |
+
w.WriteHeader(http.StatusNotModified)
|
| 54 |
+
return
|
| 55 |
+
}
|
| 56 |
}
|
| 57 |
+
filtered := filterChatHistory(store, snapshot.Items, filter)
|
| 58 |
writeJSON(w, http.StatusOK, map[string]any{
|
| 59 |
"version": snapshot.Version,
|
| 60 |
"limit": snapshot.Limit,
|
| 61 |
"revision": snapshot.Revision,
|
| 62 |
+
"items": paginateChatHistory(filtered, filter),
|
| 63 |
+
"total": len(filtered),
|
| 64 |
+
"callers": buildCallerOptions(snapshot.Items, h.configAPIKeys()),
|
| 65 |
"path": store.Path(),
|
| 66 |
})
|
| 67 |
}
|
internal/httpapi/admin/history/handler_chat_history_export.go
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package history
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"net/http"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"ds2api/internal/chathistory"
|
| 11 |
+
"ds2api/internal/config"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
const (
|
| 15 |
+
exportFormatJSON = "json"
|
| 16 |
+
exportFormatMarkdown = "markdown"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
// chatHistoryExportPayload 是导出 JSON 的顶层结构,字段顺序即落盘顺序。
|
| 20 |
+
type chatHistoryExportPayload struct {
|
| 21 |
+
ExportedAt string `json:"exported_at"`
|
| 22 |
+
Count int `json:"count"`
|
| 23 |
+
Filters chatHistoryExportFilters `json:"filters"`
|
| 24 |
+
Items []chathistory.Entry `json:"items"`
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// chatHistoryExportFilters 原样回显本次导出的筛选条件,便于复现同一份结果。
|
| 28 |
+
type chatHistoryExportFilters struct {
|
| 29 |
+
Caller string `json:"caller,omitempty"`
|
| 30 |
+
Status string `json:"status,omitempty"`
|
| 31 |
+
Surface string `json:"surface,omitempty"`
|
| 32 |
+
Query string `json:"q,omitempty"`
|
| 33 |
+
Offset int `json:"offset"`
|
| 34 |
+
Limit int `json:"limit"`
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func exportFiltersFrom(f chatHistoryFilter) chatHistoryExportFilters {
|
| 38 |
+
return chatHistoryExportFilters{
|
| 39 |
+
Caller: f.Caller,
|
| 40 |
+
Status: f.Status,
|
| 41 |
+
Surface: f.Surface,
|
| 42 |
+
Query: f.Query,
|
| 43 |
+
Offset: f.Offset,
|
| 44 |
+
Limit: f.Limit,
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// exportChatHistory 以文件下载的形式导出完整响应记录(json 或 markdown)。
|
| 49 |
+
func (h *Handler) exportChatHistory(w http.ResponseWriter, r *http.Request) {
|
| 50 |
+
store := h.ChatHistory
|
| 51 |
+
if store == nil {
|
| 52 |
+
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
|
| 53 |
+
return
|
| 54 |
+
}
|
| 55 |
+
format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format")))
|
| 56 |
+
if format == "" {
|
| 57 |
+
format = exportFormatJSON
|
| 58 |
+
}
|
| 59 |
+
if format != exportFormatJSON && format != exportFormatMarkdown {
|
| 60 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "unsupported export format: " + format})
|
| 61 |
+
return
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
filter := parseChatHistoryFilter(r)
|
| 65 |
+
snapshot, err := store.Snapshot()
|
| 66 |
+
if err != nil {
|
| 67 |
+
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
| 68 |
+
"detail": err.Error(),
|
| 69 |
+
"path": store.Path(),
|
| 70 |
+
})
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
summaries := paginateChatHistory(filterChatHistory(store, snapshot.Items, filter), filter)
|
| 74 |
+
entries := make([]chathistory.Entry, 0, len(summaries))
|
| 75 |
+
for _, item := range summaries {
|
| 76 |
+
entry, err := store.Get(item.ID)
|
| 77 |
+
if err != nil {
|
| 78 |
+
// 列表与明细之间可能刚被删除;导出忽略取不到的条目。
|
| 79 |
+
continue
|
| 80 |
+
}
|
| 81 |
+
entries = append(entries, entry)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
now := time.Now()
|
| 85 |
+
ext := ".json"
|
| 86 |
+
contentType := "application/json"
|
| 87 |
+
var body []byte
|
| 88 |
+
switch format {
|
| 89 |
+
case exportFormatMarkdown:
|
| 90 |
+
ext = ".md"
|
| 91 |
+
contentType = "text/markdown; charset=utf-8"
|
| 92 |
+
body = []byte(renderChatHistoryMarkdown(now, filter, entries))
|
| 93 |
+
default:
|
| 94 |
+
payload, err := json.MarshalIndent(chatHistoryExportPayload{
|
| 95 |
+
ExportedAt: now.Format(time.RFC3339),
|
| 96 |
+
Count: len(entries),
|
| 97 |
+
Filters: exportFiltersFrom(filter),
|
| 98 |
+
Items: entries,
|
| 99 |
+
}, "", " ")
|
| 100 |
+
if err != nil {
|
| 101 |
+
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
| 102 |
+
"detail": "encode chat history export: " + err.Error(),
|
| 103 |
+
})
|
| 104 |
+
return
|
| 105 |
+
}
|
| 106 |
+
body = append(payload, '\n')
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
w.Header().Set("Content-Type", contentType)
|
| 110 |
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q",
|
| 111 |
+
"ds2api-logs-"+now.Format("20060102-150405")+ext))
|
| 112 |
+
w.Header().Set("Cache-Control", "no-store")
|
| 113 |
+
w.WriteHeader(http.StatusOK)
|
| 114 |
+
if _, err := w.Write(body); err != nil {
|
| 115 |
+
config.Logger.Warn("[admin] chat history export write failed", "format", format, "error", err)
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// renderChatHistoryMarkdown 把一批完整记录渲染成 markdown 报告。
|
| 120 |
+
func renderChatHistoryMarkdown(exportedAt time.Time, filter chatHistoryFilter, entries []chathistory.Entry) string {
|
| 121 |
+
var b strings.Builder
|
| 122 |
+
b.WriteString("# ds2api 响应记录导出\n\n")
|
| 123 |
+
fmt.Fprintf(&b, "- 导出时间: %s\n", exportedAt.UTC().Format(time.RFC3339))
|
| 124 |
+
fmt.Fprintf(&b, "- 条数: %d\n", len(entries))
|
| 125 |
+
if summary := filter.describe(); summary != "" {
|
| 126 |
+
fmt.Fprintf(&b, "- 筛选条件: %s\n", summary)
|
| 127 |
+
}
|
| 128 |
+
b.WriteString("\n")
|
| 129 |
+
for i, entry := range entries {
|
| 130 |
+
writeMarkdownEntry(&b, i+1, entry)
|
| 131 |
+
}
|
| 132 |
+
return b.String()
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
func writeMarkdownEntry(b *strings.Builder, index int, entry chathistory.Entry) {
|
| 136 |
+
fmt.Fprintf(b, "## %d. %s\n\n", index, entry.ID)
|
| 137 |
+
writeMarkdownMeta(b, entry)
|
| 138 |
+
writeMarkdownSection(b, "UserInput", entry.UserInput)
|
| 139 |
+
writeMarkdownSection(b, "FinalPrompt", entry.FinalPrompt)
|
| 140 |
+
writeMarkdownSection(b, "ReasoningContent", entry.ReasoningContent)
|
| 141 |
+
writeMarkdownSection(b, "Content", entry.Content)
|
| 142 |
+
writeMarkdownSection(b, "Error", entry.Error)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
func writeMarkdownMeta(b *strings.Builder, entry chathistory.Entry) {
|
| 146 |
+
status := strings.TrimSpace(entry.Status)
|
| 147 |
+
if entry.StatusCode != 0 {
|
| 148 |
+
status = fmt.Sprintf("%s (%d)", status, entry.StatusCode)
|
| 149 |
+
}
|
| 150 |
+
fmt.Fprintf(b, "- 时间: %s\n", formatArchiveTimestamp(entry.CreatedAt))
|
| 151 |
+
fmt.Fprintf(b, "- 接口: %s\n", markdownOrDash(entry.Surface))
|
| 152 |
+
fmt.Fprintf(b, "- 模型: %s\n", markdownOrDash(entry.Model))
|
| 153 |
+
fmt.Fprintf(b, "- 账号: %s\n", markdownOrDash(entry.AccountID))
|
| 154 |
+
fmt.Fprintf(b, "- CallerLabel: %s\n", describeCaller(entry))
|
| 155 |
+
fmt.Fprintf(b, "- 状态: %s\n", markdownOrDash(status))
|
| 156 |
+
if entry.ElapsedMs > 0 {
|
| 157 |
+
fmt.Fprintf(b, "- 耗时: %dms\n", entry.ElapsedMs)
|
| 158 |
+
}
|
| 159 |
+
if entry.Stream {
|
| 160 |
+
b.WriteString("- 流式: 是\n")
|
| 161 |
+
}
|
| 162 |
+
if finishReason := strings.TrimSpace(entry.FinishReason); finishReason != "" {
|
| 163 |
+
fmt.Fprintf(b, "- 结束原因: %s\n", finishReason)
|
| 164 |
+
}
|
| 165 |
+
b.WriteString("\n")
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// writeMarkdownSection 用代码块包裹长文本,空内容直接跳过避免噪声。
|
| 169 |
+
func writeMarkdownSection(b *strings.Builder, title, body string) {
|
| 170 |
+
body = strings.TrimSpace(body)
|
| 171 |
+
if body == "" {
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
fence := markdownFence(body)
|
| 175 |
+
fmt.Fprintf(b, "### %s\n\n%stext\n%s\n%s\n\n", title, fence, body, fence)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// markdownFence 在正文自身包含反引号围栏时自动加长围栏,避免提前闭合代码块。
|
| 179 |
+
func markdownFence(body string) string {
|
| 180 |
+
fence := "```"
|
| 181 |
+
for strings.Contains(body, fence) {
|
| 182 |
+
fence += "`"
|
| 183 |
+
}
|
| 184 |
+
return fence
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
func describeCaller(entry chathistory.Entry) string {
|
| 188 |
+
callerID := strings.TrimSpace(entry.CallerID)
|
| 189 |
+
label := strings.TrimSpace(entry.CallerLabel)
|
| 190 |
+
switch {
|
| 191 |
+
case label == "" && callerID == "":
|
| 192 |
+
return "-"
|
| 193 |
+
case label == "":
|
| 194 |
+
return callerID
|
| 195 |
+
case callerID == "":
|
| 196 |
+
return label
|
| 197 |
+
default:
|
| 198 |
+
return label + " (" + callerID + ")"
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
func formatArchiveTimestamp(ms int64) string {
|
| 203 |
+
if ms <= 0 {
|
| 204 |
+
return "-"
|
| 205 |
+
}
|
| 206 |
+
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
func markdownOrDash(value string) string {
|
| 210 |
+
if value = strings.TrimSpace(value); value == "" {
|
| 211 |
+
return "-"
|
| 212 |
+
}
|
| 213 |
+
return value
|
| 214 |
+
}
|
internal/httpapi/admin/history/handler_chat_history_filters_test.go
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package history
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/http/httptest"
|
| 7 |
+
"strings"
|
| 8 |
+
"testing"
|
| 9 |
+
|
| 10 |
+
"github.com/go-chi/chi/v5"
|
| 11 |
+
|
| 12 |
+
"ds2api/internal/auth"
|
| 13 |
+
"ds2api/internal/chathistory"
|
| 14 |
+
"ds2api/internal/config"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
const (
|
| 18 |
+
testKeyConfigured = "sk-live-aaaa1111"
|
| 19 |
+
testKeyUnused = "sk-unused-9999"
|
| 20 |
+
testCallerExternal = "caller:external"
|
| 21 |
+
testSurfaceOpenAI = "openai.chat_completions"
|
| 22 |
+
testSurfaceClaude = "claude.messages"
|
| 23 |
+
testConfiguredLabel = "生产 Key"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
// callerIDConfigured 是已登记 Key 对应的真实 CallerID(与鉴权层算法一致)。
|
| 27 |
+
var callerIDConfigured = auth.CallerTokenID(testKeyConfigured)
|
| 28 |
+
|
| 29 |
+
// seedHistoryEntry 创建一条记录并把它推进到终态。
|
| 30 |
+
func seedHistoryEntry(t *testing.T, store *chathistory.Store, start chathistory.StartParams, update chathistory.UpdateParams) chathistory.Entry {
|
| 31 |
+
t.Helper()
|
| 32 |
+
entry, err := store.Start(start)
|
| 33 |
+
if err != nil {
|
| 34 |
+
t.Fatalf("start history failed: %v", err)
|
| 35 |
+
}
|
| 36 |
+
if _, err := store.Update(entry.ID, update); err != nil {
|
| 37 |
+
t.Fatalf("update history failed: %v", err)
|
| 38 |
+
}
|
| 39 |
+
return entry
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func historyRouter(h *Handler) chi.Router {
|
| 43 |
+
r := chi.NewRouter()
|
| 44 |
+
RegisterRoutes(r, h)
|
| 45 |
+
return r
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func getHistoryJSON(t *testing.T, r chi.Router, target string, headers map[string]string) (int, map[string]any, http.Header) {
|
| 49 |
+
t.Helper()
|
| 50 |
+
req := httptest.NewRequest(http.MethodGet, target, nil)
|
| 51 |
+
req.Header.Set("Authorization", "Bearer admin")
|
| 52 |
+
for key, value := range headers {
|
| 53 |
+
req.Header.Set(key, value)
|
| 54 |
+
}
|
| 55 |
+
rec := httptest.NewRecorder()
|
| 56 |
+
r.ServeHTTP(rec, req)
|
| 57 |
+
payload := map[string]any{}
|
| 58 |
+
if rec.Code == http.StatusOK {
|
| 59 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
| 60 |
+
t.Fatalf("decode payload failed: %v body=%s", err, rec.Body.String())
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
return rec.Code, payload, rec.Header()
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
func payloadItems(t *testing.T, payload map[string]any) []any {
|
| 67 |
+
t.Helper()
|
| 68 |
+
items, ok := payload["items"].([]any)
|
| 69 |
+
if !ok {
|
| 70 |
+
t.Fatalf("expected items array, got %#v", payload["items"])
|
| 71 |
+
}
|
| 72 |
+
return items
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
func TestChatHistoryListFiltersAndCallers(t *testing.T) {
|
| 76 |
+
h, store := newChatHistoryAdminHarnessWithConfig(t, `{
|
| 77 |
+
"keys":["`+testKeyConfigured+`","`+testKeyUnused+`"],
|
| 78 |
+
"api_keys":[
|
| 79 |
+
{"key":"`+testKeyConfigured+`","name":"`+testConfiguredLabel+`","remark":"prod"},
|
| 80 |
+
{"key":"`+testKeyUnused+`"}
|
| 81 |
+
],
|
| 82 |
+
"accounts":[]
|
| 83 |
+
}`)
|
| 84 |
+
|
| 85 |
+
seedHistoryEntry(t, store,
|
| 86 |
+
chathistory.StartParams{
|
| 87 |
+
CallerID: callerIDConfigured,
|
| 88 |
+
CallerLabel: testConfiguredLabel,
|
| 89 |
+
Surface: testSurfaceOpenAI,
|
| 90 |
+
Model: "deepseek-v4-flash",
|
| 91 |
+
UserInput: "hello world",
|
| 92 |
+
},
|
| 93 |
+
chathistory.UpdateParams{Status: "success", Content: "answer one", Completed: true},
|
| 94 |
+
)
|
| 95 |
+
seedHistoryEntry(t, store,
|
| 96 |
+
chathistory.StartParams{
|
| 97 |
+
CallerID: testCallerExternal,
|
| 98 |
+
Surface: testSurfaceClaude,
|
| 99 |
+
Model: "deepseek-v4-pro",
|
| 100 |
+
UserInput: "second prompt",
|
| 101 |
+
},
|
| 102 |
+
chathistory.UpdateParams{
|
| 103 |
+
Status: "error",
|
| 104 |
+
Content: "device offline",
|
| 105 |
+
Error: "boom upstream",
|
| 106 |
+
Completed: true,
|
| 107 |
+
},
|
| 108 |
+
)
|
| 109 |
+
seedHistoryEntry(t, store,
|
| 110 |
+
chathistory.StartParams{
|
| 111 |
+
CallerID: callerIDConfigured,
|
| 112 |
+
CallerLabel: testConfiguredLabel,
|
| 113 |
+
Surface: testSurfaceOpenAI,
|
| 114 |
+
Model: "deepseek-v4-flash",
|
| 115 |
+
UserInput: "third prompt",
|
| 116 |
+
},
|
| 117 |
+
chathistory.UpdateParams{Status: "success", Content: "answer three", Completed: true},
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
r := historyRouter(h)
|
| 121 |
+
|
| 122 |
+
code, payload, header := getHistoryJSON(t, r, "/chat-history", nil)
|
| 123 |
+
if code != http.StatusOK {
|
| 124 |
+
t.Fatalf("expected 200, got %d", code)
|
| 125 |
+
}
|
| 126 |
+
if got := payloadItems(t, payload); len(got) != 3 {
|
| 127 |
+
t.Fatalf("expected 3 items, got %d", len(got))
|
| 128 |
+
}
|
| 129 |
+
if total, _ := payload["total"].(float64); total != 3 {
|
| 130 |
+
t.Fatalf("expected total=3, got %#v", payload["total"])
|
| 131 |
+
}
|
| 132 |
+
if header.Get("ETag") == "" {
|
| 133 |
+
t.Fatalf("unfiltered list must keep the etag header")
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// callers:配置里登记的 Key(含无记录的)+ 记录里出现过的 CallerID。
|
| 137 |
+
callers, ok := payload["callers"].([]any)
|
| 138 |
+
if !ok || len(callers) != 3 {
|
| 139 |
+
t.Fatalf("expected 3 callers, got %#v", payload["callers"])
|
| 140 |
+
}
|
| 141 |
+
first, _ := callers[0].(map[string]any)
|
| 142 |
+
if first["caller_id"] != callerIDConfigured || first["count"] != float64(2) || first["configured"] != true {
|
| 143 |
+
t.Fatalf("unexpected top caller: %#v", first)
|
| 144 |
+
}
|
| 145 |
+
if first["label"] != testConfiguredLabel {
|
| 146 |
+
t.Fatalf("expected configured label, got %#v", first["label"])
|
| 147 |
+
}
|
| 148 |
+
second, _ := callers[1].(map[string]any)
|
| 149 |
+
if second["caller_id"] != testCallerExternal || second["count"] != float64(1) || second["configured"] != false {
|
| 150 |
+
t.Fatalf("unexpected second caller: %#v", second)
|
| 151 |
+
}
|
| 152 |
+
third, _ := callers[2].(map[string]any)
|
| 153 |
+
unusedID := auth.CallerTokenID(testKeyUnused)
|
| 154 |
+
if third["caller_id"] != unusedID || third["count"] != float64(0) || third["configured"] != true {
|
| 155 |
+
t.Fatalf("expected unused configured key last, got %#v", third)
|
| 156 |
+
}
|
| 157 |
+
if third["label"] != config.MaskAPIKeyPreview(testKeyUnused) {
|
| 158 |
+
t.Fatalf("expected masked key label, got %#v", third["label"])
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 按 API Key 过滤
|
| 162 |
+
code, payload, header = getHistoryJSON(t, r, "/chat-history?caller="+callerIDConfigured, nil)
|
| 163 |
+
if code != http.StatusOK {
|
| 164 |
+
t.Fatalf("expected 200 for caller filter, got %d", code)
|
| 165 |
+
}
|
| 166 |
+
if got := payloadItems(t, payload); len(got) != 2 {
|
| 167 |
+
t.Fatalf("expected 2 items for caller filter, got %d", len(got))
|
| 168 |
+
}
|
| 169 |
+
if total, _ := payload["total"].(float64); total != 2 {
|
| 170 |
+
t.Fatalf("expected total=2 for caller filter, got %#v", payload["total"])
|
| 171 |
+
}
|
| 172 |
+
if header.Get("ETag") != "" {
|
| 173 |
+
t.Fatalf("filtered list must not emit a revision etag")
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// 按状态过滤
|
| 177 |
+
code, payload, _ = getHistoryJSON(t, r, "/chat-history?status=error", nil)
|
| 178 |
+
if code != http.StatusOK {
|
| 179 |
+
t.Fatalf("expected 200 for status filter, got %d", code)
|
| 180 |
+
}
|
| 181 |
+
if got := payloadItems(t, payload); len(got) != 1 {
|
| 182 |
+
t.Fatalf("expected 1 error item, got %d", len(got))
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// 按接口过滤
|
| 186 |
+
code, payload, _ = getHistoryJSON(t, r, "/chat-history?surface="+testSurfaceClaude, nil)
|
| 187 |
+
if code != http.StatusOK {
|
| 188 |
+
t.Fatalf("expected 200 for surface filter, got %d", code)
|
| 189 |
+
}
|
| 190 |
+
if got := payloadItems(t, payload); len(got) != 1 {
|
| 191 |
+
t.Fatalf("expected 1 claude item, got %d", len(got))
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// q 必须能命中 Error 字段(该条目的 Preview 是 content,不可能匹配到 boom)
|
| 195 |
+
code, payload, _ = getHistoryJSON(t, r, "/chat-history?q=BOOM", nil)
|
| 196 |
+
if code != http.StatusOK {
|
| 197 |
+
t.Fatalf("expected 200 for q filter, got %d", code)
|
| 198 |
+
}
|
| 199 |
+
if got := payloadItems(t, payload); len(got) != 1 {
|
| 200 |
+
t.Fatalf("expected 1 item matching error text, got %d", len(got))
|
| 201 |
+
}
|
| 202 |
+
if id, _ := payloadItems(t, payload)[0].(map[string]any)["status"]; id != "error" {
|
| 203 |
+
t.Fatalf("expected the error entry, got %#v", id)
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// q 也能命中模型名(不区分大小写)
|
| 207 |
+
code, payload, _ = getHistoryJSON(t, r, "/chat-history?q=V4-PRO", nil)
|
| 208 |
+
if code != http.StatusOK || len(payloadItems(t, payload)) != 1 {
|
| 209 |
+
t.Fatalf("expected model-name match, got code=%d payload=%#v", code, payload)
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// 分页:total 仍是筛选后的总数
|
| 213 |
+
code, payload, _ = getHistoryJSON(t, r, "/chat-history?offset=1&limit=1", nil)
|
| 214 |
+
if code != http.StatusOK {
|
| 215 |
+
t.Fatalf("expected 200 for paging, got %d", code)
|
| 216 |
+
}
|
| 217 |
+
if got := payloadItems(t, payload); len(got) != 1 {
|
| 218 |
+
t.Fatalf("expected 1 paged item, got %d", len(got))
|
| 219 |
+
}
|
| 220 |
+
if total, _ := payload["total"].(float64); total != 3 {
|
| 221 |
+
t.Fatalf("expected total=3 while paging, got %#v", payload["total"])
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// 带筛选参数时不允许 304
|
| 225 |
+
_, _, unfilteredHeader := getHistoryJSON(t, r, "/chat-history", nil)
|
| 226 |
+
code, _, _ = getHistoryJSON(t, r, "/chat-history?status=success",
|
| 227 |
+
map[string]string{"If-None-Match": unfilteredHeader.Get("ETag")})
|
| 228 |
+
if code != http.StatusOK {
|
| 229 |
+
t.Fatalf("filtered request must return 200 instead of 304, got %d", code)
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
func TestChatHistorySettingsAcceptsCustomLimit(t *testing.T) {
|
| 234 |
+
h, store := newChatHistoryAdminHarness(t)
|
| 235 |
+
r := historyRouter(h)
|
| 236 |
+
|
| 237 |
+
put := func(body string) *httptest.ResponseRecorder {
|
| 238 |
+
t.Helper()
|
| 239 |
+
req := httptest.NewRequest(http.MethodPut, "/chat-history/settings", strings.NewReader(body))
|
| 240 |
+
req.Header.Set("Authorization", "Bearer admin")
|
| 241 |
+
rec := httptest.NewRecorder()
|
| 242 |
+
r.ServeHTTP(rec, req)
|
| 243 |
+
return rec
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
rec := put(`{"limit":137}`)
|
| 247 |
+
if rec.Code != http.StatusOK {
|
| 248 |
+
t.Fatalf("expected 200 for limit=137, got %d body=%s", rec.Code, rec.Body.String())
|
| 249 |
+
}
|
| 250 |
+
var payload map[string]any
|
| 251 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
| 252 |
+
t.Fatalf("decode payload failed: %v", err)
|
| 253 |
+
}
|
| 254 |
+
if payload["success"] != true || payload["limit"] != float64(137) {
|
| 255 |
+
t.Fatalf("unexpected settings payload: %#v", payload)
|
| 256 |
+
}
|
| 257 |
+
snapshot, err := store.Snapshot()
|
| 258 |
+
if err != nil {
|
| 259 |
+
t.Fatalf("snapshot failed: %v", err)
|
| 260 |
+
}
|
| 261 |
+
if snapshot.Limit != 137 {
|
| 262 |
+
t.Fatalf("expected stored limit 137, got %d", snapshot.Limit)
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
if rec := put(`{"limit":500}`); rec.Code != http.StatusOK {
|
| 266 |
+
t.Fatalf("expected 200 for limit=500, got %d body=%s", rec.Code, rec.Body.String())
|
| 267 |
+
}
|
| 268 |
+
rec = put(`{"limit":501}`)
|
| 269 |
+
if rec.Code != http.StatusBadRequest {
|
| 270 |
+
t.Fatalf("expected 400 for limit=501, got %d body=%s", rec.Code, rec.Body.String())
|
| 271 |
+
}
|
| 272 |
+
if !strings.Contains(rec.Body.String(), "unsupported chat history limit") {
|
| 273 |
+
t.Fatalf("unexpected error body: %s", rec.Body.String())
|
| 274 |
+
}
|
| 275 |
+
if rec := put(`{"limit":0}`); rec.Code != http.StatusOK {
|
| 276 |
+
t.Fatalf("expected 200 for limit=0, got %d body=%s", rec.Code, rec.Body.String())
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
func TestChatHistoryExportJSONAndMarkdown(t *testing.T) {
|
| 281 |
+
h, store := newChatHistoryAdminHarness(t)
|
| 282 |
+
seedHistoryEntry(t, store,
|
| 283 |
+
chathistory.StartParams{
|
| 284 |
+
CallerID: callerIDConfigured,
|
| 285 |
+
CallerLabel: "Alpha",
|
| 286 |
+
Surface: testSurfaceOpenAI,
|
| 287 |
+
Model: "deepseek-v4-flash",
|
| 288 |
+
Stream: true,
|
| 289 |
+
UserInput: "hello world",
|
| 290 |
+
FinalPrompt: "final prompt text",
|
| 291 |
+
},
|
| 292 |
+
chathistory.UpdateParams{
|
| 293 |
+
Status: "success",
|
| 294 |
+
StatusCode: http.StatusOK,
|
| 295 |
+
ReasoningContent: "thinking hard",
|
| 296 |
+
Content: "answer one",
|
| 297 |
+
ElapsedMs: 1234,
|
| 298 |
+
FinishReason: "stop",
|
| 299 |
+
Completed: true,
|
| 300 |
+
},
|
| 301 |
+
)
|
| 302 |
+
seedHistoryEntry(t, store,
|
| 303 |
+
chathistory.StartParams{
|
| 304 |
+
CallerID: testCallerExternal,
|
| 305 |
+
Surface: testSurfaceClaude,
|
| 306 |
+
Model: "deepseek-v4-pro",
|
| 307 |
+
UserInput: "second prompt",
|
| 308 |
+
},
|
| 309 |
+
chathistory.UpdateParams{Status: "error", Error: "boom upstream", Completed: true},
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
r := historyRouter(h)
|
| 313 |
+
|
| 314 |
+
rec := httptest.NewRecorder()
|
| 315 |
+
req := httptest.NewRequest(http.MethodGet, "/chat-history/export?format=json", nil)
|
| 316 |
+
req.Header.Set("Authorization", "Bearer admin")
|
| 317 |
+
r.ServeHTTP(rec, req)
|
| 318 |
+
if rec.Code != http.StatusOK {
|
| 319 |
+
t.Fatalf("expected 200 from json export, got %d body=%s", rec.Code, rec.Body.String())
|
| 320 |
+
}
|
| 321 |
+
if got := rec.Header().Get("Content-Type"); got != "application/json" {
|
| 322 |
+
t.Fatalf("unexpected content type: %q", got)
|
| 323 |
+
}
|
| 324 |
+
disposition := rec.Header().Get("Content-Disposition")
|
| 325 |
+
if !strings.HasPrefix(disposition, `attachment; filename="ds2api-logs-`) || !strings.HasSuffix(disposition, `.json"`) {
|
| 326 |
+
t.Fatalf("unexpected content disposition: %q", disposition)
|
| 327 |
+
}
|
| 328 |
+
var exported struct {
|
| 329 |
+
ExportedAt string `json:"exported_at"`
|
| 330 |
+
Count int `json:"count"`
|
| 331 |
+
Filters map[string]any `json:"filters"`
|
| 332 |
+
Items []chathistory.Entry `json:"items"`
|
| 333 |
+
}
|
| 334 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &exported); err != nil {
|
| 335 |
+
t.Fatalf("decode export failed: %v body=%s", err, rec.Body.String())
|
| 336 |
+
}
|
| 337 |
+
if exported.Count != 2 || len(exported.Items) != 2 {
|
| 338 |
+
t.Fatalf("expected 2 exported entries, got count=%d items=%d", exported.Count, len(exported.Items))
|
| 339 |
+
}
|
| 340 |
+
if exported.ExportedAt == "" {
|
| 341 |
+
t.Fatalf("expected exported_at timestamp")
|
| 342 |
+
}
|
| 343 |
+
if _, ok := exported.Filters["caller"]; ok {
|
| 344 |
+
t.Fatalf("empty filters must be omitted, got %#v", exported.Filters)
|
| 345 |
+
}
|
| 346 |
+
var full chathistory.Entry
|
| 347 |
+
for _, item := range exported.Items {
|
| 348 |
+
if item.CallerID == callerIDConfigured {
|
| 349 |
+
full = item
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
if full.FinalPrompt != "final prompt text" || full.ReasoningContent != "thinking hard" || full.Content != "answer one" {
|
| 353 |
+
t.Fatalf("export must contain full entries, got %#v", full)
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// 过滤后再导出
|
| 357 |
+
rec = httptest.NewRecorder()
|
| 358 |
+
req = httptest.NewRequest(http.MethodGet, "/chat-history/export?format=json&caller="+callerIDConfigured, nil)
|
| 359 |
+
r.ServeHTTP(rec, req)
|
| 360 |
+
if rec.Code != http.StatusOK {
|
| 361 |
+
t.Fatalf("expected 200 from filtered export, got %d body=%s", rec.Code, rec.Body.String())
|
| 362 |
+
}
|
| 363 |
+
exported.Items = nil
|
| 364 |
+
exported.Filters = nil
|
| 365 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &exported); err != nil {
|
| 366 |
+
t.Fatalf("decode filtered export failed: %v", err)
|
| 367 |
+
}
|
| 368 |
+
if exported.Count != 1 || len(exported.Items) != 1 {
|
| 369 |
+
t.Fatalf("expected 1 filtered export entry, got count=%d", exported.Count)
|
| 370 |
+
}
|
| 371 |
+
if exported.Filters["caller"] != callerIDConfigured {
|
| 372 |
+
t.Fatalf("expected caller filter echoed, got %#v", exported.Filters)
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
// markdown 导出
|
| 376 |
+
rec = httptest.NewRecorder()
|
| 377 |
+
req = httptest.NewRequest(http.MethodGet, "/chat-history/export?format=markdown&caller="+callerIDConfigured, nil)
|
| 378 |
+
r.ServeHTTP(rec, req)
|
| 379 |
+
if rec.Code != http.StatusOK {
|
| 380 |
+
t.Fatalf("expected 200 from markdown export, got %d body=%s", rec.Code, rec.Body.String())
|
| 381 |
+
}
|
| 382 |
+
if got := rec.Header().Get("Content-Type"); got != "text/markdown; charset=utf-8" {
|
| 383 |
+
t.Fatalf("unexpected markdown content type: %q", got)
|
| 384 |
+
}
|
| 385 |
+
disposition = rec.Header().Get("Content-Disposition")
|
| 386 |
+
if !strings.HasSuffix(disposition, `.md"`) {
|
| 387 |
+
t.Fatalf("unexpected markdown content disposition: %q", disposition)
|
| 388 |
+
}
|
| 389 |
+
body := rec.Body.String()
|
| 390 |
+
for _, want := range []string{
|
| 391 |
+
"# ds2api 响应记录导出",
|
| 392 |
+
"- 条数: 1",
|
| 393 |
+
"## 1. ",
|
| 394 |
+
"- 接口: " + testSurfaceOpenAI,
|
| 395 |
+
"- 模型: deepseek-v4-flash",
|
| 396 |
+
"- CallerLabel: Alpha (" + callerIDConfigured + ")",
|
| 397 |
+
"- 状态: success (200)",
|
| 398 |
+
"- 耗时: 1234ms",
|
| 399 |
+
"### UserInput",
|
| 400 |
+
"hello world",
|
| 401 |
+
"### FinalPrompt",
|
| 402 |
+
"final prompt text",
|
| 403 |
+
"### ReasoningContent",
|
| 404 |
+
"thinking hard",
|
| 405 |
+
"### Content",
|
| 406 |
+
"answer one",
|
| 407 |
+
} {
|
| 408 |
+
if !strings.Contains(body, want) {
|
| 409 |
+
t.Fatalf("markdown export missing %q:\n%s", want, body)
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
// 未知格式必须报错
|
| 414 |
+
rec = httptest.NewRecorder()
|
| 415 |
+
req = httptest.NewRequest(http.MethodGet, "/chat-history/export?format=xml", nil)
|
| 416 |
+
r.ServeHTTP(rec, req)
|
| 417 |
+
if rec.Code != http.StatusBadRequest {
|
| 418 |
+
t.Fatalf("expected 400 for unsupported format, got %d body=%s", rec.Code, rec.Body.String())
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// 导出 filename 里的时间戳必须来自本次请求,逐次调用都应给出合法文件名。
|
| 423 |
+
func TestChatHistoryExportFilenameCarriesTimestamp(t *testing.T) {
|
| 424 |
+
h, _ := newChatHistoryAdminHarness(t)
|
| 425 |
+
r := historyRouter(h)
|
| 426 |
+
|
| 427 |
+
rec := httptest.NewRecorder()
|
| 428 |
+
req := httptest.NewRequest(http.MethodGet, "/chat-history/export", nil)
|
| 429 |
+
r.ServeHTTP(rec, req)
|
| 430 |
+
if rec.Code != http.StatusOK {
|
| 431 |
+
t.Fatalf("expected 200 from default export, got %d body=%s", rec.Code, rec.Body.String())
|
| 432 |
+
}
|
| 433 |
+
disposition := rec.Header().Get("Content-Disposition")
|
| 434 |
+
if !strings.HasPrefix(disposition, `attachment; filename="ds2api-logs-`) || !strings.HasSuffix(disposition, `.json"`) {
|
| 435 |
+
t.Fatalf("unexpected content disposition: %q", disposition)
|
| 436 |
+
}
|
| 437 |
+
if !strings.Contains(rec.Body.String(), `"count": 0`) {
|
| 438 |
+
t.Fatalf("expected empty export payload, got %s", rec.Body.String())
|
| 439 |
+
}
|
| 440 |
+
}
|
internal/httpapi/admin/history/handler_chat_history_test.go
CHANGED
|
@@ -16,10 +16,17 @@ import (
|
|
| 16 |
)
|
| 17 |
|
| 18 |
func newChatHistoryAdminHarness(t *testing.T) (*Handler, *chathistory.Store) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
t.Helper()
|
| 20 |
dir := t.TempDir()
|
| 21 |
configPath := filepath.Join(dir, "config.json")
|
| 22 |
-
if err := os.WriteFile(configPath, []byte(
|
| 23 |
t.Fatalf("write config failed: %v", err)
|
| 24 |
}
|
| 25 |
t.Setenv("DS2API_CONFIG_PATH", configPath)
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
func newChatHistoryAdminHarness(t *testing.T) (*Handler, *chathistory.Store) {
|
| 19 |
+
t.Helper()
|
| 20 |
+
return newChatHistoryAdminHarnessWithConfig(t, `{}`)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// newChatHistoryAdminHarnessWithConfig 允许测试注入配置(例如已登记的 API Key),
|
| 24 |
+
// 用于验证按 API Key 聚合 / 筛选的行为。
|
| 25 |
+
func newChatHistoryAdminHarnessWithConfig(t *testing.T, configJSON string) (*Handler, *chathistory.Store) {
|
| 26 |
t.Helper()
|
| 27 |
dir := t.TempDir()
|
| 28 |
configPath := filepath.Join(dir, "config.json")
|
| 29 |
+
if err := os.WriteFile(configPath, []byte(configJSON), 0o644); err != nil {
|
| 30 |
t.Fatalf("write config failed: %v", err)
|
| 31 |
}
|
| 32 |
t.Setenv("DS2API_CONFIG_PATH", configPath)
|
internal/httpapi/admin/history/routes.go
CHANGED
|
@@ -4,6 +4,8 @@ import "github.com/go-chi/chi/v5"
|
|
| 4 |
|
| 5 |
func RegisterRoutes(r chi.Router, h *Handler) {
|
| 6 |
r.Get("/chat-history", h.getChatHistory)
|
|
|
|
|
|
|
| 7 |
r.Get("/chat-history/{id}", h.getChatHistoryItem)
|
| 8 |
r.Delete("/chat-history", h.clearChatHistory)
|
| 9 |
r.Delete("/chat-history/{id}", h.deleteChatHistoryItem)
|
|
|
|
| 4 |
|
| 5 |
func RegisterRoutes(r chi.Router, h *Handler) {
|
| 6 |
r.Get("/chat-history", h.getChatHistory)
|
| 7 |
+
// 静态段必须先于 {id} 注册,避免 "export" 被当成记录 ID。
|
| 8 |
+
r.Get("/chat-history/export", h.exportChatHistory)
|
| 9 |
r.Get("/chat-history/{id}", h.getChatHistoryItem)
|
| 10 |
r.Delete("/chat-history", h.clearChatHistory)
|
| 11 |
r.Delete("/chat-history/{id}", h.deleteChatHistoryItem)
|
internal/httpapi/admin/settings/handler_settings_parse.go
CHANGED
|
@@ -21,29 +21,34 @@ func boolFrom(v any) bool {
|
|
| 21 |
}
|
| 22 |
}
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
if raw, ok := req["admin"].(map[string]any); ok {
|
| 38 |
cfg := &config.AdminConfig{}
|
| 39 |
if v, exists := raw["jwt_expire_hours"]; exists {
|
| 40 |
n := intFrom(v)
|
| 41 |
if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
|
| 42 |
-
return
|
| 43 |
}
|
| 44 |
cfg.JWTExpireHours = n
|
| 45 |
}
|
| 46 |
-
|
| 47 |
}
|
| 48 |
|
| 49 |
if raw, ok := req["runtime"].(map[string]any); ok {
|
|
@@ -51,42 +56,42 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 51 |
if v, exists := raw["account_max_inflight"]; exists {
|
| 52 |
n := intFrom(v)
|
| 53 |
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
|
| 54 |
-
return
|
| 55 |
}
|
| 56 |
cfg.AccountMaxInflight = n
|
| 57 |
}
|
| 58 |
if v, exists := raw["account_max_queue"]; exists {
|
| 59 |
n := intFrom(v)
|
| 60 |
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
|
| 61 |
-
return
|
| 62 |
}
|
| 63 |
cfg.AccountMaxQueue = n
|
| 64 |
}
|
| 65 |
if v, exists := raw["global_max_inflight"]; exists {
|
| 66 |
n := intFrom(v)
|
| 67 |
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
|
| 68 |
-
return
|
| 69 |
}
|
| 70 |
cfg.GlobalMaxInflight = n
|
| 71 |
}
|
| 72 |
if v, exists := raw["token_refresh_interval_hours"]; exists {
|
| 73 |
n := intFrom(v)
|
| 74 |
if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
|
| 75 |
-
return
|
| 76 |
}
|
| 77 |
cfg.TokenRefreshIntervalHours = n
|
| 78 |
}
|
| 79 |
if v, exists := raw["backup_release_count"]; exists {
|
| 80 |
n := intFrom(v)
|
| 81 |
if err := config.ValidateIntRange("runtime.backup_release_count", n, 1, 100, true); err != nil {
|
| 82 |
-
return
|
| 83 |
}
|
| 84 |
cfg.BackupReleaseCount = n
|
| 85 |
}
|
| 86 |
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
| 87 |
-
return
|
| 88 |
}
|
| 89 |
-
|
| 90 |
}
|
| 91 |
|
| 92 |
if raw, ok := req["responses"].(map[string]any); ok {
|
|
@@ -94,11 +99,11 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 94 |
if v, exists := raw["store_ttl_seconds"]; exists {
|
| 95 |
n := intFrom(v)
|
| 96 |
if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
|
| 97 |
-
return
|
| 98 |
}
|
| 99 |
cfg.StoreTTLSeconds = n
|
| 100 |
}
|
| 101 |
-
|
| 102 |
}
|
| 103 |
|
| 104 |
if raw, ok := req["embeddings"].(map[string]any); ok {
|
|
@@ -106,16 +111,16 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 106 |
if v, exists := raw["provider"]; exists {
|
| 107 |
p := strings.TrimSpace(fmt.Sprintf("%v", v))
|
| 108 |
if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
|
| 109 |
-
return
|
| 110 |
}
|
| 111 |
cfg.Provider = p
|
| 112 |
}
|
| 113 |
-
|
| 114 |
}
|
| 115 |
|
| 116 |
if raw, ok := req["model_aliases"].(map[string]any); ok {
|
| 117 |
-
if
|
| 118 |
-
|
| 119 |
}
|
| 120 |
for k, v := range raw {
|
| 121 |
key := strings.TrimSpace(k)
|
|
@@ -123,7 +128,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 123 |
if key == "" || val == "" {
|
| 124 |
continue
|
| 125 |
}
|
| 126 |
-
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
|
@@ -132,7 +137,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 132 |
if v, exists := raw["mode"]; exists {
|
| 133 |
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
|
| 134 |
if err := config.ValidateAutoDeleteMode(mode); err != nil {
|
| 135 |
-
return
|
| 136 |
}
|
| 137 |
if mode == "" {
|
| 138 |
mode = "none"
|
|
@@ -142,7 +147,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 142 |
if v, exists := raw["sessions"]; exists {
|
| 143 |
cfg.Sessions = boolFrom(v)
|
| 144 |
}
|
| 145 |
-
|
| 146 |
}
|
| 147 |
|
| 148 |
if raw, ok := req["current_input_file"].(map[string]any); ok {
|
|
@@ -154,14 +159,14 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 154 |
if v, exists := raw["min_chars"]; exists {
|
| 155 |
n := intFrom(v)
|
| 156 |
if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
|
| 157 |
-
return
|
| 158 |
}
|
| 159 |
cfg.MinChars = n
|
| 160 |
}
|
| 161 |
if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
|
| 162 |
-
return
|
| 163 |
}
|
| 164 |
-
|
| 165 |
}
|
| 166 |
|
| 167 |
if raw, ok := req["thinking_injection"].(map[string]any); ok {
|
|
@@ -173,7 +178,16 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 173 |
if v, exists := raw["prompt"]; exists {
|
| 174 |
cfg.Prompt = strings.TrimSpace(fmt.Sprintf("%v", v))
|
| 175 |
}
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
if raw, ok := req["platform"].(map[string]any); ok {
|
|
@@ -181,7 +195,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 181 |
if v, exists := raw["mode"]; exists {
|
| 182 |
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
|
| 183 |
if err := config.ValidatePlatformConfig(config.PlatformConfig{Mode: mode}); err != nil {
|
| 184 |
-
return
|
| 185 |
}
|
| 186 |
cfg.Mode = mode
|
| 187 |
}
|
|
@@ -194,8 +208,8 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 194 |
if v, exists := raw["android_api_level"]; exists {
|
| 195 |
cfg.AndroidAPILevel = strings.TrimSpace(fmt.Sprintf("%v", v))
|
| 196 |
}
|
| 197 |
-
|
| 198 |
}
|
| 199 |
|
| 200 |
-
return
|
| 201 |
}
|
|
|
|
| 21 |
}
|
| 22 |
}
|
| 23 |
|
| 24 |
+
// settingsUpdatePayload 是 PUT /admin/settings 解析后的增量配置。
|
| 25 |
+
// 每个字段为 nil 表示请求里没有出现该段,保持既有配置不变。
|
| 26 |
+
type settingsUpdatePayload struct {
|
| 27 |
+
Admin *config.AdminConfig
|
| 28 |
+
Runtime *config.RuntimeConfig
|
| 29 |
+
Responses *config.ResponsesConfig
|
| 30 |
+
Embeddings *config.EmbeddingsConfig
|
| 31 |
+
AutoDelete *config.AutoDeleteConfig
|
| 32 |
+
CurrentInputFile *config.CurrentInputFileConfig
|
| 33 |
+
ThinkingInjection *config.ThinkingInjectionConfig
|
| 34 |
+
Tools *config.ToolsConfig
|
| 35 |
+
Platform *config.PlatformConfig
|
| 36 |
+
Aliases map[string]string
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func parseSettingsUpdateRequest(req map[string]any) (settingsUpdatePayload, error) {
|
| 40 |
+
var out settingsUpdatePayload
|
| 41 |
|
| 42 |
if raw, ok := req["admin"].(map[string]any); ok {
|
| 43 |
cfg := &config.AdminConfig{}
|
| 44 |
if v, exists := raw["jwt_expire_hours"]; exists {
|
| 45 |
n := intFrom(v)
|
| 46 |
if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
|
| 47 |
+
return settingsUpdatePayload{}, err
|
| 48 |
}
|
| 49 |
cfg.JWTExpireHours = n
|
| 50 |
}
|
| 51 |
+
out.Admin = cfg
|
| 52 |
}
|
| 53 |
|
| 54 |
if raw, ok := req["runtime"].(map[string]any); ok {
|
|
|
|
| 56 |
if v, exists := raw["account_max_inflight"]; exists {
|
| 57 |
n := intFrom(v)
|
| 58 |
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
|
| 59 |
+
return settingsUpdatePayload{}, err
|
| 60 |
}
|
| 61 |
cfg.AccountMaxInflight = n
|
| 62 |
}
|
| 63 |
if v, exists := raw["account_max_queue"]; exists {
|
| 64 |
n := intFrom(v)
|
| 65 |
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
|
| 66 |
+
return settingsUpdatePayload{}, err
|
| 67 |
}
|
| 68 |
cfg.AccountMaxQueue = n
|
| 69 |
}
|
| 70 |
if v, exists := raw["global_max_inflight"]; exists {
|
| 71 |
n := intFrom(v)
|
| 72 |
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
|
| 73 |
+
return settingsUpdatePayload{}, err
|
| 74 |
}
|
| 75 |
cfg.GlobalMaxInflight = n
|
| 76 |
}
|
| 77 |
if v, exists := raw["token_refresh_interval_hours"]; exists {
|
| 78 |
n := intFrom(v)
|
| 79 |
if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
|
| 80 |
+
return settingsUpdatePayload{}, err
|
| 81 |
}
|
| 82 |
cfg.TokenRefreshIntervalHours = n
|
| 83 |
}
|
| 84 |
if v, exists := raw["backup_release_count"]; exists {
|
| 85 |
n := intFrom(v)
|
| 86 |
if err := config.ValidateIntRange("runtime.backup_release_count", n, 1, 100, true); err != nil {
|
| 87 |
+
return settingsUpdatePayload{}, err
|
| 88 |
}
|
| 89 |
cfg.BackupReleaseCount = n
|
| 90 |
}
|
| 91 |
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
| 92 |
+
return settingsUpdatePayload{}, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
| 93 |
}
|
| 94 |
+
out.Runtime = cfg
|
| 95 |
}
|
| 96 |
|
| 97 |
if raw, ok := req["responses"].(map[string]any); ok {
|
|
|
|
| 99 |
if v, exists := raw["store_ttl_seconds"]; exists {
|
| 100 |
n := intFrom(v)
|
| 101 |
if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
|
| 102 |
+
return settingsUpdatePayload{}, err
|
| 103 |
}
|
| 104 |
cfg.StoreTTLSeconds = n
|
| 105 |
}
|
| 106 |
+
out.Responses = cfg
|
| 107 |
}
|
| 108 |
|
| 109 |
if raw, ok := req["embeddings"].(map[string]any); ok {
|
|
|
|
| 111 |
if v, exists := raw["provider"]; exists {
|
| 112 |
p := strings.TrimSpace(fmt.Sprintf("%v", v))
|
| 113 |
if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
|
| 114 |
+
return settingsUpdatePayload{}, err
|
| 115 |
}
|
| 116 |
cfg.Provider = p
|
| 117 |
}
|
| 118 |
+
out.Embeddings = cfg
|
| 119 |
}
|
| 120 |
|
| 121 |
if raw, ok := req["model_aliases"].(map[string]any); ok {
|
| 122 |
+
if out.Aliases == nil {
|
| 123 |
+
out.Aliases = map[string]string{}
|
| 124 |
}
|
| 125 |
for k, v := range raw {
|
| 126 |
key := strings.TrimSpace(k)
|
|
|
|
| 128 |
if key == "" || val == "" {
|
| 129 |
continue
|
| 130 |
}
|
| 131 |
+
out.Aliases[key] = val
|
| 132 |
}
|
| 133 |
}
|
| 134 |
|
|
|
|
| 137 |
if v, exists := raw["mode"]; exists {
|
| 138 |
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
|
| 139 |
if err := config.ValidateAutoDeleteMode(mode); err != nil {
|
| 140 |
+
return settingsUpdatePayload{}, err
|
| 141 |
}
|
| 142 |
if mode == "" {
|
| 143 |
mode = "none"
|
|
|
|
| 147 |
if v, exists := raw["sessions"]; exists {
|
| 148 |
cfg.Sessions = boolFrom(v)
|
| 149 |
}
|
| 150 |
+
out.AutoDelete = cfg
|
| 151 |
}
|
| 152 |
|
| 153 |
if raw, ok := req["current_input_file"].(map[string]any); ok {
|
|
|
|
| 159 |
if v, exists := raw["min_chars"]; exists {
|
| 160 |
n := intFrom(v)
|
| 161 |
if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
|
| 162 |
+
return settingsUpdatePayload{}, err
|
| 163 |
}
|
| 164 |
cfg.MinChars = n
|
| 165 |
}
|
| 166 |
if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
|
| 167 |
+
return settingsUpdatePayload{}, err
|
| 168 |
}
|
| 169 |
+
out.CurrentInputFile = cfg
|
| 170 |
}
|
| 171 |
|
| 172 |
if raw, ok := req["thinking_injection"].(map[string]any); ok {
|
|
|
|
| 178 |
if v, exists := raw["prompt"]; exists {
|
| 179 |
cfg.Prompt = strings.TrimSpace(fmt.Sprintf("%v", v))
|
| 180 |
}
|
| 181 |
+
out.ThinkingInjection = cfg
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if raw, ok := req["tools"].(map[string]any); ok {
|
| 185 |
+
cfg := &config.ToolsConfig{}
|
| 186 |
+
if v, exists := raw["enabled"]; exists {
|
| 187 |
+
b := boolFrom(v)
|
| 188 |
+
cfg.Enabled = &b
|
| 189 |
+
}
|
| 190 |
+
out.Tools = cfg
|
| 191 |
}
|
| 192 |
|
| 193 |
if raw, ok := req["platform"].(map[string]any); ok {
|
|
|
|
| 195 |
if v, exists := raw["mode"]; exists {
|
| 196 |
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
|
| 197 |
if err := config.ValidatePlatformConfig(config.PlatformConfig{Mode: mode}); err != nil {
|
| 198 |
+
return settingsUpdatePayload{}, err
|
| 199 |
}
|
| 200 |
cfg.Mode = mode
|
| 201 |
}
|
|
|
|
| 208 |
if v, exists := raw["android_api_level"]; exists {
|
| 209 |
cfg.AndroidAPILevel = strings.TrimSpace(fmt.Sprintf("%v", v))
|
| 210 |
}
|
| 211 |
+
out.Platform = cfg
|
| 212 |
}
|
| 213 |
|
| 214 |
+
return out, nil
|
| 215 |
}
|
internal/httpapi/admin/settings/handler_settings_read.go
CHANGED
|
@@ -37,6 +37,14 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
|
| 37 |
"enabled": snap.ThinkingInjection.Enabled,
|
| 38 |
"prompt": snap.ThinkingInjection.Prompt,
|
| 39 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
"model_aliases": snap.ModelAliases,
|
| 41 |
"platform": snap.Platform,
|
| 42 |
},
|
|
@@ -65,6 +73,15 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
|
| 65 |
"prompt": h.Store.ThinkingInjectionPrompt(),
|
| 66 |
"default_prompt": promptcompat.DefaultThinkingInjectionPrompt,
|
| 67 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"model_aliases": snap.ModelAliases,
|
| 69 |
"platform": map[string]any{
|
| 70 |
"mode": h.Store.PlatformMode(),
|
|
|
|
| 37 |
"enabled": snap.ThinkingInjection.Enabled,
|
| 38 |
"prompt": snap.ThinkingInjection.Prompt,
|
| 39 |
},
|
| 40 |
+
"skills": map[string]any{
|
| 41 |
+
"enabled": snap.Skills.Enabled,
|
| 42 |
+
"max_chars": snap.Skills.EffectiveMaxChars(),
|
| 43 |
+
"count": len(snap.Skills.Items),
|
| 44 |
+
},
|
| 45 |
+
"tools": map[string]any{
|
| 46 |
+
"enabled": snap.Tools.Enabled,
|
| 47 |
+
},
|
| 48 |
"model_aliases": snap.ModelAliases,
|
| 49 |
"platform": snap.Platform,
|
| 50 |
},
|
|
|
|
| 73 |
"prompt": h.Store.ThinkingInjectionPrompt(),
|
| 74 |
"default_prompt": promptcompat.DefaultThinkingInjectionPrompt,
|
| 75 |
},
|
| 76 |
+
"skills": map[string]any{
|
| 77 |
+
"enabled": h.Store.SkillsEnabled(),
|
| 78 |
+
"max_chars": h.Store.SkillsMaxChars(),
|
| 79 |
+
"count": len(snap.Skills.Items),
|
| 80 |
+
},
|
| 81 |
+
"tools": map[string]any{
|
| 82 |
+
"enabled": h.Store.ToolsEnabled(),
|
| 83 |
+
"default_enabled": false,
|
| 84 |
+
},
|
| 85 |
"model_aliases": snap.ModelAliases,
|
| 86 |
"platform": map[string]any{
|
| 87 |
"mode": h.Store.PlatformMode(),
|
internal/httpapi/admin/settings/handler_settings_write.go
CHANGED
|
@@ -17,13 +17,13 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|
| 17 |
return
|
| 18 |
}
|
| 19 |
|
| 20 |
-
|
| 21 |
if err != nil {
|
| 22 |
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 23 |
return
|
| 24 |
}
|
| 25 |
-
if
|
| 26 |
-
if err := validateMergedRuntimeSettings(h.Store.Snapshot().Runtime,
|
| 27 |
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 28 |
return
|
| 29 |
}
|
|
@@ -32,14 +32,16 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|
| 32 |
currentInputMinCharsSet := hasNestedSettingsKey(req, "current_input_file", "min_chars")
|
| 33 |
thinkingInjectionEnabledSet := hasNestedSettingsKey(req, "thinking_injection", "enabled")
|
| 34 |
thinkingInjectionPromptSet := hasNestedSettingsKey(req, "thinking_injection", "prompt")
|
|
|
|
| 35 |
|
| 36 |
if err := h.Store.Update(func(c *config.Config) error {
|
| 37 |
-
if
|
| 38 |
-
if
|
| 39 |
-
c.Admin.JWTExpireHours =
|
| 40 |
}
|
| 41 |
}
|
| 42 |
-
if
|
|
|
|
| 43 |
if runtimeCfg.AccountMaxInflight > 0 {
|
| 44 |
c.Runtime.AccountMaxInflight = runtimeCfg.AccountMaxInflight
|
| 45 |
}
|
|
@@ -56,40 +58,43 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|
| 56 |
c.Runtime.BackupReleaseCount = runtimeCfg.BackupReleaseCount
|
| 57 |
}
|
| 58 |
}
|
| 59 |
-
if
|
| 60 |
-
c.Responses.StoreTTLSeconds =
|
| 61 |
}
|
| 62 |
-
if
|
| 63 |
-
c.Embeddings.Provider = strings.TrimSpace(
|
| 64 |
}
|
| 65 |
-
if
|
| 66 |
-
c.AutoDelete.Mode =
|
| 67 |
-
c.AutoDelete.Sessions =
|
| 68 |
}
|
| 69 |
-
if
|
| 70 |
if currentInputEnabledSet {
|
| 71 |
-
c.CurrentInputFile.Enabled =
|
| 72 |
}
|
| 73 |
if currentInputMinCharsSet {
|
| 74 |
-
c.CurrentInputFile.MinChars =
|
| 75 |
}
|
| 76 |
}
|
| 77 |
-
if
|
| 78 |
if thinkingInjectionEnabledSet {
|
| 79 |
-
c.ThinkingInjection.Enabled =
|
| 80 |
}
|
| 81 |
if thinkingInjectionPromptSet {
|
| 82 |
-
c.ThinkingInjection.Prompt =
|
| 83 |
}
|
| 84 |
}
|
| 85 |
-
if
|
| 86 |
-
c.
|
| 87 |
}
|
| 88 |
-
if
|
| 89 |
-
c.
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
c.Platform.
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
return nil
|
| 95 |
}); err != nil {
|
|
|
|
| 17 |
return
|
| 18 |
}
|
| 19 |
|
| 20 |
+
payload, err := parseSettingsUpdateRequest(req)
|
| 21 |
if err != nil {
|
| 22 |
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 23 |
return
|
| 24 |
}
|
| 25 |
+
if payload.Runtime != nil {
|
| 26 |
+
if err := validateMergedRuntimeSettings(h.Store.Snapshot().Runtime, payload.Runtime); err != nil {
|
| 27 |
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 28 |
return
|
| 29 |
}
|
|
|
|
| 32 |
currentInputMinCharsSet := hasNestedSettingsKey(req, "current_input_file", "min_chars")
|
| 33 |
thinkingInjectionEnabledSet := hasNestedSettingsKey(req, "thinking_injection", "enabled")
|
| 34 |
thinkingInjectionPromptSet := hasNestedSettingsKey(req, "thinking_injection", "prompt")
|
| 35 |
+
toolsEnabledSet := hasNestedSettingsKey(req, "tools", "enabled")
|
| 36 |
|
| 37 |
if err := h.Store.Update(func(c *config.Config) error {
|
| 38 |
+
if payload.Admin != nil {
|
| 39 |
+
if payload.Admin.JWTExpireHours > 0 {
|
| 40 |
+
c.Admin.JWTExpireHours = payload.Admin.JWTExpireHours
|
| 41 |
}
|
| 42 |
}
|
| 43 |
+
if payload.Runtime != nil {
|
| 44 |
+
runtimeCfg := payload.Runtime
|
| 45 |
if runtimeCfg.AccountMaxInflight > 0 {
|
| 46 |
c.Runtime.AccountMaxInflight = runtimeCfg.AccountMaxInflight
|
| 47 |
}
|
|
|
|
| 58 |
c.Runtime.BackupReleaseCount = runtimeCfg.BackupReleaseCount
|
| 59 |
}
|
| 60 |
}
|
| 61 |
+
if payload.Responses != nil && payload.Responses.StoreTTLSeconds > 0 {
|
| 62 |
+
c.Responses.StoreTTLSeconds = payload.Responses.StoreTTLSeconds
|
| 63 |
}
|
| 64 |
+
if payload.Embeddings != nil && strings.TrimSpace(payload.Embeddings.Provider) != "" {
|
| 65 |
+
c.Embeddings.Provider = strings.TrimSpace(payload.Embeddings.Provider)
|
| 66 |
}
|
| 67 |
+
if payload.AutoDelete != nil {
|
| 68 |
+
c.AutoDelete.Mode = payload.AutoDelete.Mode
|
| 69 |
+
c.AutoDelete.Sessions = payload.AutoDelete.Sessions
|
| 70 |
}
|
| 71 |
+
if payload.CurrentInputFile != nil {
|
| 72 |
if currentInputEnabledSet {
|
| 73 |
+
c.CurrentInputFile.Enabled = payload.CurrentInputFile.Enabled
|
| 74 |
}
|
| 75 |
if currentInputMinCharsSet {
|
| 76 |
+
c.CurrentInputFile.MinChars = payload.CurrentInputFile.MinChars
|
| 77 |
}
|
| 78 |
}
|
| 79 |
+
if payload.ThinkingInjection != nil {
|
| 80 |
if thinkingInjectionEnabledSet {
|
| 81 |
+
c.ThinkingInjection.Enabled = payload.ThinkingInjection.Enabled
|
| 82 |
}
|
| 83 |
if thinkingInjectionPromptSet {
|
| 84 |
+
c.ThinkingInjection.Prompt = payload.ThinkingInjection.Prompt
|
| 85 |
}
|
| 86 |
}
|
| 87 |
+
if payload.Tools != nil && toolsEnabledSet {
|
| 88 |
+
c.Tools.Enabled = payload.Tools.Enabled
|
| 89 |
}
|
| 90 |
+
if payload.Aliases != nil {
|
| 91 |
+
c.ModelAliases = payload.Aliases
|
| 92 |
+
}
|
| 93 |
+
if payload.Platform != nil {
|
| 94 |
+
c.Platform.Mode = payload.Platform.Mode
|
| 95 |
+
c.Platform.AndroidVersion = payload.Platform.AndroidVersion
|
| 96 |
+
c.Platform.WebVersion = payload.Platform.WebVersion
|
| 97 |
+
c.Platform.AndroidAPILevel = payload.Platform.AndroidAPILevel
|
| 98 |
}
|
| 99 |
return nil
|
| 100 |
}); err != nil {
|
internal/httpapi/admin/shared/deps.go
CHANGED
|
@@ -38,6 +38,9 @@ type ConfigStore interface {
|
|
| 38 |
CurrentInputFileMinChars() int
|
| 39 |
ThinkingInjectionEnabled() bool
|
| 40 |
ThinkingInjectionPrompt() string
|
|
|
|
|
|
|
|
|
|
| 41 |
AutoDeleteSessions() bool
|
| 42 |
SetAccountBanned(identifier string, banned bool) (config.Account, error)
|
| 43 |
SetAccountRole(identifier string, role string) (config.Account, error)
|
|
|
|
| 38 |
CurrentInputFileMinChars() int
|
| 39 |
ThinkingInjectionEnabled() bool
|
| 40 |
ThinkingInjectionPrompt() string
|
| 41 |
+
SkillsEnabled() bool
|
| 42 |
+
SkillsMaxChars() int
|
| 43 |
+
ToolsEnabled() bool
|
| 44 |
AutoDeleteSessions() bool
|
| 45 |
SetAccountBanned(identifier string, banned bool) (config.Account, error)
|
| 46 |
SetAccountRole(identifier string, role string) (config.Account, error)
|
internal/httpapi/admin/shared/helpers.go
CHANGED
|
@@ -33,6 +33,9 @@ func ToAccount(m map[string]any) config.Account {
|
|
| 33 |
func ToAPIKeys(v any) ([]config.APIKey, bool) {
|
| 34 |
return toAPIKeys(v)
|
| 35 |
}
|
|
|
|
|
|
|
|
|
|
| 36 |
func NormalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
|
| 37 |
return normalizeAPIKeyForStorage(item)
|
| 38 |
}
|
|
@@ -197,9 +200,10 @@ func toAPIKeys(v any) ([]config.APIKey, bool) {
|
|
| 197 |
}
|
| 198 |
seen[key] = struct{}{}
|
| 199 |
out = append(out, config.APIKey{
|
| 200 |
-
Key:
|
| 201 |
-
Name:
|
| 202 |
-
Remark:
|
|
|
|
| 203 |
})
|
| 204 |
default:
|
| 205 |
key := strings.TrimSpace(fmt.Sprintf("%v", item))
|
|
@@ -216,16 +220,65 @@ func toAPIKeys(v any) ([]config.APIKey, bool) {
|
|
| 216 |
return out, true
|
| 217 |
}
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
func normalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
|
| 220 |
return config.APIKey{
|
| 221 |
-
Key:
|
| 222 |
-
Name:
|
| 223 |
-
Remark:
|
|
|
|
| 224 |
}
|
| 225 |
}
|
| 226 |
|
| 227 |
func apiKeyHasMetadata(item config.APIKey) bool {
|
| 228 |
-
return strings.TrimSpace(item.Name) != "" ||
|
|
|
|
|
|
|
| 229 |
}
|
| 230 |
|
| 231 |
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
|
|
@@ -279,13 +332,18 @@ func mergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey {
|
|
| 279 |
if existing.Key == "" {
|
| 280 |
return incoming
|
| 281 |
}
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
| 284 |
}
|
| 285 |
-
if
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
| 287 |
}
|
| 288 |
-
return
|
| 289 |
}
|
| 290 |
|
| 291 |
func fieldString(m map[string]any, key string) string {
|
|
|
|
| 33 |
func ToAPIKeys(v any) ([]config.APIKey, bool) {
|
| 34 |
return toAPIKeys(v)
|
| 35 |
}
|
| 36 |
+
func ToAPIKeyInjection(v any) *config.APIKeyInjectionConfig {
|
| 37 |
+
return toAPIKeyInjection(v)
|
| 38 |
+
}
|
| 39 |
func NormalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
|
| 40 |
return normalizeAPIKeyForStorage(item)
|
| 41 |
}
|
|
|
|
| 200 |
}
|
| 201 |
seen[key] = struct{}{}
|
| 202 |
out = append(out, config.APIKey{
|
| 203 |
+
Key: key,
|
| 204 |
+
Name: fieldString(x, "name"),
|
| 205 |
+
Remark: fieldString(x, "remark"),
|
| 206 |
+
ThinkingInjection: toAPIKeyInjection(x["thinking_injection"]),
|
| 207 |
})
|
| 208 |
default:
|
| 209 |
key := strings.TrimSpace(fmt.Sprintf("%v", item))
|
|
|
|
| 220 |
return out, true
|
| 221 |
}
|
| 222 |
|
| 223 |
+
// toAPIKeyInjection 解析单个 API Key 的自定义注入设置。
|
| 224 |
+
// 传 null / 空对象 / 字段全空时返回 nil,表示回到“跟随全局”。
|
| 225 |
+
func toAPIKeyInjection(v any) *config.APIKeyInjectionConfig {
|
| 226 |
+
if v == nil {
|
| 227 |
+
return nil
|
| 228 |
+
}
|
| 229 |
+
m, ok := v.(map[string]any)
|
| 230 |
+
if !ok {
|
| 231 |
+
return nil
|
| 232 |
+
}
|
| 233 |
+
cfg := &config.APIKeyInjectionConfig{
|
| 234 |
+
Custom: boolField(m, "custom"),
|
| 235 |
+
Enabled: optionalBoolField(m, "enabled"),
|
| 236 |
+
Prompt: fieldString(m, "prompt"),
|
| 237 |
+
}
|
| 238 |
+
return cfg.Normalize()
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
func boolField(m map[string]any, key string) bool {
|
| 242 |
+
if m == nil {
|
| 243 |
+
return false
|
| 244 |
+
}
|
| 245 |
+
switch v := m[key].(type) {
|
| 246 |
+
case bool:
|
| 247 |
+
return v
|
| 248 |
+
case string:
|
| 249 |
+
switch strings.ToLower(strings.TrimSpace(v)) {
|
| 250 |
+
case "true", "1", "yes", "on":
|
| 251 |
+
return true
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
return false
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
func optionalBoolField(m map[string]any, key string) *bool {
|
| 258 |
+
if m == nil {
|
| 259 |
+
return nil
|
| 260 |
+
}
|
| 261 |
+
raw, ok := m[key]
|
| 262 |
+
if !ok || raw == nil {
|
| 263 |
+
return nil
|
| 264 |
+
}
|
| 265 |
+
value := boolField(m, key)
|
| 266 |
+
return &value
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
func normalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
|
| 270 |
return config.APIKey{
|
| 271 |
+
Key: strings.TrimSpace(item.Key),
|
| 272 |
+
Name: strings.TrimSpace(item.Name),
|
| 273 |
+
Remark: strings.TrimSpace(item.Remark),
|
| 274 |
+
ThinkingInjection: item.ThinkingInjection.Normalize(),
|
| 275 |
}
|
| 276 |
}
|
| 277 |
|
| 278 |
func apiKeyHasMetadata(item config.APIKey) bool {
|
| 279 |
+
return strings.TrimSpace(item.Name) != "" ||
|
| 280 |
+
strings.TrimSpace(item.Remark) != "" ||
|
| 281 |
+
item.ThinkingInjection.Normalize() != nil
|
| 282 |
}
|
| 283 |
|
| 284 |
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
|
|
|
|
| 332 |
if existing.Key == "" {
|
| 333 |
return incoming
|
| 334 |
}
|
| 335 |
+
// 已存在的结构化字段优先,缺失项用导入值补齐(含自定义注入设置)。
|
| 336 |
+
out := existing
|
| 337 |
+
if out.Name == "" {
|
| 338 |
+
out.Name = incoming.Name
|
| 339 |
}
|
| 340 |
+
if out.Remark == "" {
|
| 341 |
+
out.Remark = incoming.Remark
|
| 342 |
+
}
|
| 343 |
+
if out.ThinkingInjection == nil {
|
| 344 |
+
out.ThinkingInjection = incoming.ThinkingInjection
|
| 345 |
}
|
| 346 |
+
return out
|
| 347 |
}
|
| 348 |
|
| 349 |
func fieldString(m map[string]any, key string) string {
|
internal/httpapi/admin/skills/deps.go
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package skills
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"ds2api/internal/chathistory"
|
| 5 |
+
adminshared "ds2api/internal/httpapi/admin/shared"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
type Handler struct {
|
| 9 |
+
Store adminshared.ConfigStore
|
| 10 |
+
Pool adminshared.PoolController
|
| 11 |
+
DS adminshared.DeepSeekCaller
|
| 12 |
+
OpenAI adminshared.OpenAIChatCaller
|
| 13 |
+
ChatHistory *chathistory.Store
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
var writeJSON = adminshared.WriteJSON
|
internal/httpapi/admin/skills/handler_skills.go
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package skills
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
|
| 7 |
+
"ds2api/internal/config"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// skillsPayload 是 GET/PUT /admin/skills 的响应与请求形状。
|
| 11 |
+
// PUT 是整体替换语义:缺省的 enabled/max_chars/items 一律按默认值处理。
|
| 12 |
+
type skillsPayload struct {
|
| 13 |
+
Enabled bool `json:"enabled"`
|
| 14 |
+
MaxChars int `json:"max_chars"`
|
| 15 |
+
Items []config.SkillConfig `json:"items"`
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
func (h *Handler) getSkills(w http.ResponseWriter, _ *http.Request) {
|
| 19 |
+
settings := h.currentSkills()
|
| 20 |
+
writeJSON(w, http.StatusOK, skillsPayload{
|
| 21 |
+
Enabled: settings.IsEnabled(),
|
| 22 |
+
MaxChars: settings.EffectiveMaxChars(),
|
| 23 |
+
Items: itemsOrEmpty(settings.Items),
|
| 24 |
+
})
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func (h *Handler) updateSkills(w http.ResponseWriter, r *http.Request) {
|
| 28 |
+
var req struct {
|
| 29 |
+
Enabled *bool `json:"enabled"`
|
| 30 |
+
MaxChars *int `json:"max_chars"`
|
| 31 |
+
Items []config.SkillConfig `json:"items"`
|
| 32 |
+
}
|
| 33 |
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
| 34 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json: " + err.Error()})
|
| 35 |
+
return
|
| 36 |
+
}
|
| 37 |
+
if req.MaxChars != nil && *req.MaxChars < 0 {
|
| 38 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "max_chars must be >= 0"})
|
| 39 |
+
return
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
var maxChars *int
|
| 43 |
+
if req.MaxChars != nil {
|
| 44 |
+
value := *req.MaxChars
|
| 45 |
+
maxChars = &value
|
| 46 |
+
}
|
| 47 |
+
cfg := config.SkillsConfig{
|
| 48 |
+
MaxChars: maxChars,
|
| 49 |
+
Items: req.Items,
|
| 50 |
+
}
|
| 51 |
+
if req.Enabled != nil {
|
| 52 |
+
enabled := *req.Enabled
|
| 53 |
+
cfg.Enabled = &enabled
|
| 54 |
+
} else {
|
| 55 |
+
// 整体替换语义:请求没带 enabled 就按「关闭」保存。
|
| 56 |
+
enabled := false
|
| 57 |
+
cfg.Enabled = &enabled
|
| 58 |
+
}
|
| 59 |
+
if err := config.ValidateSkillsConfig(cfg); err != nil {
|
| 60 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 61 |
+
return
|
| 62 |
+
}
|
| 63 |
+
stored := cfg.Normalize()
|
| 64 |
+
if err := h.Store.Update(func(c *config.Config) error {
|
| 65 |
+
c.Skills = stored
|
| 66 |
+
return nil
|
| 67 |
+
}); err != nil {
|
| 68 |
+
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 73 |
+
"success": true,
|
| 74 |
+
"count": len(stored.Items),
|
| 75 |
+
})
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// currentSkills 返回当前全局技能库配置(含默认值归一化)。
|
| 79 |
+
func (h *Handler) currentSkills() config.SkillsConfig {
|
| 80 |
+
if h == nil || h.Store == nil {
|
| 81 |
+
return config.SkillsConfig{}
|
| 82 |
+
}
|
| 83 |
+
return h.Store.Snapshot().Skills.Normalize()
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
func itemsOrEmpty(items []config.SkillConfig) []config.SkillConfig {
|
| 87 |
+
if items == nil {
|
| 88 |
+
return []config.SkillConfig{}
|
| 89 |
+
}
|
| 90 |
+
return items
|
| 91 |
+
}
|
internal/httpapi/admin/skills/handler_skills_test.go
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package skills
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"net/http"
|
| 7 |
+
"net/http/httptest"
|
| 8 |
+
"strings"
|
| 9 |
+
"testing"
|
| 10 |
+
|
| 11 |
+
"ds2api/internal/config"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
func newSkillsTestHandler(t *testing.T, raw string) *Handler {
|
| 15 |
+
t.Helper()
|
| 16 |
+
t.Setenv("DS2API_CONFIG_JSON", raw)
|
| 17 |
+
return &Handler{Store: config.LoadStore()}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func putSkills(t *testing.T, h *Handler, body map[string]any) *httptest.ResponseRecorder {
|
| 21 |
+
t.Helper()
|
| 22 |
+
encoded, err := json.Marshal(body)
|
| 23 |
+
if err != nil {
|
| 24 |
+
t.Fatalf("marshal payload: %v", err)
|
| 25 |
+
}
|
| 26 |
+
req := httptest.NewRequest(http.MethodPut, "/admin/skills", bytes.NewReader(encoded))
|
| 27 |
+
rec := httptest.NewRecorder()
|
| 28 |
+
h.updateSkills(rec, req)
|
| 29 |
+
return rec
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func TestGetSkillsDefaults(t *testing.T) {
|
| 33 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 34 |
+
req := httptest.NewRequest(http.MethodGet, "/admin/skills", nil)
|
| 35 |
+
rec := httptest.NewRecorder()
|
| 36 |
+
h.getSkills(rec, req)
|
| 37 |
+
if rec.Code != http.StatusOK {
|
| 38 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 39 |
+
}
|
| 40 |
+
var body skillsPayload
|
| 41 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
| 42 |
+
t.Fatalf("decode response: %v", err)
|
| 43 |
+
}
|
| 44 |
+
if body.Enabled {
|
| 45 |
+
t.Fatal("expected skills disabled by default")
|
| 46 |
+
}
|
| 47 |
+
if body.MaxChars != config.DefaultSkillsMaxChars {
|
| 48 |
+
t.Fatalf("max_chars=%d want=%d", body.MaxChars, config.DefaultSkillsMaxChars)
|
| 49 |
+
}
|
| 50 |
+
if body.Items == nil || len(body.Items) != 0 {
|
| 51 |
+
t.Fatalf("expected empty items array, got %#v", body.Items)
|
| 52 |
+
}
|
| 53 |
+
// 空数组要序列化成 [],不能是 null。
|
| 54 |
+
if !strings.Contains(rec.Body.String(), `"items":[]`) {
|
| 55 |
+
t.Fatalf("expected items to serialize as [], got %s", rec.Body.String())
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func TestUpdateSkillsSavesAndResolves(t *testing.T) {
|
| 60 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 61 |
+
rec := putSkills(t, h, map[string]any{
|
| 62 |
+
"enabled": true,
|
| 63 |
+
"max_chars": 200,
|
| 64 |
+
"items": []any{
|
| 65 |
+
map[string]any{"id": "sql-report", "name": "SQL 报表", "description": "按固定口径写 SQL", "body": "# 说明\n先对齐口径"},
|
| 66 |
+
map[string]any{"id": "review_v2", "name": "评审", "body": "评审要点"},
|
| 67 |
+
},
|
| 68 |
+
})
|
| 69 |
+
if rec.Code != http.StatusOK {
|
| 70 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 71 |
+
}
|
| 72 |
+
var result map[string]any
|
| 73 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
| 74 |
+
t.Fatalf("decode response: %v", err)
|
| 75 |
+
}
|
| 76 |
+
if ok, _ := result["success"].(bool); !ok {
|
| 77 |
+
t.Fatalf("expected success=true, got %v", result)
|
| 78 |
+
}
|
| 79 |
+
if got, _ := result["count"].(float64); int(got) != 2 {
|
| 80 |
+
t.Fatalf("count=%v want=2", result["count"])
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
snap := h.Store.Snapshot()
|
| 84 |
+
if !snap.Skills.IsEnabled() {
|
| 85 |
+
t.Fatal("expected skills enabled after save")
|
| 86 |
+
}
|
| 87 |
+
if got := snap.Skills.EffectiveMaxChars(); got != 200 {
|
| 88 |
+
t.Fatalf("max_chars=%d want=200", got)
|
| 89 |
+
}
|
| 90 |
+
if len(snap.Skills.Items) != 2 || snap.Skills.Items[0].ID != "sql-report" {
|
| 91 |
+
t.Fatalf("unexpected stored items: %#v", snap.Skills.Items)
|
| 92 |
+
}
|
| 93 |
+
settings := h.Store.Snapshot().Skills
|
| 94 |
+
if !settings.IsEnabled() || len(settings.Items) != 2 {
|
| 95 |
+
t.Fatalf("expected saved skills to be stored, got %#v", settings)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
func TestUpdateSkillsReplacesWholeLibrary(t *testing.T) {
|
| 100 |
+
h := newSkillsTestHandler(t, `{
|
| 101 |
+
"keys":["k1"],
|
| 102 |
+
"skills":{"enabled":true,"max_chars":50,"items":[{"id":"old","body":"old body"}]}
|
| 103 |
+
}`)
|
| 104 |
+
rec := putSkills(t, h, map[string]any{
|
| 105 |
+
"enabled": false,
|
| 106 |
+
"items": []any{map[string]any{"id": "fresh", "body": "fresh body"}},
|
| 107 |
+
})
|
| 108 |
+
if rec.Code != http.StatusOK {
|
| 109 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 110 |
+
}
|
| 111 |
+
snap := h.Store.Snapshot()
|
| 112 |
+
if snap.Skills.IsEnabled() {
|
| 113 |
+
t.Fatal("expected enabled=false after replace")
|
| 114 |
+
}
|
| 115 |
+
if len(snap.Skills.Items) != 1 || snap.Skills.Items[0].ID != "fresh" {
|
| 116 |
+
t.Fatalf("expected whole replacement, got %#v", snap.Skills.Items)
|
| 117 |
+
}
|
| 118 |
+
if got := snap.Skills.EffectiveMaxChars(); got != config.DefaultSkillsMaxChars {
|
| 119 |
+
t.Fatalf("omitted max_chars should reset to default, got %d", got)
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
func TestUpdateSkillsRejectsDuplicateID(t *testing.T) {
|
| 124 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 125 |
+
rec := putSkills(t, h, map[string]any{
|
| 126 |
+
"enabled": true,
|
| 127 |
+
"items": []any{
|
| 128 |
+
map[string]any{"id": "same", "body": "a"},
|
| 129 |
+
map[string]any{"id": "same", "body": "b"},
|
| 130 |
+
},
|
| 131 |
+
})
|
| 132 |
+
if rec.Code != http.StatusBadRequest {
|
| 133 |
+
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
| 134 |
+
}
|
| 135 |
+
if !strings.Contains(rec.Body.String(), "duplicate skill id") {
|
| 136 |
+
t.Fatalf("expected duplicate detail, got %s", rec.Body.String())
|
| 137 |
+
}
|
| 138 |
+
if h.Store.Snapshot().Skills.IsEnabled() {
|
| 139 |
+
t.Fatal("rejected payload must not persist")
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func TestUpdateSkillsRejectsInvalidID(t *testing.T) {
|
| 144 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 145 |
+
for _, id := range []string{"SQL Report", "报表", "SQL-Report", ""} {
|
| 146 |
+
rec := putSkills(t, h, map[string]any{
|
| 147 |
+
"enabled": true,
|
| 148 |
+
"items": []any{map[string]any{"id": id, "body": "body"}},
|
| 149 |
+
})
|
| 150 |
+
if rec.Code != http.StatusBadRequest {
|
| 151 |
+
t.Fatalf("id %q: expected 400, got %d body=%s", id, rec.Code, rec.Body.String())
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
func TestUpdateSkillsRejectsTooLongBody(t *testing.T) {
|
| 157 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 158 |
+
rec := putSkills(t, h, map[string]any{
|
| 159 |
+
"enabled": true,
|
| 160 |
+
"items": []any{
|
| 161 |
+
map[string]any{"id": "long", "body": strings.Repeat("字", config.MaxSkillBodyChars+1)},
|
| 162 |
+
},
|
| 163 |
+
})
|
| 164 |
+
if rec.Code != http.StatusBadRequest {
|
| 165 |
+
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
| 166 |
+
}
|
| 167 |
+
if !strings.Contains(rec.Body.String(), "body cannot exceed") {
|
| 168 |
+
t.Fatalf("expected body length detail, got %s", rec.Body.String())
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
func TestUpdateSkillsRejectsTooManyItems(t *testing.T) {
|
| 173 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 174 |
+
items := make([]any, 0, config.MaxSkillItems+1)
|
| 175 |
+
for i := 0; i <= config.MaxSkillItems; i++ {
|
| 176 |
+
items = append(items, map[string]any{"id": "skill-" + string(rune('a'+i)), "body": "body"})
|
| 177 |
+
}
|
| 178 |
+
rec := putSkills(t, h, map[string]any{"enabled": true, "items": items})
|
| 179 |
+
if rec.Code != http.StatusBadRequest {
|
| 180 |
+
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
| 181 |
+
}
|
| 182 |
+
if !strings.Contains(rec.Body.String(), "cannot exceed") {
|
| 183 |
+
t.Fatalf("expected item count detail, got %s", rec.Body.String())
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
func TestUpdateSkillsRejectsNegativeMaxChars(t *testing.T) {
|
| 188 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 189 |
+
rec := putSkills(t, h, map[string]any{"enabled": true, "max_chars": -1})
|
| 190 |
+
if rec.Code != http.StatusBadRequest {
|
| 191 |
+
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
| 192 |
+
}
|
| 193 |
+
if !strings.Contains(rec.Body.String(), "max_chars") {
|
| 194 |
+
t.Fatalf("expected max_chars detail, got %s", rec.Body.String())
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
func TestUpdateSkillsZeroMaxCharsMeansUnlimited(t *testing.T) {
|
| 199 |
+
h := newSkillsTestHandler(t, `{"keys":["k1"]}`)
|
| 200 |
+
rec := putSkills(t, h, map[string]any{"enabled": true, "max_chars": 0})
|
| 201 |
+
if rec.Code != http.StatusOK {
|
| 202 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 203 |
+
}
|
| 204 |
+
snap := h.Store.Snapshot()
|
| 205 |
+
if snap.Skills.MaxChars == nil {
|
| 206 |
+
t.Fatal("expected explicit max_chars pointer to be stored")
|
| 207 |
+
}
|
| 208 |
+
if got := h.Store.SkillsMaxChars(); got != 0 {
|
| 209 |
+
t.Fatalf("expected unlimited max_chars, got %d", got)
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
func TestGetSkillsReflectsStoredConfig(t *testing.T) {
|
| 214 |
+
h := newSkillsTestHandler(t, `{
|
| 215 |
+
"keys":["k1"],
|
| 216 |
+
"skills":{
|
| 217 |
+
"enabled":true,
|
| 218 |
+
"max_chars":321,
|
| 219 |
+
"items":[{"id":"sql-report","name":"SQL 报表","body":"body"}]
|
| 220 |
+
}
|
| 221 |
+
}`)
|
| 222 |
+
req := httptest.NewRequest(http.MethodGet, "/admin/skills", nil)
|
| 223 |
+
rec := httptest.NewRecorder()
|
| 224 |
+
h.getSkills(rec, req)
|
| 225 |
+
if rec.Code != http.StatusOK {
|
| 226 |
+
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
| 227 |
+
}
|
| 228 |
+
var body skillsPayload
|
| 229 |
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
| 230 |
+
t.Fatalf("decode response: %v", err)
|
| 231 |
+
}
|
| 232 |
+
if !body.Enabled || body.MaxChars != 321 {
|
| 233 |
+
t.Fatalf("unexpected settings: %#v", body)
|
| 234 |
+
}
|
| 235 |
+
if len(body.Items) != 1 || body.Items[0].Name != "SQL 报表" {
|
| 236 |
+
t.Fatalf("unexpected items: %#v", body.Items)
|
| 237 |
+
}
|
| 238 |
+
}
|
internal/httpapi/admin/skills/routes.go
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package skills
|
| 2 |
+
|
| 3 |
+
import "github.com/go-chi/chi/v5"
|
| 4 |
+
|
| 5 |
+
func RegisterRoutes(r chi.Router, h *Handler) {
|
| 6 |
+
r.Get("/skills", h.getSkills)
|
| 7 |
+
r.Put("/skills", h.updateSkills)
|
| 8 |
+
}
|
internal/httpapi/claude/current_input_file_test.go
CHANGED
|
@@ -28,6 +28,8 @@ func (claudeCurrentInputAuth) Determine(*http.Request) (*auth.RequestAuth, error
|
|
| 28 |
return &auth.RequestAuth{
|
| 29 |
DeepSeekToken: "direct-token",
|
| 30 |
CallerID: "caller:test",
|
|
|
|
|
|
|
| 31 |
TriedAccounts: map[string]bool{},
|
| 32 |
}, nil
|
| 33 |
}
|
|
@@ -189,7 +191,7 @@ func TestClaudeCurrentInputFileUploadsToolsSeparately(t *testing.T) {
|
|
| 189 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 190 |
}
|
| 191 |
toolsText := string(ds.uploads[1].Data)
|
| 192 |
-
if !strings.Contains(toolsText, "#
|
| 193 |
t.Fatalf("expected tools transcript to include tool schema, got %q", toolsText)
|
| 194 |
}
|
| 195 |
refIDs, _ := ds.payload["ref_file_ids"].([]any)
|
|
|
|
| 28 |
return &auth.RequestAuth{
|
| 29 |
DeepSeekToken: "direct-token",
|
| 30 |
CallerID: "caller:test",
|
| 31 |
+
// 这些用例验证工具说明文件上传,需要显式开启工具调用(默认关闭)。
|
| 32 |
+
ToolsEnabled: true,
|
| 33 |
TriedAccounts: map[string]bool{},
|
| 34 |
}, nil
|
| 35 |
}
|
|
|
|
| 191 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 192 |
}
|
| 193 |
toolsText := string(ds.uploads[1].Data)
|
| 194 |
+
if !strings.Contains(toolsText, "# 可用工具说明") || !strings.Contains(toolsText, "Tool: search") || !strings.Contains(toolsText, "Description: Search docs") {
|
| 195 |
t.Fatalf("expected tools transcript to include tool schema, got %q", toolsText)
|
| 196 |
}
|
| 197 |
refIDs, _ := ds.payload["ref_file_ids"].([]any)
|
internal/httpapi/claude/deps_injection_test.go
CHANGED
|
@@ -25,7 +25,7 @@ func TestNormalizeClaudeRequestUsesGlobalAliasMapping(t *testing.T) {
|
|
| 25 |
if err != nil {
|
| 26 |
t.Fatalf("normalizeClaudeRequest error: %v", err)
|
| 27 |
}
|
| 28 |
-
if out.Standard.ResolvedModel != "deepseek-v4-
|
| 29 |
t.Fatalf("resolved model mismatch: got=%q", out.Standard.ResolvedModel)
|
| 30 |
}
|
| 31 |
if !out.Standard.Thinking || !out.Standard.Search {
|
|
@@ -87,7 +87,7 @@ func TestNormalizeClaudeRequestNoThinkingAliasForcesThinkingOff(t *testing.T) {
|
|
| 87 |
if err != nil {
|
| 88 |
t.Fatalf("normalizeClaudeRequest error: %v", err)
|
| 89 |
}
|
| 90 |
-
if out.Standard.ResolvedModel != "deepseek-v4-
|
| 91 |
t.Fatalf("resolved model mismatch: got=%q", out.Standard.ResolvedModel)
|
| 92 |
}
|
| 93 |
if out.Standard.Thinking {
|
|
|
|
| 25 |
if err != nil {
|
| 26 |
t.Fatalf("normalizeClaudeRequest error: %v", err)
|
| 27 |
}
|
| 28 |
+
if out.Standard.ResolvedModel != "deepseek-v4-flash-search" {
|
| 29 |
t.Fatalf("resolved model mismatch: got=%q", out.Standard.ResolvedModel)
|
| 30 |
}
|
| 31 |
if !out.Standard.Thinking || !out.Standard.Search {
|
|
|
|
| 87 |
if err != nil {
|
| 88 |
t.Fatalf("normalizeClaudeRequest error: %v", err)
|
| 89 |
}
|
| 90 |
+
if out.Standard.ResolvedModel != "deepseek-v4-flash-nothinking" {
|
| 91 |
t.Fatalf("resolved model mismatch: got=%q", out.Standard.ResolvedModel)
|
| 92 |
}
|
| 93 |
if out.Standard.Thinking {
|
internal/httpapi/claude/handler_messages.go
CHANGED
|
@@ -85,6 +85,8 @@ func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) boo
|
|
| 85 |
return true
|
| 86 |
}
|
| 87 |
defer h.Auth.Release(a)
|
|
|
|
|
|
|
| 88 |
stdReq, err := h.applyCurrentInputFile(r.Context(), a, norm.Standard)
|
| 89 |
if err != nil {
|
| 90 |
status, message := mapCurrentInputFileError(err)
|
|
|
|
| 85 |
return true
|
| 86 |
}
|
| 87 |
defer h.Auth.Release(a)
|
| 88 |
+
// 工具调用默认关闭:关闭时忽略请求里的工具定义,按纯对话处理。
|
| 89 |
+
norm.Standard = promptcompat.ApplyToolsPolicy(a != nil && a.ToolsEnabled, norm.Standard)
|
| 90 |
stdReq, err := h.applyCurrentInputFile(r.Context(), a, norm.Standard)
|
| 91 |
if err != nil {
|
| 92 |
status, message := mapCurrentInputFileError(err)
|
internal/httpapi/claude/proxy_vercel_test.go
CHANGED
|
@@ -102,8 +102,8 @@ func TestClaudeProxyViaOpenAIUsesGlobalAliasMapping(t *testing.T) {
|
|
| 102 |
if rec.Code != http.StatusOK {
|
| 103 |
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
| 104 |
}
|
| 105 |
-
if got := strings.TrimSpace(openAI.seenModel); got != "deepseek-v4-
|
| 106 |
-
t.Fatalf("expected mapped proxy model deepseek-v4-
|
| 107 |
}
|
| 108 |
}
|
| 109 |
|
|
|
|
| 102 |
if rec.Code != http.StatusOK {
|
| 103 |
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
| 104 |
}
|
| 105 |
+
if got := strings.TrimSpace(openAI.seenModel); got != "deepseek-v4-flash" {
|
| 106 |
+
t.Fatalf("expected mapped proxy model deepseek-v4-flash, got %q", got)
|
| 107 |
}
|
| 108 |
}
|
| 109 |
|
internal/httpapi/claude/standard_request.go
CHANGED
|
@@ -40,6 +40,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
|
|
| 40 |
if config.IsNoThinkingModel(dsModel) {
|
| 41 |
thinkingEnabled = false
|
| 42 |
}
|
|
|
|
| 43 |
finalPrompt := prompt.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
|
| 44 |
toolNames := extractClaudeToolNames(toolsRequested)
|
| 45 |
if len(toolNames) == 0 && len(toolsRequested) > 0 {
|
|
|
|
| 40 |
if config.IsNoThinkingModel(dsModel) {
|
| 41 |
thinkingEnabled = false
|
| 42 |
}
|
| 43 |
+
searchEnabled = util.ResolveSearchEnabled(req, searchEnabled)
|
| 44 |
finalPrompt := prompt.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
|
| 45 |
toolNames := extractClaudeToolNames(toolsRequested)
|
| 46 |
if len(toolNames) == 0 && len(toolsRequested) > 0 {
|