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
4 changes: 3 additions & 1 deletion src/protocols/encoders/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
boundedReasoningIdentity,
collectAnthropicMessage,
isClaudeWebSearchToolName,
isContextOverflowError,
messageSnapshot,
sanitizeWebSearchInput,
sseFrame,
Expand Down Expand Up @@ -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) });
},
Expand Down
1 change: 1 addition & 0 deletions structure/data-planes/error-detail.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions structure/data-planes/protocol-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down
43 changes: 37 additions & 6 deletions tests/helpers/messages-overflow-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,44 @@ if (golden.caseTableSha256 !== messagesOverflowCaseTableHash()) {
export const cases = messagesOverflowLanes.flatMap(lane => applicableMessagesOverflowFixtures(lane).map(input => ({ lane, input })));
export const key = (row: Pick<Row, "lane" | "input">) => 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<string, boolean>([
["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<Row, "lane" | "input">, current: Row) {
const outcome = golden.outcomes[key(base)]!;
expect(outcome).toBeDefined();
expect(golden.lanes[outcome[0]]).toBe(base.lane);
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);
Expand All @@ -37,7 +68,7 @@ function assertRow(base: Pick<Row, "lane" | "input">, 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);
Expand All @@ -46,9 +77,9 @@ function assertRow(base: Pick<Row, "lane" | "input">, 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}$/);
Expand All @@ -65,10 +96,10 @@ function assertRow(base: Pick<Row, "lane" | "input">, 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. */
Expand Down
19 changes: 19 additions & 0 deletions tests/responses/protocol-direct-encoders-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
Expand Down
Loading