From 23c0ab531b2b17973f1e107e017ac10c5da92d8c Mon Sep 17 00:00:00 2001 From: Mars-Sea <33591741+Mars-Sea@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:47:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20400=20JSON=20node=20=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E5=8D=95=E7=8B=AC=E5=88=86=E7=B1=BB=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=90=BD=E5=85=A5=E9=80=9A=E7=94=A8=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=EF=BC=88#96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateHttpError 新增 JSON_NODE_LIMIT_REJECTION 分支(位于上下文溢出分支之后):网关的 JSON 节点数上限是结构限制、不是 token 上下文超长,文案中英双语写明上限数值与「压缩上下文无效」,失败码保持 PROVIDER_HTTP_ERROR(不重试、不轮换账号),上游原文仍按既有隐私设计不回显、只引用其给出的上限。 tests/adapter.test.ts 新增回归测试;lib/index.js 已重新构建。 验证:typecheck 干净,npm test 1059/1059,npm run build 成功。 --- lib/index.js | 16 ++++++++++++++++ src/provider-errors.ts | 34 ++++++++++++++++++++++++++++++++++ tests/adapter.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/lib/index.js b/lib/index.js index b784f6b..3576c2a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4495,6 +4495,18 @@ function parseOutputCeiling(detail) { return Number.isSafeInteger(ceiling) && ceiling > 0 ? ceiling : void 0; } /** +* The gateway's cap on the request's JSON STRUCTURE: `JSON node count +* cannot exceed 32768` (issue #96). A structural limit, not a token one — +* a session heavy in messages, tool calls and images trips it while well +* inside the context window, so it needs its own wording: reported as the +* generic refusal, the one sentence naming the actual cap was dropped and +* the user had no lever at all; reported as a context overflow, the +* harness would compact tokens and resend a structure that still exceeds +* the cap. The stated limit is captured because the gateway owns the +* number and it can move. +*/ +const JSON_NODE_LIMIT_REJECTION = /JSON node count cannot exceed\s+(\d+)/i; +/** * Whether one rejection says "ZDR requested, no ZDR-capable upstream": the * provider spells the code `cmd_zdr_no_providers` in JSON error bodies and * `CMD_ZDR_NO_PROVIDERS` in plain-text ones (the official CLI's own @@ -4635,6 +4647,10 @@ function generateHttpError(status, errText, retryAfterMs, parsed = parseProvider return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error ${status}: this request's max_tokens is not valid for this model` + (stated ? ` (${stated})` : "") + " — the session is not oversized, so compacting it will not help; lower \"Max output tokens\" or pick a model with a larger output ceiling", `Command Code API 返回 ${status}:本次请求的 max_tokens 对该模型不合法` + (stated ? `(${stated})` : "") + "——会话内容并未超长,压缩上下文无效;请调低「最大输出 token」或改用输出上限更大的模型", { status }); } if (status < 500 && isContextOverflowDetail(overflowDetail)) return bilingual(CONTEXT_WINDOW_EXCEEDED_CODE, `Command Code API error ${status}: the request exceeds the model's context window — this session is being compacted and the request retried`, `Command Code API 返回 ${status}:请求内容超出模型上下文窗口——正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, { status }); + if (status < 500 && JSON_NODE_LIMIT_REJECTION.test(overflowDetail)) { + const limit = JSON_NODE_LIMIT_REJECTION.exec(overflowDetail)?.[1]; + return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error ${status}: the request exceeds the gateway's JSON node limit (node count cannot exceed ${limit}) — the cap is on the request's structure, not its token length, so compacting the session will not help; start a new session, or reduce the messages and images in this one`, `Command Code API 返回 ${status}:请求超过网关的 JSON 节点数上限(节点数不能超过 ${limit})——限制的是请求结构,不是 token 长度,压缩上下文无效;请新建会话,或减少本会话中的消息与图片`, { status }); + } if (status === 422 && isZdrNoProviders(providerCode, providerDetail, errText)) return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error 422 (${detail}): zero data retention is enforced but no ZDR-capable upstream is available for this model — turn the ZDR setting off (or use another model)`, "Command Code API 返回 422:已开启零数据保留(ZDR),但该模型当前没有可用的 ZDR 上游——请关闭 ZDR 开关,或改用其他模型", { status: 422 }); if (status === 401) return bilingual("INVALID_CREDENTIAL", `Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file`, "Command Code API 返回 401:API 密钥缺失或无效——请在设置页检查 COMMANDCODE_API_KEY 存储的密钥,或检查 auth 文件", { status: 401 }); if (status === 413) return bilingual("PROVIDER_HTTP_ERROR", "Command Code API error 413: the request body exceeds the provider's size limit (the cap is undocumented, measured at about 50 MB) — the session history is too large to send, usually because of accumulated image attachments. Start a new session, or drop the image-heavy part of this one, and retry", "Command Code API 返回 413:请求体超过服务端的体积上限(官方未公开,实测约 50 MB)——会话历史过大,通常是历史图片累积所致。请新建会话,或移除本会话中图片较多的部分后重试", { status: 413 }); diff --git a/src/provider-errors.ts b/src/provider-errors.ts index 0f660f8..c61ce62 100644 --- a/src/provider-errors.ts +++ b/src/provider-errors.ts @@ -78,6 +78,19 @@ export function parseOutputCeiling(detail: string): number | undefined { return Number.isSafeInteger(ceiling) && ceiling > 0 ? ceiling : undefined } +/** + * The gateway's cap on the request's JSON STRUCTURE: `JSON node count + * cannot exceed 32768` (issue #96). A structural limit, not a token one — + * a session heavy in messages, tool calls and images trips it while well + * inside the context window, so it needs its own wording: reported as the + * generic refusal, the one sentence naming the actual cap was dropped and + * the user had no lever at all; reported as a context overflow, the + * harness would compact tokens and resend a structure that still exceeds + * the cap. The stated limit is captured because the gateway owns the + * number and it can move. + */ +export const JSON_NODE_LIMIT_REJECTION = /JSON node count cannot exceed\s+(\d+)/i + /** * Whether one rejection says "ZDR requested, no ZDR-capable upstream": the * provider spells the code `cmd_zdr_no_providers` in JSON error bodies and @@ -367,6 +380,27 @@ export function generateHttpError( { status }, ) } + if (status < 500 && JSON_NODE_LIMIT_REJECTION.test(overflowDetail)) { + // The gateway capped the request's JSON structure, not its token + // length (issue #96). Checked after the overflow branch — the wording + // matches neither overflow pattern, and conflating the two would send + // the harness to compact a session whose resent structure exceeds the + // same cap. Like the output-limit branch above, the advice is the only + // lever: fewer messages and images, or a new session. The raw upstream + // sentence itself stays unshown, per the display rule at the bottom of + // this function; only its stated limit is quoted. + const limit = JSON_NODE_LIMIT_REJECTION.exec(overflowDetail)?.[1] + return bilingual( + 'PROVIDER_HTTP_ERROR', + `Command Code API error ${status}: the request exceeds the gateway's JSON node limit` + + ` (node count cannot exceed ${limit}) — the cap is on the request's structure, not` + + ' its token length, so compacting the session will not help; start a new session,' + + ' or reduce the messages and images in this one', + `Command Code API 返回 ${status}:请求超过网关的 JSON 节点数上限(节点数不能超过 ${limit})` + + '——限制的是请求结构,不是 token 长度,压缩上下文无效;请新建会话,或减少本会话中的消息与图片', + { status }, + ) + } if (status === 422 && isZdrNoProviders(providerCode, providerDetail, errText)) { // Zero data retention refused (the `zdr` connection option is on): either // this model has no ZDR-capable upstream, or none had capacity at that diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index a4f65ef..b2ad7d2 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -4591,6 +4591,36 @@ test('a second output-ceiling refusal fails the turn and does not blame the cont assert.equal(attempts(), 2) }) +test('a JSON node-count refusal names the gateway structure cap, not a context overflow (issue #96)', async () => { + // The gateway refused the request's JSON STRUCTURE — too many nodes — + // while the session was well inside the token window. Before this branch + // the refusal fell through to the generic wording, which drops the + // upstream sentence entirely, so the one fact that names the actual cap + // never reached the user. + const { adapter, model, attempts } = ceilingProbeAdapter('claude-node-limit', () => + ceilingRejection(400, 'JSON node count cannot exceed 32768')) + await assert.rejects( + collect(adapter.stream({ provider: 'commandcode', model, messages: [userMessage('hi')] })), + (error: unknown) => { + const message = (error as Error).message + assert.equal((error as { code?: string }).code, 'PROVIDER_HTTP_ERROR') + // The cap the gateway stated, so the user can tell this failure apart + // from both a token budget and the generic refusal it used to land in. + assert.match(message, /32768/u) + assert.match(message, /JSON node limit/u) + // Not a context overflow: compaction shrinks tokens, and the resent + // structure would exceed the same cap. The wording has to say so, not + // merely avoid the word. + assert.doesNotMatch(message, /context window|上下文窗口/u) + assert.match(message, /compacting the session will not help/u) + assert.match(message, /压缩上下文无效/u) + return true + }, + ) + // Permanent and request-scoped: no ceiling to learn, nothing to resend. + assert.equal(attempts(), 1) +}) + test('a ceiling learned from a refusal is what the next turn sends', async () => { const { adapter, model, sent } = ceilingProbeAdapter('claude-ceiling-learned', (attempt) => attempt === 0 From c7f4ec8d5f1c42c829737142a115279c65cfd5c5 Mon Sep 17 00:00:00 2001 From: Mars-Sea <1195751600@qq.com> Date: Wed, 21 Oct 2026 13:46:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20JSON=20=E8=8A=82=E7=82=B9=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E6=96=87=E6=A1=88=E6=94=B9=E4=B8=BA=E3=80=8C=E9=80=9A?= =?UTF-8?q?=E5=B8=B8=E4=B8=8D=E8=B6=B3=E3=80=8D=EF=BC=8C=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=94=81=E5=AE=9A=E7=9C=9F=E5=AE=9E=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E6=8A=A5=E6=96=87=EF=BC=88#96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文案:压缩上下文「无效」过于绝对。压缩会减少消息条数与 content part 数, 节点数可能因此降到上限以内,改为「压缩上下文通常不足以降到该上限以内」/ "compacting the session usually will not be enough",与 issue 报告人的「通常无效」 一致,避免误导用户放弃可能有效的 /compact。 - 测试:改用 issue #96 的真实响应体形态(error.message 为内层转义 JSON 字符串, 点名上限的句子只存在于 ParsedProviderError.rawMessage),替代此前只覆盖单层 message 的构造,防止 unwrap 路径退化后回归测试仍然全绿。 - JSON_NODE_LIMIT_REJECTION 改为模块内常量(全仓无外部引用)。 - 分支内 test() 与 exec() 合并为一次 exec,nodeLimit 自然收窄为 string。 验证:npm run typecheck 干净;npm test 1059/1059;npm run build 后 lib/ 仅有 index.js 按预期更新;用 issue #96 原始报文回放命中新分支,失败码仍为 PROVIDER_HTTP_ERROR(不重试、不触发压缩、不轮换账号)。 --- lib/index.js | 6 ++---- src/provider-errors.ts | 27 +++++++++++++++------------ tests/adapter.test.ts | 31 +++++++++++++++++++++++++------ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/lib/index.js b/lib/index.js index 3576c2a..118ee0f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4647,10 +4647,8 @@ function generateHttpError(status, errText, retryAfterMs, parsed = parseProvider return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error ${status}: this request's max_tokens is not valid for this model` + (stated ? ` (${stated})` : "") + " — the session is not oversized, so compacting it will not help; lower \"Max output tokens\" or pick a model with a larger output ceiling", `Command Code API 返回 ${status}:本次请求的 max_tokens 对该模型不合法` + (stated ? `(${stated})` : "") + "——会话内容并未超长,压缩上下文无效;请调低「最大输出 token」或改用输出上限更大的模型", { status }); } if (status < 500 && isContextOverflowDetail(overflowDetail)) return bilingual(CONTEXT_WINDOW_EXCEEDED_CODE, `Command Code API error ${status}: the request exceeds the model's context window — this session is being compacted and the request retried`, `Command Code API 返回 ${status}:请求内容超出模型上下文窗口——正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, { status }); - if (status < 500 && JSON_NODE_LIMIT_REJECTION.test(overflowDetail)) { - const limit = JSON_NODE_LIMIT_REJECTION.exec(overflowDetail)?.[1]; - return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error ${status}: the request exceeds the gateway's JSON node limit (node count cannot exceed ${limit}) — the cap is on the request's structure, not its token length, so compacting the session will not help; start a new session, or reduce the messages and images in this one`, `Command Code API 返回 ${status}:请求超过网关的 JSON 节点数上限(节点数不能超过 ${limit})——限制的是请求结构,不是 token 长度,压缩上下文无效;请新建会话,或减少本会话中的消息与图片`, { status }); - } + const nodeLimit = status < 500 ? JSON_NODE_LIMIT_REJECTION.exec(overflowDetail)?.[1] : void 0; + if (nodeLimit !== void 0) return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error ${status}: the request exceeds the gateway's JSON node limit (node count cannot exceed ${nodeLimit}) — the cap is on the request's structure, not its token length, so compacting the session usually will not be enough; start a new session, or reduce the messages and images in this one`, `Command Code API 返回 ${status}:请求超过网关的 JSON 节点数上限(节点数不能超过 ${nodeLimit})——限制的是请求结构,不是 token 长度,压缩上下文通常不足以降到该上限以内;请新建会话,或减少本会话中的消息与图片`, { status }); if (status === 422 && isZdrNoProviders(providerCode, providerDetail, errText)) return bilingual("PROVIDER_HTTP_ERROR", `Command Code API error 422 (${detail}): zero data retention is enforced but no ZDR-capable upstream is available for this model — turn the ZDR setting off (or use another model)`, "Command Code API 返回 422:已开启零数据保留(ZDR),但该模型当前没有可用的 ZDR 上游——请关闭 ZDR 开关,或改用其他模型", { status: 422 }); if (status === 401) return bilingual("INVALID_CREDENTIAL", `Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file`, "Command Code API 返回 401:API 密钥缺失或无效——请在设置页检查 COMMANDCODE_API_KEY 存储的密钥,或检查 auth 文件", { status: 401 }); if (status === 413) return bilingual("PROVIDER_HTTP_ERROR", "Command Code API error 413: the request body exceeds the provider's size limit (the cap is undocumented, measured at about 50 MB) — the session history is too large to send, usually because of accumulated image attachments. Start a new session, or drop the image-heavy part of this one, and retry", "Command Code API 返回 413:请求体超过服务端的体积上限(官方未公开,实测约 50 MB)——会话历史过大,通常是历史图片累积所致。请新建会话,或移除本会话中图片较多的部分后重试", { status: 413 }); diff --git a/src/provider-errors.ts b/src/provider-errors.ts index c61ce62..a811474 100644 --- a/src/provider-errors.ts +++ b/src/provider-errors.ts @@ -89,7 +89,7 @@ export function parseOutputCeiling(detail: string): number | undefined { * the cap. The stated limit is captured because the gateway owns the * number and it can move. */ -export const JSON_NODE_LIMIT_REJECTION = /JSON node count cannot exceed\s+(\d+)/i +const JSON_NODE_LIMIT_REJECTION = /JSON node count cannot exceed\s+(\d+)/i /** * Whether one rejection says "ZDR requested, no ZDR-capable upstream": the @@ -380,24 +380,27 @@ export function generateHttpError( { status }, ) } - if (status < 500 && JSON_NODE_LIMIT_REJECTION.test(overflowDetail)) { + const nodeLimit = status < 500 ? JSON_NODE_LIMIT_REJECTION.exec(overflowDetail)?.[1] : undefined + if (nodeLimit !== undefined) { // The gateway capped the request's JSON structure, not its token // length (issue #96). Checked after the overflow branch — the wording // matches neither overflow pattern, and conflating the two would send // the harness to compact a session whose resent structure exceeds the - // same cap. Like the output-limit branch above, the advice is the only - // lever: fewer messages and images, or a new session. The raw upstream - // sentence itself stays unshown, per the display rule at the bottom of - // this function; only its stated limit is quoted. - const limit = JSON_NODE_LIMIT_REJECTION.exec(overflowDetail)?.[1] + // same cap. Compaction is not called useless: it shrinks the message + // and part count, so the node count can fall below the cap — it just + // usually will not be enough on its own. Like the output-limit branch + // above, the advice is the only lever: fewer messages and images, or + // a new session. The raw upstream sentence itself stays unshown, per + // the display rule at the bottom of this function; only its stated + // limit is quoted. return bilingual( 'PROVIDER_HTTP_ERROR', `Command Code API error ${status}: the request exceeds the gateway's JSON node limit` - + ` (node count cannot exceed ${limit}) — the cap is on the request's structure, not` - + ' its token length, so compacting the session will not help; start a new session,' - + ' or reduce the messages and images in this one', - `Command Code API 返回 ${status}:请求超过网关的 JSON 节点数上限(节点数不能超过 ${limit})` - + '——限制的是请求结构,不是 token 长度,压缩上下文无效;请新建会话,或减少本会话中的消息与图片', + + ` (node count cannot exceed ${nodeLimit}) — the cap is on the request's structure,` + + ' not its token length, so compacting the session usually will not be enough; start' + + ' a new session, or reduce the messages and images in this one', + `Command Code API 返回 ${status}:请求超过网关的 JSON 节点数上限(节点数不能超过 ${nodeLimit})` + + '——限制的是请求结构,不是 token 长度,压缩上下文通常不足以降到该上限以内;请新建会话,或减少本会话中的消息与图片', { status }, ) } diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index b2ad7d2..1d82c5d 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -4508,6 +4508,19 @@ function ceilingRejection(status: number, message: string): Response { ) } +/** + * The shape issue #96's gateway actually sends: `error.message` is an escaped + * JSON string, so the sentence naming the cap only exists after one unwrap — + * in `ParsedProviderError.rawMessage`, not `message`. Reproduced verbatim so a + * regression in that unwrap path cannot hide behind a flat single-layer body. + */ +function nestedGatewayRejection(status: number, inner: Record): Response { + return new Response( + JSON.stringify({ error: { message: `${JSON.stringify(inner)}\n`, type: 'invalid_request_error' } }), + { status }, + ) +} + test('an HTTP output-ceiling refusal retries once at the ceiling the endpoint names', async () => { const { adapter, model, sent, attempts } = ceilingProbeAdapter('claude-ceiling-http', (attempt) => attempt === 0 @@ -4598,7 +4611,12 @@ test('a JSON node-count refusal names the gateway structure cap, not a context o // upstream sentence entirely, so the one fact that names the actual cap // never reached the user. const { adapter, model, attempts } = ceilingProbeAdapter('claude-node-limit', () => - ceilingRejection(400, 'JSON node count cannot exceed 32768')) + // issue #96's real body, double-encoded: the cap sentence lives only in + // `rawMessage`, which is the path this branch has to see. + nestedGatewayRejection(400, { + message: 'Invalid request JSON: JSON node count cannot exceed 32768 trace_id: 266affc7ac3a831b5846bdafebff8372', + type: 'invalid_request_error', + })) await assert.rejects( collect(adapter.stream({ provider: 'commandcode', model, messages: [userMessage('hi')] })), (error: unknown) => { @@ -4608,12 +4626,13 @@ test('a JSON node-count refusal names the gateway structure cap, not a context o // from both a token budget and the generic refusal it used to land in. assert.match(message, /32768/u) assert.match(message, /JSON node limit/u) - // Not a context overflow: compaction shrinks tokens, and the resent - // structure would exceed the same cap. The wording has to say so, not - // merely avoid the word. + // Not a context overflow: compaction shrinks the message and part + // count, so it can help — but usually not enough on its own. The + // wording has to say both, calling neither the session oversized nor + // compaction a guaranteed fix. assert.doesNotMatch(message, /context window|上下文窗口/u) - assert.match(message, /compacting the session will not help/u) - assert.match(message, /压缩上下文无效/u) + assert.match(message, /compacting the session usually will not be enough/u) + assert.match(message, /压缩上下文通常不足以降到该上限以内/u) return true }, )