From de9d128545cf8b3a00fd7685dc3c067cfd31e34d Mon Sep 17 00:00:00 2001 From: MuziIsabel <785398289@qq.com> Date: Sat, 25 Apr 2026 12:10:28 +0800 Subject: [PATCH 01/14] fix: strip content-encoding header in proxyToGo to prevent Brotli decode error Node fetch auto-decompresses upstream responses, but proxy_go.js was forwarding the original content-encoding header (e.g. br/gzip) to clients. Clients then tried to decompress already-decompressed data and failed. Filter out content-encoding alongside content-length. --- internal/js/chat-stream/proxy_go.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/js/chat-stream/proxy_go.js b/internal/js/chat-stream/proxy_go.js index 5218df0..4e31a9c 100644 --- a/internal/js/chat-stream/proxy_go.js +++ b/internal/js/chat-stream/proxy_go.js @@ -53,7 +53,8 @@ async function proxyToGo(req, res, rawBody) { res.statusCode = upstream.status; upstream.headers.forEach((value, key) => { - if (key.toLowerCase() === 'content-length') { + const lower = key.toLowerCase(); + if (lower === 'content-length' || lower === 'content-encoding') { return; } res.setHeader(key, value); From c4cdce46c25d03c28e3b251def22ed3ac91e50e8 Mon Sep 17 00:00:00 2001 From: topkill Date: Sat, 25 Apr 2026 19:19:17 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(webui):=20=E6=9B=BF=E6=8D=A2uuid?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=EF=BC=8C=E8=A7=A3=E5=86=B3http=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E4=B8=8B=E6=97=A0=E6=B3=95=E7=94=9F=E6=88=90=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原生的`crypto.randomUUID()`只支持https和localhost的安全上下文环境。 --- webui/package-lock.json | 16 +++++++++++++++- webui/package.json | 3 ++- webui/src/features/account/AddKeyModal.jsx | 3 ++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/webui/package-lock.json b/webui/package-lock.json index 55ac557..63f1eed 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -13,7 +13,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.13.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "uuid": "^14.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.1", @@ -2021,6 +2022,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", diff --git a/webui/package.json b/webui/package.json index 3f41ca9..bc69afb 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.13.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "uuid": "^14.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.1", diff --git a/webui/src/features/account/AddKeyModal.jsx b/webui/src/features/account/AddKeyModal.jsx index 875101a..061a1a5 100644 --- a/webui/src/features/account/AddKeyModal.jsx +++ b/webui/src/features/account/AddKeyModal.jsx @@ -1,4 +1,5 @@ import { X } from 'lucide-react' +import { v4 as uuidv4 } from 'uuid' export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) { if (!show) { @@ -32,7 +33,7 @@ export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, lo {!isEditing && (
{item.remark || '-'}
{copiedKey === item.key && ( diff --git a/webui/src/features/apiTester/ConfigPanel.jsx b/webui/src/features/apiTester/ConfigPanel.jsx index ddf9848..2dbbfdd 100644 --- a/webui/src/features/apiTester/ConfigPanel.jsx +++ b/webui/src/features/apiTester/ConfigPanel.jsx @@ -10,6 +10,8 @@ import { } from 'lucide-react' import clsx from 'clsx' +import { maskSecret } from '../../utils/maskSecret' + export default function ConfigPanel({ t, configExpanded, @@ -40,6 +42,7 @@ export default function ConfigPanel({ } const selectedModel = models.find(m => m.id === model) || models[0] const SelectedModelIcon = selectedModel ? (iconMap[selectedModel.icon] || MessageSquare) : MessageSquare + const defaultKeyPreview = maskSecret(config.keys?.[0]) return (
setApiKey(e.target.value)} /> diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 1770634..a0f72a4 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -238,7 +238,7 @@ "accountSelector": "Account", "autoRandom": "🤖 Auto / Random", "apiKeyOptional": "API Key (optional)", - "apiKeyDefault": "Default: ...{suffix}", + "apiKeyDefault": "Default: {preview}", "apiKeyPlaceholder": "Enter a custom key", "modeManaged": "Managed key mode (uses account pool).", "modeDirect": "Direct token mode (requires a valid DeepSeek token).", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 01b6655..e5ca592 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -238,7 +238,7 @@ "accountSelector": "选择账号", "autoRandom": "🤖 自动 / 随机", "apiKeyOptional": "API 密钥 (可选)", - "apiKeyDefault": "默认: ...{suffix}", + "apiKeyDefault": "默认: {preview}", "apiKeyPlaceholder": "输入自定义密钥", "modeManaged": "当前使用托管 key 模式(会走账号池)。", "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。", diff --git a/webui/src/utils/maskSecret.js b/webui/src/utils/maskSecret.js new file mode 100644 index 0000000..330b41e --- /dev/null +++ b/webui/src/utils/maskSecret.js @@ -0,0 +1,10 @@ +export function maskSecret(secret) { + const value = String(secret ?? '') + if (!value) { + return '' + } + if (value.length <= 4) { + return '*'.repeat(value.length) + } + return `${value.slice(0, 2)}****${value.slice(-2)}` +} From a44afb335adb73f6b6803dcaca143f5793a2388b Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 26 Apr 2026 00:37:25 +0800 Subject: [PATCH 05/14] Relax CORS preflight handling across interfaces --- API.en.md | 2 +- API.md | 2 +- README.MD | 3 +- README.en.md | 3 +- internal/js/chat-stream/cors.js | 134 ++++++++++++++++++++++ internal/js/chat-stream/http_internal.js | 12 +- internal/js/chat-stream/index.js | 2 +- internal/server/router.go | 139 ++++++++++++++++++++++- internal/server/router_cors_test.go | 119 +++++++++++++++++++ tests/node/chat-stream.test.js | 41 +++++++ 10 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 internal/js/chat-stream/cors.js create mode 100644 internal/server/router_cors_test.go diff --git a/API.en.md b/API.en.md index a7446c1..a982b0e 100644 --- a/API.en.md +++ b/API.en.md @@ -31,7 +31,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl | Base URL | `http://localhost:5001` or your deployment domain | | Default Content-Type | `application/json` | | Health probes | `GET /healthz`, `GET /readyz` | -| CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) | +| CORS | Enabled (uniformly covers `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*`; echoes the browser `Origin` when present, otherwise `*`; default allow-list includes `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`, and also accepts third-party preflight-requested headers such as `x-stainless-*`; `/v1/chat/completions` on Vercel Node Runtime matches the same behavior; internal-only `X-Ds2-Internal-Token` remains blocked) | ### 3.0 Adapter-Layer Notes diff --git a/API.md b/API.md index f77d576..0fa81d3 100644 --- a/API.md +++ b/API.md @@ -31,7 +31,7 @@ | Base URL | `http://localhost:5001` 或你的部署域名 | | 默认 Content-Type | `application/json` | | 健康检查 | `GET /healthz`、`GET /readyz` | -| CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) | +| CORS | 已启用(统一覆盖 `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*`;浏览器有 `Origin` 时回显该 Origin,否则为 `*`;默认允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`,并会放行预检里声明的第三方请求头,如 `x-stainless-*`;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同行为;内部专用头 `X-Ds2-Internal-Token` 仍被拦截) | ### 3.0 接口适配层说明 diff --git a/README.MD b/README.MD index 0c9bcf3..c8686bc 100644 --- a/README.MD +++ b/README.MD @@ -89,6 +89,7 @@ flowchart LR | OpenAI 兼容 | `GET /v1/models`、`GET /v1/models/{id}`、`POST /v1/chat/completions`、`POST /v1/responses`、`GET /v1/responses/{response_id}`、`POST /v1/embeddings`、`POST /v1/files` | | Claude 兼容 | `GET /anthropic/v1/models`、`POST /anthropic/v1/messages`、`POST /anthropic/v1/messages/count_tokens`(及快捷路径 `/v1/messages`、`/messages`) | | Gemini 兼容 | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent`(及 `/v1/models/{model}:*` 路径) | +| 统一 CORS 兼容 | `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*` 统一走同一套 CORS 策略;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同放行规则,尽量减少第三方预检请求头限制 | | 多账号轮询 | 自动 token 刷新、邮箱/手机号双登录方式 | | 并发队列控制 | 每账号 in-flight 上限 + 等待队列,动态计算建议并发值 | | DeepSeek PoW | 纯 Go 高性能实现(DeepSeekHashV1),毫秒级响应 | @@ -233,7 +234,7 @@ cp config.example.json config.json base64 < config.json | tr -d '\n' ``` -> **流式说明**:`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`(Node Runtime)以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。 +> **流式说明**:`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`(Node Runtime)以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。虽然这里只有 OpenAI chat 流式走 Node,但 CORS 放行策略仍与 Go 主路由保持一致,统一覆盖第三方客户端预检场景。 详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。 diff --git a/README.en.md b/README.en.md index daabc70..91c0bfe 100644 --- a/README.en.md +++ b/README.en.md @@ -87,6 +87,7 @@ For the full module-by-module architecture and directory responsibilities, see [ | OpenAI compatible | `GET /v1/models`, `GET /v1/models/{id}`, `POST /v1/chat/completions`, `POST /v1/responses`, `GET /v1/responses/{response_id}`, `POST /v1/embeddings`, `POST /v1/files` | | Claude compatible | `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens` (plus shortcut paths `/v1/messages`, `/messages`) | | Gemini compatible | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` (plus `/v1/models/{model}:*` paths) | +| Unified CORS compatibility | `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*` share one CORS policy; on Vercel, the Node Runtime for `/v1/chat/completions` mirrors the same relaxed preflight behavior for third-party clients | | Multi-account rotation | Auto token refresh, email/mobile dual login | | Concurrency control | Per-account in-flight limit + waiting queue, dynamic recommended concurrency | | DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response | @@ -231,7 +232,7 @@ Recommended: convert `config.json` to Base64 locally, then paste into `DS2API_CO base64 < config.json | tr -d '\n' ``` -> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling. +> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling. This is the only interface family currently routed through Node, and its CORS allow behavior is kept aligned with the Go router so third-party preflight handling stays unified. For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md). diff --git a/internal/js/chat-stream/cors.js b/internal/js/chat-stream/cors.js new file mode 100644 index 0000000..1a4b36a --- /dev/null +++ b/internal/js/chat-stream/cors.js @@ -0,0 +1,134 @@ +'use strict'; + +const DEFAULT_CORS_ALLOW_HEADERS = [ + 'Content-Type', + 'Authorization', + 'X-API-Key', + 'X-Ds2-Target-Account', + 'X-Ds2-Source', + 'X-Vercel-Protection-Bypass', + 'X-Goog-Api-Key', + 'Anthropic-Version', + 'Anthropic-Beta', +]; + +const BLOCKED_CORS_REQUEST_HEADERS = new Set([ + 'x-ds2-internal-token', +]); + +function setCorsHeaders(res, req) { + const origin = asString(readHeader(req, 'origin')); + res.setHeader('Access-Control-Allow-Origin', origin || '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Max-Age', '600'); + res.setHeader( + 'Access-Control-Allow-Headers', + buildCORSAllowHeaders(req), + ); + addVaryHeader(res, 'Origin'); + addVaryHeader(res, 'Access-Control-Request-Headers'); + if (asString(readHeader(req, 'access-control-request-private-network')).toLowerCase() === 'true') { + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + addVaryHeader(res, 'Access-Control-Request-Private-Network'); + } +} + +function buildCORSAllowHeaders(req) { + const seen = new Set(); + const headers = []; + for (const name of DEFAULT_CORS_ALLOW_HEADERS) { + appendCORSHeaderName(headers, seen, name); + } + for (const name of splitCORSRequestHeaders(readHeader(req, 'access-control-request-headers'))) { + appendCORSHeaderName(headers, seen, name); + } + return headers.join(', '); +} + +function splitCORSRequestHeaders(raw) { + const text = asString(raw); + if (!text) { + return []; + } + return text + .split(',') + .map((part) => asString(part)) + .filter((name) => isValidCORSHeaderToken(name)) + .filter((name) => !BLOCKED_CORS_REQUEST_HEADERS.has(name.toLowerCase())); +} + +function appendCORSHeaderName(headers, seen, name) { + const text = asString(name); + if (!isValidCORSHeaderToken(text)) { + return; + } + const lower = text.toLowerCase(); + if (BLOCKED_CORS_REQUEST_HEADERS.has(lower) || seen.has(lower)) { + return; + } + seen.add(lower); + headers.push(text); +} + +function isValidCORSHeaderToken(name) { + return /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(asString(name)); +} + +function addVaryHeader(res, token) { + const text = asString(token); + if (!text || typeof res.setHeader !== 'function') { + return; + } + const current = typeof res.getHeader === 'function' ? res.getHeader('Vary') : ''; + const seen = new Set(); + const merged = []; + const addToken = (value) => { + const trimmed = asString(value); + if (!trimmed) { + return; + } + const lower = trimmed.toLowerCase(); + if (seen.has(lower)) { + return; + } + seen.add(lower); + merged.push(trimmed); + }; + if (Array.isArray(current)) { + for (const value of current) { + for (const part of String(value).split(',')) { + addToken(part); + } + } + } else { + for (const part of String(current || '').split(',')) { + addToken(part); + } + } + addToken(text); + res.setHeader('Vary', merged.join(', ')); +} + +function readHeader(req, key) { + if (!req || !req.headers) { + return ''; + } + return req.headers[String(key).toLowerCase()]; +} + +function asString(v) { + if (typeof v === 'string') { + return v.trim(); + } + if (Array.isArray(v)) { + return asString(v[0]); + } + if (v == null) { + return ''; + } + return String(v).trim(); +} + +module.exports = { + setCorsHeaders, +}; diff --git a/internal/js/chat-stream/http_internal.js b/internal/js/chat-stream/http_internal.js index 20f24c8..01caa8d 100644 --- a/internal/js/chat-stream/http_internal.js +++ b/internal/js/chat-stream/http_internal.js @@ -3,15 +3,9 @@ const { writeOpenAIError, } = require('./error_shape'); - -function setCorsHeaders(res) { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); - res.setHeader( - 'Access-Control-Allow-Headers', - 'Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Vercel-Protection-Bypass', - ); -} +const { + setCorsHeaders, +} = require('./cors'); function header(req, key) { if (!req || !req.headers) { diff --git a/internal/js/chat-stream/index.js b/internal/js/chat-stream/index.js index 57740fd..398fc9b 100644 --- a/internal/js/chat-stream/index.js +++ b/internal/js/chat-stream/index.js @@ -40,7 +40,7 @@ const { } = require('./dedupe'); async function handler(req, res) { - setCorsHeaders(res); + setCorsHeaders(res, req); if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); diff --git a/internal/server/router.go b/internal/server/router.go index e1bf6f4..0e547e0 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -140,11 +140,25 @@ func (noopLogEntry) Write(_ int, _ int, _ http.Header, _ time.Duration, _ interf func (noopLogEntry) Panic(_ interface{}, _ []byte) {} +var defaultCORSAllowHeaders = []string{ + "Content-Type", + "Authorization", + "X-API-Key", + "X-Ds2-Target-Account", + "X-Ds2-Source", + "X-Vercel-Protection-Bypass", + "X-Goog-Api-Key", + "Anthropic-Version", + "Anthropic-Beta", +} + +var blockedCORSRequestHeaders = map[string]struct{}{ + "x-ds2-internal-token": {}, +} + func cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Ds2-Source, X-Vercel-Protection-Bypass") + setCORSHeaders(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -153,6 +167,125 @@ func cors(next http.Handler) http.Handler { }) } +func setCORSHeaders(w http.ResponseWriter, r *http.Request) { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + w.Header().Set("Access-Control-Allow-Origin", origin) + addVaryHeaderToken(w.Header(), "Origin") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", buildCORSAllowHeaders(r)) + w.Header().Set("Access-Control-Max-Age", "600") + addVaryHeaderToken(w.Header(), "Access-Control-Request-Headers") + if strings.EqualFold(strings.TrimSpace(r.Header.Get("Access-Control-Request-Private-Network")), "true") { + w.Header().Set("Access-Control-Allow-Private-Network", "true") + addVaryHeaderToken(w.Header(), "Access-Control-Request-Private-Network") + } +} + +func buildCORSAllowHeaders(r *http.Request) string { + names := make([]string, 0, len(defaultCORSAllowHeaders)+4) + seen := make(map[string]struct{}, len(defaultCORSAllowHeaders)+4) + for _, name := range defaultCORSAllowHeaders { + appendCORSHeaderName(&names, seen, name) + } + if r == nil { + return strings.Join(names, ", ") + } + for _, name := range splitCORSRequestHeaders(r.Header.Get("Access-Control-Request-Headers")) { + appendCORSHeaderName(&names, seen, name) + } + return strings.Join(names, ", ") +} + +func splitCORSRequestHeaders(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if !isValidCORSHeaderToken(name) { + continue + } + if _, blocked := blockedCORSRequestHeaders[strings.ToLower(name)]; blocked { + continue + } + out = append(out, name) + } + return out +} + +func appendCORSHeaderName(dst *[]string, seen map[string]struct{}, name string) { + name = strings.TrimSpace(name) + if !isValidCORSHeaderToken(name) { + return + } + key := strings.ToLower(name) + if _, blocked := blockedCORSRequestHeaders[key]; blocked { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + *dst = append(*dst, name) +} + +func isValidCORSHeaderToken(v string) bool { + if v == "" { + return false + } + for i := 0; i < len(v); i++ { + c := v[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + continue + } + switch c { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~': + continue + default: + return false + } + } + return true +} + +func addVaryHeaderToken(h http.Header, token string) { + if h == nil { + return + } + token = strings.TrimSpace(token) + if token == "" { + return + } + current := h.Values("Vary") + seen := map[string]struct{}{} + merged := make([]string, 0, len(current)+1) + for _, value := range current { + for _, part := range strings.Split(value, ",") { + name := strings.TrimSpace(part) + if name == "" { + continue + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + merged = append(merged, name) + } + } + key := strings.ToLower(token) + if _, ok := seen[key]; !ok { + merged = append(merged, token) + } + h.Set("Vary", strings.Join(merged, ", ")) +} + func WriteUnhandledError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) diff --git a/internal/server/router_cors_test.go b/internal/server/router_cors_test.go new file mode 100644 index 0000000..448b1f1 --- /dev/null +++ b/internal/server/router_cors_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCORSPreflightAllowsThirdPartyRequestedHeaders(t *testing.T) { + handler := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + })) + + req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil) + req.Header.Set("Origin", "app://obsidian.md") + req.Header.Set("Access-Control-Request-Headers", "authorization, x-stainless-os, x-stainless-runtime, x-ds2-internal-token") + req.Header.Set("Access-Control-Request-Private-Network", "true") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204 for preflight, got %d", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "app://obsidian.md" { + t.Fatalf("expected origin echo, got %q", got) + } + if got := rec.Header().Get("Access-Control-Allow-Private-Network"); got != "true" { + t.Fatalf("expected private network allow header, got %q", got) + } + + allowHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers")) + for _, want := range []string{"authorization", "x-stainless-os", "x-stainless-runtime"} { + if !strings.Contains(allowHeaders, want) { + t.Fatalf("expected allow headers to include %q, got %q", want, rec.Header().Get("Access-Control-Allow-Headers")) + } + } + if strings.Contains(allowHeaders, "x-ds2-internal-token") { + t.Fatalf("expected internal-only header to stay blocked, got %q", rec.Header().Get("Access-Control-Allow-Headers")) + } + + vary := strings.ToLower(rec.Header().Get("Vary")) + for _, want := range []string{"origin", "access-control-request-headers", "access-control-request-private-network"} { + if !strings.Contains(vary, want) { + t.Fatalf("expected vary to include %q, got %q", want, rec.Header().Get("Vary")) + } + } +} + +func TestBuildCORSAllowHeadersKeepsDefaultsWithoutRequest(t *testing.T) { + got := strings.ToLower(buildCORSAllowHeaders(nil)) + for _, want := range []string{"content-type", "x-goog-api-key", "anthropic-version", "x-ds2-source"} { + if !strings.Contains(got, want) { + t.Fatalf("expected default allow headers to include %q, got %q", want, got) + } + } +} + +func TestAppCORSPreflightIsUnifiedAcrossInterfaces(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"u@example.com","password":"p"}]}`) + t.Setenv("DS2API_ENV_WRITEBACK", "0") + + app, err := NewApp() + if err != nil { + t.Fatalf("NewApp() error: %v", err) + } + + cases := []struct { + name string + path string + headers string + }{ + { + name: "openai", + path: "/v1/chat/completions", + headers: "authorization, x-stainless-os", + }, + { + name: "claude", + path: "/anthropic/v1/messages", + headers: "x-api-key, anthropic-version, x-stainless-os", + }, + { + name: "gemini", + path: "/v1beta/models/gemini-2.5-pro:generateContent", + headers: "x-goog-api-key, x-client-version", + }, + { + name: "admin", + path: "/admin/login", + headers: "content-type, x-requested-with", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodOptions, tc.path, nil) + req.Header.Set("Origin", "app://obsidian.md") + req.Header.Set("Access-Control-Request-Headers", tc.headers) + + rec := httptest.NewRecorder() + app.Router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected %s preflight status 204, got %d", tc.path, rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "app://obsidian.md" { + t.Fatalf("expected origin echo for %s, got %q", tc.path, got) + } + allowHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers")) + for _, want := range splitCORSRequestHeaders(tc.headers) { + if !strings.Contains(allowHeaders, strings.ToLower(want)) { + t.Fatalf("expected allow headers for %s to include %q, got %q", tc.path, want, rec.Header().Get("Access-Control-Allow-Headers")) + } + } + }) + } +} diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 4f78374..4394a86 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -9,6 +9,9 @@ const { processToolSieveChunk, flushToolSieve, } = require('../../internal/js/helpers/stream-tool-sieve.js'); +const { + setCorsHeaders, +} = require('../../internal/js/chat-stream/http_internal.js'); const { parseChunkForContent, @@ -26,6 +29,18 @@ const { trimContinuationOverlap, } = handler.__test; +function createMockResponse() { + const headers = new Map(); + return { + setHeader(key, value) { + headers.set(String(key).toLowerCase(), value); + }, + getHeader(key) { + return headers.get(String(key).toLowerCase()); + }, + }; +} + test('chat-stream exposes parser test hooks', () => { assert.equal(typeof parseChunkForContent, 'function'); assert.equal(typeof resolveToolcallPolicy, 'function'); @@ -400,6 +415,32 @@ test('extractPathname strips query only', () => { assert.equal(extractPathname('/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=1'), '/v1beta/models/gemini-2.5-flash:streamGenerateContent'); }); +test('setCorsHeaders reflects requested third-party headers and blocks internal-only headers', () => { + const res = createMockResponse(); + setCorsHeaders(res, { + headers: { + origin: 'app://obsidian.md', + 'access-control-request-headers': 'authorization, x-stainless-os, x-stainless-runtime, x-ds2-internal-token', + 'access-control-request-private-network': 'true', + }, + }); + + assert.equal(res.getHeader('access-control-allow-origin'), 'app://obsidian.md'); + assert.equal(res.getHeader('access-control-allow-private-network'), 'true'); + assert.equal(res.getHeader('access-control-max-age'), '600'); + + const allowHeaders = String(res.getHeader('access-control-allow-headers') || '').toLowerCase(); + assert.equal(allowHeaders.includes('authorization'), true); + assert.equal(allowHeaders.includes('x-stainless-os'), true); + assert.equal(allowHeaders.includes('x-stainless-runtime'), true); + assert.equal(allowHeaders.includes('x-ds2-internal-token'), false); + + const vary = String(res.getHeader('vary') || '').toLowerCase(); + assert.equal(vary.includes('origin'), true); + assert.equal(vary.includes('access-control-request-headers'), true); + assert.equal(vary.includes('access-control-request-private-network'), true); +}); + test('trimContinuationOverlap preserves short normal tokens and trims long snapshots', () => { assert.equal(trimContinuationOverlap('我们被问到', '我们'), '我们'); const existing = '我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。'; From 1b0e8cbadb28f538d2e00f30be33688734f49261 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 26 Apr 2026 01:17:16 +0800 Subject: [PATCH 06/14] Tighten XML tool call parsing and upstream empty handling --- .../adapter/claude/handler_stream_test.go | 26 +- internal/adapter/claude/handler_util_test.go | 6 +- .../adapter/openai/chat_stream_runtime.go | 12 +- internal/adapter/openai/handler_chat.go | 2 +- .../adapter/openai/handler_toolcall_test.go | 4 +- internal/adapter/openai/history_split_test.go | 2 +- .../adapter/openai/message_normalize_test.go | 6 +- internal/adapter/openai/prompt_build_test.go | 4 +- internal/adapter/openai/responses_handler.go | 2 +- .../openai/responses_stream_runtime_core.go | 17 +- .../adapter/openai/responses_stream_test.go | 4 +- internal/adapter/openai/tool_sieve_xml.go | 15 +- .../adapter/openai/tool_sieve_xml_test.go | 75 ++-- internal/adapter/openai/upstream_empty.go | 8 +- .../format/openai/render_stream_events.go | 25 +- .../js/helpers/stream-tool-sieve/parse.js | 4 +- .../stream-tool-sieve/parse_payload.js | 80 +--- .../js/helpers/stream-tool-sieve/sieve-xml.js | 6 +- .../stream-tool-sieve/tool-keywords.js | 8 +- internal/prompt/tool_calls.go | 8 +- internal/prompt/tool_calls_test.go | 6 +- internal/toolcall/regression_test.go | 16 +- internal/toolcall/tool_prompt.go | 56 ++- internal/toolcall/tool_prompt_test.go | 4 +- internal/toolcall/toolcalls_input_parse.go | 3 +- internal/toolcall/toolcalls_markup.go | 130 ------ internal/toolcall/toolcalls_parse.go | 9 +- internal/toolcall/toolcalls_parse_markup.go | 420 ++---------------- internal/toolcall/toolcalls_test.go | 219 ++------- .../expected/toolcalls_function_call_tag.json | 13 +- .../expected/toolcalls_invoke_attr.json | 13 +- .../expected/toolcalls_xml_tool_call.json | 13 +- ...olcalls_xml_tool_name_parameters_json.json | 12 +- .../fixtures/toolcalls/function_call_tag.json | 2 +- .../fixtures/toolcalls/xml_tool_call.json | 2 +- .../xml_tool_name_parameters_json.json | 2 +- tests/node/stream-tool-sieve.test.js | 30 +- 37 files changed, 273 insertions(+), 991 deletions(-) diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/adapter/claude/handler_stream_test.go index f5f7d75..aabc2f3 100644 --- a/internal/adapter/claude/handler_stream_test.go +++ b/internal/adapter/claude/handler_stream_test.go @@ -247,16 +247,18 @@ func asString(v any) string { func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.T) { tests := []struct { - name string - payload string + name string + payload string + wantToolUse bool }{ - {name: "xml_tool_call", payload: `Bashpwd`}, - {name: "xml_json_tool_call", payload: `{"tool":"Bash","params":{"command":"pwd"}}`}, - {name: "nested_tool_tag_style", payload: `pwd`}, - {name: "function_tag_style", payload: `Bashpwd`}, - {name: "antml_argument_style", payload: `pwd`}, - {name: "antml_function_attr_parameters", payload: `{"command":"pwd"}`}, - {name: "invoke_parameter_style", payload: `pwd`}, + {name: "canonical_tools_wrapper", payload: `Bashpwd`, wantToolUse: true}, + {name: "legacy_single_tool_root", payload: `Bashpwd`, wantToolUse: false}, + {name: "legacy_tool_call_json", payload: `{"tool":"Bash","params":{"command":"pwd"}}`, wantToolUse: false}, + {name: "legacy_nested_tool_tag_style", payload: `pwd`, wantToolUse: false}, + {name: "legacy_function_tag_style", payload: `Bashpwd`, wantToolUse: false}, + {name: "legacy_antml_argument_style", payload: `pwd`, wantToolUse: false}, + {name: "legacy_antml_function_attr_parameters", payload: `{"command":"pwd"}`, wantToolUse: false}, + {name: "legacy_invoke_parameter_style", payload: `pwd`, wantToolUse: false}, } for _, tc := range tests { @@ -280,8 +282,8 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing. break } } - if !foundToolUse { - t.Fatalf("expected tool_use block for format %s, body=%s", tc.name, rec.Body.String()) + if foundToolUse != tc.wantToolUse { + t.Fatalf("unexpected tool_use=%v for format %s, body=%s", foundToolUse, tc.name, rec.Body.String()) } }) } @@ -289,7 +291,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing. func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) { h := &Handler{} - payload := "I'll call a tool now.\\nwrite_file{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}" + payload := "I'll call a tool now.\\nwrite_file{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}" resp := makeClaudeSSEHTTPResponse( `data: {"p":"response/content","v":"`+payload+`"}`, `data: [DONE]`, diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index 171c52a..7efa8dd 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -93,10 +93,10 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) { t.Fatalf("expected call id preserved, got %#v", call) } content, _ := m["content"].(string) - if !containsStr(content, "") || !containsStr(content, "search_web") { + if !containsStr(content, "") || !containsStr(content, "search_web") { t.Fatalf("expected assistant content to include XML tool call history, got %q", content) } - if !containsStr(content, "\n \n ") { + if !containsStr(content, "\n \n ") { t.Fatalf("expected assistant content to include serialized parameters, got %q", content) } } @@ -292,7 +292,7 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "Search the web") { t.Fatalf("expected description in prompt") } - if !containsStr(prompt, "") { + if !containsStr(prompt, "") { t.Fatalf("expected XML tool_calls format in prompt") } if !containsStr(prompt, "TOOL CALL FORMAT") { diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 1d7fff6..25124c7 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -201,17 +201,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) { finishReason = "tool_calls" } if len(detected.Calls) == 0 && !s.toolCallsEmitted && strings.TrimSpace(finalText) == "" { - status := http.StatusTooManyRequests - message := "Upstream model returned empty output." - code := "upstream_empty_output" - if strings.TrimSpace(finalThinking) != "" { - message = "Upstream model returned reasoning without visible output." - } - if finishReason == "content_filter" { - status = http.StatusBadRequest - message = "Upstream content filtered the response and returned no output." - code = "content_filter" - } + status, message, code := upstreamEmptyOutputDetail(finishReason == "content_filter", finalText, finalThinking) s.sendFailedChunk(status, message, code) return } diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index 29636fd..4890e83 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -166,7 +166,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co if historySession != nil { historySession.error(status, message, code, finalThinking, finalText) } - writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) + writeUpstreamEmptyOutputError(w, finalText, finalThinking, result.ContentFilter) return } respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index 3a2e4e2..e2015fa 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -217,8 +217,8 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( - `data: {"p":"response/content","v":"前置文本\n\n \n read_file\n {\"path\":\"README.MD\"}\n \n"}`, - `data: {"p":"response/content","v":"中间文本\n\n \n search\n {\"q\":\"golang\"}\n \n"}`, + `data: {"p":"response/content","v":"前置文本\n\n \n read_file\n {\"path\":\"README.MD\"}\n \n"}`, + `data: {"p":"response/content","v":"中间文本\n\n \n search\n {\"q\":\"golang\"}\n \n"}`, `data: [DONE]`, ) rec := httptest.NewRecorder() diff --git a/internal/adapter/openai/history_split_test.go b/internal/adapter/openai/history_split_test.go index 1ba6777..554346b 100644 --- a/internal/adapter/openai/history_split_test.go +++ b/internal/adapter/openai/history_split_test.go @@ -76,7 +76,7 @@ func TestBuildOpenAIHistoryTranscriptUsesInjectedFileWrapper(t *testing.T) { if !strings.Contains(transcript, "[reasoning_content]") || !strings.Contains(transcript, "hidden reasoning") { t.Fatalf("expected reasoning block preserved, got %q", transcript) } - if !strings.Contains(transcript, "") { + if !strings.Contains(transcript, "") { t.Fatalf("expected tool calls preserved, got %q", transcript) } if !strings.HasSuffix(transcript, "\n[file name]: IGNORE\n[file content begin]\n") { diff --git a/internal/adapter/openai/message_normalize_test.go b/internal/adapter/openai/message_normalize_test.go index 564fea7..b7a4bb6 100644 --- a/internal/adapter/openai/message_normalize_test.go +++ b/internal/adapter/openai/message_normalize_test.go @@ -38,7 +38,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized)) } assistantContent, _ := normalized[2]["content"].(string) - if !strings.Contains(assistantContent, "") { + if !strings.Contains(assistantContent, "") { t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent) } if !strings.Contains(assistantContent, "get_weather") { @@ -49,7 +49,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes } prompt := util.MessagesPrepare(normalized) - if !strings.Contains(prompt, "") { + if !strings.Contains(prompt, "") { t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt) } } @@ -258,7 +258,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi if strings.Contains(content, "null") { t.Fatalf("expected no null literal injection, got %q", content) } - if !strings.Contains(content, "") { + if !strings.Contains(content, "") { t.Fatalf("expected assistant tool history in normalized content, got %q", content) } } diff --git a/internal/adapter/openai/prompt_build_test.go b/internal/adapter/openai/prompt_build_test.go index 0d7e1c5..989399c 100644 --- a/internal/adapter/openai/prompt_build_test.go +++ b/internal/adapter/openai/prompt_build_test.go @@ -47,7 +47,7 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes if !strings.Contains(finalPrompt, `"condition":"sunny"`) { t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt) } - if !strings.Contains(finalPrompt, "") { + if !strings.Contains(finalPrompt, "") { t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt) } if !strings.Contains(finalPrompt, "get_weather") { @@ -74,7 +74,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * } finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false) - if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the XML block at the end of your response.") { + if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the ... XML block at the end of your response.") { t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt) } if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") { diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index 7d5be12..3518722 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -135,7 +135,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res if searchEnabled { sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks) } - if writeUpstreamEmptyOutputError(w, sanitizedText, result.ContentFilter) { + if writeUpstreamEmptyOutputError(w, sanitizedText, sanitizedThinking, result.ContentFilter) { return } textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames) diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index af7eb8e..bba0b43 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -99,7 +99,7 @@ func newResponsesStreamRuntime( } } -func (s *responsesStreamRuntime) failResponse(message, code string) { +func (s *responsesStreamRuntime) failResponse(status int, message, code string) { s.failed = true failedResp := map[string]any{ "id": s.responseID, @@ -107,11 +107,12 @@ func (s *responsesStreamRuntime) failResponse(message, code string) { "object": "response", "model": s.model, "status": "failed", + "status_code": status, "output": []any{}, "output_text": "", "error": map[string]any{ "message": message, - "type": "invalid_request_error", + "type": openAIErrorType(status), "code": code, "param": nil, }, @@ -119,7 +120,7 @@ func (s *responsesStreamRuntime) failResponse(message, code string) { if s.persistResponse != nil { s.persistResponse(failedResp) } - s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, message, code)) + s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, status, message, code)) s.sendDone() } @@ -145,16 +146,12 @@ func (s *responsesStreamRuntime) finalize() { s.closeMessageItem() if s.toolChoice.IsRequired() && len(detected) == 0 { - s.failResponse("tool_choice requires at least one valid tool call.", "tool_choice_violation") + s.failResponse(http.StatusUnprocessableEntity, "tool_choice requires at least one valid tool call.", "tool_choice_violation") return } if len(detected) == 0 && strings.TrimSpace(finalText) == "" { - code := "upstream_empty_output" - message := "Upstream model returned empty output." - if finalThinking != "" { - message = "Upstream model returned reasoning without visible output." - } - s.failResponse(message, code) + status, message, code := upstreamEmptyOutputDetail(false, finalText, finalThinking) + s.failResponse(status, message, code) return } s.closeIncompleteFunctionItems() diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index 7999fa7..2fa2184 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -122,8 +122,8 @@ func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t return "data: " + string(b) + "\n" } - streamBody := sseLine("前置文本\n\n \n read_file\n {\"path\":\"README.MD\"}\n \n") + - sseLine("中间文本\n\n \n search\n {\"q\":\"golang\"}\n \n") + + streamBody := sseLine("前置文本\n\n \n read_file\n {\"path\":\"README.MD\"}\n \n") + + sseLine("中间文本\n\n \n search\n {\"q\":\"golang\"}\n \n") + "data: [DONE]\n" resp := &http.Response{ StatusCode: http.StatusOK, diff --git a/internal/adapter/openai/tool_sieve_xml.go b/internal/adapter/openai/tool_sieve_xml.go index b019b93..d00c86b 100644 --- a/internal/adapter/openai/tool_sieve_xml.go +++ b/internal/adapter/openai/tool_sieve_xml.go @@ -9,22 +9,18 @@ import ( // --- XML tool call support for the streaming sieve --- //nolint:unused // kept as explicit tag inventory for future XML sieve refinements. -var xmlToolCallClosingTags = []string{"", "", "", "", "", "", +var xmlToolCallClosingTags = []string{"", "", // Agent-style XML tags (Roo Code, Cline, etc.) "", "", "", ""} -var xmlToolCallOpeningTags = []string{""}, + {""}, {""}, - {""}, - {""}, - {""}, - {""}, // Agent-style: these are XML "tool call" patterns from coding agents. // They get captured → parsed. If parsing fails, the raw XML is preserved // so the caller can still see the original text. @@ -36,11 +32,10 @@ var xmlToolCallTagPairs = []struct{ open, close string }{ // xmlToolCallBlockPattern matches a complete XML tool call block (wrapper or standalone). // //nolint:unused // reserved for future fast-path XML block detection. -var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(\s*(?:.*?)\s*|\s*(?:.*?)\s*|]*>(?:.*?)|]*>(?:.*?)|(?:.*?)|(?:.*?)|(?:.*?)|(?:.*?))`) +var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(]*>\s*(?:.*?)\s*|]*>(?:.*?)|(?:.*?)|(?:.*?)|(?:.*?))`) // xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart. -var xmlToolTagsToDetect = []string{"", "", "", "", +var xmlToolTagsToDetect = []string{"", "", "", "", ""} diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 16827cc..9bd3acd 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -9,12 +9,12 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) { var state toolStreamSieveState // Simulate a model producing XML tool call output chunk by chunk. chunks := []string{ - "\n", + "\n", " \n", " read_file\n", - ` {"path":"README.MD"}` + "\n", + ` {"path":"README.MD"}` + "\n", " \n", - "", + "", } var events []toolStreamEvent for _, c := range chunks { @@ -48,10 +48,10 @@ func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) { payload := strings.Repeat("x", 4096) splitAt := len(payload) / 2 chunks := []string{ - "\n \n " + toolName + "\n \n \n \n " + toolName + "\n \n \n \n \n", + "]]>\n \n \n", } var events []toolStreamEvent @@ -90,8 +90,8 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) { // Model outputs some prose then an XML tool call. chunks := []string{ "Let me check the file.\n", - "\n \n read_file\n", - ` {"path":"go.mod"}` + "\n \n", + "\n \n read_file\n", + ` {"path":"go.mod"}` + "\n \n", } var events []toolStreamEvent for _, c := range chunks { @@ -123,7 +123,7 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) { func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) { var state toolStreamSieveState - chunk := `示例 XMLplain text xml payload` + chunk := `示例 XMLplain text xml payload` events := processToolSieveChunk(&state, chunk, []string{"read_file"}) events = append(events, flushToolSieve(&state, []string{"read_file"})...) @@ -143,7 +143,7 @@ func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) { func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) { var state toolStreamSieveState - chunk := `plain xml{"path":"README.MD"}` + chunk := `plain xmlread_file{"path":"README.MD"}` events := processToolSieveChunk(&state, chunk, []string{"read_file"}) events = append(events, flushToolSieve(&state, []string{"read_file"})...) @@ -153,11 +153,11 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) { textContent.WriteString(evt.Content) toolCalls += len(evt.ToolCalls) } - if !strings.Contains(textContent.String(), `plain xml`) { + if !strings.Contains(textContent.String(), `plain xml`) { t.Fatalf("expected leading non-tool XML to be preserved, got %q", textContent.String()) } - if strings.Contains(textContent.String(), ``) { - t.Fatalf("expected invoke tool XML to be intercepted, got %q", textContent.String()) + if strings.Contains(textContent.String(), ``) { + t.Fatalf("expected canonical tool XML to be intercepted, got %q", textContent.String()) } if toolCalls != 1 { t.Fatalf("expected exactly one parsed tool call from suffix, got %d events=%#v", toolCalls, events) @@ -166,7 +166,7 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) { func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) { var state toolStreamSieveState - chunk := `{"path":"README.md"}` + chunk := `{"path":"README.md"}` events := processToolSieveChunk(&state, chunk, []string{"read_file"}) events = append(events, flushToolSieve(&state, []string{"read_file"})...) @@ -189,17 +189,17 @@ func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) { var state toolStreamSieveState input := strings.Join([]string{ "Before first example.\n```", - "xml\nread_file{\"path\":\"README.md\"}\n```\n", + "xml\nread_file{\"path\":\"README.md\"}\n```\n", "Between examples.\n```xml\n", - "search{\"q\":\"golang\"}\n", + "search{\"q\":\"golang\"}\n", "```\nAfter examples.", }, "") chunks := []string{ "Before first example.\n```", - "xml\nread_file{\"path\":\"README.md\"}\n```\n", + "xml\nread_file{\"path\":\"README.md\"}\n```\n", "Between examples.\n```xml\n", - "search{\"q\":\"golang\"}\n", + "search{\"q\":\"golang\"}\n", "```\nAfter examples.", } @@ -230,13 +230,13 @@ func TestProcessToolSieveKeepsPartialXMLTagInsideFencedExample(t *testing.T) { var state toolStreamSieveState input := strings.Join([]string{ "Example:\n```xml\nread_file{\"path\":\"README.md\"}\n```\n", + "ll>read_file{\"path\":\"README.md\"}\n```\n", "Done.", }, "") chunks := []string{ "Example:\n```xml\nread_file{\"path\":\"README.md\"}\n```\n", + "ll>read_file{\"path\":\"README.md\"}\n```\n", "Done.", } @@ -288,11 +288,9 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { input string want int }{ - {"tool_calls_tag", "some text \n", 10}, + {"tools_tag", "some text \n", 10}, {"tool_call_tag", "prefix \n", 7}, - {"invoke_tag", "text body", 5}, - {"xml_inside_code_fence", "```xml\nread_file\n```", -1}, - {"function_call_tag", "body", 0}, + {"xml_inside_code_fence", "```xml\nread_file\n```", -1}, {"no_xml", "just plain text", -1}, {"gemini_json_no_detect", `some text {"functionCall":{"name":"search"}}`, -1}, } @@ -312,10 +310,10 @@ func TestFindPartialXMLToolTagStart(t *testing.T) { input string want int }{ + {"partial_tools", "Hello done", -1}, + {"complete_tag", "Text done", -1}, {"no_lt", "plain text", -1}, {"closed_lt", "a < b > c", -1}, } @@ -330,10 +328,10 @@ func TestFindPartialXMLToolTagStart(t *testing.T) { } func TestHasOpenXMLToolTag(t *testing.T) { - if !hasOpenXMLToolTag("\nfoo") { + if !hasOpenXMLToolTag("\n\nfoo") { t.Fatal("should detect open XML tool tag without closing tag") } - if hasOpenXMLToolTag("\nfoo") { + if hasOpenXMLToolTag("\n\nfoo\n") { t.Fatal("should return false when closing tag is present") } if hasOpenXMLToolTag("plain text without any XML") { @@ -342,14 +340,14 @@ func TestHasOpenXMLToolTag(t *testing.T) { } // Test the EXACT scenario the user reports: token-by-token streaming where -// tag arrives in small pieces. +// tag arrives in small pieces. func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) { var state toolStreamSieveState // Simulate DeepSeek model generating tokens one at a time. chunks := []string{ "<", "tool", - "_calls", + "s", ">\n", " <", "tool", @@ -366,21 +364,20 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) { "_name", ">\n", " <", - "parameters", + "param", ">", `{"path"`, `: "README.MD"`, `}`, "\n", " \n", "", } var events []toolStreamEvent @@ -401,7 +398,7 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) { if strings.Contains(textContent, "") { + if strings.Contains(textContent, "tools>") { t.Fatalf("closing tag fragment leaked to text: %q", textContent) } if strings.Contains(textContent, "read_file") { @@ -417,7 +414,7 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) { var state toolStreamSieveState // XML block starts but stream ends before completion. chunks := []string{ - "\n", + "\n", " \n", " read_file\n", } @@ -440,19 +437,19 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) { } } -// Test that the opening tag "\n " is NOT emitted as text content. +// Test that the opening tag "\n " is NOT emitted as text content. func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) { var state toolStreamSieveState // First chunk is the opening tag - should be held, not emitted. - evts1 := processToolSieveChunk(&state, "\n ", []string{"read_file"}) + evts1 := processToolSieveChunk(&state, "\n ", []string{"read_file"}) for _, evt := range evts1 { - if strings.Contains(evt.Content, "") { + if strings.Contains(evt.Content, "") { t.Fatalf("opening tag leaked on first chunk: %q", evt.Content) } } // Remaining content arrives. - evts2 := processToolSieveChunk(&state, "\n read_file\n {\"path\":\"README.MD\"}\n \n", []string{"read_file"}) + evts2 := processToolSieveChunk(&state, "\n read_file\n {\"path\":\"README.MD\"}\n \n", []string{"read_file"}) evts2 = append(evts2, flushToolSieve(&state, []string{"read_file"})...) var textContent string diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index bb2da1f..23e82e6 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -12,16 +12,16 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter" } if thinking != "" { - return http.StatusTooManyRequests, "Upstream model returned reasoning without visible output.", "upstream_empty_output" + return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned reasoning without visible output.", "upstream_empty_output" } - return http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output" + return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned empty output.", "upstream_empty_output" } -func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool { +func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool { if !shouldWriteUpstreamEmptyOutputError(text) { return false } - status, message, code := upstreamEmptyOutputDetail(contentFilter, text, "") + status, message, code := upstreamEmptyOutputDetail(contentFilter, text, thinking) writeOpenAIErrorWithCode(w, status, message, code) return true } diff --git a/internal/format/openai/render_stream_events.go b/internal/format/openai/render_stream_events.go index 6c1121a..33c7c09 100644 --- a/internal/format/openai/render_stream_events.go +++ b/internal/format/openai/render_stream_events.go @@ -117,7 +117,7 @@ func BuildResponsesFunctionCallArgumentsDonePayload(responseID, itemID string, o } } -func BuildResponsesFailedPayload(responseID, model, message, code string) map[string]any { +func BuildResponsesFailedPayload(responseID, model string, status int, message, code string) map[string]any { code = strings.TrimSpace(code) if code == "" { code = "api_error" @@ -129,15 +129,36 @@ func BuildResponsesFailedPayload(responseID, model, message, code string) map[st "object": "response", "model": model, "status": "failed", + "status_code": status, "error": map[string]any{ "message": message, - "type": "invalid_request_error", + "type": responsesErrorType(status), "code": code, "param": nil, }, } } +func responsesErrorType(status int) string { + switch status { + case 400, 404, 422: + return "invalid_request_error" + case 401: + return "authentication_error" + case 403: + return "permission_error" + case 429: + return "rate_limit_error" + case 503: + return "service_unavailable_error" + default: + if status >= 500 { + return "api_error" + } + return "invalid_request_error" + } +} + func BuildResponsesCompletedPayload(response map[string]any) map[string]any { responseID, _ := response["id"].(string) return map[string]any{ diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index f6bb865..b7993c9 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -8,7 +8,7 @@ const { stripFencedCodeBlocks, } = require('./parse_payload'); -const TOOL_MARKUP_PREFIXES = [']*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi; -const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/>/gi; +const TOOLS_WRAPPER_PATTERN = /]*>([\s\S]*?)<\/tools>/gi; +const TOOL_CALL_MARKUP_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?tool_call\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_call>/gi; +const TOOL_CALL_CANONICAL_BODY_PATTERN = /^\s*<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_name>\s*<(?:[a-z0-9_:-]+:)?param\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?param>\s*$/i; const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi; -const TOOL_CALL_MARKUP_ATTR_PATTERN = /(name|function|tool)\s*=\s*"([^"]+)"/i; -const TOOL_CALL_MARKUP_NAME_PATTERNS = [ - /<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_name>/i, - /<(?:[a-z0-9_:-]+:)?function_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function_name>/i, - /<(?:[a-z0-9_:-]+:)?name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?name>/i, - /<(?:[a-z0-9_:-]+:)?function\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function>/i, -]; -const TOOL_CALL_MARKUP_ARGS_PATTERNS = [ - /<(?:[a-z0-9_:-]+:)?input\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?input>/i, - /<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?arguments>/i, - /<(?:[a-z0-9_:-]+:)?argument\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?argument>/i, - /<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameters>/i, - /<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameter>/i, - /<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i, - /<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i, -]; const CDATA_PATTERN = /^$/i; -const HTML_ENTITIES_PATTERN = /&[a-z0-9#]+;/gi; const { toStringSafe, @@ -40,22 +24,19 @@ function parseMarkupToolCalls(text) { return []; } const out = []; - for (const m of raw.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) { - const parsed = parseMarkupSingleToolCall(toStringSafe(m[2]).trim(), toStringSafe(m[3]).trim()); - if (parsed) { - out.push(parsed); - } - } - for (const m of raw.matchAll(TOOL_CALL_MARKUP_SELFCLOSE_PATTERN)) { - const parsed = parseMarkupSingleToolCall(toStringSafe(m[1]).trim(), ''); - if (parsed) { - out.push(parsed); + for (const wrapper of raw.matchAll(TOOLS_WRAPPER_PATTERN)) { + const body = toStringSafe(wrapper[1]); + for (const block of body.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) { + const parsed = parseMarkupSingleToolCall(toStringSafe(block[1]).trim()); + if (parsed) { + out.push(parsed); + } } } return out; } -function parseMarkupSingleToolCall(attrs, inner) { +function parseMarkupSingleToolCall(inner) { // Try inline JSON parse for the inner content. if (inner) { try { @@ -70,28 +51,18 @@ function parseMarkupSingleToolCall(attrs, inner) { // Not JSON, continue with markup parsing. } } - let name = ''; - const attrMatch = attrs.match(TOOL_CALL_MARKUP_ATTR_PATTERN); - if (attrMatch && attrMatch[2]) { - name = toStringSafe(attrMatch[2]).trim(); - } - if (!name) { - name = extractRawTagValue(findMarkupTagValue(inner, TOOL_CALL_MARKUP_NAME_PATTERNS)); + + const match = inner.match(TOOL_CALL_CANONICAL_BODY_PATTERN); + if (!match || match.length < 3) { + return null; } + + const name = extractRawTagValue(match[1]).trim(); if (!name) { return null; } - let input = {}; - const argsRaw = findMarkupTagValue(inner, TOOL_CALL_MARKUP_ARGS_PATTERNS); - if (argsRaw) { - input = parseMarkupInput(argsRaw); - } else { - const kv = parseMarkupKVObject(inner); - if (Object.keys(kv).length > 0) { - input = kv; - } - } + const input = parseMarkupInput(match[2]); return { name, input }; } @@ -187,21 +158,6 @@ function unescapeHtml(safe) { .replace(/'/g, "'"); } -function stripTagText(text) { - return toStringSafe(text).replace(/<[^>]+>/g, ' ').trim(); -} - -function findMarkupTagValue(text, patterns) { - const source = toStringSafe(text); - for (const p of patterns) { - const m = source.match(p); - if (m && m[1] !== undefined) { - return toStringSafe(m[1]); - } - } - return ''; -} - function parseToolCallInput(v) { if (v == null) { return {}; diff --git a/internal/js/helpers/stream-tool-sieve/sieve-xml.js b/internal/js/helpers/stream-tool-sieve/sieve-xml.js index 6442dbc..6eb5280 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve-xml.js +++ b/internal/js/helpers/stream-tool-sieve/sieve-xml.js @@ -3,12 +3,8 @@ const { parseToolCalls } = require('./parse'); // Tag pairs ordered longest-first: wrapper tags checked before inner tags. const XML_TOOL_TAG_PAIRS = [ - { open: '' }, + { open: '' }, { open: '' }, - { open: '' }, - { open: '' }, - { open: '' }, - { open: '' }, ]; const XML_TOOL_OPENING_TAGS = XML_TOOL_TAG_PAIRS.map(p => p.open); diff --git a/internal/js/helpers/stream-tool-sieve/tool-keywords.js b/internal/js/helpers/stream-tool-sieve/tool-keywords.js index ed7fbe0..473600b 100644 --- a/internal/js/helpers/stream-tool-sieve/tool-keywords.js +++ b/internal/js/helpers/stream-tool-sieve/tool-keywords.js @@ -1,16 +1,15 @@ 'use strict'; const XML_TOOL_SEGMENT_TAGS = [ - '', '', '', '', + '', '', '', '', '', '', '', '', + '', '', ]; module.exports = { @@ -18,4 +17,3 @@ module.exports = { XML_TOOL_OPENING_TAGS, XML_TOOL_CLOSING_TAGS, }; - diff --git a/internal/prompt/tool_calls.go b/internal/prompt/tool_calls.go index 4c14f6b..b24234d 100644 --- a/internal/prompt/tool_calls.go +++ b/internal/prompt/tool_calls.go @@ -38,7 +38,7 @@ func FormatToolCallsForPrompt(raw any) string { if len(blocks) == 0 { return "" } - return "\n" + strings.Join(blocks, "\n") + "\n" + return "\n" + strings.Join(blocks, "\n") + "\n" } // StringifyToolCallArguments normalizes tool arguments into a compact string @@ -105,16 +105,16 @@ func formatToolCallParametersForPrompt(raw any) string { body, ok := renderPromptToolXMLBody(value, " ") if ok { if strings.TrimSpace(body) == "" { - return " " + return " " } - return " \n" + body + "\n " + return " \n" + body + "\n " } fallback := StringifyToolCallArguments(raw) if strings.TrimSpace(fallback) == "" { fallback = "{}" } - return " " + renderPromptXMLText(fallback) + "" + return " " + renderPromptXMLText(fallback) + "" } func normalizePromptToolCallValue(raw any) any { diff --git a/internal/prompt/tool_calls_test.go b/internal/prompt/tool_calls_test.go index 2d30770..451120b 100644 --- a/internal/prompt/tool_calls_test.go +++ b/internal/prompt/tool_calls_test.go @@ -22,7 +22,7 @@ func TestFormatToolCallsForPromptXML(t *testing.T) { if got == "" { t.Fatal("expected non-empty formatted tool calls") } - if got != "\n \n search_web\n \n \n \n \n" { + if got != "\n \n search_web\n \n \n \n \n" { t.Fatalf("unexpected formatted tool call XML: %q", got) } } @@ -34,7 +34,7 @@ func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) { "arguments": `{"q":"a < b && c > d"}`, }, }) - want := "\n \n search<&>\n \n d]]>\n \n \n" + want := "\n \n search<&>\n \n d]]>\n \n \n" if got != want { t.Fatalf("unexpected escaped tool call XML: %q", got) } @@ -50,7 +50,7 @@ func TestFormatToolCallsForPromptUsesCDATAForMultilineContent(t *testing.T) { }, }, }) - want := "\n \n write_file\n \n \n \n \n \n" + want := "\n \n write_file\n \n \n \n \n \n" if got != want { t.Fatalf("unexpected multiline cdata tool call XML: %q", got) } diff --git a/internal/toolcall/regression_test.go b/internal/toolcall/regression_test.go index d268374..0e58952 100644 --- a/internal/toolcall/regression_test.go +++ b/internal/toolcall/regression_test.go @@ -13,18 +13,18 @@ func TestRegression_RobustXMLAndCDATA(t *testing.T) { }{ { name: "Standard JSON parameters (Regression)", - text: `foo{"a": 1}`, + text: `foo{"a": 1}`, expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"a": float64(1)}}}, }, { name: "XML tags parameters (Regression)", - text: `foohello`, + text: `foohello`, expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"arg1": "hello"}}}, }, { name: "CDATA parameters (New Feature)", - text: `write_file and & symbols]]>`, + text: `write_file and & symbols]]>`, expected: []ParsedToolCall{{ Name: "write_file", Input: map[string]any{"content": "line 1\nline 2 with and & symbols"}, @@ -32,9 +32,9 @@ line 2 with and & symbols]]>`, }, { name: "Nested XML with repeated parameters (New Feature)", - text: `write_filescript.shwrite_filescript.shfirstsecond`, +]]>firstsecond`, expected: []ParsedToolCall{{ Name: "write_file", Input: map[string]any{ @@ -46,7 +46,7 @@ echo "hello" }, { name: "Dirty XML with unescaped symbols (Robustness Improvement)", - text: `bashecho "hello" > out.txt && cat out.txt`, + text: `bashecho "hello" > out.txt && cat out.txt`, expected: []ParsedToolCall{{ Name: "bash", Input: map[string]any{"command": "echo \"hello\" > out.txt && cat out.txt"}, @@ -54,7 +54,7 @@ echo "hello" }, { name: "Mixed JSON inside CDATA (New Hybrid Case)", - text: `foo`, + text: `foo`, expected: []ParsedToolCall{{ Name: "foo", Input: map[string]any{"json_param": "works"}, diff --git a/internal/toolcall/tool_prompt.go b/internal/toolcall/tool_prompt.go index 8e896e9..7990ef8 100644 --- a/internal/toolcall/tool_prompt.go +++ b/internal/toolcall/tool_prompt.go @@ -36,19 +36,19 @@ func BuildToolCallInstructions(toolNames []string) string { return `TOOL CALL FORMAT — FOLLOW EXACTLY: - + TOOL_NAME_HERE - + - + - + RULES: -1) Use the XML format only. Never emit JSON or function-call syntax. -2) Put one or more entries under a single root. -3) Parameters must be XML, not JSON. +1) Use the XML wrapper format only. +2) Put one or more entries under a single root. +3) Use for the tool name and for the argument container. 4) All string values must use , even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries. 5) Objects use nested XML elements. Arrays may repeat the same tag or use children. 6) Numbers, booleans, and null stay plain text. @@ -64,53 +64,51 @@ PARAMETER SHAPES: 【WRONG — Do NOT do these】: Wrong 1 — mixed text after XML: - ... I hope this helps. -Wrong 2 — function-call syntax: - Grep({"pattern": "token"}) -Wrong 3 — JSON parameters: - ` + ex1 + `{"path":"x"} -Wrong 4 — Markdown code fences: + ... I hope this helps. +Wrong 2 — JSON payload inside : + ` + ex1 + `{"path":"x"} +Wrong 3 — Markdown code fences: ` + "```xml" + ` - ... + ... ` + "```" + ` -Remember: The ONLY valid way to use tools is the XML block at the end of your response. +Remember: The ONLY valid way to use tools is the ... XML block at the end of your response. 【CORRECT EXAMPLES】: Example A — Single tool: - + ` + ex1 + ` - ` + ex1Params + ` + ` + ex1Params + ` - + Example B — Two tools in parallel: - + ` + ex1 + ` - ` + ex1Params + ` + ` + ex1Params + ` ` + ex2 + ` - ` + ex2Params + ` + ` + ex2Params + ` - + Example C — Tool with nested XML parameters: - + ` + ex3 + ` - ` + ex3Params + ` + ` + ex3Params + ` - + Example D — Tool with long script using CDATA (RELIABLE FOR CODE/SCRIPTS): - + ` + ex2 + ` - + ` + promptCDATA("script.sh") + ` - + - + ` } diff --git a/internal/toolcall/tool_prompt_test.go b/internal/toolcall/tool_prompt_test.go index 67aeb27..85cdeb4 100644 --- a/internal/toolcall/tool_prompt_test.go +++ b/internal/toolcall/tool_prompt_test.go @@ -10,7 +10,7 @@ func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) { if !strings.Contains(out, `exec_command`) { t.Fatalf("expected exec_command in examples, got: %s", out) } - if !strings.Contains(out, ``) { + if !strings.Contains(out, ``) { t.Fatalf("expected cmd parameter example for exec_command, got: %s", out) } } @@ -20,7 +20,7 @@ func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T if !strings.Contains(out, `execute_command`) { t.Fatalf("expected execute_command in examples, got: %s", out) } - if !strings.Contains(out, ``) { + if !strings.Contains(out, ``) { t.Fatalf("expected command parameter example for execute_command, got: %s", out) } } diff --git a/internal/toolcall/toolcalls_input_parse.go b/internal/toolcall/toolcalls_input_parse.go index b987e64..4b7ef7e 100644 --- a/internal/toolcall/toolcalls_input_parse.go +++ b/internal/toolcall/toolcalls_input_parse.go @@ -2,6 +2,7 @@ package toolcall import ( "encoding/json" + "html" "strings" "unicode" ) @@ -13,7 +14,7 @@ func parseToolCallInput(v any) map[string]any { case map[string]any: return x case string: - raw := strings.TrimSpace(x) + raw := strings.TrimSpace(html.UnescapeString(x)) if raw == "" { return map[string]any{} } diff --git a/internal/toolcall/toolcalls_markup.go b/internal/toolcall/toolcalls_markup.go index 94420dc..3d8e657 100644 --- a/internal/toolcall/toolcalls_markup.go +++ b/internal/toolcall/toolcalls_markup.go @@ -7,120 +7,10 @@ import ( "strings" ) -var toolCallMarkupTagNames = []string{"tool_call", "function_call", "invoke"} -var toolCallMarkupTagPatternByName = map[string]*regexp.Regexp{ - "tool_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?tool_call\b([^>]*)>(.*?)`), - "function_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function_call\b([^>]*)>(.*?)`), - "invoke": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)>(.*?)`), -} -var toolCallMarkupSelfClosingPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)/>`) var toolCallMarkupKVPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?([a-z0-9_\-.]+)\b[^>]*>(.*?)`) -var toolCallMarkupAttrPattern = regexp.MustCompile(`(?is)(name|function|tool)\s*=\s*"([^"]+)"`) -var anyTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) -var toolCallMarkupNameTagNames = []string{"name", "function"} -var toolCallMarkupNamePatternByTag = map[string]*regexp.Regexp{ - "name": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?name\b[^>]*>(.*?)`), - "function": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function\b[^>]*>(.*?)`), -} // cdataPattern matches a standalone CDATA section. var cdataPattern = regexp.MustCompile(`(?is)^$`) -var toolCallMarkupArgsTagNames = []string{"input", "arguments", "argument", "parameters", "parameter", "args", "params"} -var toolCallMarkupArgsPatternByTag = map[string]*regexp.Regexp{ - "input": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?input\b[^>]*>(.*?)`), - "arguments": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>(.*?)`), - "argument": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?argument\b[^>]*>(.*?)`), - "parameters": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>(.*?)`), - "parameter": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>(.*?)`), - "args": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?args\b[^>]*>(.*?)`), - "params": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?params\b[^>]*>(.*?)`), -} - -func parseMarkupToolCalls(text string) []ParsedToolCall { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return nil - } - - out := make([]ParsedToolCall, 0) - for _, tagName := range toolCallMarkupTagNames { - pattern := toolCallMarkupTagPatternByName[tagName] - for _, m := range pattern.FindAllStringSubmatch(trimmed, -1) { - if len(m) < 3 { - continue - } - attrs := strings.TrimSpace(m[1]) - inner := strings.TrimSpace(m[2]) - if parsed := parseMarkupSingleToolCall(attrs, inner); parsed.Name != "" { - out = append(out, parsed) - } - } - } - for _, m := range toolCallMarkupSelfClosingPattern.FindAllStringSubmatch(trimmed, -1) { - if len(m) < 2 { - continue - } - if parsed := parseMarkupSingleToolCall(strings.TrimSpace(m[1]), ""); parsed.Name != "" { - out = append(out, parsed) - } - } - if len(out) == 0 { - return nil - } - return out -} - -func parseMarkupSingleToolCall(attrs string, inner string) ParsedToolCall { - // Try parsing inner content as a JSON tool call object. - if raw := strings.TrimSpace(inner); raw != "" && strings.HasPrefix(raw, "{") { - var obj map[string]any - if err := json.Unmarshal([]byte(raw), &obj); err == nil { - name, _ := obj["name"].(string) - if name == "" { - if fn, ok := obj["function"].(map[string]any); ok { - name, _ = fn["name"].(string) - } - } - if name == "" { - if fc, ok := obj["functionCall"].(map[string]any); ok { - name, _ = fc["name"].(string) - } - } - if strings.TrimSpace(name) != "" { - input := parseToolCallInput(obj["input"]) - if len(input) == 0 { - if args, ok := obj["arguments"]; ok { - input = parseToolCallInput(args) - } - } - return ParsedToolCall{Name: strings.TrimSpace(name), Input: input} - } - } - } - - name := "" - if m := toolCallMarkupAttrPattern.FindStringSubmatch(attrs); len(m) >= 3 { - name = strings.TrimSpace(m[2]) - } - if name == "" { - name = findMarkupTagValue(inner, toolCallMarkupNameTagNames, toolCallMarkupNamePatternByTag) - } - if name == "" { - return ParsedToolCall{} - } - - input := map[string]any{} - if argsRaw := findMarkupTagValue(inner, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); argsRaw != "" { - input = parseMarkupInput(argsRaw) - } else if kv := parseMarkupKVObject(inner); len(kv) > 0 { - input = kv - } - return ParsedToolCall{Name: name, Input: input} -} - -func parseMarkupInput(raw string) map[string]any { - return parseStructuredToolCallInput(raw) -} func parseMarkupKVObject(text string) map[string]any { matches := toolCallMarkupKVPattern.FindAllStringSubmatch(strings.TrimSpace(text), -1) @@ -212,23 +102,3 @@ func extractRawTagValue(inner string) string { // but for KV objects we usually want the value. return html.UnescapeString(inner) } - -func stripTagText(text string) string { - return strings.TrimSpace(anyTagPattern.ReplaceAllString(text, "")) -} - -func findMarkupTagValue(text string, tagNames []string, patternByTag map[string]*regexp.Regexp) string { - for _, tag := range tagNames { - pattern := patternByTag[tag] - if pattern == nil { - continue - } - if m := pattern.FindStringSubmatch(text); len(m) >= 2 { - value := extractRawTagValue(m[1]) - if value != "" { - return value - } - } - } - return "" -} diff --git a/internal/toolcall/toolcalls_parse.go b/internal/toolcall/toolcalls_parse.go index bc61124..70c1529 100644 --- a/internal/toolcall/toolcalls_parse.go +++ b/internal/toolcall/toolcalls_parse.go @@ -46,9 +46,6 @@ func parseToolCallsDetailedXMLOnly(text string) ToolCallParseResult { } parsed := parseXMLToolCalls(trimmed) - if len(parsed) == 0 { - parsed = parseMarkupToolCalls(trimmed) - } if len(parsed) == 0 { return result } @@ -77,12 +74,8 @@ func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []strin func looksLikeToolCallSyntax(text string) bool { lower := strings.ToLower(text) - return strings.Contains(lower, "\s*(.*?)\s*`) -var functionCallPattern = regexp.MustCompile(`(?is)\s*([^<]+?)\s*`) -var functionParamPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) -var antmlFunctionCallPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?function_call[^>]*(?:name|function)="([^"]+)"[^>]*>\s*(.*?)\s*`) -var antmlArgumentPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?argument\s+name="([^"]+)"\s*>\s*(.*?)\s*`) -var invokeCallPattern = regexp.MustCompile(`(?is)(.*?)`) -var invokeParamPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) -var toolUseFunctionPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) -var toolUseNameParametersPattern = regexp.MustCompile(`(?is)\s*\s*([^<]+?)\s*\s*\s*(.*?)\s*\s*`) -var toolUseFunctionNameParametersPattern = regexp.MustCompile(`(?is)\s*\s*([^<]+?)\s*\s*\s*(.*?)\s*\s*`) -var toolUseToolNameBodyPattern = regexp.MustCompile(`(?is)\s*\s*([^<]+?)\s*\s*(.*?)\s*`) -var xmlToolNamePatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>(.*?)`), - regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function_name\b[^>]*>(.*?)`), -} +var xmlToolsWrapperPattern = regexp.MustCompile(`(?is)]*>\s*(.*?)\s*`) +var xmlToolCallPattern = regexp.MustCompile(`(?is)]*>\s*(.*?)\s*`) +var xmlCanonicalToolCallBodyPattern = regexp.MustCompile(`(?is)^\s*<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>(.*?)\s*<(?:[a-z0-9_:-]+:)?param\b[^>]*>(.*?)\s*$`) func parseXMLToolCalls(text string) []ParsedToolCall { - matches := xmlToolCallPattern.FindAllString(text, -1) - out := make([]ParsedToolCall, 0, len(matches)+1) - for _, block := range matches { - call, ok := parseSingleXMLToolCall(block) - if !ok { + wrappers := xmlToolsWrapperPattern.FindAllStringSubmatch(text, -1) + if len(wrappers) == 0 { + return nil + } + out := make([]ParsedToolCall, 0, len(wrappers)) + for _, wrapper := range wrappers { + if len(wrapper) < 2 { continue } - out = append(out, call) + for _, block := range xmlToolCallPattern.FindAllString(wrapper[1], -1) { + call, ok := parseSingleXMLToolCall(block) + if !ok { + continue + } + out = append(out, call) + } } - if len(out) > 0 { - return out + if len(out) == 0 { + return nil } - if call, ok := parseFunctionCallTagStyle(text); ok { - return []ParsedToolCall{call} - } - if calls := parseAntmlFunctionCallStyles(text); len(calls) > 0 { - return calls - } - if call, ok := parseInvokeFunctionCallStyle(text); ok { - return []ParsedToolCall{call} - } - if call, ok := parseToolUseFunctionStyle(text); ok { - return []ParsedToolCall{call} - } - if call, ok := parseToolUseNameParametersStyle(text); ok { - return []ParsedToolCall{call} - } - if call, ok := parseToolUseFunctionNameParametersStyle(text); ok { - return []ParsedToolCall{call} - } - if call, ok := parseToolUseToolNameBodyStyle(text); ok { - return []ParsedToolCall{call} - } - return nil + return out } func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) { @@ -69,15 +43,10 @@ func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) { if strings.HasPrefix(inner, "{") { var payload map[string]any if err := json.Unmarshal([]byte(inner), &payload); err == nil { - name := strings.TrimSpace(asString(payload["tool"])) - if name == "" { - name = strings.TrimSpace(asString(payload["tool_name"])) - } + name := strings.TrimSpace(asString(payload["name"])) if name != "" { input := map[string]any{} - if params, ok := payload["params"].(map[string]any); ok { - input = params - } else if params, ok := payload["parameters"].(map[string]any); ok { + if params, ok := payload["input"].(map[string]any); ok { input = params } return ParsedToolCall{Name: name, Input: input}, true @@ -85,350 +54,15 @@ func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) { } } - name := "" - params := extractXMLToolParamsByRegex(inner) - dec := xml.NewDecoder(strings.NewReader(block)) - inTool := false - for { - tok, err := dec.Token() - if err != nil { - break - } - switch t := tok.(type) { - case xml.StartElement: - tag := strings.ToLower(t.Name.Local) - switch tag { - case "tool": - inTool = true - for _, attr := range t.Attr { - if strings.EqualFold(strings.TrimSpace(attr.Name.Local), "name") && strings.TrimSpace(name) == "" { - name = strings.TrimSpace(attr.Value) - } - } - case "parameters": - var node struct { - Inner string `xml:",innerxml"` - } - if err := dec.DecodeElement(&node, &t); err == nil { - inner := strings.TrimSpace(node.Inner) - if inner != "" { - extracted := extractRawTagValue(inner) - if parsed := parseStructuredToolCallInput(extracted); len(parsed) > 0 { - for k, vv := range parsed { - params[k] = vv - } - } - } - } - case "tool_name", "function_name", "name": - var v string - if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" { - name = strings.TrimSpace(v) - } - case "input", "arguments", "argument", "args", "params": - var v string - if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" { - if parsed := parseStructuredToolCallInput(strings.TrimSpace(v)); len(parsed) > 0 { - for k, vv := range parsed { - params[k] = vv - } - } - } - default: - if inTool { - var v string - if err := dec.DecodeElement(&v, &t); err == nil { - params[t.Name.Local] = strings.TrimSpace(html.UnescapeString(v)) - } - } - } - case xml.EndElement: - tag := strings.ToLower(t.Name.Local) - if tag == "tool" { - inTool = false - } - } - } - if strings.TrimSpace(name) == "" { - name = strings.TrimSpace(html.UnescapeString(extractXMLToolNameByRegex(stripTopLevelXMLParameters(inner)))) + m := xmlCanonicalToolCallBodyPattern.FindStringSubmatch(inner) + if len(m) < 3 { + return ParsedToolCall{}, false } + name := strings.TrimSpace(html.UnescapeString(extractRawTagValue(m[1]))) if strings.TrimSpace(name) == "" { return ParsedToolCall{}, false } - return ParsedToolCall{Name: strings.TrimSpace(html.UnescapeString(name)), Input: params}, true -} - -func stripTopLevelXMLParameters(inner string) string { - out := strings.TrimSpace(inner) - for { - idx := strings.Index(strings.ToLower(out), "") - if openEnd < 0 { - return out - } - closeIdx := strings.Index(segmentLower, "") - if closeIdx < 0 { - return out[:idx] - } - end := idx + closeIdx + len("") - out = out[:idx] + out[end:] - } -} - -func extractXMLToolNameByRegex(inner string) string { - for _, pattern := range xmlToolNamePatterns { - if m := pattern.FindStringSubmatch(inner); len(m) >= 2 { - if v := strings.TrimSpace(stripTagText(m[1])); v != "" { - return v - } - } - } - return "" -} - -func extractXMLToolParamsByRegex(inner string) map[string]any { - raw := findMarkupTagValue(inner, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag) - if raw == "" { - return map[string]any{} - } - parsed := parseMarkupInput(raw) - if parsed == nil { - return map[string]any{} - } - return parsed -} - -func parseFunctionCallTagStyle(text string) (ParsedToolCall, bool) { - m := functionCallPattern.FindStringSubmatch(text) - if len(m) < 2 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - input := map[string]any{} - for _, pm := range functionParamPattern.FindAllStringSubmatch(text, -1) { - if len(pm) < 3 { - continue - } - key := strings.TrimSpace(pm[1]) - val := extractRawTagValue(pm[2]) - if key != "" { - if parsed := parseStructuredToolCallInput(val); len(parsed) > 0 { - if isOnlyRawValue(parsed, val) { - input[key] = val - } else { - input[key] = parsed - } - } - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseAntmlFunctionCallStyles(text string) []ParsedToolCall { - matches := antmlFunctionCallPattern.FindAllStringSubmatch(text, -1) - if len(matches) == 0 { - return nil - } - out := make([]ParsedToolCall, 0, len(matches)) - for _, m := range matches { - if call, ok := parseSingleAntmlFunctionCallMatch(m); ok { - out = append(out, call) - } - } - if len(out) == 0 { - return nil - } - return out -} - -func parseSingleAntmlFunctionCallMatch(m []string) (ParsedToolCall, bool) { - if len(m) < 3 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - body := strings.TrimSpace(m[2]) - input := map[string]any{} - if strings.HasPrefix(body, "{") { - if err := json.Unmarshal([]byte(body), &input); err == nil { - return ParsedToolCall{Name: name, Input: input}, true - } - } - for _, am := range antmlArgumentPattern.FindAllStringSubmatch(body, -1) { - if len(am) < 3 { - continue - } - k := strings.TrimSpace(am[1]) - v := extractRawTagValue(am[2]) - if k != "" { - input[k] = v - } - } - if len(input) > 0 { - return ParsedToolCall{Name: name, Input: input}, true - } - if paramsRaw := findMarkupTagValue(body, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); paramsRaw != "" { - if parsed := parseMarkupInput(paramsRaw); len(parsed) > 0 { - return ParsedToolCall{Name: name, Input: parsed}, true - } - } - if strings.Contains(body, "<") { - if parsed := parseStructuredToolCallInput(body); len(parsed) > 0 && !isOnlyRawValue(parsed, body) { - return ParsedToolCall{Name: name, Input: parsed}, true - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseInvokeFunctionCallStyle(text string) (ParsedToolCall, bool) { - m := invokeCallPattern.FindStringSubmatch(text) - if len(m) < 3 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - input := map[string]any{} - for _, pm := range invokeParamPattern.FindAllStringSubmatch(m[2], -1) { - if len(pm) < 3 { - continue - } - k := strings.TrimSpace(pm[1]) - v := extractRawTagValue(pm[2]) - if k != "" { - if parsed := parseStructuredToolCallInput(v); len(parsed) > 0 { - if isOnlyRawValue(parsed, v) { - input[k] = v - } else { - input[k] = parsed - } - } - } - } - if len(input) == 0 { - if argsRaw := findMarkupTagValue(m[2], toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); argsRaw != "" { - input = parseMarkupInput(argsRaw) - } else if kv := parseMarkupKVObject(m[2]); len(kv) > 0 { - input = kv - } else if parsed := parseStructuredToolCallInput(m[2]); len(parsed) > 0 && !isOnlyRawValue(parsed, strings.TrimSpace(html.UnescapeString(m[2]))) { - input = parsed - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) { - m := toolUseFunctionPattern.FindStringSubmatch(text) - if len(m) < 3 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - body := m[2] - input := map[string]any{} - for _, pm := range invokeParamPattern.FindAllStringSubmatch(body, -1) { - if len(pm) < 3 { - continue - } - k := strings.TrimSpace(pm[1]) - v := extractRawTagValue(pm[2]) - if k != "" { - if parsed := parseStructuredToolCallInput(v); len(parsed) > 0 { - if isOnlyRawValue(parsed, v) { - input[k] = v - } else { - input[k] = parsed - } - } - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseToolUseNameParametersStyle(text string) (ParsedToolCall, bool) { - m := toolUseNameParametersPattern.FindStringSubmatch(text) - if len(m) < 3 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - raw := strings.TrimSpace(m[2]) - input := map[string]any{} - if raw != "" { - if parsed := parseStructuredToolCallInput(raw); len(parsed) > 0 { - input = parsed - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseToolUseFunctionNameParametersStyle(text string) (ParsedToolCall, bool) { - m := toolUseFunctionNameParametersPattern.FindStringSubmatch(text) - if len(m) < 3 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - raw := strings.TrimSpace(m[2]) - input := map[string]any{} - if raw != "" { - if parsed := parseStructuredToolCallInput(raw); len(parsed) > 0 { - input = parsed - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseToolUseToolNameBodyStyle(text string) (ParsedToolCall, bool) { - m := toolUseToolNameBodyPattern.FindStringSubmatch(text) - if len(m) < 3 { - return ParsedToolCall{}, false - } - name := strings.TrimSpace(html.UnescapeString(m[1])) - if name == "" { - return ParsedToolCall{}, false - } - body := strings.TrimSpace(m[2]) - input := map[string]any{} - if body != "" { - if kv := parseXMLChildKV(body); len(kv) > 0 { - input = kv - } else if kv := parseMarkupKVObject(body); len(kv) > 0 { - input = kv - } else if parsed := parseStructuredToolCallInput(body); len(parsed) > 0 { - input = parsed - } - } - return ParsedToolCall{Name: name, Input: input}, true -} - -func parseXMLChildKV(body string) map[string]any { - trimmed := strings.TrimSpace(body) - if trimmed == "" { - return nil - } - parsed := parseStructuredToolCallInput(trimmed) - if len(parsed) == 0 { - return nil - } - return parsed + return ParsedToolCall{Name: name, Input: parseStructuredToolCallInput(m[2])}, true } func asString(v any) string { diff --git a/internal/toolcall/toolcalls_test.go b/internal/toolcall/toolcalls_test.go index ec1fa5b..25ee32c 100644 --- a/internal/toolcall/toolcalls_test.go +++ b/internal/toolcall/toolcalls_test.go @@ -16,8 +16,8 @@ func TestFormatOpenAIToolCalls(t *testing.T) { } } -func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) { - text := `Bashpwdshow cwd` +func TestParseToolCallsSupportsToolsWrapper(t *testing.T) { + text := `Bashpwdshow cwd` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -30,10 +30,10 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) { } } -func TestParseToolCallsSupportsMultilineCDATAAndRepeatedXMLTags(t *testing.T) { - text := `write_filescript.shwrite_filescript.shfirstsecond` +]]>firstsecond` calls := ParseToolCalls(text, []string{"write_file"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -54,8 +54,8 @@ echo "hello" } } -func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) { - text := `get_weather{"city":"beijing","unit":"c"}` +func TestParseToolCallsSupportsCanonicalParamsJSON(t *testing.T) { + text := `get_weather{"city":"beijing","unit":"c"}` calls := ParseToolCalls(text, []string{"get_weather"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -68,8 +68,8 @@ func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) { } } -func TestParseToolCallsPreservesRawMalformedXMLParameters(t *testing.T) { - text := `execute_commandcd /root && git status` +func TestParseToolCallsPreservesRawMalformedParams(t *testing.T) { + text := `execute_commandcd /root && git status` calls := ParseToolCalls(text, []string{"execute_command"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -86,8 +86,8 @@ func TestParseToolCallsPreservesRawMalformedXMLParameters(t *testing.T) { } } -func TestParseToolCallsSupportsXMLParametersJSONWithAmpersandCommand(t *testing.T) { - text := `execute_command{"command":"sshpass -p 'xxx' ssh -o StrictHostKeyChecking=no -p 1111 root@111.111.111.111 'cd /root && git clone https://github.com/ericc-ch/copilot-api.git'","cwd":null,"timeout":null}` +func TestParseToolCallsSupportsParamsJSONWithAmpersandCommand(t *testing.T) { + text := `execute_command{"command":"sshpass -p 'xxx' ssh -o StrictHostKeyChecking=no -p 1111 root@111.111.111.111 'cd /root && git clone https://github.com/ericc-ch/copilot-api.git'","cwd":null,"timeout":null}` calls := ParseToolCalls(text, []string{"execute_command"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -101,8 +101,8 @@ func TestParseToolCallsSupportsXMLParametersJSONWithAmpersandCommand(t *testing. } } -func TestParseToolCallsDoesNotTreatParameterNameTagAsToolName(t *testing.T) { - text := `file.txtpwd` +func TestParseToolCallsDoesNotTreatParamsNameTagAsToolName(t *testing.T) { + text := `execute_commandfile.txtpwd` calls := ParseToolCalls(text, []string{"execute_command"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -110,13 +110,13 @@ func TestParseToolCallsDoesNotTreatParameterNameTagAsToolName(t *testing.T) { if calls[0].Name != "execute_command" { t.Fatalf("expected tool name execute_command, got %q", calls[0].Name) } - if calls[0].Input["name"] != "file.txt" { + if calls[0].Input["tool_name"] != "file.txt" { t.Fatalf("expected parameter name preserved, got %#v", calls[0].Input) } } -func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) { - text := `Bashpwd` +func TestParseToolCallsDetailedMarksToolsSyntax(t *testing.T) { + text := `Bashpwd` res := ParseToolCallsDetailed(text, []string{"bash"}) if !res.SawToolCallSyntax { t.Fatalf("expected SawToolCallSyntax=true, got %#v", res) @@ -126,8 +126,8 @@ func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) { } } -func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) { - text := `{"tool":"Bash","params":{"command":"pwd","description":"show cwd"}}` +func TestParseToolCallsSupportsInlineJSONToolObject(t *testing.T) { + text := `{"name":"Bash","input":{"command":"pwd","description":"show cwd"}}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) @@ -140,170 +140,35 @@ func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) { } } -func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) { - text := `Bashls -lalist` - calls := ParseToolCalls(text, []string{"bash"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "Bash" { - t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) - } - if calls[0].Input["command"] != "ls -la" { - t.Fatalf("expected command argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) { - text := `{"command":"pwd","description":"x"}` - calls := ParseToolCalls(text, []string{"bash"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "Bash" { - t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) - } - if calls[0].Input["command"] != "pwd" { - t.Fatalf("expected command argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) { - text := `pwdx` - calls := ParseToolCalls(text, []string{"bash"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "Bash" { - t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) - } - if calls[0].Input["command"] != "pwd" { - t.Fatalf("expected command argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) { - text := `pwdd` - calls := ParseToolCalls(text, []string{"bash"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "Bash" { - t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) - } - if calls[0].Input["command"] != "pwd" { - t.Fatalf("expected command argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) { - text := `test` - calls := ParseToolCalls(text, []string{"search_web"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "search_web" { - t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name) - } - if calls[0].Input["query"] != "test" { - t.Fatalf("expected query argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) { - text := `write_file{"path":"/tmp/a.txt","content":"abc"}` - calls := ParseToolCalls(text, []string{"write_file"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "write_file" { - t.Fatalf("expected tool name write_file, got %q", calls[0].Name) - } - if calls[0].Input["path"] != "/tmp/a.txt" { - t.Fatalf("expected path argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) { - text := `write_file{"path":"/tmp/b.txt","content":"xyz"}` - calls := ParseToolCalls(text, []string{"write_file"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "write_file" { - t.Fatalf("expected tool name write_file, got %q", calls[0].Name) - } - if calls[0].Input["content"] != "xyz" { - t.Fatalf("expected content argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) { - text := `write_file/tmp/c.txthello` - calls := ParseToolCalls(text, []string{"write_file"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "write_file" { - t.Fatalf("expected tool name write_file, got %q", calls[0].Name) - } - if calls[0].Input["path"] != "/tmp/c.txt" { - t.Fatalf("expected path argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) { - text := `pwdshow cwd` - calls := ParseToolCalls(text, []string{"bash"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "Bash" { - t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) - } - if calls[0].Input["command"] != "pwd" { - t.Fatalf("expected command argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testing.T) { - text := `{"command":"pwd"}` - calls := ParseToolCalls(text, []string{"bash"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %#v", calls) - } - if calls[0].Name != "Bash" { - t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) - } - if calls[0].Input["command"] != "pwd" { - t.Fatalf("expected command argument, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) { - text := `{"command":"pwd"}{"file_path":"README.md"}` - calls := ParseToolCalls(text, []string{"bash", "read"}) - if len(calls) != 2 { - t.Fatalf("expected 2 calls, got %#v", calls) - } - if calls[0].Name != "Bash" || calls[1].Name != "Read" { - t.Fatalf("expected original names [Bash Read], got %#v", calls) - } -} - func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) { - text := `read_file{"path":"README.md"}` + text := `read_file{"path":"README.md"}` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected mismatched tags to be rejected, got %#v", calls) } } -func TestParseToolCallsDoesNotTreatParametersFunctionNameAsToolName(t *testing.T) { - text := `data_onlyREADME.md` +func TestParseToolCallsDoesNotTreatNameInsideParamsAsToolName(t *testing.T) { + text := `data_onlyREADME.md` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { - t.Fatalf("expected no tool call when function_name appears only under parameters, got %#v", calls) + t.Fatalf("expected no tool call when name appears only under params, got %#v", calls) + } +} + +func TestParseToolCallsRejectsLegacyToolCallsRoot(t *testing.T) { + text := `read_file{"path":"README.md"}` + calls := ParseToolCalls(text, []string{"read_file"}) + if len(calls) != 0 { + t.Fatalf("expected legacy tool_calls root to be rejected, got %#v", calls) + } +} + +func TestParseToolCallsRejectsLegacyParametersTag(t *testing.T) { + text := `read_file{"path":"README.md"}` + calls := ParseToolCalls(text, []string{"read_file"}) + if len(calls) != 0 { + t.Fatalf("expected legacy parameters tag to be rejected, got %#v", calls) } } @@ -445,7 +310,7 @@ func TestRepairLooseJSONWithNestedObjects(t *testing.T) { } func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) { - text := `Bash{"command":"echo a > out.txt"}` + text := `Bash{"command":"echo a > out.txt"}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected one call, got %#v", calls) @@ -457,7 +322,7 @@ func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) { } func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) { - text := "Here is an example:\n```xml\nread_file{\"path\":\"README.md\"}\n```\nDo not execute it." + text := "Here is an example:\n```xml\nread_file{\"path\":\"README.md\"}\n```\nDo not execute it." res := ParseToolCallsDetailed(text, []string{"read_file"}) if len(res.Calls) != 0 { t.Fatalf("expected no parsed calls for fenced example, got %#v", res.Calls) @@ -465,7 +330,7 @@ func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) { } func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) { - text := "```xml\nread_file{\"path\":\"README.md\"}\n```\nsearch{\"q\":\"golang\"}" + text := "```xml\nread_file{\"path\":\"README.md\"}\n```\nsearch{\"q\":\"golang\"}" res := ParseToolCallsDetailed(text, []string{"read_file", "search"}) if len(res.Calls) != 1 { t.Fatalf("expected exactly one parsed call outside fence, got %#v", res.Calls) @@ -476,7 +341,7 @@ func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) { } func TestParseToolCallsParsesAfterFourBacktickFence(t *testing.T) { - text := "````markdown\n```xml\nread_file{\"path\":\"README.md\"}\n```\n````\nsearch{\"q\":\"outside\"}" + text := "````markdown\n```xml\nread_file{\"path\":\"README.md\"}\n```\n````\nsearch{\"q\":\"outside\"}" res := ParseToolCallsDetailed(text, []string{"read_file", "search"}) if len(res.Calls) != 1 { t.Fatalf("expected exactly one parsed call outside four-backtick fence, got %#v", res.Calls) diff --git a/tests/compat/expected/toolcalls_function_call_tag.json b/tests/compat/expected/toolcalls_function_call_tag.json index 5bcd9ce..4643a9b 100644 --- a/tests/compat/expected/toolcalls_function_call_tag.json +++ b/tests/compat/expected/toolcalls_function_call_tag.json @@ -1,13 +1,6 @@ { - "calls": [ - { - "name": "read_file", - "input": { - "path": "README.MD" - } - } - ], - "sawToolCallSyntax": true, + "calls": [], + "sawToolCallSyntax": false, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +} diff --git a/tests/compat/expected/toolcalls_invoke_attr.json b/tests/compat/expected/toolcalls_invoke_attr.json index 5bcd9ce..4643a9b 100644 --- a/tests/compat/expected/toolcalls_invoke_attr.json +++ b/tests/compat/expected/toolcalls_invoke_attr.json @@ -1,13 +1,6 @@ { - "calls": [ - { - "name": "read_file", - "input": { - "path": "README.MD" - } - } - ], - "sawToolCallSyntax": true, + "calls": [], + "sawToolCallSyntax": false, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +} diff --git a/tests/compat/expected/toolcalls_xml_tool_call.json b/tests/compat/expected/toolcalls_xml_tool_call.json index 5bcd9ce..4643a9b 100644 --- a/tests/compat/expected/toolcalls_xml_tool_call.json +++ b/tests/compat/expected/toolcalls_xml_tool_call.json @@ -1,13 +1,6 @@ { - "calls": [ - { - "name": "read_file", - "input": { - "path": "README.MD" - } - } - ], - "sawToolCallSyntax": true, + "calls": [], + "sawToolCallSyntax": false, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +} diff --git a/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json b/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json index 8eabce0..4643a9b 100644 --- a/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json +++ b/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json @@ -1,14 +1,6 @@ { - "calls": [ - { - "name": "get_weather", - "input": { - "city": "beijing", - "unit": "c" - } - } - ], - "sawToolCallSyntax": true, + "calls": [], + "sawToolCallSyntax": false, "rejectedByPolicy": false, "rejectedToolNames": [] } diff --git a/tests/compat/fixtures/toolcalls/function_call_tag.json b/tests/compat/fixtures/toolcalls/function_call_tag.json index 0f35956..a345ed2 100644 --- a/tests/compat/fixtures/toolcalls/function_call_tag.json +++ b/tests/compat/fixtures/toolcalls/function_call_tag.json @@ -1,5 +1,5 @@ { - "text": "read_file{\"path\":\"README.MD\"}", + "text": "read_file{\"path\":\"README.MD\"}", "tool_names": [ "read_file" ] diff --git a/tests/compat/fixtures/toolcalls/xml_tool_call.json b/tests/compat/fixtures/toolcalls/xml_tool_call.json index 279f1a2..b4ba281 100644 --- a/tests/compat/fixtures/toolcalls/xml_tool_call.json +++ b/tests/compat/fixtures/toolcalls/xml_tool_call.json @@ -1,5 +1,5 @@ { - "text": "read_file{\"path\":\"README.MD\"}", + "text": "read_file{\"path\":\"README.MD\"}", "tool_names": [ "read_file" ] diff --git a/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json b/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json index 6ccd51e..22843dc 100644 --- a/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json +++ b/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json @@ -1,5 +1,5 @@ { - "text": "get_weather{\"city\":\"beijing\",\"unit\":\"c\"}", + "text": "get_weather{\"city\":\"beijing\",\"unit\":\"c\"}", "tool_names": [ "get_weather" ] diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index a5f29ac..80b4bd9 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -42,7 +42,7 @@ test('extractToolNames keeps only declared tool names (Go parity)', () => { }); test('parseToolCalls parses XML markup tool call', () => { - const payload = 'read_file{"path":"README.MD"}'; + const payload = 'read_file{"path":"README.MD"}'; const calls = parseToolCalls(payload, ['read_file']); assert.equal(calls.length, 1); assert.equal(calls[0].name, 'read_file'); @@ -61,7 +61,7 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co const text = [ 'I will call a tool now.', '```xml', - 'read_file{"path":"README.md"}', + 'read_file{"path":"README.md"}', '```', ].join('\n'); const calls = parseToolCalls(text, ['read_file']); @@ -69,7 +69,7 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co }); test('parseToolCalls keeps unknown schema names when toolNames is provided', () => { - const payload = 'not_in_schema{"q":"go"}'; + const payload = 'not_in_schema{"q":"go"}'; const calls = parseToolCalls(payload, ['search']); assert.equal(calls.length, 1); assert.equal(calls[0].name, 'not_in_schema'); @@ -77,7 +77,7 @@ test('parseToolCalls keeps unknown schema names when toolNames is provided', () test('sieve emits tool_calls for XML tool call payload', () => { const events = runSieve( - ['read_file{"path":"README.MD"}'], + ['read_file{"path":"README.MD"}'], ['read_file'], ); const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); @@ -88,8 +88,8 @@ test('sieve emits tool_calls for XML tool call payload', () => { test('sieve emits tool_calls when XML tag spans multiple chunks', () => { const events = runSieve( [ - 'read_file', - '{"path":"README.MD"}', + 'read_file', + '{"path":"README.MD"}', ], ['read_file'], ); @@ -103,10 +103,10 @@ test('sieve keeps long XML tool calls buffered until the closing tag arrives', ( const splitAt = longContent.length / 2; const events = runSieve( [ - '\n \n write_to_file\n \n \n \n write_to_file\n \n \n \n \n', + ']]>\n \n \n', ], ['write_to_file'], ); @@ -147,7 +147,7 @@ test('sieve keeps embedded invalid tool-like json as normal text to avoid stream }); test('sieve passes malformed executable-looking XML through as text', () => { - const chunk = '{"path":"README.MD"}'; + const chunk = '{"path":"README.MD"}'; const events = runSieve([chunk], ['read_file']); const leakedText = collectText(events); const hasToolCalls = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); @@ -159,14 +159,14 @@ test('sieve flushes incomplete captured XML tool blocks by falling back to raw t const events = runSieve( [ '前置正文G。', - '\n', + '\n', ' \n', ' read_file\n', ], ['read_file'], ); const leakedText = collectText(events); - const expected = ['前置正文G。', '\n', ' \n', ' read_file\n'].join(''); + const expected = ['前置正文G。', '\n', ' \n', ' read_file\n'].join(''); const hasToolCalls = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); assert.equal(hasToolCalls, false); assert.equal(leakedText, expected); @@ -176,7 +176,7 @@ test('sieve captures XML wrapper tags with attributes without leaking wrapper te const events = runSieve( [ '前置正文H。', - 'read_file{"path":"README.MD"}', + 'read_file{"path":"README.MD"}', '后置正文I。', ], ['read_file'], @@ -186,8 +186,8 @@ test('sieve captures XML wrapper tags with attributes without leaking wrapper te assert.equal(hasToolCall, true); assert.equal(leakedText.includes('前置正文H。'), true); assert.equal(leakedText.includes('后置正文I。'), true); - assert.equal(leakedText.includes(''), false); - assert.equal(leakedText.includes(''), false); + assert.equal(leakedText.includes(''), false); + assert.equal(leakedText.includes(''), false); }); test('sieve keeps plain text intact in tool mode when no tool call appears', () => { @@ -270,7 +270,7 @@ test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => { }); test('parseToolCalls rejects mismatched markup tags', () => { - const payload = 'read_file{"path":"README.md"}'; + const payload = 'read_file{"path":"README.md"}'; const calls = parseToolCalls(payload, ['read_file']); assert.equal(calls.length, 0); }); From f13ad231acef559e9a72f54ecc0830ce681c59f9 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 26 Apr 2026 01:58:15 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E5=85=A8=E5=B1=80=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 + API.en.md | 41 +- API.md | 43 +- README.MD | 22 +- README.en.md | 15 +- config.example.json | 7 +- docs/README.md | 4 + docs/prompt-compatibility.md | 391 ++++++++++++++++++ internal/adapter/claude/convert.go | 2 +- internal/adapter/claude/deps.go | 2 +- .../adapter/claude/deps_injection_test.go | 33 +- internal/adapter/claude/handler_messages.go | 2 +- internal/adapter/claude/proxy_vercel_test.go | 31 +- internal/adapter/claude/stream_status_test.go | 7 +- internal/admin/handler_config_import.go | 17 - internal/admin/handler_config_read.go | 7 +- internal/admin/handler_config_write.go | 8 +- internal/admin/handler_settings_parse.go | 43 +- internal/admin/handler_settings_read.go | 1 - internal/admin/handler_settings_runtime.go | 10 - internal/admin/handler_settings_test.go | 28 ++ internal/admin/handler_settings_write.go | 6 +- internal/claudeconv/convert.go | 25 +- internal/config/codec.go | 29 +- internal/config/config.go | 25 +- internal/config/config_edge_test.go | 70 ++-- internal/config/model_alias_test.go | 54 +++ internal/config/models.go | 151 +++++-- internal/config/store_accessors.go | 12 - internal/util/messages.go | 2 +- internal/util/messages_test.go | 12 + internal/util/util_edge_test.go | 18 +- webui/src/features/settings/ModelSection.jsx | 29 +- .../src/features/settings/useSettingsForm.js | 5 - webui/src/locales/en.json | 3 +- webui/src/locales/zh.json | 3 +- 36 files changed, 855 insertions(+), 308 deletions(-) create mode 100644 docs/prompt-compatibility.md diff --git a/AGENTS.md b/AGENTS.md index 77991bc..ff2006e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,8 @@ These rules apply to all agent-made changes in this repository. - Keep changes additive and tightly scoped to the requested feature or bugfix. - Do not mix unrelated refactors into feature PRs unless they are required to make the change pass gates. + +## Documentation Sync + +- `docs/prompt-compatibility.md` is the source-of-truth document for the “API -> pure-text web-chat context” compatibility flow. +- If a change affects message normalization, tool prompt injection, prompt-visible tool history, file/reference handling, history split, or completion payload assembly, update `docs/prompt-compatibility.md` in the same change. diff --git a/API.en.md b/API.en.md index a982b0e..15b491c 100644 --- a/API.en.md +++ b/API.en.md @@ -217,9 +217,9 @@ For `chat` / `responses` / `embeddings`, DS2API follows a wide-input/strict-outp Current built-in default aliases (excerpt): -- OpenAI: `gpt-4o`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-5`, `gpt-5-mini`, `gpt-5-codex` -- OpenAI reasoning: `o1`, `o1-mini`, `o3`, `o3-mini` -- Claude: `claude-sonnet-4-5`, `claude-haiku-4-5`, `claude-opus-4-6` (plus compatibility aliases `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus`) +- OpenAI: `gpt-4o`, `gpt-4.1`, `gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex` +- OpenAI reasoning: `o1`, `o1-mini`, `o3`, `o4-mini` +- Claude: `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` (plus compatibility aliases `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus`) - Gemini: `gemini-2.5-pro`, `gemini-2.5-flash` ### `POST /v1/chat/completions` @@ -235,7 +235,7 @@ Content-Type: application/json | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-5`, `gpt-5-mini`, `gpt-5-codex`, `o3`, `claude-opus-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash`, etc.) | +| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex`, `o3`, `claude-opus-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash`, etc.) | | `messages` | array | ✅ | OpenAI-style messages | | `stream` | boolean | ❌ | Default `false` | | `tools` | array | ❌ | Function calling schema | @@ -442,17 +442,17 @@ No auth required. { "object": "list", "data": [ - {"id": "claude-sonnet-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-haiku-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"} ], "first_id": "claude-opus-4-6", - "last_id": "claude-instant-1.0", + "last_id": "claude-3-haiku-20240307", "has_more": false } ``` -> Note: the example is partial; besides the current primary aliases, the real response also includes Claude 4.x snapshots plus historical 3.x / 2.x / 1.x IDs and common aliases. +> Note: the example is partial; besides the current primary aliases, the real response also includes Claude 4.x snapshots plus historical 3.x IDs and common aliases. ### `POST /anthropic/v1/messages` @@ -470,7 +470,7 @@ anthropic-version: 2023-06-01 | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `model` | string | ✅ | For example `claude-sonnet-4-5` / `claude-opus-4-6` / `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`), plus historical Claude model IDs | +| `model` | string | ✅ | For example `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`), plus historical Claude model IDs | | `messages` | array | ✅ | Claude-style messages | | `max_tokens` | number | ❌ | Auto-filled to `8192` when omitted; not strictly enforced by upstream bridge | | `stream` | boolean | ❌ | Default `false` | @@ -484,7 +484,7 @@ anthropic-version: 2023-06-01 "id": "msg_1738400000000000000", "type": "message", "role": "assistant", - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "content": [ {"type": "text", "text": "response"} ], @@ -538,7 +538,7 @@ data: {"type":"message_stop"} ```json { - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "messages": [ {"role": "user", "content": "Hello"} ] @@ -666,16 +666,16 @@ Returns sanitized config, including both `keys` and `api_keys`. "token_preview": "abcde..." } ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` ### `POST /admin/config` -Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`. +Only updates `keys`, `api_keys`, `accounts`, and `model_aliases`. If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback. **Request**: @@ -690,9 +690,9 @@ If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` @@ -707,7 +707,7 @@ Reads runtime settings and status, including: - `compat` (`wide_input_strict_output`, `strip_reference_markers`) - `responses` / `embeddings` - `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`) -- `claude_mapping` / `model_aliases` +- `model_aliases` - `env_backed`, `needs_vercel_sync` - `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings @@ -721,7 +721,6 @@ Hot-updates runtime settings. Supported fields: - `responses.store_ttl_seconds` - `embeddings.provider` - `auto_delete.mode` -- `claude_mapping` - `model_aliases` - `toolcall` policy is fixed and is no longer writable through settings @@ -746,7 +745,7 @@ Imports full config with: The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. Query params `?mode=merge` / `?mode=replace` are also supported. -Import accepts `keys`, `api_keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. +Import accepts `keys`, `api_keys`, `accounts`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. > `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`. @@ -1338,7 +1337,7 @@ curl http://localhost:5001/anthropic/v1/messages \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{ - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }' diff --git a/API.md b/API.md index 0fa81d3..e309eff 100644 --- a/API.md +++ b/API.md @@ -217,11 +217,13 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` 当前内置默认 alias(节选): -- OpenAI:`gpt-4o`、`gpt-4.1`、`gpt-4.1-mini`、`gpt-4.1-nano`、`gpt-5`、`gpt-5-mini`、`gpt-5-codex` +- OpenAI:`gpt-4o`、`gpt-4.1`、`gpt-4.1-mini`、`gpt-4.1-nano`、`gpt-5`、`gpt-5.4`、`gpt-5.5`、`gpt-5-mini`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5.5-pro`、`gpt-5-codex`、`gpt-5.3-codex` - OpenAI Reasoning:`o1`、`o1-mini`、`o3`、`o3-mini` -- Claude:`claude-sonnet-4-5`、`claude-haiku-4-5`、`claude-opus-4-6`(及 `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus` 兼容别名) +- Claude:`claude-sonnet-4-6`、`claude-haiku-4-5`、`claude-opus-4-6`(及 `claude-sonnet-4-5` / `claude-3-5-sonnet` / `claude-3-5-haiku` / `claude-3-opus` 兼容别名) - Gemini:`gemini-2.5-pro`、`gemini-2.5-flash` +> 截至 2026-04-26:OpenAI 开发者模型页当前推荐 `gpt-5.5` 作为旗舰 API 模型;ChatGPT Help Center 当前主打 `GPT-5.3 Instant / GPT-5.5 Thinking / GPT-5.5 Pro`;Anthropic 官方模型页当前主推 `claude-opus-4-6`、`claude-sonnet-4-6`、`claude-haiku-4-5`。 + ### `POST /v1/chat/completions` **请求头**: @@ -235,7 +237,7 @@ Content-Type: application/json | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias(如 `gpt-5`、`gpt-5-mini`、`gpt-5-codex`、`o3`、`claude-opus-4-6`、`gemini-2.5-pro`、`gemini-2.5-flash` 等) | +| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias(如 `gpt-5.5`、`gpt-5.4-mini`、`gpt-5.3-codex`、`o3`、`claude-opus-4-6`、`claude-sonnet-4-6`、`gemini-2.5-pro`、`gemini-2.5-flash` 等) | | `messages` | array | ✅ | OpenAI 风格消息数组 | | `stream` | boolean | ❌ | 默认 `false` | | `tools` | array | ❌ | Function Calling 定义 | @@ -443,17 +445,17 @@ data: [DONE] { "object": "list", "data": [ - {"id": "claude-sonnet-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-haiku-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, {"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"} ], "first_id": "claude-opus-4-6", - "last_id": "claude-instant-1.0", + "last_id": "claude-3-haiku-20240307", "has_more": false } ``` -> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots,以及 3.x / 2.x / 1.x 历史模型 ID 与常见别名。 +> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots,以及 3.x 历史模型 ID 与常见别名。 ### `POST /anthropic/v1/messages` @@ -471,7 +473,7 @@ anthropic-version: 2023-06-01 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `model` | string | ✅ | 例如 `claude-sonnet-4-5` / `claude-opus-4-6` / `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest`),并支持历史 Claude 模型 ID | +| `model` | string | ✅ | 例如 `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5`(兼容 `claude-sonnet-4-5`、`claude-3-5-haiku-latest`),并支持历史 Claude 模型 ID | | `messages` | array | ✅ | Claude 风格消息数组 | | `max_tokens` | number | ❌ | 缺省自动补 `8192`;当前实现不会硬性截断上游输出 | | `stream` | boolean | ❌ | 默认 `false` | @@ -485,7 +487,7 @@ anthropic-version: 2023-06-01 "id": "msg_1738400000000000000", "type": "message", "role": "assistant", - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "content": [ {"type": "text", "text": "回复内容"} ], @@ -539,7 +541,7 @@ data: {"type":"message_stop"} ```json { - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "messages": [ {"role": "user", "content": "你好"} ] @@ -667,16 +669,16 @@ data: {"type":"message_stop"} "token_preview": "abcde..." } ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` ### `POST /admin/config` -只更新 `keys`、`api_keys`、`accounts`、`claude_mapping`。 +只更新 `keys`、`api_keys`、`accounts`、`model_aliases`。 如果同时发送 `api_keys` 与 `keys`,优先保留 `api_keys` 中的结构化 `name` / `remark`;`keys` 仅作为旧格式兼容回退。 **请求**: @@ -691,9 +693,9 @@ data: {"type":"message_stop"} "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" + "model_aliases": { + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-opus-4-6": "deepseek-v4-pro" } } ``` @@ -708,7 +710,7 @@ data: {"type":"message_stop"} - `compat`(`wide_input_strict_output`、`strip_reference_markers`) - `responses` / `embeddings` - `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理) -- `claude_mapping` / `model_aliases` +- `model_aliases` - `env_backed`、`needs_vercel_sync` - `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改 @@ -722,7 +724,6 @@ data: {"type":"message_stop"} - `responses.store_ttl_seconds` - `embeddings.provider` - `auto_delete.mode` -- `claude_mapping` - `model_aliases` - `toolcall` 策略已固定,不再作为可写入字段 @@ -747,7 +748,7 @@ data: {"type":"message_stop"} 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 -导入时会接受 `keys`、`api_keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 +导入时会接受 `keys`、`api_keys`、`accounts`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 > `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。 @@ -1242,7 +1243,7 @@ curl http://localhost:5001/v1/responses \ -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt-5-codex", + "model": "gpt-5.3-codex", "input": "写一个 golang 的 hello world", "stream": true }' @@ -1341,7 +1342,7 @@ curl http://localhost:5001/anthropic/v1/messages \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{ - "model": "claude-sonnet-4-5", + "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "你好"}] }' diff --git a/README.MD b/README.MD index c8686bc..2e78110 100644 --- a/README.MD +++ b/README.MD @@ -122,18 +122,20 @@ flowchart LR | vision | `deepseek-v4-vision` | 默认开启,可由请求参数控制 | ❌ | | vision | `deepseek-v4-vision-search` | 默认开启,可由请求参数控制 | ✅ | -除原生模型外,也支持常见 alias 输入(如 `gpt-5`、`gpt-5-mini`、`gpt-5-codex`、`gpt-4.1`、`o3`、`claude-opus-4-6`、`claude-sonnet-4-5`、`gemini-2.5-pro`、`gemini-2.5-flash` 等),但 `/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID。 +除原生模型外,也支持常见 alias 输入(如 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.3-codex`、`gpt-4.1`、`o3`、`claude-opus-4-6`、`claude-sonnet-4-6`、`gemini-2.5-pro`、`gemini-2.5-flash` 等),但 `/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID。 ### Claude 接口(`GET /anthropic/v1/models`) | 当前常用模型 | 默认映射 | | --- | --- | -| `claude-sonnet-4-5` | `deepseek-v4-flash` | +| `claude-sonnet-4-6` | `deepseek-v4-flash` | | `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest`) | `deepseek-v4-flash` | | `claude-opus-4-6` | `deepseek-v4-pro` | -可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射关系。 -`/anthropic/v1/models` 除上述当前主别名外,还会返回 Claude 4.x snapshots,以及 3.x / 2.x / 1.x 历史模型 ID 与常见 alias,便于旧客户端直接兼容。 +可通过配置中的 `model_aliases` 覆盖映射关系。 +`/anthropic/v1/models` 除上述当前主别名外,还会返回 Claude 4.x snapshots,以及 3.x 历史模型 ID 与常见 alias,便于旧客户端直接兼容。 + +> 截至 2026-04-26:Anthropic 官方模型页当前主推 `claude-opus-4-6`、`claude-sonnet-4-6`、`claude-haiku-4-5`;OpenAI 官方开发者模型页当前推荐从 `gpt-5.5` 开始,ChatGPT Help Center 当前主打 `GPT-5.3 Instant / GPT-5.5 Thinking / GPT-5.5 Pro`。本文档中的 alias 示例按“兼容客户端会传来的最新官方模型 ID”维护。 #### Claude Code 接入避坑(实测) @@ -289,9 +291,9 @@ go run ./cmd/ds2api ], "model_aliases": { "gpt-4o": "deepseek-v4-flash", - "gpt-5": "deepseek-v4-flash", - "gpt-5-mini": "deepseek-v4-flash", - "gpt-5-codex": "deepseek-v4-pro", + "gpt-5.5": "deepseek-v4-flash", + "gpt-5.4-mini": "deepseek-v4-flash", + "gpt-5.3-codex": "deepseek-v4-pro", "o3": "deepseek-v4-pro", "claude-opus-4-6": "deepseek-v4-pro", "gemini-2.5-flash": "deepseek-v4-flash" @@ -306,10 +308,6 @@ go run ./cmd/ds2api "embeddings": { "provider": "deterministic" }, - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" - }, "admin": { "jwt_expire_hours": 24 }, @@ -335,7 +333,7 @@ go run ./cmd/ds2api - `toolcall`:旧字段,当前实现已固定为特征匹配 + 高置信早发;即使保留在配置里也会被忽略 - `responses.store_ttl_seconds`:`/v1/responses/{id}` 的内存缓存 TTL - `embeddings.provider`:embedding 提供方(当前内置 `deterministic/mock/builtin`) -- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`) +- `model_aliases`:全局统一模型映射表,OpenAI / Claude / Gemini 共用;项目内只维护这一套映射入口 - `admin`:管理后台设置(JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新 - `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算,`token_refresh_interval_hours=6` 为默认强制重登间隔 - `auto_delete.mode`:请求结束后如何清理 DeepSeek 远端聊天记录,支持 `none`(默认,不删除)、`single`(仅删除当前会话)、`all`(清空全部会话);旧配置里的 `auto_delete.sessions=true` 仍会被视为 `all` diff --git a/README.en.md b/README.en.md index 91c0bfe..f2e77cb 100644 --- a/README.en.md +++ b/README.en.md @@ -120,18 +120,18 @@ For the full module-by-module architecture and directory responsibilities, see [ | vision | `deepseek-v4-vision` | enabled by default, request-controlled | ❌ | | vision | `deepseek-v4-vision-search` | enabled by default, request-controlled | ✅ | -Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-5`, `gpt-5-mini`, `gpt-5-codex`, `gpt-4.1`, `o3`, `claude-opus-4-6`, `claude-sonnet-4-5`, `gemini-2.5-pro`, `gemini-2.5-flash`), but `/v1/models` returns normalized DeepSeek native model IDs. +Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-4.1`, `o3`, `claude-opus-4-6`, `claude-sonnet-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash`), but `/v1/models` returns normalized DeepSeek native model IDs. ### Claude Endpoint (`GET /anthropic/v1/models`) | Current common model | Default Mapping | | --- | --- | -| `claude-sonnet-4-5` | `deepseek-v4-flash` | +| `claude-sonnet-4-6` | `deepseek-v4-flash` | | `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`) | `deepseek-v4-flash` | | `claude-opus-4-6` | `deepseek-v4-pro` | -Override mapping via `claude_mapping` or `claude_model_mapping` in config. -Besides the current primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x / 2.x / 1.x IDs and common aliases for legacy client compatibility. +Override mapping via the global `model_aliases` config. +Besides the current primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x IDs and common aliases for legacy client compatibility. #### Claude Code integration pitfalls (validated) @@ -295,10 +295,6 @@ The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usua "embeddings": { "provider": "deterministic" }, - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" - }, "admin": { "jwt_expire_hours": 24 }, @@ -317,13 +313,12 @@ The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usua - `keys`: API access keys; clients authenticate via `Authorization: Bearer ` - `accounts`: DeepSeek account list, supports `email` or `mobile` login - `token`: Even if set in `config.json`, it is cleared during load (DS2API does not read persisted tokens from config); runtime tokens are maintained/refreshed in memory only -- `model_aliases`: Map common model names (GPT/Codex/Claude) to DeepSeek models +- `model_aliases`: Single global alias map shared by OpenAI / Claude / Gemini model names - `compat.wide_input_strict_output`: Keep `true` (current default policy) - `compat.strip_reference_markers`: Keep `true`; it strips reference markers from visible output - `toolcall`: Legacy field; the current behavior is fixed to feature matching + high-confidence early emit, and any config value is ignored - `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}` - `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in) -- `claude_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models (still compatible with `claude_model_mapping`) - `admin`: Admin panel settings (JWT expiry, password hash, etc.), hot-reloadable via Admin Settings API - `runtime`: Runtime parameters (concurrency limits, queue sizes, managed token refresh interval), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values, `token_refresh_interval_hours=6` is the default forced re-login interval - `auto_delete.mode`: How to clean up DeepSeek remote chat records after each request completes. Supported values: `none` (default, no deletion), `single` (delete only the current session), `all` (delete all sessions); legacy `auto_delete.sessions=true` is still treated as `all` diff --git a/config.example.json b/config.example.json index ce3d902..f93a2c3 100644 --- a/config.example.json +++ b/config.example.json @@ -39,7 +39,8 @@ ], "model_aliases": { "gpt-4o": "deepseek-v4-flash", - "gpt-5-codex": "deepseek-v4-pro", + "gpt-5.5": "deepseek-v4-flash", + "gpt-5.3-codex": "deepseek-v4-pro", "o3": "deepseek-v4-pro" }, "compat": { @@ -56,10 +57,6 @@ "embeddings": { "provider": "deterministic" }, - "claude_mapping": { - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro" - }, "admin": { "jwt_expire_hours": 24 }, diff --git a/docs/README.md b/docs/README.md index f8b5d8d..837fc87 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ ### 专题文档 +- [API -> 网页对话纯文本兼容主链路说明](./prompt-compatibility.md) - [Tool Calling 统一语义](./toolcall-semantics.md) - [DeepSeek SSE 行为结构说明(逆向观察)](./DeepSeekSSE行为结构说明-2026-04-05.md) @@ -23,6 +24,7 @@ - `README.MD` / `README.en.md`:面向首次接触用户,保留“是什么 + 怎么快速跑起来”。 - `docs/ARCHITECTURE*.md`:面向开发者,集中维护项目结构、模块职责与调用链。 - `API*.md`:面向客户端接入者,聚焦接口行为、鉴权和示例。 +- `docs/prompt-compatibility.md`:面向维护者,集中维护“API -> 网页对话纯文本上下文”的统一兼容语义;相关行为修改时必须同步更新。 - 其他 `docs/*.md`:主题化说明,避免在多个文档重复粘贴同一段内容。 --- @@ -42,6 +44,7 @@ Recommended reading order: ### Topical docs +- [API -> pure-text web-chat compatibility pipeline](./prompt-compatibility.md) - [Tool-calling unified semantics](./toolcall-semantics.md) - [DeepSeek SSE behavior notes (reverse-engineered)](./DeepSeekSSE行为结构说明-2026-04-05.md) @@ -50,4 +53,5 @@ Recommended reading order: - `README.MD` / `README.en.md`: onboarding-oriented (“what + quick start”). - `docs/ARCHITECTURE*.md`: developer-oriented source of truth for module boundaries and execution flow. - `API*.md`: integration-oriented behavior/contracts. +- `docs/prompt-compatibility.md`: maintainer-oriented source of truth for the “API -> pure-text web-chat context” compatibility flow; update it whenever related behavior changes. - Other `docs/*.md`: focused topics, avoid copy-pasting the same section into multiple files. diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md new file mode 100644 index 0000000..1b03ca0 --- /dev/null +++ b/docs/prompt-compatibility.md @@ -0,0 +1,391 @@ +# API -> 网页对话纯文本兼容主链路说明 + +文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [接口文档](../API.md) / [测试指南](./TESTING.md) + +> 本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。 +> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、history split、下游 completion payload 组装等行为,都必须同步更新本文档。 + +## 1. 核心结论 + +DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`、`attachments` 原样转发给下游。 + +而是把这些高层 API 语义,统一压缩成 DeepSeek 网页对话更容易理解的三类输入: + +1. `prompt` + 一个单字符串,里面带有角色标记、system 指令、历史消息、assistant reasoning 标签、历史 tool call XML 等。 +2. `ref_file_ids` + 一个文件引用数组,承载附件、inline 上传文件,以及必要时被拆出去的历史文件。 +3. 控制位 + 例如 `thinking_enabled`、`search_enabled`、部分 passthrough 参数。 + +也就是说,项目最重要的兼容动作,是把“结构化 API 会话”翻译成“网页对话纯文本上下文 + 文件引用”。 + +## 2. 为什么这是核心产物 + +因为对下游来说,真正稳定的输入面不是 OpenAI/Claude/Gemini 的原生 schema,而是: + +- 一段连续的对话 prompt +- 一组可引用文件 +- 少量开关位 + +这也是为什么很多表面上看像“协议兼容”的代码,最终都会收敛到同一类逻辑: + +- 先把不同协议的消息统一成内部消息序列 +- 再把工具声明改写成 system prompt 文本 +- 再把历史 tool call / tool result 改写成 prompt 可见内容 +- 最后输出成 DeepSeek completion payload + +## 3. 统一心智模型 + +当前主链路可以这样理解: + +```text +客户端请求 + -> 协议适配层(OpenAI / Claude / Gemini) + -> 统一消息标准化 + -> tool prompt 注入 + -> DeepSeek 风格 prompt 拼装 + -> 文件收集 / inline 上传 / history split + -> completion payload + -> 下游网页对话接口 +``` + +对应的关键代码入口: + +- OpenAI Chat / Responses: + [internal/adapter/openai/standard_request.go](../internal/adapter/openai/standard_request.go) +- OpenAI prompt 组装: + [internal/adapter/openai/prompt_build.go](../internal/adapter/openai/prompt_build.go) +- OpenAI 消息标准化: + [internal/adapter/openai/message_normalize.go](../internal/adapter/openai/message_normalize.go) +- Claude 标准化: + [internal/adapter/claude/standard_request.go](../internal/adapter/claude/standard_request.go) +- Claude 消息与 tool_use/tool_result 归一: + [internal/adapter/claude/handler_utils.go](../internal/adapter/claude/handler_utils.go) +- Gemini 复用 OpenAI prompt builder: + [internal/adapter/gemini/convert_request.go](../internal/adapter/gemini/convert_request.go) +- DeepSeek prompt 角色标记拼装: + [internal/prompt/messages.go](../internal/prompt/messages.go) +- prompt 可见 tool history XML: + [internal/prompt/tool_calls.go](../internal/prompt/tool_calls.go) +- completion payload: + [internal/util/standard_request.go](../internal/util/standard_request.go) + +## 4. 下游真正收到的东西 + +在“完成标准化后”,下游 completion payload 的核心形态是: + +```json +{ + "chat_session_id": "session-id", + "model_type": "default", + "parent_message_id": null, + "prompt": "<|begin▁of▁sentence|>...", + "ref_file_ids": [ + "file-history", + "file-systemprompt", + "file-other-attachment" + ], + "thinking_enabled": true, + "search_enabled": false +} +``` + +重点是: + +- `prompt` 才是对话上下文主载体。 +- `ref_file_ids` 只承载文件引用,不承载普通文本消息。 +- `tools` 不会作为“原生工具 schema”直接下发给下游,而是被改写进 `prompt`。 + +## 5. prompt 是怎么拼出来的 + +### 5.1 角色标记 + +最终 prompt 使用 DeepSeek 风格角色标记: + +- `<|begin▁of▁sentence|>` +- `<|System|>` +- `<|User|>` +- `<|Assistant|>` +- `<|Tool|>` +- `<|end▁of▁instructions|>` +- `<|end▁of▁sentence|>` +- `<|end▁of▁toolresults|>` + +实现位置: +[internal/prompt/messages.go](../internal/prompt/messages.go) + +### 5.2 thinking continuity 说明 + +如果启用了 thinking,会在最前面额外插入一个 system block,提醒模型: + +- 继续既有会话,不要重开 +- earlier messages 是 binding context +- 不要把最终回答只留在 reasoning 里 + +这部分不是客户端原始消息,而是兼容层主动补进去的连续性契约。 + +### 5.3 相邻同角色消息会合并 + +在最终 `MessagesPrepareWithThinking` 中,相邻同 role 的消息会被合并成一个块,中间插入空行。 + +这意味着: + +- prompt 中看到的是“合并后的 role block” +- 不是客户端传来的逐条 message 原样排列 + +## 6. tools 为什么是“文本注入”,不是原生下发 + +当前项目把工具能力视为“prompt 约束的一部分”。 + +具体做法: + +1. 把每个 tool 的名称、描述、参数 schema 序列化成文本。 +2. 拼成 `You have access to these tools:` 大段说明。 +3. 再附上统一的 XML tool call 格式约束。 +4. 把这整段内容并入 system prompt。 + +OpenAI 路径实现: +[internal/adapter/openai/handler_toolcall_format.go](../internal/adapter/openai/handler_toolcall_format.go) + +Claude 路径实现: +[internal/adapter/claude/handler_utils.go](../internal/adapter/claude/handler_utils.go) + +统一工具调用格式模板: +[internal/toolcall/tool_prompt.go](../internal/toolcall/tool_prompt.go) + +这也是项目“网页对话纯文本兼容”的关键设计: + +- tools 对下游来说,本质上是 prompt 内规则 +- 不是 native tool schema transport + +## 7. assistant 的 tool_calls / reasoning 如何保留 + +### 7.1 reasoning 保留方式 + +assistant 的 reasoning 会变成一个显式标签块: + +```text +[reasoning_content] +... +[/reasoning_content] +``` + +然后再接可见回答正文。 + +### 7.2 历史 tool_calls 保留方式 + +assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON,而会转成 prompt 可见的 XML: + +```xml + + + read_file + + + + + +``` + +这件事很重要,因为它决定了: + +- 历史工具调用在 prompt 中是“可见文本历史” +- 不是“隐藏结构化元数据” + +实现位置: +[internal/prompt/tool_calls.go](../internal/prompt/tool_calls.go) + +### 7.3 tool result 保留方式 + +tool / function role 的结果会作为 `<|Tool|>...<|end▁of▁toolresults|>` 进入 prompt。 + +如果 tool content 为空,当前会补成字符串 `"null"`,避免整个 tool turn 丢失。 + +## 8. files、附件、systemprompt 文件的实际语义 + +这里要明确区分两类东西: + +1. 文本型 system prompt + 例如 OpenAI `developer` / `system` / Responses `instructions` / Claude top-level `system` + 这类会进入 `prompt`。 +2. 文件型 systemprompt + 例如通过附件、`input_file`、base64、data URL 上传的文件 + 这类不会直接内联进 `prompt`,而是进入 `ref_file_ids`。 + +OpenAI 文件相关实现: + +- inline/base64/data URL 上传: + [internal/adapter/openai/file_inline_upload.go](../internal/adapter/openai/file_inline_upload.go) +- 文件 ID 收集: + [internal/adapter/openai/file_refs.go](../internal/adapter/openai/file_refs.go) + +结论: + +- “systemprompt 文字”在 prompt 里 +- “systemprompt 文件”通常只在 `ref_file_ids` 里 + +除非调用方自己把文件内容展开后再塞进 system/developer 文本,否则文件内容不会自动出现在 prompt 正文。 + +## 9. 多轮历史为什么不会一直完整内联在 prompt + +默认情况下,history split 是开启的,且默认从第 2 个 user turn 起就可能触发。 + +相关实现: + +- 配置访问器: + [internal/config/store_accessors.go](../internal/config/store_accessors.go) +- 历史拆分: + [internal/adapter/openai/history_split.go](../internal/adapter/openai/history_split.go) + +触发后行为: + +1. 旧历史消息被切出去。 +2. 旧历史会被重新序列化成一个文本文件。 +3. 文件名固定是 `IGNORE`。 +4. 该文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面。 +5. live prompt 只保留: + - system / developer + - 最新 user turn 起的上下文 + +历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript: + +```text +[file content end] + +<|begin▁of▁sentence|><|User|>...<|Assistant|>...<|Tool|>... + +[file name]: IGNORE +[file content begin] +``` + +所以“完整上下文”在当前实现里,其实通常分散在两处: + +- `prompt` 里的 live context +- `ref_file_ids` 指向的 history transcript file + +## 10. 各协议入口的差异 + +### 10.1 OpenAI Chat / Responses + +特点: + +- `developer` 会映射到 `system` +- Responses `instructions` 会 prepend 为 system message +- `tools` 会注入 system prompt +- `attachments` / `input_file` / inline 文件会进入 `ref_file_ids` +- history split 主要在这条链路里生效 + +### 10.2 Claude Messages + +特点: + +- top-level `system` 优先作为系统提示 +- `tool_use` / `tool_result` 会被转换成统一的 assistant/tool 历史语义 +- `tools` 同样会被并进 system prompt +- 当前代码里没有像 OpenAI 那样完整的 `ref_file_ids` 附件链路 + +### 10.3 Gemini + +特点: + +- `systemInstruction`、`contents.parts`、`functionCall`、`functionResponse` 会先归一 +- tools 会转成 OpenAI 风格 function schema +- prompt 构建复用 OpenAI 的 `BuildPromptForAdapter` + +也就是说,Gemini 在“最终 prompt 语义”上,尽量和 OpenAI 保持一致。 + +## 11. 一份贴近真实的最终上下文示意 + +假设用户发来一个多轮请求: + +- 有 system/developer 文本 +- 有 tools +- 有一个文件型 systemprompt 附件 +- 有历史 assistant tool call / tool result +- history split 已触发 + +那么最终上下文更接近: + +```json +{ + "prompt": "<|begin▁of▁sentence|><|System|>continuity instructions...\\n\\n原 system / developer\\n\\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>最新问题<|Assistant|>", + "ref_file_ids": [ + "file-history-ignore", + "file-systemprompt", + "file-other-attachment" + ], + "thinking_enabled": true, + "search_enabled": false +} +``` + +这正是“API 转网页对话纯文本”的核心成果: + +- 大部分结构化语义被压进 `prompt` +- 文件保持文件 +- 历史必要时拆文件 + +## 12. 修改时必须同步本文档的场景 + +只要触碰以下任一类行为,就必须在同一提交或同一 PR 中更新本文档: + +- 角色映射变更 +- system / developer / instructions 合并规则变更 +- assistant reasoning 保留格式变更 +- assistant 历史 `tool_calls` 的 XML 呈现方式变更 +- tool result 注入方式变更 +- tool prompt 模板或 tool_choice 约束变更 +- inline 文件上传 / 文件引用收集规则变更 +- history split 触发条件、上传格式、`IGNORE` 包装格式变更 +- completion payload 字段语义变更 +- Claude / Gemini 对这套统一语义的复用关系变更 + +优先检查这些文件: + +- `internal/adapter/openai/standard_request.go` +- `internal/adapter/openai/prompt_build.go` +- `internal/adapter/openai/message_normalize.go` +- `internal/adapter/openai/handler_toolcall_format.go` +- `internal/adapter/openai/file_inline_upload.go` +- `internal/adapter/openai/file_refs.go` +- `internal/adapter/openai/history_split.go` +- `internal/adapter/openai/responses_input_normalize.go` +- `internal/adapter/claude/standard_request.go` +- `internal/adapter/claude/handler_utils.go` +- `internal/adapter/gemini/convert_request.go` +- `internal/adapter/gemini/convert_messages.go` +- `internal/adapter/gemini/convert_tools.go` +- `internal/prompt/messages.go` +- `internal/prompt/tool_calls.go` +- `internal/util/standard_request.go` + +## 13. 建议的最小验证 + +改动这条链路后,至少补齐或检查这些测试: + +- `go test ./internal/prompt/...` +- `go test ./internal/adapter/openai/...` +- `go test ./internal/adapter/claude/...` +- `go test ./internal/adapter/gemini/...` +- `go test ./internal/util/...` + +如果改的是 tool call 相关兼容语义,还应同时检查: + +- `go test ./internal/toolcall/...` +- `node --test tests/node/stream-tool-sieve.test.js` + +## 14. 文档同步约定 + +本文档是这条兼容链路的专项说明。 + +如果外部接口行为也变了,还应同步检查: + +- [API.md](../API.md) +- [API.en.md](../API.en.md) +- [docs/toolcall-semantics.md](./toolcall-semantics.md) + +原则是: + +- 内部主链路变化,至少更新本文档 +- 外部可见契约变化,再同步更新 API 文档 diff --git a/internal/adapter/claude/convert.go b/internal/adapter/claude/convert.go index dbb5e1a..2233a65 100644 --- a/internal/adapter/claude/convert.go +++ b/internal/adapter/claude/convert.go @@ -4,7 +4,7 @@ import ( "ds2api/internal/claudeconv" ) -const defaultClaudeModel = "claude-sonnet-4-5" +const defaultClaudeModel = "claude-sonnet-4-6" func convertClaudeToDeepSeek(claudeReq map[string]any, store ConfigReader) map[string]any { return claudeconv.ConvertClaudeToDeepSeek(claudeReq, store, defaultClaudeModel) diff --git a/internal/adapter/claude/deps.go b/internal/adapter/claude/deps.go index 0088e81..7f82ba8 100644 --- a/internal/adapter/claude/deps.go +++ b/internal/adapter/claude/deps.go @@ -21,7 +21,7 @@ type DeepSeekCaller interface { } type ConfigReader interface { - ClaudeMapping() map[string]string + ModelAliases() map[string]string CompatStripReferenceMarkers() bool } diff --git a/internal/adapter/claude/deps_injection_test.go b/internal/adapter/claude/deps_injection_test.go index c585b36..c880dc4 100644 --- a/internal/adapter/claude/deps_injection_test.go +++ b/internal/adapter/claude/deps_injection_test.go @@ -3,13 +3,13 @@ package claude import "testing" type mockClaudeConfig struct { - m map[string]string + aliases map[string]string } -func (m mockClaudeConfig) ClaudeMapping() map[string]string { return m.m } -func (mockClaudeConfig) CompatStripReferenceMarkers() bool { return true } +func (m mockClaudeConfig) ModelAliases() map[string]string { return m.aliases } +func (mockClaudeConfig) CompatStripReferenceMarkers() bool { return true } -func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { +func TestNormalizeClaudeRequestUsesGlobalAliasMapping(t *testing.T) { req := map[string]any{ "model": "claude-opus-4-6", "messages": []any{ @@ -17,9 +17,8 @@ func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { }, } out, err := normalizeClaudeRequest(mockClaudeConfig{ - m: map[string]string{ - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro-search", + aliases: map[string]string{ + "claude-opus-4-6": "deepseek-v4-pro-search", }, }, req) if err != nil { @@ -32,3 +31,23 @@ func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { t.Fatalf("unexpected flags: thinking=%v search=%v", out.Standard.Thinking, out.Standard.Search) } } + +func TestNormalizeClaudeRequestPrefersGlobalAliasMapping(t *testing.T) { + req := map[string]any{ + "model": "claude-sonnet-4-6", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + out, err := normalizeClaudeRequest(mockClaudeConfig{ + aliases: map[string]string{ + "claude-sonnet-4-6": "deepseek-v4-flash", + }, + }, req) + if err != nil { + t.Fatalf("normalizeClaudeRequest error: %v", err) + } + if out.Standard.ResolvedModel != "deepseek-v4-flash" { + t.Fatalf("expected global alias to win for explicit model, got=%q", out.Standard.ResolvedModel) + } +} diff --git a/internal/adapter/claude/handler_messages.go b/internal/adapter/claude/handler_messages.go index 526d316..6ae23ab 100644 --- a/internal/adapter/claude/handler_messages.go +++ b/internal/adapter/claude/handler_messages.go @@ -44,7 +44,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C model, _ := req["model"].(string) stream := util.ToBool(req["stream"]) - // Preserve claude_mapping (fast/slow/opus routing) while proxying via OpenAI. + // Use the shared global model resolver so Claude/OpenAI/Gemini stay consistent. translateModel := model if store != nil { if norm, normErr := normalizeClaudeRequest(store, cloneMap(req)); normErr == nil && strings.TrimSpace(norm.Standard.ResolvedModel) != "" { diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/adapter/claude/proxy_vercel_test.go index 56ff708..67e62de 100644 --- a/internal/adapter/claude/proxy_vercel_test.go +++ b/internal/adapter/claude/proxy_vercel_test.go @@ -9,12 +9,10 @@ import ( ) type claudeProxyStoreStub struct { - mapping map[string]string + aliases map[string]string } -func (s claudeProxyStoreStub) ClaudeMapping() map[string]string { - return s.mapping -} +func (s claudeProxyStoreStub) ModelAliases() map[string]string { return s.aliases } func (claudeProxyStoreStub) CompatStripReferenceMarkers() bool { return true } @@ -23,6 +21,27 @@ type openAIProxyStub struct { body string } +func TestClaudeProxyViaOpenAIPrefersGlobalAliasMapping(t *testing.T) { + openAI := &openAIProxyCaptureStub{} + h := &Handler{ + Store: claudeProxyStoreStub{ + aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}, + }, + OpenAI: openAI, + } + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}],"stream":false}`)) + rec := httptest.NewRecorder() + + h.Messages(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + if got := strings.TrimSpace(openAI.seenModel); got != "deepseek-v4-flash" { + t.Fatalf("expected global alias mapped proxy model deepseek-v4-flash, got %q", got) + } +} + func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) { if s.status == 0 { s.status = http.StatusOK @@ -68,10 +87,10 @@ func TestClaudeProxyViaOpenAIVercelPreparePassthrough(t *testing.T) { } } -func TestClaudeProxyViaOpenAIPreservesClaudeMapping(t *testing.T) { +func TestClaudeProxyViaOpenAIUsesGlobalAliasMapping(t *testing.T) { openAI := &openAIProxyCaptureStub{} h := &Handler{ - Store: claudeProxyStoreStub{mapping: map[string]string{"fast": "deepseek-v4-flash", "slow": "deepseek-v4-pro"}}, + Store: claudeProxyStoreStub{aliases: map[string]string{"claude-3-opus": "deepseek-v4-pro"}}, OpenAI: openAI, } req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-3-opus","messages":[{"role":"user","content":"hi"}],"stream":false}`)) diff --git a/internal/adapter/claude/stream_status_test.go b/internal/adapter/claude/stream_status_test.go index a3d4633..2a2586f 100644 --- a/internal/adapter/claude/stream_status_test.go +++ b/internal/adapter/claude/stream_status_test.go @@ -21,12 +21,7 @@ func (streamStatusClaudeOpenAIStub) ChatCompletions(w http.ResponseWriter, _ *ht type streamStatusClaudeStoreStub struct{} -func (streamStatusClaudeStoreStub) ClaudeMapping() map[string]string { - return map[string]string{ - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro", - } -} +func (streamStatusClaudeStoreStub) ModelAliases() map[string]string { return nil } func (streamStatusClaudeStoreStub) CompatStripReferenceMarkers() bool { return true } diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index 7decbde..fe5faff 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -82,23 +82,6 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { importedAccounts++ } - if len(incoming.ClaudeMapping) > 0 { - if next.ClaudeMapping == nil { - next.ClaudeMapping = map[string]string{} - } - for k, v := range incoming.ClaudeMapping { - next.ClaudeMapping[k] = v - } - } - if len(incoming.ClaudeModelMap) > 0 { - if next.ClaudeModelMap == nil { - next.ClaudeModelMap = map[string]string{} - } - for k, v := range incoming.ClaudeModelMap { - next.ClaudeModelMap[k] = v - } - } - if len(incoming.ModelAliases) > 0 { if next.ModelAliases == nil { next.ModelAliases = map[string]string{} diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index 20e5b1d..9fc876f 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -18,12 +18,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { "env_source_present": h.Store.HasEnvConfigSource(), "env_writeback_enabled": h.Store.IsEnvWritebackEnabled(), "config_path": h.Store.ConfigPath(), - "claude_mapping": func() map[string]string { - if len(snap.ClaudeMapping) > 0 { - return snap.ClaudeMapping - } - return snap.ClaudeModelMap - }(), + "model_aliases": snap.ModelAliases, } accounts := make([]map[string]any, 0, len(snap.Accounts)) for _, acc := range snap.Accounts { diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index 1929f26..7f6afb8 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -58,12 +58,12 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { } c.Accounts = accounts } - if m, ok := req["claude_mapping"].(map[string]any); ok { - newMap := map[string]string{} + if m, ok := req["model_aliases"].(map[string]any); ok { + aliases := make(map[string]string, len(m)) for k, v := range m { - newMap[k] = fmt.Sprintf("%v", v) + aliases[k] = fmt.Sprintf("%v", v) } - c.ClaudeMapping = newMap + c.ModelAliases = aliases } return nil }) diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index c02d421..0cc297e 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -21,7 +21,7 @@ func boolFrom(v any) bool { } } -func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, map[string]string, error) { +func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, error) { var ( adminCfg *config.AdminConfig runtimeCfg *config.RuntimeConfig @@ -30,7 +30,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi embCfg *config.EmbeddingsConfig autoDeleteCfg *config.AutoDeleteConfig historySplitCfg *config.HistorySplitConfig - claudeMap map[string]string aliasMap map[string]string ) @@ -39,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["jwt_expire_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.JWTExpireHours = n } @@ -51,33 +50,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["account_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxInflight = n } if v, exists := raw["account_max_queue"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxQueue = n } if v, exists := raw["global_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.GlobalMaxInflight = n } if v, exists := raw["token_refresh_interval_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TokenRefreshIntervalHours = n } if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } runtimeCfg = cfg } @@ -100,7 +99,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["store_ttl_seconds"]; exists { n := intFrom(v) if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.StoreTTLSeconds = n } @@ -112,27 +111,17 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["provider"]; exists { p := strings.TrimSpace(fmt.Sprintf("%v", v)) if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.Provider = p } embCfg = cfg } - if raw, ok := req["claude_mapping"].(map[string]any); ok { - claudeMap = map[string]string{} - for k, v := range raw { - key := strings.TrimSpace(k) - val := strings.TrimSpace(fmt.Sprintf("%v", v)) - if key == "" || val == "" { - continue - } - claudeMap[key] = val - } - } - if raw, ok := req["model_aliases"].(map[string]any); ok { - aliasMap = map[string]string{} + if aliasMap == nil { + aliasMap = map[string]string{} + } for k, v := range raw { key := strings.TrimSpace(k) val := strings.TrimSpace(fmt.Sprintf("%v", v)) @@ -148,7 +137,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["mode"]; exists { mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) if err := config.ValidateAutoDeleteMode(mode); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } if mode == "" { mode = "none" @@ -170,15 +159,15 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["trigger_after_turns"]; exists { n := intFrom(v) if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TriggerAfterTurns = &n } if err := config.ValidateHistorySplitConfig(*cfg); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } historySplitCfg = cfg } - return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, nil + return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, aliasMap, nil } diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go index dc060a8..2f9e7d2 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/admin/handler_settings_read.go @@ -34,7 +34,6 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "enabled": h.Store.HistorySplitEnabled(), "trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(), }, - "claude_mapping": settingsClaudeMapping(snap), "model_aliases": snap.ModelAliases, "env_backed": h.Store.IsEnvBacked(), "needs_vercel_sync": needsSync, diff --git a/internal/admin/handler_settings_runtime.go b/internal/admin/handler_settings_runtime.go index b090c38..a713c08 100644 --- a/internal/admin/handler_settings_runtime.go +++ b/internal/admin/handler_settings_runtime.go @@ -42,13 +42,3 @@ func defaultRuntimeRecommended(accountCount, maxPer int) int { } return accountCount * maxPer } - -func settingsClaudeMapping(c config.Config) map[string]string { - if len(c.ClaudeMapping) > 0 { - return c.ClaudeMapping - } - if len(c.ClaudeModelMap) > 0 { - return c.ClaudeModelMap - } - return map[string]string{"fast": "deepseek-v4-flash", "slow": "deepseek-v4-pro"} -} diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index 4300cfe..e231739 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -346,6 +346,34 @@ func TestUpdateConfigLegacyKeysPreserveStructuredMetadata(t *testing.T) { } } +func TestUpdateConfigReplacesModelAliases(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["k1"], + "model_aliases":{"claude-sonnet-4-6":"deepseek-v4-flash"} + }`) + + payload := map[string]any{ + "model_aliases": map[string]any{ + "gpt-5.5": "deepseek-v4-pro", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.ModelAliases) != 1 { + t.Fatalf("expected aliases to be replaced, got %#v", snap.ModelAliases) + } + if snap.ModelAliases["gpt-5.5"] != "deepseek-v4-pro" { + t.Fatalf("expected updated alias, got %#v", snap.ModelAliases) + } +} + func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) { hash := authn.HashAdminPassword("old-password") h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index ee4105a..2510d01 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) + adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, aliasMap, err := parseSettingsUpdateRequest(req) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -75,10 +75,6 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns } } - if claudeMap != nil { - c.ClaudeMapping = claudeMap - c.ClaudeModelMap = nil - } if aliasMap != nil { c.ModelAliases = aliasMap } diff --git a/internal/claudeconv/convert.go b/internal/claudeconv/convert.go index aa64e5a..cd6e156 100644 --- a/internal/claudeconv/convert.go +++ b/internal/claudeconv/convert.go @@ -1,34 +1,23 @@ package claudeconv -import "strings" +import ( + "strings" -type ClaudeMappingProvider interface { - ClaudeMapping() map[string]string -} + "ds2api/internal/config" +) -func ConvertClaudeToDeepSeek(claudeReq map[string]any, mappingProvider ClaudeMappingProvider, defaultClaudeModel string) map[string]any { +func ConvertClaudeToDeepSeek(claudeReq map[string]any, aliasProvider config.ModelAliasReader, defaultClaudeModel string) map[string]any { messages, _ := claudeReq["messages"].([]any) model, _ := claudeReq["model"].(string) if model == "" { model = defaultClaudeModel } - mapping := map[string]string{} - if mappingProvider != nil { - mapping = mappingProvider.ClaudeMapping() - } - dsModel := mapping["fast"] - if dsModel == "" { + dsModel, ok := config.ResolveModel(aliasProvider, model) + if !ok || strings.TrimSpace(dsModel) == "" { dsModel = "deepseek-v4-flash" } - modelLower := strings.ToLower(model) - if strings.Contains(modelLower, "opus") || strings.Contains(modelLower, "reasoner") || strings.Contains(modelLower, "slow") { - if slow := mapping["slow"]; slow != "" { - dsModel = slow - } - } - convertedMessages := make([]any, 0, len(messages)+1) if system, ok := claudeReq["system"].(string); ok && system != "" { convertedMessages = append(convertedMessages, map[string]any{"role": "system", "content": system}) diff --git a/internal/config/codec.go b/internal/config/codec.go index 11bf1d6..246df9b 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -26,12 +26,6 @@ func (c Config) MarshalJSON() ([]byte, error) { if len(c.Proxies) > 0 { m["proxies"] = c.Proxies } - if len(c.ClaudeMapping) > 0 { - m["claude_mapping"] = c.ClaudeMapping - } - if len(c.ClaudeModelMap) > 0 { - m["claude_model_mapping"] = c.ClaudeModelMap - } if len(c.ModelAliases) > 0 { m["model_aliases"] = c.ModelAliases } @@ -88,13 +82,8 @@ func (c *Config) UnmarshalJSON(b []byte) error { return fmt.Errorf("invalid field %q: %w", k, err) } case "claude_mapping": - if err := json.Unmarshal(v, &c.ClaudeMapping); err != nil { - return fmt.Errorf("invalid field %q: %w", k, err) - } case "claude_model_mapping": - if err := json.Unmarshal(v, &c.ClaudeModelMap); err != nil { - return fmt.Errorf("invalid field %q: %w", k, err) - } + // Removed legacy mapping fields are ignored instead of persisted. case "model_aliases": if err := json.Unmarshal(v, &c.ModelAliases); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -150,15 +139,13 @@ func (c *Config) UnmarshalJSON(b []byte) error { func (c Config) Clone() Config { clone := Config{ - Keys: slices.Clone(c.Keys), - APIKeys: slices.Clone(c.APIKeys), - Accounts: slices.Clone(c.Accounts), - Proxies: slices.Clone(c.Proxies), - ClaudeMapping: cloneStringMap(c.ClaudeMapping), - ClaudeModelMap: cloneStringMap(c.ClaudeModelMap), - ModelAliases: cloneStringMap(c.ModelAliases), - Admin: c.Admin, - Runtime: c.Runtime, + Keys: slices.Clone(c.Keys), + APIKeys: slices.Clone(c.APIKeys), + Accounts: slices.Clone(c.Accounts), + Proxies: slices.Clone(c.Proxies), + ModelAliases: cloneStringMap(c.ModelAliases), + Admin: c.Admin, + Runtime: c.Runtime, Compat: CompatConfig{ WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput), StripReferenceMarkers: cloneBoolPtr(c.Compat.StripReferenceMarkers), diff --git a/internal/config/config.go b/internal/config/config.go index dd1d5df..43856c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,8 +12,6 @@ type Config struct { APIKeys []APIKey `json:"api_keys,omitempty"` Accounts []Account `json:"accounts,omitempty"` Proxies []Proxy `json:"proxies,omitempty"` - ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` - ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"` ModelAliases map[string]string `json:"model_aliases,omitempty"` Admin AdminConfig `json:"admin,omitempty"` Runtime RuntimeConfig `json:"runtime,omitempty"` @@ -100,6 +98,8 @@ func (c *Config) NormalizeCredentials() { c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name) c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark) } + + c.normalizeModelAliases() } // DropInvalidAccounts removes accounts that cannot be addressed by admin APIs @@ -119,6 +119,27 @@ func (c *Config) DropInvalidAccounts() { c.Accounts = kept } +func (c *Config) normalizeModelAliases() { + if c == nil { + return + } + + aliases := map[string]string{} + for k, v := range c.ModelAliases { + key := strings.TrimSpace(lower(k)) + val := strings.TrimSpace(lower(v)) + if key == "" || val == "" { + continue + } + aliases[key] = val + } + if len(aliases) == 0 { + c.ModelAliases = nil + } else { + c.ModelAliases = aliases + } +} + type CompatConfig struct { WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"` StripReferenceMarkers *bool `json:"strip_reference_markers,omitempty"` diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index f1658ef..7741777 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -145,12 +145,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { trueVal := true falseVal := false cfg := Config{ - Keys: []string{"key1", "key2"}, - Accounts: []Account{{Email: "user@example.com", Password: "pass", Token: "tok"}}, - ClaudeMapping: map[string]string{ - "fast": "deepseek-v4-flash", - "slow": "deepseek-v4-pro", - }, + Keys: []string{"key1", "key2"}, + Accounts: []Account{{Email: "user@example.com", Password: "pass", Token: "tok"}}, + ModelAliases: map[string]string{"Claude-Sonnet-4-6": "DeepSeek-V4-Flash"}, AutoDelete: AutoDeleteConfig{ Mode: "single", }, @@ -188,8 +185,8 @@ func TestConfigJSONRoundtrip(t *testing.T) { if len(decoded.Accounts) != 1 || decoded.Accounts[0].Email != "user@example.com" { t.Fatalf("unexpected accounts: %#v", decoded.Accounts) } - if decoded.ClaudeMapping["fast"] != "deepseek-v4-flash" { - t.Fatalf("unexpected claude mapping: %#v", decoded.ClaudeMapping) + if decoded.ModelAliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("unexpected normalized model aliases: %#v", decoded.ModelAliases) } if decoded.Runtime.TokenRefreshIntervalHours != 12 { t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours) @@ -255,6 +252,23 @@ func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { } } +func TestConfigUnmarshalJSONIgnoresRemovedLegacyModelMappings(t *testing.T) { + raw := `{"keys":["k1"],"accounts":[],"claude_mapping":{"fast":"deepseek-v4-pro"},"claude_model_mapping":{"slow":"deepseek-v4-pro"}}` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(cfg.ModelAliases) != 0 { + t.Fatalf("expected removed legacy mappings to be ignored, got %#v", cfg.ModelAliases) + } + if _, ok := cfg.AdditionalFields["claude_mapping"]; ok { + t.Fatalf("expected removed legacy field not to persist in additional fields: %#v", cfg.AdditionalFields) + } + if _, ok := cfg.AdditionalFields["claude_model_mapping"]; ok { + t.Fatalf("expected removed legacy field not to persist in additional fields: %#v", cfg.AdditionalFields) + } +} + // ─── Config.Clone ──────────────────────────────────────────────────── func TestConfigCloneIsDeepCopy(t *testing.T) { @@ -262,11 +276,9 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { trueVal := true turns := 2 cfg := Config{ - Keys: []string{"key1"}, - Accounts: []Account{{Email: "user@test.com", Token: "token"}}, - ClaudeMapping: map[string]string{ - "fast": "deepseek-v4-flash", - }, + Keys: []string{"key1"}, + Accounts: []Account{{Email: "user@test.com", Token: "token"}}, + ModelAliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}, Compat: CompatConfig{ StripReferenceMarkers: &falseVal, }, @@ -282,7 +294,7 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { // Modify original cfg.Keys[0] = "modified" cfg.Accounts[0].Email = "modified@test.com" - cfg.ClaudeMapping["fast"] = "modified-model" + cfg.ModelAliases["claude-sonnet-4-6"] = "modified-model" if cfg.Compat.StripReferenceMarkers != nil { *cfg.Compat.StripReferenceMarkers = true } @@ -300,8 +312,8 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cloned.Accounts[0].Email != "user@test.com" { t.Fatalf("clone accounts was affected: %#v", cloned.Accounts) } - if cloned.ClaudeMapping["fast"] != "deepseek-v4-flash" { - t.Fatalf("clone claude mapping was affected: %#v", cloned.ClaudeMapping) + if cloned.ModelAliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("clone model aliases was affected: %#v", cloned.ModelAliases) } if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers { t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers) @@ -652,25 +664,27 @@ func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) { } } -func TestStoreClaudeMapping(t *testing.T) { - t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-v4-flash","slow":"deepseek-v4-pro"}}`) +func TestStoreModelAliasesIncludesDefaultsAndOverrides(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"model_aliases":{"claude-opus-4-6":"deepseek-v4-pro-search"}}`) store := LoadStore() - mapping := store.ClaudeMapping() - if mapping["fast"] != "deepseek-v4-flash" { - t.Fatalf("unexpected fast mapping: %q", mapping["fast"]) + aliases := store.ModelAliases() + if aliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("expected default alias to remain available, got %q", aliases["claude-sonnet-4-6"]) } - if mapping["slow"] != "deepseek-v4-pro" { - t.Fatalf("unexpected slow mapping: %q", mapping["slow"]) + if aliases["claude-opus-4-6"] != "deepseek-v4-pro-search" { + t.Fatalf("expected custom alias override, got %q", aliases["claude-opus-4-6"]) } } -func TestStoreClaudeMappingEmpty(t *testing.T) { +func TestStoreModelAliasesDefault(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[]}`) store := LoadStore() - mapping := store.ClaudeMapping() - // Even without config mapping, there are defaults - if mapping == nil { - t.Fatal("expected non-nil mapping (may contain defaults)") + aliases := store.ModelAliases() + if aliases == nil { + t.Fatal("expected non-nil aliases") + } + if aliases["claude-sonnet-4-6"] != "deepseek-v4-flash" { + t.Fatalf("expected built-in alias, got %q", aliases["claude-sonnet-4-6"]) } } diff --git a/internal/config/model_alias_test.go b/internal/config/model_alias_test.go index c00aed6..f537b21 100644 --- a/internal/config/model_alias_test.go +++ b/internal/config/model_alias_test.go @@ -20,6 +20,47 @@ func TestResolveModelAlias(t *testing.T) { } } +func TestResolveLatestOpenAIAlias(t *testing.T) { + got, ok := ResolveModel(nil, "gpt-5.5") + if !ok || got != "deepseek-v4-flash" { + t.Fatalf("expected alias gpt-5.5 -> deepseek-v4-flash, got ok=%v model=%q", ok, got) + } +} + +func TestResolveLatestClaudeAlias(t *testing.T) { + got, ok := ResolveModel(nil, "claude-sonnet-4-6") + if !ok || got != "deepseek-v4-flash" { + t.Fatalf("expected alias claude-sonnet-4-6 -> deepseek-v4-flash, got ok=%v model=%q", ok, got) + } +} + +func TestResolveExpandedHistoricalAliases(t *testing.T) { + cases := []struct { + name string + model string + want string + }{ + {name: "openai old chatgpt", model: "chatgpt-4o", want: "deepseek-v4-flash"}, + {name: "openai codex max", model: "gpt-5.1-codex-max", want: "deepseek-v4-pro"}, + {name: "openai deep research", model: "o3-deep-research", want: "deepseek-v4-pro-search"}, + {name: "openai historical reasoning", model: "o1-preview", want: "deepseek-v4-pro"}, + {name: "claude latest historical", model: "claude-3-5-sonnet-latest", want: "deepseek-v4-flash"}, + {name: "claude historical opus", model: "claude-3-opus-20240229", want: "deepseek-v4-pro"}, + {name: "claude historical haiku", model: "claude-3-haiku-20240307", want: "deepseek-v4-flash"}, + {name: "gemini latest alias", model: "gemini-flash-latest", want: "deepseek-v4-flash"}, + {name: "gemini historical pro", model: "gemini-1.5-pro", want: "deepseek-v4-pro"}, + {name: "gemini vision legacy", model: "gemini-pro-vision", want: "deepseek-v4-vision"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ResolveModel(nil, tc.model) + if !ok || got != tc.want { + t.Fatalf("expected alias %s -> %s, got ok=%v model=%q", tc.model, tc.want, ok, got) + } + }) + } +} + func TestResolveModelHeuristicReasoner(t *testing.T) { got, ok := ResolveModel(nil, "o3-super") if !ok || got != "deepseek-v4-pro" { @@ -51,6 +92,19 @@ func TestResolveModelRejectsLegacyDeepSeekIDs(t *testing.T) { } } +func TestResolveModelRejectsRetiredHistoricalModels(t *testing.T) { + retiredModels := []string{ + "claude-2.1", + "claude-instant-1.2", + "gpt-3.5-turbo", + } + for _, model := range retiredModels { + if got, ok := ResolveModel(nil, model); ok { + t.Fatalf("expected retired model %q to be rejected, got %q", model, got) + } + } +} + func TestResolveModelDirectDeepSeekExpert(t *testing.T) { got, ok := ResolveModel(nil, "deepseek-v4-pro") if !ok || got != "deepseek-v4-pro" { diff --git a/internal/config/models.go b/internal/config/models.go index d4d1afa..7b28ec3 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -26,11 +26,11 @@ var DeepSeekModels = []ModelInfo{ var ClaudeModels = []ModelInfo{ // Current aliases {ID: "claude-opus-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-sonnet-4-5", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, + {ID: "claude-sonnet-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-haiku-4-5", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - // Current snapshots - {ID: "claude-opus-4-5-20251101", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, + // Claude 4.x snapshots and prior aliases kept for compatibility + {ID: "claude-sonnet-4-5", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-1-20250805", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, @@ -51,17 +51,6 @@ var ClaudeModels = []ModelInfo{ {ID: "claude-3-5-haiku-latest", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-3-5-haiku-20241022", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-3-haiku-20240307", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - - // Claude 2.x and 1.x (retired but accepted for compatibility) - {ID: "claude-2.1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-2.0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.3", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.2", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-1.0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-instant-1.2", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-instant-1.1", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, - {ID: "claude-instant-1.0", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, } func GetModelConfig(model string) (thinking bool, search bool, ok bool) { @@ -95,25 +84,103 @@ func IsSupportedDeepSeekModel(model string) bool { func DefaultModelAliases() map[string]string { return map[string]string{ - "gpt-4o": "deepseek-v4-flash", - "gpt-4.1": "deepseek-v4-flash", - "gpt-4.1-mini": "deepseek-v4-flash", - "gpt-4.1-nano": "deepseek-v4-flash", - "gpt-5": "deepseek-v4-flash", - "gpt-5-mini": "deepseek-v4-flash", - "gpt-5-codex": "deepseek-v4-pro", - "o1": "deepseek-v4-pro", - "o1-mini": "deepseek-v4-pro", - "o3": "deepseek-v4-pro", - "o3-mini": "deepseek-v4-pro", - "claude-sonnet-4-5": "deepseek-v4-flash", - "claude-haiku-4-5": "deepseek-v4-flash", - "claude-opus-4-6": "deepseek-v4-pro", - "claude-3-5-sonnet": "deepseek-v4-flash", - "claude-3-5-haiku": "deepseek-v4-flash", - "claude-3-opus": "deepseek-v4-pro", - "gemini-2.5-pro": "deepseek-v4-pro", - "gemini-2.5-flash": "deepseek-v4-flash", + // OpenAI GPT / ChatGPT families + "chatgpt-4o": "deepseek-v4-flash", + "gpt-4": "deepseek-v4-flash", + "gpt-4-turbo": "deepseek-v4-flash", + "gpt-4-turbo-preview": "deepseek-v4-flash", + "gpt-4.5-preview": "deepseek-v4-flash", + "gpt-4o": "deepseek-v4-flash", + "gpt-4o-mini": "deepseek-v4-flash", + "gpt-4.1": "deepseek-v4-flash", + "gpt-4.1-mini": "deepseek-v4-flash", + "gpt-4.1-nano": "deepseek-v4-flash", + "gpt-5": "deepseek-v4-flash", + "gpt-5-chat": "deepseek-v4-flash", + "gpt-5.1": "deepseek-v4-flash", + "gpt-5.1-chat": "deepseek-v4-flash", + "gpt-5.2": "deepseek-v4-flash", + "gpt-5.2-chat": "deepseek-v4-flash", + "gpt-5.3-chat": "deepseek-v4-flash", + "gpt-5.4": "deepseek-v4-flash", + "gpt-5.5": "deepseek-v4-flash", + "gpt-5-mini": "deepseek-v4-flash", + "gpt-5-nano": "deepseek-v4-flash", + "gpt-5.4-mini": "deepseek-v4-flash", + "gpt-5.4-nano": "deepseek-v4-flash", + "gpt-5-pro": "deepseek-v4-pro", + "gpt-5.2-pro": "deepseek-v4-pro", + "gpt-5.4-pro": "deepseek-v4-pro", + "gpt-5.5-pro": "deepseek-v4-pro", + "gpt-5-codex": "deepseek-v4-pro", + "gpt-5.1-codex": "deepseek-v4-pro", + "gpt-5.1-codex-mini": "deepseek-v4-pro", + "gpt-5.1-codex-max": "deepseek-v4-pro", + "gpt-5.2-codex": "deepseek-v4-pro", + "gpt-5.3-codex": "deepseek-v4-pro", + "codex-mini-latest": "deepseek-v4-pro", + + // OpenAI reasoning / research families + "o1": "deepseek-v4-pro", + "o1-preview": "deepseek-v4-pro", + "o1-mini": "deepseek-v4-pro", + "o1-pro": "deepseek-v4-pro", + "o3": "deepseek-v4-pro", + "o3-mini": "deepseek-v4-pro", + "o3-pro": "deepseek-v4-pro", + "o3-deep-research": "deepseek-v4-pro-search", + "o4-mini": "deepseek-v4-pro", + "o4-mini-deep-research": "deepseek-v4-pro-search", + + // Claude current and historical aliases + "claude-opus-4-6": "deepseek-v4-pro", + "claude-opus-4-1": "deepseek-v4-pro", + "claude-opus-4-1-20250805": "deepseek-v4-pro", + "claude-opus-4-0": "deepseek-v4-pro", + "claude-opus-4-20250514": "deepseek-v4-pro", + "claude-sonnet-4-6": "deepseek-v4-flash", + "claude-sonnet-4-5": "deepseek-v4-flash", + "claude-sonnet-4-5-20250929": "deepseek-v4-flash", + "claude-sonnet-4-0": "deepseek-v4-flash", + "claude-sonnet-4-20250514": "deepseek-v4-flash", + "claude-haiku-4-5": "deepseek-v4-flash", + "claude-haiku-4-5-20251001": "deepseek-v4-flash", + "claude-3-7-sonnet": "deepseek-v4-flash", + "claude-3-7-sonnet-latest": "deepseek-v4-flash", + "claude-3-7-sonnet-20250219": "deepseek-v4-flash", + "claude-3-5-sonnet": "deepseek-v4-flash", + "claude-3-5-sonnet-latest": "deepseek-v4-flash", + "claude-3-5-sonnet-20240620": "deepseek-v4-flash", + "claude-3-5-sonnet-20241022": "deepseek-v4-flash", + "claude-3-5-haiku": "deepseek-v4-flash", + "claude-3-5-haiku-latest": "deepseek-v4-flash", + "claude-3-5-haiku-20241022": "deepseek-v4-flash", + "claude-3-opus": "deepseek-v4-pro", + "claude-3-opus-20240229": "deepseek-v4-pro", + "claude-3-sonnet": "deepseek-v4-flash", + "claude-3-sonnet-20240229": "deepseek-v4-flash", + "claude-3-haiku": "deepseek-v4-flash", + "claude-3-haiku-20240307": "deepseek-v4-flash", + + // Gemini current and historical text / multimodal models + "gemini-pro": "deepseek-v4-pro", + "gemini-pro-vision": "deepseek-v4-vision", + "gemini-pro-latest": "deepseek-v4-pro", + "gemini-flash-latest": "deepseek-v4-flash", + "gemini-1.5-pro": "deepseek-v4-pro", + "gemini-1.5-flash": "deepseek-v4-flash", + "gemini-1.5-flash-8b": "deepseek-v4-flash", + "gemini-2.0-flash": "deepseek-v4-flash", + "gemini-2.0-flash-lite": "deepseek-v4-flash", + "gemini-2.5-pro": "deepseek-v4-pro", + "gemini-2.5-flash": "deepseek-v4-flash", + "gemini-2.5-flash-lite": "deepseek-v4-flash", + "gemini-3.1-pro": "deepseek-v4-pro", + "gemini-3-pro": "deepseek-v4-pro", + "gemini-3-flash": "deepseek-v4-flash", + "gemini-3.1-flash": "deepseek-v4-flash", + "gemini-3.1-flash-lite": "deepseek-v4-flash", + "llama-3.1-70b-instruct": "deepseek-v4-flash", "qwen-max": "deepseek-v4-flash", } @@ -124,6 +191,9 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) { if model == "" { return "", false } + if isRetiredHistoricalModel(model) { + return "", false + } if IsSupportedDeepSeekModel(model) { return model, true } @@ -179,6 +249,21 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) { } } +func isRetiredHistoricalModel(model string) bool { + switch { + case strings.HasPrefix(model, "claude-1."): + return true + case strings.HasPrefix(model, "claude-2."): + return true + case strings.HasPrefix(model, "claude-instant-"): + return true + case strings.HasPrefix(model, "gpt-3.5"): + return true + default: + return false + } +} + func lower(s string) string { b := []byte(s) for i, c := range b { diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index b0a0f31..6849b85 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -6,18 +6,6 @@ import ( "strings" ) -func (s *Store) ClaudeMapping() map[string]string { - s.mu.RLock() - defer s.mu.RUnlock() - if len(s.cfg.ClaudeModelMap) > 0 { - return cloneStringMap(s.cfg.ClaudeModelMap) - } - if len(s.cfg.ClaudeMapping) > 0 { - return cloneStringMap(s.cfg.ClaudeMapping) - } - return map[string]string{"fast": "deepseek-v4-flash", "slow": "deepseek-v4-pro"} -} - func (s *Store) ModelAliases() map[string]string { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/util/messages.go b/internal/util/messages.go index b6920c0..3a43f24 100644 --- a/internal/util/messages.go +++ b/internal/util/messages.go @@ -6,7 +6,7 @@ import ( "ds2api/internal/prompt" ) -const ClaudeDefaultModel = "claude-sonnet-4-5" +const ClaudeDefaultModel = "claude-sonnet-4-6" type Message struct { Role string `json:"role"` diff --git a/internal/util/messages_test.go b/internal/util/messages_test.go index e7fd822..077e903 100644 --- a/internal/util/messages_test.go +++ b/internal/util/messages_test.go @@ -104,6 +104,18 @@ func TestConvertClaudeToDeepSeek(t *testing.T) { } } +func TestConvertClaudeToDeepSeekUsesGlobalAliasResolution(t *testing.T) { + store := config.LoadStore() + req := map[string]any{ + "model": "claude-3-5-sonnet-latest", + "messages": []any{map[string]any{"role": "user", "content": "Hi"}}, + } + out := ConvertClaudeToDeepSeek(req, store) + if out["model"] != "deepseek-v4-flash" { + t.Fatalf("expected global alias resolution, got model=%q", out["model"]) + } +} + func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(sub) == 0 || (len(s) > 0 && (indexOf(s, sub) >= 0))) } diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index d168fdc..6084d9c 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -348,8 +348,7 @@ func TestConvertClaudeToDeepSeekNoSystem(t *testing.T) { } } -func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) { - t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-v4-flash","slow":"deepseek-v4-pro"}}`) +func TestConvertClaudeToDeepSeekOpusUsesGlobalAlias(t *testing.T) { store := config.LoadStore() req := map[string]any{ "model": "claude-opus-4-6", @@ -357,6 +356,19 @@ func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) { } out := ConvertClaudeToDeepSeek(req, store) if out["model"] != "deepseek-v4-pro" { - t.Fatalf("expected opus to use slow mapping, got %q", out["model"]) + t.Fatalf("expected opus to use global alias, got %q", out["model"]) + } +} + +func TestConvertClaudeToDeepSeekUsesExplicitModelAlias(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"model_aliases":{"claude-sonnet-4-6":"deepseek-v4-pro-search"}}`) + store := config.LoadStore() + req := map[string]any{ + "model": "claude-sonnet-4-6", + "messages": []any{map[string]any{"role": "user", "content": "Hi"}}, + } + out := ConvertClaudeToDeepSeek(req, store) + if out["model"] != "deepseek-v4-pro-search" { + t.Fatalf("expected explicit alias override, got %q", out["model"]) } } diff --git a/webui/src/features/settings/ModelSection.jsx b/webui/src/features/settings/ModelSection.jsx index b1a220e..d377ac5 100644 --- a/webui/src/features/settings/ModelSection.jsx +++ b/webui/src/features/settings/ModelSection.jsx @@ -2,26 +2,15 @@ export default function ModelSection({ t, form, setForm }) { return (

{t('settings.modelTitle')}

-
-