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 01/21] 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'); + } +}); From b94dce6f814c09f6c8cc1a19ccbdba3c2209eb33 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 19:57:58 +0800 Subject: [PATCH 02/21] feat: add explicit guarded reasoning budget policy --- src/thinking.mjs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/thinking.mjs diff --git a/src/thinking.mjs b/src/thinking.mjs new file mode 100644 index 0000000..e481f2d --- /dev/null +++ b/src/thinking.mjs @@ -0,0 +1,27 @@ +// Deterministic, local preflight policy for the upstream's reasoning variant. +// It cannot cap upstream reasoning tokens; guarded mode only rejects requests +// whose total output budget is known to be too small. +export const THINKING_MODES = Object.freeze(['off', 'guarded', 'passthrough']); + +export function wantsThinking(payload) { + if (!payload || typeof payload !== 'object') return false; + const effort = payload.reasoning_effort ?? payload.reasoning?.effort; + if (typeof effort === 'string') { + if (!['none', 'off', 'disabled'].includes(effort.toLowerCase())) return true; + } else if (effort !== undefined && effort !== null && effort !== false) { + return true; + } + if (payload.reasoning === true || payload.enable_thinking === true) return true; + if (payload.thinking === true) return true; + if (payload.thinking && typeof payload.thinking === 'object') { + return payload.thinking.type === 'enabled' || payload.thinking.enabled === true; + } + return false; +} + +export function validateThinkingBudget(payload, { mode = 'off', minTokens = 32768 } = {}) { + if (mode !== 'guarded' || !wantsThinking(payload)) return null; + const tokens = payload.max_tokens; + if (Number.isSafeInteger(tokens) && tokens >= minTokens) return null; + return `Thinking in guarded mode requires an explicit max_tokens >= ${minTokens}; got ${JSON.stringify(tokens ?? null)}. Raise max_tokens or set CNB2API_THINKING_MODE=passthrough. This is a precaution, not a guarantee that the upstream will finish reasoning.`; +} From 611022a6fc67766cc58a6384ec47c09f1af1ffb8 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:05:56 +0800 Subject: [PATCH 03/21] feat: configure off/guarded/passthrough reasoning modes --- src/config.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/config.mjs b/src/config.mjs index 33d990e..9b45160 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,4 +1,8 @@ +import { THINKING_MODES } from './thinking.mjs'; + // Central env loading + fail-fast validation. +const thinkingMode = process.env.CNB2API_THINKING_MODE + || (process.env.CNB2API_ALLOW_THINKING === '1' ? 'passthrough' : 'off'); // The proxy refuses to start with a missing/weak key instead of running exposed. export const config = { port: Number(process.env.PROXY_PORT || 9001), @@ -21,11 +25,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', + // Off by default. Legacy CNB2API_ALLOW_THINKING=1 retains passthrough behavior. + // guarded checks max_tokens before forwarding but cannot cap upstream reasoning. + thinkingMode, + thinkingMinTokens: Number(process.env.CNB2API_THINKING_MIN_TOKENS || 32768), + allowThinking: thinkingMode !== 'off', upstreamUrl: '', }; @@ -48,6 +52,14 @@ if (!config.upstreamUrl) { console.error('[config] upstream URL is empty: set CNB_REPO_SLUG (org/repo) or UPSTREAM_OVERRIDE. Refusing to start.'); process.exit(1); } +if (!THINKING_MODES.includes(config.thinkingMode)) { + console.error('[config] CNB2API_THINKING_MODE must be off, guarded, or passthrough. Refusing to start.'); + process.exit(1); +} +if (!Number.isSafeInteger(config.thinkingMinTokens) || config.thinkingMinTokens <= 0) { + console.error('[config] CNB2API_THINKING_MIN_TOKENS must be a positive integer. Refusing to start.'); + process.exit(1); +} // 数字环境变量塞非法值时 Number() 得 NaN,setTimeout(fn, NaN) 按 0ms 立即触发 → // 每条上游流瞬间 abort,全线不可用。fail-fast 在启动时拦下,而不是带病运行。 for (const [key, env] of [['upstreamTimeoutMs', 'PROXY_UPSTREAM_TIMEOUT_MS'], ['idleTimeoutMs', 'PROXY_IDLE_TIMEOUT_MS']]) { From b1179dd16abde7d5cd79b039ecf937551d59d501 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:06:05 +0800 Subject: [PATCH 04/21] feat: reject undersized reasoning requests before upstream fetch --- src/server.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server.mjs b/src/server.mjs index 339ce61..7069348 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -3,6 +3,7 @@ import http from 'node:http'; import { config } from './config.mjs'; import { checkAuth } from './auth.mjs'; import { forward } from './proxy.mjs'; +import { validateThinkingBudget } from './thinking.mjs'; import { log, newReqId } from './log.mjs'; import { snapshot as usageSnapshot } from './usage.mjs'; import { list as listModels } from './models.mjs'; @@ -95,6 +96,8 @@ async function handleChatCompletions(req, res, reqId) { const parsed = await readJsonBody(req, res, reqId); if (parsed === null) return; + const budgetError = validateThinkingBudget(parsed, { mode: config.thinkingMode, minTokens: config.thinkingMinTokens }); + if (budgetError) return send(res, 400, { error: { type: 'invalid_request_error', message: budgetError } }); const wantStream = parsed.stream === true; const out = await forward({ reqId, payload: parsed, wantStream, res }); if (out.handled) return; // 流式:proxy.mjs 已写完响应 @@ -166,6 +169,8 @@ const server = http.createServer(async (req, res) => { } throw e; } + const budgetError = validateThinkingBudget(openaiPayload, { mode: config.thinkingMode, minTokens: config.thinkingMinTokens }); + if (budgetError) return send(res, 400, anthropicError(400, { error: { message: budgetError } })); const clientModel = anthropicBody.model || ''; const wantStream = openaiPayload.stream === true; From 2b65f9f407191cf27dcd09524a11a450c660aba8 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:06:22 +0800 Subject: [PATCH 05/21] fix: surface interrupted upstream streams as errors --- src/anthropic.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/anthropic.mjs b/src/anthropic.mjs index b6e1e39..b5340d7 100644 --- a/src/anthropic.mjs +++ b/src/anthropic.mjs @@ -360,10 +360,16 @@ export class AnthropicStream { return [...head, ...this.out]; } - // 上游中途夭折:Anthropic 规范无 abort 事件,尽力收尾(客户端看到的是截断的完整消息) + // Upstream stream failure must never be disguised as a successful message_stop. abort() { if (this.finished) return []; - return this.end(); + this.finished = true; + this.out.length = 0; + this.out.push(evt('error', { + type: 'error', + error: { type: 'api_error', message: 'upstream stream interrupted' }, + })); + return [...this.out]; } deltaFor(type, deltaEvent, partial) { From 29d39c19a32917aa47a7c9d45f3ad672649d0834 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:06:24 +0800 Subject: [PATCH 06/21] fix: surface interrupted upstream streams as errors --- src/proxy.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index 693ad71..a06201f 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -153,8 +153,11 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { ac.abort(); // 中途断流归因:客户端先断(res 已死)→ 取消上游属正常止损;res 仍活 → 上游停滞/读失败(watchdog 或传输错误) if (res.destroyed || res.writableEnded) clientGone = true; - if (transform) writeEvents(transform.stream.abort()); - if (!res.destroyed) res.end(); + if (!res.destroyed && !res.writableEnded) { + if (transform) writeEvents(transform.stream.abort()); + else res.write('data: ' + JSON.stringify({ error: { type: 'api_error', message: 'upstream stream interrupted' } }) + '\\n\\n'); + res.end(); + } log.warn(reqId, 'stream aborted', { err: String(e).slice(0, 120) }); } finally { clearTimeout(idle); From 229276df9a9d7de02264b5fb65fbca7bdd465c4c Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:06:27 +0800 Subject: [PATCH 07/21] fix: portable Windows test working directory --- test/proxy.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index bf15775..cab4739 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import net from 'node:net'; import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { rmSync } from 'node:fs'; @@ -90,7 +91,7 @@ async function waitPort(port, timeoutMs = 5000) { function startProxy(env = {}) { return spawn(process.execPath, ['src/server.mjs'], { - cwd: new URL('..', import.meta.url).pathname, + cwd: fileURLToPath(new URL('..', import.meta.url)), env: { ...process.env, PROXY_PORT: String(PROXY_PORT), PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', CNB_TOKEN: 'test-token', UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}`, PROXY_UPSTREAM_TIMEOUT_MS: '400', PROXY_IDLE_TIMEOUT_MS: '400', PROXY_MODELS_STATE: STATE_PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -419,7 +420,7 @@ test('reasoning_effort 出站剥除(真机教训:触发上游 thinking 变 test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN 曾使 setTimeout 按 0ms 触发、每条流瞬间 abort)', async () => { const bad = spawn(process.execPath, ['src/server.mjs'], { - cwd: new URL('..', import.meta.url).pathname, + cwd: fileURLToPath(new URL('..', import.meta.url)), env: { ...process.env, PROXY_PORT: String(PROXY_PORT + 1), PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', CNB_TOKEN: 'test-token', UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}`, PROXY_IDLE_TIMEOUT_MS: 'abc' }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -434,7 +435,7 @@ test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN 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, + cwd: fileURLToPath(new URL('..', import.meta.url)), 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}`, From baaca8c5509f625ea561394742789574e7d13a95 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:06:46 +0800 Subject: [PATCH 08/21] fix: encode OpenAI stream errors as valid SSE frames --- src/proxy.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index a06201f..8789345 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -155,7 +155,7 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { if (res.destroyed || res.writableEnded) clientGone = true; if (!res.destroyed && !res.writableEnded) { if (transform) writeEvents(transform.stream.abort()); - else res.write('data: ' + JSON.stringify({ error: { type: 'api_error', message: 'upstream stream interrupted' } }) + '\\n\\n'); + else res.write('data: ' + JSON.stringify({ error: { type: 'api_error', message: 'upstream stream interrupted' } }) + '\n\n'); res.end(); } log.warn(reqId, 'stream aborted', { err: String(e).slice(0, 120) }); From bfd2d333882534d98996f519fb83683f8bb512ff Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:07:04 +0800 Subject: [PATCH 09/21] test: assert interrupted Anthropic streams never report success --- test/anthropic.test.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs index eb58cbb..9e05bbb 100644 --- a/test/anthropic.test.mjs +++ b/test/anthropic.test.mjs @@ -104,7 +104,7 @@ test('请求转换:assistant tool_use arguments 同样改写,tool_choice 缺 ); }); -test('流式:abort() 收尾闭合所有块并以 message_stop 结束', () => { +test('流式:abort() 发出错误而非伪造 message_stop', () => { const s = new AnthropicStream({ requestId: 'ra' }); const raw = [ ...s.feed({ choices: [{ index: 0, delta: { role: 'assistant', content: 'partial' } }] }), @@ -112,13 +112,13 @@ test('流式:abort() 收尾闭合所有块并以 message_stop 结束', () => { ...s.abort(), ]; const names = raw.map((e) => e.event); - // 未闭合的 text 块必须先 content_block_stop,再收尾两事件;序列中不允许重复 stop - assert.deepEqual(names.slice(-3), ['content_block_stop', 'message_delta', 'message_stop']); - assert.equal(names.filter((n) => n === 'message_stop').length, 1); - assert.equal(names.filter((n) => n === 'content_block_stop').length, 1); - // abort 后再 feed/end 不产出任何事件(幂等收尾) + assert.equal(names.at(-1), 'error'); + assert.equal(names.includes('message_stop'), false); + assert.equal(names.includes('message_delta'), false); + assert.deepEqual(JSON.parse(raw.at(-1).data), { type: 'error', error: { type: 'api_error', message: 'upstream stream interrupted' } }); assert.deepEqual(s.feed({ choices: [{ delta: { content: 'x' } }] }), []); assert.deepEqual(s.end(), []); + assert.deepEqual(s.abort(), []); }); test('流式:上游空流(200 零 data 事件)收尾仍发出完整 message_start…message_stop', () => { From 13f2a122c32385ba5bac2d5d5bc9b6d489715686 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:07:16 +0800 Subject: [PATCH 10/21] test: cover reasoning mode detection and guarded budgets --- test/thinking.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 test/thinking.test.mjs diff --git a/test/thinking.test.mjs b/test/thinking.test.mjs new file mode 100644 index 0000000..faeb342 --- /dev/null +++ b/test/thinking.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { wantsThinking, validateThinkingBudget } from '../src/thinking.mjs'; + +test('thinking detection respects explicit disabled settings', () => { + for (const body of [{}, { reasoning_effort: 'none' }, { enable_thinking: false }, + { thinking: { type: 'disabled' } }, { reasoning: { effort: 'off' } }]) { + assert.equal(wantsThinking(body), false); + } + for (const body of [{ reasoning_effort: 'high' }, { enable_thinking: true }, + { thinking: { type: 'enabled' } }, { reasoning: { effort: 'medium' } }]) { + assert.equal(wantsThinking(body), true); + } +}); + +test('guarded mode fails closed when max_tokens is missing or too small', () => { + const settings = { mode: 'guarded', minTokens: 32768 }; + for (const max_tokens of [undefined, null, 8192, 32767, 32768.5, '60000', -1]) { + assert.match(validateThinkingBudget({ thinking: { type: 'enabled' }, max_tokens }, settings), /max_tokens/); + } + assert.equal(validateThinkingBudget({ thinking: { type: 'enabled' }, max_tokens: 32768 }, settings), null); + assert.equal(validateThinkingBudget({ thinking: { type: 'enabled' }, max_tokens: 60000 }, settings), null); +}); + +test('plain requests and backwards-compatible opt-in remain unaffected', () => { + const low = { max_tokens: 64, reasoning_effort: 'high' }; + assert.equal(validateThinkingBudget(low, { mode: 'off' }), null); + assert.equal(validateThinkingBudget(low, { mode: 'passthrough' }), null); + assert.equal(validateThinkingBudget({ max_tokens: 64 }, { mode: 'guarded' }), null); +}); From 8734cbe8f3f10ba371052a4940ae771620697da0 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:07:50 +0800 Subject: [PATCH 11/21] docs: document guarded reasoning configuration and its limits --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 2e85a68..34fa9e2 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,15 @@ CNB_REPO_SLUG=your-org/ai-proxy # 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. +# Legacy alias (equivalent to mode=passthrough when mode is not explicitly set): #CNB2API_ALLOW_THINKING=1 +# +# Preferred mode: off (default), guarded, or passthrough. +# guarded returns HTTP 400 for requests explicitly enabling Thinking unless +# max_tokens is an integer >= CNB2API_THINKING_MIN_TOKENS. This DOES NOT cap +# reasoning output or guarantee the upstream will produce a final answer. +#CNB2API_THINKING_MODE=guarded +#CNB2API_THINKING_MIN_TOKENS=32768 # ---- self-registration to the fixed-domain relay (optional) ---- # Full URL of the relay endpoint, e.g. https://ai.example.com/ops/register From 7e3248a30dcd761656c60a8ed29e630954d3f98e Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:07:58 +0800 Subject: [PATCH 12/21] ci: run checks on enhancement branch pushes --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 794fcff..051a748 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: test on: push: - branches: [main] + branches: [main, feat/reliable-thinking-streams] pull_request: jobs: From 69aad4d9f62aefad9a3c1faf2a2673c7cb9b7b63 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:15:20 +0800 Subject: [PATCH 13/21] fix: reject incomplete upstream SSE and unblock idle-timeout cancellations --- src/proxy.mjs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index 8789345..b5e6242 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -92,6 +92,7 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { let sseTail = ''; // 跨 chunk 的半行缓冲(SSE 行可能被 TCP 分包截断) let usage = null; // 流式路径从尾部 chunk 提取 usage(OpenAI 约定:最后一个 data 带 usage) let ok = false; // 流正常走完(未中途 abort/停滞) + let sawFinishReason = false; // A clean EOF without a final choice is still an incomplete response. let clientGone = false; // 客户端主动断开(正常行为,与上游流故障区分:不计 errors) // 行处理:usage 提取(所有路径)+ 协议转换(挂 transform 时逐行喂流式状态机) const handleLine = (line) => { @@ -101,6 +102,8 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { if (!data || data === '[DONE]') return; let j; try { j = JSON.parse(data); } catch { return; /* 心跳/非 JSON 行忽略 */ } + if (j.error) throw new Error('upstream stream returned an error event'); + if (j.choices?.some((choice) => choice?.finish_reason)) sawFinishReason = true; if (j.usage) usage = j.usage; if (j.model) observeModel(j.model); // 嗅探上游真实模型名(无 list 接口,只能从响应读) if (transform) writeEvents(transform.stream.feed(j)); @@ -127,7 +130,12 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { }); try { for (;;) { - if (res.destroyed || ac.signal.aborted) { clientGone = true; await reader.cancel().catch(() => {}); break; } + if (res.destroyed || ac.signal.aborted) { + clientGone = true; + ac.abort(); + void reader.cancel().catch(() => {}); + break; + } const { done, value } = await readChunk(); if (done) break; if (transform) { @@ -143,14 +151,17 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { } } // 收尾 flush:末尾 usage chunk 若不以换行结尾(上游直接 FIN),残余在 sseTail 里,此处补解析 - decodeAndParse(null); - if (transform) writeEvents(transform.stream.end()); - if (!res.destroyed) res.end(); - ok = true; + if (!clientGone) { + decodeAndParse(null); + if (!sawFinishReason) throw new Error('upstream stream ended without finish_reason'); + if (transform) writeEvents(transform.stream.end()); + if (!res.destroyed) res.end(); + ok = true; + } } catch (e) { clearTimeout(idle); - await reader.cancel().catch(() => {}); - ac.abort(); + ac.abort(); // Abort pending read before cancellation; a stuck reader.cancel() must not hang error delivery. + void reader.cancel().catch(() => {}); // 中途断流归因:客户端先断(res 已死)→ 取消上游属正常止损;res 仍活 → 上游停滞/读失败(watchdog 或传输错误) if (res.destroyed || res.writableEnded) clientGone = true; if (!res.destroyed && !res.writableEnded) { @@ -185,6 +196,11 @@ export async function forward({ reqId, payload, wantStream, res, transform }) { } clearTimeout(idleTimer); const body = aggregate(text); + if (!body.choices?.[0]?.finish_reason) { + recordUsage({ errors: 1 }); + log.warn(reqId, 'upstream incomplete response', { ms: Date.now() - started }); + return { handled: false, status: 502, body: { error: { message: 'upstream stream ended without finish_reason' } } }; + } if (body.model) observeModel(body.model); // 嗅探上游真实模型名(非流式聚合结果里的 model) recordUsage({ prompt: body.usage.prompt_tokens || 0, completion: body.usage.completion_tokens || 0 }); log.info(reqId, 'chat done', { ms: Date.now() - started, out: body.usage.completion_tokens }); From 3c01fe640f562c54b2464de190e3b66315bcc1e0 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:15:26 +0800 Subject: [PATCH 14/21] fix: never manufacture a stop finish reason for incomplete upstream responses --- src/sse.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sse.mjs b/src/sse.mjs index bcc70a0..1b5ca2b 100644 --- a/src/sse.mjs +++ b/src/sse.mjs @@ -53,7 +53,7 @@ export function aggregate(text) { return { id, object: 'chat.completion', created, model, - choices: [{ index: 0, message, finish_reason: finishReason || 'stop' }], + choices: [{ index: 0, message, finish_reason: finishReason }], usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, usage_estimated: true }, }; } From c9a95027e8b9a34a4b760e6cf471e2155ac3b65e Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:16:06 +0800 Subject: [PATCH 15/21] test: cover truncated upstream SSE and guarded request rejection --- test/proxy.test.mjs | 49 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index cab4739..3399091 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -17,13 +17,14 @@ const KEY = 'test-key-12345'; const STATE_PATH = join(tmpdir(), `cnb2api-models-test-${process.pid}.json`); // ---- mock 上游:完整 SSE(usage/tool_calls/finish_reason)+ 可选慢响应 ---- -const upstreamState = { clientAborted: false, lastBody: null }; +const upstreamState = { clientAborted: false, lastBody: null, requestCount: 0 }; const mockServer = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const parsed = JSON.parse(body); upstreamState.lastBody = parsed; // 捕获打向上游的出站请求体(剥除类断言用) + upstreamState.requestCount++; if (parsed.model === 'mock-500') { res.writeHead(500, { 'Content-Type': 'application/json' }); @@ -39,6 +40,12 @@ const mockServer = http.createServer((req, res) => { res.socket.destroy(); return; } + if (parsed.model === 'mock-early-fin') { + // 200 + valid partial SSE but no terminal finish_reason. + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end('data: {"choices":[{"delta":{"content":"partial"}}]}\\n\\n'); + return; + } if (parsed.model === 'mock-stall') { // 出响应头后停滞 → 代理流空闲看门狗应 abort 并结束响应 res.writeHead(200, { 'Content-Type': 'text/event-stream' }); @@ -418,6 +425,46 @@ test('reasoning_effort 出站剥除(真机教训:触发上游 thinking 变 assert.equal(upstreamState.lastBody.stream, true); }); +test('上游提前结束没有 finish_reason → OpenAI 流式错误事件,非流式 HTTP 502', async () => { + const streamed = await chat({ model: 'mock-early-fin', messages: [], stream: true }); + assert.equal(streamed.status, 200, 'SSE headers are already sent before stream failure is detected'); + const frames = await streamed.text(); + assert.match(frames, /"content":"partial"/); + assert.match(frames, /"type":"api_error"/); + const plain = await chat({ model: 'mock-early-fin', messages: [], stream: false }); + assert.equal(plain.status, 502); + assert.match((await plain.json()).error.message, /without finish_reason/); +}); + +test('guarded Thinking rejects insufficient budget without calling upstream', async () => { + const guardPort = PROXY_PORT + 3; + const child = startProxy({ + PROXY_PORT: String(guardPort), + CNB2API_THINKING_MODE: 'guarded', + CNB2API_THINKING_MIN_TOKENS: '32768', + PROXY_MODELS_STATE: join(tmpdir(), `cnb2api-guard-${process.pid}.json`), + }); + try { + await waitPort(guardPort); + const send = (data) => fetch(`http://127.0.0.1:${guardPort}/v1/chat/completions`, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}` }, + body: JSON.stringify({ model: 'mock-model', messages: [], stream: false, ...data }), + }); + const before = upstreamState.requestCount; + const blocked = await send({ max_tokens: 8192, reasoning_effort: 'high' }); + assert.equal(blocked.status, 400); + assert.match((await blocked.json()).error.message, /max_tokens/); + assert.equal(upstreamState.requestCount, before, 'request must be rejected before reaching CNB'); + const plain = await send({ max_tokens: 64 }); + assert.equal(plain.status, 200, 'non-thinking requests are not blocked'); + const allowed = await send({ max_tokens: 32768, reasoning_effort: 'medium' }); + assert.equal(allowed.status, 200); + assert.equal(upstreamState.lastBody.reasoning_effort, 'medium'); + } finally { + child.kill('SIGTERM'); + } +}); + test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN 曾使 setTimeout 按 0ms 触发、每条流瞬间 abort)', async () => { const bad = spawn(process.execPath, ['src/server.mjs'], { cwd: fileURLToPath(new URL('..', import.meta.url)), From 089d2960ab9ad0b610b5c4da88aa59335a9b8ae2 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:16:23 +0800 Subject: [PATCH 16/21] test: ensure mock truncated SSE contains actual newline delimiters --- test/proxy.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index 3399091..074716e 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -43,7 +43,7 @@ const mockServer = http.createServer((req, res) => { if (parsed.model === 'mock-early-fin') { // 200 + valid partial SSE but no terminal finish_reason. res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.end('data: {"choices":[{"delta":{"content":"partial"}}]}\\n\\n'); + res.end('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n'); return; } if (parsed.model === 'mock-stall') { From 305d0de8a94917b2e1e6650e6e887e8a32189c1b Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:17:01 +0800 Subject: [PATCH 17/21] test: verify incomplete Anthropic streams signal errors end-to-end --- test/anthropic.test.mjs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs index 9e05bbb..dde443c 100644 --- a/test/anthropic.test.mjs +++ b/test/anthropic.test.mjs @@ -438,11 +438,16 @@ const mockServer = http.createServer((req, res) => { return; } if (parsed.model === 'mock-empty') { - // 200 但 SSE 体零 data 事件(只有注释行):空流收尾必须完整 + // 200 with no completion must be treated as an upstream error. res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.end(': keep-alive\n\n'); return; } + if (parsed.model === 'mock-early-fin') { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end('data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}\n\n'); + return; + } res.writeHead(200, { 'Content-Type': 'text/event-stream' }); const chunks = [ { id: 'c1', model: 'mock-model', choices: [{ index: 0, delta: { role: 'assistant', content: 'he' } }] }, @@ -520,13 +525,19 @@ test('E2E 错误透传:上游 500 → 500 Anthropic error envelope', async () assert.equal(body.error.message, 'boom'); }); -test('E2E 空流:上游 200 零 data 事件 → 流式仍以 message_delta + message_stop 收尾', async () => { - const res = await postMessages({ model: 'mock-empty', max_tokens: 32, stream: true, messages: [{ role: 'user', content: 'hi' }] }, { 'x-api-key': KEY }); - assert.equal(res.status, 200); - const text = await res.text(); - const events = [...text.matchAll(/event: (.+)\ndata: (.+)\n\n/g)].map((m) => ({ event: m[1], data: JSON.parse(m[2]) })); - assert.deepEqual(events.map((e) => e.event), ['message_start', 'ping', 'message_delta', 'message_stop']); - assert.equal(events[2].data.delta.stop_reason, 'end_turn'); +test('E2E 空流和提前结束 → Anthropic error 事件,而不是 message_stop', async () => { + for (const model of ['mock-empty', 'mock-early-fin']) { + const res = await postMessages({ model, max_tokens: 32, stream: true, messages: [{ role: 'user', content: 'hi' }] }, { 'x-api-key': KEY }); + assert.equal(res.status, 200); + const text = await res.text(); + const events = [...text.matchAll(/event: (.+)\ndata: (.+)\n\n/g)].map((m) => ({ event: m[1], data: JSON.parse(m[2]) })); + assert.equal(events.at(-1).event, 'error'); + assert.equal(events.at(-1).data.error.type, 'api_error'); + assert.equal(events.some((e) => e.event === 'message_stop'), false); + } + const nonstream = await postMessages({ model: 'mock-empty', max_tokens: 32, messages: [{ role: 'user', content: 'hi' }] }, { 'x-api-key': KEY }); + assert.equal(nonstream.status, 502); + assert.equal((await nonstream.json()).error.type, 'api_error'); }); test('E2E 413:超限请求体 → Anthropic error envelope', async () => { From 814bf20e932038c38a622ff6355d48d1199f1837 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:18:22 +0800 Subject: [PATCH 18/21] ci: add manual test trigger and Node syntax checks --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 051a748..06aee0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: [main, feat/reliable-thinking-streams] pull_request: + workflow_dispatch: jobs: test: @@ -13,6 +14,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + - name: JavaScript syntax check + run: | + for file in src/*.mjs test/*.mjs bin/*.mjs; do node --check "$file"; done - run: node --test - name: deploy scripts syntax run: | From 24deabec7a664cdf0a9742999b569275ce567ee1 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:18:47 +0800 Subject: [PATCH 19/21] docs: explain guarded Thinking and CNB branch deployment caveat --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 119bf4d..9a45ceb 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,9 @@ 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. | +| `CNB2API_THINKING_MODE` | `off` | `off`: remove thinking toggles (original default); `guarded`: allow thinking only when the request declares a sufficient total token budget; `passthrough`: forward thinking toggles unchanged. | +| `CNB2API_THINKING_MIN_TOKENS` | `32768` | Minimum explicit `max_tokens` in guarded mode. This is a warning threshold, **not** an upstream reasoning-token cap. | +| `CNB2API_ALLOW_THINKING` | off | Legacy alias: `1` selects `passthrough` when `CNB2API_THINKING_MODE` is unset. 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. | @@ -193,7 +195,9 @@ That default is safe but absolute: it also locks out callers that *do* provide a 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. +identically in all modes. In `guarded` mode, a client explicitly requesting Thinking with `max_tokens` below 32768 (or with no numeric budget) receives HTTP 400 *before* the upstream request. This is only a preflight check; CNB may still exhaust larger budgets. Upstream SSE streams with no terminal finish reason are reported as errors instead of fabricated successful completions. + +**CNB branch caution:** while validating a feature branch, make sure its workspace runs the same branch. The existing `.cnb.yml` keepalive is pinned to `main` and can restart a `main` workspace. Do not merge before tests. --- From e58752177e73f776d18725e67a944870f1636791 Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:18:50 +0800 Subject: [PATCH 20/21] docs: explain guarded Thinking and CNB branch deployment caveat --- README.zh-CN.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 4003a98..efd2e4d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -176,7 +176,9 @@ 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` 时开启——详见下方说明。 | +| `CNB2API_THINKING_MODE` | `off` | `off`:默认剥除思考参数;`guarded`:仅在请求携带足够大 `max_tokens` 时放行;`passthrough`:完全透传思考开关。 | +| `CNB2API_THINKING_MIN_TOKENS` | `32768` | `guarded` 模式要求的最小 `max_tokens`;这是预检阈值,**不是**上游推理 token 的实际限额。 | +| `CNB2API_ALLOW_THINKING` | 关闭 | 兼容旧版:当未配置 `CNB2API_THINKING_MODE` 时,设为 `1` 等同于 `passthrough`。设为 `1` 时,把客户端的思考开关(`reasoning_effort` / `reasoning` / `enable_thinking` / `thinking`,以及 Anthropic 的 `thinking:{type:"enabled"}`)透传到上游 thinking 变体,而不是出站剥除。仅在客户端会给出足够大 `max_tokens` 时开启——详见下方说明。 | | `REGISTER_URL` | — (可选) | 中继自注册 `/ops/register` 地址。 | | `REG_TOKEN` | — (可选) | 中继自注册鉴权密钥。 | @@ -189,7 +191,9 @@ npm run quota # 或: npx cnb2api-quota 该默认值安全但绝对:它同时把**给了足够预算**的调用方一并挡在门外,而这类调用方本来是能 正常思考的(`max_tokens: 60000` 实测推理与正文依次产出)。设 `CNB2API_ALLOW_THINKING=1` -即可显式开启,并由调用方自行保证预算。未携带思考开关的请求在两种设置下行为完全一致。 +即可显式开启,并由调用方自行保证预算。未携带思考开关的请求在两种设置下行为完全一致。`guarded` 模式下,若请求显式开启 Thinking,但 `max_tokens` 小于 32768(或没有合法的数值),会在访问上游前返回 HTTP 400。阈值只是本地预检,不保证 CNB 上游一定能在预算内答完。 + +**CNB 分支注意:**在开发分支验证时,确保工作区也运行该分支。原 `.cnb.yml` 的保活任务固定指向 `main`,自动恢复时可能启动未改进的旧版本。不要在测试通过前合并到 `main`。 --- From cdd0915835f53550cf88c47b5f0b322166b5b6aa Mon Sep 17 00:00:00 2001 From: XiAzir <1791081637@qq.com> Date: Tue, 13 Oct 2026 20:19:43 +0800 Subject: [PATCH 21/21] test: cover guarded Anthropic Thinking after request conversion --- test/thinking.test.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/thinking.test.mjs b/test/thinking.test.mjs index faeb342..3223f2c 100644 --- a/test/thinking.test.mjs +++ b/test/thinking.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { wantsThinking, validateThinkingBudget } from '../src/thinking.mjs'; +import { fromAnthropicRequest } from '../src/anthropic.mjs'; test('thinking detection respects explicit disabled settings', () => { for (const body of [{}, { reasoning_effort: 'none' }, { enable_thinking: false }, @@ -28,3 +29,19 @@ test('plain requests and backwards-compatible opt-in remain unaffected', () => { assert.equal(validateThinkingBudget(low, { mode: 'passthrough' }), null); assert.equal(validateThinkingBudget({ max_tokens: 64 }, { mode: 'guarded' }), null); }); + +test('Anthropic Thinking is checked after conversion to the upstream shape', () => { + const original = { + model: 'mock-model', + max_tokens: 8192, + thinking: { type: 'enabled', budget_tokens: 4096 }, + messages: [{ role: 'user', content: 'hello' }], + }; + const enabled = fromAnthropicRequest(original, { allowThinking: true }); + assert.equal(enabled.thinking.type, 'enabled'); + assert.match(validateThinkingBudget(enabled, { mode: 'guarded', minTokens: 32768 }), /max_tokens/); + assert.equal(validateThinkingBudget({ ...enabled, max_tokens: 60000 }, { mode: 'guarded' }), null); + const disabled = fromAnthropicRequest(original, { allowThinking: false }); + assert.equal(disabled.thinking, undefined); + assert.equal(validateThinkingBudget(disabled, { mode: 'off' }), null); +});