diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index fcffd5690f0..c88045d5721 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -235,3 +235,13 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin ## Codex 할당량 네트워크 진단 메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작할 때 Windows의 정적 프록시 설정만 읽습니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요. + +## 프로토콜 증거 기반 Responses 스트림 복구 + +네이티브 HTTP Responses는 downstream 본문 소비 중 protocol inspection이 `response.created`를 +파싱하고 출력이나 도구 이벤트를 하나도 관찰하지 않은 상태에서 연결 재설정 read 오류를 받으면 +스트림을 한 번 교체할 수 있습니다. 응답 헤더는 첫 SSE 이벤트를 기다리지 않습니다. 교체 요청은 +남은 전송 한도와 같은 선택 자격 증명을 사용합니다. +`response.created` 전, 출력 이후, 교체 스트림, WebSocket, 네이티브 Chat에서 발생한 reset은 +재전송하지 않습니다. 형식이 맞지 않거나 실패 상태인 교체 응답은 버리고 원본 스트림 오류를 +유지합니다. 이 동작은 `emptyCompletionRetry`와 별개입니다. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e243f5edd90..470cef7312d 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -615,3 +615,13 @@ WebSocket control paths. See the canonical guide for [supported steering routes and settings](../../guides/codex-integration.md#steering-continuation-settings-and-public-api), [typed result and approval continuations](../../guides/codex-integration.md#rich-tool-results-and-explicit-approvals-after-response-completion), and [confirmation deadlines and retained context](../../guides/codex-integration.md#steering-confirmation-deadlines-and-retained-context). + +## Protocol-gated Responses stream recovery + +Native HTTP Responses can replace one stream after response headers when protocol inspection during +downstream body consumption has parsed `response.created`, has observed no output or tool event, +and then receives a connection-reset read error. Response headers do not wait for the first SSE +event. The replacement uses the request's remaining send allowance and the same selected +credential. A reset before `response.created`, after any output, on the replacement, over WebSocket, +or on native Chat is never replayed. An incompatible or unsuccessful replacement is discarded and +the original stream failure is preserved. This behavior is independent of `emptyCompletionRetry`. diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 8dd6f1146f1..b64e4b4625a 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -8,13 +8,15 @@ * becomes a terminal, non-replayable response unless the operation is explicitly safe. * * Deliberately narrow: timeouts, aborts, ECONNREFUSED/DNS/TLS failures, and HTTP error - * statuses (returned as Response, never thrown) are NOT retried. Mid-stream SSE resets are - * out of scope — the response has already resolved by then. + * statuses (returned as Response, never thrown) are NOT retried. The Responses transport + * separately permits one post-header replacement only after its eager SSE preflight has + * observed response.created and no protocol output event. * * MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports * the shared abort helpers from here). */ import { clearableDeadline } from "./abort"; +import { redactSecretString } from "./redact"; /** * Responses the origin may already be executing. RFC 9110 §9.2.2 forbids an intermediary @@ -661,3 +663,47 @@ export async function fetchWithTransientRetry( opts.onSendsConsumed?.(sent); } } + +export type ProtocolSafeRefetch = ( + recovery?: UpstreamSendRecovery, + signal?: AbortSignal, +) => Promise; + +export interface ProtocolSafeRefetchOptions extends ResetRetryOptions { + /** The replacement must match the response contract already selected for the client. */ + acceptResponse?: (response: Response) => boolean; +} + +/** + * Attempt one caller-authorized replacement after protocol inspection proved that no output + * event was observed. The caller owns that proof and the physical-send budget. + */ +export async function refetchAfterProtocolSafeReset( + doFetch: ProtocolSafeRefetch, + err: unknown, + opts: ProtocolSafeRefetchOptions = {}, +): Promise { + if (!isConnectionResetError(err) || opts.abortSignal?.aborted || opts.attempts === 0) return null; + const label = opts.label + ? " (" + redactSecretString(opts.label).replace(/[\r\n\u0000-\u001f\u007f]/g, "").slice(0, 128) + ")" + : ""; + let replacement: Response; + try { + replacement = await doFetch("connection-reset", opts.abortSignal); + } catch { + console.warn("[upstream-retry] protocol-safe refetch failed" + label + "; preserving original stream error"); + return null; + } + const body = replacement.body; + let accepted = !opts.abortSignal?.aborted && replacement.ok && body !== null + && !replacement.bodyUsed && !body.locked && !isNonReplayableResponse(replacement); + try { if (accepted && opts.acceptResponse) accepted = opts.acceptResponse(replacement); } + catch { accepted = false; } + if (!accepted || opts.abortSignal?.aborted || body?.locked) { + try { void body?.cancel().catch(() => {}); } catch { /* already locked or closed */ } + console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error"); + return null; + } + console.warn("[upstream-retry] pre-output Responses reset" + label + "; using one replacement stream"); + return replacement; +} diff --git a/src/server/relay.ts b/src/server/relay.ts index 1e71ea35b90..5108f454b4a 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -880,6 +880,8 @@ export type SseInspectorHandlers = { * with an empty `output`. */ onParsedPayload?: (payload: unknown) => void; + /** A complete data payload that was not parsed as a JSON event, including [DONE]. */ + onOpaquePayload?: () => void; onFirstOutput?: () => void; /** * Provider-scoped compatibility: persist the completed snapshot under the @@ -1060,6 +1062,9 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector /* malformed SSE payloads remain best-effort/no-throw */ } } + if (parsed === undefined && handlers.onOpaquePayload) { + try { handlers.onOpaquePayload(); } catch { /* inspection must never throw into the pump */ } + } if (!reported && handlers.logCtx) { inspectResponseLogSsePayloadParsed(handlers.logCtx, payload, parsed); } diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 22e1ae12e45..ed9a33aa9ea 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -108,6 +108,13 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true; const type = (payload as { type?: unknown }).type; if (typeof type !== "string") return true; + if (type === "response.created") { + const response = (payload as { response?: unknown }).response; + if (response && typeof response === "object" && !Array.isArray(response)) { + const output = (response as { output?: unknown }).output; + if (Array.isArray(output) && output.length > 0) return true; + } + } return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); } @@ -186,11 +193,13 @@ function failedTerminalResponse( export type ComboStreamPreflightResult = | { kind: "accepted"; response: Response } - | { kind: "failed"; response: Response }; + | { kind: "failed"; response: Response } + | { kind: "read-error-before-output"; response: Response; error: unknown }; /** - * Buffer a combo child's downstream SSE only until the request becomes unsafe to - * replay or reaches a terminal. This owns exactly one body reader. The aggregate + * Buffer a Responses SSE only until the request becomes unsafe to replay or reaches + * a terminal. Combo failover and native reset recovery share this protocol boundary. + * This owns exactly one body reader. The aggregate * buffer is capped by bytes and retained chunks; hitting either cap commits the * current target instead of growing memory or guessing that replay is safe. */ @@ -211,12 +220,16 @@ export async function preflightComboStreamResponse( const buffered: Uint8Array[] = []; let bufferedBytes = 0; let outputCommitted = false; + let responseCreated = false; let terminalStatus: ResponsesTerminalStatus | undefined; let retryableTerminalPayload: Record | undefined; const inspector = createSseInspector({ logCtx, + onOpaquePayload: () => { outputCommitted = true; }, onParsedPayload: payload => { if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return; + if (payload !== null && typeof payload === "object" && !Array.isArray(payload) + && (payload as { type?: unknown }).type === "response.created") responseCreated = true; const retryable = retryableTerminal(payload); const matchedBareError = retryable && payload !== null && typeof payload === "object" && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error"; @@ -239,7 +252,10 @@ export async function preflightComboStreamResponse( // The native relay still owns post-header transport failures. Preserve // the bounded prefix and the errored reader; cancelling it here would // erase the failure before either client relay or inspection sees it. - return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) }; + const replay = replayBufferedResponse(response, reader, buffered); + return responseCreated && !outputCommitted && terminalStatus === undefined + ? { kind: "read-error-before-output", response: replay, error } + : { kind: "accepted", response: replay }; } if (next.done) { inspector.finish(); @@ -277,3 +293,96 @@ export async function preflightComboStreamResponse( inspector.dispose(); } } + +export type ProtocolSafeResetRecovery = (error: unknown) => Promise; + +/** + * Defer protocol inspection until the downstream actually pulls the body. Direct + * passthrough must return response headers before the first SSE event arrives; + * combo routing is the only caller that intentionally awaits this preflight. + */ +export function deferProtocolSafeResetRecovery( + response: Response, + logCtx: RequestLogContext, + recover: ProtocolSafeResetRecovery, + options?: { allowMissingContentType?: boolean }, +): Response { + if (!response.body) return response; + + let reader: ReadableStreamDefaultReader | undefined; + let initialization: Promise | undefined; + let closed = false; + + const cancelBody = (body: ReadableStream | null, reason?: unknown): void => { + try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ } + }; + const initialize = async (): Promise => { + const preflight = await preflightComboStreamResponse( + response, + logCtx, + () => false, + { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true }, + ); + let selected = preflight.response; + if (preflight.kind === "read-error-before-output") { + const replacement = await recover(preflight.error); + if (replacement) { + cancelBody(selected.body, "using protocol-safe replacement stream"); + selected = replacement; + } + } + if (closed) { + cancelBody(selected.body, "downstream cancelled before protocol preflight completed"); + return; + } + reader = selected.body?.getReader(); + }; + + const body = new ReadableStream({ + async pull(controller) { + try { + initialization ??= initialize(); + await initialization; + if (closed) return; + if (!reader) { + closed = true; + controller.close(); + return; + } + const next = await reader.read(); + if (closed) return; + if (next.done) { + closed = true; + try { reader.releaseLock(); } catch { /* already released */ } + reader = undefined; + controller.close(); + return; + } + controller.enqueue(next.value); + } catch (error) { + if (closed) return; + closed = true; + try { reader?.releaseLock(); } catch { /* errored reader */ } + reader = undefined; + controller.error(error); + } + }, + cancel(reason) { + if (closed) return; + closed = true; + if (reader) { + try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } + try { reader.releaseLock(); } catch { /* already released */ } + reader = undefined; + } else { + cancelBody(response.body, reason); + } + }, + }, { highWaterMark: 0 }); + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index b2e44969740..b9ca6d0da0c 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -112,6 +112,8 @@ export function sendWithConnectionPolicy( } export interface ProviderFetchOptions { + /** A replacement HTTP body must not initiate a fresh WebSocket exchange. */ + httpOnly?: boolean; nativeControl?: NativeResponseControl; providerName?: string; modelId?: string; @@ -164,7 +166,7 @@ export function providerFetch( // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details. const unpaced = async (input: Parameters[0], init?: RequestInit) => { const upstreamWebsocket = provider.upstreamWebsocket === true; - if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { + if (!options.httpOnly && typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { // The fallback has to be the same HTTP fetch the non-WS branch would have // used, protocol pin included: a WS turn that falls back is serving the // request over HTTP, and dropping the provider's `upstreamHttpVersion` diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index ed8288a9140..c676bd72aa1 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -112,7 +112,9 @@ import { fetchWithTransientRetry, applyUpstreamRecoveryInit, isNonReplayableResponse, - prepareSameTarget429Wait, + refetchAfterProtocolSafeReset, + TRANSIENT_RETRY_MAX_ATTEMPTS, + prepareSameTarget429Wait, sleepWithAbort, } from "../../lib/upstream-retry"; import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error"; @@ -149,7 +151,8 @@ import { reasoningEffortRejectionText, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; -import { preflightComboStreamResponse } from "./combo-stream-preflight"; +import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight"; +import { isCodexWsUpstreamResponse } from "./ws-upstream"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; @@ -179,6 +182,8 @@ export async function preparePassthroughExchange( | "genericFailoverAccountId" | "passiveQuotaWriterGeneration" | "oauthDispatch" + | "selectionIsCurrent" + | "requestBindings" | "resolveSelectionAdapter" | "isOAuth401ReplayProvider" | "sentOAuthSnapshot" @@ -1544,6 +1549,79 @@ export async function preparePassthroughExchange( continue passthroughRecovery; } } + + const streamRecoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; + const protocolRecoveryCandidate = upstreamResponse.ok + && !!upstreamResponse.body + && !isNonReplayableResponse(upstreamResponse) + && !isCodexWsUpstreamResponse(upstreamResponse) + // A downstream WebSocket turn that fell back to HTTP must relay response.created + // immediately so the client can address the turn and receive explicit control + // refusal. The recovery preflight must retain that event until output commits, so + // the two contracts cannot share one body owner. + && !(options.nativeControl && options.inboundTransport === "websocket") + && remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS) > 0 + && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream)); + if (protocolRecoveryCandidate) { + upstreamResponse = deferProtocolSafeResetRecovery( + upstreamResponse, + { model: logCtx.model, provider: logCtx.provider }, + error => refetchAfterProtocolSafeReset( + (_recovery, signal = upstream.signal) => fetchWithTransientRetry( + () => fetchWithHeaderTimeout( + request.url, + applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, "connection-reset"), + signal, + connectMs, + true, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + httpOnly: true, + providerName: route.providerName, + modelId: route.modelId, + dispatchOverride: oauthDispatch(request), + beforeDispatch: headers => { + if (signal.aborted) throw signal.reason; + if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) { + throw new Error("Credential selection changed before pre-output stream recovery"); + } + if (isCanonicalOpenAiForwardProvider(route.provider)) { + createCodexReserveDispatchGuard( + admissionState.authCtx, + options.codexAuthPolicy ?? config, + route.modelId, + options.admission, + options.visionDescribeTerminal === true, + )?.(headers); + } + transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); + }, + }), + route.provider.authMode === "forward", + ).then(adoptObservedResponse), + { + abortSignal: signal, + label: safeHostLabel(request.url), + attempts: Math.min(1, remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS)), + onSendsConsumed: noteTransientSends, + }, + ), + error, + { + abortSignal: upstream.signal, + label: safeHostLabel(request.url), + acceptResponse: candidate => { + const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; + return type.includes("text/event-stream") || (!type && parsed.stream); + }, + }, + ), + { allowMissingContentType: !streamRecoveryContentType && parsed.stream }, + ); + } break; } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 75238008dfe..57e32f6dcc1 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -209,3 +209,5 @@ Native steering generation overrides, explicit public-API eligibility and the co Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +[Protocol-gated HTTP stream recovery](../transports/streaming-health.md#protocol-gated-http-stream-recovery) reuses the current adapter and validates the existing credential binding. diff --git a/structure/catalog.md b/structure/catalog.md index dfa424ea1b5..da6b38a82f2 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -483,3 +483,5 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). + +Model selection is not repeated by [protocol-gated HTTP stream recovery](transports/streaming-health.md#protocol-gated-http-stream-recovery); the selected request and its remaining allowance stay authoritative. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e7edb67892c..071add31000 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -190,3 +190,5 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +Desktop routing continues through the existing ingress; [protocol-gated HTTP stream recovery](../transports/streaming-health.md#protocol-gated-http-stream-recovery) changes no client setup and never repeats a sent WebSocket exchange. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index bbdd23646d4..55ebd74b2f3 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -225,3 +225,5 @@ existing explicit confirmation. The journal endpoint evaluates Undo against the Recovery reads commit history and ownership through strict store methods. Unreadable or malformed metadata is uncertainty, never evidence that a transaction did not commit. Pending records validate complete ownership, exact Cline paths and result fingerprints before either native file is replaced. + +Client configuration writers remain separate from [protocol-gated HTTP stream recovery](../transports/streaming-health.md#protocol-gated-http-stream-recovery), which changes only an eligible in-flight Responses exchange. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index b01790ff752..81a5995d057 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -133,3 +133,5 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +Image-loop retry policy is separate from [protocol-gated HTTP stream recovery](../transports/streaming-health.md#protocol-gated-http-stream-recovery), which is limited to the native Responses data plane. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index f0e5ce5c656..45fd0c4aeda 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -346,3 +346,5 @@ Native steering generation overrides, explicit public-API eligibility and the co Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +Native Chat retains its existing terminal reset behavior; only Responses with [protocol-gated evidence](../transports/streaming-health.md#protocol-gated-http-stream-recovery) can replace a post-header HTTP stream. diff --git a/structure/decisions/ADR-3389-ambiguous-connection-reset-replay-boundary.md b/structure/decisions/ADR-3389-ambiguous-connection-reset-replay-boundary.md new file mode 100644 index 00000000000..c5204065cf7 --- /dev/null +++ b/structure/decisions/ADR-3389-ambiguous-connection-reset-replay-boundary.md @@ -0,0 +1,12 @@ +# ADR-3389 — decision recorded under "Ambiguous connection-reset replay boundary" + +- Contract owner: [transports/responses.md](../transports/responses.md#ambiguous-connection-reset-replay-boundary) + +## Decision record + +- Intent: Recover one native HTTP Responses turn when a pooled socket resets after headers but before protocol output, without replaying a turn that may already have emitted a tool call. +- Prior constraint: `devlog/_fin/260703_sse-midstream-reset-tail/00_plan.md` prohibited mid-stream resend because downstream byte counts cannot prove that the origin committed nothing. +- Alternatives considered: Keep every post-header reset terminal; resend when the downstream reader consumed zero bytes; inspect and buffer the protocol preamble during downstream body consumption before deciding. +- Decision: Permit one same-request HTTP replacement only when deferred SSE inspection parsed `response.created`, observed no output, tool, unknown or terminal event, and then received a reset-shaped read error. +- Rationale: Bun may discard buffered chunks on reset, making zero consumed bytes timing-dependent. The body preflight drains until a protocol commit boundary and provides stronger positive evidence while preserving progressive response headers and the original failure for every ambiguous shape. +- Consequences: Native Responses can recover this narrow failure using existing send accounting and credential admission. Resets before `response.created`, after any committing event, on a replacement stream, over WebSocket, and on native Chat remain non-replayable. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9935ba95946..02891e53f30 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -714,3 +714,5 @@ Shared response-log retention and native SSE inspection pacing follow the [bound Native steering retains fixed phase deadlines and reconciled replay output; see the [steering stability contract](transports/streaming-health.md#steering-deadlines-and-replay-completeness). Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +A [protocol-gated HTTP recovery](transports/streaming-health.md#protocol-gated-http-stream-recovery) records its physical send as `connection-reset`; logs do not include a rejected replacement body. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index cf6de0f2317..04b46df46eb 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -455,3 +455,5 @@ Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#r Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](../transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +The [protocol-gated recovery contract](../transports/streaming-health.md#protocol-gated-http-stream-recovery) is covered by the existing retry, preflight, send-budget and WebSocket routing suites. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e2957c84734..d9d29ebfc5c 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -194,3 +194,5 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +[Protocol-gated HTTP stream recovery](../transports/streaming-health.md#protocol-gated-http-stream-recovery) is request-local; it starts no service timer and changes no sidecar retry policy. diff --git a/structure/overview.md b/structure/overview.md index fb5b287d5e7..ecaa9d57f7d 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -194,3 +194,5 @@ Shared response-log retention and native SSE inspection pacing follow the [bound Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). + +Responses HTTP recovery retains the existing request boundary; see [protocol-gated stream recovery](transports/streaming-health.md#protocol-gated-http-stream-recovery) for its one-send, no-committed-output contract. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 37815ed04d1..29b16da822a 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -177,3 +177,5 @@ Native steering generation overrides, explicit public-API eligibility and the co Shared startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](../runtime.md). Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +Native xAI Responses uses shared [protocol-gated HTTP recovery](../transports/streaming-health.md#protocol-gated-http-stream-recovery) without bypassing the selected OAuth binding or choosing another account. diff --git a/structure/subagents.md b/structure/subagents.md index a607de463e3..a70c4700e96 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -409,3 +409,5 @@ Native steering generation overrides, explicit public-API eligibility and the co Startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](runtime.md). Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). + +A child Responses request retains its parent workflow charge during [protocol-gated HTTP recovery](transports/streaming-health.md#protocol-gated-http-stream-recovery); the replacement cannot create a new fan-out allowance. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index dc1fe383b01..acd0a0e1b1d 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -163,3 +163,5 @@ Schema size still determines traversal work and the cost of copying a changed br `tests/responses/openai-responses-passthrough.test.ts` covers the existing wire contract. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +During [protocol-gated HTTP recovery](streaming-health.md#protocol-gated-http-stream-recovery), the deferred body preflight retains only its existing bounded prefix. Rejected replacement bodies are cancelled rather than accumulated. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5f35e4d81c0..148aa280541 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -289,3 +289,5 @@ is left to the HTTP agent, which may pool or destroy it. `tests/lib/pinned-http-content-coding.test.ts` covers both routes on the same payload. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +The shared fetch executor has an HTTP-only mode for [protocol-gated HTTP recovery](streaming-health.md#protocol-gated-http-stream-recovery); pacing, dispatch overrides and credential checks still precede the physical send. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 48fc1fb9867..51afdc3b732 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -665,7 +665,8 @@ is unchanged; only the native recovery caller supplies the exact error predicate Both shapes carry the inbound caller-abort signal separately from the turn/shutdown controller. A caller-driven read rejection is 499/client_cancel without pool penalty; -a genuine upstream reset seen while reading the stream remains synthetic 502; the +a genuine upstream reset seen after protocol commitment remains synthetic 502; the +created-only preflight exception and the distinct pre-header case is a different verdict and is covered by [ambiguous connection-reset replay boundary](#ambiguous-connection-reset-replay-boundary). An already received terminal, including @@ -779,7 +780,8 @@ becomes the terminal refusal described in [ambiguous connection-reset replay boundary](#ambiguous-connection-reset-replay-boundary). Reusable request bytes were never the test: a string body makes a send mechanically repeatable, not idempotent, and a model POST is not idempotent. Timeouts, aborts, -`ECONNREFUSED`, HTTP error statuses, and mid-stream SSE failures are never retried at all. +`ECONNREFUSED` and HTTP error statuses are never reset-retried. One post-header Responses +exception is defined by [protocol-gated HTTP stream recovery](streaming-health.md#protocol-gated-http-stream-recovery). The opted-in callers are the sidecars, whose work is a tool call rather than a turn: the vision describers, the web-search executors and loop, and the image loop. The model-POST @@ -1124,18 +1126,27 @@ was never processed, so the decision not to replay is ours, made before any resp existed — the same shape as `request_send_budget_exhausted`, and it takes the same status for the same reason. Only an explicitly replay-safe operation opts into reset retries. -**An upstream reset observed mid-stream or after a terminal keeps its existing behaviour.** -The passthrough read path still settles a genuine upstream reset as a synthetic 502, and the +**An upstream reset after protocol output or after a terminal keeps its existing behaviour.** +The passthrough read path still settles such a reset as a synthetic 502, and the Codex WebSocket transport still settles `upstream_closed_before_response` (socket closed after the create frame) and `upstream_no_response` (origin never produced an event) as 502 and 504. Those describe something the upstream did after our send, they are the contract the -public server reference already documents, and this release does not move them. +public server reference already documents. Native HTTP Responses has one narrower exception: +the body-consumption preflight may replace the stream once after it parsed `response.created`, +observed no output, tool, unknown or terminal event, and then received a reset-shaped read +rejection. Response headers remain progressive because inspection starts on downstream body +consumption. This positive protocol gate replaces the unsafe raw-byte-count test; a reset before +`response.created` is not enough evidence, and native Chat has no equivalent gate. A downstream +WebSocket turn that fell back to HTTP keeps ordinary progressive SSE instead of entering this +preflight, because withholding `response.created` would make the turn unaddressable to that client. This reclassification is the recorded behaviour change: before it, the pre-header refusal borrowed `upstream_closed_before_response` and its 502, which multiplied the duplicate send the refusal exists to prevent. The distinct code is what keeps the two separable afterwards — both are non-replayable, but only one is ours to restate. +> Decision record: [ADR-3389](../decisions/ADR-3389-ambiguous-connection-reset-replay-boundary.md) + Because the refusal now carries 429, a 429 is no longer sufficient evidence of a provider rate limit. Every same-target replay, key rotation, account rotation and pool-quota recorder that keys on 429 first asks `isNonReplayableResponse`: diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ba387f403e5..4beae517887 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -513,3 +513,32 @@ cover effective wire settings, immutable-route refusals, policy preservation, independent API credentials, unavailable-mode diagnostics and safe probe outcomes. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +## Protocol-gated HTTP stream recovery + +`src/server/responses/combo-stream-preflight.ts` returns native HTTP Responses headers without +waiting for the first event, then inspects the stream when downstream consumption begins. A +reset-shaped read rejection becomes eligible for one replacement only after the shared +`createSseInspector` parser observed `response.created` and no committing payload. +Any output-item event, delta, unknown event or terminal commits the original stream. A reset before +`response.created` is also left on the original failure path. The gate therefore uses parsed +protocol evidence gathered by the deferred body reader, rather than downstream byte-consumption +timing. Upstream WebSocket responses bypass this wrapper so their response-attached quota, stage, +and bounded-relay identity remains intact. A downstream WebSocket turn that falls back to HTTP +also bypasses it: `response.created` must reach that client immediately so the response id remains +addressable and steering or injection can be rejected explicitly. +Native Responses retains physical-send credential admission across a replacement attempt, and +cancellation does not leave a replacement send running. + +`src/server/responses/passthrough-dispatch.ts` spends at most one remaining request send, preserves +the selected credential binding, records the physical send as `connection-reset`, and forces the +replacement through HTTP so a sent WebSocket exchange is never repeated. Non-success, bodyless, +locked, non-replayable or content-type-incompatible replacements are cancelled; the original +bounded prefix and read error then reach the existing failed-tail path. Cancellation and exhausted +send allowance suppress recovery. The replacement is not preflighted again, so a second reset +cannot trigger another send. Native Chat remains outside this recovery because its protocol does +not expose the required `response.created` evidence at the dispatch gate. + +This is a narrow reversal of the no-mid-stream-resend rule recorded in +`devlog/_fin/260703_sse-midstream-reset-tail/00_plan.md`: the old rule remains for every stream +without this positive protocol evidence and for every stream that has committed output. diff --git a/tests/lib/upstream-retry.test.ts b/tests/lib/upstream-retry.test.ts index e8d09b309e3..f8146d9a7cb 100644 --- a/tests/lib/upstream-retry.test.ts +++ b/tests/lib/upstream-retry.test.ts @@ -7,6 +7,7 @@ import { isNonReplayableResponse, UPSTREAM_RESET_REPLAY_REFUSED_CODE, prepareSameTarget429Wait, + refetchAfterProtocolSafeReset, releaseResponseBodyBestEffort, retryBackoffDelayMs, sleepWithHeartbeats, @@ -67,6 +68,54 @@ describe("isConnectionResetError", () => { }); }); +describe("refetchAfterProtocolSafeReset", () => { + test("accepts one compatible replacement for caller-proved protocol-safe reset", async () => { + silenceWarn(); + const calls: string[] = []; + const replacement = new Response("replacement", { + headers: { "content-type": "text/event-stream" }, + }); + const result = await refetchAfterProtocolSafeReset(async recovery => { + calls.push(recovery ?? "none"); + return replacement; + }, bunResetError(), { + acceptResponse: response => response.headers.get("content-type") === "text/event-stream", + }); + expect(result).toBe(replacement); + expect(calls).toEqual(["connection-reset"]); + }); + + test("rejects non-reset errors and incompatible or non-success replacements", async () => { + silenceWarn(); + let calls = 0; + expect(await refetchAfterProtocolSafeReset(async () => { + calls += 1; + return new Response("unused"); + }, new Error("other"))).toBeNull(); + expect(calls).toBe(0); + + const wrongType = Response.json({ error: "wrong wire" }); + expect(await refetchAfterProtocolSafeReset(async () => wrongType, bunResetError(), { + acceptResponse: response => response.headers.get("content-type") === "text/event-stream", + })).toBeNull(); + expect(wrongType.bodyUsed).toBe(true); + + const unavailable = new Response("busy", { status: 503 }); + expect(await refetchAfterProtocolSafeReset(async () => unavailable, bunResetError())).toBeNull(); + expect(unavailable.bodyUsed).toBe(true); + }); + + test("does not dispatch with zero allowance or after cancellation", async () => { + let calls = 0; + const execute = async () => { calls += 1; return new Response("unused"); }; + expect(await refetchAfterProtocolSafeReset(execute, bunResetError(), { attempts: 0 })).toBeNull(); + const abort = new AbortController(); + abort.abort(); + expect(await refetchAfterProtocolSafeReset(execute, bunResetError(), { abortSignal: abort.signal })).toBeNull(); + expect(calls).toBe(0); + }); +}); + describe("sleepWithHeartbeats", () => { test("a non-positive heartbeat interval is clamped instead of spinning forever", async () => { const events: string[] = []; diff --git a/tests/responses/responses-send-budget-counts.test.ts b/tests/responses/responses-send-budget-counts.test.ts index d6119caa0fd..ae545be7073 100644 --- a/tests/responses/responses-send-budget-counts.test.ts +++ b/tests/responses/responses-send-budget-counts.test.ts @@ -81,6 +81,51 @@ function responsesRequest(model: string): Request { }); } +function streamingResponsesRequest(model: string): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model, stream: true, input: "hello" }), + }); +} + +function responseStreamThenReset(payloads: unknown[], error: Error): Response { + let sent = false; + const body = new ReadableStream({ + pull(controller) { + if (!sent) { + sent = true; + if (payloads.length > 0) { + controller.enqueue(new TextEncoder().encode( + payloads.map(payload => `data: ${JSON.stringify(payload)}\n\n`).join(""), + )); + return; + } + } + controller.error(error); + }, + }, { highWaterMark: 0 }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); +} + +function completedStream(text: string): Response { + return new Response(`data: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp-recovered", + status: "completed", + output: [{ + type: "message", + id: "msg-recovered", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })}\n\n`, { headers: { "content-type": "text/event-stream" } }); +} + function alwaysFailing(status: number, message: string): { authorizations: string[] } { const authorizations: string[] = []; globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { @@ -387,6 +432,77 @@ describe("ambiguous reset safety across Responses recovery", () => { expect((await response.json()).error.code).toBe("upstream_reset_replay_refused"); expect(sends).toBe(1); }); + + test("native Responses refetches once after a created-only protocol prefix", async () => { + const config = comboOverTargets(1); + config.providers.t0!.adapter = "openai-responses"; + const authorizations: string[] = []; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); + if (authorizations.length === 1) { + return responseStreamThenReset([{ + type: "response.created", + response: { id: "resp-first", status: "in_progress", output: [] }, + }], Object.assign(new Error("socket connection was closed unexpectedly"), { code: "ECONNRESET" })); + } + return completedStream("recovered once"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(streamingResponsesRequest("t0/model-t0"), config, logCtx); + expect(response.status).toBe(200); + expect(await response.text()).toContain("recovered once"); + expect(authorizations).toEqual(["Bearer sk-t0", "Bearer sk-t0"]); + expect(totalSends(logCtx)).toBe(2); + }); + + test("native Responses does not refetch after a tool item commits the stream", async () => { + const config = comboOverTargets(1); + config.providers.t0!.adapter = "openai-responses"; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return responseStreamThenReset([ + { type: "response.created", response: { id: "resp-first", status: "in_progress", output: [] } }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", id: "call_1", call_id: "call_1", name: "side_effect" }, + }, + ], Object.assign(new Error("socket connection was closed unexpectedly"), { code: "ECONNRESET" })); + }) as typeof fetch; + + const response = await handleResponses( + streamingResponsesRequest("t0/model-t0"), + config, + { model: "", provider: "" }, + ); + const body = await response.text(); + expect(body).toContain("response.output_item.added"); + expect(body).toContain("response.failed"); + expect(sends).toBe(1); + }); + + test("native Responses does not refetch a reset before response.created", async () => { + const config = comboOverTargets(1); + config.providers.t0!.adapter = "openai-responses"; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return responseStreamThenReset([], Object.assign( + new Error("socket connection was closed unexpectedly"), + { code: "ECONNRESET" }, + )); + }) as typeof fetch; + + const response = await handleResponses( + streamingResponsesRequest("t0/model-t0"), + config, + { model: "", provider: "" }, + ); + expect(await response.text()).toContain("response.failed"); + expect(sends).toBe(1); + }); }); describe("ambiguous reset safety after outer recovery", () => { diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 798cce0ef22..0d3da36c640 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -51,6 +51,9 @@ const createdPrefix = new TextEncoder().encode(`data: ${JSON.stringify({ describe("combo stream preflight", () => { test("keeps only lifecycle preamble replayable and treats unknown output conservatively", () => { expect(comboStreamPayloadCommitsOutput({ type: "response.created" })).toBe(false); + expect(comboStreamPayloadCommitsOutput({ + type: "response.created", response: { output: [{ type: "function_call" }] }, + })).toBe(true); expect(comboStreamPayloadCommitsOutput({ type: "response.heartbeat" })).toBe(false); expect(comboStreamPayloadCommitsOutput({ type: "response.failed" })).toBe(false); expect(comboStreamPayloadCommitsOutput({ type: "response.incomplete" })).toBe(false); @@ -538,7 +541,7 @@ describe("combo stream preflight", () => { expect(source.cancelSpy()!.mock.calls).toHaveLength(0); }); - test("replayReadErrors accepts a reconstructed prefix and the same reader.read error", async () => { + test("replayReadErrors exposes protocol-safe reset evidence after response.created", async () => { const readError = new Error("preflight-read-reset"); const source = prefixThenReadError(createdPrefix, readError); const result = await preflightComboStreamResponse( @@ -547,7 +550,9 @@ describe("combo stream preflight", () => { undefined, { replayReadErrors: true }, ); - expect(result.kind).toBe("accepted"); + expect(result.kind).toBe("read-error-before-output"); + if (result.kind !== "read-error-before-output") throw new Error("expected protocol reset evidence"); + expect(result.error).toBe(readError); expect(source.cancelSpy()).toBeDefined(); expect(source.cancelSpy()!.mock.calls).toHaveLength(0); const reader = result.response.body!.getReader(); @@ -559,4 +564,56 @@ describe("combo stream preflight", () => { expect(source.cancelSpy()!.mock.calls).toHaveLength(0); }); + test("replayReadErrors does not expose recovery evidence before response.created", async () => { + const readError = new Error("preflight-read-reset"); + const source = prefixThenReadError(new TextEncoder().encode(": heartbeat\n\n"), readError); + const result = await preflightComboStreamResponse( + source.response, + { model: "m1", provider: "a" }, + undefined, + { replayReadErrors: true }, + ); + expect(result.kind).toBe("accepted"); + const reader = result.response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe(": heartbeat\n\n"); + await expect(reader.read()).rejects.toBe(readError); + }); + + test("replayReadErrors stays committed after a tool item event", async () => { + const readError = new Error("preflight-read-reset"); + const prefix = new TextEncoder().encode( + new TextDecoder().decode(createdPrefix) + + `data: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", id: "call_1", call_id: "call_1", name: "side_effect" }, + })}\n\n`, + ); + const source = prefixThenReadError(prefix, readError); + const result = await preflightComboStreamResponse( + source.response, + { model: "m1", provider: "a" }, + undefined, + { replayReadErrors: true }, + ); + expect(result.kind).toBe("accepted"); + expect(await result.response.body!.getReader().read()).toMatchObject({ done: false }); + }); + + for (const opaque of ["data: {not-json}\n\n", "data: [DONE]\n\n"]) { + test(`replayReadErrors stays committed after opaque payload ${JSON.stringify(opaque.trim())}`, async () => { + const readError = new Error("preflight-read-reset"); + const source = prefixThenReadError(new TextEncoder().encode( + new TextDecoder().decode(createdPrefix) + opaque, + ), readError); + const result = await preflightComboStreamResponse( + source.response, + { model: "m1", provider: "a" }, + undefined, + { replayReadErrors: true }, + ); + expect(result.kind).toBe("accepted"); + }); + } + }); diff --git a/tests/server/fetch-header-timeout.test.ts b/tests/server/fetch-header-timeout.test.ts index 1ce097def08..8409bc56829 100644 --- a/tests/server/fetch-header-timeout.test.ts +++ b/tests/server/fetch-header-timeout.test.ts @@ -141,6 +141,35 @@ describe("#2567 the upstream fetch disables Bun's per-request idle timeout", () expect((calls[0] as { timeout?: number }).timeout).toBe(0); }); + test("providerFetch httpOnly keeps an eligible replacement send on HTTP", async () => { + const { providerFetch } = await import("../../src/server/responses/fetch-helpers"); + const { calls, fetch } = recordingFetch(); + const provider = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + fetch, + } as unknown as Parameters[0]; + const originalWebSocket = globalThis.WebSocket; + let websocketAttempts = 0; + globalThis.WebSocket = class { + constructor() { + websocketAttempts += 1; + throw new Error("replacement must stay on HTTP"); + } + } as unknown as typeof WebSocket; + try { + const response = await providerFetch(provider, "1.4.0", { httpOnly: true })( + "https://chatgpt.com/backend-api/codex/responses", + { method: "POST", body: JSON.stringify({ model: "fixture", stream: true }) }, + ); + expect(await response.text()).toBe("ok"); + } finally { + globalThis.WebSocket = originalWebSocket; + } + expect(calls).toHaveLength(1); + expect(websocketAttempts).toBe(0); + }); + test("fetchWithHeaderTimeout passes timeout: 0 while keeping its abort signal", async () => { const server = startHeaderEchoServer(); const seen: RequestInit[] = [];