Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions src/provider-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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
Expand Down Expand Up @@ -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
Expand Down
Loading