diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index cce9d5a92e4..631a9355191 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -302,7 +302,26 @@ returns an error instead of partial JSON with HTTP 200. Streaming callers are un When a provider filters or truncates a response, an unfinished tool call remains `incomplete` in both JSON and SSE. Partial output is preserved, and the bridge does not emit an argument 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. +the provider outcome. + +On the Responses streaming wire, an explicit non-retryable content-filter stop from +Anthropic (`refusal` / `content_filter`) or Kiro (`CONTENT_FILTERED` / +`GUARDRAIL_INTERVENED`) ends with `response.failed`, `error.code: "invalid_prompt"`, +and `retryable: false`. This prevents clients that retry incomplete streams from +resending the refused context. Usage and partial output are preserved. Web-search +and image sidecars stop without executing calls from that refused iteration or +asking the model again, on every inbound wire. + +Buffered Responses JSON retains `status: "incomplete"`; Chat Completions retains +`finish_reason: "content_filter"`, and Messages retains `stop_reason: "refusal"`. +A content filter without an explicit non-retryable verdict, token limits, stalls, +and EOFs keep their existing incomplete behavior. + +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 @@ -859,3 +878,10 @@ An ordinary 400 and a 429 are never retried this way, and neither is a 5xx, with exception: a 502 whose body is the exact encrypted tool-output decrypt rejection, sent for a request that carries encrypted tool output, gets the same single resend. A second rejection reaches the client unchanged. If that happens, start a new conversation on the destination provider. + +### Malformed tool-call history + +Routed requests tolerate malformed serialized function arguments as an empty object. +Two bounded history repairs preserve usable arguments: a missing JSON object prefix, +and MiMo's trailing `` envelope with a missing closing quote or brace. +A repair must decode to a JSON object. Valid JSON values containing those tags are preserved. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index de0c47f87ed..b6be13d22d5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,13 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", + "bridge-terminal-refusal.test.ts": "adapters", + "loop-terminal-refusal.test.ts": "images", + "web-search-terminal-refusal.test.ts": "web-search", + "combo-terminal-refusal.test.ts": "routing", + "server-terminal-refusal-wiring.test.ts": "server", + "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.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", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 2e98267362b..c9a18603a1d 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -313,11 +313,12 @@ function isAnthropicContentFilterStopReason( } /** - * Anthropic `refusal` / `content_filter` is a permanent sampling decision, not a disconnect. - * Emitting `done` with that stopReason used to surface as `response.incomplete` without - * `retryable`, which Codex treats as a dropped stream and retries five times (#4312). - * The explicit incomplete event is what the bridge already forwards into - * `incomplete_details.retryable`. Usage is preserved: a filtered turn still consumed tokens. + * Anthropic `refusal` / `content_filter` is a permanent sampling decision, not a disconnect + * (#4312). Codex does not read `incomplete_details.retryable`: it retries every + * `response.incomplete`. The explicit `retryable: false` verdict here is the marker the bridge + * uses to deliver the turn to a Responses client as a terminal `response.failed` instead + * (`src/bridge/terminal-refusal.ts`); a `done` with that stopReason carries no verdict and + * stays a plain incomplete. Usage is preserved: a filtered turn still consumed tokens. * `max_tokens` stays a `done` so the client can continue from a legitimate truncation. */ function anthropicContentFilterIncomplete( @@ -1237,8 +1238,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti return; } // Refusal / content_filter must not look like a dropped stream. `done` with that - // stopReason becomes `response.incomplete` without `retryable`, and Codex retries - // the same refusal five times (#4312). + // stopReason carries no verdict, so the bridge could not tell it from a retryable + // incomplete and Codex would re-send the same refusal (#4312). if (isAnthropicContentFilterStopReason(pendingStopReason)) { yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage)); return; @@ -1433,7 +1434,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } // Same rule as emitDone: refusal / content_filter is a permanent decision, not a // disconnect. This branch bypasses emitDone, so the check has to be repeated here - // or the EOF route still emits `done` and Codex retries the refusal (#4312). + // or the EOF route emits a verdict-less `done` the bridge cannot make terminal (#4312). if (isAnthropicContentFilterStopReason(pendingStopReason)) { emittedDone = true; yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage)); diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 976222d7ee3..74e3bb48751 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 { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; @@ -662,9 +663,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; @@ -757,7 +760,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/bridge/response-json.ts b/src/bridge/response-json.ts index f5840a1e457..fb2cc73ae04 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -657,7 +657,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 0a8340a503a..765065b9310 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -13,7 +13,7 @@ import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, - isCyberPolicyCode, + isTerminalRefusalCode, type OcxErrorPayload, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; @@ -51,6 +51,7 @@ import { } from "../lib/translator-budget"; import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal"; import type { OutputItem, StringChunks } from "./internal"; +import { terminalRefusalFailure } from "./terminal-refusal"; function sseEvent(name: string, data: Record): string { return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; @@ -121,6 +122,12 @@ export function bridgeToResponsesSSE( * would reach the client under the invented name instead of the normalized one. */ enforceDeclaredToolNames?: boolean; + /** + * Deliver a non-retryable upstream refusal as `response.failed` with a terminal-refusal code + * instead of `response.incomplete` (see `./terminal-refusal`). Set only for the Responses + * inbound wire: the chat and Anthropic encoders read the incomplete to report a filter stop. + */ + terminalRefusalAsFailed?: boolean; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; /** @@ -1312,12 +1319,25 @@ export function bridgeToResponsesSSE( flushHiddenReasoningEnvelope(); options?.onUsage?.(event.usage); await awaitThoughtSignatureDurability(); + const refusal = options?.terminalRefusalAsFailed ? terminalRefusalFailure(event) : undefined; + if (refusal) { + emit("response.failed", { + response: { + ...responseSnapshot("failed", finishedItems, event.endTurn), + usage: responsesUsage(event.usage), + ...refusal, + }, + }); + reportTerminal("failed"); + terminalEvent = true; + break; + } emit("response.incomplete", { response: { ...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 } : {}), }, @@ -1350,7 +1370,7 @@ export function bridgeToResponsesSSE( ...(event.usage ? { usage: responsesUsage(event.usage) } : {}), error: failure.error, last_error: failure.error, - ...(isCyberPolicyCode(failure.error.code) + ...(isTerminalRefusalCode(failure.error.code) ? { retryable: false } : event.retryable !== undefined ? { retryable: event.retryable } : {}), }, @@ -1391,7 +1411,7 @@ export function bridgeToResponsesSSE( ...responseSnapshot("failed", finishedItems), error: failure, last_error: failure, - ...(isCyberPolicyCode(failure.code) ? { retryable: false } : {}), + ...(isTerminalRefusalCode(failure.code) ? { retryable: false } : {}), }, }); reportTerminal("failed"); diff --git a/src/bridge/terminal-refusal.ts b/src/bridge/terminal-refusal.ts new file mode 100644 index 00000000000..e7586e26cd2 --- /dev/null +++ b/src/bridge/terminal-refusal.ts @@ -0,0 +1,59 @@ +import type { AdapterEvent } from "../types"; +import type { OcxErrorPayload } from "../lib/errors"; +import { redactSecretString } from "../lib/redact"; + +/** + * The terminal-refusal code Codex ends a turn on. It is a member of `TERMINAL_REFUSAL_CODES` + * in `src/lib/errors.ts`, and the one that says "this prompt, as sent, is refused" without + * claiming a specific policy family the upstream never named. + */ +const TERMINAL_REFUSAL_FAILED_CODE = "invalid_prompt"; + +interface TerminalRefusalFailure { + error: OcxErrorPayload; + last_error: OcxErrorPayload; + retryable: false; +} + +/** + * Client-visible copy: what happened, and that resending the same context cannot help. It points + * at a new conversation rather than a new turn, because a later turn replays the refused context. + */ +function terminalRefusalMessage(detail: string | undefined): string { + const reason = detail?.trim() ? ` (${redactSecretString(detail.trim())})` : ""; + return `The upstream model refused this request${reason}. ` + + "Retrying with the same conversation context will not succeed; change the request or start a new conversation."; +} + +/** + * An adapter `incomplete` that is a non-retryable upstream refusal: a content filter carrying an + * explicit `retryable: false` verdict. A filter without the verdict, an output cap, a stall and an + * adapter EOF do not qualify. The sidecar loops read this too: a refused iteration ends the turn, + * so a sidecar call it carried is neither run nor answered with another model request. + */ +export function isTerminalRefusalIncomplete(event: AdapterEvent | undefined): boolean { + return event?.type === "incomplete" && event.reason === "content_filter" && event.retryable === false; +} + +/** + * `response.failed` fields for an adapter `incomplete` that is a non-retryable upstream refusal + * (#4312), or `undefined` when the event is anything else. The Anthropic and Kiro adapters are + * the ones that emit it today. + * + * codex-rs maps every `response.incomplete` to a retryable stream error and never reads + * `incomplete_details.retryable`, so a refusal delivered that way is re-sent through the whole + * stream retry budget. It only ends the turn on `response.failed` carrying a terminal-refusal + * `error.code`. Only an explicit `retryable: false` content filter qualifies: a filter without + * the verdict, an output cap, a stall and an adapter EOF stay `response.incomplete`. + */ +export function terminalRefusalFailure( + event: Extract, +): TerminalRefusalFailure | undefined { + if (!isTerminalRefusalIncomplete(event)) return undefined; + const error: OcxErrorPayload = { + type: "invalid_request_error", + code: TERMINAL_REFUSAL_FAILED_CODE, + message: terminalRefusalMessage(event.message), + }; + return { error, last_error: error, retryable: false }; +} diff --git a/src/images/loop.ts b/src/images/loop.ts index cc33a556bae..21cbf3d349d 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -20,6 +20,7 @@ import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; +import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; @@ -359,6 +360,8 @@ export interface ImageBridgeDeps { onCompletedResponse?: (response: Record, providerState?: OcxProviderContinuationState) => void; /** WebSocket Responses path only — leave response id empty for protocol compatibility. */ forceEmptyResponseId?: boolean; + /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */ + terminalRefusalAsFailed?: boolean; } /** @@ -869,7 +872,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise 0 && !split.hasRealToolCall && !forceFinal; + // A refused iteration also finalizes: fulfilling its media call would re-ask the model + // with the refused context (#7148). + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceFinal + && !split.passthrough.some(isTerminalRefusalIncomplete); if (!shouldLoop) { if (hiddenUsage) { for (let i = split.passthrough.length - 1; i >= 0; i--) { @@ -1097,6 +1103,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + 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/responses/parser.ts b/src/responses/parser.ts index 6979da5bb4b..e193cdb900e 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -68,10 +68,35 @@ function repairJsonObjectEnvelope(text: string): Record | undef return undefined; } +/** + * Repair the observed MiMo tool-call corruption: the model emits its proprietary + * `` closing tags INSIDE the JSON string value of the + * function arguments, and drops the JSON object's closing brace. Strip the + * suffix, restore the brace, and only accept the repair when the result parses + * into an object. + */ +function repairMimoToolCallEnvelope(text: string): Record | undefined { + const suffix = ""; + if (!text.endsWith(suffix)) return undefined; + const stripped = text.slice(0, -suffix.length); + if (!stripped.startsWith("{")) return undefined; + // A trailing brace or quote can itself belong to the unterminated string. + // Try only the three observed envelope endings, accepting an object parse. + for (const candidate of [stripped, `${stripped}}`, `${stripped}"}`]) { + try { + const parsed: unknown = JSON.parse(candidate); + if (isObj(parsed)) return parsed; + } catch { + /* try the next bounded envelope ending */ + } + } + return undefined; +} + /** * Decode a function call's serialized arguments the way history replay tolerates them: empty - * or non-JSON text becomes `{}` instead of failing every later turn, and the one bounded - * lost-brace corruption above is repaired. Shared with the direct Chat decoder + * or non-JSON text becomes `{}` instead of failing every later turn, and the bounded + * lost-brace and MiMo closing-tag corruptions above are repaired. Shared with the direct Chat decoder * (`src/protocols/codecs/chat-ir.ts`) so both decode the same call to the same object. */ export function functionCallArgumentsFromText(callId: string, rawArguments: string | undefined): Record { @@ -82,6 +107,15 @@ export function functionCallArgumentsFromText(callId: string, rawArguments: stri const parsed: unknown = JSON.parse(rawArgs); if (isObj(parsed)) args = parsed; } catch { + // Corruptions observed on Xiaomi MiMo routed requests, each with a bounded + // repair so a serialized call the model can still see is restored instead + // of replaying {} forever. + const mimoRepaired = repairMimoToolCallEnvelope(rawArgs); + if (mimoRepaired !== undefined) { + args = mimoRepaired; + console.warn(`[parser] function_call ${callId} repaired MiMo tool-call envelope (tags inside string)`); + return args; + } // One observed serialization corruption loses exactly the JSON object's opening // brace; the closed envelope is tight enough to repair back into a call the // routed model can still see and retry, instead of replaying {} forever. diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index e39ffde29f9..4dd61093fc8 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -205,6 +205,7 @@ export async function deliverAdapterResponse( declaredToolNames, bareCustomToolNames, enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", + terminalRefusalAsFailed: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), 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/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 6eddb05a73d..a26ba4d7a1a 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -893,6 +893,8 @@ export async function executeResponsesRunTurn( declaredToolNames, bareCustomToolNames, enforceDeclaredToolNames, + // Same wire split: only a Responses client (Codex) needs the refusal as a failure. + terminalRefusalAsFailed: enforceDeclaredToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 01973827e21..ab6ba0e6dd4 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -555,6 +555,7 @@ export async function executeResponsesSidecars( ? null : rateLimitRetryPolicyFor(route.provider), onFirstOutput: noteSidecarOutput, ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}), + terminalRefusalAsFailed: inboundWire !== "chat" && inboundWire !== "anthropic", onCompletedResponse: (response, providerState) => { const served = transportState.replayOAuthCredentialSnapshot; if (route.providerName === "kiro" && response.status === "completed" && served @@ -636,6 +637,7 @@ export async function executeResponsesSidecars( settings: wsPlan.settings, maxSearches: wsPlan.maxSearches, forceEmptyResponseId: true, + terminalRefusalAsFailed: inboundWire !== "chat" && inboundWire !== "anthropic", abortSignal: options.abortSignal, onFirstOutput: noteSidecarOutput, onRequestBuilt: request => { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 79d6c291f7c..004ac013967 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -6,6 +6,7 @@ import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaqu import type { AttemptRecoveryKind } from "../usage/log"; import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; +import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; @@ -303,6 +304,8 @@ export interface WebSearchLoopDeps { settings: SidecarSettings; maxSearches: number; forceEmptyResponseId?: boolean; + /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */ + terminalRefusalAsFailed?: boolean; abortSignal?: AbortSignal; recordSidecarOutcome?: SidecarOutcomeRecorder; beforeSidecarDispatch?: () => void; @@ -920,7 +923,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 && !split.hasRealToolCall; + // A refused iteration ends the turn even when it carried a search call: running the + // search would send the refused context back to the model (#7148). + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall + && !split.passthrough.some(isTerminalRefusalIncomplete); if (!shouldLoop) { // #1001: a forced-answer pass that ends `done` must have produced // usable output — never a malformed tool call, and never silence. @@ -1015,6 +1021,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise.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. | +| `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`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). 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. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 97f29f08eea..b70f62db859 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -2,12 +2,12 @@ ## 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. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). 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. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). 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`. `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 @@ -529,8 +529,7 @@ request-log accounting without promoting a truncated repair candidate. Terminal ## Inbound history and code-mode shell wire repairs -Inbound function-call history with a missing JSON object prefix is repaired for every provider when -restoring it produces an object; other malformed argument strings replay as `{}` (`src/responses/parser.ts`). +Inbound function-call history with a missing JSON object prefix is repaired for every provider when restoring it produces an object. A terminal MiMo `` suffix is removed only when the remaining text parses as an object unchanged or after restoring a closing brace or quote and brace. Valid JSON containing these tags stays opaque; unrelated malformed arguments replay as `{}` without logging their content (`src/responses/parser.ts`). Regression coverage lives in `tests/responses/responses-mimo-envelope.test.ts`. Function-wrapper restoration supports `default.`-prefixed `exec` and `apply_patch` aliases while retaining the existing ambiguity and foreign-grammar boundaries. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index d0d1fcd17c2..42b891a5eee 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -585,16 +585,16 @@ 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 -is cancelled. +Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or is cancelled. The shared endpoint path also preserves the cyber-policy stop when a malformed UTF-8 5xx body is replacement-decoded; other malformed-body usage, quota, reset evidence and classification retain the status-only fallback. Rebuilt failures remain non-replayable and cyber-policy failures carry neither `Retry-After` nor quota-reset metadata. The [Responses failover contract](responses-failover.md) owns the bounded recovery and replay decisions. + +On the `responses` inbound wire only (`terminalRefusalAsFailed`, set by the same test as `enforceDeclaredToolNames` in `src/server/responses/adapter-delivery.ts`, `src/server/responses/run-turn-execution.ts` and the sidecar loops started from `src/server/responses/sidecar-execution.ts`), an adapter `incomplete` with `reason: "content_filter"` and `retryable: false` is delivered as `response.failed` whose `error` and `last_error` carry `invalid_request_error` / `invalid_prompt` with `retryable: false` and the turn's usage, built by `src/bridge/terminal-refusal.ts`: Codex retries every `response.incomplete` without reading `incomplete_details.retryable` and ends a turn only on a terminal-refusal `error.code` (#4312). The Anthropic and Kiro adapters emit that event today. Inside a combo a refusal with no output never reaches the client as a stream: `src/server/responses/combo-stream-preflight.ts` converts the zero-output `response.failed` into an HTTP 400 JSON error with the same `invalid_prompt` code, and `comboFailureDecision` stops the chain on it instead of sending the refused prompt to the next target (`tests/routing/combo-terminal-refusal.test.ts`). The web-search and image sidecar loops (`src/web-search/loop.ts`, `src/images/loop.ts`) check `isTerminalRefusalIncomplete` before looping, on every inbound wire: a refused iteration that carried a sidecar call ends the turn instead of running the call and re-asking the model with the refused context (`tests/web-search/web-search-terminal-refusal.test.ts`, `tests/images/loop-terminal-refusal.test.ts`); `src/web-search/run-turn-loop.ts` already stops on any incomplete. Every other incomplete, the chat and Anthropic inbound wires, and `buildResponseJSON` keep the incomplete shape. A `response.failed` built from an adapter error carries `retryable: false` for every code in `isTerminalRefusalCode` (`src/lib/errors.ts`), not only cyber policy. `tests/adapters/bridge-terminal-refusal.test.ts` pins both, `tests/server/server-terminal-refusal-wiring.test.ts` pins which inbound wire opts in, and `tests/server/server-kiro-refusal-e2e.test.ts` pins the adapter, runTurn and both sidecar-loop deliveries. 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 23679505861..284c9a9955c 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: "max_output_tokens" }]); + 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/adapters/bridge-terminal-refusal.test.ts b/tests/adapters/bridge-terminal-refusal.test.ts new file mode 100644 index 00000000000..6ad059dcb89 --- /dev/null +++ b/tests/adapters/bridge-terminal-refusal.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { isTerminalRefusalCode } from "../../src/lib/errors"; +import type { AdapterEvent, OcxUsage } from "../../src/types"; + +/** + * #4312: codex-rs maps every `response.incomplete` to a retryable stream error and never reads + * `incomplete_details.retryable`, so an upstream refusal delivered that way is re-sent through the + * whole stream retry budget. Only `response.failed` with a terminal-refusal `error.code` ends the + * turn, and only the Responses inbound wire opts into that delivery. + */ +type BridgeOptions = NonNullable[7]>; +type Frame = { name: string; data: Record }; + +const TERMINALS = new Set(["response.completed", "response.incomplete", "response.failed"]); +const USAGE: OcxUsage = { inputTokens: 120, outputTokens: 7, totalTokens: 127 }; +const REFUSAL: AdapterEvent = { + type: "incomplete", + reason: "content_filter", + retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: USAGE, +}; + +async function frames(events: AdapterEvent[], options?: BridgeOptions): Promise { + async function* source(): AsyncGenerator { + for (const event of events) yield event; + } + const text = await new Response( + bridgeToResponsesSSE(source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, options), + ).text(); + return text.split("\n\n").flatMap(frame => { + const lines = frame.trim().split("\n"); + const name = lines.find(line => line.startsWith("event: "))?.slice(7); + const data = lines.find(line => line.startsWith("data: "))?.slice(6); + return name && data ? [{ name, data: JSON.parse(data) as Record }] : []; + }); +} + +async function terminal(events: AdapterEvent[], options?: BridgeOptions): Promise { + const terminals = (await frames(events, options)).filter(frame => TERMINALS.has(frame.name)); + expect(terminals).toHaveLength(1); + return terminals[0]!; +} + +describe("Responses bridge: terminal upstream refusal", () => { + test("a non-retryable content filter becomes response.failed with a terminal-refusal code", async () => { + const statuses: string[] = []; + const usages: Array = []; + const frame = await terminal([REFUSAL], { + terminalRefusalAsFailed: true, + onTerminal: status => { statuses.push(status); }, + onUsage: usage => { usages.push(usage); }, + }); + + expect(frame.name).toBe("response.failed"); + const response = frame.data.response; + expect(response.status).toBe("failed"); + expect(response.error).toEqual(response.last_error); + expect(response.error.type).toBe("invalid_request_error"); + expect(response.error.code).toBe("invalid_prompt"); + // The code is what ends the Codex turn, so it must stay inside the set Codex treats as final. + expect(isTerminalRefusalCode(response.error.code)).toBe(true); + expect(response.error.message).toContain("refused"); + expect(response.error.message).toContain("will not succeed"); + expect(response.error.message).toContain('stop_reason "refusal"'); + expect(response.error.message).toContain("start a new conversation"); + expect(response.retryable).toBe(false); + expect(response.incomplete_details).toBeUndefined(); + // A refused turn still consumed tokens. + expect(response.usage.input_tokens).toBe(120); + expect(response.usage.output_tokens).toBe(7); + expect(usages).toEqual([USAGE]); + expect(statuses).toEqual(["failed"]); + }); + + test("output opened before the refusal is closed the same way as on the incomplete path", async () => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "I can" }, + { type: "tool_call_start", id: "call_1", name: "lookup" }, + { type: "tool_call_delta", arguments: '{"q":"par' }, + REFUSAL, + ]; + const done = (all: Frame[]) => all + .filter(frame => frame.name === "response.output_item.done") + .map(frame => [frame.data.item.type, frame.data.item.status]); + + const failed = await frames(events, { terminalRefusalAsFailed: true }); + const incomplete = await frames(events); + + expect(failed.at(-1)?.name).toBe("response.failed"); + expect(incomplete.at(-1)?.name).toBe("response.incomplete"); + expect(done(failed)).toEqual(done(incomplete)); + expect(done(failed)).toEqual([["message", "completed"], ["function_call", "incomplete"]]); + expect(failed.at(-1)!.data.response.output.map((item: { type: string }) => item.type)) + .toEqual(incomplete.at(-1)!.data.response.output.map((item: { type: string }) => item.type)); + }); + + test("a Kiro-shaped refusal is promoted and keeps its end_turn verdict", async () => { + // src/adapters/kiro/stream.ts: CONTENT_FILTERED / GUARDRAIL_INTERVENED, endTurn false, with + // continuation state. A failed turn is never remembered, so the state is not handed out. + const completed: unknown[] = []; + const frame = await terminal([{ + type: "incomplete", + reason: "content_filter", + retryable: false, + message: "Kiro stopped with CONTENT_FILTERED before an explicit final answer", + usage: USAGE, + endTurn: false, + providerState: { kiro: { conversationId: "conv_1" } }, + }], { terminalRefusalAsFailed: true, onCompletedResponse: response => { completed.push(response); } }); + + expect(frame.name).toBe("response.failed"); + expect(frame.data.response.error.code).toBe("invalid_prompt"); + expect(frame.data.response.error.message).toContain("CONTENT_FILTERED"); + expect(frame.data.response.retryable).toBe(false); + expect(frame.data.response.end_turn).toBe(false); + expect(completed).toEqual([]); + }); + + test("end_turn is carried only when the adapter set it", async () => { + const options = { terminalRefusalAsFailed: true }; + + expect((await terminal([{ ...REFUSAL, endTurn: true }], options)).data.response.end_turn).toBe(true); + expect((await terminal([{ ...REFUSAL, endTurn: false }], options)).data.response.end_turn).toBe(false); + expect("end_turn" in (await terminal([REFUSAL], options)).data.response).toBe(false); + }); + + test("a refusal without an adapter message still says what happened", async () => { + for (const message of [undefined, "", " "]) { + const frame = await terminal( + [{ type: "incomplete", reason: "content_filter", retryable: false, ...(message !== undefined ? { message } : {}) }], + { terminalRefusalAsFailed: true }, + ); + + expect(frame.name).toBe("response.failed"); + expect(frame.data.response.error.message).toBe( + "The upstream model refused this request. Retrying with the same conversation context will not succeed; " + + "change the request or start a new conversation.", + ); + } + }); + + test("a credential echoed in the adapter message is masked before it reaches the client", async () => { + // Assembled at runtime so the fixture is not itself a credential-shaped literal. + const bearer = ["tok", "en", "0123456789", "abcdef"].join(""); + const apiKey = ["sk", "refusal0123456789"].join("-"); + const frame = await terminal([{ + type: "incomplete", reason: "content_filter", retryable: false, + message: `upstream refused; request carried Authorization: Bearer ${bearer} and api_key=${apiKey}`, + }], { terminalRefusalAsFailed: true }); + + const message: string = frame.data.response.error.message; + expect(frame.name).toBe("response.failed"); + expect(message).toContain("upstream refused; request carried"); + expect(message).not.toContain(bearer); + expect(message).not.toContain(apiKey); + expect(frame.data.response.last_error.message).toBe(message); + }); + + test("without the option the refusal stays response.incomplete", async () => { + for (const options of [undefined, {}, { terminalRefusalAsFailed: false }] as const) { + const statuses: string[] = []; + const frame = await terminal([REFUSAL], { ...options, onTerminal: status => { statuses.push(status); } }); + + expect(frame.name).toBe("response.incomplete"); + expect(frame.data.response.status).toBe("incomplete"); + expect(frame.data.response.error).toBeUndefined(); + expect(frame.data.response.incomplete_details).toEqual({ + reason: "content_filter", + message: 'upstream ended the turn with stop_reason "refusal"', + retryable: false, + }); + expect(frame.data.response.usage.input_tokens).toBe(120); + expect(statuses).toEqual(["incomplete"]); + } + }); + + test("only an explicit non-retryable content filter is promoted", async () => { + const stillIncomplete: AdapterEvent[] = [ + { type: "incomplete", reason: "content_filter", usage: USAGE }, + { type: "incomplete", reason: "content_filter", retryable: true, usage: USAGE }, + { type: "incomplete", reason: "max_output_tokens", usage: USAGE }, + // A cap is a legitimate truncation the client may continue, whatever the verdict says. + { type: "incomplete", reason: "max_output_tokens", retryable: false, usage: USAGE }, + { type: "incomplete", reason: "upstream_stall_timeout", retryable: false }, + ]; + for (const event of stillIncomplete) { + const frame = await terminal([event], { terminalRefusalAsFailed: true }); + + expect(frame.name).toBe("response.incomplete"); + expect(frame.data.response.incomplete_details.reason).toBe((event as { reason: string }).reason); + expect(frame.data.response.error).toBeUndefined(); + } + }); + + test("a done carrying a filter stop reason is not promoted", async () => { + const frame = await terminal( + [{ type: "done", stopReason: "content_filter", usage: USAGE }], + { terminalRefusalAsFailed: true }, + ); + + expect(frame.name).toBe("response.incomplete"); + expect(frame.data.response.incomplete_details.reason).toBe("content_filter"); + }); + + test("an adapter error with a terminal-refusal code is marked non-retryable", async () => { + for (const code of ["invalid_prompt", "misalignment_policy_violation", "bio_policy", "cyber_policy"]) { + // The verdict belongs to the code: an adapter that claims the refusal is retryable is wrong. + for (const retryable of [undefined, true]) { + const frame = await terminal([{ + type: "error", message: "upstream refused the request", status: 400, + errorType: "invalid_request_error", code, + ...(retryable !== undefined ? { retryable } : {}), + }]); + + expect(frame.name).toBe("response.failed"); + expect(frame.data.response.error.code).toBe(code); + expect(frame.data.response.retryable).toBe(false); + } + } + }); + + test("an adapter error with an ordinary code keeps the adapter's own verdict", async () => { + const base = { type: "error", message: "upstream overloaded", status: 503, errorType: "server_error", code: "server_is_overloaded" } as const; + + expect((await terminal([base])).data.response.retryable).toBeUndefined(); + expect((await terminal([{ ...base, retryable: true }])).data.response.retryable).toBe(true); + expect((await terminal([{ ...base, retryable: false }])).data.response.retryable).toBe(false); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f9528894450..b87204c3d77 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,11 @@ -{ - "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", - "prefer-model-alias-ids.test.ts": "server", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", +{ "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", + "bridge-terminal-refusal.test.ts": "adapters", + "loop-terminal-refusal.test.ts": "images", + "web-search-terminal-refusal.test.ts": "web-search", + "combo-terminal-refusal.test.ts": "routing", + "server-terminal-refusal-wiring.test.ts": "server", + + "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", diff --git a/tests/images/loop-terminal-refusal.test.ts b/tests/images/loop-terminal-refusal.test.ts new file mode 100644 index 00000000000..ad05475a4c1 --- /dev/null +++ b/tests/images/loop-terminal-refusal.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; +import type { ImageBridgePlan } from "../../src/images/types"; +import { runWithImageBridge } from "../../src/images/loop"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #7148: a refusal that ends an iteration carrying a complete image_gen call must end the turn. + * Fulfilling the call and re-asking would send the refused context back to the model. + */ +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); +}); + +test.each([true, false, undefined])("a refused image_gen iteration stops with terminalRefusalAsFailed=%s", async terminalRefusalAsFailed => { + home = mkdtempSync(join(tmpdir(), "ocx-image-refusal-")); + process.env.OPENCODEX_HOME = home; + let mediaFetches = 0; + globalThis.fetch = (async () => { mediaFetches++; return new Response("unavailable", { status: 503 }); }) as typeof fetch; + let routedRequests = 0; + const adapter: ProviderAdapter = { + name: "mock-refusal", + buildRequest: async () => { + routedRequests++; + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("ok", { status: 200 }), + async *parseStream() { + yield { type: "tool_call_start", id: "call_1", name: "image_gen" } satisfies AdapterEvent; + yield { type: "tool_call_delta", arguments: '{"prompt":"a cat"}' } satisfies AdapterEvent; + yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { + type: "incomplete", reason: "content_filter", retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, + } satisfies AdapterEvent; + }, + async parseResponse() { throw new Error("parseResponse must be unreachable"); }, + }; + const plan = { + provider: {} as never, + auth: { baseUrl: "https://api.x.ai", token: "test-token" }, + model: "grok-imagine-image-quality", + toolNames: new Set(["image_gen"]), + } as ImageBridgePlan; + + const response = await runWithImageBridge({ + parsed: { modelId: "test-model", context: { messages: [], tools: [] }, stream: true, options: {} } as OcxParsedRequest, + adapter, + plan, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + terminalRefusalAsFailed, + }); + const text = await response.text(); + + expect(routedRequests).toBe(1); + expect(mediaFetches).toBe(0); + expect(text).toContain(`event: response.${terminalRefusalAsFailed ? "failed" : "incomplete"}`); + if (terminalRefusalAsFailed) expect(text).toContain('"code":"invalid_prompt"'); + else expect(text).toContain('"reason":"content_filter"'); + expect(text).not.toContain("event: response.completed"); +}); 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); } + }); + } + } +}); 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); diff --git a/tests/responses/responses-call-id-linkage.test.ts b/tests/responses/responses-call-id-linkage.test.ts new file mode 100644 index 00000000000..78bd6d72642 --- /dev/null +++ b/tests/responses/responses-call-id-linkage.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "bun:test"; +import { parseRequest } from "../../src/responses/parser"; +import { buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; +describe("Issue #6469: Responses Request Parser & Serializer Offline Regression", () => { + describe("B. Call ID preservation and linkage", () => { + test("preserves call_id across serialization and links toolResult correctly", () => { + const callId = "call_custom_unique_id_xyz"; + const events: AdapterEvent[] = [ + { + type: "tool_call_start", + id: callId, + name: "fetch_record", + }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ record_id: "REC-42" }), + }, + { + type: "tool_call_end", + }, + { type: "done" }, + ]; + + const serialized = buildResponseJSON(events, "claude-3-7-sonnet"); + const output = serialized.output as Array>; + expect(output[0].call_id).toBe(callId); + expect(output[0].type).toBe("function_call"); + expect(output[0].name).toBe("fetch_record"); + + // Inbound continuation with explicit input items + const rawInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Fetch record 42" }, + { + type: "function_call", + call_id: callId, + name: "fetch_record", + arguments: JSON.stringify({ record_id: "REC-42" }), + }, + { + type: "function_call_output", + call_id: callId, + output: JSON.stringify({ title: "Annual Report" }), + }, + ], + }; + + const parsed = parseRequest(rawInbound); + const messages = parsed.context.messages; + expect(messages.length).toBe(3); + + const assistantMsg = messages[1]; + expect(assistantMsg.role).toBe("assistant"); + const toolCallPart = (assistantMsg.content as Array>).find(p => p.type === "toolCall"); + expect(toolCallPart?.id).toBe(callId); + expect(toolCallPart?.name).toBe("fetch_record"); + + const toolResultMsg = messages[2] as { role: string; toolCallId: string; toolName: string; content: string }; + expect(toolResultMsg.role).toBe("toolResult"); + expect(toolResultMsg.toolCallId).toBe(callId); + expect(toolResultMsg.toolName).toBe("fetch_record"); + }); + }); + + describe("C. Invalid Call ID cases", () => { + test("handles unknown call_id without throwing, defaulting toolName to empty", () => { + const inbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Previous question" }, + { + type: "function_call_output", + call_id: "call_non_existent_999", + output: "some result", + }, + ], + }; + + const parsed = parseRequest(inbound); + const toolResult = parsed.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + content: string; + }; + expect(toolResult).toBeDefined(); + expect(toolResult.toolCallId).toBe("call_non_existent_999"); + expect(toolResult.toolName).toBe(""); // Unlinked call produces empty toolName + }); + + test("handles duplicate call IDs accurately across single-turn and multi-turn histories", () => { + const duplicateId = "call_dup_777"; + + // 1. Single assistant turn (consecutive function_calls): + // findToolById walks backward over messages, but iterates forward across m.content parts, + // so within the same assistant turn it binds to the first tool_call with that ID. + const sameTurnInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Start" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v1", + arguments: "{}", + }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v2", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: duplicateId, + output: "output for tool", + }, + ], + }; + + const parsedSameTurn = parseRequest(sameTurnInbound); + const sameTurnResult = parsedSameTurn.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + }; + expect(sameTurnResult).toBeDefined(); + expect(sameTurnResult.toolCallId).toBe(duplicateId); + expect(sameTurnResult.toolName).toBe("tool_v1"); // First part in m.content + + // 2. Multi-turn history (distinct assistant turns separated by user message): + // findToolById walks backward over messages, so it binds to the most recent assistant turn's call. + const multiTurnInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Start" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v1", + arguments: "{}", + }, + { role: "user", content: "Retry with newer tool" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v2", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: duplicateId, + output: "output for tool", + }, + ], + }; + + const parsedMultiTurn = parseRequest(multiTurnInbound); + const multiTurnResult = parsedMultiTurn.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + }; + expect(multiTurnResult).toBeDefined(); + expect(multiTurnResult.toolCallId).toBe(duplicateId); + expect(multiTurnResult.toolName).toBe("tool_v2"); // Most recent assistant turn + }); + + test("tolerates missing call_id in function_call and function_call_output with undefined id", () => { + // schema catch-all (z.object({ type: z.string() }).loose()) matches items with missing call_id, + // so parseRequest does NOT throw; call_id simply resolves to undefined on the parsed part. + const missingCallIdItem = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Test" }, + { + type: "function_call", + name: "lookup_tool", + arguments: "{}", + // call_id omitted + }, + ], + }; + + const parsed = parseRequest(missingCallIdItem); + const assistantMsg = parsed.context.messages.find(m => m.role === "assistant"); + expect(assistantMsg).toBeDefined(); + const toolCallPart = (assistantMsg?.content as Array>).find(p => p.type === "toolCall"); + expect(toolCallPart?.id).toBeUndefined(); + + // function_call_output without call_id likewise resolves to an undefined toolCallId. + const missingCallIdOutput = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Test" }, + { + type: "function_call_output", + output: "output without call_id", + }, + ], + }; + const parsedOutput = parseRequest(missingCallIdOutput); + const toolResult = parsedOutput.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId?: string; + toolName: string; + }; + expect(toolResult).toBeDefined(); + expect(toolResult.toolCallId).toBeUndefined(); + expect(toolResult.toolName).toBe(""); + }); + + test("handles mismatched call_id without corrupting sibling calls", () => { + const inbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Fetch" }, + { + type: "function_call", + call_id: "call_alpha", + name: "alpha_tool", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_beta", // Mismatched + output: "output for beta", + }, + ], + }; + + const parsed = parseRequest(inbound); + const messages = parsed.context.messages; + const alphaPart = (messages[1].content as Array>).find(p => p.type === "toolCall"); + expect(alphaPart?.id).toBe("call_alpha"); + expect(alphaPart?.name).toBe("alpha_tool"); + + const betaResult = messages[2] as { role: string; toolCallId: string; toolName: string }; + expect(betaResult.toolCallId).toBe("call_beta"); + expect(betaResult.toolName).toBe(""); + }); + }); +}); diff --git a/tests/responses/responses-mimo-envelope.test.ts b/tests/responses/responses-mimo-envelope.test.ts new file mode 100644 index 00000000000..1d4646473a9 --- /dev/null +++ b/tests/responses/responses-mimo-envelope.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { functionCallArgumentsFromText, parseRequest } from "../../src/responses/parser"; + +const suffix = ""; + +describe("MiMo function-call history envelope", () => { + test.each([ + ['{"code":"run()' + suffix, { code: "run()" }], + ['{"code":"run()"' + suffix, { code: "run()" }], + ['{"code":"run()"}' + suffix, { code: "run()" }], + ['{"code":"if (ready) {}' + suffix, { code: "if (ready) {}" }], + ['{"code":"run() ' + suffix, { code: "run() " }], + ] as const)("restores a bounded object envelope: %s", (text, expected) => { + expect(functionCallArgumentsFromText("call_mimo", text)).toEqual(expected); + }); + + test("an escaped terminal quote remains inside the repaired string", () => { + const expected = { code: 'print("ready")"' }; + const text = JSON.stringify(expected).slice(0, -2) + suffix; + expect(functionCallArgumentsFromText("call_escaped", text)).toEqual(expected); + }); + + test("valid JSON preserves embedded tags and escaped quotes", () => { + const expected = { code: `print("${suffix}")` }; + expect(functionCallArgumentsFromText("call_valid", JSON.stringify(expected))).toEqual(expected); + }); + + test.each([ + '{"code":"run()', + '{"code":"run()' + suffix + ' trailing', + 'plain text' + suffix, + '["run()"]' + suffix, + '{"nested":{"code":"run()' + suffix, + ])("unrelated malformed text retains the empty-object fallback: %s", text => { + expect(functionCallArgumentsFromText("call_bad", text)).toEqual({}); + }); + + test("parseRequest restores a call before its tool output", () => { + const parsed = parseRequest({ + model: "mimo-test", + input: [ + { type: "function_call", call_id: "call_history", name: "js", arguments: '{"code":"run()' + suffix }, + { type: "function_call_output", call_id: "call_history", output: "done" }, + ], + }); + const assistant = parsed.context.messages.find(message => message.role === "assistant"); + expect(assistant?.content.find(part => part.type === "toolCall")).toMatchObject({ + type: "toolCall", id: "call_history", name: "js", arguments: { code: "run()" }, + }); + }); + + test("fallback diagnostics never include raw argument content", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + functionCallArgumentsFromText("call_private", '{"private":"argument-content-marker'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls.flat().join(" ")).not.toContain("argument-content-marker"); + } finally { warn.mockRestore(); } + }); +}); diff --git a/tests/routing/combo-terminal-refusal.test.ts b/tests/routing/combo-terminal-refusal.test.ts new file mode 100644 index 00000000000..068b9044b12 --- /dev/null +++ b/tests/routing/combo-terminal-refusal.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { comboFailureDecision } from "../../src/combos"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { preflightComboStreamResponse } from "../../src/server/responses/combo-stream-preflight"; +import { consumeComboFailure } from "../../src/server/responses/core-combo-failure"; +import type { AdapterEvent } from "../../src/types"; + +/** + * #4312 inside a combo. A refusal with no output is a zero-output `response.failed`, which the + * combo preflight converts into an HTTP error before any byte reaches the client. That must end + * the chain rather than send the refused prompt to the next target, and once output has been + * relayed the same terminal has to stay a streamed `response.failed`. + */ +const REFUSAL: AdapterEvent = { + type: "incomplete", + reason: "content_filter", + retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: { inputTokens: 10, outputTokens: 0, totalTokens: 10 }, +}; + +function bridged(events: AdapterEvent[]): Response { + async function* source(): AsyncGenerator { + for (const event of events) yield event; + } + return new Response( + bridgeToResponsesSSE(source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { + terminalRefusalAsFailed: true, + }), + { headers: { "content-type": "text/event-stream" } }, + ); +} + +describe("combo: terminal upstream refusal", () => { + test("a zero-output refusal becomes an HTTP 400 that stops the chain", async () => { + const logCtx = {} as RequestLogContext; + const preflight = await preflightComboStreamResponse(bridged([REFUSAL]), logCtx); + + if (preflight.kind !== "failed") throw new Error(`expected a failed preflight, got ${preflight.kind}`); + expect(preflight.response.status).toBe(400); + expect(preflight.response.headers.get("content-type")).toContain("application/json"); + const body = await preflight.response.clone().json() as { error: { type: string; code: string; message: string } }; + expect(body.error.type).toBe("invalid_request_error"); + expect(body.error.code).toBe("invalid_prompt"); + expect(body.error.message).toContain("refused"); + // The steps `src/server/responses/core-combo.ts` takes with a failed preflight: consume the + // failure body, carry the preflight's model-refusal evidence over, then decide. The loop's + // other stop conditions (non-replayable marker, spent replacement) are not in play here. + const failure = await consumeComboFailure(preflight.response); + expect(failure.response.status).toBe(400); + expect(failure.upstreamCode).toBe("invalid_prompt"); + expect(comboFailureDecision(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + codexModelRefusal: preflight.codexModelRefusal ?? failure.codexModelRefusal, + })).toBe("stop"); + }); + + test("a refusal after output is relayed as the streamed response.failed", async () => { + const preflight = await preflightComboStreamResponse( + bridged([{ type: "text_delta", text: "I can" }, REFUSAL]), + {} as RequestLogContext, + ); + + expect(preflight.kind).toBe("accepted"); + const text = await preflight.response.text(); + expect(text).toContain("event: response.failed"); + expect(text).toContain('"code":"invalid_prompt"'); + expect(text).not.toContain("event: response.incomplete"); + }); +}); diff --git a/tests/server/server-kiro-refusal-e2e.test.ts b/tests/server/server-kiro-refusal-e2e.test.ts index 74d17cc9514..eb0b42b5450 100644 --- a/tests/server/server-kiro-refusal-e2e.test.ts +++ b/tests/server/server-kiro-refusal-e2e.test.ts @@ -522,3 +522,84 @@ describe("Kiro refusal recovery through Responses", () => { }); } }); + +/** + * #4312: Kiro's CONTENT_FILTERED / GUARDRAIL_INTERVENED stop is an `incomplete` with + * `retryable: false`. A Responses client must see it as a terminal `response.failed` on every + * delivery that owns a bridge call: the adapter stream, both sidecar loops, and runTurn. No + * runTurn adapter emits this verdict today, so that case drives the same event through a + * `runTurn` stand-in on the Kiro registry entry. + */ +describe("Kiro content-filter stop through Responses streaming", () => { + const frame = (eventType: string, payload: Record) => encodeMessage( + { ":message-type": "event", ":event-type": eventType }, + new TextEncoder().encode(JSON.stringify(payload))); + const filtered = (stopReason: string) => new Response(Buffer.concat([ + frame("assistantResponseEvent", { content: "Partial." }), + frame("metadataEvent", { stopReason }), + ]), { headers: { "content-type": "application/vnd.amazon.eventstream" } }); + const lookupTool = { type: "function", name: "lookup", description: "Look something up.", + parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } }; + const xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", + apiKey: "synthetic", models: ["grok-4"] }; + + for (const [label, stopReason, extra, tools] of [ + ["adapter delivery", "CONTENT_FILTERED", {}, [lookupTool]], + ["adapter delivery", "GUARDRAIL_INTERVENED", {}, [lookupTool]], + ["web-search sidecar loop", "CONTENT_FILTERED", + { webSearchSidecar: { backend: "exa", exaApiKey: "synthetic" } }, + [{ type: "web_search" }]], + ["image sidecar loop", "CONTENT_FILTERED", + { images: { bridgeEnabled: true }, providers: { ...config().providers, xai } }, [{ type: "image_generation" }]], + ["runTurn delivery", "CONTENT_FILTERED", {}, [lookupTool]], + ] as const) { + test(`${label}: ${stopReason} ends with one response.failed carrying invalid_prompt`, async () => { + await seed(); + saveConfig({ ...config(), ...extra } as OcxConfig); + let sends = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://runtime.") && url.endsWith(".kiro.dev/")) { + sends++; + return filtered(stopReason); + } + return realFetch(input, init); + }) as typeof fetch; + const originalCreate = ADAPTER_REGISTRY.kiro.create; + if (label === "runTurn delivery") { + ADAPTER_REGISTRY.kiro.create = (provider, context) => ({ + ...originalCreate(provider, context), + async runTurn(_parsed, _incoming, emit) { + sends++; + emit({ type: "text_delta", text: "Partial." }); + emit({ type: "incomplete", reason: "content_filter", retryable: false, endTurn: false, + message: `Kiro stopped with ${stopReason} before an explicit final answer` }); + }, + }); + } + const server = startServer(0); + try { + const response = await realFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "kiro/claude-sonnet-4.5", input: "hello", stream: true, tools }), + }); + expect(response.status).toBe(200); + const text = await response.text(); + const terminals = text.split("\n\n").flatMap(block => { + const name = block.split("\n").find(line => line.startsWith("event: "))?.slice(7) ?? ""; + const data = block.split("\n").find(line => line.startsWith("data: "))?.slice(6); + return /^response\.(completed|incomplete|failed)$/.test(name) && data + ? [{ name, response: JSON.parse(data).response as Record }] : []; + }); + expect(terminals.map(terminal => terminal.name)).toEqual(["response.failed"]); + const failed = terminals[0]!.response; + expect(failed.error.type).toBe("invalid_request_error"); + expect(failed.error.code).toBe("invalid_prompt"); + expect(failed.error.message).toContain(stopReason); + expect(failed.retryable).toBe(false); + // The refusal is neither replayed on the same account nor rotated to the other one. + expect(sends).toBe(1); + } finally { ADAPTER_REGISTRY.kiro.create = originalCreate; await server.stop(true); } + }); + } +}); diff --git a/tests/server/server-terminal-refusal-wiring.test.ts b/tests/server/server-terminal-refusal-wiring.test.ts new file mode 100644 index 00000000000..8aaf07d0755 --- /dev/null +++ b/tests/server/server-terminal-refusal-wiring.test.ts @@ -0,0 +1,190 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #4312, end to end: which inbound wire sees an upstream refusal as what. + * + * `tests/adapters/bridge-terminal-refusal.test.ts` pins the bridge; this pins the wiring — that + * only the Responses inbound wire asks the bridge for `response.failed`, through the real server + * and a real Anthropic-shaped upstream that ends the turn with `stop_reason: "refusal"`. + */ +type Frame = { name?: string; data: Record }; + +const MODEL = "claude-refusal/claude-test"; +const RESPONSES_TERMINALS = new Set(["response.completed", "response.incomplete", "response.failed"]); + +function anthropicSse(stopReason: string): string { + const frame = (name: string, data: Record) => + `event: ${name}\ndata: ${JSON.stringify({ type: name, ...data })}\n\n`; + return [ + frame("message_start", { + message: { id: "msg_1", model: "claude-test", usage: { input_tokens: 10, output_tokens: 1 } }, + }), + frame("content_block_start", { index: 0, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "text_delta", text: "I can" } }), + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }), + frame("message_stop", {}), + ].join(""); +} + +function frames(text: string): Frame[] { + return text.split("\n\n").flatMap(block => { + const lines = block.trim().split("\n"); + const name = lines.find(line => line.startsWith("event: "))?.slice(7); + const data = lines.find(line => line.startsWith("data: "))?.slice(6); + if (!data || data === "[DONE]") return []; + return [{ name, data: JSON.parse(data) as Record }]; + }); +} + +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +let isolated: IsolatedCodexHome | undefined; +let upstream: ReturnType | undefined; +let server: ReturnType | undefined; +let stopReason = "refusal"; +let upstreamRequests = 0; + +beforeAll(async () => { + upstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + upstreamRequests += 1; + const body = await request.json() as { stream?: boolean }; + if (body.stream !== true) { + return Response.json({ + id: "msg_1", type: "message", role: "assistant", model: "claude-test", + content: [{ type: "text", text: "I can" }], + stop_reason: stopReason, stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 3 }, + }); + } + return new Response(anthropicSse(stopReason), { headers: { "content-type": "text/event-stream" } }); + }, + }); + home = mkdtempSync(join(tmpdir(), "ocx-refusal-wiring-")); + isolated = installIsolatedCodexHome("ocx-refusal-wiring-codex-"); + process.env.OPENCODEX_HOME = home; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "claude-refusal", + providers: { + "claude-refusal": { + adapter: "anthropic", + baseUrl: `http://127.0.0.1:${upstream.port}`, + apiKey: "sk-test", + // The upstream is a loopback fixture, and nothing here may reach a real catalog. + allowPrivateNetwork: true, + liveModels: false, + defaultModel: "claude-test", + models: ["claude-test"], + }, + }, + } as OcxConfig); + server = startServer(0); + // Requests admitted before the native-main startup gate settles are held, not served. + await waitForNativeMainStartupGate(); +}); + +afterAll(async () => { + await server?.stop(true); + upstream?.stop(true); + // Assigning `undefined` to an env var stores the string "undefined", which then becomes a + // directory name under the working tree. Delete the key when there was nothing to restore. + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolated?.restore(); + if (home) removeTreeWithRetry(home); +}); + +async function post(path: string, body: Record): Promise { + return fetch(new URL(path, server!.url), { + method: "POST", + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify(body), + }); +} + +describe("upstream refusal, per inbound wire", () => { + test("/v1/responses streaming ends with exactly one response.failed carrying invalid_prompt", async () => { + stopReason = "refusal"; + const before = upstreamRequests; + const response = await post("/v1/responses", { model: MODEL, input: "hi", stream: true }); + const all = frames(await response.text()); + + expect(response.status).toBe(200); + expect(all.map(frame => frame.name).filter(name => RESPONSES_TERMINALS.has(name ?? ""))) + .toEqual(["response.failed"]); + const failed = all.find(frame => frame.name === "response.failed")!.data.response; + expect(failed.status).toBe("failed"); + expect(failed.error).toEqual(failed.last_error); + expect(failed.error.type).toBe("invalid_request_error"); + expect(failed.error.code).toBe("invalid_prompt"); + expect(failed.error.message).toContain('stop_reason "refusal"'); + expect(failed.retryable).toBe(false); + expect(failed.usage.input_tokens).toBe(10); + // The proxy itself must not replay a refusal either. + expect(upstreamRequests - before).toBe(1); + }); + + test("/v1/responses streaming still completes an ordinary turn", async () => { + stopReason = "end_turn"; + const response = await post("/v1/responses", { model: MODEL, input: "hi", stream: true }); + const names = frames(await response.text()).map(frame => frame.name); + + expect(names.filter(name => RESPONSES_TERMINALS.has(name ?? ""))).toEqual(["response.completed"]); + }); + + test("/v1/responses non-streaming keeps the incomplete body", async () => { + stopReason = "refusal"; + const response = await post("/v1/responses", { model: MODEL, input: "hi", stream: false }); + const body = await response.json() as Record; + + expect(response.status).toBe(200); + expect(body.status).toBe("incomplete"); + expect(body.incomplete_details.reason).toBe("content_filter"); + expect(body.error ?? null).toBeNull(); + expect(JSON.stringify(body)).not.toContain("invalid_prompt"); + }); + + test("/v1/chat/completions streaming reports finish_reason content_filter", async () => { + stopReason = "refusal"; + const response = await post("/v1/chat/completions", { + model: MODEL, messages: [{ role: "user", content: "hi" }], stream: true, + }); + const text = await response.text(); + const finishes = frames(text) + .flatMap(frame => (frame.data.choices ?? []) as Array<{ finish_reason?: string | null }>) + .map(choice => choice.finish_reason) + .filter(Boolean); + + expect(response.status).toBe(200); + expect(finishes).toEqual(["content_filter"]); + expect(text).not.toContain("invalid_prompt"); + }); + + test("/v1/messages streaming reports stop_reason refusal", async () => { + stopReason = "refusal"; + const response = await post("/v1/messages", { + model: MODEL, max_tokens: 64, messages: [{ role: "user", content: "hi" }], stream: true, + }); + const text = await response.text(); + const delta = frames(text).find(frame => frame.name === "message_delta"); + + expect(response.status).toBe(200); + expect(delta?.data.delta.stop_reason).toBe("refusal"); + expect(text).not.toContain("invalid_prompt"); + expect(text).not.toContain("event: error"); + }); +}); diff --git a/tests/web-search/web-search-terminal-refusal.test.ts b/tests/web-search/web-search-terminal-refusal.test.ts new file mode 100644 index 00000000000..af88991b997 --- /dev/null +++ b/tests/web-search/web-search-terminal-refusal.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, test } from "bun:test"; +import type { AdapterEvent } from "../../src/types"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { parseRequest } from "../../src/responses/parser"; +import { runWithWebSearch } from "../../src/web-search/loop"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * #7148: a refusal that ends an iteration carrying a complete web_search call must end the turn. + * Running the search and re-asking would send the refused context back to the model, the same + * re-send the Responses bridge's terminal `response.failed` exists to prevent. + */ +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test.each([true, false, undefined])("a refused web_search iteration stops with terminalRefusalAsFailed=%s", async terminalRefusalAsFailed => { + let searchFetches = 0; + globalThis.fetch = (async () => { + searchFetches++; + return Response.json({ results: [{ title: "Fixture", url: "https://example.test/r", content: "result" }] }); + }) as typeof fetch; + let routedSends = 0; + const adapter: ProviderAdapter = { + name: "mock-refusal", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => { routedSends++; return new Response("ok", { status: 200 }); }, + async *parseStream() { + yield { type: "tool_call_start", id: "search-1", name: "web_search" } satisfies AdapterEvent; + yield { type: "tool_call_delta", arguments: JSON.stringify({ query: "fixture query" }) } satisfies AdapterEvent; + yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { + type: "incomplete", reason: "content_filter", retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, + } satisfies AdapterEvent; + }, + async parseResponse() { throw new Error("parseResponse must be unreachable"); }, + }; + + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + backend: "exa", + exaApiKey: "synthetic-exa-key", + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers(), + settings: { model: "exa-fixture-model", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 2, + terminalRefusalAsFailed, + }); + const text = await response.text(); + + expect(routedSends).toBe(1); + expect(searchFetches).toBe(0); + expect(text).toContain(`event: response.${terminalRefusalAsFailed ? "failed" : "incomplete"}`); + if (terminalRefusalAsFailed) expect(text).toContain('"code":"invalid_prompt"'); + else expect(text).toContain('"reason":"content_filter"'); + expect(text).not.toContain("event: response.completed"); +});