diff --git a/lib/index.js b/lib/index.js index b784f6b..118ee0f 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,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 }); + 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 0f660f8..a811474 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. + */ +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,30 @@ export function generateHttpError( { status }, ) } + 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. 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 ${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)) { // 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..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 @@ -4591,6 +4604,42 @@ 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', () => + // 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) => { + 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 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 usually will not be enough/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