From d16b02641341e780e261d655fc7362fb5a149f23 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:32:38 -0400 Subject: [PATCH 1/2] fix(responses): normalize explicit incomplete reasons --- .../content/docs/reference/proxy-formats.md | 5 ++ src/bridge/response-json.ts | 2 +- src/bridge/sse.ts | 2 +- src/protocols/encoders/adapter-events.ts | 2 +- structure/data-planes/protocol-paths.md | 2 +- structure/transports/responses-wire-shapes.md | 14 ++-- structure/transports/responses.md | 6 +- .../bridge-nonstreaming-terminal.test.ts | 73 +++++++++++++++++++ .../protocol-direct-encoders-chat.test.ts | 16 ++++ .../protocol-direct-encoders-messages.test.ts | 16 ++++ 10 files changed, 124 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 0168480b8e6..6dd03a977ba 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -296,6 +296,11 @@ in both JSON and SSE. Partial output is preserved, and the bridge does not emit completion event for that open call. Calls already completed keep their status. This preserves the provider outcome; client retry behavior for incomplete responses is unchanged. +For adapter-backed responses, explicit incomplete reasons use the same vocabulary in JSON and SSE: +output-limit aliases such as `length` and `max_tokens` become `max_output_tokens`, and refusal/filter +aliases such as `refusal` become `content_filter`. Other reasons remain unchanged, along with the +provider's message, retryability, and usage. Direct Chat and Messages encoders apply the same mapping. + On the pending `dev` implementation for #4112, a final upstream HTTP 413 on this surface is classified as `invalid_request_error` / `context_length_exceeded`. Non-streaming callers retain HTTP 413 with a JSON `error`; streaming callers retain the terminal SSE failure. diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index f74ebccf77b..6439d24669a 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -646,7 +646,7 @@ function buildResponseJSONWithBudget( : errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}), ...(incompleteEvent ? { incomplete_details: { - reason: incompleteEvent.reason, + reason: truncationReasonFor(incompleteEvent.reason) ?? incompleteEvent.reason, ...(incompleteEvent.message ? { message: incompleteEvent.message } : {}), ...(incompleteEvent.retryable !== undefined ? { retryable: incompleteEvent.retryable } : {}), }, diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index e34c3bebdf6..06ea4071175 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1292,7 +1292,7 @@ export function bridgeToResponsesSSE( ...responseSnapshot("incomplete", finishedItems, event.endTurn), usage: responsesUsage(event.usage), incomplete_details: { - reason: event.reason, + reason: truncationReasonFor(event.reason) ?? event.reason, ...(event.message ? { message: event.message } : {}), ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), }, diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 8c10eb2db49..fc4b466f177 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -729,7 +729,7 @@ export function encodeAdapterEventStream( terminal = { status: "incomplete", incomplete: { - reason: event.reason, + reason: truncationReasonFor(event.reason) ?? event.reason, ...(event.message ? { message: event.message } : {}), ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), }, diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 6c08185aa9a..434eacf5247 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -229,7 +229,7 @@ converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/ou `chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, `anthropicIncompleteOutcome`, `anthropicFailedStatus`, 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. +the existing collector. 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/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index ede4d9a7ca1..45fe647f37e 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -2,12 +2,13 @@ ## Client rate-limit retry advice -With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes -`resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. -`src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest -lower bound first; competing original hints are retained under an explicit provider-detail label. -Other explicit verdicts, proxy replay budgets and event ordering are unchanged. This creates -no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. +With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes `resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. `src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest lower bound first; competing original hints are retained under an explicit provider-detail label. +Other explicit verdicts, proxy replay budgets and event ordering are unchanged. This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. + +## Adapter incomplete reasons + +`src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. +Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `steered`; message, retryability, usage, and incomplete status are preserved. `tests/adapters/bridge-nonstreaming-terminal.test.ts` covers both Responses projections and real passthrough parsing; the direct Chat and Messages parity suites cover the client-wire outcomes. ## Compaction image input @@ -526,7 +527,6 @@ request-log accounting without promoting a truncated repair candidate. Terminal > Decision record: [ADR-0044](../decisions/ADR-0044-responses-http-sse.md) - ## Inbound history and code-mode shell wire repairs Inbound function-call history with a missing JSON object prefix is repaired for every provider when diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 0a065aec8ba..1b1fe3f5b1a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -585,9 +585,9 @@ machine for those encoders, so a change to item boundaries, tool naming or termi `sse.ts` has to be made there too; the parity tests fail when the two diverge. `src/bridge/errors.ts` (`formatErrorResponse`) formats error responses and keeps only allowlisted transport verdict codes and the explicit local `spend_ledger_storage_unavailable` identity ([storage refusal contract](responses-spend.md#durable-spend-reservations)). Adapter error events take a different path: `src/bridge/internal.ts` preserves explicit verdicts except cyber-policy and known rate-limit mappings -([client retry advice](responses-wire-shapes.md#client-rate-limit-retry-advice)). The shared usage shaping's `input_tokens_details` and -`output_tokens_details` are always emitted, with zero defaults, because strict Responses clients -deserialize them as required fields. +([client retry advice](responses-wire-shapes.md#client-rate-limit-retry-advice)). Explicit incomplete reasons follow the [reason contract](responses-wire-shapes.md#adapter-incomplete-reasons). +The shared usage shaping's `input_tokens_details` and `output_tokens_details` are always emitted, +with zero defaults, because strict Responses clients deserialize them as required fields. Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or diff --git a/tests/adapters/bridge-nonstreaming-terminal.test.ts b/tests/adapters/bridge-nonstreaming-terminal.test.ts index 23679505861..85ef787ead3 100644 --- a/tests/adapters/bridge-nonstreaming-terminal.test.ts +++ b/tests/adapters/bridge-nonstreaming-terminal.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { isTruncatedStopReason, truncationReasonFor } from "../../src/responses/truncated-stop-reason"; import type { AdapterEvent } from "../../src/types"; @@ -95,6 +97,77 @@ describe("buffered turns without an adapter terminal", () => { }); }); +describe("explicit incomplete reasons use the Responses vocabulary", () => { + const cases = [ + ["length", "max_output_tokens"], + ["max_tokens", "max_output_tokens"], + ["model_context_window_exceeded", "max_output_tokens"], + ["refusal", "content_filter"], + ["content-filter", "content_filter"], + [" SAFETY ", "content_filter"], + ["max_output_tokens", "max_output_tokens"], + ["content_filter", "content_filter"], + ["max_messages", "max_messages"], + ["steered", "steered"], + ["adapter_eof", "adapter_eof"], + [" provider_specific_stop ", " provider_specific_stop "], + ] as const; + + async function project(events: AdapterEvent[], streaming: boolean) { + if (!streaming) return buildResponseJSON(events, "routed/model"); + const text = await sseText(events); + expect(terminalEventNames(text)).toEqual(["response.incomplete"]); + const terminal = text.split("\n\n").find(frame => frame.startsWith("event: response.incomplete\n")); + const data = terminal?.split("\n").find(line => line.startsWith("data: "))?.slice(6); + expect(data).toBeDefined(); + return JSON.parse(data!).response; + } + + for (const streaming of [true, false]) { + test.each(cases)(`stream=${streaming}: %s becomes %s without losing metadata`, async (reason, expected) => { + const response = await project([ + { type: "text_delta", text: "partial answer" }, + { + type: "incomplete", reason, message: "provider stopped this turn", retryable: false, + usage: { inputTokens: 10, outputTokens: 2 }, + }, + ], streaming); + expect(response).toMatchObject({ + status: "incomplete", + incomplete_details: { reason: expected, message: "provider stopped this turn", retryable: false }, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }); + expect(response.output.find((item: { type: string }) => item.type === "message").content[0].text) + .toBe("partial answer"); + }); + } + + for (const upstreamStream of [true, false]) { + test(`passthrough stream=${upstreamStream} length reaches both projections as max_output_tokens`, async () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", baseUrl: "https://provider.example/v1", authMode: "forward", + }); + const budget = createTranslatorBudget(); + try { + const payload = { status: "incomplete", incomplete_details: { reason: "length" } }; + const events: AdapterEvent[] = []; + if (upstreamStream) { + const wire = `event: response.incomplete\ndata: ${JSON.stringify({ type: "response.incomplete", response: payload })}\n\n`; + for await (const event of adapter.parseStream(new Response(wire), budget)) events.push(event); + } else { + events.push(...await adapter.parseResponse!(Response.json(payload), budget)); + } + expect(events).toEqual([{ type: "incomplete", reason: "length" }]); + for (const streaming of [true, false]) { + expect(await project(events, streaming)).toMatchObject({ + status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, + }); + } + } finally { budget.dispose(); } + }); + } +}); + describe("compaction is never installed from a truncated turn", () => { // #422: a compaction item becomes REPLACEMENT HISTORY. The original guard could only see // explicit error/incomplete events, so a stream that stopped without any terminal slipped diff --git a/tests/responses/protocol-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts index 3625ca9010f..029ae974e03 100644 --- a/tests/responses/protocol-direct-encoders-chat.test.ts +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -280,6 +280,22 @@ const SCENARIOS: Record { + test.each([["length", "length"], ["refusal", "content_filter"]] as const)( + "explicit %s incomplete finishes with %s on both paths", async (reason, finishReason) => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "partial" }, { type: "incomplete", reason, usage }, + ]; + const frames = await expectStreamParity(events); + expect(frames.at(-1)).toBe("[DONE]"); + expect(frames.at(-2)).toMatchObject({ + choices: [{ finish_reason: finishReason }], usage: { prompt_tokens: 120, completion_tokens: 30 }, + }); + const folded = await expectFoldParity(events); + expect(folded.status).toBe(200); + expect(folded.body).toMatchObject({ choices: [{ finish_reason: finishReason }] }); + }, + ); + for (const [name, scenario] of Object.entries(SCENARIOS)) { test(name, async () => { await expectStreamParity(scenario.events, scenario.options); diff --git a/tests/responses/protocol-direct-encoders-messages.test.ts b/tests/responses/protocol-direct-encoders-messages.test.ts index 6df602c0b5d..666e8f4f7cc 100644 --- a/tests/responses/protocol-direct-encoders-messages.test.ts +++ b/tests/responses/protocol-direct-encoders-messages.test.ts @@ -301,6 +301,22 @@ const SCENARIOS: Record { + test.each([["length", "max_tokens"], ["refusal", "refusal"]] as const)( + "explicit %s incomplete stops with %s on both paths", async (reason, stopReason) => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "partial" }, { type: "incomplete", reason, usage }, + ]; + const frames = await expectStreamParity(events); + expect(frames.at(-1)!.event).toBe("message_stop"); + expect(frames.at(-2)!.data).toMatchObject({ + delta: { stop_reason: stopReason }, usage: { output_tokens: 30 }, + }); + const folded = await expectFoldParity(events); + expect(folded.status).toBe(200); + expect(folded.body.stop_reason).toBe(stopReason); + }, + ); + for (const [name, scenario] of Object.entries(SCENARIOS)) { test(name, async () => { await expectStreamParity(scenario.events, scenario.options); From 4059cb91d351eace21b9b97ff6c3cdceabbb03eb Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:23:45 +0900 Subject: [PATCH 2/2] fix(responses): normalize incomplete reasons on native relays --- .../content/docs/reference/proxy-formats.md | 3 +- scripts/test-layout/layout.json | 4 +- src/adapters/openai-responses/passthrough.ts | 10 +- src/responses/incomplete-reason.ts | 31 +++++ src/server/responses/codex-ws-wire.ts | 5 +- src/server/responses/passthrough-delivery.ts | 4 +- structure/providers-and-adapters.md | 2 +- structure/transports/responses-wire-shapes.md | 2 +- structure/transports/streaming-health.md | 2 + .../bridge-nonstreaming-terminal.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../incomplete-reason-normalization.test.ts | 124 ++++++++++++++++++ 12 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 src/responses/incomplete-reason.ts create mode 100644 tests/responses/incomplete-reason-normalization.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 6dd03a977ba..b483c7db714 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -296,7 +296,8 @@ in both JSON and SSE. Partial output is preserved, and the bridge does not emit completion event for that open call. Calls already completed keep their status. This preserves the provider outcome; client retry behavior for incomplete responses is unchanged. -For adapter-backed responses, explicit incomplete reasons use the same vocabulary in JSON and SSE: +Explicit incomplete reasons use the same vocabulary in adapter-backed responses and native +Responses JSON, SSE, and WebSocket delivery: output-limit aliases such as `length` and `max_tokens` become `max_output_tokens`, and refusal/filter aliases such as `refusal` become `content_filter`. Other reasons remain unchanged, along with the provider's message, retryability, and usage. Direct Chat and Messages encoders apply the same mapping. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..8bca2a2bd84 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,9 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "service-log.test.ts": "service", + "explicit": { + "incomplete-reason-normalization.test.ts": "responses", + "service-log.test.ts": "service", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 19a1cefabba..767ac232271 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -1,3 +1,4 @@ +import { truncationReasonFor } from "../../responses/truncated-stop-reason"; import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; @@ -656,9 +657,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): case "error": yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) }; return; - case "response.incomplete": - yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) }; + case "response.incomplete": { + const reason = responsesErrorMessage(payload.response ?? payload); + yield { type: "incomplete", reason: truncationReasonFor(reason) ?? reason }; return; + } case "response.completed": { completedSeen = true; @@ -751,7 +754,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): return [{ type: "error", message: responsesErrorMessage(payload) }]; } if (payload.status === "incomplete") { - return [{ type: "incomplete", reason: responsesErrorMessage(payload) }]; + const reason = responsesErrorMessage(payload); + return [{ type: "incomplete", reason: truncationReasonFor(reason) ?? reason }]; } const usage = usageFromResponsesPayload(payload); const output = Array.isArray(payload.output) ? payload.output : []; diff --git a/src/responses/incomplete-reason.ts b/src/responses/incomplete-reason.ts new file mode 100644 index 00000000000..c8230ad3322 --- /dev/null +++ b/src/responses/incomplete-reason.ts @@ -0,0 +1,31 @@ +import { truncationReasonFor } from "./truncated-stop-reason"; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function normalizeResponse(value: unknown): unknown { + if (!record(value) || !record(value.incomplete_details)) return value; + const details = value.incomplete_details; + if (typeof details.reason !== "string") return value; + const reason = truncationReasonFor(details.reason); + return reason !== undefined && reason !== details.reason + ? { ...value, incomplete_details: { ...details, reason } } + : value; +} + +/** Normalize only Responses metadata, never output content or unknown provider reasons. */ +export function normalizeResponsesIncompleteReason(payload: T): T { + if (!record(payload)) return payload; + const root = normalizeResponse(payload) as Record; + const response = normalizeResponse(root.response); + return (response === root.response ? root : { ...root, response }) as T; +} + +/** Identity for malformed JSON and unchanged metadata, preserving upstream framing on SSE. */ +export function rewriteResponsesIncompleteReasonJson(json: string): string { + let payload: unknown; + try { payload = JSON.parse(json); } catch { return json; } + const normalized = normalizeResponsesIncompleteReason(payload); + return normalized === payload ? json : JSON.stringify(normalized); +} diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 59fe27f2699..22a749492ba 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -1,3 +1,4 @@ +import { normalizeResponsesIncompleteReason } from "../../responses/incomplete-reason"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; import { isTransientUpstreamStatus, @@ -517,12 +518,12 @@ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEv return null; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - const record = payload as Record; + const record = normalizeResponsesIncompleteReason(payload as Record); if (typeof record.type !== "string") return null; // A native error may be pretty-printed JSON. SSE prefixes one data line; // embedded physical newlines would otherwise truncate the JSON for readers. if (record.type !== "response.done") return { - type: record.type, text: /[\r\n]/.test(text) ? JSON.stringify(record) : text, payload: record, + type: record.type, text: record !== payload || /[\r\n]/.test(text) ? JSON.stringify(record) : text, payload: record, }; const response = record.response; diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 25d4531c1ac..9d5dbbf9dbb 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -58,6 +58,7 @@ import { hasResponsesSnapshotRepair, createResponsesSnapshotBlockRewrite, } from "../responses-snapshot-repair"; +import { rewriteResponsesIncompleteReasonJson } from "../../responses/incomplete-reason"; import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; import { createImageGenCallRestoreRewrite, restoreImageGenCallsInJson } from "../responses-image-gen-repair"; import { @@ -768,6 +769,7 @@ export async function deliverPassthroughResponse( : undefined; // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ + rewriteResponsesIncompleteReasonJson, createImageGenCallRestoreRewrite(imageGenCallAliases), // #3217: a call whose namespace repeats its own name is unroutable in codex-rs. createSelfNamedToolCallNamespaceScrubRewrite(selfNamedNamespaceScrubAuthorization), @@ -1317,7 +1319,7 @@ export async function deliverPassthroughResponse( const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; - return modelRewritten; + return rewriteResponsesIncompleteReasonJson(modelRewritten); })(); if (plaintextV2RestoreFailed) { return formatErrorResponse(502, "upstream_error", PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c522806f375..f79957d46fe 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -206,7 +206,7 @@ rewrite rules and the routed-id settlement. | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. Because an organization profile ARN is shared by its users, `src/oauth/kiro-account-identity.ts` keys Kiro rows by ARN plus the same-session `whoami` email (#6641): a login without a user keeps the legacy ARN identity but never replaces an identified user of the same profile unless the refresh token proves the same CLI session; reauth of a row with a user and rotated-CLI recovery require that same user (a row without a user recovers only while no other row shares its profile, and explicit reauth may record its user unless that user owns another row of the profile), an unmatched login claims an identity-less active slot only as the same session while the profile has other rows, and Add account records the current CLI user on ARN-only rows through a fenced same-session bind. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. | | `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | -| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | +| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. Compaction parsing normalizes known incomplete aliases under the [incomplete reason contract](transports/responses-wire-shapes.md#adapter-incomplete-reasons). The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 45fe647f37e..fe18699db1e 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -7,7 +7,7 @@ Other explicit verdicts, proxy replay budgets and event ordering are unchanged. ## Adapter incomplete reasons -`src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. +`src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. `src/responses/incomplete-reason.ts` applies the same classifier to native Responses JSON and SSE delivery in `src/server/responses/passthrough-delivery.ts` and to WebSocket frames in `src/server/responses/codex-ws-wire.ts`, including native control chains. It copies only changed `incomplete_details.reason` metadata, leaving output content and all other response fields intact. The compaction parser in `src/adapters/openai-responses/passthrough.ts` also normalizes its internal incomplete event. `tests/responses/incomplete-reason-normalization.test.ts` covers native HTTP delivery, WS terminal normalization and both adapter parser formats. Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `steered`; message, retryability, usage, and incomplete status are preserved. `tests/adapters/bridge-nonstreaming-terminal.test.ts` covers both Responses projections and real passthrough parsing; the direct Chat and Messages parity suites cover the client-wire outcomes. ## Compaction image input diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 3d4f57ab9ac..a3a8becb95a 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -17,6 +17,8 @@ terminal on drift; see [continuation binding contract](../providers-and-adapters Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314). +Responses WebSocket terminal frames normalize known `incomplete_details.reason` aliases under the [incomplete reason contract](responses-wire-shapes.md#adapter-incomplete-reasons), including native control chains. + ## Heartbeat and stall deadline RunTurn search interception forwards adapter heartbeats immediately and emits progress for buffered semantic events. Its bridge uses the search plan's stall deadline so a bounded sidecar search fits the watchdog budget. diff --git a/tests/adapters/bridge-nonstreaming-terminal.test.ts b/tests/adapters/bridge-nonstreaming-terminal.test.ts index 85ef787ead3..284c9a9955c 100644 --- a/tests/adapters/bridge-nonstreaming-terminal.test.ts +++ b/tests/adapters/bridge-nonstreaming-terminal.test.ts @@ -157,7 +157,7 @@ describe("explicit incomplete reasons use the Responses vocabulary", () => { } else { events.push(...await adapter.parseResponse!(Response.json(payload), budget)); } - expect(events).toEqual([{ type: "incomplete", reason: "length" }]); + expect(events).toEqual([{ type: "incomplete", reason: "max_output_tokens" }]); for (const streaming of [true, false]) { expect(await project(events, streaming)).toMatchObject({ status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..e93fc0b71b0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "incomplete-reason-normalization.test.ts": "responses", "codex-history-pending-wal.test.ts": "codex-integration", "service-log.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", diff --git a/tests/responses/incomplete-reason-normalization.test.ts b/tests/responses/incomplete-reason-normalization.test.ts new file mode 100644 index 00000000000..0c0bcc3a40c --- /dev/null +++ b/tests/responses/incomplete-reason-normalization.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { rewriteResponsesIncompleteReasonJson } from "../../src/responses/incomplete-reason"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { normalizeResponsesWsRelayEvent } from "../../src/server/responses/codex-ws-wire"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const aliases = [ + ["length", "max_output_tokens"], [" MAX_TOKENS ", "max_output_tokens"], + ["model_context_window_exceeded", "max_output_tokens"], ["safety", "content_filter"], + ["refusal", "content_filter"], ["content-filter", "content_filter"], + ["max_output_tokens", "max_output_tokens"], ["content_filter", "content_filter"], + ["max_messages", "max_messages"], ["steered", "steered"], + ["adapter_eof", "adapter_eof"], ["vendor-limit", "vendor-limit"], +] as const; +const snapshot = (reason: string) => ({ + id: "resp_incomplete", object: "response", model: "test-model", status: "incomplete", + incomplete_details: { reason, message: "partial turn", retryable: false, vendor: 7 }, + output: [{ id: "msg_partial", type: "message", role: "assistant", status: "incomplete", + content: [{ type: "output_text", text: "partial answer", annotations: [] }] }], + usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 }, +}); +const frame = (response: unknown) => `event: response.incomplete\ndata: ${JSON.stringify({ type: "response.incomplete", response })}\n\n`; + +describe("Responses incomplete reasons at transport boundaries", () => { + test.each([ + "not json", "[DONE]", "null", "[]", '{ "status": "completed" }', + '{ "status": "incomplete", "incomplete_details": { "reason": 7 } }', + '{ "status": "incomplete", "incomplete_details": { "reason": "steered" } }', + '{ "output": [{ "incomplete_details": { "reason": "length" } }] }', + ])("unchanged or malformed metadata remains byte-identical: %s", json => { + expect(rewriteResponsesIncompleteReasonJson(json)).toBe(json); + }); + test("nested user output stays opaque and the input object stays unchanged", () => { + const response = { ...snapshot("length"), output: [{ incomplete_details: { reason: "length" } }] }; + const json = JSON.stringify(response); + expect(JSON.parse(rewriteResponsesIncompleteReasonJson(json))).toEqual({ ...response, + incomplete_details: { ...response.incomplete_details, reason: "max_output_tokens" } }); + expect(response.incomplete_details.reason).toBe("length"); + }); + for (const [reason, canonical] of aliases) { + for (const type of ["response.incomplete", "response.done"]) { + test(`WebSocket ${type}: ${reason}`, () => { + const response = snapshot(reason); + const result = normalizeResponsesWsRelayEvent(JSON.stringify({ type, response, sequence_number: 9 })); + expect(result?.type).toBe("response.incomplete"); + expect(JSON.parse(result!.text)).toEqual({ type: "response.incomplete", sequence_number: 9, + response: { ...response, incomplete_details: { ...response.incomplete_details, reason: canonical } } }); + expect(result?.payload).toEqual(JSON.parse(result!.text)); + }); + } + for (const stream of [false, true]) { + test(`compaction adapter ${stream ? "SSE" : "JSON"}: ${reason}`, async () => { + const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", baseUrl: "https://example.invalid/v1", authMode: "key" }); + const budget = createTranslatorBudget(); + try { + const response = snapshot(reason); + const upstream = stream ? new Response(frame(response)) : Response.json(response); + const events = stream ? await Array.fromAsync(adapter.parseStream(upstream, budget)) + : await adapter.parseResponse!(upstream, budget); + expect(events.find(event => event.type === "incomplete")).toMatchObject({ type: "incomplete", reason: canonical }); + } finally { budget.dispose(); } + }); + } + } +}); + +let testDir = ""; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + codexHome = installIsolatedCodexHome("ocx-incomplete-reason-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-incomplete-reason-")); + process.env.OPENCODEX_HOME = testDir; +}); +afterEach(() => { + resetProviderRequestPacingForTest(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restore(); + removeTreeWithRetry(testDir); +}); + +describe("Responses native passthrough delivery", () => { + for (const [reason, canonical] of aliases) { + for (const stream of [false, true]) { + test(`${stream ? "SSE" : "JSON"}: ${reason}`, async () => { + const response = snapshot(reason); + let sends = 0; + const upstream = Bun.serve({ port: 0, fetch() { + sends++; + return stream ? new Response(frame(response), { headers: { "content-type": "text/event-stream" } }) + : Response.json(response); + } }); + saveConfig({ port: 0, defaultProvider: "mock", providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", allowPrivateNetwork: true, + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + } } }); + const proxy = startServer(0); + try { + const result = await fetch(new URL("/v1/responses", proxy.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", input: "hello", stream }), + }); + expect(result.status).toBe(200); + const text = await result.text(); + const terminal = stream ? text.split("\n").filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).find(event => event.type === "response.incomplete").response + : JSON.parse(text); + expect(terminal).toEqual({ ...response, incomplete_details: { ...response.incomplete_details, reason: canonical } }); + expect(sends).toBe(1); + } finally { await proxy.stop(true); await upstream.stop(true); } + }); + } + } +});