From 4072e47e4dff7f3508bc18a57d8cbc049a11207f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 22:21:32 +0900 Subject: [PATCH 01/22] Fix native Responses prelude EOF recovery --- .../fr/reference/configuration/providers.md | 2 + .../ja/reference/configuration/providers.md | 2 + .../ko/reference/configuration/providers.md | 2 + .../docs/reference/configuration/providers.md | 2 + .../ru/reference/configuration/providers.md | 2 + .../tr/reference/configuration/providers.md | 2 + .../reference/configuration/providers.md | 2 + .../reference/configuration/providers.md | 2 + src/lib/upstream-retry.ts | 18 ++- .../responses/combo-stream-preflight.ts | 56 +++++++-- src/server/responses/passthrough-dispatch.ts | 4 +- .../transports/responses-combo-boundaries.md | 5 +- structure/transports/responses-failover.md | 1 + .../responses-canonical-nonstream.test.ts | 31 +++++ tests/routing/combo-stream-preflight.test.ts | 110 ++++++++++++++++++ 15 files changed, 222 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index c4bd73f020c..aae4ecba127 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,6 +188,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Pendant l’inspection, des commentaires SSE maintiennent la connexion sans compter comme sortie du modèle. + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 3459c6b2caf..a8922292827 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,6 +180,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。検査中の SSE コメントは接続を維持し、モデル出力には数えません。 + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index d59609c3db8..5b572a05db5 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,6 +180,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 검사 중에는 SSE 주석이 연결을 유지하며 모델 출력으로 간주되지 않습니다. + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d9cca538c98..8cde6952778 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -442,6 +442,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. While inspection holds the stream, SSE comments keep the connection alive without counting as model output. + Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, and Gemini-3.7-Flash support `low`, `medium`, `high`, `xhigh`, and `max`. Ladders remain diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 4ffac816f4d..b9bd76486b6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,6 +193,8 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Во время проверки SSE-комментарии поддерживают соединение и не считаются выводом модели. + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 5c9a70f54a1..a5c6ec53340 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,6 +192,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. İnceleme sırasında SSE yorumları bağlantıyı canlı tutar ve model çıktısı sayılmaz. + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 41890df5aa3..aec8816affe 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,6 +182,8 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。检查期间,SSE 注释保持连接,且不计为模型输出。 + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 80b14de4608..6718a12f451 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,6 +153,8 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。檢查期間,SSE 註解會維持連線,且不算作模型輸出。 + LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 0c478c73532..49f825c3b35 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -913,6 +913,14 @@ export async function fetchWithTransientRetry( export type ProtocolSafeRefetch = (signal?: AbortSignal) => Promise; +/** A Responses SSE stream ended after only uncommitted protocol control frames. */ +export class ProtocolPreOutputEof extends Error { + constructor() { + super("upstream Responses stream ended before output or a terminal event"); + this.name = "ProtocolPreOutputEof"; + } +} + export interface ProtocolSafeRefetchOptions extends ResetRetryOptions { /** The replacement must match the response contract already selected for the client. */ acceptResponse?: (response: Response) => boolean; @@ -927,7 +935,7 @@ export interface ProtocolSafeRefetchOptions extends ResetRetryOptions { } /** - * Attempt ONE caller-authorized replacement of a stream that died after the response head. + * Attempt ONE caller-authorized replacement of a reset or pre-output EOF after the response head. * * The caller owns the proof that nothing was observed -- it comes from protocol inspection, * not from this module -- and owns the physical-send budget. What lives here is the part that @@ -940,12 +948,14 @@ export async function refetchAfterProtocolSafeReset( err: unknown, opts: ProtocolSafeRefetchOptions = {}, ): Promise { - if (!isConnectionResetError(err) || opts.abortSignal?.aborted || opts.attempts === 0) return null; + if ((!isConnectionResetError(err) && !(err instanceof ProtocolPreOutputEof)) + || 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) + ")" : ""; + const failure = err instanceof ProtocolPreOutputEof ? "pre-output stream EOF" : "pre-output stream reset"; if (opts.authorize && !opts.authorize()) { - console.warn("[upstream-retry] post-header reset replacement refused" + label + "; preserving original stream error"); + console.warn("[upstream-retry] " + failure + " replacement refused" + label + "; preserving original stream"); return null; } let replacement: Response; @@ -965,7 +975,7 @@ export async function refetchAfterProtocolSafeReset( console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error"); return null; } - console.warn("[upstream-retry] pre-output stream reset" + label + "; using one replacement stream"); + console.warn("[upstream-retry] " + failure + label + "; using one replacement stream"); return replacement; } diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index a0dcb42c239..9404d0bb3d4 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -10,10 +10,12 @@ import { createSseInspector } from "../relay"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; import { REQUIRED_TOOL_CONTRACT_CODE } from "../../lib/errors"; import { requiredToolReasoningPrelude } from "./combo-required-tool-stream"; +import { ProtocolPreOutputEof } from "../../lib/upstream-retry"; const COMBO_STREAM_PREFLIGHT_MAX_BYTES = MAX_CLIENT_SSE_FRAME_BYTES; // Required-tool reasoning may delay delivery, but never for an unbounded/trickling prelude. const COMBO_REQUIRED_TOOL_REASONING_HOLD_MS = 1_000; +const RESET_PREFLIGHT_HEARTBEAT_MS = 15_000; // Keep retained object count proportional to the same byte budget used by the // shared SSE framer. Tiny or empty upstream reads must not bypass the byte cap. const COMBO_STREAM_PREFLIGHT_MAX_CHUNKS = Math.max( @@ -281,7 +283,9 @@ export type ComboStreamPreflightResult = * a projected terminal can treat this exactly as `accepted`, which is what it was before * the stage became observable. */ - | { kind: "read-error"; response: Response; error: unknown; stage: RequestFailureStage }; + | { kind: "read-error"; response: Response; error: unknown; stage: RequestFailureStage } + /** A clean EOF after control frames, retained for an explicitly opted-in recovery owner. */ + | { kind: "pre-output-eof"; response: Response; error: ProtocolPreOutputEof; stage: RequestFailureStage }; /** * Buffer a Responses SSE only until the request becomes unsafe to replay or reaches a @@ -294,7 +298,7 @@ export async function preflightComboStreamResponse( response: Response, logCtx: RequestLogContext, retryableTerminal: (payload: unknown) => boolean = retryableZeroOutputTerminal, - options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean; requiredToolPrelude?: boolean; signal?: AbortSignal }, + options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean; replayCleanEof?: boolean; requiredToolPrelude?: boolean; signal?: AbortSignal }, ): Promise { const contentType = response.headers.get("content-type"); const isEventStream = hasResponseMediaType(response.headers, "text/event-stream") @@ -376,6 +380,14 @@ export async function preflightComboStreamResponse( } if (next.done) { inspector.finish(); + if (options?.replayCleanEof && !outputCommitted && terminalStatus === undefined) { + return { + kind: "pre-output-eof", + response: replayBufferedResponse(response, reader, buffered), + error: new ProtocolPreOutputEof(), + stage: observedResponsesStage({ outputCommitted, terminalStatus, responseCreated }), + }; + } } else { if (bufferedBytes + next.value.byteLength > COMBO_STREAM_PREFLIGHT_MAX_BYTES) { // Keep the cap about memory the preflight allocates. The upstream chunk already exists; @@ -420,6 +432,7 @@ export async function preflightComboStreamResponse( export type ProtocolSafeResetRecovery = ( error: unknown, stage: RequestFailureStage, + signal: AbortSignal, ) => Promise; /** @@ -434,13 +447,19 @@ export function deferProtocolSafeResetRecovery( response: Response, logCtx: RequestLogContext, recover: ProtocolSafeResetRecovery, - options?: { allowMissingContentType?: boolean }, + options?: { allowMissingContentType?: boolean; heartbeatIntervalMs?: number }, ): Response { if (!response.body) return response; let reader: ReadableStreamDefaultReader | undefined; let initialization: Promise | undefined; + let initializationResult: { kind: "ready" } | { kind: "failed"; error: unknown } | undefined; + let notifyInitialization: (() => void) | undefined; let closed = false; + const preflightAbort = new AbortController(); + const requestedHeartbeatMs = options?.heartbeatIntervalMs; + const heartbeatMs = requestedHeartbeatMs !== undefined && Number.isFinite(requestedHeartbeatMs) && requestedHeartbeatMs > 0 + ? requestedHeartbeatMs : RESET_PREFLIGHT_HEARTBEAT_MS; const cancelBody = (body: ReadableStream | null, reason?: unknown): void => { try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ } @@ -450,11 +469,12 @@ export function deferProtocolSafeResetRecovery( response, logCtx, () => false, - { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true }, + { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true, + replayCleanEof: true, signal: preflightAbort.signal }, ); let selected = preflight.response; - if (preflight.kind === "read-error") { - const replacement = await recover(preflight.error, preflight.stage); + if (!closed && (preflight.kind === "read-error" || preflight.kind === "pre-output-eof")) { + const replacement = await recover(preflight.error, preflight.stage, preflightAbort.signal); if (replacement) { cancelBody(selected.body, "using protocol-safe replacement stream"); selected = replacement; @@ -470,9 +490,22 @@ export function deferProtocolSafeResetRecovery( const body = new ReadableStream({ async pull(controller) { try { - initialization ??= initialize(); - await initialization; + initialization ??= initialize().then( + () => { initializationResult = { kind: "ready" }; notifyInitialization?.(); }, + error => { initializationResult = { kind: "failed", error }; notifyInitialization?.(); }, + ); + // Keep only one waiter on the initialization; racing it afresh for every heartbeat + // would retain one promise callback per interval until a quiet upstream settles. + const initialized = initializationResult !== undefined || await new Promise(resolve => { + const timer = setTimeout(() => { notifyInitialization = undefined; resolve(false); }, heartbeatMs); + notifyInitialization = () => { clearTimeout(timer); notifyInitialization = undefined; resolve(true); }; + }); if (closed) return; + if (!initialized) { + controller.enqueue(new TextEncoder().encode(": keepalive\n\n")); + return; + } + if (initializationResult?.kind === "failed") throw initializationResult.error; if (!reader) { closed = true; controller.close(); @@ -499,6 +532,7 @@ export function deferProtocolSafeResetRecovery( cancel(reason) { if (closed) return; closed = true; + preflightAbort.abort(reason); if (reader) { try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } try { reader.releaseLock(); } catch { /* already released */ } @@ -507,10 +541,8 @@ export function deferProtocolSafeResetRecovery( // Nothing has read the upstream yet, so this body is still ours to cancel. cancelBody(response.body, reason); } - // A cancel while the preflight is mid-flight falls through deliberately. That body is - // locked by the preflight's own reader, so cancelling it here would reject and be - // swallowed; `initialize` sees `closed` when it settles and releases whichever body it - // ended up selecting, which is the one that actually has to be let go. + // Aborting cancels the preflight's locked reader or the in-flight replacement. Once + // initialization settles, it releases whichever body was selected. }, }, { highWaterMark: 0 }); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 2bb3e78499c..3142e6afbf2 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1920,11 +1920,11 @@ export async function preparePassthroughExchange( upstreamResponse = deferProtocolSafeResetRecovery( upstreamResponse, { model: logCtx.model, provider: logCtx.provider }, - (error, stage) => refetchAfterProtocolSafeReset( + (error, stage, signal) => refetchAfterProtocolSafeReset( (signal = upstream.signal) => sendAmbiguousReplacement(signal).then(adoptObservedResponse), error, { - abortSignal: upstream.signal, + abortSignal: AbortSignal.any([upstream.signal, signal]), label: safeHostLabel(request.url), destination: errorDestinationLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), // The whole decision, including the stage the preflight observed and the grant the diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 67b78a5a769..339a5354da1 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -136,7 +136,10 @@ about the same bytes. With `replayReadErrors`, the preflight reports the stage i `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the resend gate decides. A `response.created` whose snapshot already carries output items is not a -prelude. +prelude. A clean EOF before output or a terminal is offered only to the native reset recovery +owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. +The deferred native hold emits bounded SSE comment heartbeats without counting them as output, +and downstream cancellation aborts the hold or an in-flight replacement. - a non-control Responses event (apart from the held mandatory-tool reasoning prelude above) begins client-visible output or a tool/action item, after which the target is committed and cross-target replay is forbidden; diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index d2df5ad2a9e..a5ad274856d 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -82,6 +82,7 @@ request's execution budget, so a combo child that derives its own scope draws on counter rather than holding a second. A replacement never widens a send budget: it still has to fit inside the allowance the leg already had, and it is charged to the same counter every other send goes through; initial Combo bookings follow the [prepaid-send contract](responses-spend.md#prepaid-initial-sends). +For native Responses SSE, clean EOF after uncommitted lifecycle events uses the same grant and send budget; see the [combo stream boundary](responses-combo-boundaries.md). Generic translated dispatch in `src/server/responses/adapter-dispatch.ts` asks the same pre-header gate for initial and rebuilt sends, sharing the replacement grant and charging each physical send once to the existing request/workflow budgets. Adapter-owned transports and translated post-header failures are excluded. Coverage: `tests/responses/responses-translated-reset.test.ts`. The number of replacements is the request's as well. A leg reads it from `route.provider`, which credential rotation, OAuth refresh, transport resolution and each combo target reassign inside one diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 913454a29e7..58acfedd02e 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -350,6 +350,37 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 expect(outbound.map(body => body.stream)).toEqual([true, true]); }); + test.each([ + ["self-contained buffered", false, false, true, false, 2], + ["self-contained streaming", true, false, true, false, 2], + ["no opt-in", false, false, false, false, 1], + ["stored request", false, true, true, false, 1], + ["persistent EOF", false, false, true, true, 2], + ] as const)("clean prelude EOF recovery: %s", async (_name, stream, store, enabled, persistent, expectedSends) => { + let sends = 0; + globalThis.fetch = (async () => { + sends++; + const first = sseEvent("response.created", { response: { id: "resp_first", status: "in_progress", output: [] } }); + const completed = sseEvent("response.completed", { response: { + id: "resp_replacement", status: "completed", model: "gpt-5.6-sol", output: [], + } }); + return new Response(sends === 1 || persistent ? first : completed, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const resetConfig: OcxConfig = { + ...config, providers: { openai: { ...provider, ...(enabled ? { retryOnReset: {} } : {}) } }, + }; + const response = await call(requestBody(stream, store), {}, resetConfig); + const text = await response.text(); + expect(sends).toBe(expectedSends); + if (expectedSends === 2 && !persistent) { + expect(response.status).toBe(200); + expect(text).toContain("resp_replacement"); + expect(text).not.toContain("resp_first"); + } else { + expect(text).not.toContain("resp_replacement"); + } + }); + test.each([ ["clean EOF", sseEvent("response.created", { response: { id: "resp_eof", status: "in_progress", output: [] } })], ["malformed payload", [ diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 97d9f905441..b79a5b5e0c0 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -697,6 +697,116 @@ describe("combo stream preflight", () => { expect(seen).toEqual([]); }); + test("a clean prelude-only EOF offers one replacement before relaying the prefix", async () => { + const original = sse({ type: "response.created", response: { id: "first", output: [] } }); + const stages: RequestFailureStage[] = []; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async (_error, stage) => { + stages.push(stage); + return sse({ type: "response.completed", response: { id: "replacement", status: "completed" } }); + }); + const text = await wrapped.text(); + expect(stages).toEqual(["protocol-prelude"]); + expect(text).toContain("replacement"); + expect(text).not.toContain('"first"'); + }); + + test("declined clean EOF recovery preserves the original prelude", async () => { + const original = sse({ type: "response.created", response: { id: "first", output: [] } }); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return null; + }); + expect(await wrapped.text()).toContain('"first"'); + expect(calls).toBe(1); + }); + + test.each([ + [{ type: "response.completed", response: { status: "completed", output: [] } }], + [{ type: "response.output_text.delta", delta: "visible" }], + [{ error: { type: "server_error", message: "fixture" } }], + [{ type: "response.created", response: { output: [{ type: "message" }] } }], + ])("terminal, output or explicit error never offers EOF recovery (%j)", async payload => { + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(sse(payload), { model: "m1", provider: "a" }, async () => { + calls++; + return null; + }); + await wrapped.text(); + expect(calls).toBe(0); + }); + + test.each(["data: {not-json}\n\n", 'data: {"type":"response.output'])( + "opaque or truncated payload never offers EOF recovery", async payload => { + let calls = 0; + const source = new Response(payload, { headers: { "content-type": "text/event-stream" } }); + const wrapped = deferProtocolSafeResetRecovery(source, { model: "m1", provider: "a" }, async () => { calls++; return null; }); + expect(await wrapped.text()).toBe(payload); + expect(calls).toBe(0); + }, + ); + + test("downstream cancellation while the prelude is held never starts a replacement", async () => { + let source!: ReadableStreamDefaultController; + let onPull!: () => void; + const pulled = new Promise(resolve => { onPull = resolve; }); + const upstream = new ReadableStream({ + start(controller) { source = controller; }, + pull() { onPull(); }, + }); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(new Response(upstream, { headers: { "content-type": "text/event-stream" } }), + { model: "m1", provider: "a" }, async () => { calls++; return null; }); + const reader = wrapped.body!.getReader(); + const pending = reader.read(); + await pulled; + await reader.cancel(); + try { source.close(); } catch { /* a correct wrapper may already cancel the source */ } + await pending; + await Bun.sleep(0); + expect(calls).toBe(0); + }); + + test("downstream cancellation aborts a replacement already in flight", async () => { + let started!: (signal: AbortSignal) => void; + const recoveryStarted = new Promise(resolve => { started = resolve; }); + const wrapped = deferProtocolSafeResetRecovery( + sse({ type: "response.created", response: { id: "first", output: [] } }), + { model: "m1", provider: "a" }, + async (_error, _stage, signal) => { + started(signal); + return new Promise(resolve => signal.addEventListener("abort", () => resolve(null), { once: true })); + }, + ); + const reader = wrapped.body!.getReader(); + const pending = reader.read(); + const signal = await recoveryStarted; + await reader.cancel(); + await pending; + expect(signal.aborted).toBe(true); + }); + + test("held prelude sends bounded keepalive comments while upstream is silent", async () => { + let source!: ReadableStreamDefaultController; + const upstream = new ReadableStream({ start(controller) { source = controller; }, pull() {} }); + const wrapped = deferProtocolSafeResetRecovery(new Response(upstream, { headers: { "content-type": "text/event-stream" } }), + { model: "m1", provider: "a" }, async () => null, { heartbeatIntervalMs: 5 }); + const reader = wrapped.body!.getReader(); + try { + for (let i = 0; i < 2; i++) { + const result = await Promise.race([reader.read(), Bun.sleep(150).then(() => null)]); + expect(result).not.toBeNull(); + if (result && !result.done) expect(new TextDecoder().decode(result.value)).toMatch(/^:.*\n\n$/); + } + source.enqueue(new TextEncoder().encode('data: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n')); + source.close(); + const result = await reader.read(); + expect(result.done).toBe(false); + if (!result.done) expect(new TextDecoder().decode(result.value)).toContain("response.completed"); + expect((await reader.read()).done).toBe(true); + } finally { await reader.cancel(); } + }); + test("a response.created carrying output is not a prelude", () => { expect(comboStreamPayloadCommitsOutput({ type: "response.created", response: { id: "r1", output: [] }, From 9112b9c2d0f8b7a08826b14b5aab756699be5e0b Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:44:26 +0900 Subject: [PATCH 02/22] Preserve lifecycle snapshots before EOF recovery --- .../responses/combo-stream-preflight.ts | 12 ++++---- .../transports/responses-combo-boundaries.md | 4 +-- tests/routing/combo-stream-preflight.test.ts | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 9404d0bb3d4..4dfbe3b341f 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -151,14 +151,14 @@ 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") { - // A created event is a control frame only while its snapshot is empty. An origin that - // resumes a turn can put completed items in it, and treating that as a prelude would let - // a replacement re-emit output the caller already received. + if (PRE_OUTPUT_CONTROL_EVENTS.has(type)) { + // Lifecycle snapshots may already carry output or an error. Neither can be discarded + // for a replacement, even if the event name otherwise describes only a prelude. 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; + const snapshot = response as { output?: unknown; error?: unknown }; + if (snapshot.output !== undefined && (!Array.isArray(snapshot.output) || snapshot.output.length > 0)) return true; + if (snapshot.error != null) return true; } } return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 339a5354da1..dd61d743de9 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -135,8 +135,8 @@ Native post-header reset recovery shares that boundary, because it is asking the about the same bytes. With `replayReadErrors`, the preflight reports the stage it observed — `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the -resend gate decides. A `response.created` whose snapshot already carries output items is not a -prelude. A clean EOF before output or a terminal is offered only to the native reset recovery +resend gate decides. A lifecycle snapshot with output items, malformed output, or a non-null error +commits the original stream. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. The deferred native hold emits bounded SSE comment heartbeats without counting them as output, and downstream cancellation aborts the hold or an in-flight replacement. diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index b79a5b5e0c0..de09b773e98 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -817,4 +817,34 @@ describe("combo stream preflight", () => { })).toBe(true); }); + for (const type of ["response.created", "response.in_progress", "response.queued", "response.heartbeat"]) { + for (const [description, snapshot] of [ + ["output", { output: [{ type: "message", role: "assistant" }] }], + ["malformed output", { output: { type: "message" } }], + ["error", { output: [], error: { code: "server_error", message: "original failure" } }], + ] as const) { + test(`${type} snapshot with ${description} preserves the original at EOF`, async () => { + const original = sse({ type, response: snapshot }); + const expected = await original.clone().text(); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + expect(await wrapped.text()).toBe(expected); + expect(calls).toBe(0); + }); + } + test(`${type} with an empty snapshot still permits EOF recovery`, async () => { + let calls = 0; + const original = sse({ type, response: { output: [], error: null } }); + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + expect(await wrapped.text()).toContain('"replacement"'); + expect(calls).toBe(1); + }); + } + }); From befaa3111649fb417f94955a08beddd9ef180d06 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:04:03 +0900 Subject: [PATCH 03/22] Preserve ambiguous SSE frames before recovery --- src/server/relay.ts | 4 + .../responses/combo-stream-preflight.ts | 38 +++++++++ .../transports/responses-combo-boundaries.md | 4 +- tests/routing/combo-stream-preflight.test.ts | 81 +++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/server/relay.ts b/src/server/relay.ts index 0e29bd85c85..8135fa73af9 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -1134,6 +1134,8 @@ export type SseInspectorHandlers = { * with an empty `output`. */ onParsedPayload?: (payload: unknown) => void; + /** Opt-in access to the original bounded frame before payload classification. */ + onFrame?: (frame: string) => void; /** * A complete data payload that did not parse as a JSON event, `[DONE]` included. * @@ -1467,6 +1469,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector const frame = takeCandidate(); if (reported && !handlers.onCompletedResponse && !handlers.onTerminalResponse) return; const decoded = decoder!.decode(frame); + try { handlers.onFrame?.(decoded); } catch { /* inspection must never throw into the pump */ } scanPayload(sseDataPayload(decoded), sourceBytes); }; @@ -1524,6 +1527,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector if (!discardingOversizedFrame && candidateBytes > 0 && !reported) { const sourceBytes = candidateBytes; const decoded = decoder!.decode(takeCandidate()); + try { handlers.onFrame?.(decoded); } catch { /* inspection must never throw into the pump */ } scanPayload(decoded.trim() ? sseDataPayload(decoded) : null, sourceBytes); } } finally { diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 4dfbe3b341f..04f3f6651e4 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -7,6 +7,7 @@ import type { RequestFailureStage } from "../../lib/request-failure-model"; import type { RequestLogContext } from "../request-log"; import { carryResponseToolOwnership } from "./response-tool-ownership"; import { createSseInspector } from "../relay"; +import { sseDataPayload } from "../sse-payload-rewrite"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; import { REQUIRED_TOOL_CONTRACT_CODE } from "../../lib/errors"; import { requiredToolReasoningPrelude } from "./combo-required-tool-stream"; @@ -46,6 +47,33 @@ const RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS = new Set([ "upstream_stall_timeout", ]); +// SSE uses the last event field, with an empty value resetting it to the default. +// A mismatched or unreadable frame is not a proven lifecycle prelude. +function ambiguousSseFrame(frame: string): boolean { + let event: string | undefined; + let hasNonComment = false; + for (const line of frame.split(/\r\n|\r|\n/)) { + if (!line || line.startsWith(":")) continue; + hasNonComment = true; + const colon = line.indexOf(":"); + const field = colon < 0 ? line : line.slice(0, colon); + if (field === "event") { + const value = colon < 0 ? "" : line.slice(colon + 1); + event = value.startsWith(" ") ? value.slice(1) : value; + } + } + const data = sseDataPayload(frame); + if (data === null || data === "") return hasNonComment; + if (!event) return false; + try { + const payload: unknown = JSON.parse(data); + return !payload || typeof payload !== "object" || Array.isArray(payload) + || (payload as { type?: unknown }).type !== event; + } catch { + return true; + } +} + function bareErrorStatus(payload: unknown): number | undefined { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; const event = payload as Record; @@ -321,6 +349,10 @@ export async function preflightComboStreamResponse( let holdDeadline: Promise<"hold-expired"> | undefined; const inspector = createSseInspector({ logCtx, + onFrame: frame => { + if (terminalStatus === undefined && !outputCommitted && !retryableTerminalPayload + && ambiguousSseFrame(frame)) outputCommitted = true; + }, // A payload the inspector could not parse still reached this proxy, and it may be output. // Committing on it is what keeps an unreadable frame from reading as an empty prelude. onOpaquePayload: () => { outputCommitted = true; }, @@ -371,10 +403,16 @@ export async function preflightComboStreamResponse( next = observed; } catch (error) { if (!options?.replayReadErrors) throw error; + // A rejected read can leave an output, terminal or opaque frame buffered + // in the inspector. Flush it before deciding whether recovery is safe. + inspector.finish(); // 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. const replay = replayBufferedResponse(response, reader, buffered); + if (outputCommitted || terminalStatus !== undefined || retryableTerminalPayload) { + return { kind: "accepted", response: replay }; + } const stage = observedResponsesStage({ outputCommitted, terminalStatus, responseCreated }); return { kind: "read-error", response: replay, error, stage }; } diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index dd61d743de9..12e85f54dcc 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -136,7 +136,9 @@ about the same bytes. With `replayReadErrors`, the preflight reports the stage i `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the resend gate decides. A lifecycle snapshot with output items, malformed output, or a non-null error -commits the original stream. A clean EOF before output or a terminal is offered only to the native reset recovery +commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, +or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and +read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. The deferred native hold emits bounded SSE comment heartbeats without counting them as output, and downstream cancellation aborts the hold or an in-flight replacement. diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index de09b773e98..874542d85f4 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -807,6 +807,87 @@ describe("combo stream preflight", () => { } finally { await reader.cancel(); } }); + test.each([ + ['event: error\ndata: {"type":"response.created"}\n\n'], + ['event: response.output_text.delta\ndata: {"type":"response.created"}\n\n'], + ['event: response.created\nevent: error\ndata: {"type":"response.created"}\n\n'], + ['event: error\ndata: {"type":"response.failed","response":{"status":"failed"}}\n\n'], + ['event: error\ndata:\n\n'], + ])("conflicting or opaque SSE frames preserve original bytes (%s)", async frame => { + const bytes = new TextEncoder().encode(frame); + let offset = 0; + const source = new ReadableStream({ + pull(controller) { + if (offset === bytes.length) return controller.close(); + controller.enqueue(bytes.slice(offset, ++offset)); + }, + }); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery( + new Response(source, { headers: { "content-type": "text/event-stream" } }), + { model: "m1", provider: "a" }, async () => { calls++; return sse({ type: "response.completed" }); }, + ); + expect(await wrapped.text()).toBe(frame); + expect(calls).toBe(0); + }); + + test.each([ + 'data: {"type":"response.created"}\n\n', + 'event: error\nevent: response.created\ndata: {"type":"response.created"}\n\n', + 'event:\ndata: {"type":"response.created"}\n\n', + ])("ordinary prelude event semantics still allow EOF recovery (%s)", async frame => { + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery( + new Response(frame, { headers: { "content-type": "text/event-stream" } }), + { model: "m1", provider: "a" }, async () => { calls++; return sse({ type: "response.completed", response: { id: "replacement" } }); }, + ); + expect(await wrapped.text()).toContain('"replacement"'); + expect(calls).toBe(1); + }); + + test("a retryable bare error remains authoritative over a later conflicting frame in the same chunk", async () => { + const body = 'data: {"type":"error","error":{"message":"first"}}\n\n' + + 'event: error\ndata: {"type":"response.created"}\n\n'; + const result = await preflightComboStreamResponse( + new Response(body, { headers: { "content-type": "text/event-stream" } }), + { model: "m1", provider: "a" }, + ); + expect(result.kind).toBe("failed"); + expect(result.response.status).toBe(502); + }); + + test.each([ + 'data: {"type":"response.output_text.delta","delta":"original"}', + 'data: {"type":"error","error":{"message":"original"}}', + 'data: {"type":"response.completed","response":{"status":"completed"}}', + 'data: {"type":"response.output', + 'data:', + 'event: error', + ])("read rejection retains unfinished unsafe frame and the same error (%s)", async frame => { + const readError = new Error("original reset"); + const prefix = new TextEncoder().encode(frame); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery( + prefixThenReadError(prefix, readError).response, { model: "m1", provider: "a" }, + async () => { calls++; return sse({ type: "response.completed", response: { id: "replacement" } }); }, + ); + const reader = wrapped.body!.getReader(); + expect((await reader.read()).value).toEqual(prefix); + await expect(reader.read()).rejects.toBe(readError); + expect(calls).toBe(0); + }); + + test("read rejection after an unfinished empty control still allows recovery", async () => { + const prefix = new TextEncoder().encode('data: {"type":"response.created","response":{"output":[]}}'); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery( + prefixThenReadError(prefix, new Error("reset")).response, { model: "m1", provider: "a" }, + async () => { calls++; return sse({ type: "response.completed", response: { id: "replacement" } }); }, + ); + expect(await wrapped.text()).toContain('"replacement"'); + expect(calls).toBe(1); + }); + test("a response.created carrying output is not a prelude", () => { expect(comboStreamPayloadCommitsOutput({ type: "response.created", response: { id: "r1", output: [] }, From 0111a234fc42f64f5496ec3e2665c90bd40d3316 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:09:53 +0900 Subject: [PATCH 04/22] Clarify native stream keepalive scope --- .../src/content/docs/fr/reference/configuration/providers.md | 2 +- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- .../src/content/docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/reference/configuration/providers.md | 2 +- .../content/docs/zh-cn/reference/configuration/providers.md | 2 +- .../content/docs/zh-tw/reference/configuration/providers.md | 2 +- structure/transports/responses-combo-boundaries.md | 5 +++-- 9 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index aae4ecba127..15753c4f2af 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,7 +188,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Pendant l’inspection, des commentaires SSE maintiennent la connexion sans compter comme sortie du modèle. +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a8922292827..cbd1186483e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,7 +180,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。検査中の SSE コメントは接続を維持し、モデル出力には数えません。 +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 5b572a05db5..e5c3dbdcf40 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,7 +180,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 검사 중에는 SSE 주석이 연결을 유지하며 모델 출력으로 간주되지 않습니다. +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8cde6952778..030c0c18547 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -442,7 +442,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. While inspection holds the stream, SSE comments keep the connection alive without counting as model output. +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Direct streams can receive SSE keepalive comments during inspection; combo preflight holds these comments until it selects a response. Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index b9bd76486b6..d811d072f51 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,7 +193,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Во время проверки SSE-комментарии поддерживают соединение и не считаются выводом модели. +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index a5c6ec53340..912ac9511f6 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,7 +192,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. İnceleme sırasında SSE yorumları bağlantıyı canlı tutar ve model çıktısı sayılmaz. +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index aec8816affe..b694a6fcf64 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,7 +182,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。检查期间,SSE 注释保持连接,且不计为模型输出。 +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 6718a12f451..e171c95959c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,7 +153,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。檢查期間,SSE 註解會維持連線,且不算作模型輸出。 +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 12e85f54dcc..91311317a78 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -140,8 +140,9 @@ commits the original stream. An SSE event name that conflicts with its JSON type or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. -The deferred native hold emits bounded SSE comment heartbeats without counting them as output, -and downstream cancellation aborts the hold or an in-flight replacement. +The deferred native hold emits bounded SSE comment heartbeats without counting them as output. +Direct streams can receive them during the hold; combo preflight buffers them until selection. +Downstream cancellation aborts the hold or an in-flight replacement. - a non-control Responses event (apart from the held mandatory-tool reasoning prelude above) begins client-visible output or a tool/action item, after which the target is committed and cross-target replay is forbidden; From 3643f7bf858787aa5e9ce2fdac7ad2674f546ed5 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:31:40 +0900 Subject: [PATCH 05/22] Preserve preflight activity and lifecycle errors --- .../responses/combo-stream-preflight.ts | 37 +++-- src/server/responses/passthrough-dispatch.ts | 2 +- .../transports/responses-combo-boundaries.md | 10 +- tests/routing/combo-stream-preflight.test.ts | 134 +++++++++++++++++- 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 04f3f6651e4..bf57f179482 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -182,11 +182,13 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { if (PRE_OUTPUT_CONTROL_EVENTS.has(type)) { // Lifecycle snapshots may already carry output or an error. Neither can be discarded // for a replacement, even if the event name otherwise describes only a prelude. - const response = (payload as { response?: unknown }).response; + const event = payload as { response?: unknown; error?: unknown }; + if (event.error != null) return true; + const response = event.response; if (response && typeof response === "object" && !Array.isArray(response)) { - const snapshot = response as { output?: unknown; error?: unknown }; + const snapshot = response as { output?: unknown; error?: unknown; last_error?: unknown }; if (snapshot.output !== undefined && (!Array.isArray(snapshot.output) || snapshot.output.length > 0)) return true; - if (snapshot.error != null) return true; + if (snapshot.error != null || snapshot.last_error != null) return true; } } return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); @@ -326,7 +328,7 @@ export async function preflightComboStreamResponse( response: Response, logCtx: RequestLogContext, retryableTerminal: (payload: unknown) => boolean = retryableZeroOutputTerminal, - options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean; replayCleanEof?: boolean; requiredToolPrelude?: boolean; signal?: AbortSignal }, + options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean; replayCleanEof?: boolean; requiredToolPrelude?: boolean; signal?: AbortSignal; onUpstreamActivity?: () => void }, ): Promise { const contentType = response.headers.get("content-type"); const isEventStream = hasResponseMediaType(response.headers, "text/event-stream") @@ -427,6 +429,7 @@ export async function preflightComboStreamResponse( }; } } else { + if (next.value.byteLength > 0) options?.onUpstreamActivity?.(); if (bufferedBytes + next.value.byteLength > COMBO_STREAM_PREFLIGHT_MAX_BYTES) { // Keep the cap about memory the preflight allocates. The upstream chunk already exists; // copying it before committing would transiently exceed the boundary for no @@ -485,7 +488,7 @@ export function deferProtocolSafeResetRecovery( response: Response, logCtx: RequestLogContext, recover: ProtocolSafeResetRecovery, - options?: { allowMissingContentType?: boolean; heartbeatIntervalMs?: number }, + options?: { allowMissingContentType?: boolean; heartbeatIntervalMs?: number; keepalive?: "periodic" | "upstream-activity" | "none" }, ): Response { if (!response.body) return response; @@ -493,6 +496,7 @@ export function deferProtocolSafeResetRecovery( let initialization: Promise | undefined; let initializationResult: { kind: "ready" } | { kind: "failed"; error: unknown } | undefined; let notifyInitialization: (() => void) | undefined; + let activityPending = false; let closed = false; const preflightAbort = new AbortController(); const requestedHeartbeatMs = options?.heartbeatIntervalMs; @@ -508,7 +512,11 @@ export function deferProtocolSafeResetRecovery( logCtx, () => false, { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true, - replayCleanEof: true, signal: preflightAbort.signal }, + replayCleanEof: true, signal: preflightAbort.signal, + onUpstreamActivity: options?.keepalive === "upstream-activity" ? () => { + activityPending = true; + notifyInitialization?.(); + } : undefined }, ); let selected = preflight.response; if (!closed && (preflight.kind === "read-error" || preflight.kind === "pre-output-eof")) { @@ -534,12 +542,18 @@ export function deferProtocolSafeResetRecovery( ); // Keep only one waiter on the initialization; racing it afresh for every heartbeat // would retain one promise callback per interval until a quiet upstream settles. - const initialized = initializationResult !== undefined || await new Promise(resolve => { - const timer = setTimeout(() => { notifyInitialization = undefined; resolve(false); }, heartbeatMs); - notifyInitialization = () => { clearTimeout(timer); notifyInitialization = undefined; resolve(true); }; - }); + const keepalive = options?.keepalive ?? "periodic"; + if (initializationResult === undefined && !(keepalive === "upstream-activity" && activityPending)) { + await new Promise(resolve => { + const timer = keepalive === "periodic" + ? setTimeout(() => { notifyInitialization = undefined; resolve(); }, heartbeatMs) + : undefined; + notifyInitialization = () => { if (timer !== undefined) clearTimeout(timer); notifyInitialization = undefined; resolve(); }; + }); + } if (closed) return; - if (!initialized) { + if (initializationResult === undefined) { + activityPending = false; controller.enqueue(new TextEncoder().encode(": keepalive\n\n")); return; } @@ -571,6 +585,7 @@ export function deferProtocolSafeResetRecovery( if (closed) return; closed = true; preflightAbort.abort(reason); + notifyInitialization?.(); if (reader) { try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } try { reader.releaseLock(); } catch { /* already released */ } diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 3142e6afbf2..bba2af95cb3 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1938,7 +1938,7 @@ export async function preparePassthroughExchange( }, }, ), - { allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream }, + { allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, keepalive: canonicalBufferedJson ? "upstream-activity" : options.comboAttempt ? "none" : "periodic" }, ); } break; diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 91311317a78..e317319faa6 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -135,13 +135,17 @@ Native post-header reset recovery shares that boundary, because it is asking the about the same bytes. With `replayReadErrors`, the preflight reports the stage it observed — `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the -resend gate decides. A lifecycle snapshot with output items, malformed output, or a non-null error +resend gate decides. A lifecycle snapshot with output items, malformed output, a non-null top-level +`error`, or a non-null `response.error` or `response.last_error` commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. -The deferred native hold emits bounded SSE comment heartbeats without counting them as output. -Direct streams can receive them during the hold; combo preflight buffers them until selection. +The deferred native hold emits SSE comment heartbeats for direct streaming clients. Combo streaming +children omit those timer-generated comments while their parent holds the headers, so those comments +cannot consume the retained-chunk cap. For canonical buffered JSON, the hold emits a comment +only when it reads non-empty upstream bytes: this reports real prelude activity to the collector's +inactivity clock without making a silent provider appear active. The byte and chunk caps remain in force. Downstream cancellation aborts the hold or an in-flight replacement. - a non-control Responses event (apart from the held mandatory-tool reasoning prelude above) begins client-visible output or a tool/action item, after which the diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 874542d85f4..e2129047602 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, spyOn, test } from "bun:test"; +import { describe, expect, jest, spyOn, test } from "bun:test"; +import { collectBufferedResponsesSse } from "../../src/server/responses/buffered-sse-json"; import { comboStreamPayloadCommitsOutput, deferProtocolSafeResetRecovery, @@ -14,6 +15,7 @@ const sse = (...payloads: unknown[]): Response => new Response( ); const preflightChunkLimit = Math.max(1, Math.ceil(MAX_CLIENT_SSE_FRAME_BYTES / 1024)); +const flushMicrotasks = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; function prefixThenReadError(prefix: Uint8Array, error: Error): { response: Response; @@ -51,6 +53,98 @@ const createdPrefix = new TextEncoder().encode(`data: ${JSON.stringify({ `); describe("combo stream preflight", () => { + test.each(["silence", "comment", "lifecycle"] as const)("buffered collector counts upstream activity, not timer heartbeats (%s)", async activity => { + jest.useFakeTimers(); + let source!: ReadableStreamDefaultController; + let cancelled = 0; + const original = new Response(new ReadableStream({ + start(controller) { source = controller; }, cancel() { cancelled++; }, + }), { headers: { "content-type": "text/event-stream" } }); + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => null, + { heartbeatIntervalMs: 15_000, keepalive: "upstream-activity" }); + const upstream = new AbortController(); + const cancellation = new AbortController(); + const pending = collectBufferedResponsesSse(wrapped.body!, upstream, + { signal: cancellation.signal, read: { firstByteTimeoutMs: 300_000, inactivityTimeoutMs: 300_000, totalTimeoutMs: 900_000 } }); + let result: Awaited | undefined; + void pending.then(value => { result = value; }); + try { + await flushMicrotasks(); + for (let i = 0; i < 19; i++) { jest.advanceTimersByTime(15_000); await flushMicrotasks(); } + expect(result).toBeUndefined(); + if (activity !== "silence") { + source.enqueue(activity === "comment" ? new TextEncoder().encode(": upstream\n\n") : createdPrefix); + await flushMicrotasks(); + for (let i = 0; i < 19; i++) { jest.advanceTimersByTime(15_000); await flushMicrotasks(); } + expect(result).toBeUndefined(); + } + jest.advanceTimersByTime(15_000); + await flushMicrotasks(); + expect(result?.ok).toBe(false); + if (result && !result.ok) expect(result.kind).toBe("timeout"); + expect(upstream.signal.aborted).toBe(true); + expect(cancelled).toBe(1); + } finally { + cancellation.abort(); + await pending; + jest.useRealTimers(); + } + }); + + test("combo preflight can fail over after more than its chunk cap of heartbeat intervals", async () => { + jest.useFakeTimers(); + let source!: ReadableStreamDefaultController; + const original = new Response(new ReadableStream({ start(controller) { source = controller; } }), + { headers: { "content-type": "text/event-stream" } }); + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => null, + { heartbeatIntervalMs: 1, keepalive: "none" }); + const abort = new AbortController(); + const pending = preflightComboStreamResponse(wrapped, { model: "m1", provider: "a" }, undefined, { signal: abort.signal }); + let result: Awaited | undefined; + void pending.then(value => { result = value; }, () => {}); + try { + await flushMicrotasks(); + for (let i = 0; i <= preflightChunkLimit; i++) { jest.advanceTimersByTime(1); await flushMicrotasks(); } + expect(result).toBeUndefined(); + source.enqueue(new TextEncoder().encode('data: {"type":"response.failed","response":{"status":"failed","error":{"message":"busy"}}}\n\n')); + source.close(); + await flushMicrotasks(); + expect((await pending).kind).toBe("failed"); + } finally { abort.abort(); await pending.catch(() => {}); jest.useRealTimers(); } + }); + + test("buffered collector accepts a successful recovery after real prelude activity", async () => { + let replacements = 0; + const wrapped = deferProtocolSafeResetRecovery( + sse({ type: "response.created", response: { id: "original", status: "in_progress", output: [] } }), + { model: "m1", provider: "a" }, async () => { + replacements++; + return sse({ type: "response.completed", response: { id: "replacement", status: "completed", output: [] } }); + }, { keepalive: "upstream-activity" }, + ); + const result = await collectBufferedResponsesSse(wrapped.body!, new AbortController()); + expect(result).toMatchObject({ ok: true, terminal: { status: "completed", response: { id: "replacement" } } }); + expect(replacements).toBe(1); + }); + + test("buffered collector cancellation aborts held preflight without starting recovery", async () => { + let cancelled = 0; + let replacements = 0; + const original = new Response(new ReadableStream({ cancel() { cancelled++; } }), + { headers: { "content-type": "text/event-stream" } }); + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + replacements++; + return null; + }, { keepalive: "upstream-activity" }); + const cancellation = new AbortController(); + const result = collectBufferedResponsesSse(wrapped.body!, new AbortController(), { signal: cancellation.signal }); + await flushMicrotasks(); + cancellation.abort(new Error("client cancelled")); + expect(await result).toMatchObject({ ok: false, kind: "aborted" }); + await flushMicrotasks(); + expect(cancelled).toBe(1); + expect(replacements).toBe(0); + }); test("keeps only lifecycle preamble replayable and treats unknown output conservatively", () => { expect(comboStreamPayloadCommitsOutput({ type: "response.created" })).toBe(false); expect(comboStreamPayloadCommitsOutput({ type: "response.heartbeat" })).toBe(false); @@ -899,6 +993,31 @@ describe("combo stream preflight", () => { }); for (const type of ["response.created", "response.in_progress", "response.queued", "response.heartbeat"]) { + for (const field of ["error", "response.error", "last_error"] as const) { + for (const ending of ["eof", "read-error"] as const) { + test(`${type} preserves ${field} evidence on ${ending}`, async () => { + const payload = field === "error" + ? { type, error: { message: "original error" }, response: { output: [] } } + : { type, response: { output: [], [field === "response.error" ? "error" : "last_error"]: { message: "original error" } } }; + const frame = `data: ${JSON.stringify(payload)}\n\n`; + const bytes = new TextEncoder().encode(frame); + const error = new Error("original reset"); + const original = ending === "eof" + ? new Response(bytes, { headers: { "content-type": "text/event-stream" } }) + : prefixThenReadError(bytes, error).response; + let replacements = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + replacements++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + const reader = wrapped.body!.getReader(); + expect((await reader.read()).value).toEqual(bytes); + if (ending === "read-error") await expect(reader.read()).rejects.toBe(error); + else expect((await reader.read()).done).toBe(true); + expect(replacements).toBe(0); + }); + } + } for (const [description, snapshot] of [ ["output", { output: [{ type: "message", role: "assistant" }] }], ["malformed output", { output: { type: "message" } }], @@ -918,7 +1037,7 @@ describe("combo stream preflight", () => { } test(`${type} with an empty snapshot still permits EOF recovery`, async () => { let calls = 0; - const original = sse({ type, response: { output: [], error: null } }); + const original = sse({ type, error: null, response: { output: [], error: null, last_error: null } }); const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { calls++; return sse({ type: "response.completed", response: { id: "replacement" } }); @@ -926,6 +1045,17 @@ describe("combo stream preflight", () => { expect(await wrapped.text()).toContain('"replacement"'); expect(calls).toBe(1); }); + test(`${type} with null errors still permits read-error recovery`, async () => { + const frame = new TextEncoder().encode(`data: ${JSON.stringify({ type, error: null, response: { output: [], error: null, last_error: null } })}\n\n`); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(prefixThenReadError(frame, new Error("reset")).response, + { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + expect(await wrapped.text()).toContain('"replacement"'); + expect(calls).toBe(1); + }); } }); From f209f4abc25f16df82c95ca2b3af8b1c2a74fec6 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:42:57 +0900 Subject: [PATCH 06/22] Preserve conflicting lifecycle evidence before retry --- .../responses/combo-stream-preflight.ts | 14 +++-- .../transports/responses-combo-boundaries.md | 5 +- tests/routing/combo-stream-preflight.test.ts | 51 +++++++++++++++++-- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index bf57f179482..22d673829a1 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -182,13 +182,19 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { if (PRE_OUTPUT_CONTROL_EVENTS.has(type)) { // Lifecycle snapshots may already carry output or an error. Neither can be discarded // for a replacement, even if the event name otherwise describes only a prelude. - const event = payload as { response?: unknown; error?: unknown }; - if (event.error != null) return true; + const event = payload as Record; + const conflictingStatus = (status: unknown) => status != null && status !== "queued" && status !== "in_progress"; + if (event.error != null || event.last_error != null || event.incomplete_details != null + || event.item != null || event.delta != null || event.content != null + || event.message != null || event.code != null || conflictingStatus(event.status)) return true; + if (event.output !== undefined && (!Array.isArray(event.output) || event.output.length > 0)) return true; const response = event.response; + if (response != null && (typeof response !== "object" || Array.isArray(response))) return true; if (response && typeof response === "object" && !Array.isArray(response)) { - const snapshot = response as { output?: unknown; error?: unknown; last_error?: unknown }; + const snapshot = response as { output?: unknown; error?: unknown; last_error?: unknown; incomplete_details?: unknown; status?: unknown }; if (snapshot.output !== undefined && (!Array.isArray(snapshot.output) || snapshot.output.length > 0)) return true; - if (snapshot.error != null || snapshot.last_error != null) return true; + if (snapshot.error != null || snapshot.last_error != null || snapshot.incomplete_details != null + || conflictingStatus(snapshot.status)) return true; } } return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index e317319faa6..685e8a269f1 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -135,9 +135,8 @@ Native post-header reset recovery shares that boundary, because it is asking the about the same bytes. With `replayReadErrors`, the preflight reports the stage it observed — `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the -resend gate decides. A lifecycle snapshot with output items, malformed output, a non-null top-level -`error`, or a non-null `response.error` or `response.last_error` -commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, +resend gate decides. A lifecycle event with output, an error, incomplete details, a contradictory +status, or a malformed snapshot commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index e2129047602..763c0d4ef96 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -993,11 +993,11 @@ describe("combo stream preflight", () => { }); for (const type of ["response.created", "response.in_progress", "response.queued", "response.heartbeat"]) { - for (const field of ["error", "response.error", "last_error"] as const) { + for (const field of ["error", "response.error", "response.last_error", "last_error"] as const) { for (const ending of ["eof", "read-error"] as const) { test(`${type} preserves ${field} evidence on ${ending}`, async () => { - const payload = field === "error" - ? { type, error: { message: "original error" }, response: { output: [] } } + const payload = field === "error" || field === "last_error" + ? { type, [field]: { message: "original error" }, response: { output: [] } } : { type, response: { output: [], [field === "response.error" ? "error" : "last_error"]: { message: "original error" } } }; const frame = `data: ${JSON.stringify(payload)}\n\n`; const bytes = new TextEncoder().encode(frame); @@ -1018,6 +1018,49 @@ describe("combo stream preflight", () => { }); } } + for (const status of ["completed", "failed", "incomplete", "cancelled", "future_terminal", 400]) { + for (const ending of ["eof", "read-error"] as const) { + test(`${type} preserves snapshot status ${status} on ${ending}`, async () => { + const frame = `data: ${JSON.stringify({ type, response: { output: [], status } })}\n\n`; + const bytes = new TextEncoder().encode(frame); + const error = new Error("original reset"); + const original = ending === "eof" + ? new Response(bytes, { headers: { "content-type": "text/event-stream" } }) + : prefixThenReadError(bytes, error).response; + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + const reader = wrapped.body!.getReader(); + expect((await reader.read()).value).toEqual(bytes); + if (ending === "eof") expect((await reader.read()).done).toBe(true); + else await expect(reader.read()).rejects.toBe(error); + expect(calls).toBe(0); + }); + } + } + for (const payload of [ + { type, status: 400 }, + { type, last_error: { message: "original" } }, + { type, output: [{ type: "message" }] }, + { type, item: { type: "function_call" } }, + { type, delta: "original output" }, + { type, response: { output: [], incomplete_details: { reason: "upstream_stall_timeout" } } }, + { type, response: ["malformed snapshot"] }, + ]) { + test(`${type} preserves contradictory or malformed evidence at EOF (%j)`, async () => { + const original = sse(payload); + const expected = await original.clone().text(); + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + expect(await wrapped.text()).toBe(expected); + expect(calls).toBe(0); + }); + } for (const [description, snapshot] of [ ["output", { output: [{ type: "message", role: "assistant" }] }], ["malformed output", { output: { type: "message" } }], @@ -1037,7 +1080,7 @@ describe("combo stream preflight", () => { } test(`${type} with an empty snapshot still permits EOF recovery`, async () => { let calls = 0; - const original = sse({ type, error: null, response: { output: [], error: null, last_error: null } }); + const original = sse({ type, error: null, last_error: null, response: { output: [], error: null, last_error: null, status: "in_progress" } }); const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { calls++; return sse({ type: "response.completed", response: { id: "replacement" } }); From b7bd9e580c6884cf881898c89b018bdaeeb4e8b6 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:54:28 +0900 Subject: [PATCH 07/22] Honor replay-unsafe lifecycle events --- .../responses/combo-stream-preflight.ts | 12 ++++++- .../transports/responses-combo-boundaries.md | 3 +- .../responses-canonical-nonstream.test.ts | 27 ++++++++++++++ tests/routing/combo-stream-preflight.test.ts | 36 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 22d673829a1..4d4706f5e9d 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -31,6 +31,11 @@ const PRE_OUTPUT_CONTROL_EVENTS = new Set([ "response.heartbeat", ]); +const PRE_OUTPUT_EVENT_FIELDS = new Set([ + "type", "response", "sequence_number", "replayUnsafe", "error", "last_error", + "incomplete_details", "status", "output", "item", "delta", "content", "message", "code", +]); + const TERMINAL_EVENTS = new Set([ "response.completed", "response.failed", @@ -184,6 +189,10 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { // for a replacement, even if the event name otherwise describes only a prelude. const event = payload as Record; const conflictingStatus = (status: unknown) => status != null && status !== "queued" && status !== "in_progress"; + if (Object.keys(event).some(key => !PRE_OUTPUT_EVENT_FIELDS.has(key))) return true; + if (Object.hasOwn(event, "replayUnsafe") && event.replayUnsafe !== false) return true; + if (event.sequence_number !== undefined && !(typeof event.sequence_number === "number" + && Number.isSafeInteger(event.sequence_number) && event.sequence_number >= 0)) return true; if (event.error != null || event.last_error != null || event.incomplete_details != null || event.item != null || event.delta != null || event.content != null || event.message != null || event.code != null || conflictingStatus(event.status)) return true; @@ -191,8 +200,9 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { const response = event.response; if (response != null && (typeof response !== "object" || Array.isArray(response))) return true; if (response && typeof response === "object" && !Array.isArray(response)) { - const snapshot = response as { output?: unknown; error?: unknown; last_error?: unknown; incomplete_details?: unknown; status?: unknown }; + const snapshot = response as { output?: unknown; error?: unknown; last_error?: unknown; incomplete_details?: unknown; status?: unknown; replayUnsafe?: unknown }; if (snapshot.output !== undefined && (!Array.isArray(snapshot.output) || snapshot.output.length > 0)) return true; + if (Object.hasOwn(snapshot, "replayUnsafe") && snapshot.replayUnsafe !== false) return true; if (snapshot.error != null || snapshot.last_error != null || snapshot.incomplete_details != null || conflictingStatus(snapshot.status)) return true; } diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 685e8a269f1..63668743b3b 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -136,7 +136,8 @@ about the same bytes. With `replayReadErrors`, the preflight reports the stage i `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the resend gate decides. A lifecycle event with output, an error, incomplete details, a contradictory -status, or a malformed snapshot commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, +status, a replay-unsafe mark, unknown fields, or a malformed snapshot commits the original stream. +An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 58acfedd02e..59e37191004 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -381,6 +381,33 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 } }); + for (const ending of ["eof", "read-error"] as const) { + test(`native streaming never resends replay-unsafe heartbeat after ${ending}`, async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends++; + const frame = new TextEncoder().encode(sseEvent("response.heartbeat", { replayUnsafe: true })); + if (sends > 1) return new Response(sseEvent("response.completed", { response: { id: "replacement" } }), { + headers: { "content-type": "text/event-stream" }, + }); + let sent = false; + return new Response(new ReadableStream({ + pull(controller) { + if (!sent) { sent = true; controller.enqueue(frame); } + else if (ending === "eof") controller.close(); + else controller.error(new Error("upstream reset")); + }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const resetConfig: OcxConfig = { ...config, providers: { openai: { ...provider, retryOnReset: {} } } }; + const response = await call(requestBody(true, false), {}, resetConfig); + const body = await response.text(); + expect(sends).toBe(1); + expect(body).toContain('"replayUnsafe":true'); + expect(body).not.toContain("replacement"); + }); + } + test.each([ ["clean EOF", sseEvent("response.created", { response: { id: "resp_eof", status: "in_progress", output: [] } })], ["malformed payload", [ diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 763c0d4ef96..9430f9c1c30 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -993,6 +993,38 @@ describe("combo stream preflight", () => { }); for (const type of ["response.created", "response.in_progress", "response.queued", "response.heartbeat"]) { + for (const value of [true, null, "false", 0]) { + for (const ending of ["eof", "read-error"] as const) { + test(`${type} preserves replayUnsafe=${JSON.stringify(value)} on ${ending}`, async () => { + const frame = `data: ${JSON.stringify({ type, replayUnsafe: value })}\n\n`; + const bytes = new TextEncoder().encode(frame); + const error = new Error("original reset"); + const original = ending === "eof" + ? new Response(bytes, { headers: { "content-type": "text/event-stream" } }) + : prefixThenReadError(bytes, error).response; + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + const reader = wrapped.body!.getReader(); + expect((await reader.read()).value).toEqual(bytes); + if (ending === "eof") expect((await reader.read()).done).toBe(true); + else await expect(reader.read()).rejects.toBe(error); + expect(calls).toBe(0); + }); + } + } + test(`${type} with replayUnsafe=false remains replayable`, async () => { + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(sse({ type, replayUnsafe: false, sequence_number: 1 }), + { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + expect(await wrapped.text()).toContain('"replacement"'); + expect(calls).toBe(1); + }); for (const field of ["error", "response.error", "response.last_error", "last_error"] as const) { for (const ending of ["eof", "read-error"] as const) { test(`${type} preserves ${field} evidence on ${ending}`, async () => { @@ -1048,6 +1080,10 @@ describe("combo stream preflight", () => { { type, delta: "original output" }, { type, response: { output: [], incomplete_details: { reason: "upstream_stall_timeout" } } }, { type, response: ["malformed snapshot"] }, + { type, stream_id: "another-stream" }, + { type, sequence_number: "invalid" }, + { type, response: { output: [], replayUnsafe: true } }, + { type, future_signal: { effect: "unknown" } }, ]) { test(`${type} preserves contradictory or malformed evidence at EOF (%j)`, async () => { const original = sse(payload); From d08f0b4c958acdb1985838022e609a4894e09c46 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:11:43 +0900 Subject: [PATCH 08/22] Guard gateway upstream activity before reset recovery --- .../responses/opengateway-completion-guard.ts | 8 +- .../responses/passthrough-completion.ts | 35 ++++ src/server/responses/passthrough-dispatch.ts | 8 +- structure/providers/opengateway.md | 6 +- tests/responses/empty-completion-core.test.ts | 171 +++++++++++++++++- 5 files changed, 219 insertions(+), 9 deletions(-) diff --git a/src/server/responses/opengateway-completion-guard.ts b/src/server/responses/opengateway-completion-guard.ts index 1a36a1e2a61..acdb366edc4 100644 --- a/src/server/responses/opengateway-completion-guard.ts +++ b/src/server/responses/opengateway-completion-guard.ts @@ -56,6 +56,8 @@ export interface OpenGatewayCompletionGuardOptions { readonly recordUsage: (usage: unknown) => void; readonly retryEnabled?: boolean; readonly stallTimeoutMs?: number; + /** This response's real upstream body is already guarded before reset preflight. */ + readonly initialBodyInactivityGuarded?: boolean; /** Expiry releases original bytes; it never aborts a healthy upstream. */ readonly holdTimeoutMs?: number; } @@ -63,12 +65,12 @@ export interface OpenGatewayCompletionGuardOptions { type Inspection = { kind: "pass"; response: Response } | { kind: "empty" | "invalid"; snapshot: RecordValue; streaming: boolean }; -async function inspect(response: Response, options: OpenGatewayCompletionGuardOptions): Promise { +async function inspect(response: Response, options: OpenGatewayCompletionGuardOptions, alreadyGuarded: boolean): Promise { const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; const streaming = contentType.includes("text/event-stream") || (!contentType && isPlainObject(options.body) && options.body.stream === true); if (!response.body || (!streaming && !contentType.includes("application/json"))) return { kind: "pass", response }; - const guarded = guardResponseBodyInactivity(response, options.signal, streaming ? options.stallTimeoutMs ?? 0 : 0); + const guarded = guardResponseBodyInactivity(response, options.signal, streaming && !alreadyGuarded ? options.stallTimeoutMs ?? 0 : 0); const reader = guarded.response.body?.getReader(); if (!reader) return { kind: "pass", response }; const framer = new BoundedSseFrameBuffer(MAX_BYTES); @@ -213,7 +215,7 @@ export async function guardOpenGatewayCompletion(response: Response, options: Op && selfContainedResponsesBody(body) && !isNonReplayableResponse(response); let selected = response; for (let attempt = 0; ; attempt++) { - const inspected = await inspect(selected, options); + const inspected = await inspect(selected, options, attempt === 0 && options.initialBodyInactivityGuarded === true); if (inspected.kind === "pass") return inspected.response; const usage = inspected.snapshot.usage; options.recordUsage(usage); diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 85ccac8af4a..60bc47649b0 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -1,10 +1,44 @@ import type { AdapterRequest } from "../../adapters/base"; import { formatErrorResponse } from "../../bridge"; +import { guardResponseBodyInactivity } from "../../lib/response-body-inactivity"; import { resolveStallTimeoutMs } from "../../stall-timeout"; import type { OcxConfig } from "../../types"; import { recordKeyWireAttemptUsage, usageFromResponsesPayload, type RequestLogContext } from "../request-log"; import { isPlainObject } from "../responses-snapshot-codec"; +import { deferProtocolSafeResetRecovery, type ProtocolSafeResetRecovery } from "./combo-stream-preflight"; import { guardOpenGatewayCompletion } from "./opengateway-completion-guard"; +import { carryResponseToolOwnership } from "./response-tool-ownership"; + +const guardedResetResponses = new WeakSet(); + +/** The gateway clock must see upstream reads, not generated comments or held preludes. */ +export function deferPassthroughResetRecovery( + response: Response, + logCtx: RequestLogContext, + recover: ProtocolSafeResetRecovery, + options: { + request: AdapterRequest; + config: Pick; + signal: AbortSignal; + allowMissingContentType: boolean; + keepalive: "periodic" | "upstream-activity" | "none"; + }, +): Response { + const guard = options.request.url === "https://apis.opengateway.ai/v1/responses" + && response.status === 200 && response.body !== null; + const protect = (source: Response): Response => guard + ? carryResponseToolOwnership(source, guardResponseBodyInactivity( + source, options.signal, resolveStallTimeoutMs(options.config.stallTimeoutSec), + ).response) + : source; + const deferred = deferProtocolSafeResetRecovery(protect(response), logCtx, async (error, stage, signal) => { + // Recover returns only a validated, fresh body; wrapping earlier would hide its lock state. + const replacement = await recover(error, stage, signal); + return replacement ? protect(replacement) : null; + }, options); + if (guard) guardedResetResponses.add(deferred); + return deferred; +} export function parseOutboundRequestBody(bodyText: string): Record | undefined { try { @@ -30,6 +64,7 @@ export function guardPassthroughCompletion( signal, retryEnabled: config.emptyCompletionRetry, stallTimeoutMs: resolveStallTimeoutMs(config.stallTimeoutSec), + initialBodyInactivityGuarded: guardedResetResponses.has(response), recordUsage: usage => { recordKeyWireAttemptUsage(logCtx, usageFromResponsesPayload(usage)); }, retry: async () => { const result = await rebuildAndRefetch("empty-completion"); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index bba2af95cb3..be8c4f6aaad 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -168,7 +168,7 @@ import { reasoningEffortRejectionText, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; -import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight"; +import { preflightComboStreamResponse } from "./combo-stream-preflight"; import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; @@ -176,7 +176,7 @@ import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-ze import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { createNativeContinuationDispatch } from "./native-continuation-send"; -import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; +import { deferPassthroughResetRecovery, guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; /** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */ export interface PassthroughAdapterHandoff { kind: "adapter-handoff" } @@ -1917,7 +1917,7 @@ export async function preparePassthroughExchange( && remainingTransientSendBudget(transientSendAttempts()) > 0 && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && upstreamRequestsStream)); if (protocolRecoveryCandidate) { - upstreamResponse = deferProtocolSafeResetRecovery( + upstreamResponse = deferPassthroughResetRecovery( upstreamResponse, { model: logCtx.model, provider: logCtx.provider }, (error, stage, signal) => refetchAfterProtocolSafeReset( @@ -1938,7 +1938,7 @@ export async function preparePassthroughExchange( }, }, ), - { allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, keepalive: canonicalBufferedJson ? "upstream-activity" : options.comboAttempt ? "none" : "periodic" }, + { request, config, signal: upstream.signal, allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, keepalive: canonicalBufferedJson ? "upstream-activity" : options.comboAttempt ? "none" : "periodic" }, ); } break; diff --git a/structure/providers/opengateway.md b/structure/providers/opengateway.md index 6d87f19e0b9..e8176f8e3e2 100644 --- a/structure/providers/opengateway.md +++ b/structure/providers/opengateway.md @@ -35,7 +35,11 @@ Text, refusal, tool/action or unknown events release the original bytes without holds at most 1 MiB, 1,024 events/chunks or 30 seconds, then hands the held prefix and pending read to the consumer without cancelling a healthy source. Consumed/cancelled prefix references are released; terminal disposal and consumer cancellation do not await upstream cleanup. SSE inactivity -keeps the existing stall-timeout policy. Rewritten responses preserve tool ownership. +keeps the existing stall-timeout policy. When reset preflight is active, its source and any accepted +reset replacement are guarded before preflight, so the clock follows upstream reads rather than +synthetic keepalives or held combo frames. The completion guard skips only the duplicate clock for +that initial selected body; a later empty-completion retry gets its own guard. Rewritten responses +preserve tool ownership. `tests/responses/opengateway-completion-receipts.test.ts` covers wire outcomes and resource release; `tests/responses/empty-completion-core.test.ts` and `tests/responses/empty-completion-hardening.test.ts` cover integration and replay barriers. Other destinations and translated retry defaults are unchanged. diff --git a/tests/responses/empty-completion-core.test.ts b/tests/responses/empty-completion-core.test.ts index 134ab37163b..76e70522c73 100644 --- a/tests/responses/empty-completion-core.test.ts +++ b/tests/responses/empty-completion-core.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, mock, test } from "bun:test"; import type { ProviderAdapter } from "../../src/adapters/base"; import { providerRequestPacingStatus, @@ -534,6 +534,175 @@ describe("OpenGateway native empty completion", () => { return { cfg, sends }; } + test("reset preflight heartbeats cannot hide a silent OpenGateway upstream", async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + pull() { firstRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + endpoint, { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const req = request(true, "question", { store: false }); + const pending = handleResponses(new Request(req, { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(15_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(cancelled).toBe(0); + jest.advanceTimersByTime(5_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(1); + } finally { + client.abort(); + const response = await pending; + await response.body?.cancel().catch(() => {}); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test("combo preflight respects real OpenGateway activity before failover", async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let source!: ReadableStreamDefaultController; + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + start(controller) { source = controller; }, + pull() { firstRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + endpoint, { retryOnReset: { enabled: true, replacements: 1 } }); + let backupSends = 0; + const backup = { ...f.cfg.providers.fixture!, baseUrl: "https://backup.example/v1", fetch: async () => { + backupSends++; + return sse(answer()); + } }; + const combo: OcxConfig = { ...f.cfg, stallTimeoutSec: 20, + providers: { ...f.cfg.providers, backup }, + combos: { clock: { strategy: "failover", targets: [ + { provider: "fixture", model: "model" }, { provider: "backup", model: "model" }, + ] } }, + }; + const client = new AbortController(); + const req = request(true, "question", { model: "combo/clock", store: false }); + const pending = handleResponses(new Request(req, { signal: client.signal }), combo, { model: "", provider: "" }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(10_000); + source.enqueue(new TextEncoder().encode('data: {"type":"response.created","response":{"status":"in_progress","output":[]}}\n\n')); + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(10_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(cancelled).toBe(0); + expect(backupSends).toBe(0); + jest.advanceTimersByTime(5_000); + source.enqueue(new TextEncoder().encode('data: {"type":"response.failed","response":{"status":"failed","error":{"type":"server_error","message":"busy"}}}\n\n')); + source.close(); + const response = await pending; + expect(await response.text()).toContain("resp_answer"); + expect(f.sends).toHaveLength(1); + expect(backupSends).toBe(1); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test("direct OpenGateway streaming retains keepalives before its upstream stall deadline", async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + pull() { firstRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + endpoint, { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const req = request(true, "question", { store: false }); + const pending = handleResponses(new Request(req, { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 40 }, { model: "", provider: "" }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(15_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(15_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const response = await pending; + const reader = response.body!.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toContain(": keepalive"); + expect(cancelled).toBe(0); + expect(f.sends).toHaveLength(1); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + for (const recovery of ["reset", "empty-completion"] as const) { + test(`OpenGateway ${recovery} replacement keeps its own inactivity deadline`, async () => { + takeSpendHome(); + jest.useFakeTimers(); + const replacementRead = Promise.withResolvers(); + let cancelled = 0; + const first = recovery === "reset" + ? () => new Response('data: {"type":"response.created","response":{"status":"in_progress","output":[]}}\n\n', + { headers: { "content-type": "text/event-stream" } }) + : () => sse(empty()); + const f = fixture([first, () => new Response(new ReadableStream({ + pull() { replacementRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + endpoint, { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const req = request(true, "question", { store: false }); + const pending = handleResponses(new Request(req, { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + await replacementRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(f.sends).toHaveLength(2); + jest.advanceTimersByTime(20_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(2); + } finally { + client.abort(); + const response = await pending; + await response.body?.cancel().catch(() => {}); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + } + + test("OpenGateway reset replacement can complete with its original output", async () => { + takeSpendHome(); + const f = fixture([ + () => new Response('data: {"type":"response.created","response":{"status":"in_progress","output":[]}}\n\n', + { headers: { "content-type": "text/event-stream" } }), + () => sse(answer()), + ], endpoint, { retryOnReset: { enabled: true, replacements: 1 } }); + const response = await handleResponses(request(true, "question", { store: false }), f.cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("resp_answer"); + expect(f.sends).toHaveLength(2); + }); + for (const streaming of [false, true]) { test(`native ${streaming ? "SSE" : "JSON"} exhausted reasoning becomes incomplete without replay`, async () => { const f = fixture([() => streaming ? sse(empty(16)) : Response.json(empty(16))]); From 4d03d55439c3a923f7acc6f70e0ec0651259dc15 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:26:16 +0900 Subject: [PATCH 09/22] Reject ambiguous lifecycle snapshot extensions --- .../responses/combo-stream-preflight.ts | 59 ++++++++++++++++++- .../transports/responses-combo-boundaries.md | 2 +- .../responses-canonical-nonstream.test.ts | 29 +++++++++ tests/routing/combo-stream-preflight.test.ts | 53 +++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 4d4706f5e9d..39c81a9cc6c 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -36,6 +36,58 @@ const PRE_OUTPUT_EVENT_FIELDS = new Set([ "incomplete_details", "status", "output", "item", "delta", "content", "message", "code", ]); +// These are response identity and request-configuration fields, not output carriers. +// Unknown snapshot extensions commit the original instead of guessing whether they hide output. +const PRE_OUTPUT_SNAPSHOT_FIELDS = new Set([ + "id", "object", "created_at", "completed_at", "status", "model", "output", "error", "last_error", + "incomplete_details", "usage", "background", "instructions", "max_output_tokens", "max_tool_calls", + "metadata", "parallel_tool_calls", "previous_response_id", "prompt", "prompt_cache_key", + "prompt_cache_retention", "reasoning", "safety_identifier", "service_tier", "store", "temperature", + "text", "tool_choice", "tools", "top_p", "top_logprobs", "truncation", "user", "end_turn", "replayUnsafe", +]); + +const PRE_OUTPUT_STRING_FIELDS = [ + "id", "object", "model", "prompt_cache_key", "prompt_cache_retention", "safety_identifier", + "service_tier", "truncation", "user", +] as const; +const PRE_OUTPUT_NUMBER_FIELDS = ["created_at", "max_output_tokens", "max_tool_calls", "temperature", "top_p", "top_logprobs"] as const; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function recordWithOnly(value: unknown, fields: readonly string[]): value is Record { + return isRecord(value) && Object.keys(value).every(key => fields.includes(key)); +} + +function snapshotMetadataIsSafe(snapshot: Record): boolean { + if (PRE_OUTPUT_STRING_FIELDS.some(key => snapshot[key] != null && typeof snapshot[key] !== "string")) return false; + if (snapshot.object != null && snapshot.object !== "response") return false; + if (PRE_OUTPUT_NUMBER_FIELDS.some(key => snapshot[key] != null + && !(typeof snapshot[key] === "number" && Number.isFinite(snapshot[key])))) return false; + if (snapshot.parallel_tool_calls != null && typeof snapshot.parallel_tool_calls !== "boolean") return false; + if (snapshot.tools != null && !Array.isArray(snapshot.tools)) return false; + if (snapshot.instructions != null && typeof snapshot.instructions !== "string" && !Array.isArray(snapshot.instructions)) return false; + if (snapshot.prompt != null && !recordWithOnly(snapshot.prompt, ["id", "version", "variables"])) return false; + if (snapshot.tool_choice != null && typeof snapshot.tool_choice !== "string" + && !recordWithOnly(snapshot.tool_choice, ["type", "name", "namespace", "function"])) return false; + if (snapshot.usage != null && !recordWithOnly(snapshot.usage, ["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"])) return false; + if (snapshot.metadata != null && (!isRecord(snapshot.metadata) + || Object.values(snapshot.metadata).some(value => typeof value !== "string"))) return false; + if (snapshot.reasoning != null) { + if (!recordWithOnly(snapshot.reasoning, ["effort", "summary", "generate_summary"]) + || Object.values(snapshot.reasoning).some(value => value != null && typeof value !== "string")) return false; + } + if (snapshot.text != null) { + if (!recordWithOnly(snapshot.text, ["format", "verbosity"]) + || (snapshot.text.verbosity != null && typeof snapshot.text.verbosity !== "string")) return false; + const format = snapshot.text.format; + if (format != null && (!recordWithOnly(format, ["type", "name", "description", "schema", "strict"]) + || !["text", "json_object", "json_schema"].includes(String(format.type)))) return false; + } + return true; +} + const TERMINAL_EVENTS = new Set([ "response.completed", "response.failed", @@ -200,11 +252,16 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { const response = event.response; if (response != null && (typeof response !== "object" || Array.isArray(response))) return true; if (response && typeof response === "object" && !Array.isArray(response)) { - const snapshot = response as { output?: unknown; error?: unknown; last_error?: unknown; incomplete_details?: unknown; status?: unknown; replayUnsafe?: unknown }; + const snapshot = response as Record; + if (Object.keys(snapshot).some(key => !PRE_OUTPUT_SNAPSHOT_FIELDS.has(key))) return true; + if (!snapshotMetadataIsSafe(snapshot)) return true; if (snapshot.output !== undefined && (!Array.isArray(snapshot.output) || snapshot.output.length > 0)) return true; if (Object.hasOwn(snapshot, "replayUnsafe") && snapshot.replayUnsafe !== false) return true; if (snapshot.error != null || snapshot.last_error != null || snapshot.incomplete_details != null || conflictingStatus(snapshot.status)) return true; + if (snapshot.completed_at != null || (snapshot.end_turn != null && snapshot.end_turn !== false) + || (snapshot.background != null && snapshot.background !== false) + || (snapshot.store != null && snapshot.store !== false) || snapshot.previous_response_id != null) return true; } } return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 63668743b3b..f7f5a9d0598 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -136,7 +136,7 @@ about the same bytes. With `replayReadErrors`, the preflight reports the stage i `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the resend gate decides. A lifecycle event with output, an error, incomplete details, a contradictory -status, a replay-unsafe mark, unknown fields, or a malformed snapshot commits the original stream. +status, a replay-unsafe mark, unknown event or snapshot fields, or a malformed snapshot commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 59e37191004..7ff2315d466 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -381,6 +381,35 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 } }); + for (const ending of ["eof", "read-error"] as const) { + test(`native streaming preserves lifecycle snapshot output after ${ending}`, async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends++; + const frame = new TextEncoder().encode(sseEvent("response.created", { + response: { id: "original", output: [], output_text: "already produced" }, + })); + if (sends > 1) return new Response(sseEvent("response.completed", { response: { id: "replacement" } }), { + headers: { "content-type": "text/event-stream" }, + }); + let sent = false; + return new Response(new ReadableStream({ + pull(controller) { + if (!sent) { sent = true; controller.enqueue(frame); } + else if (ending === "eof") controller.close(); + else controller.error(new Error("upstream reset")); + }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const resetConfig: OcxConfig = { ...config, providers: { openai: { ...provider, retryOnReset: {} } } }; + const response = await call(requestBody(true, false), {}, resetConfig); + const body = await response.text(); + expect(sends).toBe(1); + expect(body).toContain("already produced"); + expect(body).not.toContain("replacement"); + }); + } + for (const ending of ["eof", "read-error"] as const) { test(`native streaming never resends replay-unsafe heartbeat after ${ending}`, async () => { let sends = 0; diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 9430f9c1c30..9b26fc073b1 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -993,6 +993,59 @@ describe("combo stream preflight", () => { }); for (const type of ["response.created", "response.in_progress", "response.queued", "response.heartbeat"]) { + for (const extra of [ + { output_text: "already produced" }, + { content: [{ type: "output_text", text: "already produced" }] }, + { tool_calls: [{ type: "function", name: "act" }] }, + { future_signal: { effect: "unknown" } }, + { text: "already produced" }, + { text: { output_text: "already produced" } }, + { reasoning: { summary: [{ type: "summary_text", text: "already produced" }] } }, + { model: { output_text: "already produced" } }, + { prompt: { output_text: "already produced" } }, + { tool_choice: { output_text: "already produced" } }, + { instructions: { output_text: "already produced" } }, + ]) { + for (const ending of ["eof", "read-error"] as const) { + test(`${type} preserves unknown snapshot evidence on ${ending}`, async () => { + const frame = `data: ${JSON.stringify({ type, response: { output: [], ...extra } })}\n\n`; + const bytes = new TextEncoder().encode(frame); + const error = new Error("original reset"); + const original = ending === "eof" + ? new Response(bytes, { headers: { "content-type": "text/event-stream" } }) + : prefixThenReadError(bytes, error).response; + let calls = 0; + const wrapped = deferProtocolSafeResetRecovery(original, { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + const reader = wrapped.body!.getReader(); + expect((await reader.read()).value).toEqual(bytes); + if (ending === "eof") expect((await reader.read()).done).toBe(true); + else await expect(reader.read()).rejects.toBe(error); + expect(calls).toBe(0); + }); + } + } + test(`${type} with ordinary response metadata remains replayable`, async () => { + let calls = 0; + const response = { + id: "resp_first", object: "response", created_at: 1, completed_at: null, status: type === "response.queued" ? "queued" : "in_progress", + model: "m1", output: [], error: null, incomplete_details: null, usage: null, + background: false, instructions: null, max_output_tokens: null, max_tool_calls: null, + metadata: {}, parallel_tool_calls: true, previous_response_id: null, prompt_cache_key: null, + reasoning: { effort: null, summary: null }, safety_identifier: null, service_tier: "default", + store: false, temperature: 1, text: { format: { type: "text" } }, + tool_choice: "auto", tools: [], top_p: 1, truncation: "disabled", user: null, + }; + const wrapped = deferProtocolSafeResetRecovery(sse({ type, response }), + { model: "m1", provider: "a" }, async () => { + calls++; + return sse({ type: "response.completed", response: { id: "replacement" } }); + }); + expect(await wrapped.text()).toContain('"replacement"'); + expect(calls).toBe(1); + }); for (const value of [true, null, "false", 0]) { for (const ending of ["eof", "read-error"] as const) { test(`${type} preserves replayUnsafe=${JSON.stringify(value)} on ${ending}`, async () => { From 8af653060503326abf03ee9221ad277215668ea0 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:53:34 +0900 Subject: [PATCH 10/22] Validate nested lifecycle snapshot metadata --- .../responses/combo-stream-preflight.ts | 82 +++++++++++++++++-- .../transports/responses-combo-boundaries.md | 2 +- tests/routing/combo-stream-preflight.test.ts | 15 +++- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 39c81a9cc6c..36f60a94325 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -60,30 +60,98 @@ function recordWithOnly(value: unknown, fields: readonly string[]): value is Rec return isRecord(value) && Object.keys(value).every(key => fields.includes(key)); } +function safeSnapshotTools(value: unknown, budget: { remaining: number }, depth = 0): boolean { + if (!Array.isArray(value) || depth > 4) return false; + return value.every(tool => { + if (--budget.remaining < 0 || !isRecord(tool)) return false; + if (tool.type === "function") { + return recordWithOnly(tool, ["type", "name", "description", "parameters", "strict"]) + && typeof tool.name === "string" + && (tool.description == null || typeof tool.description === "string") + && (tool.parameters == null || isRecord(tool.parameters)) + && (tool.strict == null || typeof tool.strict === "boolean"); + } + if (tool.type === "custom") { + if (!recordWithOnly(tool, ["type", "name", "description", "format"]) + || typeof tool.name !== "string" + || (tool.description != null && typeof tool.description !== "string")) return false; + if (tool.format == null) return true; + return recordWithOnly(tool.format, ["type", "syntax", "definition"]) + && (tool.format.type === "text" || tool.format.type === "grammar") + && (tool.format.syntax == null || typeof tool.format.syntax === "string") + && (tool.format.definition == null || typeof tool.format.definition === "string"); + } + if (tool.type === "tool_search") { + return recordWithOnly(tool, ["type", "execution", "description"]) + && tool.execution === "client" && (tool.description == null || typeof tool.description === "string"); + } + return tool.type === "namespace" && recordWithOnly(tool, ["type", "name", "description", "tools"]) + && typeof tool.name === "string" && (tool.description == null || typeof tool.description === "string") + && safeSnapshotTools(tool.tools, budget, depth + 1); + }); +} + +function safeSnapshotUsage(value: unknown): boolean { + if (!recordWithOnly(value, ["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"])) return false; + for (const key of ["input_tokens", "output_tokens", "total_tokens"]) { + if (value[key] != null && !(typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0)) return false; + } + for (const [key, fields] of [ + ["input_tokens_details", ["cached_tokens"]], + ["output_tokens_details", ["reasoning_tokens"]], + ] as const) { + const detail = value[key]; + if (detail != null && (!recordWithOnly(detail, fields) + || Object.values(detail).some(count => !(typeof count === "number" && Number.isFinite(count) && count >= 0)))) return false; + } + return true; +} + function snapshotMetadataIsSafe(snapshot: Record): boolean { if (PRE_OUTPUT_STRING_FIELDS.some(key => snapshot[key] != null && typeof snapshot[key] !== "string")) return false; if (snapshot.object != null && snapshot.object !== "response") return false; if (PRE_OUTPUT_NUMBER_FIELDS.some(key => snapshot[key] != null && !(typeof snapshot[key] === "number" && Number.isFinite(snapshot[key])))) return false; if (snapshot.parallel_tool_calls != null && typeof snapshot.parallel_tool_calls !== "boolean") return false; - if (snapshot.tools != null && !Array.isArray(snapshot.tools)) return false; - if (snapshot.instructions != null && typeof snapshot.instructions !== "string" && !Array.isArray(snapshot.instructions)) return false; - if (snapshot.prompt != null && !recordWithOnly(snapshot.prompt, ["id", "version", "variables"])) return false; - if (snapshot.tool_choice != null && typeof snapshot.tool_choice !== "string" - && !recordWithOnly(snapshot.tool_choice, ["type", "name", "namespace", "function"])) return false; - if (snapshot.usage != null && !recordWithOnly(snapshot.usage, ["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"])) return false; + if (snapshot.tools != null && !safeSnapshotTools(snapshot.tools, { remaining: 4096 })) return false; + if (snapshot.instructions != null && typeof snapshot.instructions !== "string") return false; + if (snapshot.prompt != null) { + if (!recordWithOnly(snapshot.prompt, ["id", "version", "variables"]) + || typeof snapshot.prompt.id !== "string" + || (snapshot.prompt.version != null && typeof snapshot.prompt.version !== "string") + || (snapshot.prompt.variables != null && (!isRecord(snapshot.prompt.variables) + || Object.values(snapshot.prompt.variables).some(value => typeof value !== "string")))) return false; + } + if (typeof snapshot.tool_choice === "string" && !["none", "auto", "required"].includes(snapshot.tool_choice)) return false; + if (snapshot.tool_choice != null && typeof snapshot.tool_choice !== "string") { + if (!recordWithOnly(snapshot.tool_choice, ["type", "name", "namespace", "function"]) + || !["function", "custom", "tool_search"].includes(String(snapshot.tool_choice.type)) + || (snapshot.tool_choice.name != null && typeof snapshot.tool_choice.name !== "string") + || (snapshot.tool_choice.namespace != null && typeof snapshot.tool_choice.namespace !== "string")) return false; + const selected = snapshot.tool_choice.function; + if (selected != null && (!recordWithOnly(selected, ["name"]) || typeof selected.name !== "string")) return false; + } + if (snapshot.usage != null && !safeSnapshotUsage(snapshot.usage)) return false; if (snapshot.metadata != null && (!isRecord(snapshot.metadata) || Object.values(snapshot.metadata).some(value => typeof value !== "string"))) return false; if (snapshot.reasoning != null) { if (!recordWithOnly(snapshot.reasoning, ["effort", "summary", "generate_summary"]) || Object.values(snapshot.reasoning).some(value => value != null && typeof value !== "string")) return false; + if (snapshot.reasoning.effort != null + && !["none", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(snapshot.reasoning.effort))) return false; + if ([snapshot.reasoning.summary, snapshot.reasoning.generate_summary].some(value => value != null + && !["auto", "concise", "detailed"].includes(String(value)))) return false; } if (snapshot.text != null) { if (!recordWithOnly(snapshot.text, ["format", "verbosity"]) || (snapshot.text.verbosity != null && typeof snapshot.text.verbosity !== "string")) return false; const format = snapshot.text.format; if (format != null && (!recordWithOnly(format, ["type", "name", "description", "schema", "strict"]) - || !["text", "json_object", "json_schema"].includes(String(format.type)))) return false; + || !["text", "json_object", "json_schema"].includes(String(format.type)) + || (format.name != null && typeof format.name !== "string") + || (format.description != null && typeof format.description !== "string") + || (format.schema != null && !isRecord(format.schema)) + || (format.strict != null && typeof format.strict !== "boolean"))) return false; } return true; } diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index f7f5a9d0598..aeeba04534d 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -136,7 +136,7 @@ about the same bytes. With `replayReadErrors`, the preflight reports the stage i `headers-only` before any parsed event, `protocol-prelude` after `response.created`, `semantic-output` once anything else arrives, including a payload it could not parse — and the resend gate decides. A lifecycle event with output, an error, incomplete details, a contradictory -status, a replay-unsafe mark, unknown event or snapshot fields, or a malformed snapshot commits the original stream. +status, a replay-unsafe mark, unknown event or snapshot fields, or unsafe nested metadata commits the original stream. An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 9b26fc073b1..cf31d26c55f 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -1005,6 +1005,16 @@ describe("combo stream preflight", () => { { prompt: { output_text: "already produced" } }, { tool_choice: { output_text: "already produced" } }, { instructions: { output_text: "already produced" } }, + { usage: { output_tokens_details: { output_text: "already produced" } } }, + { usage: { input_tokens: { output_text: "already produced" } } }, + { prompt: { id: { output_text: "already produced" } } }, + { tool_choice: { type: "function", function: { output_text: "already produced" } } }, + { tools: [{ type: "web_search" }] }, + { tools: [{ type: "function", name: "read", output_text: "already produced" }] }, + { tools: [{ type: "namespace", name: "ns", tools: [{ type: "mcp" }] }] }, + { tools: [{ type: "custom", name: "act", format: { type: { output_text: "already produced" } } }] }, + { tool_choice: "already produced" }, + { reasoning: { summary: "already produced" } }, ]) { for (const ending of ["eof", "read-error"] as const) { test(`${type} preserves unknown snapshot evidence on ${ending}`, async () => { @@ -1031,12 +1041,13 @@ describe("combo stream preflight", () => { let calls = 0; const response = { id: "resp_first", object: "response", created_at: 1, completed_at: null, status: type === "response.queued" ? "queued" : "in_progress", - model: "m1", output: [], error: null, incomplete_details: null, usage: null, + model: "m1", output: [], error: null, incomplete_details: null, + usage: { input_tokens: 4, output_tokens: 0, total_tokens: 4, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } }, background: false, instructions: null, max_output_tokens: null, max_tool_calls: null, metadata: {}, parallel_tool_calls: true, previous_response_id: null, prompt_cache_key: null, reasoning: { effort: null, summary: null }, safety_identifier: null, service_tier: "default", store: false, temperature: 1, text: { format: { type: "text" } }, - tool_choice: "auto", tools: [], top_p: 1, truncation: "disabled", user: null, + tool_choice: { type: "function", name: "read" }, tools: [{ type: "function", name: "read", parameters: { type: "object", properties: {} }, strict: false }], top_p: 1, truncation: "disabled", user: null, }; const wrapped = deferProtocolSafeResetRecovery(sse({ type, response }), { model: "m1", provider: "a" }, async () => { From 712dc8ff71666d11a45adbc5e927bbaba6266f18 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:54:38 +0900 Subject: [PATCH 11/22] Preserve response metadata during deferred reset recovery --- .../responses/combo-stream-preflight.ts | 22 +++- src/server/responses/passthrough-dispatch.ts | 6 +- .../responses-canonical-nonstream.test.ts | 31 ++++++ .../responses-main-quota-observation.test.ts | 9 +- .../responses/responses-reset-replay.test.ts | 100 ++++++++++++++++++ 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 36f60a94325..19b71dd20ed 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -617,6 +617,19 @@ export type ProtocolSafeResetRecovery = ( signal: AbortSignal, ) => Promise; +/** A deferred body cannot change the response head already returned to its caller. */ +export function sameDeferredResponseMetadata(original: Response, replacement: Response): boolean { + if (original.status !== replacement.status || original.statusText !== replacement.statusText) return false; + const first = original.headers.entries(); + const second = replacement.headers.entries(); + for (;;) { + const a = first.next(); + const b = second.next(); + if (a.done || b.done) return a.done === b.done; + if (a.value[0] !== b.value[0] || a.value[1] !== b.value[1]) return false; + } +} + /** * Defer protocol inspection until the downstream actually pulls the body. * @@ -663,8 +676,13 @@ export function deferProtocolSafeResetRecovery( if (!closed && (preflight.kind === "read-error" || preflight.kind === "pre-output-eof")) { const replacement = await recover(preflight.error, preflight.stage, preflightAbort.signal); if (replacement) { - cancelBody(selected.body, "using protocol-safe replacement stream"); - selected = replacement; + if (!closed && sameDeferredResponseMetadata(response, replacement)) { + cancelBody(selected.body, "using protocol-safe replacement stream"); + selected = replacement; + } else { + cancelBody(replacement.body, "declining protocol-safe replacement stream"); + if (!closed) console.warn("[upstream-retry] replacement response metadata differs; preserving original stream"); + } } } if (closed) { diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index be8c4f6aaad..f00122775c9 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -168,7 +168,7 @@ import { reasoningEffortRejectionText, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; -import { preflightComboStreamResponse } from "./combo-stream-preflight"; +import { preflightComboStreamResponse, sameDeferredResponseMetadata } from "./combo-stream-preflight"; import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; @@ -1917,6 +1917,7 @@ export async function preparePassthroughExchange( && remainingTransientSendBudget(transientSendAttempts()) > 0 && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && upstreamRequestsStream)); if (protocolRecoveryCandidate) { + const originalResponse = upstreamResponse; upstreamResponse = deferPassthroughResetRecovery( upstreamResponse, { model: logCtx.model, provider: logCtx.provider }, @@ -1934,7 +1935,8 @@ export async function preparePassthroughExchange( authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed, acceptResponse: candidate => { const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; - return type.includes("text/event-stream") || (!type && upstreamRequestsStream); + return sameDeferredResponseMetadata(originalResponse, candidate) + && (type.includes("text/event-stream") || (!type && upstreamRequestsStream)); }, }, ), diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 7ff2315d466..9afd642c1fe 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -381,6 +381,37 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 } }); + test.each([[true, false], [false, false], [true, true], [false, true]] as const)("refuses replacement with different turn metadata (stream=%s, combo=%s)", async (stream, combo) => { + let sends = 0; + let replacementCancelled = false; + const first = sseEvent("response.created", { response: { id: "resp_original", status: "in_progress", output: [] } }); + globalThis.fetch = (async () => { + sends++; + if (sends === 1) return new Response(first, { headers: { + "content-type": "text/event-stream", "x-codex-turn-state": "first", "x-request-id": "first-id", + } }); + return new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new TextEncoder().encode(sseEvent("response.completed", { + response: { id: "resp_replacement", status: "completed", output: [] }, + }))); controller.close(); }, + cancel() { replacementCancelled = true; }, + }, { highWaterMark: 0 }), { headers: { + "content-type": "text/event-stream", "x-codex-turn-state": "second", "x-request-id": "second-id", + } }); + }) as typeof fetch; + const resetConfig: OcxConfig = { ...config, providers: { openai: { ...provider, retryOnReset: {} }, + ...(combo ? { fixture: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-key", + baseUrl: "https://provider.example/v1", retryOnReset: {} } } : {}) }, + ...(combo ? { combos: { metadata: { strategy: "failover", targets: [{ provider: "fixture", model: "gpt-5.6-sol" }] } } } : {}) }; + const response = await call({ ...requestBody(stream, false), ...(combo ? { model: "combo/metadata" } : {}) }, {}, resetConfig); + const text = await response.text(); + expect(sends).toBe(2); + expect(text).not.toContain("resp_replacement"); + if (combo) expect(text).toContain("upstream_reset_replay_refused"); + else if (stream) expect(text).toContain("resp_original"); + expect(replacementCancelled).toBe(true); + }); + for (const ending of ["eof", "read-error"] as const) { test(`native streaming preserves lifecycle snapshot output after ${ending}`, async () => { let sends = 0; diff --git a/tests/responses/responses-main-quota-observation.test.ts b/tests/responses/responses-main-quota-observation.test.ts index 45d0bf6e3d9..6d43e722012 100644 --- a/tests/responses/responses-main-quota-observation.test.ts +++ b/tests/responses/responses-main-quota-observation.test.ts @@ -534,7 +534,7 @@ describe("credential-bound plain-main Responses quota", () => { }); } - test("plaintext V2 deferred reset keeps original headers bound to their arrival dispatch", async () => { + test("plaintext V2 deferred reset refuses a replacement with different quota headers", async () => { observe(); const renewalSpy = spyOn(mainCache, "renewMainQuotaDispatchForAttempt"); const claimSpy = spyOn(mainCache, "isMainQuotaDispatchWsClaimed"); @@ -566,16 +566,15 @@ describe("credential-bound plain-main Responses quota", () => { }), { ...config, defaultProvider: "openai", plaintextV2AgentMessages: true, providers: { openai: { ...provider, codexAccountMode: "direct", upstreamWebsocket: false, retryOnReset: {} } } }, { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); - expect(response.status).toBe(200); - expect(await response.text()).toContain("fixture-plaintext-reset"); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain("fixture-plaintext-reset"); expect(httpCalls).toBe(2); expect(renewalSpy).toHaveBeenCalledTimes(2); const arrival = renewalSpy.mock.results[0]!.value as mainCache.MainQuotaDispatch; const successor = renewalSpy.mock.results[1]!.value as mainCache.MainQuotaDispatch; expect(successor).not.toBe(arrival); expect(successor).toEqual(arrival); - // The prefix probe awaits the replacement before publication; the original proof stays live. - // Its 17% may publish only under that proof. Replacement-header publication is a follow-up. + // Only the original response's quota may publish under its arrival proof. expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); expect(claimSpy).toHaveBeenCalledTimes(1); diff --git a/tests/responses/responses-reset-replay.test.ts b/tests/responses/responses-reset-replay.test.ts index 3413782ba4f..30adbc4fc3d 100644 --- a/tests/responses/responses-reset-replay.test.ts +++ b/tests/responses/responses-reset-replay.test.ts @@ -5,6 +5,106 @@ import { selfContainedResponsesBody, } from "../../src/server/responses/reset-replay"; import { authorizeResendForRecovery } from "../../src/lib/request-resend-gate"; +import { deferProtocolSafeResetRecovery } from "../../src/server/responses/combo-stream-preflight"; + +const prelude = 'event: response.created\ndata: {"type":"response.created","response":{"id":"first","output":[]}}\n\n'; +const completed = 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"second","output":[]}}\n\n'; +const responseLog = { model: "test", provider: "test" }; + +describe("deferred response metadata", () => { + const baseHeaders = { "content-type": "text/event-stream", "x-request-id": "id-one", "x-codex-turn-state": "turn-one", "x-ratelimit-remaining-requests": "7", "x-model": "model-one" }; + test.each([ + ["status", { status: 201, headers: baseHeaders }], + ["status text", { statusText: "Different", headers: baseHeaders }], + ["content type", { headers: { ...baseHeaders, "content-type": "application/json" } }], + ["turn state", { headers: { ...baseHeaders, "x-codex-turn-state": "turn-two" } }], + ["quota", { headers: { ...baseHeaders, "x-ratelimit-remaining-requests": "6" } }], + ["model", { headers: { ...baseHeaders, "x-model": "model-two" } }], + ["request id", { headers: { ...baseHeaders, "x-request-id": "id-two" } }], + ["missing header", { headers: { "content-type": "text/event-stream" } }], + ["extra header", { headers: { ...baseHeaders, "x-new-provider-field": "value" } }], + ["set-cookie", { headers: { ...baseHeaders, "set-cookie": "fixture=value" } }], + ])("refuses a replacement with different %s and cancels its body", async (_name, replacementInit) => { + let cancelled = false; + const original = new Response(prelude, { headers: baseHeaders }); + const replacement = new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new TextEncoder().encode(completed)); controller.close(); }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }), replacementInit); + const result = deferProtocolSafeResetRecovery(original, responseLog, async () => replacement, { keepalive: "none" }); + expect(await result.text()).toBe(prelude); + expect(result.status).toBe(200); + expect([...result.headers]).toEqual([...original.headers]); + expect(cancelled).toBe(true); + }); + + test("accepts equal metadata and leaves ordinary calls unchanged", async () => { + const original = new Response(prelude, { headers: baseHeaders }); + const result = deferProtocolSafeResetRecovery(original, responseLog, async () => new Response(completed, { headers: baseHeaders }), { keepalive: "none" }); + expect(await result.text()).toBe(completed); + expect([...result.headers]).toEqual([...original.headers]); + let recovered = false; + const ordinary = new Response(completed, { headers: baseHeaders }); + const unchanged = deferProtocolSafeResetRecovery(ordinary, responseLog, async () => { recovered = true; return null; }, { keepalive: "none" }); + expect(await unchanged.text()).toBe(completed); + expect([...unchanged.headers]).toEqual([...ordinary.headers]); + expect(recovered).toBe(false); + }); + + test("refuses a changed repeated header", async () => { + const first = new Headers(baseHeaders); + first.append("set-cookie", "a=1"); + first.append("set-cookie", "b=2"); + const second = new Headers(baseHeaders); + second.append("set-cookie", "a=1"); + second.append("set-cookie", "b=3"); + const result = deferProtocolSafeResetRecovery(new Response(prelude, { headers: first }), responseLog, + async () => new Response(completed, { headers: second }), { keepalive: "none" }); + expect(await result.text()).toBe(prelude); + }); + + test("retains the original read error on metadata refusal", async () => { + const failure = new Error("original reset"); + let sent = false; + const original = new Response(new ReadableStream({ pull(controller) { + if (!sent) { sent = true; controller.enqueue(new TextEncoder().encode(prelude)); } + else controller.error(failure); + } }), { headers: baseHeaders }); + const result = deferProtocolSafeResetRecovery(original, responseLog, async () => new Response(completed, { + headers: { ...baseHeaders, "x-request-id": "id-two" }, + }), { keepalive: "none" }); + const reader = result.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe(prelude); + await expect(reader.read()).rejects.toBe(failure); + }); + + test("cancels a replacement arriving after the client disconnects", async () => { + let announce!: () => void; + let resolveReplacement!: (response: Response) => void; + let cancelled = false; + let aborted = false; + const started = new Promise(resolve => { announce = resolve; }); + const pending = new Promise(resolve => { resolveReplacement = resolve; }); + const result = deferProtocolSafeResetRecovery(new Response(prelude, { headers: baseHeaders }), responseLog, + async (_error, _stage, signal) => { + signal.addEventListener("abort", () => { aborted = true; }); + announce(); + return pending; + }, { keepalive: "none" }); + const reader = result.body!.getReader(); + const read = reader.read(); + await started; + await reader.cancel("client disconnected"); + expect(aborted).toBe(true); + const cancelledBody = new Promise(resolve => { + resolveReplacement(new Response(new ReadableStream({ cancel() { cancelled = true; resolve(); } }, + { highWaterMark: 0 }), { headers: baseHeaders })); + }); + await cancelledBody; + expect(cancelled).toBe(true); + await read; + }); +}); const clientTurn = { store: false, From f751e845239f97d6f9edf7cf7ea43700d11e420b Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:01:36 +0900 Subject: [PATCH 12/22] Document deferred response metadata boundary --- .../docs/fr/reference/configuration/providers.md | 2 +- .../docs/ja/reference/configuration/providers.md | 2 +- .../docs/ko/reference/configuration/providers.md | 2 +- .../content/docs/reference/configuration/providers.md | 2 +- .../docs/ru/reference/configuration/providers.md | 2 +- .../docs/tr/reference/configuration/providers.md | 2 +- .../docs/zh-cn/reference/configuration/providers.md | 2 +- .../docs/zh-tw/reference/configuration/providers.md | 2 +- src/server/responses/passthrough-completion.ts | 9 ++++++++- src/server/responses/passthrough-dispatch.ts | 10 +++------- structure/transports/responses-combo-boundaries.md | 4 +++- 11 files changed, 22 insertions(+), 17 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 15753c4f2af..7d9c04d31e3 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,7 +188,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à l'original ; sinon, le flux original est conservé. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index cbd1186483e..6bc3ad8cd94 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,7 +180,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。 +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが元の応答と一致しない場合は、元のストリームを保持します。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index e5c3dbdcf40..cfe725dae3a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,7 +180,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 원본과 일치할 때만 교체하며, 다르면 원본 스트림을 유지합니다. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 030c0c18547..dbb005b751f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -442,7 +442,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Direct streams can receive SSE keepalive comments during inspection; combo preflight holds these comments until it selects a response. +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. A replacement is used only when its HTTP status, status text and complete response headers match the original; otherwise the original stream is retained. Direct streams can receive SSE keepalive comments during inspection; combo preflight holds these comments until it selects a response. Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index d811d072f51..d0e34f8873c 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,7 +193,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с оригиналом; иначе сохраняется исходный поток. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 912ac9511f6..b9c89399756 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,7 +192,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları orijinalle eşleşen yanıt kullanılır; aksi halde orijinal akış korunur. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index b694a6fcf64..823c9cdaad3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,7 +182,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。 +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。仅当替换响应的 HTTP 状态、状态文本和所有响应头与原响应一致时才会替换;否则保留原始流。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index e171c95959c..caad48e4ade 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,7 +153,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。 +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與原回應相符時才會替換;否則保留原始串流。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 60bc47649b0..5ce9ab3374a 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -5,12 +5,19 @@ import { resolveStallTimeoutMs } from "../../stall-timeout"; import type { OcxConfig } from "../../types"; import { recordKeyWireAttemptUsage, usageFromResponsesPayload, type RequestLogContext } from "../request-log"; import { isPlainObject } from "../responses-snapshot-codec"; -import { deferProtocolSafeResetRecovery, type ProtocolSafeResetRecovery } from "./combo-stream-preflight"; +import { deferProtocolSafeResetRecovery, sameDeferredResponseMetadata, type ProtocolSafeResetRecovery } from "./combo-stream-preflight"; import { guardOpenGatewayCompletion } from "./opengateway-completion-guard"; import { carryResponseToolOwnership } from "./response-tool-ownership"; const guardedResetResponses = new WeakSet(); +/** Match both the already-returned head and the expected upstream stream contract. */ +export function acceptsDeferredReplacement(original: Response, candidate: Response, upstreamRequestsStream: boolean): boolean { + const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; + return sameDeferredResponseMetadata(original, candidate) + && (type.includes("text/event-stream") || (!type && upstreamRequestsStream)); +} + /** The gateway clock must see upstream reads, not generated comments or held preludes. */ export function deferPassthroughResetRecovery( response: Response, diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index f00122775c9..f61bb8167d1 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -168,7 +168,7 @@ import { reasoningEffortRejectionText, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; -import { preflightComboStreamResponse, sameDeferredResponseMetadata } from "./combo-stream-preflight"; +import { preflightComboStreamResponse } from "./combo-stream-preflight"; import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; @@ -176,7 +176,7 @@ import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-ze import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { createNativeContinuationDispatch } from "./native-continuation-send"; -import { deferPassthroughResetRecovery, guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; +import { acceptsDeferredReplacement, deferPassthroughResetRecovery, guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; /** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */ export interface PassthroughAdapterHandoff { kind: "adapter-handoff" } @@ -1933,11 +1933,7 @@ export async function preparePassthroughExchange( // allowance, which is what keeps a turn that already emitted output from draining // the replacement a later ambiguous reset would have been entitled to. authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed, - acceptResponse: candidate => { - const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; - return sameDeferredResponseMetadata(originalResponse, candidate) - && (type.includes("text/event-stream") || (!type && upstreamRequestsStream)); - }, + acceptResponse: candidate => acceptsDeferredReplacement(originalResponse, candidate, upstreamRequestsStream), }, ), { request, config, signal: upstream.signal, allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, keepalive: canonicalBufferedJson ? "upstream-activity" : options.comboAttempt ? "none" : "periodic" }, diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index aeeba04534d..3275545b409 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -140,7 +140,9 @@ status, a replay-unsafe mark, unknown event or snapshot fields, or unsafe nested An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery -owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. +owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. A +replacement with a different status, status text, or header set is cancelled and the original +stream is preserved because the response head may already be committed. The deferred native hold emits SSE comment heartbeats for direct streaming clients. Combo streaming children omit those timer-generated comments while their parent holds the headers, so those comments cannot consume the retained-chunk cap. For canonical buffered JSON, the hold emits a comment From a5c576955ac34a81e7bb453f8958ebbb13854709 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:46:33 +0900 Subject: [PATCH 13/22] Select reset replacement before committing the response head Native passthrough reset recovery returned the original HTTP head at once and then refused any replacement whose status or headers differed. Per-send quota headers, request ids and x-codex-turn-state always differ, so an ordinary pre-output reset or clean EOF could no longer recover. Dispatch now starts the protocol preflight immediately and waits for the first of selection, abort, or one heartbeat interval capped by the stall timeout. A selection that finishes first publishes the selected response, including a replacement with its own status, headers and body. When the deadline wins, the original head is returned and the same preflight continues behind it; a replacement must then match that head, checked at acceptance and on arrival. The published head is bound to the main quota proof of the attempt that produced it, so a renewed context proof cannot move ownership. The OpenGateway completion hold is shortened by the time selection already held the head. Abort ends selection or the hold at once without awaiting source cleanup. --- .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../responses/combo-stream-preflight.ts | 213 ++++++++++++++---- src/server/responses/core-codex-account.ts | 17 +- .../responses/opengateway-completion-guard.ts | 10 +- .../responses/passthrough-completion.ts | 62 +++-- src/server/responses/passthrough-delivery.ts | 2 +- src/server/responses/passthrough-dispatch.ts | 12 +- structure/providers/openai-tiers.md | 4 +- structure/providers/opengateway.md | 5 +- .../transports/responses-combo-boundaries.md | 20 +- .../responses-canonical-nonstream.test.ts | 184 +++++++++++++-- .../responses-main-quota-observation.test.ts | 121 +++++++++- .../responses/responses-reset-replay.test.ts | 196 +++++++++++++++- 20 files changed, 743 insertions(+), 119 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 7d9c04d31e3..c2056c35dc1 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,7 +188,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à l'original ; sinon, le flux original est conservé. +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Si la réinitialisation ou la fin de flux survient avant le renvoi de l'en-tête de réponse, dans les 15 secondes ou dans le délai d'inactivité s'il est plus court, le client reçoit le remplacement avec son propre statut et ses propres en-têtes. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original est conservé. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 6bc3ad8cd94..eb1f88a4e09 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,7 +180,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが元の応答と一致しない場合は、元のストリームを保持します。 +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。応答ヘッダーを返す前(15 秒以内、ストールタイムアウトの方が短い場合はその時間内)にリセットまたは EOF が起きた場合、クライアントは置換応答をそのステータスとヘッダーのまま受け取ります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームを保持します。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index cfe725dae3a..4d15a97df39 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,7 +180,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 원본과 일치할 때만 교체하며, 다르면 원본 스트림을 유지합니다. +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 응답 헤더를 반환하기 전(15초 이내, 정지 시간 제한이 더 짧으면 그 시간 이내)에 리셋이나 EOF가 발생하면 클라이언트는 교체 응답을 그 응답의 상태와 헤더 그대로 받습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림을 유지합니다. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index dbb005b751f..d73ad1f89ed 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -442,7 +442,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. A replacement is used only when its HTTP status, status text and complete response headers match the original; otherwise the original stream is retained. Direct streams can receive SSE keepalive comments during inspection; combo preflight holds these comments until it selects a response. +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. If the reset or EOF happens before the response head is returned, within 15 seconds or the stall timeout if that is shorter, the client receives the replacement with its own status and headers. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream is retained. Direct streams still being inspected after that point receive SSE keepalive comments; combo preflight holds these comments until it selects a response. Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index d0e34f8873c..908ee49228e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,7 +193,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с оригиналом; иначе сохраняется исходный поток. +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Если сброс или EOF происходит до возврата заголовков ответа, в течение 15 секунд или тайм-аута простоя, если он короче, клиент получает замену с её собственными статусом и заголовками. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняется исходный поток. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index b9c89399756..a3c2a102dcc 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,7 +192,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları orijinalle eşleşen yanıt kullanılır; aksi halde orijinal akış korunur. +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Sıfırlama veya EOF yanıt başlığı döndürülmeden önce, 15 saniye içinde ya da daha kısaysa takılma zaman aşımı içinde olursa istemci yedek yanıtı kendi durumu ve başlıklarıyla alır. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış korunur. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 823c9cdaad3..bb4b0b732a9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,7 +182,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。仅当替换响应的 HTTP 状态、状态文本和所有响应头与原响应一致时才会替换;否则保留原始流。 +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。如果重置或 EOF 发生在返回响应头之前(15 秒内,若停滞超时更短则在其时限内),客户端会收到带有其自身状态和响应头的替换响应。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index caad48e4ade..6a530055c6d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,7 +153,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與原回應相符時才會替換;否則保留原始串流。 +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。如果重設或 EOF 發生在回傳回應標頭之前(15 秒內,若停滯逾時更短則在其時限內),用戶端會收到帶有其自身狀態和回應標頭的替換回應。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 19b71dd20ed..667fb3e612b 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -615,6 +615,8 @@ export type ProtocolSafeResetRecovery = ( error: unknown, stage: RequestFailureStage, signal: AbortSignal, + /** Whether the selection can still publish this candidate; it narrows once a head is committed. */ + accepts: (candidate: Response) => boolean, ) => Promise; /** A deferred body cannot change the response head already returned to its caller. */ @@ -630,32 +632,59 @@ export function sameDeferredResponseMetadata(original: Response, replacement: Re } } +export interface DeferredResetRecoveryOptions { + allowMissingContentType?: boolean; + heartbeatIntervalMs?: number; + keepalive?: "periodic" | "upstream-activity" | "none"; +} + +/** Which physical attempt produced the head a reset selection published. */ +export type ResetSelectionOwner = "original" | "replacement"; + +export interface ResetSelectionOptions extends DeferredResetRecoveryOptions { + /** Upper bound on the pre-head wait. The wait never exceeds one heartbeat interval either. */ + maxSelectionMs?: number; + /** Ends the selection, the committed hold and an in-flight replacement without awaiting cleanup. */ + signal?: AbortSignal; + /** Called once, synchronously, before the caller receives the published response. */ + onCommit?: (published: Response, owner: ResetSelectionOwner) => void; +} + +const positiveMs = (value: number | undefined): number | undefined => + value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined; + +// A fresh chunk per enqueue: a consumer may transfer the buffer it receives. +const resetPreflightKeepalive = (): Uint8Array => new TextEncoder().encode(": keepalive\n\n"); + /** - * Defer protocol inspection until the downstream actually pulls the body. - * - * Direct passthrough must return response headers before the first SSE event arrives, so the - * inspection cannot be awaited at the dispatch site the way combo routing awaits it. Wrapping - * the body moves it to the first pull, which is the earliest moment the client is willing to - * wait anyway. + * One protocol preflight and at most one replacement for a native SSE body, behind a head that is + * either still open (`early`) or already committed. While the head is open, any replacement the + * caller's recovery accepts can be published with its own status and headers. Once a head is + * committed, a replacement must match it exactly, and the original keeps its bytes and error. */ -export function deferProtocolSafeResetRecovery( +function createResetSelection( response: Response, logCtx: RequestLogContext, recover: ProtocolSafeResetRecovery, - options?: { allowMissingContentType?: boolean; heartbeatIntervalMs?: number; keepalive?: "periodic" | "upstream-activity" | "none" }, -): Response { - if (!response.body) return response; - + options: ResetSelectionOptions | undefined, + early: boolean, +): { held: Response; selected?: Promise } { let reader: ReadableStreamDefaultReader | undefined; + let selection: { response: Response; owner: ResetSelectionOwner } | undefined; let initialization: Promise | undefined; let initializationResult: { kind: "ready" } | { kind: "failed"; error: unknown } | undefined; let notifyInitialization: (() => void) | undefined; + let commitEarly: ((published: Response, owner: ResetSelectionOwner) => void) | undefined; let activityPending = false; + let heartbeatDue = false; let closed = false; + let headCommitted = !early; + let output!: ReadableStreamDefaultController; const preflightAbort = new AbortController(); - const requestedHeartbeatMs = options?.heartbeatIntervalMs; - const heartbeatMs = requestedHeartbeatMs !== undefined && Number.isFinite(requestedHeartbeatMs) && requestedHeartbeatMs > 0 - ? requestedHeartbeatMs : RESET_PREFLIGHT_HEARTBEAT_MS; + const heartbeatMs = positiveMs(options?.heartbeatIntervalMs) ?? RESET_PREFLIGHT_HEARTBEAT_MS; + const keepalive = options?.keepalive ?? "periodic"; + const signal = options?.signal; + const accepts = (candidate: Response): boolean => !headCommitted || sameDeferredResponseMetadata(response, candidate); const cancelBody = (body: ReadableStream | null, reason?: unknown): void => { try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ } @@ -667,42 +696,72 @@ export function deferProtocolSafeResetRecovery( () => false, { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true, replayCleanEof: true, signal: preflightAbort.signal, - onUpstreamActivity: options?.keepalive === "upstream-activity" ? () => { + onUpstreamActivity: keepalive === "upstream-activity" ? () => { activityPending = true; notifyInitialization?.(); } : undefined }, ); - let selected = preflight.response; + let chosen: { response: Response; owner: ResetSelectionOwner } = { response: preflight.response, owner: "original" }; if (!closed && (preflight.kind === "read-error" || preflight.kind === "pre-output-eof")) { - const replacement = await recover(preflight.error, preflight.stage, preflightAbort.signal); + const replacement = await recover(preflight.error, preflight.stage, preflightAbort.signal, accepts); if (replacement) { - if (!closed && sameDeferredResponseMetadata(response, replacement)) { - cancelBody(selected.body, "using protocol-safe replacement stream"); - selected = replacement; + // Checked again here: the head may have been committed while the replacement was in flight. + if (!closed && accepts(replacement)) { + cancelBody(chosen.response.body, "using protocol-safe replacement stream"); + chosen = { response: replacement, owner: "replacement" }; } else { cancelBody(replacement.body, "declining protocol-safe replacement stream"); - if (!closed) console.warn("[upstream-retry] replacement response metadata differs; preserving original stream"); + if (!closed) console.warn("[upstream-retry] replacement response metadata differs from the committed head; preserving original stream"); } } } if (closed) { - cancelBody(selected.body, "downstream cancelled before protocol preflight completed"); + cancelBody(chosen.response.body, "downstream cancelled before protocol preflight completed"); return; } - reader = selected.body?.getReader(); + selection = chosen; }; + const start = (): Promise => initialization ??= initialize().then( + () => { initializationResult = { kind: "ready" }; notifyInitialization?.(); }, + error => { initializationResult = { kind: "failed", error }; notifyInitialization?.(); }, + ); + const detach = (): void => signal?.removeEventListener("abort", onAbort); + const release = (reason: unknown): boolean => { + if (closed) return false; + closed = true; + detach(); + preflightAbort.abort(reason); + notifyInitialization?.(); + if (reader) { + try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } + try { reader.releaseLock(); } catch { /* already released */ } + reader = undefined; + } else if (selection) { + cancelBody(selection.response.body, reason); + } else if (initialization === undefined) { + // Nothing has read the upstream yet, so this body is still ours to cancel. + cancelBody(response.body, reason); + } + // Otherwise aborting cancels the preflight's locked reader or the in-flight replacement, and + // initialization releases whichever body it ends up choosing. Nothing here awaits cleanup. + return true; + }; + function onAbort(): void { + const reason = signal?.reason; + if (release(reason)) { + try { output.error(reason); } catch { /* already closed */ } + } + commitEarly?.(held, "original"); + } const body = new ReadableStream({ + start(controller) { output = controller; }, async pull(controller) { try { - initialization ??= initialize().then( - () => { initializationResult = { kind: "ready" }; notifyInitialization?.(); }, - error => { initializationResult = { kind: "failed", error }; notifyInitialization?.(); }, - ); + void start(); // Keep only one waiter on the initialization; racing it afresh for every heartbeat // would retain one promise callback per interval until a quiet upstream settles. - const keepalive = options?.keepalive ?? "periodic"; - if (initializationResult === undefined && !(keepalive === "upstream-activity" && activityPending)) { + if (initializationResult === undefined && !heartbeatDue && !(keepalive === "upstream-activity" && activityPending)) { await new Promise(resolve => { const timer = keepalive === "periodic" ? setTimeout(() => { notifyInitialization = undefined; resolve(); }, heartbeatMs) @@ -713,12 +772,15 @@ export function deferProtocolSafeResetRecovery( if (closed) return; if (initializationResult === undefined) { activityPending = false; - controller.enqueue(new TextEncoder().encode(": keepalive\n\n")); + heartbeatDue = false; + controller.enqueue(resetPreflightKeepalive()); return; } - if (initializationResult?.kind === "failed") throw initializationResult.error; + if (initializationResult.kind === "failed") throw initializationResult.error; + reader ??= selection?.response.body?.getReader(); if (!reader) { closed = true; + detach(); controller.close(); return; } @@ -726,6 +788,7 @@ export function deferProtocolSafeResetRecovery( if (closed) return; if (next.done) { closed = true; + detach(); try { reader.releaseLock(); } catch { /* already released */ } reader = undefined; controller.close(); @@ -735,32 +798,88 @@ export function deferProtocolSafeResetRecovery( } catch (error) { if (closed) return; closed = true; + detach(); try { reader?.releaseLock(); } catch { /* errored reader */ } reader = undefined; controller.error(error); } }, cancel(reason) { - if (closed) return; - closed = true; - preflightAbort.abort(reason); - notifyInitialization?.(); - if (reader) { - try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } - try { reader.releaseLock(); } catch { /* already released */ } - reader = undefined; - } else if (initialization === undefined) { - // Nothing has read the upstream yet, so this body is still ours to cancel. - cancelBody(response.body, reason); - } - // Aborting cancels the preflight's locked reader or the in-flight replacement. Once - // initialization settles, it releases whichever body was selected. + release(reason); }, }, { highWaterMark: 0 }); - - return new Response(body, { + const held = carryResponseToolOwnership(response, new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers, + })); + + if (!early) { + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + return { held }; + } + const selected = new Promise(resolve => { + let deadline: ReturnType | undefined; + commitEarly = (published, owner) => { + commitEarly = undefined; + headCommitted = true; + if (deadline !== undefined) clearTimeout(deadline); + // A published selection owns its own body; the hold and its abort hook are finished. + if (published !== held) detach(); + try { options?.onCommit?.(published, owner); } finally { resolve(published); } + }; + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + deadline = setTimeout(() => { + // The client has waited one heartbeat interval for a head; its first read gets a keepalive. + if (keepalive === "periodic") heartbeatDue = true; + commitEarly?.(held, "original"); + }, Math.min(heartbeatMs, positiveMs(options?.maxSelectionMs) ?? Infinity)); + void start().then(() => { + if (selection && !closed) commitEarly?.(selection.response, selection.owner); + else commitEarly?.(held, "original"); + }); }); + return { held, selected }; +} + +/** + * Defer protocol inspection behind a head the caller has already committed. + * + * The inspection starts at the first pull. A replacement is published only when its status and + * headers match the committed head exactly. + */ +export function deferProtocolSafeResetRecovery( + response: Response, + logCtx: RequestLogContext, + recover: ProtocolSafeResetRecovery, + options?: DeferredResetRecoveryOptions, +): Response { + if (!response.body) return response; + return createResetSelection(response, logCtx, recover, options, false).held; +} + +/** + * Choose the native response before its head is committed, within one heartbeat interval. + * + * Inspection starts immediately. If selection finishes first, the caller receives the selected + * response, a replacement included, with its own status and headers. If the deadline wins, the + * caller receives the original head and the same preflight continues behind it, where a + * replacement must match that head. `onCommit` names which attempt owns the published head. + */ +export function selectProtocolSafeResetResponse( + response: Response, + logCtx: RequestLogContext, + recover: ProtocolSafeResetRecovery, + options: ResetSelectionOptions = {}, +): Promise { + if (!response.body) { + options.onCommit?.(response, "original"); + return Promise.resolve(response); + } + return createResetSelection(response, logCtx, recover, options, true).selected!; } diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index b7520b6c5ff..31d1cf20379 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -221,14 +221,25 @@ export class PoolPreOutputRefusals { } +// A recovery that can publish either of two physical attempts binds the proof to the published head. +const publishedMainQuotaDispatches = new WeakMap(); + +/** Bind the quota proof of the attempt whose head `published` carries; none means no proof. */ +export function bindPublishedMainQuotaDispatch(published: Response, dispatch: MainQuotaDispatch | undefined): void { + publishedMainQuotaDispatches.set(published, dispatch ?? null); +} + /** Live proof that this plain-main response used the observed main credential. */ export function liveMainQuotaDispatch( - authCtx: CodexAuthContext, provider: OcxProviderConfig, + authCtx: CodexAuthContext, provider: OcxProviderConfig, published?: Response, ): MainQuotaDispatch | undefined { - if (authCtx.kind !== "main" || !authCtx.mainQuotaDispatch) return undefined; + if (authCtx.kind !== "main") return undefined; + const dispatch = published && publishedMainQuotaDispatches.has(published) + ? publishedMainQuotaDispatches.get(published) : authCtx.mainQuotaDispatch; + if (!dispatch) return undefined; if (!isCanonicalOpenAiForwardProvider(provider) || provider.authMode !== "forward" || provider.adapter !== "openai-responses") return undefined; - return isMainQuotaDispatchLive(authCtx.mainQuotaDispatch) ? authCtx.mainQuotaDispatch : undefined; + return isMainQuotaDispatchLive(dispatch) ? dispatch : undefined; } export function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig, modelId?: string): CodexWsQuotaObserver | undefined { diff --git a/src/server/responses/opengateway-completion-guard.ts b/src/server/responses/opengateway-completion-guard.ts index acdb366edc4..243750093ef 100644 --- a/src/server/responses/opengateway-completion-guard.ts +++ b/src/server/responses/opengateway-completion-guard.ts @@ -58,6 +58,8 @@ export interface OpenGatewayCompletionGuardOptions { readonly stallTimeoutMs?: number; /** This response's real upstream body is already guarded before reset preflight. */ readonly initialBodyInactivityGuarded?: boolean; + /** Time reset selection already held this response's head; the first hold is shortened by it. */ + readonly initialHoldSpentMs?: number; /** Expiry releases original bytes; it never aborts a healthy upstream. */ readonly holdTimeoutMs?: number; } @@ -65,7 +67,7 @@ export interface OpenGatewayCompletionGuardOptions { type Inspection = { kind: "pass"; response: Response } | { kind: "empty" | "invalid"; snapshot: RecordValue; streaming: boolean }; -async function inspect(response: Response, options: OpenGatewayCompletionGuardOptions, alreadyGuarded: boolean): Promise { +async function inspect(response: Response, options: OpenGatewayCompletionGuardOptions, alreadyGuarded: boolean, heldMs: number): Promise { const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; const streaming = contentType.includes("text/event-stream") || (!contentType && isPlainObject(options.body) && options.body.stream === true); @@ -77,7 +79,7 @@ async function inspect(response: Response, options: OpenGatewayCompletionGuardOp const held: Array = []; let bytes = 0, events = 0, transferred = false, cleaned = false; let pending: ReturnType | undefined; - const holdMs = options.holdTimeoutMs ?? MAX_HOLD_MS; + const holdMs = Math.max(0, (options.holdTimeoutMs ?? MAX_HOLD_MS) - heldMs); const deadline = performance.now() + holdMs; let timer: ReturnType | undefined; const expired = new Promise<"expired">(resolve => { timer = setTimeout(() => resolve("expired"), holdMs); }); @@ -215,7 +217,9 @@ export async function guardOpenGatewayCompletion(response: Response, options: Op && selfContainedResponsesBody(body) && !isNonReplayableResponse(response); let selected = response; for (let attempt = 0; ; attempt++) { - const inspected = await inspect(selected, options, attempt === 0 && options.initialBodyInactivityGuarded === true); + const initial = attempt === 0; + const inspected = await inspect(selected, options, initial && options.initialBodyInactivityGuarded === true, + initial ? Math.max(0, options.initialHoldSpentMs ?? 0) : 0); if (inspected.kind === "pass") return inspected.response; const usage = inspected.snapshot.usage; options.recordUsage(usage); diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 5ce9ab3374a..02da010c79f 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -1,26 +1,38 @@ import type { AdapterRequest } from "../../adapters/base"; import { formatErrorResponse } from "../../bridge"; +import type { MainQuotaDispatch } from "../../codex/main-account-cache"; +import { isLocalUpstream } from "../../lib/local-upstream"; import { guardResponseBodyInactivity } from "../../lib/response-body-inactivity"; import { resolveStallTimeoutMs } from "../../stall-timeout"; import type { OcxConfig } from "../../types"; import { recordKeyWireAttemptUsage, usageFromResponsesPayload, type RequestLogContext } from "../request-log"; import { isPlainObject } from "../responses-snapshot-codec"; -import { deferProtocolSafeResetRecovery, sameDeferredResponseMetadata, type ProtocolSafeResetRecovery } from "./combo-stream-preflight"; +import { selectProtocolSafeResetResponse, type ProtocolSafeResetRecovery } from "./combo-stream-preflight"; +import { bindPublishedMainQuotaDispatch } from "./core-codex-account"; +import type { ResponsesAdmissionState } from "./core-options"; import { guardOpenGatewayCompletion } from "./opengateway-completion-guard"; import { carryResponseToolOwnership } from "./response-tool-ownership"; -const guardedResetResponses = new WeakSet(); +// A guarded published reset response, mapped to when its selection began holding the head. +const guardedResetResponses = new WeakMap(); -/** Match both the already-returned head and the expected upstream stream contract. */ -export function acceptsDeferredReplacement(original: Response, candidate: Response, upstreamRequestsStream: boolean): boolean { +/** A replacement must still be the stream the upstream request asked for; refetch requires 2xx. */ +export function isResetReplacementStream(candidate: Response, upstreamRequestsStream: boolean): boolean { const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; - return sameDeferredResponseMetadata(original, candidate) - && (type.includes("text/event-stream") || (!type && upstreamRequestsStream)); + return type.includes("text/event-stream") || (!type && upstreamRequestsStream); } -/** The gateway clock must see upstream reads, not generated comments or held preludes. */ -export function deferPassthroughResetRecovery( +/** + * Select the native response a reset preflight publishes. + * + * The selection waits at most one heartbeat interval, and never longer than the stall budget, for + * a head; see `selectProtocolSafeResetResponse`. The gateway clock must see upstream reads, not + * generated comments or held preludes. The published head carries the main quota proof of the + * physical attempt that produced it, because the replacement send renews the context's proof. + */ +export function selectPassthroughResetResponse( response: Response, + admission: Pick, logCtx: RequestLogContext, recover: ProtocolSafeResetRecovery, options: { @@ -30,7 +42,7 @@ export function deferPassthroughResetRecovery( allowMissingContentType: boolean; keepalive: "periodic" | "upstream-activity" | "none"; }, -): Response { +): Promise { const guard = options.request.url === "https://apis.opengateway.ai/v1/responses" && response.status === 200 && response.body !== null; const protect = (source: Response): Response => guard @@ -38,13 +50,28 @@ export function deferPassthroughResetRecovery( source, options.signal, resolveStallTimeoutMs(options.config.stallTimeoutSec), ).response) : source; - const deferred = deferProtocolSafeResetRecovery(protect(response), logCtx, async (error, stage, signal) => { + const quotaOwner = (): MainQuotaDispatch | undefined => + admission.authCtx.kind === "main" ? admission.authCtx.mainQuotaDispatch : undefined; + const originalOwner = quotaOwner(); + let replacementOwner: MainQuotaDispatch | undefined; + const stallMs = resolveStallTimeoutMs(options.config.stallTimeoutSec, { localUpstream: isLocalUpstream(options.request.url) }); + const heldSince = performance.now(); + return selectProtocolSafeResetResponse(protect(response), logCtx, async (error, stage, signal, accepts) => { // Recover returns only a validated, fresh body; wrapping earlier would hide its lock state. - const replacement = await recover(error, stage, signal); - return replacement ? protect(replacement) : null; - }, options); - if (guard) guardedResetResponses.add(deferred); - return deferred; + const replacement = await recover(error, stage, signal, accepts); + if (!replacement) return null; + replacementOwner = quotaOwner(); + return protect(replacement); + }, { + allowMissingContentType: options.allowMissingContentType, + keepalive: options.keepalive, + signal: options.signal, + maxSelectionMs: stallMs > 0 ? stallMs : undefined, + onCommit: (published, owner) => { + if (guard) guardedResetResponses.set(published, heldSince); + bindPublishedMainQuotaDispatch(published, owner === "replacement" ? replacementOwner : originalOwner); + }, + }); } export function parseOutboundRequestBody(bodyText: string): Record | undefined { @@ -65,13 +92,16 @@ export function guardPassthroughCompletion( rebuildAndRefetch: (kind: "empty-completion") => Promise, ): Promise { if (request.url !== "https://apis.opengateway.ai/v1/responses") return Promise.resolve(response); + const heldSince = guardedResetResponses.get(response); return guardOpenGatewayCompletion(response, { url: request.url, body: parseOutboundRequestBody(request.body), signal, retryEnabled: config.emptyCompletionRetry, stallTimeoutMs: resolveStallTimeoutMs(config.stallTimeoutSec), - initialBodyInactivityGuarded: guardedResetResponses.has(response), + initialBodyInactivityGuarded: heldSince !== undefined, + // The completion hold and reset selection share one bound on how long the head waits. + initialHoldSpentMs: heldSince === undefined ? 0 : performance.now() - heldSince, recordUsage: usage => { recordKeyWireAttemptUsage(logCtx, usageFromResponsesPayload(usage)); }, retry: async () => { const result = await rebuildAndRefetch("empty-completion"); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 2bb2301bf50..cf5f44ce8e9 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -438,7 +438,7 @@ export async function deliverPassthroughResponse( ): Promise { const { route } = requestState; // The proof must belong to the response whose headers are published. - const arrivalMainDispatch = liveMainQuotaDispatch(admissionState.authCtx, route.provider); + const arrivalMainDispatch = liveMainQuotaDispatch(admissionState.authCtx, route.provider, nativeExchange.upstreamResponse); const { logCtx, config, options, req } = requestContext; const { codexSafetyBufferingOptions, diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index f61bb8167d1..0e7636ba9f5 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -176,7 +176,7 @@ import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-ze import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { createNativeContinuationDispatch } from "./native-continuation-send"; -import { acceptsDeferredReplacement, deferPassthroughResetRecovery, guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; +import { guardPassthroughCompletion, isResetReplacementStream, parseOutboundRequestBody, selectPassthroughResetResponse } from "./passthrough-completion"; /** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */ export interface PassthroughAdapterHandoff { kind: "adapter-handoff" } @@ -1917,11 +1917,11 @@ export async function preparePassthroughExchange( && remainingTransientSendBudget(transientSendAttempts()) > 0 && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && upstreamRequestsStream)); if (protocolRecoveryCandidate) { - const originalResponse = upstreamResponse; - upstreamResponse = deferPassthroughResetRecovery( - upstreamResponse, + // Selection may publish the replacement's own head until one is committed; see the helper. + upstreamResponse = await selectPassthroughResetResponse( + upstreamResponse, admissionState, { model: logCtx.model, provider: logCtx.provider }, - (error, stage, signal) => refetchAfterProtocolSafeReset( + (error, stage, signal, accepts) => refetchAfterProtocolSafeReset( (signal = upstream.signal) => sendAmbiguousReplacement(signal).then(adoptObservedResponse), error, { @@ -1933,7 +1933,7 @@ export async function preparePassthroughExchange( // allowance, which is what keeps a turn that already emitted output from draining // the replacement a later ambiguous reset would have been entitled to. authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed, - acceptResponse: candidate => acceptsDeferredReplacement(originalResponse, candidate, upstreamRequestsStream), + acceptResponse: candidate => accepts(candidate) && isResetReplacementStream(candidate, upstreamRequestsStream), }, ), { request, config, signal: upstream.signal, allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, keepalive: canonicalBufferedJson ? "upstream-activity" : options.comboAttempt ? "none" : "periodic" }, diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 7308da0bde6..e44356e0549 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -451,8 +451,8 @@ remain process-local and never enter disk, logs, or management DTOs. Plain-main HTTP and WebSocket Responses on the canonical OpenAI forward provider refresh cached main usage under that same credential/workspace match, including stored-main substitution and an identical caller-owned credential. Materialization captures a process-local dispatch proof; -HTTP delivery captures the response-arrival proof before any body await and retains it for those -headers even if deferred recovery renews the context. Publication rechecks identity and credential +HTTP delivery captures the proof of the response it publishes before any body await. Native reset selection binds a published replacement head to the replacement attempt's renewed proof +and an original head to its arrival proof, so a later context renewal cannot move ownership of those headers. Publication rechecks identity and credential generations, including after an awaited HTTP import and for every WebSocket frame. A replaced credential, unmatched workspace, or custom destination cannot publish main usage. Pool health/failover handling stays scoped to Pool contexts. diff --git a/structure/providers/opengateway.md b/structure/providers/opengateway.md index e8176f8e3e2..d1fde4a614b 100644 --- a/structure/providers/opengateway.md +++ b/structure/providers/opengateway.md @@ -38,8 +38,9 @@ released; terminal disposal and consumer cancellation do not await upstream clea keeps the existing stall-timeout policy. When reset preflight is active, its source and any accepted reset replacement are guarded before preflight, so the clock follows upstream reads rather than synthetic keepalives or held combo frames. The completion guard skips only the duplicate clock for -that initial selected body; a later empty-completion retry gets its own guard. Rewritten responses -preserve tool ownership. +that initial selected body; a later empty-completion retry gets its own guard. Its first hold is +shortened by the time reset selection already held the head, so both holds share the 30-second +bound. Rewritten responses preserve tool ownership. `tests/responses/opengateway-completion-receipts.test.ts` covers wire outcomes and resource release; `tests/responses/empty-completion-core.test.ts` and `tests/responses/empty-completion-hardening.test.ts` cover integration and replay barriers. Other destinations and translated retry defaults are unchanged. diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 3275545b409..26fb8bcc43e 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -140,15 +140,25 @@ status, a replay-unsafe mark, unknown event or snapshot fields, or unsafe nested An SSE event name that conflicts with its JSON type, an opaque frame, or an unfinished output/error/terminal frame on a rejected read also keeps the original bytes and read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery -owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. A -replacement with a different status, status text, or header set is cancelled and the original -stream is preserved because the response head may already be committed. -The deferred native hold emits SSE comment heartbeats for direct streaming clients. Combo streaming +owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. +Native passthrough starts this preflight at dispatch, before it returns a head +(`selectProtocolSafeResetResponse` in `src/server/responses/combo-stream-preflight.ts`). Dispatch +waits for whichever comes first: the selection, an abort, or a deadline of one 15-second heartbeat +interval capped by the stall timeout. A selection that finishes first publishes the selected +response, including a replacement with its own status and headers. If the deadline wins, the +original head is returned and the same preflight continues behind it. From then on a replacement +with a different status, status text, or header set is cancelled and the original bytes and error +are preserved; the check runs when the recovery accepts the replacement and again when it lands. +`src/server/responses/passthrough-completion.ts` binds the published head to the main quota proof +of the attempt that produced it ([OpenAI tiers](../providers/openai-tiers.md)). Direct streaming +clients get an SSE comment on their first read after a deadline commit and then once per heartbeat +interval while the hold lasts. Combo streaming children omit those timer-generated comments while their parent holds the headers, so those comments cannot consume the retained-chunk cap. For canonical buffered JSON, the hold emits a comment only when it reads non-empty upstream bytes: this reports real prelude activity to the collector's inactivity clock without making a silent provider appear active. The byte and chunk caps remain in force. -Downstream cancellation aborts the hold or an in-flight replacement. +Downstream cancellation or the request's abort signal ends the selection or hold at once, aborts an +in-flight replacement, and never awaits upstream cleanup. - a non-control Responses event (apart from the held mandatory-tool reasoning prelude above) begins client-visible output or a tool/action item, after which the target is committed and cross-target replay is forbidden; diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 9afd642c1fe..5c4afb0c95b 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, jest, test } from "bun:test"; import { readFileSync } from "node:fs"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { getDefaultConfig } from "../../src/config"; @@ -381,35 +381,181 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 } }); - test.each([[true, false], [false, false], [true, true], [false, true]] as const)("refuses replacement with different turn metadata (stream=%s, combo=%s)", async (stream, combo) => { + const selectionMatrix = (["eof", "reset"] as const).flatMap(ending => ([[true, false], [false, false], [true, true], [false, true]] as const) + .map(([stream, combo]) => [ending, stream, combo] as const)); + const metadataHeaders = (turn: string, requestId: string, remaining: string): Record => ({ + "content-type": "text/event-stream", "x-codex-turn-state": turn, "x-request-id": requestId, + "x-ratelimit-remaining-requests": remaining, + }); + const metadataConfig = (combo: boolean): OcxConfig => ({ ...config, providers: { openai: { ...provider, retryOnReset: {} }, + ...(combo ? { fixture: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-key", + baseUrl: "https://provider.example/v1", retryOnReset: {} } } : {}) }, + ...(combo ? { combos: { metadata: { strategy: "failover", targets: [{ provider: "fixture", model: "gpt-5.6-sol" }] } } } : {}) }); + const metadataBody = (stream: boolean, combo: boolean) => ({ ...requestBody(stream, false), ...(combo ? { model: "combo/metadata" } : {}) }); + /** A created prelude, then a pre-output EOF or connection reset once `gate` opens. */ + const preludeThenEnd = (ending: "eof" | "reset", headers: Record, waiting?: () => void, gate?: Promise, + cancel?: () => unknown) => { + let sent = false; + return new Response(new ReadableStream({ + async pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new TextEncoder().encode(sseEvent("response.created", { response: { id: "resp_original", status: "in_progress", output: [] } }))); + return; + } + waiting?.(); + await gate; + if (ending === "eof") controller.close(); + else controller.error(Object.assign(new Error("fixture pre-output reset"), { code: "ECONNRESET" })); + }, + cancel, + }, { highWaterMark: 0 }), { headers }); + }; + const trackedReplacement = (state: { cancelled: boolean }) => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new TextEncoder().encode(sseEvent("response.completed", { + response: { id: "resp_replacement", status: "completed", output: [] }, + }))); controller.close(); }, + cancel() { state.cancelled = true; }, + }, { highWaterMark: 0 }), { headers: metadataHeaders("second", "second-id", "6") }); + const flushMicrotasks = async () => { for (let i = 0; i < 100; i++) await Promise.resolve(); }; + + test.each(selectionMatrix)("publishes a pre-head %s replacement with its own metadata (stream=%s, combo=%s)", async (ending, stream, combo) => { let sends = 0; - let replacementCancelled = false; - const first = sseEvent("response.created", { response: { id: "resp_original", status: "in_progress", output: [] } }); + const replacement = { cancelled: false }; + const outboundTurnState: (string | null)[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sends++; + outboundTurnState.push(new Headers(init?.headers).get("x-codex-turn-state")); + if (sends === 1) return preludeThenEnd(ending, metadataHeaders("first", "first-id", "7")); + if (sends === 2) return trackedReplacement(replacement); + return new Response(sseEvent("response.completed", { response: { id: "resp_follow_up", status: "completed", output: [] } }), + { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const resetConfig = metadataConfig(combo); + const response = await call(metadataBody(stream, combo), {}, resetConfig); + const text = await response.text(); + expect(sends).toBe(2); + expect(response.status).toBe(200); + expect(text).toContain("resp_replacement"); + expect(text).not.toContain("resp_original"); + expect(replacement.cancelled).toBe(false); + expect(response.headers.get("x-ratelimit-remaining-requests")).toBe("6"); + const handle = response.headers.get("x-codex-turn-state"); + expect(handle).not.toBeNull(); + // A key-auth combo target never receives a client turn state; the direct route redeems it. + if (combo) return; + // The client echoes the published turn state; it must redeem to the replacement's token. + const followUp = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer fixture-forward-token", "x-codex-turn-state": handle! }, + body: JSON.stringify(metadataBody(true, combo)), + }), resetConfig, { model: "", provider: "" }); + await followUp.text(); + expect(sends).toBe(3); + expect(outboundTurnState[2]).toBe("second"); + }); + + test.each(selectionMatrix)("a heartbeat commits the original head before a later %s (stream=%s, combo=%s)", async (ending, stream, combo) => { + jest.useFakeTimers(); + let sends = 0; + const replacement = { cancelled: false }; + const gate = Promise.withResolvers(); + const waiting = Promise.withResolvers(); globalThis.fetch = (async () => { sends++; - if (sends === 1) return new Response(first, { headers: { - "content-type": "text/event-stream", "x-codex-turn-state": "first", "x-request-id": "first-id", - } }); + return sends === 1 + ? preludeThenEnd(ending, metadataHeaders("first", "first-id", "7"), waiting.resolve, gate.promise) + : trackedReplacement(replacement); + }) as typeof fetch; + const pending = call(metadataBody(stream, combo), {}, metadataConfig(combo)); + try { + // Selection is still open: the prelude is held and the upstream has not ended. + await waiting.promise; + await flushMicrotasks(); + expect(sends).toBe(1); + jest.advanceTimersByTime(15_000); + await flushMicrotasks(); + let text = ""; + let response: Response; + if (stream && !combo) { + // The direct client already holds the original head and an immediate keepalive. + response = await pending; + const reader = response.body!.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toBe(": keepalive\n\n"); + gate.resolve(); + for (;;) { + const next = await reader.read().catch(() => ({ done: true as const, value: undefined })); + if (next.done) break; + text += new TextDecoder().decode(next.value); + } + } else { + gate.resolve(); + response = await pending; + text = await response.text(); + } + expect(sends).toBe(2); + expect(replacement.cancelled).toBe(true); + expect(text).not.toContain("resp_replacement"); + if (stream && !combo) { + expect(text).toContain("resp_original"); + expect(response.headers.get("x-ratelimit-remaining-requests")).toBe("7"); + } + } finally { + gate.resolve(); + await pending.then(response => response.body?.cancel().catch(() => undefined), () => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + // Pre-head selection publishes a replacement's own status, so the existing 2xx refetch gate matters. + test.each([true, false])("a failed pre-head replacement never replaces the original (stream=%s)", async stream => { + let sends = 0; + const replacement = { cancelled: false }; + globalThis.fetch = (async () => { + sends++; + if (sends === 1) return preludeThenEnd("reset", metadataHeaders("first", "first-id", "7")); return new Response(new ReadableStream({ pull(controller) { controller.enqueue(new TextEncoder().encode(sseEvent("response.completed", { response: { id: "resp_replacement", status: "completed", output: [] }, }))); controller.close(); }, - cancel() { replacementCancelled = true; }, - }, { highWaterMark: 0 }), { headers: { - "content-type": "text/event-stream", "x-codex-turn-state": "second", "x-request-id": "second-id", - } }); + cancel() { replacement.cancelled = true; }, + }, { highWaterMark: 0 }), { status: 503, headers: metadataHeaders("second", "second-id", "6") }); }) as typeof fetch; - const resetConfig: OcxConfig = { ...config, providers: { openai: { ...provider, retryOnReset: {} }, - ...(combo ? { fixture: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-key", - baseUrl: "https://provider.example/v1", retryOnReset: {} } } : {}) }, - ...(combo ? { combos: { metadata: { strategy: "failover", targets: [{ provider: "fixture", model: "gpt-5.6-sol" }] } } } : {}) }; - const response = await call({ ...requestBody(stream, false), ...(combo ? { model: "combo/metadata" } : {}) }, {}, resetConfig); + const response = await call(metadataBody(stream, false), {}, metadataConfig(false)); const text = await response.text(); expect(sends).toBe(2); + expect(replacement.cancelled).toBe(true); + expect(response.status).not.toBe(503); expect(text).not.toContain("resp_replacement"); - if (combo) expect(text).toContain("upstream_reset_replay_refused"); - else if (stream) expect(text).toContain("resp_original"); - expect(replacementCancelled).toBe(true); + expect(response.headers.get("x-ratelimit-remaining-requests")).not.toBe("6"); + }); + + test("client abort ends pre-head selection although upstream cancellation never settles", async () => { + let sends = 0; + let sourceCancels = 0; + const waiting = Promise.withResolvers(); + globalThis.fetch = (async () => { + sends++; + return preludeThenEnd("reset", metadataHeaders("first", "first-id", "7"), waiting.resolve, new Promise(() => {}), () => { + sourceCancels++; + return new Promise(() => {}); + }); + }) as typeof fetch; + releaseSpendHome = acquireOwnedSpendHome(); + const client = new AbortController(); + const pending = handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", signal: client.signal, + headers: { "content-type": "application/json", authorization: "Bearer fixture-forward-token" }, + body: JSON.stringify(metadataBody(true, false)), + }), metadataConfig(false), { model: "", provider: "" }); + await waiting.promise; + client.abort(new Error("client left")); + const response = await pending; + await response.body?.cancel().catch(() => undefined); + expect(sends).toBe(1); + expect(sourceCancels).toBe(1); }); for (const ending of ["eof", "read-error"] as const) { diff --git a/tests/responses/responses-main-quota-observation.test.ts b/tests/responses/responses-main-quota-observation.test.ts index 6d43e722012..239568c5d18 100644 --- a/tests/responses/responses-main-quota-observation.test.ts +++ b/tests/responses/responses-main-quota-observation.test.ts @@ -534,7 +534,7 @@ describe("credential-bound plain-main Responses quota", () => { }); } - test("plaintext V2 deferred reset refuses a replacement with different quota headers", async () => { + test("plaintext V2 pre-head reset publishes the replacement under its own attempt proof", async () => { observe(); const renewalSpy = spyOn(mainCache, "renewMainQuotaDispatchForAttempt"); const claimSpy = spyOn(mainCache, "isMainQuotaDispatchWsClaimed"); @@ -566,20 +566,131 @@ describe("credential-bound plain-main Responses quota", () => { }), { ...config, defaultProvider: "openai", plaintextV2AgentMessages: true, providers: { openai: { ...provider, codexAccountMode: "direct", upstreamWebsocket: false, retryOnReset: {} } } }, { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); - expect(response.status).toBe(502); - expect(await response.text()).not.toContain("fixture-plaintext-reset"); + expect(response.status).toBe(200); + expect(await response.text()).toContain("fixture-plaintext-reset"); expect(httpCalls).toBe(2); expect(renewalSpy).toHaveBeenCalledTimes(2); const arrival = renewalSpy.mock.results[0]!.value as mainCache.MainQuotaDispatch; const successor = renewalSpy.mock.results[1]!.value as mainCache.MainQuotaDispatch; expect(successor).not.toBe(arrival); expect(successor).toEqual(arrival); - // Only the original response's quota may publish under its arrival proof. + // The reset preceded the head, so the client receives the replacement's head and only its + // quota publishes, under the proof of the attempt that produced it. + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(claimSpy).toHaveBeenCalledTimes(1); + expect(claimSpy.mock.calls[0]![0]).toBe(successor); + expect(claimSpy.mock.calls[0]![0]).not.toBe(arrival); + } finally { + claimSpy.mockRestore(); + renewalSpy.mockRestore(); + } + }); + + const resetQuotaConfig = (stallTimeoutSec?: number): OcxConfig => ({ ...config, defaultProvider: "openai", + ...(stallTimeoutSec === undefined ? {} : { stallTimeoutSec }), + providers: { openai: { ...provider, codexAccountMode: "direct", upstreamWebsocket: false, retryOnReset: {} } } }); + const resetQuotaRequest = () => new Request("http://localhost/v1/responses", { + method: "POST", headers: new Headers({ ...Object.fromEntries(caller()), "content-type": "application/json" }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, store: false, input: "fixture self-contained turn" }), + }); + const sseQuotaHeaders = (percent: string, contentType = "text/event-stream"): Headers => { + const headers = quotaHeaders(percent); + headers.set("content-type", contentType); + return headers; + }; + const completedFrame = (id: string) => new TextEncoder().encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", response: { id, status: "completed", output: [] }, + })}\n\n`); + + test("heartbeat-committed reset keeps the arrival head and never publishes replacement quota", async () => { + observe(); + const renewalSpy = spyOn(mainCache, "renewMainQuotaDispatchForAttempt"); + const claimSpy = spyOn(mainCache, "isMainQuotaDispatchWsClaimed"); + const gate = Promise.withResolvers(); + let httpCalls = 0; + let replacementCancelled = false; + globalThis.fetch = (async () => { + httpCalls++; + if (httpCalls === 1) { + let sent = false; + return new Response(new ReadableStream({ + async pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new TextEncoder().encode(`event: response.created\ndata: ${JSON.stringify({ + type: "response.created", response: { id: "fixture-original", status: "in_progress", output: [] }, + })}\n\n`)); + return; + } + await gate.promise; + controller.error(Object.assign(new Error("fixture pre-output reset"), { code: "ECONNRESET" })); + }, + }, { highWaterMark: 0 }), { status: 200, headers: sseQuotaHeaders("17") }); + } + return new Response(new ReadableStream({ + pull(controller) { controller.enqueue(completedFrame("fixture-replacement")); controller.close(); }, + cancel() { replacementCancelled = true; }, + }, { highWaterMark: 0 }), { status: 200, headers: sseQuotaHeaders("99") }); + }) as typeof fetch; + try { + // A one-second stall budget bounds the pre-head wait, so the head commits before the reset. + const response = await handleResponses(resetQuotaRequest(), resetQuotaConfig(1), + { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + expect(httpCalls).toBe(1); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + gate.resolve(); + const text = await response.text(); + expect(httpCalls).toBe(2); + expect(replacementCancelled).toBe(true); + expect(text).toContain("fixture-original"); + expect(text).not.toContain("fixture-replacement"); expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); + expect(renewalSpy).toHaveBeenCalledTimes(2); + // Renewed proofs are structurally equal; ownership is identity. + expect(claimSpy).toHaveBeenCalledTimes(1); + expect(claimSpy.mock.calls[0]![0]).toBe(renewalSpy.mock.results[0]!.value); + expect(claimSpy.mock.calls[0]![0]).not.toBe(renewalSpy.mock.results[1]!.value); + } finally { + gate.resolve(); + claimSpy.mockRestore(); + renewalSpy.mockRestore(); + } + }); + + test("a refused pre-head replacement leaves the original head bound to its arrival proof", async () => { + observe(); + const renewalSpy = spyOn(mainCache, "renewMainQuotaDispatchForAttempt"); + const claimSpy = spyOn(mainCache, "isMainQuotaDispatchWsClaimed"); + let httpCalls = 0; + globalThis.fetch = (async () => { + httpCalls++; + if (httpCalls === 1) { + return new Response(new ReadableStream({ + async pull(controller) { + await new Promise(resolve => setTimeout(resolve, 0)); + controller.error(Object.assign(new Error("fixture pre-output reset"), { code: "ECONNRESET" })); + }, + }), { status: 200, headers: sseQuotaHeaders("17") }); + } + // Not the stream the request asked for, so recovery refuses it after the send renewed the proof. + return new Response(JSON.stringify({ id: "fixture-json" }), { status: 200, headers: sseQuotaHeaders("99", "application/json") }); + }) as typeof fetch; + try { + const response = await handleResponses(resetQuotaRequest(), resetQuotaConfig(), + { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + const text = await response.text(); + expect(httpCalls).toBe(2); + expect(text).not.toContain("fixture-json"); + expect(renewalSpy).toHaveBeenCalledTimes(2); + const arrival = renewalSpy.mock.results[0]!.value as mainCache.MainQuotaDispatch; + // Renewed proofs are structurally equal; ownership is identity. expect(claimSpy).toHaveBeenCalledTimes(1); expect(claimSpy.mock.calls[0]![0]).toBe(arrival); - expect(claimSpy.mock.calls[0]![0]).not.toBe(successor); + expect(claimSpy.mock.calls[0]![0]).not.toBe(renewalSpy.mock.results[1]!.value); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(17); } finally { claimSpy.mockRestore(); renewalSpy.mockRestore(); diff --git a/tests/responses/responses-reset-replay.test.ts b/tests/responses/responses-reset-replay.test.ts index 30adbc4fc3d..e4b5dac6293 100644 --- a/tests/responses/responses-reset-replay.test.ts +++ b/tests/responses/responses-reset-replay.test.ts @@ -1,16 +1,208 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, jest, test } from "bun:test"; import { ambiguousResendAllowanceFor, selfContainedChatBody, selfContainedResponsesBody, } from "../../src/server/responses/reset-replay"; import { authorizeResendForRecovery } from "../../src/lib/request-resend-gate"; -import { deferProtocolSafeResetRecovery } from "../../src/server/responses/combo-stream-preflight"; +import { deferProtocolSafeResetRecovery, selectProtocolSafeResetResponse, type ResetSelectionOwner } from "../../src/server/responses/combo-stream-preflight"; const prelude = 'event: response.created\ndata: {"type":"response.created","response":{"id":"first","output":[]}}\n\n'; const completed = 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"second","output":[]}}\n\n'; const responseLog = { model: "test", provider: "test" }; +describe("early reset selection", () => { + const originalHeaders = { "content-type": "text/event-stream", "x-request-id": "id-one", "x-codex-turn-state": "turn-one" }; + const replacementHeaders = { "content-type": "text/event-stream", "x-request-id": "id-two", "x-codex-turn-state": "turn-two" }; + const decoder = new TextDecoder(); + const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; + let fakeTimers = false; + const useFakeTimers = () => { fakeTimers = true; jest.useFakeTimers(); }; + afterEach(() => { + if (fakeTimers) jest.clearAllTimers(); + fakeTimers = false; + jest.useRealTimers(); + }); + + /** A prelude, then a connection reset once `gate` opens. */ + function gatedOriginal(gate: Promise, state: { waiting?: () => void; cancels: number; hangCancel?: boolean }) { + let sent = false; + return new Response(new ReadableStream({ + async pull(controller) { + if (!sent) { sent = true; controller.enqueue(new TextEncoder().encode(prelude)); return; } + state.waiting?.(); + await gate; + controller.error(Object.assign(new Error("fixture reset"), { code: "ECONNRESET" })); + }, + cancel() { state.cancels++; return state.hangCancel ? new Promise(() => {}) : undefined; }, + }, { highWaterMark: 0 }), { headers: originalHeaders }); + } + function trackedReplacement(state: { cancelled: boolean }, headers: Record = replacementHeaders, status = 200) { + return new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new TextEncoder().encode(completed)); controller.close(); }, + cancel() { state.cancelled = true; }, + }, { highWaterMark: 0 }), { status, headers }); + } + async function readAll(reader: ReadableStreamDefaultReader): Promise<{ text: string; error?: unknown }> { + let text = ""; + for (;;) { + try { + const next = await reader.read(); + if (next.done) return { text }; + text += decoder.decode(next.value); + } catch (error) { return { text, error }; } + } + } + + test("publishes a replacement selected before the deadline with its own status and headers", async () => { + const original = { cancels: 0 }; + const replacement = { cancelled: false }; + const commits: [Response, ResetSelectionOwner][] = []; + let recoveries = 0; + const published = await selectProtocolSafeResetResponse(gatedOriginal(Promise.resolve(), original), responseLog, + async (_error, stage, _signal, accepts) => { + recoveries++; + expect(stage).toBe("protocol-prelude"); + const candidate = trackedReplacement(replacement, replacementHeaders, 201); + expect(accepts(candidate)).toBe(true); + return candidate; + }, { heartbeatIntervalMs: 60_000, onCommit: (response, owner) => commits.push([response, owner]) }); + expect(recoveries).toBe(1); + expect(published.status).toBe(201); + expect([...published.headers]).toEqual([...new Headers(replacementHeaders)]); + expect(commits).toEqual([[published, "replacement"]]); + expect(await published.text()).toBe(completed); + expect(replacement.cancelled).toBe(false); + }); + + test("a deadline commits the original head; a later mismatched replacement is refused", async () => { + useFakeTimers(); + const gate = Promise.withResolvers(); + const waiting = Promise.withResolvers(); + const original = { cancels: 0, waiting: waiting.resolve }; + const replacement = { cancelled: false }; + const commits: ResetSelectionOwner[] = []; + let acceptedAfterCommit: boolean | undefined; + const selected = selectProtocolSafeResetResponse(gatedOriginal(gate.promise, original), responseLog, + async (_error, _stage, _signal, accepts) => { + const candidate = trackedReplacement(replacement); + acceptedAfterCommit = accepts(candidate); + return candidate; + }, { heartbeatIntervalMs: 15_000, onCommit: (_response, owner) => commits.push(owner) }); + await waiting.promise; + await flush(); + expect(commits).toEqual([]); + jest.advanceTimersByTime(15_000); + const published = await selected; + expect(commits).toEqual(["original"]); + expect([...published.headers]).toEqual([...new Headers(originalHeaders)]); + const reader = published.body!.getReader(); + // The client already waited one heartbeat interval for this head. + expect(decoder.decode((await reader.read()).value)).toBe(": keepalive\n\n"); + gate.resolve(); + const rest = await readAll(reader); + expect(acceptedAfterCommit).toBe(false); + expect(replacement.cancelled).toBe(true); + expect(rest.text).toBe(prelude); + expect(String(rest.error)).toContain("fixture reset"); + }); + + test("a matching replacement still continues behind a committed head", async () => { + useFakeTimers(); + const gate = Promise.withResolvers(); + const waiting = Promise.withResolvers(); + const replacement = { cancelled: false }; + const selected = selectProtocolSafeResetResponse(gatedOriginal(gate.promise, { cancels: 0, waiting: waiting.resolve }), responseLog, + async () => trackedReplacement(replacement, originalHeaders), { keepalive: "none" }); + await waiting.promise; + jest.advanceTimersByTime(15_000); + const published = await selected; + const reader = published.body!.getReader(); + const read = readAll(reader); + gate.resolve(); + // No timer-generated comment for a caller that owns its own keepalive policy. + expect((await read).text).toBe(completed); + expect(replacement.cancelled).toBe(false); + }); + + test("the stall budget narrows the pre-head wait", async () => { + useFakeTimers(); + const waiting = Promise.withResolvers(); + const commits: ResetSelectionOwner[] = []; + const selected = selectProtocolSafeResetResponse(gatedOriginal(new Promise(() => {}), { cancels: 0, waiting: waiting.resolve }), + responseLog, async () => null, { maxSelectionMs: 2_000, keepalive: "none", onCommit: (_r, owner) => commits.push(owner) }); + await waiting.promise; + jest.advanceTimersByTime(1_999); + await flush(); + expect(commits).toEqual([]); + jest.advanceTimersByTime(1); + const published = await selected; + expect(commits).toEqual(["original"]); + await published.body?.cancel(); + }); + + test("abort ends the pre-head wait at once although upstream cancellation never settles", async () => { + const waiting = Promise.withResolvers(); + const original = { cancels: 0, waiting: waiting.resolve, hangCancel: true }; + const abort = new AbortController(); + let recoveries = 0; + const selected = selectProtocolSafeResetResponse(gatedOriginal(new Promise(() => {}), original), responseLog, + async () => { recoveries++; return null; }, { signal: abort.signal, heartbeatIntervalMs: 60_000 }); + await waiting.promise; + const reason = new Error("client left"); + abort.abort(reason); + const published = await selected; + await expect(published.body!.getReader().read()).rejects.toBe(reason); + expect(original.cancels).toBe(1); + expect(recoveries).toBe(0); + }); + + test("abort during an in-flight replacement cancels it when it finally arrives", async () => { + const started = Promise.withResolvers(); + const pending = Promise.withResolvers(); + const replacement = { cancelled: false }; + const abort = new AbortController(); + let recoverySignal: AbortSignal | undefined; + const selected = selectProtocolSafeResetResponse(gatedOriginal(Promise.resolve(), { cancels: 0 }), responseLog, + async (_error, _stage, signal) => { recoverySignal = signal; started.resolve(); return pending.promise; }, + { signal: abort.signal, heartbeatIntervalMs: 60_000 }); + await started.promise; + abort.abort(new Error("client left")); + const published = await selected; + expect(recoverySignal?.aborted).toBe(true); + await expect(published.body!.getReader().read()).rejects.toThrow("client left"); + pending.resolve(trackedReplacement(replacement)); + await flush(); + expect(replacement.cancelled).toBe(true); + }); + + test("a replacement in flight when the deadline fires must then match the committed head", async () => { + useFakeTimers(); + const started = Promise.withResolvers(); + const pending = Promise.withResolvers(); + const replacement = { cancelled: false }; + const commits: ResetSelectionOwner[] = []; + let accepts!: (candidate: Response) => boolean; + const selected = selectProtocolSafeResetResponse(gatedOriginal(Promise.resolve(), { cancels: 0 }), responseLog, + async (_error, _stage, _signal, accept) => { accepts = accept; started.resolve(); return pending.promise; }, + { keepalive: "none", onCommit: (_r, owner) => commits.push(owner) }); + await started.promise; + const candidate = trackedReplacement(replacement); + expect(accepts(candidate)).toBe(true); + jest.advanceTimersByTime(15_000); + const published = await selected; + expect(commits).toEqual(["original"]); + expect(accepts(candidate)).toBe(false); + const read = readAll(published.body!.getReader()); + pending.resolve(candidate); + const rest = await read; + expect(replacement.cancelled).toBe(true); + expect(rest.text).toBe(prelude); + expect(String(rest.error)).toContain("fixture reset"); + expect(commits).toEqual(["original"]); + }); +}); + describe("deferred response metadata", () => { const baseHeaders = { "content-type": "text/event-stream", "x-request-id": "id-one", "x-codex-turn-state": "turn-one", "x-ratelimit-remaining-requests": "7", "x-model": "model-one" }; test.each([ From 0af39c930ba82039f64193294f2b2fcaa5f5a732 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:09:40 +0900 Subject: [PATCH 14/22] Bound buffered reset selection by upstream reads Pre-head reset selection holds a canonical buffered turn for up to one heartbeat interval (capped by the stall budget) before the collector starts. The collector then began a fresh first-byte clock and treated any delivered byte as activity, so a silent upstream with a 5 s stall budget failed after about 10 s, a prelude read during selection and replayed later reset the clock, and a quiet replacement behind a committed head inherited the original's last activity and was cut short. Guard the original body and any accepted replacement at their upstream reads for the canonical buffered consumer, as OpenGateway already is, so each physical attempt's stall budget runs from its own head. The collector reports the guard's failure as a stall timeout, which the reset gate never resends. The held stream also reports a replacement head that lands behind a committed head, so the collector's own byte clock never ends a turn before the guard. --- src/server/responses/buffered-sse-json.ts | 4 +- .../responses/combo-stream-preflight.ts | 15 ++ .../responses/passthrough-completion.ts | 23 +-- structure/providers/opengateway.md | 3 +- .../transports/responses-combo-boundaries.md | 12 +- .../responses-canonical-nonstream.test.ts | 133 ++++++++++++++++++ 6 files changed, 176 insertions(+), 14 deletions(-) diff --git a/src/server/responses/buffered-sse-json.ts b/src/server/responses/buffered-sse-json.ts index a4b71128200..69aa96162ca 100644 --- a/src/server/responses/buffered-sse-json.ts +++ b/src/server/responses/buffered-sse-json.ts @@ -1,4 +1,5 @@ import type { ResponsesTerminalStatus } from "../../bridge"; +import { ResponseBodyInactivityError } from "../../lib/response-body-inactivity"; import { createSseInspector, createSseTerminalOutputBoundary, @@ -328,7 +329,8 @@ export async function collectBufferedResponsesSse( ok: false, kind: options.signal?.aborted ? "aborted" - : error instanceof BufferedSseTimeoutError + // A source guarded at its upstream reads reports its stall through the body it feeds us. + : error instanceof BufferedSseTimeoutError || error instanceof ResponseBodyInactivityError ? "timeout" : error instanceof SseAggregateLimitError || error instanceof SseFrameTooLargeError diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 667fb3e612b..3bef1b02ef6 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -635,6 +635,11 @@ export function sameDeferredResponseMetadata(original: Response, replacement: Re export interface DeferredResetRecoveryOptions { allowMissingContentType?: boolean; heartbeatIntervalMs?: number; + /** + * `periodic` comments a waiting client once per heartbeat. `upstream-activity` comments only when + * the upstream reads non-empty bytes or a replacement head lands behind a committed head, for a + * consumer whose silence clock sees only delivered bytes; its owner still bounds real silence. + */ keepalive?: "periodic" | "upstream-activity" | "none"; } @@ -676,6 +681,8 @@ function createResetSelection( let notifyInitialization: (() => void) | undefined; let commitEarly: ((published: Response, owner: ResetSelectionOwner) => void) | undefined; let activityPending = false; + // A replacement head behind a committed head is upstream activity the held stream must report. + let replacementHeadPending = false; let heartbeatDue = false; let closed = false; let headCommitted = !early; @@ -709,6 +716,7 @@ function createResetSelection( if (!closed && accepts(replacement)) { cancelBody(chosen.response.body, "using protocol-safe replacement stream"); chosen = { response: replacement, owner: "replacement" }; + replacementHeadPending = headCommitted && keepalive === "upstream-activity"; } else { cancelBody(replacement.body, "declining protocol-safe replacement stream"); if (!closed) console.warn("[upstream-retry] replacement response metadata differs from the committed head; preserving original stream"); @@ -777,6 +785,13 @@ function createResetSelection( return; } if (initializationResult.kind === "failed") throw initializationResult.error; + if (replacementHeadPending) { + // A buffered consumer's silence clock sees only delivered bytes. Without this, a quiet + // replacement would inherit the original's last activity and be cut short. + replacementHeadPending = false; + controller.enqueue(resetPreflightKeepalive()); + return; + } reader ??= selection?.response.body?.getReader(); if (!reader) { closed = true; diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 02da010c79f..7587f107e13 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -26,9 +26,14 @@ export function isResetReplacementStream(candidate: Response, upstreamRequestsSt * Select the native response a reset preflight publishes. * * The selection waits at most one heartbeat interval, and never longer than the stall budget, for - * a head; see `selectProtocolSafeResetResponse`. The gateway clock must see upstream reads, not - * generated comments or held preludes. The published head carries the main quota proof of the - * physical attempt that produced it, because the replacement send renews the context's proof. + * a head; see `selectProtocolSafeResetResponse`. A consumer that measures silence on the bytes it + * receives (the gateway completion guard, the canonical buffered collector) cannot tell a held + * prelude, its later replay or a generated comment from an upstream read, and it starts after the + * selection already waited. Both bodies are therefore guarded before preflight, so each physical + * attempt's stall budget runs on its own upstream reads from its own head. The guard's failure is + * a timeout, which the reset gate never treats as replayable. The published head carries the main + * quota proof of the physical attempt that produced it, because the replacement send renews the + * context's proof. */ export function selectPassthroughResetResponse( response: Response, @@ -40,21 +45,21 @@ export function selectPassthroughResetResponse( config: Pick; signal: AbortSignal; allowMissingContentType: boolean; + /** `upstream-activity` is the canonical buffered collector; see the selection's keepalive modes. */ keepalive: "periodic" | "upstream-activity" | "none"; }, ): Promise { - const guard = options.request.url === "https://apis.opengateway.ai/v1/responses" + const openGateway = options.request.url === "https://apis.opengateway.ai/v1/responses" && response.status === 200 && response.body !== null; + const guard = openGateway || options.keepalive === "upstream-activity"; + const stallMs = resolveStallTimeoutMs(options.config.stallTimeoutSec, { localUpstream: isLocalUpstream(options.request.url) }); const protect = (source: Response): Response => guard - ? carryResponseToolOwnership(source, guardResponseBodyInactivity( - source, options.signal, resolveStallTimeoutMs(options.config.stallTimeoutSec), - ).response) + ? carryResponseToolOwnership(source, guardResponseBodyInactivity(source, options.signal, stallMs).response) : source; const quotaOwner = (): MainQuotaDispatch | undefined => admission.authCtx.kind === "main" ? admission.authCtx.mainQuotaDispatch : undefined; const originalOwner = quotaOwner(); let replacementOwner: MainQuotaDispatch | undefined; - const stallMs = resolveStallTimeoutMs(options.config.stallTimeoutSec, { localUpstream: isLocalUpstream(options.request.url) }); const heldSince = performance.now(); return selectProtocolSafeResetResponse(protect(response), logCtx, async (error, stage, signal, accepts) => { // Recover returns only a validated, fresh body; wrapping earlier would hide its lock state. @@ -68,7 +73,7 @@ export function selectPassthroughResetResponse( signal: options.signal, maxSelectionMs: stallMs > 0 ? stallMs : undefined, onCommit: (published, owner) => { - if (guard) guardedResetResponses.set(published, heldSince); + if (openGateway) guardedResetResponses.set(published, heldSince); bindPublishedMainQuotaDispatch(published, owner === "replacement" ? replacementOwner : originalOwner); }, }); diff --git a/structure/providers/opengateway.md b/structure/providers/opengateway.md index d1fde4a614b..4b25261b643 100644 --- a/structure/providers/opengateway.md +++ b/structure/providers/opengateway.md @@ -37,7 +37,8 @@ to the consumer without cancelling a healthy source. Consumed/cancelled prefix r released; terminal disposal and consumer cancellation do not await upstream cleanup. SSE inactivity keeps the existing stall-timeout policy. When reset preflight is active, its source and any accepted reset replacement are guarded before preflight, so the clock follows upstream reads rather than -synthetic keepalives or held combo frames. The completion guard skips only the duplicate clock for +synthetic keepalives or held combo frames; canonical buffered JSON shares that guard +([combo stream boundary](../transports/responses-combo-boundaries.md)). The completion guard skips only the duplicate clock for that initial selected body; a later empty-completion retry gets its own guard. Its first hold is shortened by the time reset selection already held the head, so both holds share the 30-second bound. Rewritten responses preserve tool ownership. diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 26fb8bcc43e..e49570311f2 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -154,9 +154,15 @@ of the attempt that produced it ([OpenAI tiers](../providers/openai-tiers.md)). clients get an SSE comment on their first read after a deadline commit and then once per heartbeat interval while the hold lasts. Combo streaming children omit those timer-generated comments while their parent holds the headers, so those comments -cannot consume the retained-chunk cap. For canonical buffered JSON, the hold emits a comment -only when it reads non-empty upstream bytes: this reports real prelude activity to the collector's -inactivity clock without making a silent provider appear active. The byte and chunk caps remain in force. +cannot consume the retained-chunk cap. For canonical buffered JSON, the stall budget runs on upstream +reads: `src/server/responses/passthrough-completion.ts` guards the original body and any accepted +replacement before preflight, so each physical attempt is bounded from its own head and only its +non-empty reads restart the clock. A prelude read during selection and replayed later, or a generated +comment, cannot extend it, and the collector reports the guard's failure as a stall timeout that the +reset gate never resends. The hold emits a comment only when it reads non-empty upstream bytes or a +replacement head lands behind a committed head, so the collector's own clock, which starts after +selection and sees only delivered bytes, never ends a turn before the guard would. The byte and chunk +caps and the collector's whole-turn ceiling remain in force. Downstream cancellation or the request's abort signal ends the selection or hold at once, aborts an in-flight replacement, and never awaits upstream cleanup. diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 5c4afb0c95b..0317e5f6b63 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -558,6 +558,139 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 expect(sourceCancels).toBe(1); }); + describe("buffered stall clock across reset selection", () => { + const STALL_MS = 5_000; + const stallConfig: OcxConfig = { ...config, stallTimeoutSec: STALL_MS / 1_000, providers: { openai: { ...provider, retryOnReset: {} } } }; + const created = sseEvent("response.created", { response: { id: "resp_original", status: "in_progress", output: [] } }); + const heartbeat = sseEvent("response.heartbeat", {}); + /** An upstream body that yields only what the test sends, at the fake time it sends it. */ + const scripted = () => { + const queue: (string | Error)[] = []; + let wake: (() => void) | undefined; + let cancels = 0; + const body = new ReadableStream({ + async pull(controller) { + while (queue.length === 0) await new Promise(resolve => { wake = resolve; }); + const next = queue.shift()!; + if (typeof next === "string") controller.enqueue(new TextEncoder().encode(next)); + else controller.error(next); + }, + cancel() { cancels++; }, + }, { highWaterMark: 0 }); + const push = (item: string | Error) => { queue.push(item); wake?.(); wake = undefined; }; + return { response: new Response(body, { headers: metadataHeaders("first", "first-id", "7") }), push, cancels: () => cancels }; + }; + const at = (ms: number, action: () => void) => { setTimeout(action, ms); }; + /** Step the fake clock until the buffered turn settles; real I/O runs between steps. */ + const settle = async (pending: Promise, from: () => number) => { + let settled: Response | undefined; + void pending.then(response => { settled = response; }, () => undefined); + for (;;) { + await flushMicrotasks(); + await new Promise(resolve => setImmediate(resolve)); + await flushMicrotasks(); + if (settled) return { response: settled, elapsed: Date.now() - from(), text: await settled.text() }; + if (Date.now() - from() > 30_000) throw new Error("buffered turn never settled"); + jest.advanceTimersByTime(100); + } + }; + const withFakeClock = async (run: () => Promise) => { + jest.useFakeTimers(); + try { await run(); } finally { jest.clearAllTimers(); jest.useRealTimers(); } + }; + + test("a silent upstream stalls one budget after its head, not after selection", () => withFakeClock(async () => { + const original = scripted(); + let sends = 0; + let sentAt = 0; + globalThis.fetch = (async () => { sends++; sentAt = Date.now(); return original.response; }) as typeof fetch; + const { response, elapsed, text } = await settle(call(requestBody(false, false), {}, stallConfig), () => sentAt); + expect(response.status).toBe(502); + expect(text).toContain("stalled before completing"); + expect(elapsed).toBeGreaterThan(STALL_MS - 100); + expect(elapsed).toBeLessThanOrEqual(STALL_MS); + // A stall is not a reset: nothing is sent again, and the silent body is released. + expect(sends).toBe(1); + expect(original.cancels()).toBeGreaterThanOrEqual(1); + })); + + test("a prelude held by selection anchors the clock at its read, not at its replay", () => withFakeClock(async () => { + const original = scripted(); + let sends = 0; + let sentAt = 0; + globalThis.fetch = (async () => { + sends++; + sentAt = Date.now(); + at(1_000, () => original.push(created)); + return original.response; + }) as typeof fetch; + const { response, elapsed, text } = await settle(call(requestBody(false, false), {}, stallConfig), () => sentAt); + expect(response.status).toBe(502); + expect(text).toContain("stalled before completing"); + expect(elapsed).toBeGreaterThan(1_000 + STALL_MS - 100); + expect(elapsed).toBeLessThanOrEqual(1_000 + STALL_MS); + expect(sends).toBe(1); + })); + + test.each([ + ["completes", 12_000, 200], + ["goes silent", 8_000 + STALL_MS, 502], + ] as const)("real upstream activity behind a held prelude keeps the turn alive until it %s", (ending, settlesAt, status) => + withFakeClock(async () => { + const original = scripted(); + let sends = 0; + let sentAt = 0; + globalThis.fetch = (async () => { + sends++; + sentAt = Date.now(); + original.push(created); + at(4_000, () => original.push(heartbeat)); + at(8_000, () => original.push(heartbeat)); + if (ending === "completes") { + at(12_000, () => original.push(sseEvent("response.completed", { + response: { id: "resp_live", status: "completed", model: "gpt-5.6-sol", output: [] }, + }))); + } + return original.response; + }) as typeof fetch; + const { response, elapsed, text } = await settle(call(requestBody(false, false), {}, stallConfig), () => sentAt); + expect(response.status).toBe(status); + expect(elapsed).toBeGreaterThan(settlesAt - 100); + expect(elapsed).toBeLessThanOrEqual(settlesAt); + if (ending === "completes") expect(text).toContain("resp_live"); + else expect(text).toContain("stalled before completing"); + expect(sends).toBe(1); + })); + + test.each([ + ["before the head is committed", 3_000], + ["behind the committed original head", 7_000], + ] as const)("a replacement selected %s runs on its own attempt clock", (_name, resetAt) => withFakeClock(async () => { + const original = scripted(); + const replacement = scripted(); + let sends = 0; + let sentAt = 0; + globalThis.fetch = (async () => { + sends++; + if (sends > 1) return replacement.response; + sentAt = Date.now(); + original.push(created); + at(4_000, () => original.push(heartbeat)); + at(resetAt, () => original.push(Object.assign(new Error("fixture pre-output reset"), { code: "ECONNRESET" }))); + return original.response; + }) as typeof fetch; + const { response, elapsed, text } = await settle(call(requestBody(false, false), {}, stallConfig), () => sentAt); + // The replacement arrived at the reset and never spoke: one budget from its own head. + expect(response.status).toBe(502); + expect(text).toContain("stalled before completing"); + expect(elapsed).toBeGreaterThan(resetAt + STALL_MS - 100); + expect(elapsed).toBeLessThanOrEqual(resetAt + STALL_MS); + // One replacement for the reset; its stall is final and its body is released. + expect(sends).toBe(2); + expect(replacement.cancels()).toBeGreaterThanOrEqual(1); + })); + }); + for (const ending of ["eof", "read-error"] as const) { test(`native streaming preserves lifecycle snapshot output after ${ending}`, async () => { let sends = 0; From fba82619675189f3bf338332fda55449f19ccd25 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:28:04 +0900 Subject: [PATCH 15/22] Break required-tool preflight import cycle --- .../responses/combo-required-tool-fulfillments.ts | 2 +- src/server/responses/outbound-request-body.ts | 11 +++++++++++ src/server/responses/passthrough-completion.ts | 13 +++---------- structure/transports/responses-combo-boundaries.md | 2 ++ 4 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 src/server/responses/outbound-request-body.ts diff --git a/src/server/responses/combo-required-tool-fulfillments.ts b/src/server/responses/combo-required-tool-fulfillments.ts index 24239ed15e6..3b77af3adb2 100644 --- a/src/server/responses/combo-required-tool-fulfillments.ts +++ b/src/server/responses/combo-required-tool-fulfillments.ts @@ -1,7 +1,7 @@ import type { AdapterRequest } from "../../adapters/base"; import { collectResponsesToolGroups } from "../../responses/tool-groups"; import { requiredToolContractOf, type RequiredToolContract } from "./combo-required-tool-contract"; -import { parseOutboundRequestBody } from "./passthrough-completion"; +import { parseOutboundRequestBody } from "./outbound-request-body"; type Item = Record; const record = (value: unknown): Item | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Item : undefined; diff --git a/src/server/responses/outbound-request-body.ts b/src/server/responses/outbound-request-body.ts new file mode 100644 index 00000000000..558e04c7b85 --- /dev/null +++ b/src/server/responses/outbound-request-body.ts @@ -0,0 +1,11 @@ +import { isPlainObject } from "../responses-snapshot-codec"; + +/** Parse the physical outbound request without accepting arrays or primitive JSON. */ +export function parseOutboundRequestBody(bodyText: string): Record | undefined { + try { + const body: unknown = JSON.parse(bodyText); + return isPlainObject(body) ? body : undefined; + } catch { + return undefined; + } +} diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 7587f107e13..9aa428f1100 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -6,13 +6,15 @@ import { guardResponseBodyInactivity } from "../../lib/response-body-inactivity" import { resolveStallTimeoutMs } from "../../stall-timeout"; import type { OcxConfig } from "../../types"; import { recordKeyWireAttemptUsage, usageFromResponsesPayload, type RequestLogContext } from "../request-log"; -import { isPlainObject } from "../responses-snapshot-codec"; import { selectProtocolSafeResetResponse, type ProtocolSafeResetRecovery } from "./combo-stream-preflight"; import { bindPublishedMainQuotaDispatch } from "./core-codex-account"; import type { ResponsesAdmissionState } from "./core-options"; import { guardOpenGatewayCompletion } from "./opengateway-completion-guard"; +import { parseOutboundRequestBody } from "./outbound-request-body"; import { carryResponseToolOwnership } from "./response-tool-ownership"; +export { parseOutboundRequestBody } from "./outbound-request-body"; + // A guarded published reset response, mapped to when its selection began holding the head. const guardedResetResponses = new WeakMap(); @@ -79,15 +81,6 @@ export function selectPassthroughResetResponse( }); } -export function parseOutboundRequestBody(bodyText: string): Record | undefined { - try { - const body: unknown = JSON.parse(bodyText); - return isPlainObject(body) ? body : undefined; - } catch { - return undefined; - } -} - export function guardPassthroughCompletion( response: Response, request: AdapterRequest, diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index e49570311f2..a09e627073d 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -93,6 +93,8 @@ array, including empty, so removing a preference cannot retain an earlier build' replaced image function only when that send still declares the hosted capability. A filtered tool or lowered `none` selector never erases caller intent: missing calls retain the usual pre-commit fallback or post-commit typed failure. Caller hosted non-function selectors retain their terminal owner. +The outbound request parser shared with passthrough lives in `src/server/responses/outbound-request-body.ts`, +so required-tool checks do not import the stream recovery owner. Completed item events and terminal snapshots can satisfy it; a call start alone cannot. Missing calls turn `response.completed` into `response.failed` with `required_tool_contract_unsatisfied`, retaining response identity, output and usage. From 6a887f49f9e04adbe5bbdb5b083fc841fbf99944 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:56:55 +0900 Subject: [PATCH 16/22] Register outbound parser in Responses owner inventory --- tests/helpers/responses-core-source.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 593dff509ab..1d129f12a12 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -69,6 +69,7 @@ export const RESPONSES_CORE_MODULES = [ // Reached from passthrough-dispatch.ts: exact-URL OpenGateway empty-completion recovery. "passthrough-completion.ts", "opengateway-completion-guard.ts", + "outbound-request-body.ts", "passthrough-observations.ts", "passthrough-key-rotation.ts", // Reached from passthrough/adapter dispatch, compact and runTurn: local spend-ledger refusal mapping. From 9920030974248208170ab38c4ed1ef267c13d6ee Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:55:38 +0900 Subject: [PATCH 17/22] Guard ambiguous Responses retries at physical dispatch --- src/server/responses/core-codex-account.ts | 3 + src/server/responses/fetch-helpers.ts | 16 +-- src/server/responses/passthrough-dispatch.ts | 60 +++++---- src/server/responses/reset-replay.ts | 38 ++++-- src/server/responses/ws-upstream.ts | 4 +- structure/transports/responses-failover.md | 2 +- .../responses-canonical-nonstream.test.ts | 115 +++++++++++++++++- ...onses-passthrough-transient-policy.test.ts | 2 +- 8 files changed, 189 insertions(+), 51 deletions(-) diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 31d1cf20379..90c1dcef221 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -22,6 +22,7 @@ import { resetUpstreamHostHealth, } from "../../codex/upstream-host-health"; import { safeOriginLabel, fetchWithHeaderTimeout, providerFetch } from "./fetch-helpers"; +import type { AdapterRequest } from "../../adapters/base"; import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease"; import { formatErrorResponse } from "../../bridge"; import { readBoundedResponseBody } from "../../lib/bounded-body"; @@ -538,6 +539,7 @@ export interface CodexPoolAccountRetryArgs { httpOnly?: boolean; /** Pre-output refusal hold for the alternate's WebSocket send (#6969); the caller restores. */ preOutputRefusalHold?: CodexWsPreOutputRefusalHold; + onPhysicalSend?: (request: AdapterRequest, body: BodyInit | null | undefined) => void; onResponse?: ( response: Response, authCtx: CodexAuthContext, @@ -1076,6 +1078,7 @@ export async function retryCodexPoolOnAlternateAccount( httpOnly, providerName: route.providerName, modelId: route.modelId, + onPhysicalDispatch: body => args.onPhysicalSend?.(request, body), preOutputRefusalHold: args.preOutputRefusalHold, accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: retryAuthCtx }, headers), onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId), diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 184e0043748..74133eac547 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -234,7 +234,7 @@ export function sendWithConnectionPolicy( const largeCodexBody = !compressed && typeof body === "string" && isCanonicalCodexResponsesUrl(target) && Buffer.byteLength(body, "utf8") >= 1024 * 1024; - (init as Record void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.(); + (init as Record void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.(body); (init as Record void) | undefined> | undefined)?.[PHYSICAL_SEND_OBSERVER]?.(target, headers); // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the // request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status). @@ -288,7 +288,7 @@ export interface ProviderFetchOptions { /** Synchronous admission at actual credential dispatch, after pacing/backoff. */ beforeDispatch?: (headers: Headers) => void; /** One-shot receipt after final local HTTP admission, immediately before executor entry. */ - onPhysicalDispatch?: () => void; + onPhysicalDispatch?: (body?: BodyInit | null) => void; /** Repeatable admission for native successor creates, after pacing and auth checks. */ onContinuationDispatch?: (frame: { body: Record; text: string }) => void; /** @@ -370,12 +370,12 @@ export function providerFetch( options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); // No proxy option is attached here: a `dispatchOverride` may rebuild this request against // a different destination, so the route is decided at the physical send instead. - const sharedReceipt = (init as Record void) | undefined> | undefined)?.[WS_FALLBACK_RECEIPT]; + const sharedReceipt = (init as Record void) | undefined> | undefined)?.[WS_FALLBACK_RECEIPT]; let notified = false; const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0, - ...(options.onPhysicalDispatch ? { [PHYSICAL_DISPATCH]: () => { - if (sharedReceipt) sharedReceipt(); - else if (!notified) { options.onPhysicalDispatch!(); notified = true; } + ...(options.onPhysicalDispatch ? { [PHYSICAL_DISPATCH]: (body: BodyInit | null | undefined) => { + if (sharedReceipt) sharedReceipt(body); + else if (!notified) { options.onPhysicalDispatch!(body); notified = true; } } } : {}), }; return override @@ -416,10 +416,10 @@ export function providerFetch( // request over HTTP, and dropping the provider's `upstreamHttpVersion` // there would silently negotiate a transport the operator ruled out. let notified = false; - const notifyOnce = (): void => { + const notifyOnce = (body: BodyInit | null | undefined): void => { if (notified) return; + options.onPhysicalDispatch?.(body); notified = true; - options.onPhysicalDispatch?.(); }; const fallback = ((url: Parameters[0], fallbackInit?: RequestInit) => httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing, null)) as typeof globalThis.fetch; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 0e7636ba9f5..c7bf369651f 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -170,7 +170,7 @@ import { import type { RequestLogContext } from "../request-log"; import { preflightComboStreamResponse } from "./combo-stream-preflight"; import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; -import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; +import { ambiguousResendAllowanceFor, createPhysicalReplayGuard, selfContainedResponsesBody, UnsafePhysicalReplacement } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; @@ -765,16 +765,10 @@ export async function preparePassthroughExchange( ); const configuredTransientSendBudgetExhausted = (): boolean => transientSendPolicy() !== null && transientSendAttempts() === 0; - /** - * Judged once. The inbound body does not change between legs, and every rebuild this lane - * performs only ever REMOVES a hazard -- `previous_response_id` is expanded, hosted tools - * are lowered into client execution -- so a body that was replaceable stays replaceable. - * Memoized rather than recomputed because it walks the input array, and a provider that - * never opted in must not pay for it at all. - */ - let selfContainedJudgment: boolean | undefined; - const requestIsSelfContained = (): boolean => - selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody); + const replayGuard = createPhysicalReplayGuard(() => isXaiResponsesDestination(route.provider) + && collectProviderExecutedCallTypes(clientToolAuthorizationBody).size > 0); + const requestIsSelfContained = (): boolean => selfContainedResponsesBody(parsed._rawBody) && replayGuard.isSelfContained(request); + const recordPhysicalSend = (next?: () => void) => (body?: BodyInit | null): void => replayGuard.record(request, body, next); /** * The operator's replacement grant for THIS logical request. * @@ -792,8 +786,12 @@ export async function preparePassthroughExchange( * the server tree; routing the answer through `authorizeResendForRecovery` here is what * keeps the decision derived from the table rather than restated as a boolean. */ - const claimPreHeaderResend = (): boolean => - authorizeResendForRecovery("pre-header", "connection-reset", ambiguousResend()).allowed; + const claimRecoveryResend = (stage: Parameters[0]): boolean => { + const allowed = authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed; + if (allowed) replayGuard.authorize(); + return allowed; + }; + const claimPreHeaderResend = (): boolean => claimRecoveryResend("pre-header"); /** * The one replacement send the ambiguous rows at the end of the recovery loop may buy: an SSE * body that died before any output, and a Codex WebSocket that died under its create frame @@ -829,7 +827,12 @@ export async function preparePassthroughExchange( providerName: route.providerName, modelId: route.modelId, dispatchOverride: oauthDispatch(request, parsed, "passthrough"), - onPhysicalDispatch: accounting.onPhysicalDispatch ? () => { accounting.onPhysicalDispatch!(); started = true; } : undefined, + onPhysicalDispatch: recordPhysicalSend(() => { + transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); + if (accounting.onPhysicalDispatch) accounting.onPhysicalDispatch(); + else if (report.beforeSend?.() === false) throw new SendBudgetExhaustedError(errorDestinationLabel(request.url)); + started = true; + }), beforeDispatch: headers => { if (signal.aborted) throw signal.reason; if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) { @@ -844,16 +847,6 @@ export async function preparePassthroughExchange( options.visionDescribeTerminal === true, )?.(headers); } - // Recorded with the kind the gate derived its cause from, at the moment the - // send actually leaves. One authorisation, one recorded reason, one send. - transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); - // Charged to the SAME request counter every other send goes through. The - // replacement is bought here rather than by a nested retry helper, so there is - // one charge for one send and no per-layer counter to reconcile. - if (!accounting.onPhysicalDispatch) { - if (report.beforeSend?.() === false) throw new SendBudgetExhaustedError(errorDestinationLabel(request.url)); - started = true; - } }, }), route.provider.authMode === "forward", @@ -1033,7 +1026,7 @@ export async function preparePassthroughExchange( dispatchOverride: oauthDispatch(request, parsed, "passthrough"), providerName: route.providerName, modelId: route.modelId, - onPhysicalDispatch: receiptMode ? sendBudgetState.noteInitialDispatch : initialAccounting.onPhysicalDispatch, + onPhysicalDispatch: recordPhysicalSend(receiptMode ? sendBudgetState.noteInitialDispatch : initialAccounting.onPhysicalDispatch), onContinuationDispatch, accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers), onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), @@ -1168,7 +1161,7 @@ export async function preparePassthroughExchange( && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? options.nativeControl : undefined, dispatchOverride: oauthDispatch(request, parsed, "passthrough"), - onPhysicalDispatch: recoveryAccounting.onPhysicalDispatch, + onPhysicalDispatch: recordPhysicalSend(recoveryAccounting.onPhysicalDispatch), providerName: route.providerName, modelId: route.modelId, onContinuationDispatch, @@ -1289,6 +1282,7 @@ export async function preparePassthroughExchange( && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? options.nativeControl : undefined, dispatchOverride: oauthDispatch(request, parsed, "passthrough"), + onPhysicalDispatch: recordPhysicalSend(), providerName: route.providerName, modelId: route.modelId, onContinuationDispatch, @@ -1438,7 +1432,7 @@ export async function preparePassthroughExchange( && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? options.nativeControl : undefined, dispatchOverride: oauthDispatch(request, parsed, "passthrough"), - onPhysicalDispatch: oauthReplayAccounting.onPhysicalDispatch, + onPhysicalDispatch: recordPhysicalSend(oauthReplayAccounting.onPhysicalDispatch), providerName: route.providerName, modelId: route.modelId, onContinuationDispatch, @@ -1588,7 +1582,7 @@ export async function preparePassthroughExchange( && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? options.nativeControl : undefined, dispatchOverride: oauthDispatch(request, parsed, "passthrough"), - onPhysicalDispatch: failoverAccounting.onPhysicalDispatch, + onPhysicalDispatch: recordPhysicalSend(failoverAccounting.onPhysicalDispatch), providerName: route.providerName, modelId: route.modelId, onContinuationDispatch, @@ -1696,6 +1690,7 @@ export async function preparePassthroughExchange( stream: upstreamRequestsStream, httpOnly: canonicalBufferedJson, preOutputRefusalHold: preOutputRefusalHold(), + onPhysicalSend: (retryRequest, body) => replayGuard.record(retryRequest, body), onResponse: (response, retryAuthCtx, retryRequest) => { adoptCodexWsStage(response, logCtx); captureAffinityResponse( @@ -1870,17 +1865,18 @@ export async function preparePassthroughExchange( // Asked before the gate, which claims last: a replacement the budget cannot fund must not // spend the request's one grant. && remainingTransientSendBudget(transientSendAttempts()) > 0 - && authorizeResendForRecovery(socketDeathStage, "connection-reset", ambiguousResend()).allowed + && claimRecoveryResend(socketDeathStage) ) { - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } console.warn(`[upstream-retry] codex websocket died before any Responses event (${safeHostLabel(request.url)}); ` + "using one replacement over HTTP"); let replacement: Response | undefined; + let unsafeReplacement = false; while (!replacement) { try { replacement = await sendAmbiguousReplacement().then(adoptObservedResponse); } catch (err) { if (upstream.signal.aborted) return transportFailureResponse(err); + if (err instanceof UnsafePhysicalReplacement) { unsafeReplacement = true; break; } // A replacement that reset before its head is the pre-header row again: a configured // second grant (and a send the budget can still fund) may buy one more send. The gate // claims from the request's finite allowance, so this loop is bounded by it. @@ -1894,6 +1890,8 @@ export async function preparePassthroughExchange( } // The first send may already have run the turn, so a replacement that failed settles as // the refusal rather than as a transport error the client would retry. + if (unsafeReplacement) break passthroughRecovery; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } upstreamResponse = replacement ? settleOperatorReplacement(replacement) : replayRefusalResponse(); continue passthroughRecovery; } @@ -1932,7 +1930,7 @@ export async function preparePassthroughExchange( // pre-header helper shares. A committed stage refuses here without touching the // allowance, which is what keeps a turn that already emitted output from draining // the replacement a later ambiguous reset would have been entitled to. - authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed, + authorize: () => claimRecoveryResend(stage), acceptResponse: candidate => accepts(candidate) && isResetReplacementStream(candidate, upstreamRequestsStream), }, ), diff --git a/src/server/responses/reset-replay.ts b/src/server/responses/reset-replay.ts index 00c3f7a963d..2a78025cc95 100644 --- a/src/server/responses/reset-replay.ts +++ b/src/server/responses/reset-replay.ts @@ -9,15 +9,40 @@ * one whose second send cannot do more than run the same inference again, and the whole * logical request gets a fixed number of replacements no matter how many legs ask. * - * The judgment is made on the inbound body the client sent, which is already parsed. It is - * conservative for the outbound request: the proxy expands `previous_response_id` and lowers - * hosted tools into client execution, so every hazard that reaches the wire was visible here, - * and a hazard visible here may already have been removed. A cheap fail-closed answer beats - * re-parsing a multi-megabyte outbound body on every send. + * Callers select the body to judge. Native passthrough judges physical outbound bodies and + * adapter substitution metadata; translated dispatch retains its own inbound-body policy. */ import type { OcxProviderConfig } from "../../types"; import { resetReplayPolicyFor } from "../../providers/key-failover"; import type { AmbiguousResendAllowance } from "../../lib/request-resend-gate"; +import type { AdapterRequest } from "../../adapters/base"; +import { parseOutboundRequestBody } from "./outbound-request-body"; + +type PhysicalReplaySnapshot = { body: BodyInit | null | undefined; hosted: boolean; providerExecuted: boolean }; +export class UnsafePhysicalReplacement extends Error {} + +/** Captures the attempt that actually left, and fences any replacement after queued rebuilding. */ +export function createPhysicalReplayGuard(providerExecuted: () => boolean) { + let last: PhysicalReplaySnapshot | undefined; + let pending = false; + const snapshot = (request: AdapterRequest, body: BodyInit | null | undefined): PhysicalReplaySnapshot => ({ + body, hosted: (request.hostedToolSubstitutions?.length ?? 0) > 0, providerExecuted: providerExecuted(), + }); + const safe = (value: PhysicalReplaySnapshot | undefined): boolean => value !== undefined + && !value.hosted && !value.providerExecuted && typeof value.body === "string" + && selfContainedResponsesBody(parseOutboundRequestBody(value.body)); + return { + isSelfContained: (request: AdapterRequest) => safe(last) && safe(snapshot(request, request.body)), + authorize: () => { pending = true; }, + record: (request: AdapterRequest, body: BodyInit | null | undefined, next?: () => void) => { + const physical = snapshot(request, body); + if (pending && !safe(physical)) throw new UnsafePhysicalReplacement("The rebuilt request cannot be safely replayed"); + next?.(); + last = physical; + pending = false; + }, + }; +} /** Input items a client owns end to end: replaying them re-runs nothing but the model. */ const CLIENT_INPUT_ITEM_TYPES: ReadonlySet = new Set([ @@ -123,8 +148,7 @@ export function selfContainedChatBody(body: unknown): boolean { * undifferentiated no. * * `selfContained` is a predicate rather than a body, and the getter below is why: a provider - * that never opted in must not pay to walk the input array, and the caller memoizes one answer - * across every leg of the request. + * that never opted in must not pay to walk the input array. */ export function ambiguousResendAllowanceFor( provider: Pick, diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index 761ca029d5d..9e462490433 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -163,7 +163,7 @@ export async function codexWsUpstreamFetch( beforeDispatch?: (headers: Headers) => void, nativeControl?: NativeResponseControl, beforeContinuation?: () => Promise, - onPhysicalDispatch?: () => void, + onPhysicalDispatch?: (body: BodyInit | null | undefined) => void, /** Opt-in pre-output refusal hold (#6969); see codexWsExchange. */ preOutputRefusalHold?: CodexWsPreOutputRefusalHold, onContinuationDispatch?: (frame: { body: Record; text: string }) => void, @@ -251,7 +251,7 @@ export async function codexWsUpstreamFetch( return codexWsExchange({ session, url, init, prepared, sseFallback, onQuota, beforeDispatch, nativeControl: control, - beforeContinuation, onPhysicalDispatch, onContinuationDispatch, + beforeContinuation, onPhysicalDispatch: () => onPhysicalDispatch?.(frameText), onContinuationDispatch, preOutputRefusalHold, bunVersion: typeof runtime === "string" ? runtime : runtime.version, metadataContext: codexWsMetadataContext(init, prepared.model), diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index a5ad274856d..60e951f17a2 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -82,7 +82,7 @@ request's execution budget, so a combo child that derives its own scope draws on counter rather than holding a second. A replacement never widens a send budget: it still has to fit inside the allowance the leg already had, and it is charged to the same counter every other send goes through; initial Combo bookings follow the [prepaid-send contract](responses-spend.md#prepaid-initial-sends). -For native Responses SSE, clean EOF after uncommitted lifecycle events uses the same grant and send budget; see the [combo stream boundary](responses-combo-boundaries.md). +For native Responses SSE, clean EOF after uncommitted lifecycle events uses the same grant and send budget; see the [combo stream boundary](responses-combo-boundaries.md). Native passthrough judges the body and provider-executed substitution metadata of the physical attempt and rechecks the replacement at physical dispatch after credential rebuilding. A replacement that is no longer self-contained is refused before the send, preserving the original stream or refusal. Generic translated dispatch in `src/server/responses/adapter-dispatch.ts` asks the same pre-header gate for initial and rebuilt sends, sharing the replacement grant and charging each physical send once to the existing request/workflow budgets. Adapter-owned transports and translated post-header failures are excluded. Coverage: `tests/responses/responses-translated-reset.test.ts`. The number of replacements is the request's as well. A leg reads it from `route.provider`, which credential rotation, OAuth refresh, transport resolution and each combo target reassign inside one diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 0317e5f6b63..5e16203b50a 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, jest, test } from "bun:test"; +import { afterEach, describe, expect, jest, spyOn, test } from "bun:test"; import { readFileSync } from "node:fs"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { getDefaultConfig } from "../../src/config"; @@ -17,6 +17,8 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/t import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; import { repoPath } from "../helpers/repo-root"; +import * as pacing from "../../src/providers/request-pacing"; +import * as adapterResolve from "../../src/server/adapter-resolve"; const provider: OcxProviderConfig = { adapter: "openai-responses", @@ -100,6 +102,117 @@ function sseEvent(type: string, payload: Record): string { return `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`; } +describe("physical hosted image replay safety", () => { + test("a queued replacement is refused if credential rebuilding adds a hosted tool", async () => { + const sent: Record[] = []; + let releaseQueue!: () => void; + let queued!: () => void; + const queueGate = new Promise(resolve => { releaseQueue = resolve; }); + const queuedSignal = new Promise(resolve => { queued = resolve; }); + const waitForSlot = pacing.waitForProviderRequestSlot; + const resolveAdapter = adapterResolve.resolveAdapter; + const built: Record[] = []; + const adapterSpy = spyOn(adapterResolve, "resolveAdapter").mockImplementation((...args) => { + const adapter = resolveAdapter(...args); + const build = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (...buildArgs) => { + const request = await build(...buildArgs); + built.push(JSON.parse(request.body) as Record); + return request; + }; + return adapter; + }); + let waits = 0; + const spy = spyOn(pacing, "waitForProviderRequestSlot").mockImplementation(async (...args) => { + if (++waits === 2) { queued(); await queueGate; } + return waitForSlot(...args); + }); + globalThis.fetch = (async (_input, init) => { + sent.push(JSON.parse(String(init?.body)) as Record); + if (sent.length === 1) throw Object.assign(new Error("fixture reset"), { code: "ECONNRESET" }); + return new Response(sseEvent("response.completed", { response: { id: "replacement", status: "completed", output: [] } }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const fixture: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://provider.example.test/v1", + authMode: "key", apiKey: "fixture-key", retryOnReset: {}, apiKeySelectionRevision: "initial", + requestPacing: { enabled: true, maxConcurrentRequests: 1 } }; + const requestConfig: OcxConfig = { ...config, defaultProvider: "fixture", providers: { fixture } }; + try { + const pending = call({ ...requestBody(true, false), model: "fixture/gpt-5.6-sol", tools: [{ type: "function", name: "image_gen.imagegen", parameters: { type: "object", properties: {} } }] }, {}, requestConfig); + await queuedSignal; + fixture.modelPreferHostedTools = { "gpt-5.6-sol": ["image_generation"] }; + fixture.apiKeySelectionRevision = "changed"; + releaseQueue(); + const response = await pending; + const text = await response.text(); + expect(built.length).toBeGreaterThanOrEqual(2); + expect(built.at(-1)?.tools).toContainEqual({ type: "image_generation" }); + expect(sent).toHaveLength(1); + expect(text).toContain("upstream_reset_replay_refused"); + expect(text).not.toContain("replacement"); + } finally { releaseQueue(); spy.mockRestore(); adapterSpy.mockRestore(); pacing.resetProviderRequestPacingForTest(); } + }); + + test.each([false, true])("a purely client-executed tool can use the replacement grant (combo=%s)", async combo => { + const sent: Record[] = []; + globalThis.fetch = (async (_input, init) => { + sent.push(JSON.parse(String(init?.body)) as Record); + const event = sent.length === 1 + ? sseEvent("response.created", { response: { id: "original", status: "in_progress", output: [] } }) + : sseEvent("response.completed", { response: { id: "replacement", status: "completed", output: [] } }); + return new Response(event, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const requestConfig: OcxConfig = { ...config, providers: { openai: { ...provider, retryOnReset: {} }, + ...(combo ? { fixture: { adapter: "openai-responses", baseUrl: "https://provider.example.test/v1", authMode: "key", apiKey: "fixture-key", retryOnReset: {} } } : {}) }, + ...(combo ? { combos: { replay: { strategy: "failover", targets: [{ provider: "fixture", model: "gpt-5.6-sol" }] } } } : {}) }; + const response = await call({ ...requestBody(true, false), ...(combo ? { model: "combo/replay" } : {}), tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }] }, {}, requestConfig); + const text = await response.text(); + expect(sent).toHaveLength(2); + expect(text).toContain("replacement"); + }); + + test("an exhausted replacement grant cannot send a third client-tool request", async () => { + let sends = 0; + globalThis.fetch = (async () => { + sends++; + return new Response(sseEvent("response.created", { response: { id: `attempt_${sends}`, status: "in_progress", output: [] } }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const requestConfig: OcxConfig = { ...config, providers: { openai: { ...provider, retryOnReset: { enabled: true, replacements: 1 } } } }; + const response = await call({ ...requestBody(true, false), tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }] }, {}, requestConfig); + await response.text(); + expect(sends).toBe(2); + }); + + for (const ending of ["eof", "reset", "pre-header"] as const) { + test(`does not resend a substituted image tool after ${ending}`, async () => { + const sent: Record[] = []; + const prelude = sseEvent("response.created", { response: { id: "original", status: "in_progress", output: [] } }); + globalThis.fetch = (async (_input, init) => { + sent.push(JSON.parse(String(init?.body)) as Record); + if (sent.length > 1) return new Response(sseEvent("response.completed", { response: { id: "replacement", status: "completed", output: [] } }), { headers: { "content-type": "text/event-stream" } }); + if (ending === "pre-header") throw Object.assign(new Error("fixture reset"), { code: "ECONNRESET" }); + let first = true; + return new Response(new ReadableStream({ pull(controller) { + if (first) { first = false; controller.enqueue(new TextEncoder().encode(prelude)); } + else if (ending === "eof") controller.close(); + else controller.error(Object.assign(new Error("fixture reset"), { code: "ECONNRESET" })); + } }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const requestConfig: OcxConfig = { ...config, defaultProvider: "fixture", providers: { fixture: { ...provider, baseUrl: "https://provider.example.test/v1", authMode: "key", apiKey: "fixture-key", retryOnReset: {}, + modelPreferHostedTools: { "gpt-5.6-sol": ["image_generation"] } } } }; + const response = await call({ ...requestBody(true, false), model: "fixture/gpt-5.6-sol", tools: [{ type: "function", name: "image_gen.imagegen", parameters: { type: "object", properties: {} } }] }, {}, requestConfig); + let text = ""; + let readError: unknown; + try { text = await response.text(); } catch (error) { readError = error; } + expect(sent[0]?.tools).toContainEqual({ type: "image_generation" }); + expect(sent).toHaveLength(1); + if (ending === "eof") expect(text).toContain("original"); + if (ending === "reset") expect(readError ? String(readError) : text).toContain("fixture reset"); + if (ending === "pre-header") expect(text).toContain("upstream_reset_replay_refused"); + expect(text).not.toContain("replacement"); + }); + } +}); + describe("canonical ChatGPT transport for non-streaming Responses callers (#6162)", () => { test.each([false, true])("forces upstream SSE without changing explicit store:%s", store => { const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)); diff --git a/tests/responses/responses-passthrough-transient-policy.test.ts b/tests/responses/responses-passthrough-transient-policy.test.ts index f62c5fb13de..355541843ea 100644 --- a/tests/responses/responses-passthrough-transient-policy.test.ts +++ b/tests/responses/responses-passthrough-transient-policy.test.ts @@ -60,7 +60,7 @@ describe("the Responses passthrough lane reads the provider transient policy", ( // Combo receipt mode settles directly; other sends keep the shared reporter. Copilot's // reporter defers its charge until physical dispatch, after negotiation can refuse locally. expect(packed).toContain("constinitialAccounting=copilotSendAccounting()"); - expect(packed).toContain("onPhysicalDispatch:receiptMode?sendBudgetState.noteInitialDispatch:initialAccounting.onPhysicalDispatch"); + expect(packed).toContain("onPhysicalDispatch:recordPhysicalSend(receiptMode?sendBudgetState.noteInitialDispatch:initialAccounting.onPhysicalDispatch)"); expect(packed).toContain("...(receiptMode?{}:{onSendsConsumed:initialAccounting.report})"); expect(packed).toContain("=>physicalSendAccounting(transientSendReporter(permit),"); expect(budgeted + allowanceBacked + prepaidInitial).toBe(sites); From eae2600146eef2d33e0a4a22c260b5a9ff65bf04 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:37:04 +0900 Subject: [PATCH 18/22] Bound upstream silence during reset selection --- .../responses/passthrough-completion.ts | 11 +- .../transports/responses-combo-boundaries.md | 8 +- tests/responses/empty-completion-core.test.ts | 194 ++++++++++++++++++ 3 files changed, 202 insertions(+), 11 deletions(-) diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 9aa428f1100..740fcb222d8 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -29,9 +29,8 @@ export function isResetReplacementStream(candidate: Response, upstreamRequestsSt * * The selection waits at most one heartbeat interval, and never longer than the stall budget, for * a head; see `selectProtocolSafeResetResponse`. A consumer that measures silence on the bytes it - * receives (the gateway completion guard, the canonical buffered collector) cannot tell a held - * prelude, its later replay or a generated comment from an upstream read, and it starts after the - * selection already waited. Both bodies are therefore guarded before preflight, so each physical + * receives cannot tell a held prelude, its later replay or a generated comment from an upstream + * read, and it starts after selection already waited. Both bodies are guarded before preflight, so each physical * attempt's stall budget runs on its own upstream reads from its own head. The guard's failure is * a timeout, which the reset gate never treats as replayable. The published head carries the main * quota proof of the physical attempt that produced it, because the replacement send renews the @@ -53,11 +52,9 @@ export function selectPassthroughResetResponse( ): Promise { const openGateway = options.request.url === "https://apis.opengateway.ai/v1/responses" && response.status === 200 && response.body !== null; - const guard = openGateway || options.keepalive === "upstream-activity"; const stallMs = resolveStallTimeoutMs(options.config.stallTimeoutSec, { localUpstream: isLocalUpstream(options.request.url) }); - const protect = (source: Response): Response => guard - ? carryResponseToolOwnership(source, guardResponseBodyInactivity(source, options.signal, stallMs).response) - : source; + const protect = (source: Response): Response => + carryResponseToolOwnership(source, guardResponseBodyInactivity(source, options.signal, stallMs).response); const quotaOwner = (): MainQuotaDispatch | undefined => admission.authCtx.kind === "main" ? admission.authCtx.mainQuotaDispatch : undefined; const originalOwner = quotaOwner(); diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 9eca60a7c00..a06880a1d6f 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -156,13 +156,13 @@ of the attempt that produced it ([OpenAI tiers](../providers/openai-tiers.md)). clients get an SSE comment on their first read after a deadline commit and then once per heartbeat interval while the hold lasts. Combo streaming children omit those timer-generated comments while their parent holds the headers, so those comments -cannot consume the retained-chunk cap. For canonical buffered JSON, the stall budget runs on upstream +cannot consume the retained-chunk cap. In all selection modes, the stall budget runs on upstream reads: `src/server/responses/passthrough-completion.ts` guards the original body and any accepted replacement before preflight, so each physical attempt is bounded from its own head and only its non-empty reads restart the clock. A prelude read during selection and replayed later, or a generated -comment, cannot extend it, and the collector reports the guard's failure as a stall timeout that the -reset gate never resends. The hold emits a comment only when it reads non-empty upstream bytes or a -replacement head lands behind a committed head, so the collector's own clock, which starts after +comment, cannot extend it, and the reset gate never resends a guard timeout. For canonical buffered +JSON, the collector reports the guard's failure as a stall timeout. The hold emits a comment only when +it reads non-empty upstream bytes or a replacement head lands behind a committed head, so the collector's own clock, which starts after selection and sees only delivered bytes, never ends a turn before the guard would. The byte and chunk caps and the collector's whole-turn ceiling remain in force. Downstream cancellation or the request's abort signal ends the selection or hold at once, aborts an diff --git a/tests/responses/empty-completion-core.test.ts b/tests/responses/empty-completion-core.test.ts index 76e70522c73..b9dc505b901 100644 --- a/tests/responses/empty-completion-core.test.ts +++ b/tests/responses/empty-completion-core.test.ts @@ -534,6 +534,200 @@ describe("OpenGateway native empty completion", () => { return { cfg, sends }; } + for (const replayable of [false, true]) { + test(`direct non-gateway reset selection bounds upstream silence (replayable=${replayable})`, async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + pull() { firstRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const pending = handleResponses(new Request(request(true, "question", replayable ? { store: false } : {}), + { signal: client.signal }), { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(15_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const response = await pending; + const reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain(": keepalive"); + jest.advanceTimersByTime(5_000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(1); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + } + + test("direct reset selection resets its deadline on real upstream bytes", async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let source!: ReadableStreamDefaultController; + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + start(controller) { source = controller; }, + pull() { firstRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const pending = handleResponses(new Request(request(true, "question", { store: false }), { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(10_000); + source.enqueue(new TextEncoder().encode('data: {"type":"response.created","response":{"status":"in_progress","output":[]}}\n\n')); + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(5_000); + const response = await pending; + const reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain(": keepalive"); + jest.advanceTimersByTime(14_999); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(cancelled).toBe(0); + jest.advanceTimersByTime(1); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(1); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test("direct reset replacement has its own silence deadline", async () => { + takeSpendHome(); + jest.useFakeTimers(); + const replacementRead = Promise.withResolvers(); + let cancelled = 0; + const f = fixture([ + () => new Response('data: {"type":"response.created","response":{"status":"in_progress","output":[]}}\n\n', + { headers: { "content-type": "text/event-stream" } }), + () => new Response(new ReadableStream({ + pull() { replacementRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } }), + ], "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const pending = handleResponses(new Request(request(true, "question", { store: false }), { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + const response = await pending; + const reader = response.body!.getReader(); + const read = reader.read(); + await replacementRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(f.sends).toHaveLength(2); + jest.advanceTimersByTime(15_000); + jest.advanceTimersByTime(5_000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(2); + await read.catch(() => undefined); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test("direct committed output remains visible when its source later stalls", async () => { + takeSpendHome(); + jest.useFakeTimers(); + let cancelled = 0; + let sent = false; + const nextRead = Promise.withResolvers(); + const f = fixture([() => new Response(new ReadableStream({ + pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":"visible"}\n\n')); + } else nextRead.resolve(); + }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const pending = handleResponses(new Request(request(true, "question", { store: false }), { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + const response = await pending; + const reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("visible"); + const read = reader.read(); + await nextRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(20_000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(1); + await read.catch(() => undefined); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + for (const policy of ["explicit-off", "local-default"] as const) { + test(`direct reset selection honors ${policy} and cancels on downstream abort`, async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + pull() { firstRead.resolve(); }, + cancel() { cancelled++; return new Promise(() => {}); }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + policy === "local-default" ? "http://127.0.0.1:7777/v1/responses" : "https://fixture.test/v1/responses", + { retryOnReset: { enabled: true, replacements: 1 }, ...(policy === "local-default" ? { allowPrivateNetwork: true } : {}) }); + const client = new AbortController(); + const pending = handleResponses(new Request(request(true, "question", { store: false }), { signal: client.signal }), + { ...f.cfg, ...(policy === "explicit-off" ? { stallTimeoutSec: 0 } : {}) }, { model: "", provider: "" }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(15_000); + const response = await pending; + const reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain(": keepalive"); + jest.advanceTimersByTime(300_000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(cancelled).toBe(0); + client.abort(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(1); + await reader.cancel().catch(() => {}); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + } + test("reset preflight heartbeats cannot hide a silent OpenGateway upstream", async () => { takeSpendHome(); jest.useFakeTimers(); From fc26e0b4d4b37027cd5ce88a1907f6a47be19da9 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:42:23 +0900 Subject: [PATCH 19/22] Return direct stream heads before reset inspection settles Native reset selection held a direct streaming client's response head for up to one 15-second heartbeat interval while it inspected the original upstream. A client with a shorter header timeout failed on a healthy slow turn, including requests the resend gate could never replace. A request that is not self-contained now skips the preflight entirely, so its head and prelude are relayed as for a provider without reset replay. A direct stream commits the original head once the original's own inspection has run for 250 ms; inspection continues behind that head and a replacement must then match it exactly. A failure observed inside the window still waits for the replacement's own head, bounded by the existing deadline. Bun's server writes a head only with the first body bytes, so the window commit owes the client an SSE comment at once, as the deadline commit already did. Canonical buffered JSON and combo children keep the deadline because their consumers publish no head first. The physical replay guard memoizes its verdict for the body that left, so the eligibility check does not parse a large outbound body twice. --- .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- scripts/test-layout/layout-replay.json | 1 + .../responses/combo-stream-preflight.ts | 31 ++- .../responses/passthrough-completion.ts | 28 ++- src/server/responses/passthrough-dispatch.ts | 6 +- src/server/responses/reset-replay.ts | 10 +- .../transports/responses-combo-boundaries.md | 18 +- tests/fixtures/test-layout-expected.json | 2 +- tests/responses/empty-completion-core.test.ts | 108 ++++++---- .../responses/responses-reset-replay.test.ts | 82 ++++++++ .../responses-reset-selection-http.test.ts | 194 ++++++++++++++++++ 18 files changed, 438 insertions(+), 58 deletions(-) create mode 100644 tests/responses/responses-reset-selection-http.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index c2056c35dc1..c14acacb792 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,7 +188,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Si la réinitialisation ou la fin de flux survient avant le renvoi de l'en-tête de réponse, dans les 15 secondes ou dans le délai d'inactivité s'il est plus court, le client reçoit le remplacement avec son propre statut et ses propres en-têtes. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original est conservé. +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Les requêtes hors de ces limites ne sont pas inspectées et leur flux n'est jamais retenu. Un flux direct reçoit l'en-tête de réponse original dans les 250 millisecondes, même si l'amont est encore silencieux. Si la réinitialisation ou la fin de flux survient avant le renvoi de cet en-tête, le client reçoit le remplacement avec son propre statut et ses propres en-têtes, après avoir attendu l'en-tête du remplacement au plus 15 secondes, ou le délai d'inactivité s'il est plus court. Les requêtes non diffusées collectées en JSON et les cibles de combo peuvent attendre aussi longtemps avant de choisir un en-tête, car leur client ne reçoit aucun en-tête avant la collecte ou la sélection de la réponse. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original et son erreur sont conservés. Les flux directs encore inspectés après ce point reçoivent un commentaire SSE keepalive avec l'en-tête, puis toutes les 15 secondes ; la vérification préalable du combo retient ces commentaires jusqu'à ce qu'elle sélectionne une réponse. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index eb1f88a4e09..fd2733dcc66 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,7 +180,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。応答ヘッダーを返す前(15 秒以内、ストールタイムアウトの方が短い場合はその時間内)にリセットまたは EOF が起きた場合、クライアントは置換応答をそのステータスとヘッダーのまま受け取ります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームを保持します。 +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。これらの制限外のリクエストは検査されず、ストリームが保留されることはありません。直接ストリームは、上流がまだ無応答でも 250 ミリ秒以内に元の応答ヘッダーを受け取ります。そのヘッダーを返す前にリセットまたは EOF が起きた場合、置換応答のヘッダーを最大 15 秒(ストールタイムアウトの方が短い場合はその時間)待ち、クライアントは置換応答をそのステータスとヘッダーのまま受け取ります。JSON に収集する非ストリーミングリクエストとコンボのターゲットは、応答の収集または選択までクライアントにヘッダーが返らないため、ヘッダーの選択を同じ時間まで待つことがあります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームとそのエラーを保持します。その後も検査中の直接ストリームにはヘッダーとともに SSE keepalive コメントが送られ、その後は 15 秒ごとに送られます。コンボのプリフライトは応答を選択するまでこれらのコメントを保留します。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 4d15a97df39..54c027870e6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,7 +180,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 응답 헤더를 반환하기 전(15초 이내, 정지 시간 제한이 더 짧으면 그 시간 이내)에 리셋이나 EOF가 발생하면 클라이언트는 교체 응답을 그 응답의 상태와 헤더 그대로 받습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림을 유지합니다. +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 이 제한을 벗어난 요청은 검사하지 않으므로 스트림이 보류되지 않습니다. 직접 스트림은 업스트림이 아직 조용하더라도 250밀리초 안에 원본 응답 헤더를 받습니다. 그 헤더를 반환하기 전에 리셋이나 EOF가 발생하면 교체 응답의 헤더를 최대 15초(정지 시간 제한이 더 짧으면 그 시간)까지 기다리며, 클라이언트는 교체 응답을 그 응답의 상태와 헤더 그대로 받습니다. JSON으로 모으는 비스트리밍 요청과 콤보 대상은 응답을 모으거나 선택하기 전까지 클라이언트에 헤더가 전달되지 않으므로 헤더 선택을 같은 시간까지 기다릴 수 있습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림과 그 오류를 유지합니다. 그 뒤에도 검사 중인 직접 스트림은 헤더와 함께 SSE keepalive 주석을 받고 그 뒤 15초마다 다시 받으며, 콤보 사전 검사는 응답을 선택할 때까지 이 주석을 보류합니다. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d73ad1f89ed..fabd49929ea 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -442,7 +442,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. If the reset or EOF happens before the response head is returned, within 15 seconds or the stall timeout if that is shorter, the client receives the replacement with its own status and headers. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream is retained. Direct streams still being inspected after that point receive SSE keepalive comments; combo preflight holds these comments until it selects a response. +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Requests outside those limits are not inspected, so their streams are never held. A direct stream receives the original response head within 250 milliseconds while the upstream is still quiet. If the reset or EOF happens before that head is returned, the client receives the replacement with its own status and headers, after waiting at most 15 seconds, or the stall timeout if that is shorter, for the replacement's head. Non-streaming requests collected into JSON and combo targets may wait that long before choosing a head, because their client receives no head until the response is collected or selected. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream and its error are retained. Direct streams still being inspected after that point receive an SSE keepalive comment with the head and then every 15 seconds; combo preflight holds these comments until it selects a response. Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 908ee49228e..2bac05ec757 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,7 +193,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Если сброс или EOF происходит до возврата заголовков ответа, в течение 15 секунд или тайм-аута простоя, если он короче, клиент получает замену с её собственными статусом и заголовками. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняется исходный поток. +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Запросы вне этих ограничений не проверяются, и их поток не задерживается. Прямой поток получает исходные заголовки ответа в течение 250 миллисекунд, даже если вышестоящий сервер ещё молчит. Если сброс или EOF происходит до возврата этих заголовков, клиент получает замену с её собственными статусом и заголовками; заголовков замены ждут не дольше 15 секунд или тайм-аута простоя, если он короче. Непотоковые запросы, собираемые в JSON, и цели комбо могут ждать столько же перед выбором заголовков, потому что их клиент не получает заголовков до сбора или выбора ответа. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняются исходный поток и его ошибка. Прямые потоки, которые после этого ещё проверяются, получают SSE-комментарий keepalive вместе с заголовками, а затем каждые 15 секунд; предварительная проверка комбо удерживает эти комментарии до выбора ответа. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index a3c2a102dcc..266e333804d 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,7 +192,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Sıfırlama veya EOF yanıt başlığı döndürülmeden önce, 15 saniye içinde ya da daha kısaysa takılma zaman aşımı içinde olursa istemci yedek yanıtı kendi durumu ve başlıklarıyla alır. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış korunur. +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Bu sınırların dışındaki istekler denetlenmez, bu yüzden akışları bekletilmez. Doğrudan akış, üst sunucu henüz sessiz olsa bile orijinal yanıt başlığını 250 milisaniye içinde alır. Sıfırlama veya EOF bu başlık döndürülmeden önce olursa istemci yedek yanıtı kendi durumu ve başlıklarıyla alır; yedek yanıtın başlığı en fazla 15 saniye ya da daha kısaysa takılma zaman aşımı kadar beklenir. JSON olarak toplanan akışsız istekler ve combo hedefleri, istemcileri yanıt toplanana veya seçilene kadar başlık almadığı için başlık seçmeden önce aynı süre bekleyebilir. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış ve hatası korunur. Bundan sonra hâlâ denetlenen doğrudan akışlar başlıkla birlikte ve ardından her 15 saniyede bir SSE keepalive yorumu alır; combo ön denetimi bir yanıt seçene kadar bu yorumları tutar. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index bb4b0b732a9..ee343379f07 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,7 +182,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。如果重置或 EOF 发生在返回响应头之前(15 秒内,若停滞超时更短则在其时限内),客户端会收到带有其自身状态和响应头的替换响应。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流。 +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。超出这些限制的请求不会被检查,其流也不会被暂扣。直接流即使上游仍无响应,也会在 250 毫秒内收到原始响应头。如果重置或 EOF 发生在返回该响应头之前,客户端会收到带有其自身状态和响应头的替换响应;等待替换响应头的时间最多为 15 秒(若停滞超时更短则以其为准)。汇总为 JSON 的非流式请求和组合目标在响应汇总或选定之前不会向客户端返回响应头,因此选择响应头前可能等待同样的时间。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流及其错误。此后仍在检查的直接流会随响应头收到一条 SSE keepalive 注释,此后每 15 秒再收到一条;组合预检会在选定响应前暂扣这些注释。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 6a530055c6d..6b859f736e2 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,7 +153,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。如果重設或 EOF 發生在回傳回應標頭之前(15 秒內,若停滯逾時更短則在其時限內),用戶端會收到帶有其自身狀態和回應標頭的替換回應。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流。 +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。超出這些限制的請求不會被檢查,其串流也不會被暫扣。直接串流即使上游仍無回應,也會在 250 毫秒內收到原始回應標頭。如果重設或 EOF 發生在回傳該回應標頭之前,用戶端會收到帶有其自身狀態和回應標頭的替換回應;等待替換回應標頭的時間最多為 15 秒(若停滯逾時更短則以其為準)。彙整為 JSON 的非串流請求與組合目標在回應彙整或選定之前不會向用戶端回傳標頭,因此選擇標頭前可能等待同樣的時間。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流及其錯誤。此後仍在檢查的直接串流會隨回應標頭收到一則 SSE keepalive 註解,此後每 15 秒再收到一則;組合預檢會在選定回應前暫扣這些註解。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json index e6d28e765d1..16ec25e5696 100644 --- a/scripts/test-layout/layout-replay.json +++ b/scripts/test-layout/layout-replay.json @@ -8,6 +8,7 @@ "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses", + "responses-reset-selection-http.test.ts": "responses", "messages-native-combo.test.ts": "claude-integration", "messages-native-combo-framing.test.ts": "claude-integration" } diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 3bef1b02ef6..32e0a64c5ca 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -649,6 +649,13 @@ export type ResetSelectionOwner = "original" | "replacement"; export interface ResetSelectionOptions extends DeferredResetRecoveryOptions { /** Upper bound on the pre-head wait. The wait never exceeds one heartbeat interval either. */ maxSelectionMs?: number; + /** + * Upper bound on how long the original's own inspection may hold the head. When it expires + * before the original settles, the original head is committed and inspection continues behind + * it. A replacement requested after the original failed inside this window is still bounded by + * `maxSelectionMs`, because no healthy source is waiting behind that head. + */ + inspectionWindowMs?: number; /** Ends the selection, the committed hold and an in-flight replacement without awaiting cleanup. */ signal?: AbortSignal; /** Called once, synchronously, before the caller receives the published response. */ @@ -680,6 +687,11 @@ function createResetSelection( let initializationResult: { kind: "ready" } | { kind: "failed"; error: unknown } | undefined; let notifyInitialization: (() => void) | undefined; let commitEarly: ((published: Response, owner: ResetSelectionOwner) => void) | undefined; + let inspectionTimer: ReturnType | undefined; + const endInspectionWindow = (): void => { + if (inspectionTimer !== undefined) clearTimeout(inspectionTimer); + inspectionTimer = undefined; + }; let activityPending = false; // A replacement head behind a committed head is upstream activity the held stream must report. let replacementHeadPending = false; @@ -708,6 +720,8 @@ function createResetSelection( notifyInitialization?.(); } : undefined }, ); + // The original settled, so it no longer holds the head; only a replacement can from here. + endInspectionWindow(); let chosen: { response: Response; owner: ResetSelectionOwner } = { response: preflight.response, owner: "original" }; if (!closed && (preflight.kind === "read-error" || preflight.kind === "pre-output-eof")) { const replacement = await recover(preflight.error, preflight.stage, preflightAbort.signal, accepts); @@ -840,6 +854,7 @@ function createResetSelection( commitEarly = undefined; headCommitted = true; if (deadline !== undefined) clearTimeout(deadline); + endInspectionWindow(); // A published selection owns its own body; the hold and its abort hook are finished. if (published !== held) detach(); try { options?.onCommit?.(published, owner); } finally { resolve(published); } @@ -849,11 +864,22 @@ function createResetSelection( return; } signal?.addEventListener("abort", onAbort, { once: true }); + const deadlineMs = Math.min(heartbeatMs, positiveMs(options?.maxSelectionMs) ?? Infinity); deadline = setTimeout(() => { // The client has waited one heartbeat interval for a head; its first read gets a keepalive. if (keepalive === "periodic") heartbeatDue = true; commitEarly?.(held, "original"); - }, Math.min(heartbeatMs, positiveMs(options?.maxSelectionMs) ?? Infinity)); + }, deadlineMs); + const inspectionMs = positiveMs(options?.inspectionWindowMs); + // A quiet original is a healthy slow response as often as a dying one; its head goes out now. + // Bun's server writes a head only with the first body bytes, so the held body owes a comment. + if (inspectionMs !== undefined && inspectionMs < deadlineMs) { + inspectionTimer = setTimeout(() => { + inspectionTimer = undefined; + if (keepalive === "periodic") heartbeatDue = true; + commitEarly?.(held, "original"); + }, inspectionMs); + } void start().then(() => { if (selection && !closed) commitEarly?.(selection.response, selection.owner); else commitEarly?.(held, "original"); @@ -879,7 +905,8 @@ export function deferProtocolSafeResetRecovery( } /** - * Choose the native response before its head is committed, within one heartbeat interval. + * Choose the native response before its head is committed, within one heartbeat interval or the + * shorter `inspectionWindowMs` while the original itself is still being inspected. * * Inspection starts immediately. If selection finishes first, the caller receives the selected * response, a replacement included, with its own status and headers. If the deadline wins, the diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 740fcb222d8..dedbb4c77ba 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -18,6 +18,21 @@ export { parseOutboundRequestBody } from "./outbound-request-body"; // A guarded published reset response, mapped to when its selection began holding the head. const guardedResetResponses = new WeakMap(); +/** + * How long a direct stream's head may wait on the original's own inspection. + * + * Returning the response is the latest point this code can still choose a head, but it does not + * send one: Bun's server writes the head together with the first body bytes, so a window commit + * also gives the client a comment at once. A healthy slow upstream and a dying one look the same + * until the source fails, so a direct client gets the original head promptly instead of after a + * heartbeat interval. A failure observed inside this window may still publish the replacement's + * own head, as a pre-header reset does. + */ +export const DIRECT_RESET_INSPECTION_WINDOW_MS = 250; + +/** Who consumes the selected head: the client directly, the canonical JSON collector, or a combo parent. */ +export type ResetSelectionDelivery = "direct-stream" | "buffered-json" | "combo-child"; + /** A replacement must still be the stream the upstream request asked for; refetch requires 2xx. */ export function isResetReplacementStream(candidate: Response, upstreamRequestsStream: boolean): boolean { const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; @@ -27,8 +42,10 @@ export function isResetReplacementStream(candidate: Response, upstreamRequestsSt /** * Select the native response a reset preflight publishes. * - * The selection waits at most one heartbeat interval, and never longer than the stall budget, for - * a head; see `selectProtocolSafeResetResponse`. A consumer that measures silence on the bytes it + * A direct stream's head waits on the original's inspection for at most + * `DIRECT_RESET_INSPECTION_WINDOW_MS`. The canonical JSON collector and a combo parent publish no + * head before their own inspection ends, so their selection may wait one heartbeat interval. No + * selection waits longer than the stall budget; see `selectProtocolSafeResetResponse`. A consumer that measures silence on the bytes it * receives cannot tell a held prelude, its later replay or a generated comment from an upstream * read, and it starts after selection already waited. Both bodies are guarded before preflight, so each physical * attempt's stall budget runs on its own upstream reads from its own head. The guard's failure is @@ -46,8 +63,7 @@ export function selectPassthroughResetResponse( config: Pick; signal: AbortSignal; allowMissingContentType: boolean; - /** `upstream-activity` is the canonical buffered collector; see the selection's keepalive modes. */ - keepalive: "periodic" | "upstream-activity" | "none"; + delivery: ResetSelectionDelivery; }, ): Promise { const openGateway = options.request.url === "https://apis.opengateway.ai/v1/responses" @@ -68,7 +84,9 @@ export function selectPassthroughResetResponse( return protect(replacement); }, { allowMissingContentType: options.allowMissingContentType, - keepalive: options.keepalive, + // The collector's silence clock sees only delivered bytes; a combo parent holds the head itself. + keepalive: options.delivery === "buffered-json" ? "upstream-activity" : options.delivery === "combo-child" ? "none" : "periodic", + inspectionWindowMs: options.delivery === "direct-stream" ? DIRECT_RESET_INSPECTION_WINDOW_MS : undefined, signal: options.signal, maxSelectionMs: stallMs > 0 ? stallMs : undefined, onCommit: (published, owner) => { diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index c7bf369651f..6718191bce2 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1911,7 +1911,8 @@ export async function preparePassthroughExchange( // This preflight retains that event until output commits, so the two contracts cannot // share one body owner. && !(options.nativeControl && options.inboundTransport === "websocket") - && ambiguousResend() !== undefined + // Only a self-contained request can be replaced; any other preflight only delays its head. + && ambiguousResend()?.selfContained === true && remainingTransientSendBudget(transientSendAttempts()) > 0 && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && upstreamRequestsStream)); if (protocolRecoveryCandidate) { @@ -1934,7 +1935,8 @@ export async function preparePassthroughExchange( acceptResponse: candidate => accepts(candidate) && isResetReplacementStream(candidate, upstreamRequestsStream), }, ), - { request, config, signal: upstream.signal, allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, keepalive: canonicalBufferedJson ? "upstream-activity" : options.comboAttempt ? "none" : "periodic" }, + { request, config, signal: upstream.signal, allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream, + delivery: canonicalBufferedJson ? "buffered-json" : options.comboAttempt ? "combo-child" : "direct-stream" }, ); } break; diff --git a/src/server/responses/reset-replay.ts b/src/server/responses/reset-replay.ts index 2a78025cc95..92eee996bb1 100644 --- a/src/server/responses/reset-replay.ts +++ b/src/server/responses/reset-replay.ts @@ -24,6 +24,8 @@ export class UnsafePhysicalReplacement extends Error {} /** Captures the attempt that actually left, and fences any replacement after queued rebuilding. */ export function createPhysicalReplayGuard(providerExecuted: () => boolean) { let last: PhysicalReplaySnapshot | undefined; + // A judgment is a pure function of its snapshot, and the outbound body may be megabytes. + let lastSafe: boolean | undefined; let pending = false; const snapshot = (request: AdapterRequest, body: BodyInit | null | undefined): PhysicalReplaySnapshot => ({ body, hosted: (request.hostedToolSubstitutions?.length ?? 0) > 0, providerExecuted: providerExecuted(), @@ -32,13 +34,19 @@ export function createPhysicalReplayGuard(providerExecuted: () => boolean) { && !value.hosted && !value.providerExecuted && typeof value.body === "string" && selfContainedResponsesBody(parseOutboundRequestBody(value.body)); return { - isSelfContained: (request: AdapterRequest) => safe(last) && safe(snapshot(request, request.body)), + isSelfContained: (request: AdapterRequest) => { + if (!(lastSafe ??= safe(last))) return false; + const current = snapshot(request, request.body); + // The body that physically left was judged above; only fresh substitution flags remain. + return current.body === last!.body ? !current.hosted && !current.providerExecuted : safe(current); + }, authorize: () => { pending = true; }, record: (request: AdapterRequest, body: BodyInit | null | undefined, next?: () => void) => { const physical = snapshot(request, body); if (pending && !safe(physical)) throw new UnsafePhysicalReplacement("The rebuilt request cannot be safely replayed"); next?.(); last = physical; + lastSafe = undefined; pending = false; }, }; diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index a06880a1d6f..2d1ee5dcd87 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -144,17 +144,27 @@ or an unfinished output/error/terminal frame on a rejected read also keeps the o read error; the last SSE event field is authoritative. A clean EOF before output or a terminal is offered only to the native reset recovery owner as a distinct pre-output EOF; ordinary combo preflight keeps its existing EOF behavior. Native passthrough starts this preflight at dispatch, before it returns a head -(`selectProtocolSafeResetResponse` in `src/server/responses/combo-stream-preflight.ts`). Dispatch +(`selectProtocolSafeResetResponse` in `src/server/responses/combo-stream-preflight.ts`), and only +for a request the resend gate could replace: a request that is not self-contained is relayed without +preflight, so nothing holds its head or prelude. Dispatch waits for whichever comes first: the selection, an abort, or a deadline of one 15-second heartbeat -interval capped by the stall timeout. A selection that finishes first publishes the selected -response, including a replacement with its own status and headers. If the deadline wins, the +interval capped by the stall timeout. Returning the response is the last point at which dispatch can +choose a head, so a direct stream also commits the original head once the original's own inspection +has run for `DIRECT_RESET_INSPECTION_WINDOW_MS` (250 ms, `src/server/responses/passthrough-completion.ts`): +a quiet source may be a healthy slow turn. A failure observed inside that window ends the window, and +only the replacement's head is then awaited, still bounded by the deadline, as after a pre-header reset. +Canonical buffered JSON and combo children keep the deadline alone, because their consumer publishes +no client head before its own inspection ends. A selection that finishes first publishes the selected +response, including a replacement with its own status and headers. If the deadline or the window wins, the original head is returned and the same preflight continues behind it. From then on a replacement with a different status, status text, or header set is cancelled and the original bytes and error are preserved; the check runs when the recovery accepts the replacement and again when it lands. `src/server/responses/passthrough-completion.ts` binds the published head to the main quota proof of the attempt that produced it ([OpenAI tiers](../providers/openai-tiers.md)). Direct streaming clients get an SSE comment on their first read after a deadline commit and then once per heartbeat -interval while the hold lasts. Combo streaming +interval while the hold lasts. A window commit owes the same first comment at once: Bun's server +writes a response head only with its first body bytes, so a returned head with a silent body never +reaches the client. Combo streaming children omit those timer-generated comments while their parent holds the headers, so those comments cannot consume the retained-chunk cap. In all selection modes, the stall budget runs on upstream reads: `src/server/responses/passthrough-completion.ts` guards the original body and any accepted diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dc905cc69bd..af2717a02dc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1295,7 +1295,7 @@ "responses-parser.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", - "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", + "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", "responses-reset-selection-http.test.ts": "responses", "responses-translated-reset.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", "responses-send-budget-errors.test.ts": "responses", "responses-memory-models.test.ts": "responses", diff --git a/tests/responses/empty-completion-core.test.ts b/tests/responses/empty-completion-core.test.ts index b9dc505b901..a9e1896ac5b 100644 --- a/tests/responses/empty-completion-core.test.ts +++ b/tests/responses/empty-completion-core.test.ts @@ -534,41 +534,79 @@ describe("OpenGateway native empty completion", () => { return { cfg, sends }; } - for (const replayable of [false, true]) { - test(`direct non-gateway reset selection bounds upstream silence (replayable=${replayable})`, async () => { - takeSpendHome(); - jest.useFakeTimers(); - const firstRead = Promise.withResolvers(); - let cancelled = 0; - const f = fixture([() => new Response(new ReadableStream({ - pull() { firstRead.resolve(); }, - cancel() { cancelled++; }, - }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], - "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); - const client = new AbortController(); - const pending = handleResponses(new Request(request(true, "question", replayable ? { store: false } : {}), - { signal: client.signal }), { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); - try { - await firstRead.promise; - for (let i = 0; i < 50; i++) await Promise.resolve(); - jest.advanceTimersByTime(15_000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const response = await pending; - const reader = response.body!.getReader(); - expect(new TextDecoder().decode((await reader.read()).value)).toContain(": keepalive"); - jest.advanceTimersByTime(5_000); - for (let i = 0; i < 100; i++) await Promise.resolve(); - expect(cancelled).toBe(1); - expect(f.sends).toHaveLength(1); - await reader.cancel(); - } finally { - client.abort(); - await pending.catch(() => undefined); - jest.clearAllTimers(); - jest.useRealTimers(); - } - }); - } + test("direct non-gateway reset selection bounds upstream silence (replayable=true)", async () => { + takeSpendHome(); + jest.useFakeTimers(); + const firstRead = Promise.withResolvers(); + let cancelled = 0; + const f = fixture([() => new Response(new ReadableStream({ + pull() { firstRead.resolve(); }, + cancel() { cancelled++; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + const pending = handleResponses(new Request(request(true, "question", { store: false }), + { signal: client.signal }), { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + let returned = false; + void pending.then(() => { returned = true; }); + try { + await firstRead.promise; + for (let i = 0; i < 50; i++) await Promise.resolve(); + jest.advanceTimersByTime(249); + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(returned).toBe(false); + // The quiet original's inspection window ends; its head is returned with a comment to carry it. + jest.advanceTimersByTime(1); + const response = await pending; + const reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain(": keepalive"); + jest.advanceTimersByTime(19_749); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(0); + jest.advanceTimersByTime(1); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(cancelled).toBe(1); + expect(f.sends).toHaveLength(1); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test("a direct request that cannot be replaced is relayed without reset selection", async () => { + takeSpendHome(); + jest.useFakeTimers(); + let source!: ReadableStreamDefaultController; + const f = fixture([() => new Response(new ReadableStream({ + start(controller) { source = controller; }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } })], + "https://fixture.test/v1/responses", { retryOnReset: { enabled: true, replacements: 1 } }); + const client = new AbortController(); + // store defaults to true, so the resend gate could never fund a replacement for this turn. + const pending = handleResponses(new Request(request(true, "question"), { signal: client.signal }), + { ...f.cfg, stallTimeoutSec: 20 }, { model: "", provider: "" }); + try { + // No timer has run: nothing held the head. + const response = await pending; + const reader = response.body!.getReader(); + source.enqueue(new TextEncoder().encode('data: {"type":"response.created","response":{"id":"resp_open","status":"in_progress","output":[]}}\n\n')); + // Nor the prelude, which a reset preflight would retain until output. + expect(new TextDecoder().decode((await reader.read()).value)).toContain("resp_open"); + // From here it is an ordinary native SSE relay; its silence belongs to the client's own clock. + source.enqueue(new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":"open"}\n\n')); + expect(new TextDecoder().decode((await reader.read()).value)).toContain('"delta":"open"'); + expect(f.sends).toHaveLength(1); + await reader.cancel(); + } finally { + client.abort(); + await pending.catch(() => undefined); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); test("direct reset selection resets its deadline on real upstream bytes", async () => { takeSpendHome(); diff --git a/tests/responses/responses-reset-replay.test.ts b/tests/responses/responses-reset-replay.test.ts index e4b5dac6293..ec7a3066e3c 100644 --- a/tests/responses/responses-reset-replay.test.ts +++ b/tests/responses/responses-reset-replay.test.ts @@ -201,6 +201,88 @@ describe("early reset selection", () => { expect(String(rest.error)).toContain("fixture reset"); expect(commits).toEqual(["original"]); }); + + test("an inspection window commits a quiet original head, then keeps inspecting behind it", async () => { + useFakeTimers(); + const gate = Promise.withResolvers(); + const waiting = Promise.withResolvers(); + const replacement = { cancelled: false }; + const order: string[] = []; + let acceptedAfterCommit: boolean | undefined; + const selected = selectProtocolSafeResetResponse(gatedOriginal(gate.promise, { cancels: 0, waiting: waiting.resolve }), responseLog, + async (_error, _stage, _signal, accepts) => { + const candidate = trackedReplacement(replacement); + acceptedAfterCommit = accepts(candidate); + return candidate; + }, { heartbeatIntervalMs: 15_000, inspectionWindowMs: 250, onCommit: (_r, owner) => order.push("commit:" + owner) }); + await waiting.promise; + jest.advanceTimersByTime(249); + await flush(); + expect(order).toEqual([]); + jest.advanceTimersByTime(1); + const published = await selected; + order.push("returned"); + // The owner is bound before the caller can publish the head. + expect(order).toEqual(["commit:original", "returned"]); + expect([...published.headers]).toEqual([...new Headers(originalHeaders)]); + const reader = published.body!.getReader(); + // An HTTP server may write the head only with body bytes, so the first read is a comment. + expect(decoder.decode((await reader.read()).value)).toBe(": keepalive\n\n"); + let second: string | undefined; + const secondRead = reader.read().then(next => { second = decoder.decode(next.value); }); + jest.advanceTimersByTime(14_999); + await flush(); + expect(second).toBeUndefined(); + jest.advanceTimersByTime(1); + await secondRead; + expect(second).toBe(": keepalive\n\n"); + gate.resolve(); + const rest = await readAll(reader); + expect(acceptedAfterCommit).toBe(false); + expect(replacement.cancelled).toBe(true); + expect(rest.text).toBe(prelude); + expect(String(rest.error)).toContain("fixture reset"); + expect(order).toEqual(["commit:original", "returned"]); + }); + + test("a failure inside the inspection window may publish its replacement after the window", async () => { + useFakeTimers(); + const started = Promise.withResolvers(); + const pending = Promise.withResolvers(); + const replacement = { cancelled: false }; + const commits: ResetSelectionOwner[] = []; + const selected = selectProtocolSafeResetResponse(gatedOriginal(Promise.resolve(), { cancels: 0 }), responseLog, + async () => { started.resolve(); return pending.promise; }, + { heartbeatIntervalMs: 15_000, inspectionWindowMs: 250, onCommit: (_r, owner) => commits.push(owner) }); + await started.promise; + // The original is already dead; only the replacement's own head is outstanding. + jest.advanceTimersByTime(14_999); + await flush(); + expect(commits).toEqual([]); + pending.resolve(trackedReplacement(replacement, replacementHeaders, 201)); + const published = await selected; + expect(commits).toEqual(["replacement"]); + expect(published.status).toBe(201); + expect([...published.headers]).toEqual([...new Headers(replacementHeaders)]); + expect(await published.text()).toBe(completed); + jest.advanceTimersByTime(15_000); + expect(commits).toEqual(["replacement"]); + }); + + test("an original that commits output inside the inspection window is published once", async () => { + useFakeTimers(); + const output = 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"hi"}\n\n'; + const commits: ResetSelectionOwner[] = []; + let recoveries = 0; + const selected = selectProtocolSafeResetResponse(new Response(prelude + output, { headers: originalHeaders }), responseLog, + async () => { recoveries++; return null; }, + { heartbeatIntervalMs: 15_000, inspectionWindowMs: 250, onCommit: (_r, owner) => commits.push(owner) }); + const published = await selected; + jest.advanceTimersByTime(15_000); + expect(commits).toEqual(["original"]); + expect(recoveries).toBe(0); + expect(await published.text()).toBe(prelude + output); + }); }); describe("deferred response metadata", () => { diff --git a/tests/responses/responses-reset-selection-http.test.ts b/tests/responses/responses-reset-selection-http.test.ts new file mode 100644 index 00000000000..bab7de48c7d --- /dev/null +++ b/tests/responses/responses-reset-selection-http.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { getDefaultConfig } from "../../src/config"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +// Every leg here is real local HTTP: the client sees a head only once the proxy actually wrote one. +const realFetch = globalThis.fetch; +const encoder = new TextEncoder(); +const frame = (type: string, extra: Record = {}) => + "event: " + type + "\ndata: " + JSON.stringify({ type, ...extra }) + "\n\n"; +const created = (id: string) => frame("response.created", { response: { id, status: "in_progress", output: [] } }); +const delta = (text: string) => frame("response.output_text.delta", { item_id: "msg_1", output_index: 0, content_index: 0, delta: text }); +const completed = (id: string) => frame("response.completed", { response: { id, status: "completed", output: [] } }); +// Far below the 15-second heartbeat the head used to wait on, and far above the inspection window. +const CLIENT_HEAD_TIMEOUT_MS = 3_000; + +type Attempt = (controller: ReadableStreamDefaultController, step: number) => Promise | boolean | void; + +const cleanups: (() => void)[] = []; +afterEach(() => { + globalThis.fetch = realFetch; + for (const cleanup of cleanups.splice(0).reverse()) cleanup(); +}); + +/** + * A local upstream whose N-th physical request streams through attempts[N]. An attempt returns + * true to close its stream cleanly; it may await to make the source quiet. + */ +function upstream(attempts: { headers: Record; pull: Attempt }[]) { + const sends: unknown[] = []; + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + sends.push(await req.json()); + const attempt = attempts[Math.min(sends.length - 1, attempts.length - 1)]!; + let step = 0; + return new Response(new ReadableStream({ + async pull(controller) { + if (await attempt.pull(controller, step++) === true) controller.close(); + }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream", ...attempt.headers } }); + } }); + cleanups.push(() => server.stop(true)); + return { sends, url: "http://127.0.0.1:" + server.port + "/v1" }; +} + +function proxy(config: OcxConfig) { + const release = acquireOwnedSpendHome(); + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, idleTimeout: 0, + fetch: req => handleResponses(req, config, { model: "", provider: "" }) }); + cleanups.push(() => { server.stop(true); release(); }); + return (body: Record, signal?: AbortSignal) => realFetch("http://127.0.0.1:" + server.port + "/v1/responses", { + method: "POST", headers: { "content-type": "application/json", authorization: "Bearer fixture-token" }, + body: JSON.stringify({ model: "fixture/model", input: "ping", stream: true, ...body }), signal, + }); +} + +const localConfig = (url: string): OcxConfig => ({ port: 0, defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-responses", baseUrl: url, apiKey: "fixture-key", authMode: "key", models: ["model"], + allowPrivateNetwork: true, retryOnReset: { enabled: true, replacements: 1 }, +} } }); + +async function readUntil(reader: ReadableStreamDefaultReader, needle: string): Promise { + const decoder = new TextDecoder(); + let text = ""; + while (!text.includes(needle)) { + const next = await reader.read(); + if (next.done) break; + text += decoder.decode(next.value, { stream: true }); + } + return text; +} +async function readAll(response: Response): Promise<{ text: string; error?: unknown }> { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + try { + const next = await reader.read(); + if (next.done) return { text }; + text += decoder.decode(next.value, { stream: true }); + } catch (error) { return { text, error }; } + } +} + +describe("reset selection over real local HTTP", () => { + for (const store of [false, true]) { + test("a healthy slow stream gets its head before the upstream resumes (store=" + store + ")", async () => { + const gate = Promise.withResolvers(); + cleanups.push(() => gate.resolve()); + const up = upstream([{ headers: { "x-attempt": "one" }, pull: async (controller, step) => { + if (step === 0) { controller.enqueue(encoder.encode(created("resp_slow"))); return; } + await gate.promise; + controller.enqueue(encoder.encode(delta("late answer") + completed("resp_slow"))); + return true; + } }]); + const send = proxy(localConfig(up.url)); + const response = await send({ store }, AbortSignal.timeout(CLIENT_HEAD_TIMEOUT_MS)); + expect(response.status).toBe(200); + expect(response.headers.get("x-attempt")).toBe("one"); + const reader = response.body!.getReader(); + let text = ""; + if (store) { + // An ineligible request is never inspected, so its prelude is not held either. + text = await readUntil(reader, "resp_slow"); + expect(text).toContain("resp_slow"); + } else { + // The held prelude stays held; the comment is what carried the head onto the socket. + text = await readUntil(reader, ": keepalive"); + expect(text).toBe(": keepalive\n\n"); + } + gate.resolve(); + text += await readUntil(reader, "response.completed"); + expect(text).toContain("resp_slow"); + expect(text).toContain("late answer"); + expect(up.sends).toHaveLength(1); + await reader.cancel().catch(() => {}); + }, 10_000); + } + + test("an EOF right after the prelude publishes the replacement with its own head", async () => { + const up = upstream([ + { headers: { "x-attempt": "one" }, pull: controller => { controller.enqueue(encoder.encode(created("resp_first"))); return true; } }, + { headers: { "x-attempt": "two" }, pull: controller => { + controller.enqueue(encoder.encode(created("resp_second") + delta("recovered") + completed("resp_second"))); + return true; + } }, + ]); + const send = proxy(localConfig(up.url)); + const response = await send({ store: false }, AbortSignal.timeout(CLIENT_HEAD_TIMEOUT_MS)); + expect(response.headers.get("x-attempt")).toBe("two"); + const { text } = await readAll(response); + expect(text).toContain("recovered"); + expect(text).not.toContain("resp_first"); + expect(up.sends).toHaveLength(2); + }, 10_000); + + test("an EOF after a quiet prelude keeps the committed head and declines a different replacement head", async () => { + const headSent = Promise.withResolvers(); + const up = upstream([ + { headers: { "x-attempt": "one" }, pull: async (controller, step) => { + if (step === 0) { controller.enqueue(encoder.encode(created("resp_first"))); return; } + await headSent.promise; + return true; + } }, + { headers: { "x-attempt": "two" }, pull: controller => { + controller.enqueue(encoder.encode(created("resp_second") + delta("replacement") + completed("resp_second"))); + return true; + } }, + ]); + const send = proxy(localConfig(up.url)); + const response = await send({ store: false }, AbortSignal.timeout(CLIENT_HEAD_TIMEOUT_MS)); + expect(response.headers.get("x-attempt")).toBe("one"); + // The client holds the original head before the source fails. + headSent.resolve(); + const { text } = await readAll(response); + expect(text).toContain("resp_first"); + expect(text).not.toContain("replacement"); + // The grant still bought exactly one replacement send, which was then refused on arrival. + expect(up.sends).toHaveLength(2); + }, 10_000); + + for (const store of [false, true]) { + test("the canonical forward path returns its head while the upstream is quiet (store=" + store + ")", async () => { + const gate = Promise.withResolvers(); + const read = Promise.withResolvers(); + cleanups.push(() => gate.resolve()); + let sends = 0; + const config = { ...getDefaultConfig(), port: 0, defaultProvider: "openai", providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward", + codexAccountMode: "direct", upstreamWebsocket: false, retryOnReset: {} }, + } } as OcxConfig; + const send = proxy(config); + globalThis.fetch = (async () => { + sends++; + let step = 0; + return new Response(new ReadableStream({ + async pull(controller) { + if (step++ === 0) { controller.enqueue(encoder.encode(created("fixture"))); return; } + read.resolve(); + await gate.promise; + controller.close(); + }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const pending = send({ model: "openai/gpt-5.6-sol", store }, AbortSignal.timeout(CLIENT_HEAD_TIMEOUT_MS)); + await read.promise; + const response = await pending; + expect(response.status).toBe(200); + expect(sends).toBe(1); + await response.body?.cancel(); + }, 10_000); + } +}); From 199fae17bf36daba6b287c893ccdc3a041f06940 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:09:14 +0900 Subject: [PATCH 20/22] Commit the reset-selection head on its own clock in quota tests The quota observation case for a head committed before a reset used a one-second stall budget as its commitment timer. The same budget arms the upstream inactivity guard on the original body, and a guard timeout is never replayed, so on a slow runner the guard could win and the case saw one send instead of two. The case now arms the fixture clock to capture the direct-stream inspection window and fires it itself before releasing the reset, while the inactivity guard keeps the default 300-second budget. The window moves from 250 ms to 200 ms because the fixture clock already claims every 250 ms timer as the quota persistence debounce, which had silently swallowed the window in this file. --- .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../responses/passthrough-completion.ts | 2 +- .../transports/responses-combo-boundaries.md | 2 +- tests/responses/empty-completion-core.test.ts | 4 ++-- .../responses-main-quota-observation.test.ts | 24 +++++++++++++++---- 12 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index c14acacb792..652d512b468 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,7 +188,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Les requêtes hors de ces limites ne sont pas inspectées et leur flux n'est jamais retenu. Un flux direct reçoit l'en-tête de réponse original dans les 250 millisecondes, même si l'amont est encore silencieux. Si la réinitialisation ou la fin de flux survient avant le renvoi de cet en-tête, le client reçoit le remplacement avec son propre statut et ses propres en-têtes, après avoir attendu l'en-tête du remplacement au plus 15 secondes, ou le délai d'inactivité s'il est plus court. Les requêtes non diffusées collectées en JSON et les cibles de combo peuvent attendre aussi longtemps avant de choisir un en-tête, car leur client ne reçoit aucun en-tête avant la collecte ou la sélection de la réponse. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original et son erreur sont conservés. Les flux directs encore inspectés après ce point reçoivent un commentaire SSE keepalive avec l'en-tête, puis toutes les 15 secondes ; la vérification préalable du combo retient ces commentaires jusqu'à ce qu'elle sélectionne une réponse. +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Les requêtes hors de ces limites ne sont pas inspectées et leur flux n'est jamais retenu. Un flux direct reçoit l'en-tête de réponse original dans les 200 millisecondes, même si l'amont est encore silencieux. Si la réinitialisation ou la fin de flux survient avant le renvoi de cet en-tête, le client reçoit le remplacement avec son propre statut et ses propres en-têtes, après avoir attendu l'en-tête du remplacement au plus 15 secondes, ou le délai d'inactivité s'il est plus court. Les requêtes non diffusées collectées en JSON et les cibles de combo peuvent attendre aussi longtemps avant de choisir un en-tête, car leur client ne reçoit aucun en-tête avant la collecte ou la sélection de la réponse. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original et son erreur sont conservés. Les flux directs encore inspectés après ce point reçoivent un commentaire SSE keepalive avec l'en-tête, puis toutes les 15 secondes ; la vérification préalable du combo retient ces commentaires jusqu'à ce qu'elle sélectionne une réponse. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index fd2733dcc66..f04b4e699b8 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,7 +180,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。これらの制限外のリクエストは検査されず、ストリームが保留されることはありません。直接ストリームは、上流がまだ無応答でも 250 ミリ秒以内に元の応答ヘッダーを受け取ります。そのヘッダーを返す前にリセットまたは EOF が起きた場合、置換応答のヘッダーを最大 15 秒(ストールタイムアウトの方が短い場合はその時間)待ち、クライアントは置換応答をそのステータスとヘッダーのまま受け取ります。JSON に収集する非ストリーミングリクエストとコンボのターゲットは、応答の収集または選択までクライアントにヘッダーが返らないため、ヘッダーの選択を同じ時間まで待つことがあります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームとそのエラーを保持します。その後も検査中の直接ストリームにはヘッダーとともに SSE keepalive コメントが送られ、その後は 15 秒ごとに送られます。コンボのプリフライトは応答を選択するまでこれらのコメントを保留します。 +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。これらの制限外のリクエストは検査されず、ストリームが保留されることはありません。直接ストリームは、上流がまだ無応答でも 200 ミリ秒以内に元の応答ヘッダーを受け取ります。そのヘッダーを返す前にリセットまたは EOF が起きた場合、置換応答のヘッダーを最大 15 秒(ストールタイムアウトの方が短い場合はその時間)待ち、クライアントは置換応答をそのステータスとヘッダーのまま受け取ります。JSON に収集する非ストリーミングリクエストとコンボのターゲットは、応答の収集または選択までクライアントにヘッダーが返らないため、ヘッダーの選択を同じ時間まで待つことがあります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームとそのエラーを保持します。その後も検査中の直接ストリームにはヘッダーとともに SSE keepalive コメントが送られ、その後は 15 秒ごとに送られます。コンボのプリフライトは応答を選択するまでこれらのコメントを保留します。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 54c027870e6..fab00adb873 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,7 +180,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 이 제한을 벗어난 요청은 검사하지 않으므로 스트림이 보류되지 않습니다. 직접 스트림은 업스트림이 아직 조용하더라도 250밀리초 안에 원본 응답 헤더를 받습니다. 그 헤더를 반환하기 전에 리셋이나 EOF가 발생하면 교체 응답의 헤더를 최대 15초(정지 시간 제한이 더 짧으면 그 시간)까지 기다리며, 클라이언트는 교체 응답을 그 응답의 상태와 헤더 그대로 받습니다. JSON으로 모으는 비스트리밍 요청과 콤보 대상은 응답을 모으거나 선택하기 전까지 클라이언트에 헤더가 전달되지 않으므로 헤더 선택을 같은 시간까지 기다릴 수 있습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림과 그 오류를 유지합니다. 그 뒤에도 검사 중인 직접 스트림은 헤더와 함께 SSE keepalive 주석을 받고 그 뒤 15초마다 다시 받으며, 콤보 사전 검사는 응답을 선택할 때까지 이 주석을 보류합니다. +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 이 제한을 벗어난 요청은 검사하지 않으므로 스트림이 보류되지 않습니다. 직접 스트림은 업스트림이 아직 조용하더라도 200밀리초 안에 원본 응답 헤더를 받습니다. 그 헤더를 반환하기 전에 리셋이나 EOF가 발생하면 교체 응답의 헤더를 최대 15초(정지 시간 제한이 더 짧으면 그 시간)까지 기다리며, 클라이언트는 교체 응답을 그 응답의 상태와 헤더 그대로 받습니다. JSON으로 모으는 비스트리밍 요청과 콤보 대상은 응답을 모으거나 선택하기 전까지 클라이언트에 헤더가 전달되지 않으므로 헤더 선택을 같은 시간까지 기다릴 수 있습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림과 그 오류를 유지합니다. 그 뒤에도 검사 중인 직접 스트림은 헤더와 함께 SSE keepalive 주석을 받고 그 뒤 15초마다 다시 받으며, 콤보 사전 검사는 응답을 선택할 때까지 이 주석을 보류합니다. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index fabd49929ea..52409a96e0e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -442,7 +442,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Requests outside those limits are not inspected, so their streams are never held. A direct stream receives the original response head within 250 milliseconds while the upstream is still quiet. If the reset or EOF happens before that head is returned, the client receives the replacement with its own status and headers, after waiting at most 15 seconds, or the stall timeout if that is shorter, for the replacement's head. Non-streaming requests collected into JSON and combo targets may wait that long before choosing a head, because their client receives no head until the response is collected or selected. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream and its error are retained. Direct streams still being inspected after that point receive an SSE keepalive comment with the head and then every 15 seconds; combo preflight holds these comments until it selects a response. +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Requests outside those limits are not inspected, so their streams are never held. A direct stream receives the original response head within 200 milliseconds while the upstream is still quiet. If the reset or EOF happens before that head is returned, the client receives the replacement with its own status and headers, after waiting at most 15 seconds, or the stall timeout if that is shorter, for the replacement's head. Non-streaming requests collected into JSON and combo targets may wait that long before choosing a head, because their client receives no head until the response is collected or selected. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream and its error are retained. Direct streams still being inspected after that point receive an SSE keepalive comment with the head and then every 15 seconds; combo preflight holds these comments until it selects a response. Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 2bac05ec757..418d3be7f90 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,7 +193,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Запросы вне этих ограничений не проверяются, и их поток не задерживается. Прямой поток получает исходные заголовки ответа в течение 250 миллисекунд, даже если вышестоящий сервер ещё молчит. Если сброс или EOF происходит до возврата этих заголовков, клиент получает замену с её собственными статусом и заголовками; заголовков замены ждут не дольше 15 секунд или тайм-аута простоя, если он короче. Непотоковые запросы, собираемые в JSON, и цели комбо могут ждать столько же перед выбором заголовков, потому что их клиент не получает заголовков до сбора или выбора ответа. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняются исходный поток и его ошибка. Прямые потоки, которые после этого ещё проверяются, получают SSE-комментарий keepalive вместе с заголовками, а затем каждые 15 секунд; предварительная проверка комбо удерживает эти комментарии до выбора ответа. +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Запросы вне этих ограничений не проверяются, и их поток не задерживается. Прямой поток получает исходные заголовки ответа в течение 200 миллисекунд, даже если вышестоящий сервер ещё молчит. Если сброс или EOF происходит до возврата этих заголовков, клиент получает замену с её собственными статусом и заголовками; заголовков замены ждут не дольше 15 секунд или тайм-аута простоя, если он короче. Непотоковые запросы, собираемые в JSON, и цели комбо могут ждать столько же перед выбором заголовков, потому что их клиент не получает заголовков до сбора или выбора ответа. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняются исходный поток и его ошибка. Прямые потоки, которые после этого ещё проверяются, получают SSE-комментарий keepalive вместе с заголовками, а затем каждые 15 секунд; предварительная проверка комбо удерживает эти комментарии до выбора ответа. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 266e333804d..d05d285ff8a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,7 +192,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Bu sınırların dışındaki istekler denetlenmez, bu yüzden akışları bekletilmez. Doğrudan akış, üst sunucu henüz sessiz olsa bile orijinal yanıt başlığını 250 milisaniye içinde alır. Sıfırlama veya EOF bu başlık döndürülmeden önce olursa istemci yedek yanıtı kendi durumu ve başlıklarıyla alır; yedek yanıtın başlığı en fazla 15 saniye ya da daha kısaysa takılma zaman aşımı kadar beklenir. JSON olarak toplanan akışsız istekler ve combo hedefleri, istemcileri yanıt toplanana veya seçilene kadar başlık almadığı için başlık seçmeden önce aynı süre bekleyebilir. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış ve hatası korunur. Bundan sonra hâlâ denetlenen doğrudan akışlar başlıkla birlikte ve ardından her 15 saniyede bir SSE keepalive yorumu alır; combo ön denetimi bir yanıt seçene kadar bu yorumları tutar. +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Bu sınırların dışındaki istekler denetlenmez, bu yüzden akışları bekletilmez. Doğrudan akış, üst sunucu henüz sessiz olsa bile orijinal yanıt başlığını 200 milisaniye içinde alır. Sıfırlama veya EOF bu başlık döndürülmeden önce olursa istemci yedek yanıtı kendi durumu ve başlıklarıyla alır; yedek yanıtın başlığı en fazla 15 saniye ya da daha kısaysa takılma zaman aşımı kadar beklenir. JSON olarak toplanan akışsız istekler ve combo hedefleri, istemcileri yanıt toplanana veya seçilene kadar başlık almadığı için başlık seçmeden önce aynı süre bekleyebilir. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış ve hatası korunur. Bundan sonra hâlâ denetlenen doğrudan akışlar başlıkla birlikte ve ardından her 15 saniyede bir SSE keepalive yorumu alır; combo ön denetimi bir yanıt seçene kadar bu yorumları tutar. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index ee343379f07..1a889ae55b2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,7 +182,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。超出这些限制的请求不会被检查,其流也不会被暂扣。直接流即使上游仍无响应,也会在 250 毫秒内收到原始响应头。如果重置或 EOF 发生在返回该响应头之前,客户端会收到带有其自身状态和响应头的替换响应;等待替换响应头的时间最多为 15 秒(若停滞超时更短则以其为准)。汇总为 JSON 的非流式请求和组合目标在响应汇总或选定之前不会向客户端返回响应头,因此选择响应头前可能等待同样的时间。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流及其错误。此后仍在检查的直接流会随响应头收到一条 SSE keepalive 注释,此后每 15 秒再收到一条;组合预检会在选定响应前暂扣这些注释。 +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。超出这些限制的请求不会被检查,其流也不会被暂扣。直接流即使上游仍无响应,也会在 200 毫秒内收到原始响应头。如果重置或 EOF 发生在返回该响应头之前,客户端会收到带有其自身状态和响应头的替换响应;等待替换响应头的时间最多为 15 秒(若停滞超时更短则以其为准)。汇总为 JSON 的非流式请求和组合目标在响应汇总或选定之前不会向客户端返回响应头,因此选择响应头前可能等待同样的时间。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流及其错误。此后仍在检查的直接流会随响应头收到一条 SSE keepalive 注释,此后每 15 秒再收到一条;组合预检会在选定响应前暂扣这些注释。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 6b859f736e2..fde1c8ce99c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,7 +153,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。超出這些限制的請求不會被檢查,其串流也不會被暫扣。直接串流即使上游仍無回應,也會在 250 毫秒內收到原始回應標頭。如果重設或 EOF 發生在回傳該回應標頭之前,用戶端會收到帶有其自身狀態和回應標頭的替換回應;等待替換回應標頭的時間最多為 15 秒(若停滯逾時更短則以其為準)。彙整為 JSON 的非串流請求與組合目標在回應彙整或選定之前不會向用戶端回傳標頭,因此選擇標頭前可能等待同樣的時間。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流及其錯誤。此後仍在檢查的直接串流會隨回應標頭收到一則 SSE keepalive 註解,此後每 15 秒再收到一則;組合預檢會在選定回應前暫扣這些註解。 +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。超出這些限制的請求不會被檢查,其串流也不會被暫扣。直接串流即使上游仍無回應,也會在 200 毫秒內收到原始回應標頭。如果重設或 EOF 發生在回傳該回應標頭之前,用戶端會收到帶有其自身狀態和回應標頭的替換回應;等待替換回應標頭的時間最多為 15 秒(若停滯逾時更短則以其為準)。彙整為 JSON 的非串流請求與組合目標在回應彙整或選定之前不會向用戶端回傳標頭,因此選擇標頭前可能等待同樣的時間。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流及其錯誤。此後仍在檢查的直接串流會隨回應標頭收到一則 SSE keepalive 註解,此後每 15 秒再收到一則;組合預檢會在選定回應前暫扣這些註解。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index dedbb4c77ba..7a767afbd52 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -28,7 +28,7 @@ const guardedResetResponses = new WeakMap(); * heartbeat interval. A failure observed inside this window may still publish the replacement's * own head, as a pre-header reset does. */ -export const DIRECT_RESET_INSPECTION_WINDOW_MS = 250; +export const DIRECT_RESET_INSPECTION_WINDOW_MS = 200; /** Who consumes the selected head: the client directly, the canonical JSON collector, or a combo parent. */ export type ResetSelectionDelivery = "direct-stream" | "buffered-json" | "combo-child"; diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 2d1ee5dcd87..46a3c9639bb 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -150,7 +150,7 @@ preflight, so nothing holds its head or prelude. Dispatch waits for whichever comes first: the selection, an abort, or a deadline of one 15-second heartbeat interval capped by the stall timeout. Returning the response is the last point at which dispatch can choose a head, so a direct stream also commits the original head once the original's own inspection -has run for `DIRECT_RESET_INSPECTION_WINDOW_MS` (250 ms, `src/server/responses/passthrough-completion.ts`): +has run for `DIRECT_RESET_INSPECTION_WINDOW_MS` (200 ms, `src/server/responses/passthrough-completion.ts`): a quiet source may be a healthy slow turn. A failure observed inside that window ends the window, and only the replacement's head is then awaited, still bounded by the deadline, as after a pre-header reset. Canonical buffered JSON and combo children keep the deadline alone, because their consumer publishes diff --git a/tests/responses/empty-completion-core.test.ts b/tests/responses/empty-completion-core.test.ts index a9e1896ac5b..ab74d8afb4d 100644 --- a/tests/responses/empty-completion-core.test.ts +++ b/tests/responses/empty-completion-core.test.ts @@ -552,7 +552,7 @@ describe("OpenGateway native empty completion", () => { try { await firstRead.promise; for (let i = 0; i < 50; i++) await Promise.resolve(); - jest.advanceTimersByTime(249); + jest.advanceTimersByTime(199); for (let i = 0; i < 50; i++) await Promise.resolve(); expect(returned).toBe(false); // The quiet original's inspection window ends; its head is returned with a comment to carry it. @@ -560,7 +560,7 @@ describe("OpenGateway native empty completion", () => { const response = await pending; const reader = response.body!.getReader(); expect(new TextDecoder().decode((await reader.read()).value)).toContain(": keepalive"); - jest.advanceTimersByTime(19_749); + jest.advanceTimersByTime(19_799); for (let i = 0; i < 100; i++) await Promise.resolve(); expect(cancelled).toBe(0); jest.advanceTimersByTime(1); diff --git a/tests/responses/responses-main-quota-observation.test.ts b/tests/responses/responses-main-quota-observation.test.ts index 239568c5d18..0fb50127cac 100644 --- a/tests/responses/responses-main-quota-observation.test.ts +++ b/tests/responses/responses-main-quota-observation.test.ts @@ -31,6 +31,7 @@ import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, isCodexWsPreludeProjection } from "../../src/server/responses/ws-upstream"; import { UPGRADE_DEADLINE_MS, markCodexWsResponse, isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "../../src/server/responses/codex-ws-wire"; +import { DIRECT_RESET_INSPECTION_WINDOW_MS } from "../../src/server/responses/passthrough-completion"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -53,6 +54,8 @@ let pendingPersist: { run: () => void; timer: ReturnType } | let clock: ReturnType; let pendingPreludeTimeout: (() => void) | undefined; let pendingUpgradeTimeout: (() => void) | undefined; +// Armed by a case that commits the reset-selection head itself instead of waiting on a wall clock. +let inspectionWindow: PromiseWithResolvers<() => void> | undefined; // Exercise the real serializer without a wall-clock race, as main-quota-provenance does. function installPersistenceClock() { @@ -68,6 +71,10 @@ function installPersistenceClock() { pendingPreludeTimeout = () => callback(...args); return nativeTimeout(() => {}, delay); } + if (delay === DIRECT_RESET_INSPECTION_WINDOW_MS && inspectionWindow) { + inspectionWindow.resolve(() => callback(...args)); + return nativeTimeout(() => {}, delay); + } if (delay !== 250) return nativeTimeout(callback, delay, ...args); const timer = nativeTimeout(() => {}, 60_000); pendingPersist = { run: () => callback(...args), timer }; @@ -147,6 +154,7 @@ beforeEach(() => { pendingPersist = undefined; pendingPreludeTimeout = undefined; pendingUpgradeTimeout = undefined; + inspectionWindow = undefined; clock = installPersistenceClock(); globalThis.fetch = (async () => { throw new Error("unexpected network call"); }) as typeof fetch; }); @@ -158,6 +166,7 @@ afterEach(() => { pendingPersist = undefined; pendingPreludeTimeout = undefined; pendingUpgradeTimeout = undefined; + inspectionWindow = undefined; clock.mockRestore(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState(); clearAccountNeedsReauth(MAIN); clearAccountNeedsReauth(POOL); @@ -587,8 +596,7 @@ describe("credential-bound plain-main Responses quota", () => { } }); - const resetQuotaConfig = (stallTimeoutSec?: number): OcxConfig => ({ ...config, defaultProvider: "openai", - ...(stallTimeoutSec === undefined ? {} : { stallTimeoutSec }), + const resetQuotaConfig = (): OcxConfig => ({ ...config, defaultProvider: "openai", providers: { openai: { ...provider, codexAccountMode: "direct", upstreamWebsocket: false, retryOnReset: {} } } }); const resetQuotaRequest = () => new Request("http://localhost/v1/responses", { method: "POST", headers: new Headers({ ...Object.fromEntries(caller()), "content-type": "application/json" }), @@ -603,7 +611,7 @@ describe("credential-bound plain-main Responses quota", () => { type: "response.completed", response: { id, status: "completed", output: [] }, })}\n\n`); - test("heartbeat-committed reset keeps the arrival head and never publishes replacement quota", async () => { + test("a head committed before the reset keeps the arrival head and never publishes replacement quota", async () => { observe(); const renewalSpy = spyOn(mainCache, "renewMainQuotaDispatchForAttempt"); const claimSpy = spyOn(mainCache, "isMainQuotaDispatchWsClaimed"); @@ -634,9 +642,15 @@ describe("credential-bound plain-main Responses quota", () => { }, { highWaterMark: 0 }), { status: 200, headers: sseQuotaHeaders("99") }); }) as typeof fetch; try { - // A one-second stall budget bounds the pre-head wait, so the head commits before the reset. - const response = await handleResponses(resetQuotaRequest(), resetQuotaConfig(1), + // The case commits the head itself by firing the quiet original's inspection window, and only + // then releases the reset. The inactivity guard keeps the default 300-second budget on its own + // clock: a guard timeout is never replayed, so sharing one short deadline with the commit + // decided the send count by timer order. + inspectionWindow = Promise.withResolvers(); + const pending = handleResponses(resetQuotaRequest(), resetQuotaConfig(), { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); + (await inspectionWindow.promise)(); + const response = await pending; expect(httpCalls).toBe(1); expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(17); gate.resolve(); From cfe670c4fa9adf00390402bcc7f6ed2d87415fcc Mon Sep 17 00:00:00 2001 From: cpa Date: Mon, 9 Nov 2026 10:54:15 +0900 Subject: [PATCH 21/22] fix(responses): release a replacement authorization when its send fails; cover the physical replay guard --- src/server/responses/reset-replay.ts | 11 ++++-- .../responses/responses-reset-replay.test.ts | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/server/responses/reset-replay.ts b/src/server/responses/reset-replay.ts index 92eee996bb1..06d949952ea 100644 --- a/src/server/responses/reset-replay.ts +++ b/src/server/responses/reset-replay.ts @@ -43,11 +43,16 @@ export function createPhysicalReplayGuard(providerExecuted: () => boolean) { authorize: () => { pending = true; }, record: (request: AdapterRequest, body: BodyInit | null | undefined, next?: () => void) => { const physical = snapshot(request, body); - if (pending && !safe(physical)) throw new UnsafePhysicalReplacement("The rebuilt request cannot be safely replayed"); - next?.(); + // The authorization covers this one send, whether it leaves or fails to: a refused rebuild or + // an exhausted send budget must not leave the next ordinary send judged as a replacement. + try { + if (pending && !safe(physical)) throw new UnsafePhysicalReplacement("The rebuilt request cannot be safely replayed"); + next?.(); + } finally { + pending = false; + } last = physical; lastSafe = undefined; - pending = false; }, }; } diff --git a/tests/responses/responses-reset-replay.test.ts b/tests/responses/responses-reset-replay.test.ts index ec7a3066e3c..137f7c5f19b 100644 --- a/tests/responses/responses-reset-replay.test.ts +++ b/tests/responses/responses-reset-replay.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, jest, test } from "bun:test"; import { ambiguousResendAllowanceFor, + createPhysicalReplayGuard, selfContainedChatBody, selfContainedResponsesBody, + UnsafePhysicalReplacement, } from "../../src/server/responses/reset-replay"; import { authorizeResendForRecovery } from "../../src/lib/request-resend-gate"; +import type { AdapterRequest } from "../../src/adapters/base"; import { deferProtocolSafeResetRecovery, selectProtocolSafeResetResponse, type ResetSelectionOwner } from "../../src/server/responses/combo-stream-preflight"; const prelude = 'event: response.created\ndata: {"type":"response.created","response":{"id":"first","output":[]}}\n\n'; @@ -390,6 +393,40 @@ const clientTurn = { tools: [{ type: "function", name: "read" }], }; +describe("createPhysicalReplayGuard", () => { + const safeBody = JSON.stringify({ store: false, input: "plain prompt" }); + const unsafeBody = JSON.stringify({ store: true, input: "plain prompt" }); + const request = (body: string, hosted = false) => + ({ body, ...(hosted ? { hostedToolSubstitutions: [{}] } : {}) }) as unknown as AdapterRequest; + + test("judges the body that physically left, including fresh hosted and provider-executed flags", () => { + let providerExecuted = false; + const guard = createPhysicalReplayGuard(() => providerExecuted); + guard.record(request(safeBody), safeBody); + expect(guard.isSelfContained(request(safeBody))).toBe(true); + expect(guard.isSelfContained(request(safeBody, true))).toBe(false); + providerExecuted = true; + expect(guard.isSelfContained(request(safeBody))).toBe(false); + }); + + test("an authorized replacement that is not self-contained is refused at physical dispatch", () => { + const guard = createPhysicalReplayGuard(() => false); + guard.record(request(safeBody), safeBody); + guard.authorize(); + expect(() => guard.record(request(unsafeBody), unsafeBody)).toThrow(UnsafePhysicalReplacement); + // The refusal consumed the authorization; the next ordinary send is not a replacement. + expect(() => guard.record(request(unsafeBody), unsafeBody)).not.toThrow(); + }); + + test("a send that fails before leaving releases the authorization", () => { + const guard = createPhysicalReplayGuard(() => false); + guard.authorize(); + expect(() => guard.record(request(safeBody), safeBody, () => { throw new Error("send budget exhausted"); })) + .toThrow("send budget exhausted"); + expect(() => guard.record(request(unsafeBody), unsafeBody)).not.toThrow(); + }); +}); + describe("selfContainedResponsesBody", () => { test("accepts a turn whose second send can only repeat the inference", () => { expect(selfContainedResponsesBody(clientTurn)).toBe(true); From 1748a255623700dda6b0e64583ebba47477d73e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 15:26:10 +0900 Subject: [PATCH 22/22] fix(responses): count reset selection against the buffered turn ceiling A canonical non-streaming Responses turn could outlive its 15-minute ceiling by the time reset selection held its head, because the collector started the deadline only after selection. Selection now records when it began holding a buffered head, and the collector anchors the whole-turn deadline there. The provider reference (and its translations) now says a replacement supplies its own status and headers only when it is selected before the original head is returned, with the 200 ms direct-stream window stated separately. Refs #8343, #8347 --- .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../responses/passthrough-completion.ts | 12 +++++++ src/server/responses/passthrough-delivery.ts | 5 ++- .../transports/responses-combo-boundaries.md | 2 +- .../responses-canonical-nonstream.test.ts | 33 +++++++++++++++++++ 12 files changed, 58 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 652d512b468..43b213815b4 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -188,7 +188,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Les requêtes hors de ces limites ne sont pas inspectées et leur flux n'est jamais retenu. Un flux direct reçoit l'en-tête de réponse original dans les 200 millisecondes, même si l'amont est encore silencieux. Si la réinitialisation ou la fin de flux survient avant le renvoi de cet en-tête, le client reçoit le remplacement avec son propre statut et ses propres en-têtes, après avoir attendu l'en-tête du remplacement au plus 15 secondes, ou le délai d'inactivité s'il est plus court. Les requêtes non diffusées collectées en JSON et les cibles de combo peuvent attendre aussi longtemps avant de choisir un en-tête, car leur client ne reçoit aucun en-tête avant la collecte ou la sélection de la réponse. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original et son erreur sont conservés. Les flux directs encore inspectés après ce point reçoivent un commentaire SSE keepalive avec l'en-tête, puis toutes les 15 secondes ; la vérification préalable du combo retient ces commentaires jusqu'à ce qu'elle sélectionne une réponse. +Pour Responses natif, la même option couvre aussi une fin de flux normale après les seuls événements de cycle de vie. Les limites de requête autonome et de remplacement restent applicables. Les requêtes hors de ces limites ne sont pas inspectées et leur flux n'est jamais retenu. Un flux direct reçoit l'en-tête de réponse original dans les 200 millisecondes, même si l'amont est encore silencieux. Le remplacement n'apporte son propre statut et ses propres en-têtes que s'il est sélectionné avant le renvoi de cet en-tête : pour un flux direct, la réinitialisation ou la fin de flux doit survenir dans ces 200 millisecondes, et l'en-tête du remplacement doit arriver dans les 15 secondes suivant la réponse originale, ou dans le délai d'inactivité s'il est plus court. Les requêtes non diffusées collectées en JSON et les cibles de combo n'ont pas de fenêtre de 200 millisecondes et peuvent attendre aussi longtemps avant de choisir un en-tête, car leur client ne reçoit aucun en-tête avant la collecte ou la sélection de la réponse. Une fois l'en-tête renvoyé, le remplacement n'est utilisé que si son statut HTTP, son texte de statut et tous ses en-têtes correspondent à cet en-tête ; sinon, le flux original et son erreur sont conservés. Les flux directs encore inspectés après ce point reçoivent un commentaire SSE keepalive avec l'en-tête, puis toutes les 15 secondes ; la vérification préalable du combo retient ces commentaires jusqu'à ce qu'elle sélectionne une réponse. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index f04b4e699b8..ebcd9dfb63a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -180,7 +180,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。これらの制限外のリクエストは検査されず、ストリームが保留されることはありません。直接ストリームは、上流がまだ無応答でも 200 ミリ秒以内に元の応答ヘッダーを受け取ります。そのヘッダーを返す前にリセットまたは EOF が起きた場合、置換応答のヘッダーを最大 15 秒(ストールタイムアウトの方が短い場合はその時間)待ち、クライアントは置換応答をそのステータスとヘッダーのまま受け取ります。JSON に収集する非ストリーミングリクエストとコンボのターゲットは、応答の収集または選択までクライアントにヘッダーが返らないため、ヘッダーの選択を同じ時間まで待つことがあります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームとそのエラーを保持します。その後も検査中の直接ストリームにはヘッダーとともに SSE keepalive コメントが送られ、その後は 15 秒ごとに送られます。コンボのプリフライトは応答を選択するまでこれらのコメントを保留します。 +ネイティブ Responses では、ライフサイクルイベントだけを受信して正常 EOF で終了した場合にも同じオプトインが適用されます。自己完結したリクエストと置換回数の制限は変わりません。これらの制限外のリクエストは検査されず、ストリームが保留されることはありません。直接ストリームは、上流がまだ無応答でも 200 ミリ秒以内に元の応答ヘッダーを受け取ります。置換応答が独自のステータスとヘッダーで返るのは、そのヘッダーを返す前に置換応答が選択された場合だけです。直接ストリームではリセットまたは EOF がその 200 ミリ秒以内に起きる必要があり、置換応答のヘッダーは元の応答から 15 秒以内(ストールタイムアウトの方が短い場合はその時間内)に届く必要があります。JSON に収集する非ストリーミングリクエストとコンボのターゲットには 200 ミリ秒の枠がなく、応答の収集または選択までクライアントにヘッダーが返らないため、ヘッダーの選択を同じ時間まで待つことがあります。ヘッダーを返した後は、置換応答の HTTP ステータス、ステータステキスト、全ヘッダーが返したヘッダーと一致しない場合、元のストリームとそのエラーを保持します。その後も検査中の直接ストリームにはヘッダーとともに SSE keepalive コメントが送られ、その後は 15 秒ごとに送られます。コンボのプリフライトは応答を選択するまでこれらのコメントを保留します。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index fab00adb873..0ea0b1c9008 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -180,7 +180,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 이 제한을 벗어난 요청은 검사하지 않으므로 스트림이 보류되지 않습니다. 직접 스트림은 업스트림이 아직 조용하더라도 200밀리초 안에 원본 응답 헤더를 받습니다. 그 헤더를 반환하기 전에 리셋이나 EOF가 발생하면 교체 응답의 헤더를 최대 15초(정지 시간 제한이 더 짧으면 그 시간)까지 기다리며, 클라이언트는 교체 응답을 그 응답의 상태와 헤더 그대로 받습니다. JSON으로 모으는 비스트리밍 요청과 콤보 대상은 응답을 모으거나 선택하기 전까지 클라이언트에 헤더가 전달되지 않으므로 헤더 선택을 같은 시간까지 기다릴 수 있습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림과 그 오류를 유지합니다. 그 뒤에도 검사 중인 직접 스트림은 헤더와 함께 SSE keepalive 주석을 받고 그 뒤 15초마다 다시 받으며, 콤보 사전 검사는 응답을 선택할 때까지 이 주석을 보류합니다. +네이티브 Responses에서는 같은 선택 설정이 라이프사이클 이벤트만 받은 뒤 정상 EOF로 끝난 경우에도 적용됩니다. 자기완결 요청과 교체 횟수 제한은 그대로 적용됩니다. 이 제한을 벗어난 요청은 검사하지 않으므로 스트림이 보류되지 않습니다. 직접 스트림은 업스트림이 아직 조용하더라도 200밀리초 안에 원본 응답 헤더를 받습니다. 교체 응답이 자체 상태와 헤더로 전달되는 것은 그 헤더를 반환하기 전에 교체 응답이 선택된 경우뿐입니다. 직접 스트림에서는 리셋이나 EOF가 그 200밀리초 안에 발생해야 하고, 교체 응답의 헤더는 원본 응답으로부터 15초(정지 시간 제한이 더 짧으면 그 시간) 안에 도착해야 합니다. JSON으로 모으는 비스트리밍 요청과 콤보 대상은 200밀리초 창이 없으며, 응답을 모으거나 선택하기 전까지 클라이언트에 헤더가 전달되지 않으므로 헤더 선택을 같은 시간까지 기다릴 수 있습니다. 헤더를 반환한 뒤에는 교체 응답의 HTTP 상태, 상태 텍스트와 모든 헤더가 반환한 헤더와 일치할 때만 교체하며, 다르면 원본 스트림과 그 오류를 유지합니다. 그 뒤에도 검사 중인 직접 스트림은 헤더와 함께 SSE keepalive 주석을 받고 그 뒤 15초마다 다시 받으며, 콤보 사전 검사는 응답을 선택할 때까지 이 주석을 보류합니다. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b1daee15a55..6bc3d0e05cd 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -444,7 +444,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Requests outside those limits are not inspected, so their streams are never held. A direct stream receives the original response head within 200 milliseconds while the upstream is still quiet. If the reset or EOF happens before that head is returned, the client receives the replacement with its own status and headers, after waiting at most 15 seconds, or the stall timeout if that is shorter, for the replacement's head. Non-streaming requests collected into JSON and combo targets may wait that long before choosing a head, because their client receives no head until the response is collected or selected. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream and its error are retained. Direct streams still being inspected after that point receive an SSE keepalive comment with the head and then every 15 seconds; combo preflight holds these comments until it selects a response. +For native Responses, the same opt-in also covers a clean EOF after only lifecycle events. The self-contained-request and replacement limits still apply. Requests outside those limits are not inspected, so their streams are never held. A direct stream receives the original response head within 200 milliseconds while the upstream is still quiet. The replacement supplies its own status and headers only if it is selected before that head is returned: a direct stream's reset or EOF must happen within those 200 milliseconds, and the replacement's head must arrive within 15 seconds of the original response, or the stall timeout if that is shorter. Non-streaming requests collected into JSON and combo targets have no 200-millisecond window and may wait that long before choosing a head, because their client receives no head until the response is collected or selected. After the head is returned, a replacement is used only when its HTTP status, status text and complete response headers match that head; otherwise the original stream and its error are retained. Direct streams still being inspected after that point receive an SSE keepalive comment with the head and then every 15 seconds; combo preflight holds these comments until it selects a response. Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 418d3be7f90..fc8e53dd347 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -193,7 +193,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Запросы вне этих ограничений не проверяются, и их поток не задерживается. Прямой поток получает исходные заголовки ответа в течение 200 миллисекунд, даже если вышестоящий сервер ещё молчит. Если сброс или EOF происходит до возврата этих заголовков, клиент получает замену с её собственными статусом и заголовками; заголовков замены ждут не дольше 15 секунд или тайм-аута простоя, если он короче. Непотоковые запросы, собираемые в JSON, и цели комбо могут ждать столько же перед выбором заголовков, потому что их клиент не получает заголовков до сбора или выбора ответа. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняются исходный поток и его ошибка. Прямые потоки, которые после этого ещё проверяются, получают SSE-комментарий keepalive вместе с заголовками, а затем каждые 15 секунд; предварительная проверка комбо удерживает эти комментарии до выбора ответа. +Для нативного Responses эта настройка также охватывает обычный EOF после одних событий жизненного цикла. Ограничения на самодостаточный запрос и число замен сохраняются. Запросы вне этих ограничений не проверяются, и их поток не задерживается. Прямой поток получает исходные заголовки ответа в течение 200 миллисекунд, даже если вышестоящий сервер ещё молчит. Замена передаёт собственные статус и заголовки, только если она выбрана до возврата этих заголовков: в прямом потоке сброс или EOF должен произойти в эти 200 миллисекунд, а заголовки замены должны прийти не позднее 15 секунд после исходного ответа или тайм-аута простоя, если он короче. Непотоковые запросы, собираемые в JSON, и цели комбо не имеют окна в 200 миллисекунд и могут ждать столько же перед выбором заголовков, потому что их клиент не получает заголовков до сбора или выбора ответа. После возврата заголовков замена используется только при совпадении HTTP-статуса, текста статуса и всех заголовков с возвращёнными; иначе сохраняются исходный поток и его ошибка. Прямые потоки, которые после этого ещё проверяются, получают SSE-комментарий keepalive вместе с заголовками, а затем каждые 15 секунд; предварительная проверка комбо удерживает эти комментарии до выбора ответа. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index d05d285ff8a..209cbc26c3e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -192,7 +192,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Bu sınırların dışındaki istekler denetlenmez, bu yüzden akışları bekletilmez. Doğrudan akış, üst sunucu henüz sessiz olsa bile orijinal yanıt başlığını 200 milisaniye içinde alır. Sıfırlama veya EOF bu başlık döndürülmeden önce olursa istemci yedek yanıtı kendi durumu ve başlıklarıyla alır; yedek yanıtın başlığı en fazla 15 saniye ya da daha kısaysa takılma zaman aşımı kadar beklenir. JSON olarak toplanan akışsız istekler ve combo hedefleri, istemcileri yanıt toplanana veya seçilene kadar başlık almadığı için başlık seçmeden önce aynı süre bekleyebilir. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış ve hatası korunur. Bundan sonra hâlâ denetlenen doğrudan akışlar başlıkla birlikte ve ardından her 15 saniyede bir SSE keepalive yorumu alır; combo ön denetimi bir yanıt seçene kadar bu yorumları tutar. +Yerel Responses için aynı tercih, yalnızca yaşam döngüsü olaylarından sonra gelen normal EOF durumunu da kapsar. Kendi kendine yeterli istek ve değiştirme sınırları geçerlidir. Bu sınırların dışındaki istekler denetlenmez, bu yüzden akışları bekletilmez. Doğrudan akış, üst sunucu henüz sessiz olsa bile orijinal yanıt başlığını 200 milisaniye içinde alır. Yedek yanıt kendi durumunu ve başlıklarını yalnızca bu başlık döndürülmeden önce seçilirse getirir: doğrudan akışta sıfırlama veya EOF bu 200 milisaniye içinde olmalı, yedek yanıtın başlığı da orijinal yanıttan sonra en fazla 15 saniye ya da daha kısaysa takılma zaman aşımı içinde gelmelidir. JSON olarak toplanan akışsız istekler ve combo hedefleri 200 milisaniyelik pencereye tabi değildir ve, istemcileri yanıt toplanana veya seçilene kadar başlık almadığı için başlık seçmeden önce aynı süre bekleyebilir. Başlık döndürüldükten sonra yalnızca HTTP durumu, durum metni ve tüm yanıt başlıkları o başlıkla eşleşen yanıt kullanılır; aksi halde orijinal akış ve hatası korunur. Bundan sonra hâlâ denetlenen doğrudan akışlar başlıkla birlikte ve ardından her 15 saniyede bir SSE keepalive yorumu alır; combo ön denetimi bir yanıt seçene kadar bu yorumları tutar. LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1a889ae55b2..9a620029f77 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -182,7 +182,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。超出这些限制的请求不会被检查,其流也不会被暂扣。直接流即使上游仍无响应,也会在 200 毫秒内收到原始响应头。如果重置或 EOF 发生在返回该响应头之前,客户端会收到带有其自身状态和响应头的替换响应;等待替换响应头的时间最多为 15 秒(若停滞超时更短则以其为准)。汇总为 JSON 的非流式请求和组合目标在响应汇总或选定之前不会向客户端返回响应头,因此选择响应头前可能等待同样的时间。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流及其错误。此后仍在检查的直接流会随响应头收到一条 SSE keepalive 注释,此后每 15 秒再收到一条;组合预检会在选定响应前暂扣这些注释。 +对于原生 Responses,同一开关也涵盖仅收到生命周期事件后正常结束的 EOF。自包含请求和替换次数限制仍然适用。超出这些限制的请求不会被检查,其流也不会被暂扣。直接流即使上游仍无响应,也会在 200 毫秒内收到原始响应头。只有在返回该响应头之前选定替换响应时,替换响应才会带有其自身的状态和响应头:直接流的重置或 EOF 必须发生在这 200 毫秒内,且替换响应头必须在原始响应到达后 15 秒内(若停滞超时更短则以其为准)到达。汇总为 JSON 的非流式请求和组合目标没有 200 毫秒窗口,在响应汇总或选定之前不会向客户端返回响应头,因此选择响应头前可能等待同样的时间。返回响应头之后,仅当替换响应的 HTTP 状态、状态文本和所有响应头与已返回的响应头一致时才会替换;否则保留原始流及其错误。此后仍在检查的直接流会随响应头收到一条 SSE keepalive 注释,此后每 15 秒再收到一条;组合预检会在选定响应前暂扣这些注释。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index fde1c8ce99c..b21424538d3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -153,7 +153,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。超出這些限制的請求不會被檢查,其串流也不會被暫扣。直接串流即使上游仍無回應,也會在 200 毫秒內收到原始回應標頭。如果重設或 EOF 發生在回傳該回應標頭之前,用戶端會收到帶有其自身狀態和回應標頭的替換回應;等待替換回應標頭的時間最多為 15 秒(若停滯逾時更短則以其為準)。彙整為 JSON 的非串流請求與組合目標在回應彙整或選定之前不會向用戶端回傳標頭,因此選擇標頭前可能等待同樣的時間。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流及其錯誤。此後仍在檢查的直接串流會隨回應標頭收到一則 SSE keepalive 註解,此後每 15 秒再收到一則;組合預檢會在選定回應前暫扣這些註解。 +對原生 Responses 而言,相同開關也涵蓋只收到生命週期事件後正常結束的 EOF。自包含請求與替換次數限制仍然適用。超出這些限制的請求不會被檢查,其串流也不會被暫扣。直接串流即使上游仍無回應,也會在 200 毫秒內收到原始回應標頭。只有在回傳該回應標頭之前選定替換回應時,替換回應才會帶有其自身的狀態和回應標頭:直接串流的重設或 EOF 必須發生在這 200 毫秒內,且替換回應標頭必須在原始回應到達後 15 秒內(若停滯逾時更短則以其為準)到達。彙整為 JSON 的非串流請求與組合目標沒有 200 毫秒視窗,在回應彙整或選定之前不會向用戶端回傳標頭,因此選擇標頭前可能等待同樣的時間。回傳回應標頭之後,只有替換回應的 HTTP 狀態、狀態文字和所有回應標頭均與已回傳的標頭相符時才會替換;否則保留原始串流及其錯誤。此後仍在檢查的直接串流會隨回應標頭收到一則 SSE keepalive 註解,此後每 15 秒再收到一則;組合預檢會在選定回應前暫扣這些註解。 LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. diff --git a/src/server/responses/passthrough-completion.ts b/src/server/responses/passthrough-completion.ts index 7a767afbd52..6344df1f6df 100644 --- a/src/server/responses/passthrough-completion.ts +++ b/src/server/responses/passthrough-completion.ts @@ -17,6 +17,16 @@ export { parseOutboundRequestBody } from "./outbound-request-body"; // A guarded published reset response, mapped to when its selection began holding the head. const guardedResetResponses = new WeakMap(); +// A canonical JSON turn's published head, mapped to the wall-clock time its selection began (#8343). +const bufferedSelectionStarts = new WeakMap(); + +/** + * When reset selection began holding this canonical JSON turn's head, if it ran. The collector's + * whole-turn deadline starts there, so the time selection held the head counts against it. + */ +export function bufferedResetSelectionStartedAt(response: Response): number | undefined { + return bufferedSelectionStarts.get(response); +} /** * How long a direct stream's head may wait on the original's own inspection. @@ -76,6 +86,7 @@ export function selectPassthroughResetResponse( const originalOwner = quotaOwner(); let replacementOwner: MainQuotaDispatch | undefined; const heldSince = performance.now(); + const selectionStartedAt = Date.now(); return selectProtocolSafeResetResponse(protect(response), logCtx, async (error, stage, signal, accepts) => { // Recover returns only a validated, fresh body; wrapping earlier would hide its lock state. const replacement = await recover(error, stage, signal, accepts); @@ -91,6 +102,7 @@ export function selectPassthroughResetResponse( maxSelectionMs: stallMs > 0 ? stallMs : undefined, onCommit: (published, owner) => { if (openGateway) guardedResetResponses.set(published, heldSince); + if (options.delivery === "buffered-json") bufferedSelectionStarts.set(published, selectionStartedAt); bindPublishedMainQuotaDispatch(published, owner === "replacement" ? replacementOwner : originalOwner); }, }); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index cf5f44ce8e9..464d10cbcd7 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -173,11 +173,13 @@ import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search- import { responsesJsonToSseStream } from "../responses-json-events"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { + BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, bufferedResponsesReadOptions, collectBufferedResponsesSse, type BufferedResponsesSseFailure, type BufferedResponsesSseResult, } from "./buffered-sse-json"; +import { bufferedResetSelectionStartedAt } from "./passthrough-completion"; const PLAINTEXT_V2_SSE_PREFIX_LIMIT = 4096; @@ -970,10 +972,11 @@ export async function deliverPassthroughResponse( if (canonicalBufferedJson) { const signal = options.abortSignal ?? req.signal; + // A turn whose head waited on reset selection keeps the deadline that wait already spent. const bufferedRead = bufferedResponsesReadOptions(resolveStallTimeoutMs( config.stallTimeoutSec, { localUpstream: nativeExchange.localUpstream }, - )); + ), BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, bufferedResetSelectionStartedAt(nativeExchange.upstreamResponse) ?? Date.now()); const failBufferedTurn = ( message: string, failure?: BufferedResponsesSseFailure, diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index bfaf3928646..87252e1034e 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -177,7 +177,7 @@ comment, cannot extend it, and the reset gate never resends a guard timeout. For JSON, the collector reports the guard's failure as a stall timeout. The hold emits a comment only when it reads non-empty upstream bytes or a replacement head lands behind a committed head, so the collector's own clock, which starts after selection and sees only delivered bytes, never ends a turn before the guard would. The byte and chunk -caps and the collector's whole-turn ceiling remain in force. +caps and the collector's whole-turn ceiling remain in force. That ceiling runs from the moment selection began holding the head (`bufferedResetSelectionStartedAt` in `src/server/responses/passthrough-completion.ts`), so the selection wait counts against it while sends before selection do not (#8343). Downstream cancellation or the request's abort signal ends the selection or hold at once, aborts an in-flight replacement, and never awaits upstream cleanup. diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts index 5e16203b50a..4d37387fc40 100644 --- a/tests/responses/responses-canonical-nonstream.test.ts +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -802,6 +802,39 @@ describe("canonical ChatGPT transport for non-streaming Responses callers (#6162 expect(sends).toBe(2); expect(replacement.cancels()).toBeGreaterThanOrEqual(1); })); + + test("the whole-turn ceiling counts the time selection held the head (#8343)", () => withFakeClock(async () => { + const original = scripted(); + let sends = 0; + let sentAt = 0; + globalThis.fetch = (async () => { + sends++; + sentAt = Date.now(); + original.push(created); + // Real upstream activity inside every stall budget, for longer than the whole-turn ceiling. + const beat = () => { original.push(heartbeat); setTimeout(beat, 4_000); }; + setTimeout(beat, 4_000); + return original.response; + }) as typeof fetch; + // settle() gives up after 30 s of fake time; this turn has to reach its 15-minute ceiling. + const turn: { response?: Response } = {}; + void call(requestBody(false, false), {}, stallConfig).then(response => { turn.response = response; }, () => undefined); + for (;;) { + await flushMicrotasks(); + await new Promise(resolve => setImmediate(resolve)); + await flushMicrotasks(); + if (turn.response) break; + if (sentAt > 0 && Date.now() - sentAt > BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS + 30_000) throw new Error("buffered turn outlived its ceiling"); + jest.advanceTimersByTime(1_000); + } + const elapsed = Date.now() - sentAt; + expect(turn.response.status).toBe(502); + // Selection held the head for one stall budget before the collector started; the ceiling + // still ends the turn one total timeout after the head arrived, not after that wait. + expect(elapsed).toBeGreaterThan(BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS - 1_000); + expect(elapsed).toBeLessThanOrEqual(BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS + 1_000); + expect(sends).toBe(1); + })); }); for (const ending of ["eof", "read-error"] as const) {