Skip to content
Closed
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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,12 @@ 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.

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.

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.
Expand Down
3 changes: 2 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"version": 1,
"root": "tests",
"explicitShards": ["layout-clients.json"],
"explicit": { "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration",
"explicit": {
"incomplete-reason-normalization.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration",
"owner-registry-isolation.test.ts": "config",
"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", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses",
"messages-request-id-endpoint.test.ts": "claude-integration",
Expand Down
10 changes: 7 additions & 3 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 : [];
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/response-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
},
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
},
Expand Down
2 changes: 1 addition & 1 deletion src/protocols/encoders/adapter-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
},
Expand Down
31 changes: 31 additions & 0 deletions src/responses/incomplete-reason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { truncationReasonFor } from "./truncated-stop-reason";

function record(value: unknown): value is Record<string, unknown> {
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<T>(payload: T): T {
if (!record(payload)) return payload;
const root = normalizeResponse(payload) as Record<string, unknown>;
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);
}
5 changes: 3 additions & 2 deletions src/server/responses/codex-ws-wire.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeResponsesIncompleteReason } from "../../responses/incomplete-reason";
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
import {
isTransientUpstreamStatus,
Expand Down Expand Up @@ -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<string, unknown>;
const record = normalizeResponsesIncompleteReason(payload as Record<string, unknown>);
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;
Expand Down
4 changes: 3 additions & 1 deletion src/server/responses/passthrough-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion structure/data-planes/protocol-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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. |
Expand Down
Loading
Loading