diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index 17a27678775..99c3326af0e 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -33,6 +33,7 @@ import { boundedReasoningIdentity, collectAnthropicMessage, isClaudeWebSearchToolName, + isContextOverflowError, messageSnapshot, sanitizeWebSearchInput, sseFrame, @@ -407,9 +408,10 @@ function createAnthropicMessageWriter( } const message = typeof error.message === "string" ? error.message : "upstream request failed"; const deterministic = error.code === UNDECLARED_TOOL_CALL_ERROR_CODE; + const overflow = isContextOverflowError(error, message); const original = encoderOriginalErrorEvidence(terminal); fail(anthropicFailedStatus(error, message), message, !deterministic, - deterministic || error.code === "context_length_exceeded" ? error.code as string : undefined, + deterministic ? error.code as string : overflow ? "context_length_exceeded" : undefined, hasProxyErrorMessage(options.proxyErrorContext, message, { type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined }), isEncoderTransportFailure(terminal) ? "transport_failed" : undefined, { ...(original ?? { message, type: typeof error.type === "string" ? error.type : undefined, code: typeof error.code === "string" ? error.code : undefined }), status: original?.status ?? anthropicFailedStatus(error, message) }); }, diff --git a/structure/data-planes/error-detail.md b/structure/data-planes/error-detail.md index 45acf9ecb16..c6873ae02b4 100644 --- a/structure/data-planes/error-detail.md +++ b/structure/data-planes/error-detail.md @@ -372,6 +372,7 @@ selecting the compaction sentence. The shared `isContextOverflowError` source an remain unchanged; `tests/claude-integration/messages-overflow-baseline-contract.test.ts` binds their source bytes. The executable overflow golden compares actual client recovery-phrase recognition, HTTP status and the original internal decisions independently on every fixture/path row. +The direct Messages writer also applies that shared verdict to the emitted code: a definite generic-coded overflow carries `context_length_exceeded`, the fixed compaction sentence and HTTP 400 on collection, even with `retryable: true`; original error evidence and proxy/transport metadata remain separate. `tests/helpers/messages-overflow-assertions.ts` records the five affected fixture ids only on direct-stream/direct-fold as explicit expected changes to the immutable golden. All other paths keep their baseline comparisons; neither the fixture data nor the shared predicate is regenerated. The direct encoder retains original adapter fields by terminal identity independently of shared classification. Buffered passthrough and count_tokens preserve the upstream status and closed type/code. The type chosen diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 2d95e89095f..732ff301a4b 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -225,9 +225,9 @@ the wire-silence heartbeat and live-destination stall watchdog ([timeout policy] converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/outbound.ts` (ids, chunk and frame builders, usage mapping, `chatCompletionsStreamErrorPayload`, `chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, -`anthropicIncompleteOutcome`, `anthropicFailedStatus`, the message snapshot, the web-search pair), +`anthropicIncompleteOutcome`, `anthropicFailedStatus`, `isContextOverflowError`, the message snapshot, the web-search pair), so the frames a client receives are the converters' frames. A fold is the encoded stream read by -the existing collector. Only producer-marked watchdog/EOF terminals retain local Chat wording; upstream copies of their reason or message follow [client error normalization](error-detail.md#client-error-bodies-chat). Explicit incomplete reasons use the [shared normalization contract](../transports/responses-wire-shapes.md#adapter-incomplete-reasons) before the writers classify the outcome. +the existing collector. Messages terminal errors use the shared overflow verdict for their code as well as their status: definite generic-coded refusals carry `context_length_exceeded` even when marked retryable; the deterministic undeclared-tool refusal keeps its code. `tests/responses/protocol-direct-encoders-messages.test.ts` checks stream/fold parity for all four generic codes, plus an `upstream_error` type variant. The original adapter evidence and proxy/transport metadata still reach the [owned Messages formatter](error-detail.md#messages-error-bodies-and-headers). Only producer-marked watchdog/EOF terminals retain local Chat wording; upstream copies of their reason or message follow [client error normalization](error-detail.md#client-error-bodies-chat). Explicit incomplete reasons use the [shared normalization contract](../transports/responses-wire-shapes.md#adapter-incomplete-reasons) before the writers classify the outcome. Deliberate equivalences, pinned by the parity tests: a Chat function call is delivered as one complete tool-call chunk when it completes, as the converter always did; Messages streams diff --git a/tests/claude-integration/messages-error-normalization.test.ts b/tests/claude-integration/messages-error-normalization.test.ts index 30fd29c187a..02e5eadb6ad 100644 --- a/tests/claude-integration/messages-error-normalization.test.ts +++ b/tests/claude-integration/messages-error-normalization.test.ts @@ -63,13 +63,13 @@ for (const [index, error] of failures.entries()) { contained(text); expect(text).toContain("event: error\n"); const payload = JSON.parse(text.split("data: ").at(-1)!); - if (index === 2 || index === 3) expect(payload.error).toMatchObject({ type: "invalid_request_error", ...(direct && index === 3 ? {} : { code: "context_length_exceeded" }), message: direct && index === 3 ? "The request exceeds the model's context window." : "prompt is too long: The request exceeds the model's context window." }); + if (index === 2 || index === 3) expect(payload.error).toMatchObject({ type: "invalid_request_error", code: "context_length_exceeded", message: "prompt is too long: The request exceeds the model's context window." }); if (index === 4 || index === 5) expect(payload.error.type).toBe("rate_limit_error"); const foldBudget = createTestTranslatorBudget(); const fold = direct ? await foldAnthropicMessage((async function* () { yield { ...error, type: "error", errorType: error.type } as AdapterEvent; })(), { model: "fixture", translatorBudget: foldBudget }) : null; if (fold) { const body = await fold.text(); contained(body); - expect(fold.status).toBe(index === 2 ? 400 : 502); + expect(fold.status).toBe(index === 2 || index === 3 ? 400 : 502); expect(JSON.parse(body).error.type).toBe(payload.error.type); } }); diff --git a/tests/helpers/messages-overflow-assertions.ts b/tests/helpers/messages-overflow-assertions.ts index 97ba83092ef..257a3da9451 100644 --- a/tests/helpers/messages-overflow-assertions.ts +++ b/tests/helpers/messages-overflow-assertions.ts @@ -22,6 +22,16 @@ if (golden.caseTableSha256 !== messagesOverflowCaseTableHash()) { export const cases = messagesOverflowLanes.flatMap(lane => applicableMessagesOverflowFixtures(lane).map(input => ({ lane, input }))); export const key = (row: Pick) => messagesOverflowCaseId(row.lane, row.input); +// Reviewed #8148 deltas against the immutable #8153 baseline. No runtime classifier +// chooses expectations: only these five fixtures on the two direct-writer paths change. +const DIRECT_OVERFLOW_BASELINE_RECOVERY = new Map([ + ["shared-8128-2", false], + ["shared-8128-3", false], + ["normalization-3", false], + ["sweep-code-api_error-phrase", true], + ["sweep-code-upstream_error-phrase", true], +]); + function assertRow(base: Pick, current: Row) { const outcome = golden.outcomes[key(base)]!; expect(outcome).toBeDefined(); @@ -29,6 +39,27 @@ function assertRow(base: Pick, current: Row) { const before = { status: outcome[1], clientRecognizes: outcome[2], internalDecisions: golden.decisions[outcome[3]], classification: outcome[4], type: golden.types[outcome[5]] ?? undefined, code: golden.codes[outcome[6]] ?? undefined, mediaType: golden.mediaTypes[outcome[7]], clientError: outcome[8], bodySha256: outcome[9], bodyLength: outcome[10] }; + const correctedOverflow = DIRECT_OVERFLOW_BASELINE_RECOVERY.has(base.input.id) + && (base.lane === "direct-stream" || base.lane === "direct-fold"); + const expected = correctedOverflow ? { + ...before, + status: base.lane === "direct-fold" ? 400 : 200, + clientRecognizes: true, + code: "context_length_exceeded", + internalDecisions: base.lane === "direct-fold" + ? { status: true, writer: true, "direct-collector": true } + : { status: true, writer: true }, + } : before; + if (correctedOverflow) { + // Keep the original observation explicit; an unrelated baseline change is not a delta. + expect(before.status).toBe(base.lane === "direct-fold" ? 502 : 200); + expect(before.type).toBe("invalid_request_error"); + expect(before.code).toBeUndefined(); + expect(before.clientRecognizes).toBe(DIRECT_OVERFLOW_BASELINE_RECOVERY.get(base.input.id)); + expect(before.internalDecisions).toEqual(base.lane === "direct-fold" + ? { status: true, writer: false, "direct-collector": false } + : { status: true, writer: false }); + } expect(current.input).toEqual(base.input); const after = current.head; expect(after.text.length).toBeGreaterThan(0); @@ -37,7 +68,7 @@ function assertRow(base: Pick, current: Row) { expect(before.status).toBe(200); expect(base.input.eventLanes).toContain(base.lane); } - expect(after.status).toBe(fold502 ? 502 : before.status); + expect(after.status).toBe(fold502 ? 502 : expected.status); const classification = base.input.classification[base.lane]; expect(["error", "success"]).toContain(classification); expect(classification).toBe(before.classification); @@ -46,9 +77,9 @@ function assertRow(base: Pick, current: Row) { if (!base.input.fault) expect(base.input.eventLanes).toContain(base.lane); } expect(before.clientRecognizes).toBe(base.input.recovery[base.lane]); - expect(after.clientRecognizes).toBe(base.input.recovery[base.lane]); - expect(after.clientRecognizes).toBe(before.clientRecognizes); - expect(after.internalDecisions).toEqual(before.internalDecisions); + expect(after.clientRecognizes).toBe(correctedOverflow ? true : base.input.recovery[base.lane]); + expect(after.clientRecognizes).toBe(expected.clientRecognizes); + expect(after.internalDecisions).toEqual(expected.internalDecisions); const passthrough = base.lane === "passthrough" || base.lane === "count_tokens"; if (classification === "success" || base.input.fixedLocal) { expect(before.bodySha256).toMatch(/^[a-f0-9]{64}$/); @@ -65,10 +96,10 @@ function assertRow(base: Pick, current: Row) { expect(after.text).not.toContain("zq7Vb2LmN4pX9tR1sK8wD3fH6jY0cA5e"); if (base.input.fixedLocal) return; expect(after.envelopeType).toBe("error"); - if (before.clientError) expect(after.compactionSentence).toBe(before.clientRecognizes); + if (before.clientError) expect(after.compactionSentence).toBe(expected.clientRecognizes); expect(ANTHROPIC_ERROR_TYPES.has(after.type ?? "")).toBe(true); if (ANTHROPIC_ERROR_TYPES.has(before.type ?? "")) expect(after.type).toBe(before.type); - expect(after.code).toBe(typeof before.code === "string" && before.code !== "thread_unsupported_request" && ANTHROPIC_ERROR_CODES.has(before.code) ? before.code : undefined); + expect(after.code).toBe(typeof expected.code === "string" && expected.code !== "thread_unsupported_request" && ANTHROPIC_ERROR_CODES.has(expected.code) ? expected.code : undefined); } /** Each test owns only one 64-row child run; no shared hook computes the full matrix. */ diff --git a/tests/responses/protocol-direct-encoders-messages.test.ts b/tests/responses/protocol-direct-encoders-messages.test.ts index 2b050a6cc47..7426b61e9f4 100644 --- a/tests/responses/protocol-direct-encoders-messages.test.ts +++ b/tests/responses/protocol-direct-encoders-messages.test.ts @@ -339,6 +339,25 @@ describe("direct Messages encoder matches bridge + converter (stream)", () => { }); describe("direct Messages fold matches bridge + collector (non-stream)", () => { + test.each([ + ["server_error", "server_error"], + ["upstream_error", "server_error"], + ["api_error", "server_error"], + ["internal_error", "server_error"], + ["upstream_error", "upstream_error"], + ])("generic-coded %s context overflow with errorType %s stays terminal on both paths", async (code, errorType) => { + const events: AdapterEvent[] = [{ type: "error", errorType, + code, message: "Your input exceeds the context window of this model", retryable: true }]; + const frames = await expectStreamParity(events); + expect(frames).toEqual([{ event: "error", data: { type: "error", error: { + type: "invalid_request_error", code: "context_length_exceeded", + message: "prompt is too long: The request exceeds the model's context window.", + } } }]); + const response = await expectFoldParity(events); + expect(response.status).toBe(400); + expect(response.body).toEqual(frames[0]!.data); + }); + test.each([400, 413, undefined])("context rejection remains terminal with upstream status %s", async status => { const events: AdapterEvent[] = [{ type: "error", status, errorType: "invalid_request_error", code: "context_length_exceeded", message: "Synthetic input limit", retryable: false }];