From 7d2f8427f7712141d336d150ea3d5c1f728e70ef Mon Sep 17 00:00:00 2001 From: A8Chann <37932703+A8Chann@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:54:02 +0800 Subject: [PATCH] feat: add CNB2API_ALLOW_THINKING opt-in for the reasoning-toggle strip 331f5aa strips reasoning_effort / reasoning / enable_thinking / thinking from every outbound request, which correctly protects clients from the degenerate "reasoning eats max_tokens, content never generated" stream. The tradeoff is that it also locks out callers that do provide an adequate budget. How that plays out in practice: this repo was synced into a private deployment on 2026-10-08. Across that deployment's session logs, the provider's reasoning output went from 1,339,875 characters on 09-18 and 3,031,411 on 09-20 to exactly 0 on 10-08, 10-09 and 10-10 -- three days of traffic with thinking silently gone. No client-side spelling could reach the thinking variant again (reasoningEffort, enableThinking, chat_template_kwargs, extra_body and thinking_budget were all tried against the live gateway and all returned zero reasoning_content). The failure being guarded against is narrower than "thinking is unsafe": it is "thinking toggle + too-small max_tokens". Reproduced against the real upstream with a hard prompt: max_tokens=2048 + toggle -> reasoning 4589 chars, content 0, finish=length max_tokens=8192 + toggle -> reasoning 20305 chars, content 0, finish=length max_tokens=2048, no toggle -> content 2994 chars, finish=stop With a large enough budget the reasoning and the answer are produced in sequence, which is what the pre-331f5aa traffic did at max_tokens=60000. So this keeps stripping as the default and adds an explicit opt-in: CNB2API_ALLOW_THINKING=1 When set, thinking toggles pass through unchanged and the operator owns the budget decision. Requests without a toggle are unaffected either way. The Anthropic adapter is covered too. fromAnthropicRequest() previously dropped body.thinking unconditionally, so the flag alone would not have helped /v1/messages clients; it now maps thinking:{type:"enabled"} to the upstream shape (thinking:{type:"enabled"} plus reasoning_effort when the caller supplies one). budget_tokens is not mapped -- the upstream has no corresponding field. The flag is passed in as an option rather than imported, keeping anthropic.mjs dependency-free. Tests: - opt-in forwards reasoning_effort and thinking, other fields untouched - Anthropic adapter maps thinking when enabled, honours type:"disabled", and leaves plain requests alone - the existing default-strip test is unchanged and still passes Full suite: 73/73 pass. Note for Windows contributors: test/proxy.test.mjs uses new URL('..', import.meta.url).pathname for the child cwd, which yields "/D:/..." on Windows and makes every spawn fail with "port not up". That is pre-existing on main and unrelated to this change -- I verified it by running the suite on an unmodified checkout. Left alone here so the diff stays focused. --- .env.example | 14 ++++++++++++++ README.md | 16 ++++++++++++++++ README.zh-CN.md | 12 ++++++++++++ src/anthropic.mjs | 13 ++++++++++++- src/config.mjs | 5 +++++ src/proxy.mjs | 9 +++++++++ src/server.mjs | 2 +- test/anthropic.test.mjs | 33 ++++++++++++++++++++++++++++++++ test/proxy.test.mjs | 42 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 144 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 03e0a91..2e85a68 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,20 @@ CNB_REPO_SLUG=your-org/ai-proxy # Per-stream idle watchdog (ms) #PROXY_IDLE_TIMEOUT_MS=300000 +# ---- thinking (optional, off by default) ---- +# Forward the client's thinking toggles (reasoning_effort / reasoning / +# enable_thinking / thinking, and Anthropic thinking:{type:enabled}) to the +# upstream thinking variant instead of stripping them. +# +# Why this is off by default: the thinking variant bills its reasoning phase +# against max_tokens. With a small budget the reasoning consumes everything and +# the answer is never generated (finish_reason=length, empty content). +# +# Turn it on only if your clients send a large max_tokens (>= 32k is a safe +# starting point; 60k was verified). Requests that carry no thinking toggle are +# unaffected either way. +#CNB2API_ALLOW_THINKING=1 + # ---- self-registration to the fixed-domain relay (optional) ---- # Full URL of the relay endpoint, e.g. https://ai.example.com/ops/register #REGISTER_URL= diff --git a/README.md b/README.md index ef1c135..119bf4d 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,25 @@ npm run quota # or: npx cnb2api-quota | `PROXY_PORT` | `9001` | Listen port. | | `PROXY_UPSTREAM_TIMEOUT_MS` | `15000` | Upstream connect / first-byte timeout (ms). | | `PROXY_IDLE_TIMEOUT_MS` | `300000` | Per-stream idle watchdog (ms). | +| `CNB2API_ALLOW_THINKING` | off | Set to `1` to forward client thinking toggles (`reasoning_effort` / `reasoning` / `enable_thinking` / `thinking`, and Anthropic `thinking:{type:"enabled"}`) to the upstream thinking variant instead of stripping them. Only use it if your clients send a large `max_tokens` — see the thinking note below. | | `REGISTER_URL` | — (optional) | Relay `/ops/register` endpoint for self-registration. | | `REG_TOKEN` | — (optional) | Shared secret for self-registration. | +### Thinking is stripped by default + +The upstream thinking variant bills its reasoning phase against `max_tokens`. With a +small budget the reasoning consumes the whole allowance, the stream ends with +`finish_reason: length` and **no answer is ever generated** (measured: a hard prompt +at `max_tokens` 2048 and 8192 produced 4.6k / 20.3k characters of reasoning and zero +content). To keep clients from hitting that, thinking toggles are removed from every +outbound request. + +That default is safe but absolute: it also locks out callers that *do* provide a large +budget, for whom thinking works correctly (verified at `max_tokens: 60000`, where +reasoning and answer are emitted in sequence). Set `CNB2API_ALLOW_THINKING=1` to opt in +and take responsibility for the budget. Requests that carry no thinking toggle behave +identically either way. + --- ## FAQ diff --git a/README.zh-CN.md b/README.zh-CN.md index b6b6e39..4003a98 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -176,9 +176,21 @@ npm run quota # 或: npx cnb2api-quota | `PROXY_PORT` | `9001` | 服务监听端口。 | | `PROXY_UPSTREAM_TIMEOUT_MS` | `15000` | 上游连接首字节超时 (ms)。 | | `PROXY_IDLE_TIMEOUT_MS` | `300000` | 流式事件空闲超时看门狗 (ms)。 | +| `CNB2API_ALLOW_THINKING` | 关闭 | 设为 `1` 时,把客户端的思考开关(`reasoning_effort` / `reasoning` / `enable_thinking` / `thinking`,以及 Anthropic 的 `thinking:{type:"enabled"}`)透传到上游 thinking 变体,而不是出站剥除。仅在客户端会给出足够大 `max_tokens` 时开启——详见下方说明。 | | `REGISTER_URL` | — (可选) | 中继自注册 `/ops/register` 地址。 | | `REG_TOKEN` | — (可选) | 中继自注册鉴权密钥。 | +### 默认剥除思考开关 + +上游 thinking 变体会把推理阶段计入 `max_tokens`。预算偏小时推理吃满全部额度,流以 +`finish_reason: length` 结束且**答案永不产出**(实测:硬 prompt 在 `max_tokens` 2048 与 +8192 下分别产出 4.6k / 20.3k 字符推理、正文为零)。为避免客户端踩到该退化形态,出站请求 +统一剥除思考开关。 + +该默认值安全但绝对:它同时把**给了足够预算**的调用方一并挡在门外,而这类调用方本来是能 +正常思考的(`max_tokens: 60000` 实测推理与正文依次产出)。设 `CNB2API_ALLOW_THINKING=1` +即可显式开启,并由调用方自行保证预算。未携带思考开关的请求在两种设置下行为完全一致。 + --- ## 常见问题 (FAQ) diff --git a/src/anthropic.mjs b/src/anthropic.mjs index c6e0c36..b6e1e39 100644 --- a/src/anthropic.mjs +++ b/src/anthropic.mjs @@ -57,7 +57,7 @@ function neutralize(text) { return t; } -export function fromAnthropicRequest(body) { +export function fromAnthropicRequest(body, { allowThinking = false } = {}) { if (!body || typeof body !== 'object') throw new ProtocolError('request body must be a JSON object'); if (!Array.isArray(body.messages) || body.messages.length === 0) { throw new ProtocolError('messages: must be a non-empty array'); @@ -108,6 +108,17 @@ export function fromAnthropicRequest(body) { const tc = convertToolChoice(body.tool_choice); if (tc) out.tool_choice = tc; } + + // 思考开关(默认丢弃,见下):Anthropic 用 thinking:{type,budget_tokens} 表达, + // 上游 OpenAI 端点认 thinking:{type:enabled} + reasoning_effort。仅当部署显式开启 + // CNB2API_ALLOW_THINKING=1 时映射透传;默认丢弃,理由与 proxy.mjs 出站剥除同一 + // 教训——thinking 变体把推理计入 max_tokens,预算不足时 content 永不产出。 + // budget_tokens 不映射:上游没有对应字段,强度用 reasoning_effort 表达。 + if (allowThinking && body.thinking && typeof body.thinking === 'object' + && body.thinking.type === 'enabled') { + out.thinking = { type: 'enabled' }; + if (typeof body.thinking.effort === 'string') out.reasoning_effort = body.thinking.effort; + } return out; } diff --git a/src/config.mjs b/src/config.mjs index 43c33f9..33d990e 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -21,6 +21,11 @@ export const config = { idleTimeoutMs: Number(process.env.PROXY_IDLE_TIMEOUT_MS || 300_000), // per-stream idle cap (reset each chunk) authFailWindowMs: 60_000, authFailMax: 10, + // 放行客户端的思考开关(reasoning_effort / reasoning / enable_thinking / + // thinking)到上游 thinking 变体,而不是在出站时剥除。默认关闭:剥除是安全 + // 默认值,理由见 src/proxy.mjs 的剥除说明。开启者必须自行保证 max_tokens + // 足够大——thinking 变体把推理阶段也计入该预算。 + allowThinking: process.env.CNB2API_ALLOW_THINKING === '1', upstreamUrl: '', }; diff --git a/src/proxy.mjs b/src/proxy.mjs index 5d91f97..693ad71 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -10,9 +10,18 @@ import { observe as observeModel } from './models.mjs'; // content 全空(答案永不生成,真机实测 2048/8192 token 必现)。上游无正确开关可配, // 出站统一剥除、锁定非 thinking 变体(与 /v1/messages 请求侧丢弃 thinking 块同一策略)。 // 表驱动:上游若引入新的思考开关命名,往这张表加一项即可。 +// +// 逃生口:剥除是"安全默认值",不是"唯一正确策略"。它把客户端永久锁死在非 thinking +// 变体上,思考能力整体消失——2026-10-08 同步本提交后,某下游的 CNB 侧 reasoning 从 +// 每天数百万字符直接归零,且试遍参数拼写(reasoningEffort / enableThinking / +// chat_template_kwargs / extra_body / thinking_budget)都无法再触达 thinking 变体。 +// 会翻车的组合其实只是"思考开关 + 过小的 max_tokens";max_tokens 给够时推理与正文会 +// 依次产出(2026-09-18/09-20 真实流量在 max_tokens=60000 下完全正常)。 +// 因此:默认仍剥除,工作区显式设 CNB2API_ALLOW_THINKING=1 时放行,由客户端自负预算。 const REASONING_TRIGGERS = ['reasoning_effort', 'reasoning', 'enable_thinking', 'thinking']; function stripReasoningTriggers(payload) { + if (config.allowThinking) return payload; if (!REASONING_TRIGGERS.some((k) => k in payload)) return payload; const out = { ...payload }; for (const k of REASONING_TRIGGERS) delete out[k]; diff --git a/src/server.mjs b/src/server.mjs index 9a5f8db..339ce61 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -159,7 +159,7 @@ const server = http.createServer(async (req, res) => { let openaiPayload; try { - openaiPayload = fromAnthropicRequest(anthropicBody); + openaiPayload = fromAnthropicRequest(anthropicBody, { allowThinking: config.allowThinking }); } catch (e) { if (e instanceof ProtocolError) { return send(res, e.status, { type: 'error', error: { type: 'invalid_request_error', message: e.message } }); diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs index 37d782a..eb58cbb 100644 --- a/test/anthropic.test.mjs +++ b/test/anthropic.test.mjs @@ -233,6 +233,39 @@ test('请求转换:thinking 请求参数不泄漏出站(真机教训:触 assert.equal(out.messages.length, 1); }); +test('请求转换:显式开启 CNB2API_ALLOW_THINKING 后 thinking 映射透传(逃生口)', () => { + // 默认丢弃是安全默认值,但会把客户端永久锁死在非 thinking 变体上。部署显式 + // opt-in 后按上游 OpenAI 端点认识的形状映射:thinking:{type:enabled} + + // reasoning_effort。budget_tokens 不映射(上游无对应字段)。 + const out = fromAnthropicRequest({ + model: 'claude-sonnet-4-5', + max_tokens: 60000, + thinking: { type: 'enabled', budget_tokens: 2048, effort: 'high' }, + messages: [{ role: 'user', content: 'hi' }], + }, { allowThinking: true }); + assert.deepEqual(out.thinking, { type: 'enabled' }); + assert.equal(out.reasoning_effort, 'high'); + // 其余字段照旧 + assert.equal(out.max_tokens, 60000); + assert.equal(out.messages.length, 1); + + // thinking:disabled 不该被映射成 enabled —— 客户端明确要关就必须关 + const disabled = fromAnthropicRequest({ + max_tokens: 60000, + thinking: { type: 'disabled' }, + messages: [{ role: 'user', content: 'hi' }], + }, { allowThinking: true }); + assert.equal(disabled.thinking, undefined); + + // 没带 thinking 的请求不受开启影响(无开关可透传) + const plain = fromAnthropicRequest({ + max_tokens: 60000, + messages: [{ role: 'user', content: 'hi' }], + }, { allowThinking: true }); + assert.equal(plain.thinking, undefined); + assert.equal(plain.reasoning_effort, undefined); +}); + // --------------------------------------------------------------------------- // 非流式响应转换 // --------------------------------------------------------------------------- diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index 16fff6c..bf15775 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -426,3 +426,45 @@ test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN const code = await new Promise((r) => bad.on('exit', (c) => r(c))); assert.equal(code, 1, 'must refuse to start with a non-numeric timeout'); }); + +// --------------------------------------------------------------------------- +// 逃生口:CNB2API_ALLOW_THINKING=1 时放行思考开关 +// 独立起一个进程(配置在启动时读取),用独立端口避免与主实例冲突 +// --------------------------------------------------------------------------- +test('CNB2API_ALLOW_THINKING=1 → 思考开关透传到上游(逃生口)', async () => { + const OPTIN_PORT = PROXY_PORT + 2; + const optIn = spawn(process.execPath, ['src/server.mjs'], { + cwd: new URL('..', import.meta.url).pathname, + env: { + ...process.env, PROXY_PORT: String(OPTIN_PORT), PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', + CNB_TOKEN: 'test-token', UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}`, + PROXY_MODELS_STATE: join(tmpdir(), `cnb2api-optin-${process.pid}.json`), + CNB2API_ALLOW_THINKING: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await waitPort(OPTIN_PORT); + const r = await fetch(`http://127.0.0.1:${OPTIN_PORT}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}` }, + body: JSON.stringify({ + model: 'mock-model', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + max_tokens: 60000, + reasoning_effort: 'high', + thinking: { type: 'enabled' }, + }), + }); + assert.equal(r.status, 200); + await r.text(); + assert.equal(upstreamState.lastBody.reasoning_effort, 'high', 'opt-in must forward reasoning_effort'); + assert.deepEqual(upstreamState.lastBody.thinking, { type: 'enabled' }, 'opt-in must forward thinking'); + // 非思考字段仍原样透传 + assert.equal(upstreamState.lastBody.model, 'mock-model'); + assert.equal(upstreamState.lastBody.max_tokens, 60000); + } finally { + optIn.kill('SIGTERM'); + } +});