From 572b67102df6bc4660584af8bd6a5d7d7741e866 Mon Sep 17 00:00:00 2001 From: patch Date: Fri, 16 Oct 2026 14:25:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(anthropic):=20map=20thinking=20to=20reasoni?= =?UTF-8?q?ng=5Feffort=20=E2=80=94=20upstream=20only=20honours=20that=20sp?= =?UTF-8?q?elling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous opt-in mapped Anthropic `thinking:{type:"enabled"}` straight through as an OpenAI-side `thinking` object. Measured against the real CNB upstream on 2026-10-16, that spelling does NOT trigger the thinking variant, so `/v1/messages` stayed thinking-less even with `CNB2API_ALLOW_THINKING=1`: param reasoning_content reasoning_effort: high/low/medium > 0 (triggers) reasoning: {effort} 0 enable_thinking: true 0 thinking: {type:"enabled"} 0 thinking: {type:"enabled",budget} 0 thinking: true 0 (no param, control) 0 Re-verified with a hard prompt, and `enable_thinking` / `thinking` behave exactly like the no-param control. `reasoning_effort` is the only switch the upstream honours. Change: - anthropic.mjs: emit `reasoning_effort` instead of `thinking`; derive the level from Anthropic `budget_tokens` (<=4096 low, <=16384 medium, else high; missing/invalid -> high, since too low lets reasoning eat the whole max_tokens). A client-supplied `reasoning_effort` still wins. - proxy.mjs: correct the strip-table comment — it claimed all four spellings route to the thinking variant; only `reasoning_effort` does. The strip list itself is unchanged (extra stripping is harmless and guards against future spelling changes). - tests: assert the mapping targets reasoning_effort, cover the budget->level ladder, and add an E2E that pins `/v1/messages` -> upstream `reasoning_effort`. Verified end to end against the live CNB upstream (local patched proxy -> deployed cnb2api -> CNB): `thinking:{enabled,budget_tokens:31999}` now returns a `thinking` block (3114 chars) plus the answer (1268 chars), stop_reason `end_turn`; the no-thinking control returns text only. Tests: 79/79. --- src/anthropic.mjs | 28 +++++++++--- src/proxy.mjs | 17 ++++--- test/anthropic.test.mjs | 99 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/anthropic.mjs b/src/anthropic.mjs index d56b415..86d1fa8 100644 --- a/src/anthropic.mjs +++ b/src/anthropic.mjs @@ -109,18 +109,32 @@ export function fromAnthropicRequest(body, { allowThinking = false } = {}) { if (tc) out.tool_choice = tc; } - // 思考开关:默认丢弃(触发上游 thinking 变体会因推理计入 max_tokens 而耗尽预算, - // 见 proxy.mjs 的剥除注释)。显式开启时把 Anthropic 顶层 thinking 翻成上游能识别的 - // OpenAI 侧形态;type:"disabled" 与缺省都不加字段 = 走非 thinking 变体。 - // budget_tokens 无上游对应字段,不映射 —— 上游按 max_tokens 统一控制推理+正文预算。 + // 思考开关:默认丢弃(触发上游思考变体会因推理计入 max_tokens 而耗尽预算, + // 见 proxy.mjs 的剥除注释)。显式开启时把 Anthropic 顶层 thinking 翻成上游能识别的形态; + // type:"disabled" 与缺省都不加字段 = 走非思考变体。 + // + // ⚠️ 真机实测(2026-10-16,对 CNB 上游逐个拼写验证):上游**只认 `reasoning_effort`**。 + // `thinking:{type:"enabled"}`、`enable_thinking:true`、`reasoning:{effort}`、`thinking:true` + // 全部无效 —— reasoning_content 恒为 0,与不带任何思考参数的表现完全一致。 + // 所以这里必须映射成 reasoning_effort;映射成 thinking 会让 /v1/messages 永远拿不到思考。 + // Anthropic 的 budget_tokens 无上游对应字段,按档位折算成 effort。 if (allowThinking && body.thinking && body.thinking.type === 'enabled') { - out.thinking = { type: 'enabled' }; - // 客户端若显式给了 effort(Anthropic 协议外字段,但转发链常见)一并透传 - if (typeof body.reasoning_effort === 'string') out.reasoning_effort = body.reasoning_effort; + out.reasoning_effort = typeof body.reasoning_effort === 'string' + ? body.reasoning_effort // 客户端显式给了 effort(协议外字段,但转发链常见)→ 原样透传 + : budgetToEffort(body.thinking.budget_tokens); } return out; } +// Anthropic thinking.budget_tokens → 上游 reasoning_effort 档位。 +// 上游只分 low/medium/high(实测三档都能触发思考,档位差异表现在 reasoning 长度上)。 +function budgetToEffort(budget) { + if (typeof budget !== 'number' || !Number.isFinite(budget)) return 'high'; // 没给预算就按高档 + if (budget <= 4096) return 'low'; + if (budget <= 16384) return 'medium'; + return 'high'; +} + function normalizeSystem(system) { if (system === undefined || system === null) return ''; if (typeof system === 'string') return system; diff --git a/src/proxy.mjs b/src/proxy.mjs index 588c531..05a9134 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -5,11 +5,18 @@ import { aggregate } from './sse.mjs'; import { record as recordUsage } from './usage.mjs'; import { observe as observeModel } from './models.mjs'; -// 出站请求剥除思考开关:带 reasoning_effort 等思考参数时上游路由到 thinking 变体, -// 而该变体把推理阶段也计入 max_tokens——推理吃满预算后 finish_reason=length、 -// content 全空(答案永不生成,真机实测 2048/8192 token 必现)。上游无正确开关可配, -// 出站统一剥除、锁定非 thinking 变体(与 /v1/messages 请求侧丢弃 thinking 块同一策略)。 -// 表驱动:上游若引入新的思考开关命名,往这张表加一项即可。 +// 出站请求剥除思考开关:带上思考参数命中上游思考变体后,推理阶段也计入 max_tokens—— +// 推理吃满预算后 finish_reason=length、content 全空(答案永不生成,真机实测 2048/8192 必现、 +// 给到 32000 才正常)。所以默认出站统一剥除、锁定非思考变体(与 /v1/messages 请求侧丢弃 +// thinking 块同一策略);显式开启思考由 CNB2API_ALLOW_THINKING 控制。 +// +// ⚠️ 真机实测(2026-10-16,逐个拼写打到真实上游): +// 只有 `reasoning_effort` 能触发思考变体;`reasoning:{effort}`、`enable_thinking`、 +// `thinking:{type:"enabled"}`、`thinking:true` 全部无效(reasoning_content 恒为 0, +// 与完全不带思考参数的表现一致)。因此: +// - 对外(OpenAI 路径)→ 客户端给 reasoning_effort 即可; +// - 对 Anthropic 路径 → anthropic.mjs 把 thinking 映射成 reasoning_effort(不能映射成 thinking)。 +// 剥除列表仍保留全部 4 个拼写:多余剥除无害,且能防上游将来引入新的有效拼写。 const REASONING_TRIGGERS = ['reasoning_effort', 'reasoning', 'enable_thinking', 'thinking']; function stripReasoningTriggers(payload) { diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs index 380a3e1..2296eca 100644 --- a/test/anthropic.test.mjs +++ b/test/anthropic.test.mjs @@ -233,28 +233,44 @@ test('请求转换:thinking 请求参数不泄漏出站(真机教训:触 assert.equal(out.messages.length, 1); }); -test('请求转换:allowThinking 开启时 thinking 映射为上游形态(默认关闭不受影响)', () => { +test('请求转换:allowThinking 开启时 thinking 映射为 reasoning_effort(默认关闭不受影响)', () => { const body = { model: 'claude-sonnet-4-5', max_tokens: 64000, thinking: { type: 'enabled', budget_tokens: 2048 }, messages: [{ role: 'user', content: 'hi' }], }; - // enabled → 透出上游能识别的开关;budget_tokens 无上游对应字段,不映射 + // ⚠️ 真机实测:上游只认 reasoning_effort;thinking:{type:"enabled"} 打到真实上游 + // reasoning_content 恒为 0(与不带参数一致)。所以必须映射成 reasoning_effort。 const on = fromAnthropicRequest(body, { allowThinking: true }); - assert.deepEqual(on.thinking, { type: 'enabled' }); - assert.equal(on.budget_tokens, undefined); + assert.equal(on.reasoning_effort, 'low', 'budget 2048 → low'); + assert.equal(on.thinking, undefined, 'must NOT emit thinking: upstream ignores it'); + assert.equal(on.enable_thinking, undefined); assert.equal(on.max_tokens, 64000); - // type:"disabled" → 不加字段 = 走非 thinking 变体(不是加一个 disabled 开关) + assert.equal(on.budget_tokens, undefined); + + // budget_tokens 档位折算 + const at = (b) => fromAnthropicRequest({ ...body, thinking: { type: 'enabled', budget_tokens: b } }, { allowThinking: true }).reasoning_effort; + assert.equal(at(1024), 'low'); + assert.equal(at(4096), 'low'); + assert.equal(at(4097), 'medium'); + assert.equal(at(16384), 'medium'); + assert.equal(at(16385), 'high'); + assert.equal(at(31999), 'high'); + // 缺省/非法 budget → high(宁可高不要低,低了推理写不完会吃穿 max_tokens) + assert.equal(at(undefined), 'high'); + assert.equal(at(NaN), 'high'); + + // type:"disabled" → 不加字段 = 走非思考变体(不是加一个 disabled 开关) const off = fromAnthropicRequest({ ...body, thinking: { type: 'disabled' } }, { allowThinking: true }); + assert.equal(off.reasoning_effort, undefined); assert.equal(off.thinking, undefined); - assert.equal(off.enable_thinking, undefined); // 不带 thinking 的普通请求两种模式下完全一致 const plain = { model: 'claude-sonnet-4-5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }; assert.deepEqual(fromAnthropicRequest(plain, { allowThinking: true }), fromAnthropicRequest(plain)); - // 显式开启 + 客户端给了 effort(协议外字段)→ 一并透传 - const effort = fromAnthropicRequest({ ...body, reasoning_effort: 'high' }, { allowThinking: true }); - assert.equal(effort.reasoning_effort, 'high'); + // 客户端显式给了 effort(协议外字段)→ 优先用它,忽略 budget_tokens + const effort = fromAnthropicRequest({ ...body, reasoning_effort: 'medium' }, { allowThinking: true }); + assert.equal(effort.reasoning_effort, 'medium'); }); // --------------------------------------------------------------------------- @@ -413,11 +429,14 @@ const MOCK_PORT = 19121; const PROXY_PORT = 19122; const KEY = 'test-key-12345'; +let lastUpstreamBody = null; // 捕获出站请求体(思考开关映射类断言用) + const mockServer = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const parsed = JSON.parse(body); + lastUpstreamBody = parsed; // 断言:打到上游的一定是 OpenAI 格式(翻译正确性) assert.ok(Array.isArray(parsed.messages)); assert.equal(parsed.stream, true); @@ -564,3 +583,65 @@ test('E2E 请求校验:messages 缺失 → 400 Anthropic error envelope', asyn assert.equal(body.type, 'error'); assert.equal(body.error.type, 'invalid_request_error'); }); + +// --------------------------------------------------------------------------- +// E2E:思考 opt-in —— /v1/messages 的 thinking 必须落到上游的 reasoning_effort +// +// 真机实测(2026-10-16):上游只认 reasoning_effort;thinking:{type:"enabled"} 打到真实 +// 上游 reasoning_content 恒为 0。这条 E2E 钉住"映射成 reasoning_effort"这个事实, +// 防止未来重构把它改回 thinking(那会让 /v1/messages 静默失去思考)。 +// --------------------------------------------------------------------------- +const THINK_PORT = PROXY_PORT + 1; + +async function withThinkProxy(fn) { + const p = spawn(process.execPath, ['src/server.mjs'], { + env: { + ...process.env, PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', CNB_TOKEN: 'test-token', + UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}/upstream`, PROXY_PORT: String(THINK_PORT), + CNB2API_ALLOW_THINKING: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + const t0 = Date.now(); + for (;;) { + try { await fetch(`http://127.0.0.1:${THINK_PORT}/health`); break; } catch {} + if (Date.now() - t0 > 5000) throw new Error('think proxy not up'); + await new Promise((r) => setTimeout(r, 100)); + } + return await fn(); + } finally { + p.kill('SIGTERM'); + } +} + +test('E2E 思考 opt-in:thinking:{enabled,budget_tokens} → 上游收到 reasoning_effort(不是 thinking)', async () => { + await withThinkProxy(async () => { + const res = await fetch(`http://127.0.0.1:${THINK_PORT}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': KEY }, + body: JSON.stringify({ + model: 'claude-x', max_tokens: 64000, + thinking: { type: 'enabled', budget_tokens: 31999 }, + messages: [{ role: 'user', content: 'hi' }], + }), + }); + assert.equal(res.status, 200); + await res.json(); + assert.equal(lastUpstreamBody.reasoning_effort, 'high', 'budget 31999 → high'); + assert.equal(lastUpstreamBody.thinking, undefined, 'must not forward thinking: upstream ignores it'); + assert.equal(lastUpstreamBody.max_tokens, 64000); + }); +}); + +test('E2E 思考 opt-in:未开开关时 thinking 完全不外泄(默认行为不变)', async () => { + const res = await postMessages({ + model: 'claude-x', max_tokens: 64000, + thinking: { type: 'enabled', budget_tokens: 31999 }, + messages: [{ role: 'user', content: 'hi' }], + }, { 'x-api-key': KEY }); + assert.equal(res.status, 200); + await res.json(); + assert.equal(lastUpstreamBody.reasoning_effort, undefined); + assert.equal(lastUpstreamBody.thinking, undefined); +});