diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 6fe894b567f..8dec2432524 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -14,7 +14,7 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | `hostname?` | `string` | `"127.0.0.1"` | Adresse de liaison. Les liaisons hors bouclage nécessitent `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | URL du proxy HTTP(S) ou SOCKS5 sortant (`socks5://host:port`) ou `${ENV_VAR}`. Les URL HTTP s’appliquent à `HTTP_PROXY` / `HTTPS_PROXY` si elles sont vides. Les URL SOCKS5 utilisent le tunnel SOCKS5 intégré et sont aussi exposées via `ALL_PROXY` (`ocx start --socks5`); `HTTP(S)_PROXY` héritées sont effacées dans ce processus. Le bouclage reste dans `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Active une nouvelle tentative Responses identique lorsqu’une réponse ne contient ni texte ni appel d’outil. Cette tentative peut être facturée. `OCX_EMPTY_COMPLETION_RETRY=0` la désactive sans modifier la configuration ; les combinaisons et les tours de compactage routés restent exclus. | -| `stallTimeoutSec?` | `number` | `300` (public) / désactivé (local) | Secondes sans progression utile en amont (Responses et Chat natif) avant la coupure du flux. Sans réglage, un amont **local** (loopback, privé ou nom `.local`/`.lan`) est désactivé par défaut et un amont public vaut 300 s ; une valeur positive s'applique aux deux (minimum 1 s) ; `0` désactive le watchdog partout. Les lectures de corps en attente de `/v1/responses/compact` partagent ce budget mais valent 300 s par défaut même pour un amont local ; une valeur explicite, y compris `0`, prime. | +| `stallTimeoutSec?` | `number` | `300` (public) / désactivé (local) | Secondes sans progression utile en amont (Responses et Chat natif) avant la coupure du flux. Sans réglage, un amont **local** (loopback, privé ou nom `.local`/`.lan`) est désactivé par défaut et un amont public vaut 300 s ; une valeur positive s'applique aux deux (minimum 1 s) ; `0` désactive partout le watchdog de silence. Pour Responses qui replie le SSE canonique ChatGPT en JSON non-streaming, un plafond total indépendant de 15 minutes subsiste même lorsque ce watchdog est désactivé. Les lectures de corps en attente de `/v1/responses/compact` partagent ce budget mais valent 300 s par défaut même pour un amont local ; une valeur explicite, y compris `0`, prime. | | `connectTimeoutMs?` | `number` | `200000` | Délai maximal par tentative pour DNS/TCP/TLS et les en-têtes finaux ; il prend fin avant la génération du corps. | | `shutdownTimeoutMs?` | `number` | `5000` | Délai de vidange gracieux avant l’annulation des tours actifs. | | `websockets?` | `boolean` | `false` | Annonce et autorise la route WebSocket Responses destinée aux clients. La valeur false maintient les clients sur HTTP/SSE ; elle ne désactive pas une optimisation WebSocket canonique admissible vers ChatGPT en amont. | diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 4483284851b..3d47bfaeae4 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -75,6 +75,16 @@ Avec `stream: true`, la réponse est `text/event-stream`. Le pont émet des év Avec `stream: false` ou pas de `stream`, les mêmes événements d'adaptateur sont collectés dans une seule réponse JSON objet. Les deux formulaires préservent le modèle sélectionné, les éléments de sortie, l'état du terminal et l'utilisation. +La route canonique ChatGPT Codex n'accepte que SSE en amont ; seul l'appel amont utilise donc +`stream: true`. OpenCodex valide le flux terminal dans des limites bornées, puis le replie dans la +forme JSON demandée par le client sans modifier une valeur `store` explicite. Un échec de validation +renvoie une erreur plutôt qu'un JSON partiel avec HTTP 200. Les limites sont de 4 Mio par trame, +32 Mio pour le transcript et la source de reconstruction, 100 000 trames SSE et 10 000 éléments de +sortie reconstruits. `stallTimeoutSec` régit le premier octet du corps et les silences suivants. +Lorsqu'il vaut `0`, y compris par défaut pour un upstream local, il n'expire pas immédiatement : seul +le plafond indépendant de 15 minutes pour le tour mis en mémoire reste actif. Les clients streaming +restent inchangés. + Les trames SSE des réponses destinées au client sont limitées à 4 Mio par trame, mesuré en octets bruts avant la SSE délimiteur de bloc. Sur HTTP, une trame amont non terminée qui dépasse la limite échoue fermée avec un événement synthétique `response.failed` suivi de `data: [DONE]`. Sur les réponses WebSocket diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 7eeac7e4776..c11d7af21c0 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -14,7 +14,7 @@ description: リスナー、リモート アクセス、アドミッション | `proxy?` | `string` | — |送信 HTTP(S) または SOCKS5 プロキシ URL(`socks5://host:port`)または `${ENV_VAR}`。HTTP URL は未設定時のみ `HTTP_PROXY` / `HTTPS_PROXY` に適用されます。SOCKS5 URL は組み込みの SOCKS5 トンネルを使用し、`ALL_PROXY` にも適用されます(`ocx start --socks5`)。このプロセスで継承した `HTTP(S)_PROXY` はクリアされます。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Codex Responses パススルーから Codex の safety-buffering ヒントを除去します。対象は `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 応答ヘッダー、`safety_buffering` 型の `response.metadata` SSE イベント、およびその他の SSE イベントにある `safety_buffering` フィールドです。Codex TUI はこれらを、既定の操作でセッションをより弱いモデルに切り替える「より高速なモデルで再試行」プロンプトとして表示します。その他の `x-codex-*` ヘッダーと SSE イベントの内容は、そのフィールドの除去を除いて変更せずに転送されます。既定ではオフです。 | -| `stallTimeoutSec?` | `number` | `300`(public)/ 無効(local) | ストリームが遮断されるまでの、有効な上流進捗がない秒数(Responses とネイティブ Chat)。未設定では**ローカル**上流(loopback・プライベート・`.local`/`.lan` 名)は無効が既定、public 上流は 300 秒。正の値は両方に適用(最小 1 秒)、`0` で watchdog を全面無効化。`/v1/responses/compact` の保留ボディ読み取りもこの予算を共有するが、ローカル上流でも既定は 300 秒。明示値(`0` を含む)が優先される。 | +| `stallTimeoutSec?` | `number` | `300`(public)/ 無効(local) | ストリームが遮断されるまでの、有効な上流進捗がない秒数(Responses とネイティブ Chat)。未設定では**ローカル**上流(loopback・プライベート・`.local`/`.lan` 名)は無効が既定、public 上流は 300 秒。正の値は両方に適用(最小 1 秒)、`0` で無通信 watchdog を全面無効化。canonical ChatGPT SSE を非ストリーミング JSON にまとめる Responses リクエストには、watchdog が無効でも独立した 15 分の全体上限が残る。`/v1/responses/compact` の保留ボディ読み取りもこの予算を共有するが、ローカル上流でも既定は 300 秒。明示値(`0` を含む)が優先される。 | | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 | | `websockets?` | `boolean` | `false` | クライアント向け Responses WebSocket パスを広告して許可します。false の場合クライアントは HTTP/SSE を使いますが、対象となる canonical ChatGPT upstream WS 最適化は無効にしません。 | diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 7c5cbbb4277..db698fd90cb 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -63,6 +63,8 @@ provider events → internal adapter events → client dialect `stream: false` を指定するか、`stream` を指定しないと、同じアダプター イベントが 1 つの Responses JSON オブジェクトに収集されます。どちらの形式でも、選択したモデル、出力項目、端末の状態、使用状況が保存されます。 +canonical ChatGPT Codex ルートではアップストリームが SSE のみを受け付けるため、アップストリームへのリクエストだけを `stream: true` にします。OpenCodex は終端ストリームを制限内で検証し、クライアントが要求した JSON 形式へまとめます。明示された `store` は変更せず、検証に失敗した場合は不完全な JSON を HTTP 200 で返さずエラーにします。上限は 1 フレーム 4 MiB、transcript と再構築入力がそれぞれ 32 MiB、SSE フレーム 100,000 件、再構築される output item 10,000 件です。`stallTimeoutSec` は最初の body byte と以後の無通信時間の両方に適用されます。値が `0`、またはローカル upstream の既定値として無効な場合も即時には失効せず、独立した 15 分の全体上限だけが残ります。ストリーミング クライアントの動作は変わりません。 + クライアント向け Responses SSE フレームは、SSE ブロック区切りの前の生バイトで測って 1 フレームあたり 4 MiB に制限されます。HTTP では、区切りなしでこの上限を超えたアップストリーム フレームは、合成 `response.failed` イベントと続く `data: [DONE]` でフェイルクローズします。Responses WebSocket ブリッジでは、同じ条件で 502 `websocket_protocol_error` を送信し、アップストリーム リーダーをキャンセルします。完全な Responses 終端フレームがすでに到着している場合はそれが優先され、その後のサイズ超過または不正なバイトは、完了したターンをトランスポート障害に置き換えず破棄されます。 :::note diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index e166d1e50ba..ee24fd4f16e 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -14,7 +14,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `proxy?` | `string` | — | 송신용 HTTP(S) 또는 SOCKS5 프록시 URL(`socks5://host:port`) 또는 `${ENV_VAR}`입니다. HTTP URL은 해당 변수가 비어 있을 때 `HTTP_PROXY` / `HTTPS_PROXY`에 적용됩니다. SOCKS5 URL은 내장 SOCKS5 터널을 사용하고 `ALL_PROXY`에도 적용되며(`ocx start --socks5`), 이 프로세스에서 상속된 `HTTP(S)_PROXY`를 지웁니다. 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Canonical Codex Responses 응답의 선택적 safety-buffering 헤더 두 개와 SSE 힌트를 제거합니다. 공급자의 안전 정책이나 거절 응답은 바뀌지 않습니다. Native WS 메타데이터와 compact는 제외됩니다. | -| `stallTimeoutSec?` | `number` | `300`(public) / 비활성(local) | 스트림이 끊기기까지 유효한 업스트림 진행이 없는 시간(초, Responses 및 네이티브 Chat). 미설정 시 **로컬** 업스트림(loopback, private, `.local`/`.lan` 이름)은 비활성이 기본이고 공개 업스트림은 300초. 양수 값은 둘 다에 적용(최소 1초), `0`은 전면 비활성. `/v1/responses/compact`의 보류 바디 읽기도 이 예산을 공유하지만 로컬 업스트림에서도 기본은 300초. 명시 값(`0` 포함)이 우선한다. | +| `stallTimeoutSec?` | `number` | `300`(public) / 비활성(local) | 스트림이 끊기기까지 유효한 업스트림 진행이 없는 시간(초, Responses 및 네이티브 Chat). 미설정 시 **로컬** 업스트림(loopback, private, `.local`/`.lan` 이름)은 비활성이 기본이고 공개 업스트림은 300초. 양수 값은 둘 다에 적용(최소 1초), `0`은 무응답 watchdog을 전면 비활성화한다. canonical ChatGPT SSE를 비스트리밍 JSON으로 접는 Responses 요청에는 이 watchdog이 꺼져도 별도의 15분 전체 상한이 남는다. `/v1/responses/compact`의 보류 바디 읽기도 이 예산을 공유하지만 로컬 업스트림에서도 기본은 300초. 명시 값(`0` 포함)이 우선한다. | | `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. | | `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전에 허용하는 정상 종료 드레인 기한입니다. | | `websockets?` | `boolean` | `false` | 클라이언트용 Responses WebSocket 경로를 광고하고 허용합니다. `false`이면 클라이언트는 HTTP/SSE를 사용하며, 적격 canonical ChatGPT 업스트림 WS 최적화는 비활성화하지 않습니다. | diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 4848304cfbb..e93eb315644 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -73,6 +73,14 @@ deltas, 그리고 정확히 하나의 종료 `response.completed`, `response.fai `stream: false`이거나 `stream`이 없으면, 같은 adapter 이벤트가 하나의 Responses JSON 객체로 수집됩니다. 두 형식 모두 선택한 모델, output item, 종료 상태, usage를 보존합니다. +canonical ChatGPT Codex 경로는 upstream이 SSE만 받으므로 upstream 요청에만 `stream: true`를 사용합니다. +OpenCodex는 종료 스트림을 제한된 크기 안에서 검증한 뒤 클라이언트가 요청한 JSON 형태로 접습니다. +명시적인 `store` 값은 바꾸지 않으며, 검증에 실패하면 일부 JSON을 HTTP 200으로 반환하지 않고 오류로 +끝냅니다. 한도는 프레임당 4 MiB, transcript와 재구성 입력 각각 32 MiB, 100,000 SSE 프레임, 재구성 +output item 10,000개입니다. `stallTimeoutSec`는 첫 body byte와 이후 무응답 간격에 모두 적용됩니다. +값이 `0`이거나 로컬 upstream 기본값으로 비활성화된 경우 즉시 만료하지 않고, 독립된 15분 전체 상한만 +적용합니다. 스트리밍 클라이언트의 동작은 바뀌지 않습니다. + 클라이언트로 전달되는 Responses SSE 프레임은 SSE 블록 구분자 앞의 원시 바이트 기준으로 프레임당 4 MiB로 제한됩니다. HTTP에서는 구분자 없이 이 한도를 초과한 업스트림 프레임을 합성 `response.failed` 이벤트와 이어지는 `data: [DONE]`으로 fail closed 처리합니다. Responses WebSocket 브리지에서는 같은 조건에서 502 `websocket_protocol_error`를 보내고 업스트림 reader를 취소합니다. 완전한 Responses 종료 프레임이 이미 수신된 경우에는 그 종료가 우선하며, 이후의 과도한 크기 또는 잘못된 바이트는 완료된 턴을 전송 오류로 바꾸지 않고 버립니다. :::note diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index f0d92698dbf..8bbc6894c18 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -16,7 +16,7 @@ runs helper features around provider requests. | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. | -| `stallTimeoutSec?` | `number` | `300` (public) / disabled (local) | Seconds without meaningful upstream progress (Responses and native Chat) before the stream is cut. Unset, a **local** upstream (loopback, private, or a `.local`/`.lan` name) defaults to disabled and a public upstream to 300 s; a positive value applies to both (minimum 1 s); `0` disables the watchdog everywhere. Disabled leaves a silent-but-healthy local model connected (keep-alives still flow). Pending `/v1/responses/compact` body reads share this budget but default to 300 s even for a local upstream — the route buffers the complete body while holding an active-turn lease — and an explicit value, including `0`, still wins. | +| `stallTimeoutSec?` | `number` | `300` (public) / disabled (local) | Seconds without meaningful upstream progress (Responses and native Chat) before the stream is cut. Unset, a **local** upstream (loopback, private, or a `.local`/`.lan` name) defaults to disabled and a public upstream to 300 s; a positive value applies to both (minimum 1 s); `0` disables the silence watchdog everywhere. Disabled leaves a silent-but-healthy local model connected (keep-alives still flow). Canonical ChatGPT Responses folded from SSE into non-streaming JSON retain a separate 15-minute whole-turn ceiling even when the silence watchdog is disabled. Pending `/v1/responses/compact` body reads share this budget but default to 300 s even for a local upstream — the route buffers the complete body while holding an active-turn lease — and an explicit value, including `0`, still wins. | | `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Device-code flows never open a browser either way. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index db7b765d6cd..bad29c7e258 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -212,6 +212,18 @@ With `stream: true`, the response is `text/event-stream`. The bridge emits Respo With `stream: false` or no `stream`, the same adapter events are collected into one Responses JSON object. Both forms preserve the selected model, output items, terminal status, and usage. +The canonical ChatGPT Codex route still uses `stream: true` on its upstream-only request because that +destination is SSE-only; OpenCodex boundedly validates and folds the terminal stream back into the +JSON shape the client requested. This transport coercion does not alter an explicit `store` value. +The first terminal must be valid; a later terminal cannot replace an invalid first one. Output indices +must be contiguous and covered by a completed item or the terminal output, so a text/tool delta left +open by a sparse terminal fails closed rather than becoming partial JSON. The path caps each frame at +4 MiB, each transcript and reconstruction source at 32 MiB, the stream at 100,000 frames, and +reconstructed output at 10,000 items. The configured `stallTimeoutSec` governs both the first upstream +body byte and later silent gaps. When that stall clock is disabled (`0`, including the default for a +local upstream), it does not expire immediately; only the independent 15-minute buffered-turn ceiling +remains. An EOF, malformed or oversized frame, read error, stall, cancellation, or missing terminal +returns an error instead of partial JSON with HTTP 200. Streaming callers are unchanged. When a provider filters or truncates a response, an unfinished tool call remains `incomplete` in both JSON and SSE. Partial output is preserved, and the bridge does not emit an argument diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 2a4dcb20c68..73af6c0fbe8 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -15,7 +15,7 @@ description: Listener, удалённый доступ, admission key, тайм | `proxy?` | `string` | — | URL исходящего HTTP(S) или SOCKS5-прокси (`socks5://host:port`) или `${ENV_VAR}`. HTTP URL пишутся в `HTTP_PROXY` / `HTTPS_PROXY`, если те не заданы. SOCKS5 используют встроенный SOCKS5-туннель и также пишутся в `ALL_PROXY` (`ocx start --socks5`); унаследованные `HTTP(S)_PROXY` сбрасываются в этом процессе. Loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Удаляет подсказки Codex safety-buffering из passthrough-ответов Codex Responses: заголовки `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model`, SSE-события `response.metadata` типа `safety_buffering` и поле `safety_buffering` в других SSE-событиях. Codex TUI отображает их как предложение повторить запрос с более быстрой моделью, действие по умолчанию в котором переключает сессию на более слабую модель. Остальные заголовки `x-codex-*` и содержимое других SSE-событий передаются без изменений, кроме удаления этого поля. По умолчанию выключено. | -| `stallTimeoutSec?` | `number` | `300` (public) / выкл. (local) | Секунды без полезного прогресса upstream (Responses и нативный Chat) до обрыва потока. Без настройки **локальный** upstream (loopback, private, имя `.local`/`.lan`) по умолчанию выключен, публичный — 300 с; положительное значение действует на оба (минимум 1 с); `0` отключает watchdog везде. Ожидающие чтения тела `/v1/responses/compact` используют этот же бюджет, но по умолчанию 300 с даже для локального upstream; явное значение, включая `0`, имеет приоритет. | +| `stallTimeoutSec?` | `number` | `300` (public) / выкл. (local) | Секунды без полезного прогресса upstream (Responses и нативный Chat) до обрыва потока. Без настройки **локальный** upstream (loopback, private, имя `.local`/`.lan`) по умолчанию выключен, публичный — 300 с; положительное значение действует на оба (минимум 1 с); `0` отключает watchdog тишины везде. Для Responses, которые сворачивают canonical ChatGPT SSE в непотоковый JSON, даже при выключенном watchdog остаётся отдельный общий предел 15 минут. Ожидающие чтения тела `/v1/responses/compact` используют этот же бюджет, но по умолчанию 300 с даже для локального upstream; явное значение, включая `0`, имеет приоритет. | | `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. | | `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн graceful-drain до принудительного прерывания активных turn'ов. | | `websockets?` | `boolean` | `false` | Объявляет и разрешает клиентский WebSocket-путь Responses. При false клиенты используют HTTP/SSE; это не отключает подходящую upstream WS-оптимизацию canonical ChatGPT. | diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 976972081e2..510b3d6eadf 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -77,6 +77,15 @@ Translated-adapter'ы обрабатывают только известные При `stream: false` или при отсутствии `stream` те же события адаптера собираются в один JSON Responses. Обе формы сохраняют выбранную модель, output item'ы, terminal status и usage. +Канонический маршрут ChatGPT Codex принимает от upstream только SSE, поэтому `stream: true` +задаётся только для upstream-запроса. OpenCodex проверяет терминальный поток в заданных пределах и +сворачивает его в JSON, запрошенный клиентом, не меняя явно заданный `store`. Ошибка проверки не +возвращается как частичный JSON с HTTP 200. Ограничения: 4 MiB на frame, по 32 MiB на transcript и +источник реконструкции, 100 000 SSE-frame'ов и 10 000 восстановленных output item'ов. +`stallTimeoutSec` ограничивает ожидание первого body byte и последующие периоды тишины. Если значение +равно `0` или отключено по умолчанию для локального upstream, немедленного timeout нет и действует +только отдельный общий предел 15 минут. Поведение streaming-клиентов не меняется. + Клиентские frame'ы Responses SSE ограничены 4 MiB на frame, считая сырые байты до разделителя SSE-блока. В HTTP незавершённый upstream-frame, превысивший этот предел, завершается fail-closed синтетическим событием `response.failed`, после которого идёт `data: [DONE]`. В мосте Responses WebSocket то же условие даёт 502 `websocket_protocol_error` и отменяет upstream-reader. Если полноценный terminal-frame Responses уже получен, он остаётся авторитетным: слишком большие или некорректные байты после него отбрасываются и не заменяют завершённый ход транспортной ошибкой. :::note diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index a4d69c1f4a0..301faadfe16 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -15,7 +15,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | `hostname?` | `string` | `"127.0.0.1"` | Bağlama adresi. Geri döngü olmayan bağlamalar `OPENCODEX_API_AUTH_TOKEN` gerektirir. | | `proxy?` | `string` | — | Giden HTTP(S) veya SOCKS5 proxy URL'si (`socks5://host:port`) ya da `${ENV_VAR}`. HTTP URL'leri değişkenler boşsa `HTTP_PROXY` / `HTTPS_PROXY`'ye yazılır. SOCKS5 URL'leri yerleşik gerçek SOCKS5 tünelini kullanır ve `ALL_PROXY`'ye de yazılır (`ocx start --socks5`); bu süreçte miras `HTTP(S)_PROXY` temizlenir. Geri döngü `NO_PROXY` içinde kalır. | | `emptyCompletionRetry?` | `boolean` | `false` | Metin veya araç çağrısı içermeyen bir Responses tamamlamasını aynı istekle bir kez yeniden denemeyi açıkça etkinleştirir. Yeniden deneme ücretlendirilebilir. `OCX_EMPTY_COMPLETION_RETRY=0`, yapılandırmayı değiştirmeden devre dışı bırakır; combo ve routed-compaction turları hariçtir. | -| `stallTimeoutSec?` | `number` | `300` (public) / kapalı (local) | Akış kesilmeden önce anlamlı üst sunucu ilerlemesi olmadan geçen saniye (Responses ve yerel Chat). Ayarlanmamışsa **yerel** üst sunucu (loopback, private, `.local`/`.lan` adı) için varsayılan kapalı, genel üst sunucu için 300 sn; pozitif değer ikisine de uygulanır (en az 1 sn); `0` watchdog'u her yerde kapatır. `/v1/responses/compact` için bekleyen gövde okumaları bu bütçeyi paylaşır ama yerel üst sunucuda bile varsayılan 300 sn'dir; açık değer (`0` dahil) önceliklidir. | +| `stallTimeoutSec?` | `number` | `300` (public) / kapalı (local) | Akış kesilmeden önce anlamlı üst sunucu ilerlemesi olmadan geçen saniye (Responses ve yerel Chat). Ayarlanmamışsa **yerel** üst sunucu (loopback, private, `.local`/`.lan` adı) için varsayılan kapalı, genel üst sunucu için 300 sn; pozitif değer ikisine de uygulanır (en az 1 sn); `0` sessizlik watchdog'unu her yerde kapatır. Canonical ChatGPT SSE'yi akışsız JSON'a katlayan Responses isteklerinde watchdog kapalı olsa bile bağımsız 15 dakikalık toplam tur sınırı kalır. `/v1/responses/compact` için bekleyen gövde okumaları bu bütçeyi paylaşır ama yerel üst sunucuda bile varsayılan 300 sn'dir; açık değer (`0` dahil) önceliklidir. | | `connectTimeoutMs?` | `number` | `200000` | Deneme başına DNS/TCP/TLS/nihai başlık son tarihi; gövde üretiminden önce biter. | | `shutdownTimeoutMs?` | `number` | `5000` | Aktif turlar iptal edilmeden önce zarif boşaltma süresi sınırı. | | `websockets?` | `boolean` | `false` | Responses WebSocket yolu için `supports_websockets` bildirin. False, HTTP/SSE'yi tutar. | diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 90ed019103b..3178dfae456 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -81,6 +81,17 @@ olayı gibi Responses olaylarını yayar. Normal bir akış `data: [DONE]` ile b Responses JSON nesnesinde toplanır. Her iki form da seçilen modeli, çıktı öğelerini, terminal durumunu ve kullanımı korur. +Canonical ChatGPT Codex rotasının yukarı akışı yalnızca SSE kabul ettiğinden, +yalnızca yukarı akış isteği `stream: true` kullanır. OpenCodex terminal akışı +sınırlı boyutlar içinde doğrular ve istemcinin istediği JSON biçimine katlar; +açık bir `store` değeri değişmez. Doğrulama başarısız olursa HTTP 200 ile kısmi +JSON yerine hata döner. Sınırlar çerçeve başına 4 MiB, transcript ve yeniden +oluşturma kaynağı için ayrı ayrı 32 MiB, 100.000 SSE çerçevesi ve 10.000 yeniden +oluşturulmuş çıktı öğesidir. `stallTimeoutSec` hem ilk body byte'ını hem de +sonraki sessiz aralıkları sınırlar. Değer `0` olduğunda veya yerel upstream için +varsayılan olarak devre dışı bırakıldığında hemen zaman aşımına uğramaz; yalnızca +bağımsız 15 dakikalık toplam tur sınırı kalır. Streaming istemcileri değişmez. + İstemciye yönelik Responses SSE çerçeveleri, SSE blok sınırlayıcısından önceki ham bayt cinsinden ölçülen çerçeve başına 4 MiB ile sınırlandırılmıştır. HTTP üzerinde sınırı aşan sonlandırılmamış bir yukarı akış çerçevesi, ardından `data: diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 1fc4ee8ccc7..dc96d8ff4b9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -15,7 +15,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `proxy?` | `string` | — | 出站 HTTP(S) 或 SOCKS5 代理 URL(`socks5://host:port`),或 `${ENV_VAR}`。HTTP URL 仅在未设置时写入 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS5 URL 使用内置的真实 SOCKS5 隧道,也会写入 `ALL_PROXY`(`ocx start --socks5`);并清除本进程继承的 `HTTP(S)_PROXY`。回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | | `dropCodexSafetyBuffering?` | `boolean` | `false` | 从 Codex Responses 透传响应中移除 Codex safety-buffering 提示:`x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 响应头、类型为 `safety_buffering` 的 `response.metadata` SSE 事件,以及其他 SSE 事件中的 `safety_buffering` 字段。Codex TUI 会将这些提示显示为“使用更快模型重试”的提示框,其默认操作会把会话切换到较弱的模型。其他 `x-codex-*` 响应头和其他所有 SSE 事件内容均保持不变,但会移除该字段。默认关闭。 | -| `stallTimeoutSec?` | `number` | `300`(public)/ 禁用(local) | 上游无有效进展(Responses 和原生 Chat)多少秒后切断流。未设置时**本地**上游(loopback、private、`.local`/`.lan` 名称)默认禁用,公网上游默认 300 秒;正值对两者生效(最小 1 秒);`0` 全面禁用。`/v1/responses/compact` 的挂起响应体读取共享此预算,但即使本地上游也默认 300 秒;显式值(含 `0`)优先。 | +| `stallTimeoutSec?` | `number` | `300`(public)/ 禁用(local) | 上游无有效进展(Responses 和原生 Chat)多少秒后切断流。未设置时**本地**上游(loopback、private、`.local`/`.lan` 名称)默认禁用,公网上游默认 300 秒;正值对两者生效(最小 1 秒);`0` 全面禁用静默 watchdog。对于把 canonical ChatGPT SSE 折叠为非流式 JSON 的 Responses 请求,即使 watchdog 已禁用,仍保留独立的 15 分钟整轮上限。`/v1/responses/compact` 的挂起响应体读取共享此预算,但即使本地上游也默认 300 秒;显式值(含 `0`)优先。 | | `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 优雅停机截止时间,超过后会中止仍在进行中的请求。 | | `websockets?` | `boolean` | `false` | 声明并允许面向客户端的 Responses WebSocket 路径。设为 false 时客户端使用 HTTP/SSE;它不会禁用符合条件的 canonical ChatGPT 上游 WS 优化。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index c2d1871cfae..91ea424435d 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -72,6 +72,13 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 当 `stream: false` 或未提供 `stream` 时,同样的适配器事件会被收集为一个 Responses JSON 对象。两种形式都会保留所选模型、输出项、终止状态和 usage。 +canonical ChatGPT Codex 路由的上游只接受 SSE,因此仅对上游请求使用 `stream: true`。 +OpenCodex 会在有界限制内验证终止流,再将其折叠为客户端请求的 JSON 形式;显式 `store` 值不会 +改变。验证失败时会返回错误,而不会以 HTTP 200 返回部分 JSON。限制为:每帧 4 MiB、transcript +和重建源各 32 MiB、100,000 个 SSE 帧,以及 10,000 个重建输出项。`stallTimeoutSec` 同时控制 +首个 body byte 和后续静默间隔;当其为 `0`,或因本地上游默认禁用时,不会立即超时,只保留独立的 +15 分钟整轮上限。流式客户端的行为不变。 + 面向客户端的 Responses SSE 帧按 SSE 块分隔符之前的原始字节计算,每帧限制为 4 MiB。对于 HTTP,未终止的上游帧一旦超过该限制,会以合成的 `response.failed` 事件并随后发送 `data: [DONE]` 的方式 fail closed。对于 Responses WebSocket 桥,相同情况会发送 502 `websocket_protocol_error` 并取消上游 reader。已经完整到达的 Responses 终止帧具有优先权;其后的超大或格式错误字节会被丢弃,而不会把已经完成的轮次替换为传输失败。 :::note diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index ee1b7e72c77..dd8a0e472dc 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -13,7 +13,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `hostname?` | `string` | `"127.0.0.1"` | 綁定位址。非回送綁定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 對外 HTTP(S) 或 SOCKS5 代理 URL(`socks5://host:port`)或 `${ENV_VAR}`。HTTP URL 僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS5 URL 使用內建的真實 SOCKS5 通道,也會套用至 `ALL_PROXY`(`ocx start --socks5`),並清除此行程繼承的 `HTTP(S)_PROXY`。回送保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 明確啟用:當 Responses 完成時沒有文字或工具呼叫,以相同請求重試一次。重試可能產生費用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不變更設定的情況下停用;combo 與 routed-compaction turn 不適用。 | -| `stallTimeoutSec?` | `number` | `300`(public)/ 停用(local) | 上游無有效進展(Responses 與原生 Chat)多少秒後切斷串流。未設定時**本地**上游(loopback、private、`.local`/`.lan` 名稱)預設停用,公網上游預設 300 秒;正值對兩者生效(最小 1 秒);`0` 全面停用。`/v1/responses/compact` 的擱置回應本文讀取共用此預算,但即使本地上游也預設 300 秒;明確值(含 `0`)優先。 | +| `stallTimeoutSec?` | `number` | `300`(public)/ 停用(local) | 上游無有效進展(Responses 與原生 Chat)多少秒後切斷串流。未設定時**本地**上游(loopback、private、`.local`/`.lan` 名稱)預設停用,公網上游預設 300 秒;正值對兩者生效(最小 1 秒);`0` 全面停用靜默 watchdog。對於把 canonical ChatGPT SSE 折疊為非串流 JSON 的 Responses 請求,即使 watchdog 已停用,仍保留獨立的 15 分鐘整體上限。`/v1/responses/compact` 的擱置回應本文讀取共用此預算,但即使本地上游也預設 300 秒;明確值(含 `0`)優先。 | | `connectTimeoutMs?` | `number` | `200000` | 每次嘗試的 DNS/TCP/TLS/final-header 截止時間;它在 body 生成前結束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 在中止活躍回合前的優雅排空截止時間。 | | `websockets?` | `boolean` | `false` | 廣告並允許面向 client 的 Responses WebSocket 路徑。False 時 client 使用 HTTP/SSE;不會停用符合條件的 canonical ChatGPT upstream WS 最佳化。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 8e90432b06f..5f1ca4e8a34 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -63,6 +63,13 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 在 `stream: false` 或無 `stream` 時,相同的 adapter 事件被收集為一個 Responses JSON 物件。兩種形式都保留所選模型、輸出項目、終端狀態與 usage。 +canonical ChatGPT Codex 路由的上游只接受 SSE,因此僅對上游請求使用 `stream: true`。OpenCodex +會在有界限制內驗證終端串流,再將其折疊成客戶端要求的 JSON 形式;明確的 `store` 值不會改變。 +驗證失敗時會傳回錯誤,而不會以 HTTP 200 傳回部分 JSON。限制為每個 frame 4 MiB、transcript +與重建來源各 32 MiB、100,000 個 SSE frame,以及 10,000 個重建 output item。 +`stallTimeoutSec` 同時控制第一個 body byte 與後續靜默間隔;當它是 `0`,或因本機 upstream +預設停用時,不會立即逾時,只保留獨立的 15 分鐘整體上限。串流客戶端的行為不變。 + 每個終端 Responses usage 物件都包含兩個 detail 物件,即使供應商未回報那些細節: ```json diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c0898f6e213..0b2348555bb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -6,7 +6,7 @@ "codex-quota-query-backoff.test.ts": "codex-integration", "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", "low-quota-protection.test.ts": "codex-integration", - "responses-compaction-recovery.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", + "responses-compaction-recovery.test.ts": "responses", "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index d4533405dd0..9ef085c8cf2 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -510,6 +510,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const originalChoice = isPlainObject(parsed._rawBody) ? parsed._rawBody.tool_choice : undefined; finalBody = normalizeMuseToolChoice(finalBody, originalChoice); } + // The canonical ChatGPT Codex endpoint is SSE-only even though the public Responses surface + // permits an omitted/false `stream`. Keep the client's delivery preference on `parsed.stream` + // and coerce only this final upstream copy; passthrough delivery folds the terminal stream + // back into JSON for that client. `store` is intentionally untouched here because explicit + // caller storage semantics are independent of the transport required by the destination. + if (isCanonicalOpenAiForwardProvider(provider) + && isPlainObject(finalBody) + && finalBody.stream !== true) { + finalBody = { ...finalBody, stream: true }; + } if (isCanonicalOpenAiForwardProvider(provider)) { const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts index e1fc209c563..dc787f0eb44 100644 --- a/src/server/relay-eager.ts +++ b/src/server/relay-eager.ts @@ -88,6 +88,8 @@ export type EagerRelayOptions = { upstreamError?: string; /** Optional client-facing hint policy; inspection retains original frames. */ terminalBoundary?: CodexSafetyBufferingFilterOptions; + /** Mask the selected outbound credential in synthetic failures. */ + maskCredential?: (text: string) => string; /** Injectable clock for tests. */ now?: () => number; }; @@ -123,7 +125,7 @@ export function relaySseEagerBounded( ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); const encodeFailedTail = (error: unknown): Uint8Array | null => { try { - return failedTailFrame(terminalEncoder, error); + return failedTailFrame(terminalEncoder, error, opts?.maskCredential); } catch { return null; } @@ -140,7 +142,7 @@ export function relaySseEagerBounded( return encodeFailedTail(error); } try { - return refusalFailedTailFrame(terminalEncoder, refusalMessage, refusalCode); + return refusalFailedTailFrame(terminalEncoder, refusalMessage, refusalCode, opts?.maskCredential); } catch { return null; } @@ -335,6 +337,7 @@ export function relaySseEagerBounded( terminalEncoder, upstreamError, terminalBoundary.upstreamRefusalCode(), + opts?.maskCredential, ); queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength; try { diff --git a/src/server/relay.ts b/src/server/relay.ts index c2cc93758f0..6d7041b402e 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -29,14 +29,18 @@ import { EMPTY_BYTES, joinSseFrameBytes, MAX_CLIENT_SSE_FRAME_BYTES, + SseFrameCountLimitError, } from "./sse-frame-buffer"; import { replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite"; import { createBoundedResponseLogBody } from "./response-log-body"; import { clientWireLogOf } from "./inference/client-wire"; import { recordClientWireRequestLog } from "./inference/client-wire-log"; +import { nativeResponseFingerprint } from "./responses/native-response-json"; +import { nativeResponseOutput } from "./responses/native-response-output"; const nativePassthroughSseResponses = new WeakSet(); const eagerRelaySseResponses = new WeakSet(); +const preinspectedJsonResponses = new WeakSet(); export const MAX_INSPECTION_SSE_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES; export const MAX_COMPLETED_OUTPUT_ITEMS = 256; @@ -124,12 +128,12 @@ export function relayWithAbort( }); } -export function buildFailedTailPayload(err: unknown): string { +export function buildFailedTailPayload(err: unknown, maskCredential?: (text: string) => string): string { const translatorOverflow = isTranslatorBudgetExceededError(err); - const message = (translatorOverflow + const diagnostic = redactSecretString((translatorOverflow ? "upstream translation buffer exceeded the safe limit" - : `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`) - .slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS); + : `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`)); + const message = (maskCredential?.(diagnostic) ?? diagnostic).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS); const failure = { type: "upstream_error", code: translatorOverflow ? "translation_buffer_limit" : "upstream_reset", @@ -141,9 +145,9 @@ export function buildFailedTailPayload(err: unknown): string { }); } -function buildFailedTailPayloadOrFallback(err: unknown): string { +function buildFailedTailPayloadOrFallback(err: unknown, maskCredential?: (text: string) => string): string { try { - return buildFailedTailPayload(err); + return buildFailedTailPayload(err, maskCredential); } catch { // Error.message and String(error) may execute hostile accessors. Preserve a // bounded protocol terminal even when diagnostic serialization is unsafe. @@ -151,8 +155,8 @@ function buildFailedTailPayloadOrFallback(err: unknown): string { } } -export function failedTailFrame(encoder: TextEncoder, err: unknown): Uint8Array { - const payload = buildFailedTailPayloadOrFallback(err); +export function failedTailFrame(encoder: TextEncoder, err: unknown, maskCredential?: (text: string) => string): Uint8Array { + const payload = buildFailedTailPayloadOrFallback(err, maskCredential); return encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`); } @@ -170,17 +174,19 @@ export function upstreamErrorTailFrame( encoder: TextEncoder, message: string, refusalCode?: string, + maskCredential?: (text: string) => string, ): Uint8Array { return encoder.encode( - `event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode)}\n\n`, + `event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode, maskCredential)}\n\n`, ); } -function upstreamErrorFailedPayload(message: string, refusalCode?: string): string { +function upstreamErrorFailedPayload(message: string, refusalCode?: string, maskCredential?: (text: string) => string): string { + const diagnostic = redactSecretString(message); const error = { type: refusalCode === undefined ? "upstream_error" : "invalid_request_error", - code: refusalCode ?? "upstream_server_error", - message: redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS), + code: refusalCode === undefined ? "upstream_server_error" : (maskCredential?.(refusalCode) ?? refusalCode), + message: (maskCredential?.(diagnostic) ?? diagnostic).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS), }; return JSON.stringify({ type: "response.failed", @@ -206,8 +212,9 @@ export function refusalFailedTailFrame( encoder: TextEncoder, message: string, refusalCode: string, + maskCredential?: (text: string) => string, ): Uint8Array { - const payload = upstreamErrorFailedPayload(message, refusalCode); + const payload = upstreamErrorFailedPayload(message, refusalCode, maskCredential); return encoder.encode( `\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`, ); @@ -238,6 +245,8 @@ function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined { function boundedBareUpstreamError(payload: unknown): { message: string; refusalCode: string | undefined; + errorType: string | undefined; + errorCode: string | undefined; } | undefined { const root = asJsonRecord(payload); if (!root || root.type !== "error") return undefined; @@ -247,21 +256,30 @@ function boundedBareUpstreamError(payload: unknown): { // that supplied the message also supplies the verdict. Taking the FIRST code // rather than searching for a refusal is what stops a refusal nested below a // transient one from overruling it. - const code = [ + const candidates = [ asJsonRecord(root.error), asJsonRecord(root.last_error), asJsonRecord(response?.error), asJsonRecord(response?.incomplete_details), root, - ] - .map(candidate => stringField(candidate, "code")) - .find(candidate => candidate !== undefined); + ]; + const code = candidates.map(candidate => stringField(candidate, "code")) + .find(candidate => candidate !== undefined)?.slice(0, 128); + // Root `type` is the SSE event discriminator (`"error"`), not an error class. Only nested + // error records can authoritatively name classes such as rate_limit_error or server_error. + const errorType = candidates.slice(0, -1).map(candidate => stringField(candidate, "type")) + .find(candidate => candidate !== undefined)?.slice(0, 128); const refusalCode = code !== undefined ? (isTerminalRefusalCode(code) ? code : undefined) : message === undefined ? undefined : safetyRefusalCodeFromMessage(message); - if (message !== undefined) return { message, refusalCode }; + if (message !== undefined) return { message, refusalCode, errorType, errorCode: code }; if (refusalCode === undefined) return undefined; - return { message: terminalRefusalFallbackMessage(refusalCode), refusalCode }; + return { + message: terminalRefusalFallbackMessage(refusalCode), + refusalCode, + errorType, + errorCode: code, + }; } export type SseTerminalOutputBoundary = { @@ -271,9 +289,30 @@ export type SseTerminalOutputBoundary = { doneSeen(): boolean; upstreamError(): string | undefined; upstreamRefusalCode(): string | undefined; + upstreamErrorType(): string | undefined; + upstreamErrorCode(): string | undefined; dispose(): void; }; +export class SseAggregateLimitError extends Error { + readonly maxBytes: number; + + constructor(maxBytes: number) { + super(`upstream SSE transcript exceeded ${maxBytes} bytes`); + this.name = "SseAggregateLimitError"; + this.maxBytes = maxBytes; + } +} + +export type SseTerminalOutputBoundaryOptions = CodexSafetyBufferingFilterOptions & { + /** Optional raw bytes admitted before the first terminal; omitted keeps streaming behavior. */ + maxInputBytes?: number; + /** Optional client-facing bytes emitted through the first terminal. */ + maxOutputBytes?: number; + /** Optional aggregate frame count through the first terminal. */ + maxFrames?: number; +}; + /** * Frame-aware client output boundary shared by both native Responses relays. * It buffers only the current incomplete SSE block under the same hard byte @@ -282,9 +321,21 @@ export type SseTerminalOutputBoundary = { * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. */ export function createSseTerminalOutputBoundary( - options?: CodexSafetyBufferingFilterOptions, + options?: SseTerminalOutputBoundaryOptions, ): SseTerminalOutputBoundary { const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; + const maxInputBytes = options?.maxInputBytes; + const maxOutputBytes = options?.maxOutputBytes; + const maxFrames = options?.maxFrames; + if (maxInputBytes !== undefined && (!Number.isSafeInteger(maxInputBytes) || maxInputBytes <= 0)) { + throw new RangeError("maxInputBytes must be a positive safe integer"); + } + if (maxOutputBytes !== undefined && (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0)) { + throw new RangeError("maxOutputBytes must be a positive safe integer"); + } + if (maxFrames !== undefined && (!Number.isSafeInteger(maxFrames) || maxFrames <= 0)) { + throw new RangeError("maxFrames must be a positive safe integer"); + } const decoder = new TextDecoder(); const encoder = new TextEncoder(); const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); @@ -294,12 +345,30 @@ export function createSseTerminalOutputBoundary( let disposed = false; let upstreamError: string | undefined; let upstreamRefusalCode: string | undefined; + let upstreamErrorType: string | undefined; + let upstreamErrorCode: string | undefined; + let inputBytes = 0; + let outputBytes = 0; + let framesSeen = 0; const processFrames = ( frames: ReturnType, ): Uint8Array => { if (disposed || terminal || frames.length === 0) return EMPTY_BYTES; + if (maxFrames !== undefined && frames.length > maxFrames - framesSeen) { + throw new SseFrameCountLimitError(maxFrames); + } + framesSeen += frames.length; const output: Uint8Array[] = []; + const appendOutput = (...parts: Uint8Array[]): void => { + for (const part of parts) { + if (maxOutputBytes !== undefined && part.byteLength > maxOutputBytes - outputBytes) { + throw new SseAggregateLimitError(maxOutputBytes); + } + outputBytes += part.byteLength; + output.push(part); + } + }; let responsesTerminal = false; for (const frame of frames) { const payload = sseDataPayload(decoder.decode(frame.block)); @@ -311,6 +380,8 @@ export function createSseTerminalOutputBoundary( if (bare !== undefined) { upstreamError = bare.message; upstreamRefusalCode = bare.refusalCode; + upstreamErrorType = bare.errorType; + upstreamErrorCode = bare.errorCode; } const safetyBuffering = dropSafetyBuffering && parsed !== undefined ? codexSafetyBufferingBlockAction(parsed) : "keep"; @@ -331,7 +402,7 @@ export function createSseTerminalOutputBoundary( if (isDone) { done = true; if (responsesTerminal) { - output.push(outboundBlock, frame.delimiter); + appendOutput(outboundBlock, frame.delimiter); } else if (!pendingDone) { // Do not expose a sentinel before a Responses terminal. If EOF // follows, the synthetic incomplete path owns the one sentinel; @@ -342,11 +413,11 @@ export function createSseTerminalOutputBoundary( } // Preserve every frame through the first Responses terminal. Every // later non-DONE frame is dropped. - if (!responsesTerminal) output.push(outboundBlock, frame.delimiter); + if (!responsesTerminal) appendOutput(outboundBlock, frame.delimiter); if (!responsesTerminal && payload && terminalStatusFromParsed(parsed)) { responsesTerminal = true; if (pendingDone) { - output.push(pendingDone.block, pendingDone.delimiter); + appendOutput(pendingDone.block, pendingDone.delimiter); pendingDone = null; } } @@ -361,7 +432,26 @@ export function createSseTerminalOutputBoundary( return { feed(chunk) { if (disposed || terminal) return EMPTY_BYTES; - return processFrames(framer.feed(chunk)); + if (maxInputBytes === undefined) return processFrames(framer.feed(chunk)); + // A fetch implementation may hand us one multi-megabyte chunk containing thousands of + // complete frames. Slice BEFORE framing so neither the frame array nor the final join can be + // allocated past the explicit transcript budget. Once a valid terminal appears, trailing + // bytes in the same network chunk retain the ordinary terminal-authoritative behavior. + const slices: Uint8Array[] = []; + for (let offset = 0; offset < chunk.byteLength && !terminal;) { + const remaining = maxInputBytes - inputBytes; + if (remaining <= 0) throw new SseAggregateLimitError(maxInputBytes); + const length = Math.min(64 * 1024, remaining, chunk.byteLength - offset); + const slice = chunk.subarray(offset, offset + length); + inputBytes += slice.byteLength; + offset += length; + const processed = processFrames(framer.feed(slice)); + if (processed.byteLength > 0) slices.push(processed); + if (!terminal && offset < chunk.byteLength && inputBytes >= maxInputBytes) { + throw new SseAggregateLimitError(maxInputBytes); + } + } + return joinSseFrameBytes(slices); }, finish() { if (disposed || terminal) return EMPTY_BYTES; @@ -379,6 +469,8 @@ export function createSseTerminalOutputBoundary( doneSeen: () => done, upstreamError: () => upstreamError, upstreamRefusalCode: () => upstreamRefusalCode, + upstreamErrorType: () => upstreamErrorType, + upstreamErrorCode: () => upstreamErrorCode, dispose() { if (disposed) return; disposed = true; @@ -400,7 +492,7 @@ export function relaySseWithFailedTail( body: ReadableStream, upstream: AbortController, onClientGone?: (reason?: unknown) => void, - opts?: { upstreamError?: string; terminalBoundary?: CodexSafetyBufferingFilterOptions }, + opts?: { upstreamError?: string; terminalBoundary?: CodexSafetyBufferingFilterOptions; maskCredential?: (text: string) => string }, ): ReadableStream { const reader = body.getReader(); const encoder = new TextEncoder(); @@ -454,6 +546,7 @@ export function relaySseWithFailedTail( encoder, upstreamError, terminalBoundary.upstreamRefusalCode(), + opts?.maskCredential, )); controller.enqueue(doneFrame(encoder)); } @@ -486,8 +579,8 @@ export function relaySseWithFailedTail( const refusalCode = terminalBoundary.upstreamRefusalCode(); const refusalMessage = terminalBoundary.upstreamError(); controller.enqueue(refusalCode !== undefined && refusalMessage !== undefined - ? refusalFailedTailFrame(encoder, refusalMessage, refusalCode) - : failedTailFrame(encoder, err)); + ? refusalFailedTailFrame(encoder, refusalMessage, refusalCode, opts?.maskCredential) + : failedTailFrame(encoder, err, opts?.maskCredential)); } controller.close(); } catch { /* client already torn down */ } @@ -828,9 +921,15 @@ export function responseWithDeferredRequestLog( } if (!response.body || !contentType.includes("text/event-stream")) { if (response.body && (contentType.includes("application/json") || response.status >= 400)) { + // Some delivery paths already parsed the bounded body while constructing the client JSON. + // Retain EOF/cancel logging there, but do not materialize and parse the same near-limit + // response a second time merely for metadata the first pass already published. + const preinspectedJson = contentType.includes("application/json") + && preinspectedJsonResponses.has(response); const body = createBoundedResponseLogBody(response.body, { json: contentType.includes("application/json"), - inspect: text => inspectResponseLogJson(logCtx, text), + ...(preinspectedJson ? { maxInspectionBytes: 0 } : {}), + inspect: preinspectedJson ? () => undefined : text => inspectResponseLogJson(logCtx, text), finalize: reason => { // Preserve wire status; request history follows the adjacent SSE // convention for a client cancellation or upstream read failure. @@ -890,6 +989,16 @@ export function markNativePassthroughSseResponse(response: Response): Response { return response; } +/** Mark bounded JSON whose request-log metadata was already derived from its source events. */ +export function markPreinspectedJsonResponse(response: Response): Response { + preinspectedJsonResponses.add(response); + return response; +} + +export function isPreinspectedJsonResponse(response: Response): boolean { + return preinspectedJsonResponses.has(response); +} + export function isNativePassthroughSseResponse(response: Response): boolean { return nativePassthroughSseResponses.has(response); } @@ -995,6 +1104,21 @@ export type SseInspectorHandlers = { onTerminal?: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void; logCtx?: RequestLogContext; onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void; + /** A reconstructed snapshot for any valid terminal kind; unlike persistence, includes failures. */ + onTerminalResponse?: ( + status: ResponsesTerminalStatus, + response: { id?: unknown; output?: unknown; status?: unknown }, + ) => void; + /** + * Opt-in reconstruction policy for a caller that must materialize the whole terminal response. + * Persistence inspectors omit this and retain their smaller historical caps/merge semantics. + */ + terminalReconstruction?: { + maxItems: number; + maxSourceBytes: number; + requireCompleteOutputIndices?: boolean; + mergeSparseTerminalOutput?: boolean; + }; /** * Every parsed SSE payload, delivered BEFORE any onCompletedResponse derived from that same * payload. A caller that must decide on the whole turn -- not just its terminal snapshot -- @@ -1008,8 +1132,10 @@ export type SseInspectorHandlers = { * An inspector that only hears about parsed events cannot tell "nothing has been emitted" * from "something was emitted that I could not read", and a replay decision needs that * difference: an unreadable payload is a payload the caller may already have seen. + * The payload lets bounded collectors distinguish the valid `[DONE]` sentinel from malformed + * JSON without adding a second SSE parser; existing observers may ignore the argument. */ - onOpaquePayload?: () => void; + onOpaquePayload?: (payload: string | null) => void; onFirstOutput?: () => void; /** * Provider-scoped compatibility: persist the completed snapshot under the @@ -1021,6 +1147,13 @@ export type SseInspectorHandlers = { type CompletedOutputItem = { item: unknown; sourceBytes: number }; +function outputBearingSseEvent(event: { type?: unknown; output_index?: unknown } | null): boolean { + if (!event || typeof event.type !== "string") return false; + if (Object.hasOwn(event, "output_index")) return true; + return /^response\.(?:output_item|content_part|output_text|refusal|reasoning_[^.]+|function_call_arguments|custom_tool_call_input|image_generation_call|code_interpreter_call|file_search_call|web_search_call|computer_call|tool_search_call)\./ + .test(event.type); +} + function delimiterLengthAt( index: number, length: number, @@ -1071,12 +1204,24 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector let candidateBytes = 0; let discardingOversizedFrame = false; const reportFirstOutput = createFirstOutputReporter(handlers.onFirstOutput); - // Allocate reconstruction state only for persistence-capable inspectors. - const completedItemsByOutputIndex = handlers.onCompletedResponse + const reconstructionPolicy = handlers.terminalReconstruction; + if (reconstructionPolicy + && (!Number.isSafeInteger(reconstructionPolicy.maxItems) || reconstructionPolicy.maxItems <= 0 + || !Number.isSafeInteger(reconstructionPolicy.maxSourceBytes) || reconstructionPolicy.maxSourceBytes <= 0)) { + throw new RangeError("terminal reconstruction limits must be positive safe integers"); + } + const maxCompletedItems = reconstructionPolicy?.maxItems ?? MAX_COMPLETED_OUTPUT_ITEMS; + const maxCompletedItemSourceBytes = reconstructionPolicy?.maxSourceBytes + ?? MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES; + // Allocate reconstruction state only for snapshot-capable inspectors. + const completedItemsByOutputIndex = handlers.onCompletedResponse || handlers.onTerminalResponse ? new Map() : null; let aggregateItemBytes = 0; let reconstructionTainted = false; + const observedOutputIndices = reconstructionPolicy?.requireCompleteOutputIndices + ? new Set() + : null; let firstResponseId: string | undefined; const clearFrameState = (): void => { @@ -1088,6 +1233,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector const clearCompletedItems = (): void => { completedItemsByOutputIndex?.clear(); + observedOutputIndices?.clear(); aggregateItemBytes = 0; reconstructionTainted = false; }; @@ -1151,18 +1297,26 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector const retainCompletedItem = (index: number, item: unknown, sourceBytes: number): void => { const previous = completedItemsByOutputIndex!.get(index); if (previous) { + if (reconstructionPolicy?.mergeSparseTerminalOutput === true) { + // Repeated identical done frames are harmless, but a later frame must never rewrite an + // already-completed index. The strict buffered path treats that contradiction as taint. + if (nativeResponseFingerprint(previous.item) !== nativeResponseFingerprint(item)) { + reconstructionTainted = true; + } + return; + } aggregateItemBytes -= previous.sourceBytes; completedItemsByOutputIndex!.delete(index); } - if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) { + if (sourceBytes > maxCompletedItemSourceBytes) { reconstructionTainted = true; inspectionCounters.itemCapEvictions += 1; return; } completedItemsByOutputIndex!.set(index, { item, sourceBytes }); aggregateItemBytes += sourceBytes; - while (completedItemsByOutputIndex!.size > MAX_COMPLETED_OUTPUT_ITEMS - || aggregateItemBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) { + while (completedItemsByOutputIndex!.size > maxCompletedItems + || aggregateItemBytes > maxCompletedItemSourceBytes) { let highestIndex = -1; for (const retainedIndex of completedItemsByOutputIndex!.keys()) { if (retainedIndex > highestIndex) highestIndex = retainedIndex; @@ -1201,38 +1355,30 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector // The other half of the same observation. A payload that did not parse still reached the // caller, so a consumer deciding whether anything has been emitted has to hear about it. if (handlers.onOpaquePayload && parsed === undefined) { - try { handlers.onOpaquePayload(); } catch { /* inspection must never throw into the pump */ } + try { handlers.onOpaquePayload(payload); } catch { /* inspection must never throw into the pump */ } } reportFirstOutput.parsed(parsed); const status = terminalStatusFromParsed(parsed); - const policyTerminal = status === "failed" - && isPolicyRewriteType(parsed) - && cyberPolicyTerminalError(parsed) !== undefined; - if (status) sawTerminal = true; - if (!reported && handlers.onTerminal && status) { - try { - reported = true; - if (handlers.logCtx) { - handlers.logCtx.transportPhase = "terminal_sse"; - handlers.logCtx.terminalSource = "upstream"; - } - handlers.onTerminal(status, policyTerminal ? 400 : undefined); - } finally { - if (status === "failed" || status === "incomplete") clearCompletedItems(); + type ParsedSseEvent = { type?: unknown; output_index?: unknown; item?: unknown; response?: unknown }; + const parsedEvent = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as ParsedSseEvent + : null; + if (observedOutputIndices && outputBearingSseEvent(parsedEvent)) { + const outputIndex = parsedEvent?.output_index; + if (Number.isInteger(outputIndex) && (outputIndex as number) >= 0 + && (outputIndex as number) < maxCompletedItems) { + observedOutputIndices.add(outputIndex as number); + } else { + reconstructionTainted = true; } - } else if (status === "failed" || status === "incomplete") { - clearCompletedItems(); } - if (handlers.onCompletedResponse) { - type ParsedSseEvent = { type?: unknown; output_index?: unknown; item?: unknown; response?: unknown }; - const parsedEvent = parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as ParsedSseEvent - : null; + let reconstructedTerminalResponse: { id?: unknown; output?: unknown; status?: unknown } | null = null; + if (completedItemsByOutputIndex) { const responseRecord = parsedEvent && typeof parsedEvent.response === "object" && parsedEvent.response !== null && !Array.isArray(parsedEvent.response) - ? parsedEvent.response as { id?: unknown } + ? parsedEvent.response as { id?: unknown; output?: unknown; status?: unknown } : null; if (handlers.pinCompletedResponseIdToFirstSeen && responseRecord @@ -1244,46 +1390,86 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector && doneItem !== undefined && Number.isInteger(parsedEvent.output_index) && (parsedEvent.output_index as number) >= 0 + && (!observedOutputIndices || (parsedEvent.output_index as number) < maxCompletedItems) && typeof doneItem === "object" && doneItem !== null && !Array.isArray(doneItem) && typeof (doneItem as { type?: unknown }).type === "string") { retainCompletedItem(parsedEvent.output_index as number, doneItem, sourceBytes); } - - let response = completedResponseFromParsedEvent(parsedEvent); - if (response) { + if (status && responseRecord) { + reconstructedTerminalResponse = responseRecord; if (handlers.pinCompletedResponseIdToFirstSeen && firstResponseId !== undefined - && response.id !== firstResponseId) { - response = { ...response, id: firstResponseId }; + && reconstructedTerminalResponse.id !== firstResponseId) { + reconstructedTerminalResponse = { ...reconstructedTerminalResponse, id: firstResponseId }; } - // Authoritative output is a NON-EMPTY ARRAY only. Anything else - // (missing, null, scalar, object) keeps the historical backfill - // behavior so a malformed terminal cannot reach rememberResponseState - // and destroy continuation state (review C1-2). - const hasAuthoritativeOutput = Array.isArray(response.output) - && response.output.length > 0; - if (!hasAuthoritativeOutput && reconstructionTainted) { - clearCompletedItems(); - return; + const explicitTerminalOutput = reconstructedTerminalResponse.output; + const terminalOutput = Array.isArray(explicitTerminalOutput) + ? explicitTerminalOutput + : null; + if (explicitTerminalOutput !== undefined && explicitTerminalOutput !== null && !terminalOutput) { + reconstructionTainted = true; } - if (!hasAuthoritativeOutput && completedItemsByOutputIndex!.size > 0) { - response = { - ...response, - output: [...completedItemsByOutputIndex!.entries()] + if (terminalOutput && terminalOutput.length > maxCompletedItems) reconstructionTainted = true; + const hasAuthoritativeOutput = terminalOutput !== null && terminalOutput.length > 0; + if (reconstructionPolicy?.mergeSparseTerminalOutput === true) { + if (terminalOutput?.some(item => !item || typeof item !== "object" || Array.isArray(item) + || typeof (item as { type?: unknown }).type !== "string")) reconstructionTainted = true; + let output: Record[] = []; + if (!reconstructionTainted) { + try { + output = nativeResponseOutput( + new Map([...completedItemsByOutputIndex.entries()].map(([index, retained]) => [ + index, + retained.item as Record, + ])), + terminalOutput, + ); + } catch { + reconstructionTainted = true; + } + } + if (output.length > maxCompletedItems) reconstructionTainted = true; + for (const index of observedOutputIndices ?? []) { + if (index >= output.length) reconstructionTainted = true; + } + reconstructedTerminalResponse = reconstructionTainted + ? null + : { ...reconstructedTerminalResponse, output }; + } else if (!hasAuthoritativeOutput && reconstructionTainted) { + reconstructedTerminalResponse = null; + } else if (!hasAuthoritativeOutput && completedItemsByOutputIndex.size > 0) { + reconstructedTerminalResponse = { + ...reconstructedTerminalResponse, + output: [...completedItemsByOutputIndex.entries()] .sort(([left], [right]) => left - right) .map(([, retained]) => retained.item), }; } - try { - handlers.onCompletedResponse(response); - } finally { - clearCompletedItems(); + } + } + const policyTerminal = status === "failed" + && isPolicyRewriteType(parsed) + && cyberPolicyTerminalError(parsed) !== undefined; + if (status) sawTerminal = true; + try { + if (!reported && handlers.onTerminal && status) { + reported = true; + if (handlers.logCtx) { + handlers.logCtx.transportPhase = "terminal_sse"; + handlers.logCtx.terminalSource = "upstream"; } - } else if (parsedEvent?.type === "response.completed") { - clearCompletedItems(); + handlers.onTerminal(status, policyTerminal ? 400 : undefined); + } + if (status === "completed" && reconstructedTerminalResponse && handlers.onCompletedResponse) { + handlers.onCompletedResponse(reconstructedTerminalResponse); } + if (status && reconstructedTerminalResponse && handlers.onTerminalResponse) { + handlers.onTerminalResponse(status, reconstructedTerminalResponse); + } + } finally { + if (status) clearCompletedItems(); } }; @@ -1294,7 +1480,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector } const sourceBytes = candidateBytes; const frame = takeCandidate(); - if (reported && !handlers.onCompletedResponse) return; + if (reported && !handlers.onCompletedResponse && !handlers.onTerminalResponse) return; const decoded = decoder!.decode(frame); scanPayload(sseDataPayload(decoded), sourceBytes); }; diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index fc24e39fc3e..48c30dd87ed 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -23,6 +23,7 @@ import { resolveStallTimeoutMs } from "../../stall-timeout"; import { clientEncoderForDelivery, deliverClientEncodedResponse } from "../inference/client-encoder-delivery"; import { noteKiroServedSuccess } from "../../providers/kiro-usage"; import { persistKiroAccountState } from "../../providers/kiro-account-state-disk"; +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function deliverAdapterResponse( @@ -30,6 +31,7 @@ export async function deliverAdapterResponse( requestState: Pick< PreparedResponsesRequest, | "parsed" + | "route" | "translatorBudget" | "toolBridgeMaps" | "rememberKiroDeliveredFinalAnswer" @@ -51,6 +53,7 @@ export async function deliverAdapterResponse( const { logCtx, options, config } = requestContext; const { parsed, + route, translatorBudget, toolBridgeMaps, rememberKiroDeliveredFinalAnswer, @@ -71,6 +74,7 @@ export async function deliverAdapterResponse( } = responseEffects; const { routedCompaction } = sidecarState; const bodyInactivityMs = resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream }); + const upstreamRequestsStream = parsed.stream || isCanonicalOpenAiForwardProvider(route.provider); if (parsed.stream) { @@ -199,15 +203,29 @@ export async function deliverAdapterResponse( }); } - if (transportState.activeAdapter.parseResponse) { + if (transportState.activeAdapter.parseResponse + || (upstreamRequestsStream && transportState.activeAdapter.parseStream)) { let events: AdapterEvent[]; try { - const initialEvents = await readResponseBodyWithInactivity( - upstreamResponse, - upstream.signal, - bodyInactivityMs, - response => transportState.activeAdapter.parseResponse!(response, translatorBudget, logCtx.activeTierMetadata), - ); + const initialEvents: AdapterEvent[] = []; + if (upstreamRequestsStream && transportState.activeAdapter.parseStream) { + // The canonical ChatGPT adapter coerces its actual upstream request to SSE even for a + // JSON client. Routed compaction still owns the non-streaming client response, so fold + // that SSE through the adapter instead of handing it to parseResponse as JSON. + for await (const event of readResponseStreamWithInactivity( + upstreamResponse, + upstream.signal, + bodyInactivityMs, + response => transportState.activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata), + )) initialEvents.push(event); + } else { + initialEvents.push(...await readResponseBodyWithInactivity( + upstreamResponse, + upstream.signal, + bodyInactivityMs, + response => transportState.activeAdapter.parseResponse!(response, translatorBudget, logCtx.activeTierMetadata), + )); + } for (const event of initialEvents) options.onCompactionRecoveryAdapterEvent?.(event); let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { diff --git a/src/server/responses/buffered-sse-json.ts b/src/server/responses/buffered-sse-json.ts new file mode 100644 index 00000000000..a4b71128200 --- /dev/null +++ b/src/server/responses/buffered-sse-json.ts @@ -0,0 +1,345 @@ +import type { ResponsesTerminalStatus } from "../../bridge"; +import { + createSseInspector, + createSseTerminalOutputBoundary, + SseAggregateLimitError, + terminalStatusFromParsed, + type CodexSafetyBufferingFilterOptions, + type SseInspector, +} from "../relay"; +import { SseFrameCountLimitError, SseFrameTooLargeError } from "../sse-frame-buffer"; +import { + MAX_UPSTREAM_JSON_BODY_BYTES, + UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS, + UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS, +} from "./core-lifetime"; + +/** Larger than persistence inspection because this path must return the whole bounded response. */ +export const MAX_BUFFERED_RESPONSES_OUTPUT_ITEMS = 10_000; +export const MAX_BUFFERED_RESPONSES_RECONSTRUCTION_BYTES = MAX_UPSTREAM_JSON_BODY_BYTES; +export const MAX_BUFFERED_RESPONSES_SSE_FRAMES = 100_000; +/** + * A buffered caller cannot consume partial output, so keep one independent lifetime ceiling even + * when the operator disables the ordinary silence clock. Fifteen minutes is intentionally much + * larger than the public default stall budget while still releasing an abandoned body eventually. + */ +export const BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS = 15 * 60_000; +const BUFFERED_SSE_PROCESSING_SLICE_BYTES = 64 * 1024; + +export type BufferedResponsesTerminal = { + status: ResponsesTerminalStatus; + response: Record; +}; + +export type BufferedResponsesSseFailure = { + ok: false; + kind: "aborted" | "malformed" | "missing_terminal" | "oversized" | "read_error" | "timeout"; + error?: unknown; + /** Bounded diagnostic captured from a bare upstream `error` event. */ + upstreamError?: string; + /** Fatal provider refusal verdict that must not be flattened into a retryable transport error. */ + upstreamRefusalCode?: string; + /** Bounded structured class from the same bare error envelope as `upstreamError`. */ + upstreamErrorType?: string; + /** Bounded structured code; unknown codes remain transport failures instead of message guesses. */ + upstreamErrorCode?: string; +}; + +export type BufferedResponsesSseResult = { + ok: true; + /** Present only when the caller needs to replay the bounded transcript through rewrites. */ + bytes?: Uint8Array; + terminal: BufferedResponsesTerminal; +} | BufferedResponsesSseFailure; + +type BufferedReadOptions = { + maxBytes?: number; + maxFrames?: number; + totalTimeoutMs?: number; + /** Absolute whole-turn deadline shared by every validation pass. */ + deadlineAt?: number; + inactivityTimeoutMs?: number; + firstByteTimeoutMs?: number; +}; + +export type BufferedResponsesReadOptions = BufferedReadOptions & { deadlineAt: number }; + +/** + * Resolve the read clocks for canonical SSE that must be folded into client JSON. + * + * A positive stall budget owns both time-to-first-byte and inter-chunk silence. A disabled budget + * must not become an immediate timeout, so both clocks fall back to the independent total ceiling. + * The ceiling remains separate because a JSON caller has no partial body to keep alive forever. + */ +export function bufferedResponsesReadOptions( + stallTimeoutMs: number, + totalTimeoutMs = BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + startedAt = Date.now(), +): BufferedResponsesReadOptions { + if (!Number.isFinite(stallTimeoutMs)) throw new RangeError("stallTimeoutMs must be finite"); + if (!Number.isSafeInteger(totalTimeoutMs) || totalTimeoutMs <= 0) { + throw new RangeError("totalTimeoutMs must be a positive safe integer"); + } + if (!Number.isSafeInteger(startedAt) || startedAt < 0 || startedAt > Number.MAX_SAFE_INTEGER - totalTimeoutMs) { + throw new RangeError("startedAt must produce a safe deadline"); + } + const silenceTimeoutMs = stallTimeoutMs > 0 ? Math.ceil(stallTimeoutMs) : totalTimeoutMs; + return { + totalTimeoutMs, + deadlineAt: startedAt + totalTimeoutMs, + firstByteTimeoutMs: silenceTimeoutMs, + inactivityTimeoutMs: silenceTimeoutMs, + }; +} + +type Uint8ReadResult = Awaited["read"]>>; + +class BufferedSseTimeoutError extends Error { + constructor(deadline: "total" | "inactivity") { + super(`buffered Responses SSE ${deadline} timeout`); + this.name = "BufferedSseTimeoutError"; + } +} + +function createBufferedTerminalInspector(maxSourceBytes: number): { + inspector: SseInspector; + result: () => { ok: true; terminal: BufferedResponsesTerminal } | BufferedResponsesSseFailure; +} { + let malformed = false; + let terminalCount = 0; + let status: ResponsesTerminalStatus | undefined; + let terminalResponse: Record | undefined; + const inspector = createSseInspector({ + terminalReconstruction: { + maxItems: MAX_BUFFERED_RESPONSES_OUTPUT_ITEMS, + maxSourceBytes, + requireCompleteOutputIndices: true, + mergeSparseTerminalOutput: true, + }, + onParsedPayload(payload) { + const candidateStatus = terminalStatusFromParsed(payload); + if (!candidateStatus) return; + terminalCount += 1; + status ??= candidateStatus; + }, + onOpaquePayload(payload) { + if (payload !== "[DONE]") malformed = true; + }, + onTerminalResponse(candidateStatus, response) { + if (candidateStatus === status && terminalResponse === undefined) { + if (response.status !== undefined && response.status !== candidateStatus) { + malformed = true; + return; + } + if (response.output !== undefined && !Array.isArray(response.output)) { + malformed = true; + return; + } + terminalResponse = { + ...response, + status: candidateStatus, + output: response.output ?? [], + } as Record; + } + }, + }); + return { + inspector, + result: () => { + if (malformed) return { ok: false, kind: "malformed" }; + if (terminalCount !== 1 || status === undefined) return { ok: false, kind: "missing_terminal" }; + if (!terminalResponse || terminalResponse.status !== status || !Array.isArray(terminalResponse.output)) { + return { ok: false, kind: "malformed" }; + } + return { ok: true, terminal: { status, response: terminalResponse } }; + }, + }; +} + +/** Strict synchronous seam for terminal/reconstruction tests; production feeds bytes incrementally. */ +export function inspectBufferedResponsesTerminal(text: string): + | { ok: true; terminal: BufferedResponsesTerminal } + | BufferedResponsesSseFailure { + const state = createBufferedTerminalInspector(MAX_BUFFERED_RESPONSES_RECONSTRUCTION_BYTES); + try { + state.inspector.feed(new TextEncoder().encode(text)); + state.inspector.finish(); + return state.result(); + } finally { + state.inspector.dispose(); + } +} + +function cancelReader(reader: ReadableStreamDefaultReader, reason?: unknown): void { + try { void reader.cancel(reason).catch(() => undefined); } catch { /* non-conforming stream */ } +} + +async function deadlineRead( + reader: ReadableStreamDefaultReader, + signal: AbortSignal | undefined, + deadlineAt: number, + deadline: "total" | "inactivity", +): Promise { + if (signal?.aborted) throw signal.reason; + return await new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + callback(); + }; + const onAbort = () => finish(() => reject(signal?.reason)); + timer = setTimeout( + () => finish(() => reject(new BufferedSseTimeoutError(deadline))), + Math.max(0, deadlineAt - Date.now()), + ); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + void reader.read().then( + value => finish(() => resolve(value)), + error => finish(() => reject(error)), + ); + }); +} + +/** + * Consume through the first terminal, which must validate, with bounded framing and deadlines. + * `retainTranscript:false` feeds the strict assembler directly and never materializes the second, + * client-facing SSE transcript. Side effects remain the delivery owner's responsibility. + */ +export async function collectBufferedResponsesSse( + body: ReadableStream, + upstream: AbortController, + options: { + signal?: AbortSignal; + terminalBoundary?: CodexSafetyBufferingFilterOptions; + read?: BufferedReadOptions; + retainTranscript?: boolean; + } = {}, +): Promise { + const maxBytes = options.read?.maxBytes ?? MAX_UPSTREAM_JSON_BODY_BYTES; + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new RangeError("maxBytes must be a positive safe integer"); + } + const maxFrames = options.read?.maxFrames ?? MAX_BUFFERED_RESPONSES_SSE_FRAMES; + const totalTimeoutMs = options.read?.totalTimeoutMs ?? UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS; + const inactivityTimeoutMs = options.read?.inactivityTimeoutMs ?? UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS; + const firstByteTimeoutMs = options.read?.firstByteTimeoutMs ?? totalTimeoutMs; + const absoluteDeadlineAt = options.read?.deadlineAt; + if (absoluteDeadlineAt !== undefined && (!Number.isSafeInteger(absoluteDeadlineAt) || absoluteDeadlineAt < 0)) { + throw new RangeError("deadlineAt must be a non-negative safe integer"); + } + const retainTranscript = options.retainTranscript === true; + const reader = body.getReader(); + const boundary = createSseTerminalOutputBoundary({ + ...options.terminalBoundary, + maxInputBytes: maxBytes, + maxOutputBytes: maxBytes, + maxFrames, + }); + const state = createBufferedTerminalInspector(Math.min( + maxBytes, + MAX_BUFFERED_RESPONSES_RECONSTRUCTION_BYTES, + )); + const utf8 = new TextDecoder("utf-8", { fatal: true }); + let retained = retainTranscript ? new Uint8Array(Math.min(maxBytes, 64 * 1024)) : undefined; + let retainedBytes = 0; + const startedAt = Date.now(); + const totalDeadlineAt = absoluteDeadlineAt ?? startedAt + totalTimeoutMs; + let inactivityDeadlineAt = startedAt + firstByteTimeoutMs; + + const withBoundaryError = (failure: T): T => { + const upstreamError = boundary.upstreamError(); + const upstreamRefusalCode = boundary.upstreamRefusalCode(); + const upstreamErrorType = boundary.upstreamErrorType(); + const upstreamErrorCode = boundary.upstreamErrorCode(); + return { + ...failure, + ...(upstreamError === undefined ? {} : { upstreamError }), + ...(upstreamRefusalCode === undefined ? {} : { upstreamRefusalCode }), + ...(upstreamErrorType === undefined ? {} : { upstreamErrorType }), + ...(upstreamErrorCode === undefined ? {} : { upstreamErrorCode }), + }; + }; + + const inspect = (chunk: Uint8Array): void => { + if (chunk.byteLength === 0) return; + // Validate incrementally without retaining a second decoded transcript. The shared inspector + // independently owns SSE framing and JSON reconstruction over the same bytes. + utf8.decode(chunk, { stream: true }); + state.inspector.feed(chunk); + if (!retained) return; + if (chunk.byteLength > maxBytes - retainedBytes) throw new SseAggregateLimitError(maxBytes); + const required = retainedBytes + chunk.byteLength; + if (required > retained.byteLength) { + const grown = new Uint8Array(Math.min(maxBytes, Math.max(required, retained.byteLength * 2))); + grown.set(retained.subarray(0, retainedBytes)); + retained = grown; + } + retained.set(chunk, retainedBytes); + retainedBytes = required; + }; + + try { + for (;;) { + const deadlineAt = Math.min(totalDeadlineAt, inactivityDeadlineAt); + const deadline = totalDeadlineAt <= inactivityDeadlineAt ? "total" : "inactivity"; + const { done, value } = await deadlineRead(reader, options.signal, deadlineAt, deadline); + if (options.signal?.aborted) throw options.signal.reason; + if (done) { + inspect(boundary.finish()); + break; + } + if (!value || value.byteLength === 0) continue; + inactivityDeadlineAt = Date.now() + inactivityTimeoutMs; + // Slice the reader's chunk before framing. This keeps one oversized network read from + // producing a transcript-sized array of frames/output buffers and gives the total deadline + // and aggregate frame cap a checkpoint between bounded pieces of synchronous work. + for (let offset = 0; offset < value.byteLength && !boundary.terminalSeen();) { + if (Date.now() >= totalDeadlineAt) throw new BufferedSseTimeoutError("total"); + const end = Math.min(value.byteLength, offset + BUFFERED_SSE_PROCESSING_SLICE_BYTES); + inspect(boundary.feed(value.subarray(offset, end))); + offset = end; + } + if (boundary.terminalSeen()) { + cancelReader(reader, "Responses terminal event received"); + break; + } + } + utf8.decode(); + state.inspector.finish(); + if (options.signal?.aborted) throw options.signal.reason; + const terminal = state.result(); + if (!terminal.ok) { + upstream.abort(new Error(`buffered Responses SSE ${terminal.kind}`)); + return withBoundaryError(terminal); + } + return { + ...terminal, + ...(retained ? { bytes: retained.subarray(0, retainedBytes) } : {}), + }; + } catch (error) { + upstream.abort(error); + cancelReader(reader, error); + return withBoundaryError({ + ok: false, + kind: options.signal?.aborted + ? "aborted" + : error instanceof BufferedSseTimeoutError + ? "timeout" + : error instanceof SseAggregateLimitError + || error instanceof SseFrameTooLargeError + || error instanceof SseFrameCountLimitError + ? "oversized" + : "read_error", + error, + }); + } finally { + boundary.dispose(); + state.inspector.dispose(); + try { reader.releaseLock(); } catch { /* a cancelled pending read may still own it briefly */ } + } +} diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 47ee892258a..1f6f2992581 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -395,6 +395,8 @@ export interface CodexPoolAccountRetryArgs { connectMs: number; passthroughEstimate?: number; stream: boolean; + /** Keep a buffered canonical client on HTTP/SSE after moving to another Pool account. */ + httpOnly?: boolean; onResponse?: ( response: Response, authCtx: CodexAuthContext, @@ -535,7 +537,7 @@ export async function retryCodexPoolOnAlternateAccount( ): Promise { const { callerAuthHeaders, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, - outcomeStatus, upstream, connectMs, passthroughEstimate, stream, + outcomeStatus, upstream, connectMs, passthroughEstimate, stream, httpOnly, } = args; const inboundWire = options.inboundWire ?? "responses"; const entitlementResolver = options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements; @@ -877,6 +879,7 @@ export async function retryCodexPoolOnAlternateAccount( connectMs, stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + httpOnly, providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId), diff --git a/src/server/responses/core-lifetime.ts b/src/server/responses/core-lifetime.ts index e8cfcf4224c..c6b5653ea28 100644 --- a/src/server/responses/core-lifetime.ts +++ b/src/server/responses/core-lifetime.ts @@ -4,6 +4,8 @@ import { markNativePassthroughSseResponse, isEagerRelaySseResponse, markEagerRelaySseResponse, + isPreinspectedJsonResponse, + markPreinspectedJsonResponse, } from "../relay"; // runTurn adapters own an event queue and perform their combo preflight before @@ -77,6 +79,7 @@ export function finalizeOwnedTranslatorBudget(response: Response, budget: Transl if (isEagerRelaySseResponse(response)) { markEagerRelaySseResponse(finalizedResponse); } + if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(finalizedResponse); return finalizedResponse; } @@ -99,6 +102,7 @@ export function finalizeAccountLease(response: Response, release: () => void): R const wrapped = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }); if (isNativePassthroughSseResponse(response)) markNativePassthroughSseResponse(wrapped); if (isEagerRelaySseResponse(response)) markEagerRelaySseResponse(wrapped); + if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(wrapped); return wrapped; } diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 4c039f0d55c..c3d55b1f445 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -1,4 +1,5 @@ import { isNativeControlResponse } from "./native-response-control"; +import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesTransport } from "./request-transport"; @@ -10,6 +11,7 @@ import { createSseInspector, markEagerRelaySseResponse, markNativePassthroughSseResponse, + markPreinspectedJsonResponse, consumeForInspection, consumeForResponseLogMetadata, relaySseWithFailedTail, @@ -32,7 +34,7 @@ import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-m import { recordCodexUpstreamOutcome } from "../../codex/routing"; import { codexProbeLeaseId, codexProbeQuotaScope, codexTransientProbeGrant, releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; import { consumeComboFailure } from "./core-combo-failure"; -import { readDisplaySafeErrorText } from "./core-errors"; +import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors"; import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow"; import { formatPassthroughUpstreamError } from "./passthrough-error"; import { rewriteUpstreamPolicyRefusal } from "./policy-refusal"; @@ -131,13 +133,94 @@ import { readBoundedResponseBody } from "../../lib/bounded-body"; import { idleDeadline } from "../../lib/abort"; import { resolveStallTimeoutMs } from "../../stall-timeout"; import { formatErrorResponse } from "../../bridge"; -import { inspectResponseLogJson } from "../request-log"; +import { + httpStatusFromTerminalError, + inspectResponseLogJson, + inspectResponseLogSsePayloadParsed, +} from "../request-log"; import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { responsesJsonToSseStream } from "../responses-json-events"; +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { + bufferedResponsesReadOptions, + collectBufferedResponsesSse, + type BufferedResponsesSseFailure, + type BufferedResponsesSseResult, +} from "./buffered-sse-json"; const PLAINTEXT_V2_SSE_PREFIX_LIMIT = 4096; +/** Replay retained SSE in small views so the rewrite decoder never receives one 32 MiB chunk. */ +function replayBufferedSse(bytes: Uint8Array): ReadableStream { + let offset = 0; + return new ReadableStream({ + pull(controller) { + if (offset >= bytes.byteLength) { + controller.close(); + return; + } + const end = Math.min(bytes.byteLength, offset + 64 * 1024); + controller.enqueue(bytes.subarray(offset, end)); + offset = end; + }, + }); +} + +const BUFFERED_CONTEXT_ERROR_CODES = new Set([ + "context_length_exceeded", "context_window_exceeded", "input_too_long", +]); +const BUFFERED_QUOTA_ERROR_CODES = new Set([ + "usage_limit_exceeded", "usage_limit_reached", "rate_limit_exceeded", "insufficient_quota", +]); +const BUFFERED_OVERLOAD_ERROR_CODES = new Set(["server_is_overloaded"]); + +function bufferedErrorDefaults(status: number): { type: string; code: string } { + switch (status) { + case 400: return { type: "invalid_request_error", code: "invalid_request_error" }; + case 401: return { type: "authentication_error", code: "invalid_api_key" }; + case 403: return { type: "permission_error", code: "permission_denied" }; + case 429: return { type: "rate_limit_error", code: "rate_limit_exceeded" }; + case 499: return { type: "client_closed_request", code: "client_closed_request" }; + case 503: return { type: "server_error", code: "server_is_overloaded" }; + default: return { type: "upstream_error", code: "upstream_server_error" }; + } +} + +/** Preserve only explicit, recognized bare-error classes; diagnostic wording never sets status. */ +function bufferedBareErrorPresentation( + failure: BufferedResponsesSseFailure, + message: string, +): { status: number; error: { type: string; code: string; message: string } } { + if (failure.upstreamRefusalCode !== undefined) { + return { + status: 400, + error: { type: "invalid_request_error", code: failure.upstreamRefusalCode, message }, + }; + } + const explicitCode = failure.upstreamErrorCode; + const status = explicitCode !== undefined + ? BUFFERED_CONTEXT_ERROR_CODES.has(explicitCode) ? 400 + : BUFFERED_QUOTA_ERROR_CODES.has(explicitCode) ? 429 + : BUFFERED_OVERLOAD_ERROR_CODES.has(explicitCode) ? 503 + : httpStatusFromTerminalError({ code: explicitCode }) + : httpStatusFromTerminalError({ type: failure.upstreamErrorType }); + const defaults = bufferedErrorDefaults(status); + if (status === 502) return { status, error: { ...defaults, message } }; + const explicitType = failure.upstreamErrorType; + const typeMatchesStatus = explicitType !== undefined + && (httpStatusFromTerminalError({ type: explicitType }) === status + || (explicitType === "server_error" && status === 503)); + return { + status, + error: { + type: typeMatchesStatus ? explicitType! : defaults.type, + code: explicitCode ?? defaults.code, + message, + }, + }; +} + /** Prefix-probe budget: bounds one silent gap and the whole probe alike. */ interface PlaintextV2SseProbeOptions { timeoutMs: number; @@ -348,10 +431,12 @@ export async function deliverPassthroughResponse( const { requestBindings } = transportState; let upstreamResponse = nativeExchange.upstreamResponse; + const canonicalBufferedJson = clientRequestedStream !== true + && isCanonicalOpenAiForwardProvider(route.provider); const originalContentType = upstreamResponse.headers.get("content-type"); if (isUsageDebugEnabled() && originalContentType) logCtx.usageDebugContentType = originalContentType; if (responseEffects.plaintextV2AgentMessageToolNames.size > 0 - && upstreamResponse.ok && upstreamResponse.body && parsed.stream + && upstreamResponse.ok && upstreamResponse.body && (parsed.stream || canonicalBufferedJson) && !originalContentType?.toLowerCase().includes("text/event-stream") && !originalContentType?.toLowerCase().includes("application/json") && !isCodexWsUpstreamResponse(upstreamResponse) @@ -372,7 +457,11 @@ export async function deliverPassthroughResponse( // reach this fallback only after their first Responses event is confirmed. const passthroughCt = headers.get("content-type")?.toLowerCase(); const isEventStream = passthroughCt?.includes("text/event-stream") - || (responseEffects.plaintextV2AgentMessageToolNames.size === 0 && upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream); + || (responseEffects.plaintextV2AgentMessageToolNames.size === 0 + && upstreamResponse.ok + && !!upstreamResponse.body + && !passthroughCt + && (parsed.stream || canonicalBufferedJson)); const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder( config, admissionState.authCtx, @@ -522,10 +611,13 @@ export async function deliverPassthroughResponse( const grokUpstreamEchoEnabled = isXaiResponsesDestination(route.provider) && responsesRequestMayReplayToolOutput(parsed._rawBody); if (isEventStream && upstreamResponse.body) { - // For streamed passthrough, a successful terminal response means non-error upstream status - // before relay starts. Waiting for SSE completion would retain request state across the whole - // stream; a later body failure does not undo that this destination accepted and served the turn. - commitReasoningReplayServingRoute(nativeExchange.request.headers); + // Streaming clients commit the serving route once the upstream accepted the turn; retaining + // request state until their body ends would change the existing relay contract. The canonical + // non-streaming fold below is different: nothing is published yet, so it defers this mutation + // until both the raw and rewritten terminal snapshots validate. + if (!canonicalBufferedJson) { + commitReasoningReplayServingRoute(nativeExchange.request.headers); + } const terminalRepairPolicy = route.staticPolicy.model.responsesTerminalRepair; // #3761: opt-in hosted-web-search bridge. Codex always declares the hosted web_search tool, // and this branch relays that declaration on the assumption the destination executes it. @@ -665,7 +757,9 @@ export async function deliverPassthroughResponse( // are not the Responses wire shapes the snapshot must mirror. // Only validated client blocks may publish plaintext continuation state. // Raw inspection precedes rewriting on eager relays, so it cannot own this write. - const plaintextInspector = !grokUpstreamEchoEnabled && responseEffects.plaintextV2AgentMessageToolNames.size > 0 + const plaintextInspector = !canonicalBufferedJson + && !grokUpstreamEchoEnabled + && responseEffects.plaintextV2AgentMessageToolNames.size > 0 ? createSseInspector({ onCompletedResponse: rememberPassthroughResponseChecked }) : undefined; const plaintextEncoder = plaintextInspector ? new TextEncoder() : undefined; @@ -675,6 +769,7 @@ export async function deliverPassthroughResponse( return [block]; }, { dispose: () => plaintextInspector.dispose() }) : undefined; + const maskCredential = createOutboundCredentialMask(nativeExchange.request.headers); const blockRewrites = [ payloadRewrites.length > 0 ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites)) @@ -742,12 +837,211 @@ export async function deliverPassthroughResponse( rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, ) : undefined, + createTerminalErrorRedactionBlockRewrite(nativeExchange.request.headers, maskCredential), rememberPlaintextBlock, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); const clientBlockRewrite = blockRewrites.length > 0 ? composeSseBlockRewrites(...blockRewrites) : undefined; const needsClientRewrite = clientBlockRewrite !== undefined; + + if (canonicalBufferedJson) { + const signal = options.abortSignal ?? req.signal; + const bufferedRead = bufferedResponsesReadOptions(resolveStallTimeoutMs( + config.stallTimeoutSec, + { localUpstream: nativeExchange.localUpstream }, + )); + const failBufferedTurn = ( + message: string, + failure?: BufferedResponsesSseFailure, + ): Response => { + upstream.abort(new Error(message)); + const upstreamError = failure?.upstreamError; + const upstreamRefusalCode = failure?.upstreamRefusalCode; + const rawPresentation = upstreamError === undefined || failure === undefined + ? undefined + : bufferedBareErrorPresentation(failure, upstreamError); + const presentation = rawPresentation === undefined ? undefined : { + status: rawPresentation.status, + error: { + type: maskCredential(rawPresentation.error.type), + code: maskCredential(rawPresentation.error.code), + message: maskCredential(rawPresentation.error.message), + }, + }; + if (upstreamError !== undefined) { + // The streaming relay turns a bare `error` event into a terminal failure. Mirror that + // verdict for JSON callers before recording/formatting it, or a provider refusal is + // misreported as a retryable generic 502 and clients can replay a rejected turn. + const error = presentation!.error; + const terminalPayload = { + type: "response.failed", + response: { status: "failed", error, last_error: error }, + }; + // Classification comes only from the bounded structured type/code above. A bare + // transport reset can contain misleading copy such as "invalid api key"; letting the + // log's message fallback reinterpret it would retire a healthy credential. + logCtx.terminalHttpStatus = presentation!.status; + noteInspectedPayload(terminalPayload); + inspectResponseLogSsePayloadParsed( + logCtx, + JSON.stringify(terminalPayload), + terminalPayload, + ); + } + const failureStatus = logCtx.terminalHttpStatus ?? 502; + if (recordTerminalOutcomes) { + logCtx.transportPhase = "mid_stream"; + logCtx.terminalSource = "synthetic"; + if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true; + terminalRecorder?.("failed", failureStatus); + options.onNativePassthroughTerminal?.("failed"); + } + if (upstreamError !== undefined) { + const failureHeaders = new Headers(headers); + failureHeaders.set("content-type", "application/json"); + failureHeaders.delete("content-length"); + failureHeaders.delete("content-encoding"); + if (upstreamRefusalCode !== undefined) failureHeaders.delete("retry-after"); + const error = presentation!.error; + return formatPassthroughUpstreamError( + failureStatus, + JSON.stringify({ error, ...(upstreamRefusalCode === undefined ? {} : { retryable: false }) }), + { + headers: failureHeaders, + suppressRetryAfter: upstreamRefusalCode !== undefined, + }, + ); + } + return formatErrorResponse(502, "upstream_error", message); + }; + let raw: BufferedResponsesSseResult | undefined = await collectBufferedResponsesSse( + passthroughSseBody, + upstream, + { signal, terminalBoundary: codexSafetyBufferingOptions, read: bufferedRead, retainTranscript: true }, + ); + if (!raw.ok) { + if (raw.kind === "aborted" || signal.aborted) { + responseEffects.responseCompletionCancelled = true; + options.onNativePassthroughCancel?.(); + return clientCancelledResponse(); + } + const message = raw.kind === "oversized" + ? "upstream SSE response exceeded the safe body limit" + : raw.kind === "timeout" + ? "upstream SSE response stalled before completing" + : raw.kind === "malformed" || raw.kind === "missing_terminal" + ? "upstream SSE response ended without one valid terminal response" + : "upstream SSE response failed before a valid terminal response"; + return failBufferedTurn(message, raw); + } + if (!raw.bytes) return failBufferedTurn("upstream SSE transcript was not retained for rewriting"); + let rawBytes: Uint8Array | undefined = raw.bytes; + raw = undefined; // Release the raw reconstruction graph before building the client copy. + + // Reuse the ordinary client rewrite chain against the bounded transcript. The raw pass + // above validates before any continuation or outcome side effect; this second validation + // means a rewrite failure cannot publish a turn the non-streaming caller never received. + let bufferedClientBody: ReadableStream | undefined = clientBlockRewrite + ? relaySseWithBlockRewrite( + replayBufferedSse(rawBytes), + clientBlockRewrite, + translatorBudget, + ) + : replayBufferedSse(rawBytes); + const client = await collectBufferedResponsesSse(bufferedClientBody, upstream, { + signal, + read: bufferedRead, + retainTranscript: false, + }); + bufferedClientBody = undefined; + if (!client.ok) { + if (client.kind === "aborted" || signal.aborted) { + responseEffects.responseCompletionCancelled = true; + options.onNativePassthroughCancel?.(); + return clientCancelledResponse(); + } + const message = client.kind === "oversized" + ? "rewritten SSE response exceeded the safe body limit" + : client.kind === "timeout" + ? "rewritten SSE response stalled before completing" + : "rewritten SSE response did not contain one valid terminal response"; + return failBufferedTurn(message, client); + } + if (Date.now() >= bufferedRead.deadlineAt) { + return failBufferedTurn("buffered Responses turn exceeded the safe total deadline"); + } + + const cancelAfterValidation = (): Response => { + responseEffects.responseCompletionCancelled = true; + options.onNativePassthroughCancel?.(); + return clientCancelledResponse(); + }; + if (signal.aborted) return cancelAfterValidation(); + + // Effects intentionally run only after both bounded validations. They inspect the same + // upstream-facing transcript as the streaming relay and fire once; continuation storage + // receives the final client-visible response once after all restorations succeeded. + const reportNativeTerminal = recordTerminalOutcomes + ? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { + if (signal.aborted) return; + terminalRecorder?.(status, httpStatusOverride); + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(admissionState.authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( + req.headers, + subagentQuotaFailureModel, + quotaFailureMessage, + config, + requestState.subagentFallbackAccountId, + ); + } + } + options.onNativePassthroughTerminal?.(status); + } + : undefined; + const effectInspector = createSseInspector({ + onTerminal: reportNativeTerminal, + logCtx, + onParsedPayload: noteInspectedPayload, + onFirstOutput: options.onFirstOutput, + pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, + }); + try { + // The deferred effects pass must not turn a bounded 32 MiB transcript into one long + // event-loop monopoly. Keep decoder input small and yield between MiB groups; the + // aggregate frame cap was already enforced by both validation passes. + for (let offset = 0; offset < rawBytes.byteLength; offset += 64 * 1024) { + if (signal.aborted) return cancelAfterValidation(); + effectInspector.feed(rawBytes.subarray(offset, Math.min(rawBytes.byteLength, offset + 64 * 1024))); + if (offset > 0 && offset % (1024 * 1024) === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + if (signal.aborted) return cancelAfterValidation(); + } + } + if (signal.aborted) return cancelAfterValidation(); + effectInspector.finish(); + } finally { + effectInspector.dispose(); + } + if (signal.aborted) return cancelAfterValidation(); + commitReasoningReplayServingRoute(nativeExchange.request.headers); + rawBytes = undefined; + if (client.terminal.status === "completed") { + rememberPassthroughResponseChecked(client.terminal.response); + } + + const jsonHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); + jsonHeaders.set("content-type", "application/json"); + return markPreinspectedJsonResponse(new Response(JSON.stringify(client.terminal.response), { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: jsonHeaders, + })); + } + const relayPlatform = relayPlatformForTests ?? process.platform; // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is @@ -829,6 +1123,7 @@ export async function deliverPassthroughResponse( }, { clientGoneSignal: options.abortSignal, terminalBoundary: codexSafetyBufferingOptions, + maskCredential, ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), ...(logCtx.upstreamError === undefined ? {} : { upstreamError: logCtx.upstreamError }), }); @@ -922,7 +1217,7 @@ export async function deliverPassthroughResponse( responseEffects.responseCompletionCancelled = true; clientGone.abort(reason); }, - { upstreamError: logCtx.upstreamError, terminalBoundary: codexSafetyBufferingOptions }, + { upstreamError: logCtx.upstreamError, terminalBoundary: codexSafetyBufferingOptions, maskCredential }, ); return markNativePassthroughSseResponse(new Response(clientBody, { status: upstreamResponse.status, diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index ca1f92e473c..ec47d4e6e77 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -261,6 +261,18 @@ export async function preparePassthroughExchange( const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider) ? codexSafetyBufferingFilterOptions(config) : undefined; + // The canonical ChatGPT adapter coerces only its final outbound copy to `stream:true` so the + // separately captured client preference can still select JSON delivery. Recovery runs before + // delivery, though, and must classify the bytes the upstream was actually asked to send: + // ChatGPT may omit Content-Type, so consulting `parsed.stream` here would mistake its SSE body + // for non-streaming JSON and skip both opaque-state and safe-reset recovery. + const upstreamRequestsStream = parsed.stream === true + || isCanonicalOpenAiForwardProvider(route.provider); + // A JSON client still needs the canonical destination's HTTP/SSE response so delivery can + // validate and fold it. Letting the forced `stream:true` body select the WebSocket transport + // bypasses manual HTTP redirects and can turn a real 3xx into a connect timeout. + const canonicalBufferedJson = clientRequestedStream !== true + && isCanonicalOpenAiForwardProvider(route.provider); const imageGenCallAliases = route.provider.authMode === "forward" ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); @@ -961,8 +973,9 @@ export async function preparePassthroughExchange( method: request.method, headers: request.headers, body: request.body, - }, recovery), upstream.signal, connectMs, parsed.stream, + }, recovery), upstream.signal, connectMs, upstreamRequestsStream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + httpOnly: canonicalBufferedJson, nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? options.nativeControl : undefined, @@ -1065,12 +1078,13 @@ export async function preparePassthroughExchange( method: request.method, headers: request.headers, body: request.body, - }, innerRecovery), upstream.signal, connectMs, parsed.stream, + }, innerRecovery), upstream.signal, connectMs, upstreamRequestsStream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { - nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt - && responseEffects.plaintextV2AgentMessageToolNames.size === 0 - ? options.nativeControl : undefined, - dispatchOverride: oauthDispatch(request), + httpOnly: canonicalBufferedJson, + nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt + && responseEffects.plaintextV2AgentMessageToolNames.size === 0 + ? options.nativeControl : undefined, + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), @@ -1173,6 +1187,7 @@ export async function preparePassthroughExchange( // rather than a second one to announce. const oauthReplayExecutor = storedPoolReplayDispatchNotifier( providerFetch(route.provider, options.codexWsRuntimeIdentity, { + httpOnly: canonicalBufferedJson, nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? options.nativeControl : undefined, @@ -1202,7 +1217,7 @@ export async function preparePassthroughExchange( }, recovery), upstream.signal, connectMs, - parsed.stream, + upstreamRequestsStream, oauthReplayExecutor, route.provider.authMode === "forward", ).then(adoptObservedResponse); @@ -1320,12 +1335,13 @@ export async function preparePassthroughExchange( method: request.method, headers: request.headers, body: request.body, - }, recovery), upstream.signal, connectMs, parsed.stream, + }, recovery), upstream.signal, connectMs, upstreamRequestsStream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { - nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt - && responseEffects.plaintextV2AgentMessageToolNames.size === 0 - ? options.nativeControl : undefined, - dispatchOverride: oauthDispatch(request), + httpOnly: canonicalBufferedJson, + nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt + && responseEffects.plaintextV2AgentMessageToolNames.size === 0 + ? options.nativeControl : undefined, + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), @@ -1454,12 +1470,13 @@ export async function preparePassthroughExchange( method: request.method, headers: request.headers, body: request.body, - }, recovery), upstream.signal, connectMs, parsed.stream, + }, recovery), upstream.signal, connectMs, upstreamRequestsStream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { - nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt - && responseEffects.plaintextV2AgentMessageToolNames.size === 0 - ? options.nativeControl : undefined, - dispatchOverride: oauthDispatch(request), + httpOnly: canonicalBufferedJson, + nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt + && responseEffects.plaintextV2AgentMessageToolNames.size === 0 + ? options.nativeControl : undefined, + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), @@ -1571,7 +1588,8 @@ export async function preparePassthroughExchange( upstream, connectMs, passthroughEstimate, - stream: parsed.stream, + stream: upstreamRequestsStream, + httpOnly: canonicalBufferedJson, onResponse: (response, retryAuthCtx, retryRequest) => { adoptCodexWsStage(response); captureAffinityResponse( @@ -1621,7 +1639,7 @@ export async function preparePassthroughExchange( const recoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; const streamedFunctionOutputCandidate = upstreamResponse.ok && !!upstreamResponse.body - && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && parsed.stream)) + && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && upstreamRequestsStream)) && !opaqueBlobRecoveryGuard.attempted && !configuredTransientSendBudgetExhausted() && outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body); @@ -1639,7 +1657,7 @@ export async function preparePassthroughExchange( if (decryptRejection) preflightLog.upstreamError = ENCRYPTED_FUNCTION_OUTPUT_REJECTION; return decryptRejection; }, { - allowMissingContentType: !recoveryContentType && parsed.stream, + allowMissingContentType: !recoveryContentType && upstreamRequestsStream, replayReadErrors: true, }); if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason); @@ -1789,7 +1807,7 @@ export async function preparePassthroughExchange( && !(options.nativeControl && options.inboundTransport === "websocket") && ambiguousResend() !== undefined && remainingTransientSendBudget(transientSendAttempts()) > 0 - && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream)); + && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && upstreamRequestsStream)); if (protocolRecoveryCandidate) { upstreamResponse = deferProtocolSafeResetRecovery( upstreamResponse, @@ -1808,11 +1826,11 @@ 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 && parsed.stream); + return type.includes("text/event-stream") || (!type && upstreamRequestsStream); }, }, ), - { allowMissingContentType: !streamRecoveryContentType && parsed.stream }, + { allowMissingContentType: !streamRecoveryContentType && upstreamRequestsStream }, ); } break; diff --git a/src/server/responses/passthrough-error.ts b/src/server/responses/passthrough-error.ts index 7a993255ee3..15deda0b9c4 100644 --- a/src/server/responses/passthrough-error.ts +++ b/src/server/responses/passthrough-error.ts @@ -79,6 +79,8 @@ export function formatPassthroughUpstreamError( statusText?: string; headers?: Headers; now?: number; + /** Caller holds an authoritative fatal verdict; neither inherited nor inferred waits apply. */ + suppressRetryAfter?: boolean; /** * Provenance from the caller that still holds the response: this body is a refusal this * proxy synthesized. The body check below is the fallback for a re-wrapped body, and it @@ -96,7 +98,7 @@ export function formatPassthroughUpstreamError( const replayRefusal = options?.replayRefusal === true || isReplayRefusalBody(trimmed); // Two different reasons to answer with no wait at all, handled the same way: a hard policy // block will not become servable, and a refusal we made was never a rate limit. - const suppressRetryAfter = cyberPolicyFailure || replayRefusal; + const suppressRetryAfter = options?.suppressRetryAfter === true || cyberPolicyFailure || replayRefusal; const resolved = suppressRetryAfter ? undefined : resolveClientRetryAfter({ diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts new file mode 100644 index 00000000000..f8b26d3aedc --- /dev/null +++ b/src/server/responses/terminal-error-redaction.ts @@ -0,0 +1,53 @@ +import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib/redact"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; + +/** Mask the selected outbound credential even when upstream echoes only its raw value. */ +export function createOutboundCredentialMask(outboundHeaders: Record): (text: string) => string { + const knownSecrets = Object.entries(outboundHeaders) + .filter(([name]) => SENSITIVE_KEY_PATTERN.test(name)) + .flatMap(([name, value]) => { + const credential = /^(?:authorization|proxy-authorization)$/i.test(name) + ? value.replace(/^\S+\s+/, "") + : value; + return credential ? [credential] : []; + }) + .sort((a, b) => b.length - a.length); + return (text) => knownSecrets.reduce((safe, secret) => safe.replaceAll(secret, REDACTED_SECRET), text); +} + +/** Mask upstream diagnostics before either SSE delivery or buffered JSON reconstruction. */ +export function createTerminalErrorRedactionBlockRewrite( + outboundHeaders: Record, + maskCredential = createOutboundCredentialMask(outboundHeaders), +): SseBlockRewrite { + const redactDiagnostic = (value: unknown): unknown => { + const safe = redactSecrets(value); + const maskKnown = (entry: unknown): unknown => { + if (typeof entry === "string") return maskCredential(entry); + if (Array.isArray(entry)) return entry.map(maskKnown); + if (entry && typeof entry === "object") { + return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, maskKnown(item)])); + } + return entry; + }; + return maskKnown(safe); + }; + return (block) => { + const terminalFrame = /^event:[ \t]*response\.(?:failed|incomplete)[ \t]*\r?$/m.test(block); + const payload = sseDataPayload(block); + if (payload === null) return [terminalFrame ? maskCredential(block) : block]; + let event: Record; + try { + event = JSON.parse(payload) as Record; + } catch { + return [terminalFrame ? maskCredential(block) : block]; + } + if (event?.type !== "response.failed" && event?.type !== "response.incomplete") { + return [terminalFrame ? maskCredential(block) : block]; + } + const safeEvent = redactDiagnostic(event); + const rewritten = JSON.stringify(safeEvent); + // SSE comments and extension fields can also carry upstream text. + return [maskCredential(rewritten === payload ? block : replaceSseDataPayload(block, rewritten))]; + }; +} diff --git a/structure/decisions/ADR-6162-responses-http-sse.md b/structure/decisions/ADR-6162-responses-http-sse.md new file mode 100644 index 00000000000..1ca6842071a --- /dev/null +++ b/structure/decisions/ADR-6162-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-6162 — decision recorded under "Responses HTTP/SSE" + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Let ordinary OpenAI-compatible clients use non-streaming Responses JSON through a canonical Codex account without weakening terminal correctness. +- 기존 구현 및 제약 조건: The public Responses surface permits omitted or false `stream`, while the canonical ChatGPT Codex endpoint accepts only SSE; passthrough rewrites, continuation publication, usage logging, and account outcomes must remain single-owner effects. +- 검토한 주요 대안: Reject non-streaming clients; teach each integration to request SSE; rebuild JSON independently from deltas; or require SSE upstream and fold the bounded, rewritten terminal snapshot. +- 선택한 방식: Preserve the client preference separately, classify the actual canonical send as streaming for retry/recovery and adapter parsing, require the first terminal and every observed output index to validate under explicit frame/transcript/item-count caps, merge sparse terminal and done items by stable identity/order, then return the complete response object as JSON. Routed compaction remains a JSON client contract but folds the coerced SSE through the adapter before synthesizing its compaction item. The configured stall budget governs first-byte and inter-chunk silence; disabling it falls back to an independent 15-minute buffered-turn ceiling rather than an immediate timeout. A bare upstream `error` retains its bounded message and fatal refusal code, and is recorded/formatted with the same terminal verdict as streaming delivery. +- 다른 대안 대신 이 방식을 선택한 이유: Reusing the existing inspector and rewrite pipeline preserves tool, reasoning, refusal, annotation, usage, model, and continuation semantics that a second delta assembler would inevitably duplicate and drift. +- 장점, 단점 및 영향: Compatible non-streaming clients work without changing `store`; malformed, truncated, stalled, oversized, contradictory, open-index, or rewrite-failed streams return an error rather than partial success. Fatal upstream refusals preserve their code instead of becoming retryable generic 502s. Serving-state/cache/outcome effects wait for raw and rewritten validation. The path retains one raw transcript up to 32 MiB, admits at most 100,000 SSE frames and 10,000 completed items, processes reader chunks in 64 KiB slices, streams the rewritten pass into its assembler, releases raw reconstruction before JSON serialization, and marks request-log metadata as already inspected so the client JSON is not retained and parsed again. The independent 15-minute whole-turn ceiling is intentionally more generous than the public five-minute stall default but means a disabled silence clock is not an unbounded buffered request. This has higher latency and bounded-but-larger memory use than SSE delivery. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3fee43be69d..f61ae5e0590 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -167,7 +167,7 @@ rewrite rules and the routed-id settlement. | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | -| `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | +| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 31f2610de7d..7f03faf0306 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -396,26 +396,15 @@ Native passthrough SSE has TWO shapes, selected per request in inspection side-effect set (shared `createSseInspector` factory in `relay.ts`) including the #44 late-terminal semantics. -Both client readers also retain a bounded, redacted message from a bare upstream -`error` event. If EOF arrives without a real Responses terminal, they synthesize -one `response.failed` with that message instead of replacing it with `adapter_eof`. -That synthesized terminal also carries the upstream's own verdict. Codex classifies -a `response.failed` by `error.code` alone and retries every code outside its fatal -set, so a refusal stamped `upstream_server_error` reached the client as a retryable -disconnect and drove a reconnect loop (#5176). The readers now read a refusal code -and the message from the same candidate precedence, taking the first code present so -a refusal nested below a transient one cannot overrule it, and fall back to -recognized refusal copy only when the event carried no code at all. A refusal code -with no message still produces a terminal, and a read that fails after a refusal was -captured reports the refusal rather than a generic reset. Request-log accounting is -unchanged: a row that ends on a refusal still records the transport-level status. -The delivering reader owns this evidence; an asynchronous tee inspection branch -cannot reliably supply it before EOF. Inspection independently applies the same -bare-error rule when EOF arrives, so account health records failure instead of -clearing avoidance as if the turn had succeeded. Existing real terminals and -caller cancellation retain precedence on both branches. Native recovery preflight -also preserves a rejected body reader and its bounded prefix for the normal -mid-stream failure path; it does not turn that rejection into a decrypt retry. +Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`. +At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code without a message still +produces a terminal. Codex retries codes outside its fatal set, so code and message follow the same candidate precedence; +recognized refusal copy is used only when the event has no code. A read failure after refusal reports that refusal (#5176). +The shared outbound rewrite masks diagnostics on real failed and incomplete terminals before SSE or buffered JSON delivery, +while preserving status and output; failed turns are not retained as continuation state. Buffered JSON masks selected credentials in synthetic bare-error fields before log inspection or client formatting; request logs keep transport status. +The delivering reader owns refusal evidence before EOF; asynchronous tee inspection cannot reliably supply it. +Inspection still applies the bare-error rule at EOF for account health. Real terminals and caller cancellation take precedence. +Native recovery preflight keeps the rejected body reader and bounded prefix for normal mid-stream failure, without decrypt retry. Native Responses may rebuild once when encrypted function/custom-tool output or agent-message content receives the exact known decrypt rejection before output @@ -440,6 +429,17 @@ The two-shape contract is mirror-commented in `src/server/index.ts`; the real and the platform matrix lives in `tests/lib/bun-stream-caps.test.ts`. Keep all three in lockstep with any passthrough-policy change. +A non-streaming canonical ChatGPT client still uses the destination's SSE-only upstream path. +The [Responses HTTP/SSE owner](responses.md#responses-httpsse) validates the first terminal +and strictly covered output indices before publishing JSON or serving state; see [ADR-6162](../decisions/ADR-6162-responses-http-sse.md). +Deferred inspection checks cancellation after each yield and commits serving-route state only +after the final abort check; a disconnect returns 499 without publishing that state or a terminal. +This buffered path makes no tee/eager choice. Failed and incomplete terminals mask selected outbound +credentials across the full event, including nested output and metadata, before JSON or SSE delivery; +synthetic stream failures use the same credential mask in both streaming relays. + +> Decision record: [ADR-6162](../decisions/ADR-6162-responses-http-sse.md) + Canonical ChatGPT forward streaming has one transport-specific exception. A stable Bun runtime at or above 1.4.0 may use Codex's upstream `responses_websockets` transport; bundled Bun 1.3.14, prereleases, and diff --git a/structure/transports/responses.md b/structure/transports/responses.md index bbd5923ce18..46ea7fa69c2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection, terminal accounting, and xAI tool-envelope filtering before continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection, terminal accounting, terminal diagnostic redaction before client delivery, and xAI tool-envelope filtering before continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5c52800bcbc..a4d84857436 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -8,6 +8,7 @@ "low-quota-protection.test.ts": "codex-integration", "provider-antigravity-quota-retry.test.ts": "providers", "responses-compaction-recovery.test.ts": "responses", + "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", diff --git a/tests/helpers/codex-pool-retry.ts b/tests/helpers/codex-pool-retry.ts index 0702ff72ce3..3b8dddc3512 100644 --- a/tests/helpers/codex-pool-retry.ts +++ b/tests/helpers/codex-pool-retry.ts @@ -40,6 +40,7 @@ export function createPoolRetryHarness(context: { callerBearer?: boolean; headers?: Record; extraBody?: Record; + redirect?: RequestRedirect; }) => Promise; restoreFetch: () => void; server: ReturnType; @@ -188,6 +189,7 @@ export function createPoolRetryHarness(context: { callerBearer = true, headers = {}, extraBody = {}, + redirect, } = {}) => originalGlobalFetch(new URL(path, server.url), { method: "POST", headers: { @@ -197,6 +199,7 @@ export function createPoolRetryHarness(context: { }, body: JSON.stringify({ model, input: path.endsWith("/compact") ? [] : "hello", stream, ...extraBody }), signal, + redirect, }), }; } diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 6a9ec1d4ad7..50632431819 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -44,6 +44,8 @@ export const RESPONSES_CORE_MODULES = [ "passthrough-dispatch.ts", "reset-replay.ts", "passthrough-delivery.ts", + "buffered-sse-json.ts", + "terminal-error-redaction.ts", "sidecar-execution.ts", "completion-policy.ts", "run-turn-execution.ts", diff --git a/tests/responses/passthrough-abort.test.ts b/tests/responses/passthrough-abort.test.ts index 5d5818bd6c3..0e264ee6e07 100644 --- a/tests/responses/passthrough-abort.test.ts +++ b/tests/responses/passthrough-abort.test.ts @@ -98,7 +98,7 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(sseBranch).toContain("rewriteBlocks: clientBlockRewrite"); // Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed. expect(sseBranch).toMatch( - /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*\{\s*responseEffects\.responseCompletionCancelled\s*=\s*true;\s*clientGone\.abort\(reason\);\s*\},\s*\{\s*upstreamError:\s*logCtx\.upstreamError,\s*terminalBoundary:\s*codexSafetyBufferingOptions\s*\},\s*\)/, + /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*\{\s*responseEffects\.responseCompletionCancelled\s*=\s*true;\s*clientGone\.abort\(reason\);\s*\},\s*\{\s*upstreamError:\s*logCtx\.upstreamError,\s*terminalBoundary:\s*codexSafetyBufferingOptions,\s*maskCredential\s*\},\s*\)/, ); expect(sseBranch).toContain("new Response(clientBody"); expect(sseBranch).toContain("markNativePassthroughSseResponse"); diff --git a/tests/responses/responses-canonical-nonstream.test.ts b/tests/responses/responses-canonical-nonstream.test.ts new file mode 100644 index 00000000000..913454a29e7 --- /dev/null +++ b/tests/responses/responses-canonical-nonstream.test.ts @@ -0,0 +1,830 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { getDefaultConfig } from "../../src/config"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { handleResponses } from "../../src/server/responses"; +import { expandPreviousResponseInput } from "../../src/responses/state"; +import { setRelayPlatformForTests } from "../../src/server/responses/passthrough-delivery"; +import { + BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + bufferedResponsesReadOptions, + collectBufferedResponsesSse, + inspectBufferedResponsesTerminal, +} from "../../src/server/responses/buffered-sse-json"; +import type { HandleResponsesOptions } from "../../src/server/responses/core"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { repoPath } from "../helpers/repo-root"; + +const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + codexAccountMode: "direct", + upstreamWebsocket: false, +}; + +const config: OcxConfig = { + ...getDefaultConfig(), + port: 0, + defaultProvider: "openai", + providers: { openai: provider }, +}; + +const originalFetch = globalThis.fetch; +let releaseSpendHome: (() => void) | undefined; + +afterEach(() => { + globalThis.fetch = originalFetch; + releaseSpendHome?.(); + releaseSpendHome = undefined; + setRelayPlatformForTests(undefined); +}); + +function requestBody(stream: boolean, store: boolean): Record { + return { + model: "openai/gpt-5.6-sol", + input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }], + stream, + store, + }; +} + +const ENCRYPTED_FUNCTION_OUTPUT = `${Buffer.concat([ + Buffer.from([0x80]), + Buffer.alloc(8), + Buffer.alloc(16), + Buffer.alloc(16), + Buffer.alloc(32), +]).toString("base64url")}==`; + +function encryptedFunctionOutputBody(): Record { + return { + model: "openai/gpt-5.6-sol", + stream: false, + store: false, + input: [ + { type: "function_call", call_id: "call_6162", name: "lookup", arguments: "{}" }, + { + type: "function_call_output", + call_id: "call_6162", + output: [ + { type: "encrypted_content", encrypted_content: ENCRYPTED_FUNCTION_OUTPUT }, + { type: "input_text", text: "visible result" }, + ], + }, + { role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }; +} + +function call( + body: Record, + options: HandleResponsesOptions = {}, + requestConfig: OcxConfig = config, +): Promise { + releaseSpendHome = acquireOwnedSpendHome(); + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer fixture-forward-token", + }, + body: JSON.stringify(body), + }), requestConfig, { model: "", provider: "" }, options); +} + +function sseEvent(type: string, payload: Record): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`; +} + +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)); + const parsed = { + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { model: "gpt-5.6-sol", input: "ping", stream: false, store }, + } as OcxParsedRequest; + const built = adapter.buildRequest(parsed, { headers: new Headers() }); + const outbound = JSON.parse(built.body) as Record; + expect(outbound.stream).toBe(true); + expect(outbound.store).toBe(store); + built.releaseBodyObservation?.(); + }); + + test("forces upstream SSE when the client omitted stream", () => { + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)); + const built = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { model: "gpt-5.6-sol", input: "ping", store: false }, + } as OcxParsedRequest, { headers: new Headers() }); + expect(JSON.parse(built.body)).toMatchObject({ stream: true, store: false }); + built.releaseBodyObservation?.(); + }); + + test("does not coerce a noncanonical forward gateway", () => { + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...provider, + baseUrl: "https://forward.example.test/v1", + })); + const built = adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { model: "routed-model", input: "ping", stream: false, store: true }, + } as OcxParsedRequest, { headers: new Headers() }); + expect(JSON.parse(built.body)).toMatchObject({ stream: false, store: true }); + built.releaseBodyObservation?.(); + }); + + test("missing Content-Type still returns complete JSON with structured output and accounting", async () => { + const terminal = { + id: "resp_nonstream", + object: "response", + created_at: 1_800_000_000, + status: "completed", + model: "gpt-5.6-sol", + store: true, + output: [ + { + type: "reasoning", id: "rs_1", status: "completed", + summary: [{ type: "summary_text", text: "brief rationale" }], + content: [{ type: "reasoning_text", text: "private rationale" }], + encrypted_content: "opaque-reasoning", + }, + { + type: "message", id: "msg_1", status: "completed", role: "assistant", + content: [ + { type: "output_text", text: "answer", annotations: [{ type: "url_citation", url: "https://example.test", title: "source", start_index: 0, end_index: 6 }] }, + { type: "refusal", refusal: "declined detail" }, + ], + }, + { type: "function_call", id: "fc_1", status: "completed", call_id: "call_1", name: "lookup", arguments: "{\"q\":\"x\"}" }, + { type: "custom_tool_call", id: "ctc_1", status: "completed", call_id: "call_2", name: "patch", input: "*** Begin Patch" }, + ], + usage: { + input_tokens: 17, + output_tokens: 9, + total_tokens: 26, + input_tokens_details: { cached_tokens: 3 }, + output_tokens_details: { reasoning_tokens: 4 }, + subscription: { window: "fixture" }, + }, + }; + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + return new Response(new TextEncoder().encode([ + sseEvent("response.created", { response: { ...terminal, status: "in_progress", output: [] } }), + ...terminal.output.map((item, output_index) => sseEvent("response.output_item.done", { output_index, item })), + // Canonical streams may leave the terminal output sparse; the shared inspector must + // reconstruct every structured item from output_item.done without losing terminal fields. + sseEvent("response.completed", { response: { ...terminal, output: [] } }), + "data: [DONE]\n\n", + ].join("")), { headers: { "x-codex-turn-id": "turn-6162" } }); + }) as typeof fetch; + const terminals: string[] = []; + const completedModels: string[] = []; + + const response = await call(requestBody(false, true), { + onNativePassthroughTerminal: status => terminals.push(status), + onResponseComplete: model => completedModels.push(model), + }); + + expect(outbound?.stream).toBe(true); + expect(outbound?.store).toBe(true); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("x-codex-turn-id")).toBe("turn-6162"); + expect(await response.json()).toEqual(terminal); + expect(terminals).toEqual(["completed"]); + expect(completedModels).toEqual(["gpt-5.6-sol"]); + }); + + test("omitted stream is negotiated as JSON end-to-end while upstream receives true", async () => { + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + return new Response(sseEvent("response.completed", { response: { + id: "resp_omitted", status: "completed", model: "gpt-5.6-sol", output: [], + } }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const body = requestBody(false, false); + delete body.stream; + + const response = await call(body); + + expect(outbound?.stream).toBe(true); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toMatchObject({ id: "resp_omitted", status: "completed", output: [] }); + }); + + test.each([false, true])("redacts echoed pooled bearer in failed terminal for stream:%s", async stream => { + const bearer = "fixture-pooled-bearer-123456789"; + const failed = { + id: `resp_failed_secret_${stream}`, + status: "failed", + output: [{ + type: "message", id: "msg_secret_echo", status: "completed", role: "assistant", + content: [{ type: "output_text", text: "fixture-forward-token", annotations: [] }], + }], + error: { type: "server_error", code: "upstream_error", message: `upstream saw Bearer ${bearer}` }, + last_error: { + message: `Authorization: Bearer ${bearer}`, + detail: "raw echo fixture-forward-token", + }, + metadata: { diagnostic: "selected fixture-forward-token" }, + }; + globalThis.fetch = (async () => new Response(`: fixture-forward-token\n${sseEvent("response.failed", { + response: failed, detail: "outer fixture-forward-token", + })}`, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + + const response = await call(requestBody(stream, true)); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).not.toContain(bearer); + expect(text).not.toContain("fixture-forward-token"); + expect(text).toContain("[REDACTED]"); + if (stream) { + expect(text).toContain("event: response.failed"); + expect(text).toContain("[REDACTED]"); + expect(text).not.toContain(": fixture-forward-token"); + } else { + const json = JSON.parse(text) as typeof failed; + expect(json).toMatchObject({ id: failed.id, status: "failed", error: { + type: "server_error", code: "upstream_error", message: "upstream saw Bearer [REDACTED]", + } }); + expect(json.last_error.message).toContain("[REDACTED]"); + } + const next = { model: "openai/gpt-5.6-sol", previous_response_id: failed.id, input: "retry" }; + expect(expandPreviousResponseInput(next)).toEqual(next); + }); + + test("missing Content-Type keeps canonical non-stream opaque-state recovery on the SSE path", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input, init) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(new TextEncoder().encode(sseEvent("response.failed", { response: { + id: "resp_ciphertext_rejected", + status: "failed", + output: [], + error: { + message: "Encrypted function output content could not be decrypted or decoded.", + type: "server_error", + code: null, + }, + } }))); + } + return new Response(new TextEncoder().encode(sseEvent("response.completed", { response: { + id: "resp_ciphertext_recovered", + status: "completed", + model: "gpt-5.6-sol", + output: [], + } }))); + }) as typeof fetch; + + const response = await call(encryptedFunctionOutputBody()); + const json = await response.json() as Record; + + expect(response.status).toBe(200); + expect(json).toMatchObject({ id: "resp_ciphertext_recovered", status: "completed" }); + expect(outbound).toHaveLength(2); + expect(outbound.map(body => body.stream)).toEqual([true, true]); + expect(JSON.stringify(outbound[0])).toContain("encrypted_content"); + expect(JSON.stringify(outbound[1])).not.toContain("encrypted_content"); + expect(JSON.stringify(outbound[1])).toContain("[encrypted content omitted]"); + }); + + test("missing Content-Type keeps canonical non-stream pre-output reset recovery on the SSE path", async () => { + const outbound: Array> = []; + const created = new TextEncoder().encode(sseEvent("response.created", { response: { + id: "resp_reset_first", + status: "in_progress", + output: [], + } })); + globalThis.fetch = (async (_input, init) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + let emitted = false; + return new Response(new ReadableStream({ + pull(controller) { + if (!emitted) { + emitted = true; + controller.enqueue(created); + return; + } + controller.error(Object.assign(new Error("fixture body reset before output"), { code: "ECONNRESET" })); + }, + })); + } + return new Response(new TextEncoder().encode(sseEvent("response.completed", { response: { + id: "resp_reset_recovered", + status: "completed", + model: "gpt-5.6-sol", + output: [], + } }))); + }) as typeof fetch; + + const resetConfig: OcxConfig = { + ...config, + providers: { openai: { ...provider, retryOnReset: {} } }, + }; + const response = await call(requestBody(false, false), {}, resetConfig); + const json = await response.json() as Record; + + expect(response.status, JSON.stringify(json)).toBe(200); + expect(json).toMatchObject({ id: "resp_reset_recovered", status: "completed" }); + expect(outbound).toHaveLength(2); + expect(outbound.map(body => body.stream)).toEqual([true, true]); + }); + + test.each([ + ["clean EOF", sseEvent("response.created", { response: { id: "resp_eof", status: "in_progress", output: [] } })], + ["malformed payload", [ + "data: {not-json}\n\n", + sseEvent("response.completed", { response: { id: "resp_bad", status: "completed", output: [] } }), + ].join("")], + ])("fails closed on %s instead of returning partial HTTP 200 JSON", async (_name, body) => { + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const terminals: string[] = []; + + const response = await call(requestBody(false, false), { + onNativePassthroughTerminal: status => terminals.push(status), + }); + const error = await response.json() as { error?: { type?: string; message?: string } }; + + expect(outbound?.stream).toBe(true); + expect(outbound?.store).toBe(false); + expect(response.status).toBe(502); + expect(error.error?.type).toBe("server_error"); + expect(error.error?.message).toContain("valid terminal response"); + expect(terminals).toEqual(["failed"]); + }); + + test("client abort returns 499 without terminal or completion effects", async () => { + const abort = new AbortController(); + let cancelled = 0; + globalThis.fetch = (async () => { + setTimeout(() => abort.abort(new Error("fixture client gone")), 0); + return new Response(new ReadableStream({ + pull: () => new Promise(() => {}), + cancel: () => { cancelled += 1; }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const terminals: string[] = []; + const completedModels: string[] = []; + let nativeCancels = 0; + + const response = await call(requestBody(false, false), { + abortSignal: abort.signal, + onNativePassthroughTerminal: status => terminals.push(status), + onResponseComplete: model => completedModels.push(model), + onNativePassthroughCancel: () => { nativeCancels += 1; }, + }); + + expect(response.status).toBe(499); + expect(terminals).toEqual([]); + expect(completedModels).toEqual([]); + expect(nativeCancels).toBe(1); + expect(cancelled).toBe(1); + }); + + test("client abort during deferred replay yields returns 499 without publishing completion", async () => { + const abort = new AbortController(); + const item = { + type: "message", id: "msg_disconnect", status: "completed", role: "assistant", + content: [{ type: "output_text", text: "done", annotations: [] }], + }; + const transcript = [ + ...Array.from({ length: 14_000 }, () => sseEvent("response.output_text.delta", { + output_index: 0, item_id: "msg_disconnect", delta: "x", + })), + sseEvent("response.output_item.done", { output_index: 0, item }), + sseEvent("response.completed", { response: { + id: "resp_disconnected", status: "completed", model: "gpt-5.6-sol", output: [item], + } }), + ].join(""); + expect(new TextEncoder().encode(transcript).byteLength).toBeGreaterThan(1024 * 1024); + globalThis.fetch = (async () => new Response(transcript, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + const terminals: string[] = []; + const completedModels: string[] = []; + let nativeCancels = 0; + let firstOutputs = 0; + + const response = await call(requestBody(false, true), { + abortSignal: abort.signal, + onFirstOutput: () => { + firstOutputs += 1; + abort.abort(new Error("fixture client gone")); + }, + onNativePassthroughTerminal: status => terminals.push(status), + onResponseComplete: model => completedModels.push(model), + onNativePassthroughCancel: () => { nativeCancels += 1; }, + }); + + expect(firstOutputs).toBe(1); + expect(abort.signal.aborted).toBe(true); + expect(response.status).toBe(499); + expect(terminals).toEqual([]); + expect(completedModels).toEqual([]); + expect(nativeCancels).toBe(1); + }); + + test.each(["darwin", "win32"] as const)("redacts selected bearer in synthetic %s streaming failure", async platform => { + setRelayPlatformForTests(platform); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(controller) { controller.error(new Error("reset after fixture-forward-token")); }, + }), { headers: { "content-type": "text/event-stream" } })) as typeof fetch; + const response = await call(requestBody(true, false)); + const text = await response.text(); + expect(text).toContain("response.failed"); + expect(text).toContain("[REDACTED]"); + expect(text).not.toContain("fixture-forward-token"); + }); + + test("canonical buffered serving-state commit is ordered after both validations", () => { + const source = readFileSync(repoPath("src/server/responses/passthrough-delivery.ts"), "utf8"); + const branch = source.indexOf("if (canonicalBufferedJson) {"); + const rawValidation = source.indexOf("if (!raw.ok)", branch); + const clientValidation = source.indexOf("if (!client.ok)", rawValidation); + const deferredCommit = source.indexOf("commitReasoningReplayServingRoute(nativeExchange.request.headers);", clientValidation); + const finalAbortCheck = source.lastIndexOf("if (signal.aborted) return cancelAfterValidation();", deferredCommit); + expect(branch).toBeGreaterThan(-1); + expect(rawValidation).toBeGreaterThan(branch); + expect(clientValidation).toBeGreaterThan(rawValidation); + expect(deferredCommit).toBeGreaterThan(clientValidation); + expect(finalAbortCheck).toBeGreaterThan(source.indexOf("effectInspector.finish();", clientValidation)); + expect(deferredCommit).toBeGreaterThan(finalAbortCheck); + expect(source.slice(branch, deferredCommit)).not.toContain("commitReasoningReplayServingRoute("); + }); + + test("leaves stream:true callers on the SSE relay", async () => { + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + return new Response(sseEvent("response.completed", { + response: { id: "resp_stream", status: "completed", model: "gpt-5.6-sol", output: [] }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + const response = await call(requestBody(true, false)); + expect(outbound?.stream).toBe(true); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toContain("response.completed"); + }); + + test.each([ + ["read error", () => new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseEvent("response.created", { + response: { id: "resp_read", status: "in_progress", output: [] }, + }))); + controller.error(new Error("fixture reset")); + }, + })], + ["oversized frame", () => new Response( + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "x".repeat(4 * 1024 * 1024) })}\n\n`, + ).body!], + ])("bounded collector rejects a %s before terminal publication", async (_name, source) => { + const upstream = new AbortController(); + const result = await collectBufferedResponsesSse(source(), upstream); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.kind).toBe(_name === "oversized frame" ? "oversized" : "read_error"); + }); + + test("bounded collector applies its first-byte deadline to a silent SSE body", async () => { + const source = new ReadableStream({ pull: () => new Promise(() => {}) }); + const result = await collectBufferedResponsesSse(source, new AbortController(), { + read: { firstByteTimeoutMs: 5, inactivityTimeoutMs: 5, totalTimeoutMs: 20 }, + }); + expect(result).toMatchObject({ ok: false, kind: "timeout" }); + }); + + test("disabled stall budget falls back to the independent buffered-turn ceiling", async () => { + const startedAt = 1_000; + expect(bufferedResponsesReadOptions(0, BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, startedAt)).toEqual({ + deadlineAt: startedAt + BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + firstByteTimeoutMs: BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + inactivityTimeoutMs: BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + totalTimeoutMs: BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + }); + expect(bufferedResponsesReadOptions(2_500, BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, startedAt)).toEqual({ + deadlineAt: startedAt + BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + firstByteTimeoutMs: 2_500, + inactivityTimeoutMs: 2_500, + totalTimeoutMs: BUFFERED_RESPONSES_TOTAL_TIMEOUT_MS, + }); + + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { + controller.enqueue(new TextEncoder().encode(sseEvent("response.completed", { + response: { id: "resp_delayed", status: "completed", output: [] }, + }))); + controller.close(); + }, 10); + }, + }); + const result = await collectBufferedResponsesSse(body, new AbortController(), { + read: bufferedResponsesReadOptions(0, 100), + }); + expect(result).toMatchObject({ ok: true, terminal: { status: "completed" } }); + + // A later validation pass must inherit the original absolute deadline instead of receiving + // a fresh totalTimeoutMs window merely because it constructed a new collector. + const expiredSharedRead = bufferedResponsesReadOptions(0, 100, Date.now() - 200); + const expired = await collectBufferedResponsesSse( + new Response(sseEvent("response.completed", { + response: { id: "resp_too_late", status: "completed", output: [] }, + })).body!, + new AbortController(), + { read: expiredSharedRead }, + ); + expect(expired).toMatchObject({ ok: false, kind: "timeout" }); + }); + + test("bare upstream refusal keeps its message, code, and non-retryable status", async () => { + globalThis.fetch = (async () => new Response(sseEvent("error", { + error: { + type: "invalid_request_error", + code: "invalid_prompt", + message: "The upstream rejected this prompt. Please try again in 120s.", + }, + }), { headers: { "content-type": "text/event-stream", "retry-after": "120" } })) as typeof fetch; + const terminals: string[] = []; + + const response = await call(requestBody(false, false), { + onNativePassthroughTerminal: status => terminals.push(status), + }); + + expect(response.status).toBe(400); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("retry-after")).toBeNull(); + expect(await response.json()).toEqual({ + error: { + type: "invalid_request_error", + code: "invalid_prompt", + message: "The upstream rejected this prompt. Please try again in 120s.", + }, + retryable: false, + }); + expect(terminals).toEqual(["failed"]); + }); + + test("bare non-refusal error copy cannot relabel a transport failure or account outcome", async () => { + globalThis.fetch = (async () => new Response(sseEvent("error", { + error: { + type: "upstream_error", + code: "upstream_reset", + message: "invalid api key while forwarding the upstream reset", + }, + }), { headers: { "content-type": "text/event-stream" } })) as typeof fetch; + const terminals: string[] = []; + + const response = await call(requestBody(false, false), { + onNativePassthroughTerminal: status => terminals.push(status), + }); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: { + type: "upstream_error", + code: "upstream_server_error", + message: "invalid api key while forwarding the upstream reset", + }, + }); + expect(terminals).toEqual(["failed"]); + }); + + test("bare structured rate-limit error preserves its authoritative family", async () => { + globalThis.fetch = (async () => new Response(sseEvent("error", { + code: "rate_limit_exceeded", + message: "Rate limit exceeded.", + }), { headers: { "content-type": "text/event-stream" } })) as typeof fetch; + const terminals: string[] = []; + + const response = await call(requestBody(false, false), { + onNativePassthroughTerminal: status => terminals.push(status), + }); + + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ + error: { + type: "rate_limit_error", + code: "rate_limit_exceeded", + message: "Rate limit exceeded.", + }, + }); + expect(terminals).toEqual(["failed"]); + }); + + test("bare structured overload keeps its server class and 503 status", async () => { + globalThis.fetch = (async () => new Response(sseEvent("error", { + error: { + type: "server_error", + code: "server_is_overloaded", + message: "The upstream is overloaded.", + }, + }), { headers: { "content-type": "text/event-stream" } })) as typeof fetch; + + const response = await call(requestBody(false, false)); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: { + type: "server_error", + code: "server_is_overloaded", + message: "The upstream is overloaded.", + }, + }); + }); + + test("incomplete terminals retain already-finished output items and terminal usage", () => { + const partial = { + type: "message", id: "msg_partial", status: "incomplete", role: "assistant", + content: [{ type: "output_text", text: "partial", annotations: [] }], + }; + const inspected = inspectBufferedResponsesTerminal([ + sseEvent("response.output_item.done", { output_index: 0, item: partial }), + sseEvent("response.incomplete", { response: { + id: "resp_partial", + status: "incomplete", + output: [], + incomplete_details: { reason: "max_output_tokens" }, + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + } }), + ].join("")); + expect(inspected).toEqual({ + ok: true, + terminal: { + status: "incomplete", + response: { + id: "resp_partial", + status: "incomplete", + output: [partial], + incomplete_details: { reason: "max_output_tokens" }, + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }, + }, + }); + }); + + test("comment-only SSE heartbeats do not taint a valid terminal", () => { + const inspected = inspectBufferedResponsesTerminal([ + ": upstream heartbeat\n\n", + sseEvent("response.completed", { + response: { id: "resp_heartbeat", status: "completed", output: [] }, + }), + ].join("")); + expect(inspected).toEqual({ + ok: true, + terminal: { + status: "completed", + response: { id: "resp_heartbeat", status: "completed", output: [] }, + }, + }); + }); + + test("merges a partial nonempty terminal with later done items without loss", () => { + const first = { type: "message", id: "msg_0", status: "completed", role: "assistant", content: [] }; + const second = { type: "function_call", id: "fc_1", status: "completed", call_id: "call_1", name: "lookup", arguments: "{}" }; + const inspected = inspectBufferedResponsesTerminal([ + sseEvent("response.output_item.done", { output_index: 1, item: second }), + sseEvent("response.completed", { response: { + id: "resp_merge", status: "completed", output: [first], + } }), + ].join("")); + expect(inspected).toMatchObject({ ok: true, terminal: { response: { output: [first, second] } } }); + }); + + test("merges an identity-matched sparse terminal item without shifting output indices", () => { + const first = { type: "reasoning", id: "rs_0", summary: [] }; + const second = { type: "message", id: "msg_1", status: "completed", role: "assistant", content: [] }; + const inspected = inspectBufferedResponsesTerminal([ + sseEvent("response.output_item.done", { output_index: 0, item: first }), + sseEvent("response.output_item.done", { output_index: 1, item: second }), + sseEvent("response.completed", { response: { + id: "resp_sparse", status: "completed", output: [second], + } }), + ].join("")); + expect(inspected).toMatchObject({ ok: true, terminal: { response: { output: [first, second] } } }); + }); + + test("rejects contradictory duplicate done events for one output index", () => { + const first = { type: "message", id: "msg_a", status: "completed", role: "assistant", content: [] }; + const conflicting = { ...first, id: "msg_b" }; + expect(inspectBufferedResponsesTerminal([ + sseEvent("response.output_item.done", { output_index: 0, item: first }), + sseEvent("response.output_item.done", { output_index: 0, item: conflicting }), + sseEvent("response.completed", { response: { id: "resp_duplicate", status: "completed", output: [] } }), + ].join(""))).toMatchObject({ ok: false, kind: "malformed" }); + }); + + test("out-of-order done items are sorted when their indices are contiguous", () => { + const zero = { type: "message", id: "msg_0", status: "completed", role: "assistant", content: [] }; + const one = { type: "reasoning", id: "rs_1", summary: [] }; + const inspected = inspectBufferedResponsesTerminal([ + sseEvent("response.output_item.done", { output_index: 1, item: one }), + sseEvent("response.output_item.done", { output_index: 0, item: zero }), + sseEvent("response.completed", { response: { id: "resp_order", status: "completed", output: [] } }), + ].join("")); + expect(inspected).toMatchObject({ ok: true, terminal: { response: { output: [zero, one] } } }); + }); + + test.each([ + ["index gap", sseEvent("response.output_item.done", { + output_index: 1, + item: { type: "message", id: "msg_1", status: "completed", role: "assistant", content: [] }, + })], + ["configured item cap", sseEvent("response.output_item.done", { + output_index: 10_000, + item: { type: "message", id: "msg_cap", status: "completed", role: "assistant", content: [] }, + })], + ])("fails closed when reconstruction is tainted by %s", (_name, itemFrame) => { + const inspected = inspectBufferedResponsesTerminal(itemFrame + sseEvent("response.completed", { + response: { id: "resp_tainted", status: "completed", output: [] }, + })); + expect(inspected).toMatchObject({ ok: false, kind: "malformed" }); + }); + + test("rejects a terminal whose response status disagrees with its event", () => { + expect(inspectBufferedResponsesTerminal(sseEvent("response.completed", { + response: { id: "resp_mismatch", status: "incomplete", output: [] }, + }))).toMatchObject({ ok: false, kind: "malformed" }); + }); + + test("derives a missing response status from the terminal event", () => { + expect(inspectBufferedResponsesTerminal(sseEvent("response.failed", { + response: { id: "resp_failed", error: { code: "tool_not_allowed", message: "blocked" } }, + }))).toMatchObject({ + ok: true, + terminal: { status: "failed", response: { status: "failed", output: [] } }, + }); + }); + + test.each(["response.output_text.delta", "response.function_call_arguments.delta"])( + "rejects sparse completion after %s without output_item.done", + type => { + const delta = type === "response.output_text.delta" + ? { output_index: 0, item_id: "msg_0", delta: "partial" } + : { output_index: 0, item_id: "fc_0", call_id: "call_0", delta: "{\"q\":" }; + expect(inspectBufferedResponsesTerminal([ + sseEvent(type, delta), + sseEvent("response.completed", { response: { id: "resp_open", status: "completed", output: [] } }), + ].join(""))).toMatchObject({ ok: false, kind: "malformed" }); + }, + ); + + test("an authoritative terminal item covers an index whose delta had no done event", () => { + const item = { + type: "message", id: "msg_terminal", status: "completed", role: "assistant", + content: [{ type: "output_text", text: "whole", annotations: [] }], + }; + expect(inspectBufferedResponsesTerminal([ + sseEvent("response.output_text.delta", { output_index: 0, item_id: "msg_terminal", delta: "whole" }), + sseEvent("response.completed", { response: { id: "resp_covered", status: "completed", output: [item] } }), + ].join(""))).toMatchObject({ ok: true, terminal: { response: { output: [item] } } }); + }); + + test("aggregate input budget rejects one large multi-frame network chunk before terminal", async () => { + const body = new Response(": keepalive\n\n".repeat(200)).body!; + const result = await collectBufferedResponsesSse(body, new AbortController(), { + read: { maxBytes: 1024, firstByteTimeoutMs: 100, inactivityTimeoutMs: 100, totalTimeoutMs: 100 }, + }); + expect(result).toMatchObject({ ok: false, kind: "oversized" }); + }); + + test("aggregate frame budget rejects tiny-event amplification across reader chunks", async () => { + const bytes = new TextEncoder().encode(": heartbeat\n\n".repeat(4) + + sseEvent("response.completed", { response: { id: "resp_late", status: "completed", output: [] } })); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 28)); + controller.enqueue(bytes.subarray(28)); + controller.close(); + }, + }); + const result = await collectBufferedResponsesSse(body, new AbortController(), { + read: { maxFrames: 3, firstByteTimeoutMs: 100, inactivityTimeoutMs: 100, totalTimeoutMs: 100 }, + }); + expect(result).toMatchObject({ ok: false, kind: "oversized" }); + }); +}); diff --git a/tests/responses/responses-pool-401-refresh.test.ts b/tests/responses/responses-pool-401-refresh.test.ts index 2fc4409c130..c6a6f0450bc 100644 --- a/tests/responses/responses-pool-401-refresh.test.ts +++ b/tests/responses/responses-pool-401-refresh.test.ts @@ -8,6 +8,7 @@ import { codexPoolAffinityKey } from "../../src/codex/auth-context"; import { clearCodexUpstreamHealth, clearThreadAccountMap, + peekConversationStateIssuer, resolveCodexAccountForThreadDetailed, } from "../../src/codex/routing"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; @@ -20,6 +21,7 @@ import { import { clearResponseStateForTests, clearResponseStateMemoryForTests, + expandPreviousResponseInput, responseContinuationRetainedStoreSnapshot, runPendingResponseStatePersistForTests, } from "../../src/responses/state"; @@ -339,6 +341,92 @@ afterEach(() => { }); describe("ordinary pool 401 refresh and replay (#2887)", () => { + test.each([false, true])("failed terminal cannot echo the selected pool credential or retain it for replay (stream:%s)", async stream => { + const harness = installHarness({ + responseForSend(authorization) { + return new Response( + `event: response.failed\ndata: ${JSON.stringify({ + type: "response.failed", + response: { + id: "resp_failed_pool_echo", status: "failed", + error: { type: "server_error", code: "upstream_error", message: `rejected ${authorization}` }, + last_error: { detail: "raw token rejected-access" }, + metadata: { diagnostic: "raw token rejected-access" }, + output: [{ + type: "message", id: "msg_pool_echo", status: "completed", role: "assistant", + content: [{ type: "output_text", text: "rejected-access", annotations: [] }], + }], + }, + detail: "raw token rejected-access", + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const response = await handleResponses( + request("/v1/responses", { stream }), config(), { model: "", provider: "" } as RequestLogContext, + ); + const body = await response.text(); + expect(harness.sends).toEqual(["Bearer rejected-access"]); + expect(response.status).toBe(200); + if (stream) expect(body).toContain("event: response.failed"); + else expect(JSON.parse(body)).toMatchObject({ id: "resp_failed_pool_echo", status: "failed" }); + expect(body).not.toContain("rejected-access"); + expect(body).toContain("[REDACTED]"); + const next = { model: "gpt-5.5", previous_response_id: "resp_failed_pool_echo", input: "retry" }; + expect(expandPreviousResponseInput(next)).toEqual(next); + }); + + test("bare upstream SSE error masks the selected pool credential in buffered JSON and diagnostics", async () => { + const harness = installHarness({ + responseForSend(authorization) { + return new Response(`event: error\ndata: ${JSON.stringify({ + type: "error", + error: { type: "server_error", code: "upstream_error", message: `rejected ${authorization.slice(7)}` }, + })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }, + }); + const logCtx = { model: "", provider: "" } as RequestLogContext; + const response = await handleResponses(request("/v1/responses"), config(), logCtx); + const body = await response.text(); + expect(harness.sends).toEqual(["Bearer rejected-access"]); + expect(response.status).toBe(502); + expect(body).not.toContain("rejected-access"); + expect(body).toContain("[REDACTED]"); + expect(JSON.stringify(logCtx)).not.toContain("rejected-access"); + }); + + test("disconnect during buffered replay does not record a pool conversation-state issuer", async () => { + const abort = new AbortController(); + const headers = { "thread-id": "fixture-pool-disconnect", "x-codex-parent-thread-id": "fixture-pool-parent" }; + const affinityKey = codexPoolAffinityKey(new Headers(headers)); + expect(affinityKey).toBeDefined(); + installHarness({ + responseForSend() { + const delta = `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", output_index: 0, item_id: "msg_pool_disconnect", delta: "x", + })}\n\n`; + const item = { + type: "message", id: "msg_pool_disconnect", status: "completed", role: "assistant", + content: [{ type: "output_text", text: "done", annotations: [] }], + }; + return new Response([ + ...Array.from({ length: 14_000 }, () => delta), + `event: response.output_item.done\ndata: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { + id: "resp_pool_disconnect", status: "completed", model: "gpt-5.5", output: [item], + } })}\n\n`, + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + const response = await handleResponses( + request("/v1/responses", { headers }), config(), { model: "", provider: "" } as RequestLogContext, + { abortSignal: abort.signal, onFirstOutput: () => setTimeout(() => abort.abort(), 0) }, + ); + expect(response.status).toBe(499); + expect(peekConversationStateIssuer(affinityKey!)).toBeUndefined(); + }); + test("Responses refreshes a time-valid stored credential once and replays the same account", async () => { const harness = installHarness(); const response = await handleResponses( diff --git a/tests/responses/responses-preview-main-read-fence.test.ts b/tests/responses/responses-preview-main-read-fence.test.ts index 08008b6c1ae..f84b52ec615 100644 --- a/tests/responses/responses-preview-main-read-fence.test.ts +++ b/tests/responses/responses-preview-main-read-fence.test.ts @@ -4,11 +4,13 @@ import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { resolveCodexAuthContext, type CodexAuthContext, } from "../../src/codex/auth-context"; import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { resetCodexModelEntitlementCacheForTests, seedCodexModelEntitlementsForTests, @@ -38,6 +40,7 @@ import type { OcxConfig } from "../../src/types"; import { codexHeaders, encryptedInput, + fakeChatGptJwt, recoverySse, } from "../helpers/agent-task-recovery"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -59,6 +62,9 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const NOW = 1_800_000_000_000; const PREFERRED_MODEL = "gpt-5.6-sol"; const FALLBACK_MODEL = "xai/grok-4.5"; +const POOL_ACCESS_TOKEN = fakeChatGptJwt("pool-account", { + exp: Math.floor((NOW + 24 * 60 * 60_000) / 1_000), +}); const originalFetch = globalThis.fetch; const originalNow = Date.now; @@ -108,7 +114,7 @@ function installCredentials(): void { }, })); saveCodexAccountCredential("pool-a", { - accessToken: "pool-access-token", + accessToken: POOL_ACCESS_TOKEN, refreshToken: "pool-refresh-token", expiresAt: NOW + 24 * 60 * 60_000, chatgptAccountId: "pool-account", @@ -166,6 +172,21 @@ function completedResponses(model = PREFERRED_MODEL): Response { }); } +/** Canonical ChatGPT always answers the adapter-forced upstream stream with SSE. */ +function completedCanonicalSse(model = PREFERRED_MODEL): Response { + const response = { + id: "resp_main_read_fence", + object: "response", + status: "completed", + model, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }; + return new Response(`data: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); +} + function readableInput(): unknown[] { return [{ type: "message", @@ -213,6 +234,8 @@ beforeEach(() => { clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); clearComboSelectionState(); clearComboTargetCooldowns(); clearResponseStateMemoryForTests(); @@ -245,6 +268,8 @@ afterEach(() => { clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); clearComboSelectionState(); clearComboTargetCooldowns(); clearResponseStateForTests(); @@ -269,6 +294,9 @@ describe("preview and final authentication agree on the native-main read fence", const owned = await resolveCodexAuthContext(codexHeaders("caller-account"), config, "pool", { requestScopedMainCredential: true, modelId: PREFERRED_MODEL, + // This unit observes auth fencing, not the detached quota-prime worker. Without the seam, + // the unowned control below can keep reading into the next case after its assertion ends. + primeCodexPoolQuotas: async () => {}, }); expect(owned).toMatchObject({ kind: "pool", accountId: "pool-a" }); expect(authJsonReads).toBe(0); @@ -278,6 +306,7 @@ describe("preview and final authentication agree on the native-main read fence", await resolveCodexAuthContext(new Headers(), config, "pool", { requestScopedMainCredential: true, modelId: PREFERRED_MODEL, + primeCodexPoolQuotas: async () => {}, }); expect(authJsonReads).toBeGreaterThan(0); }); @@ -286,15 +315,18 @@ describe("preview and final authentication agree on the native-main read fence", seedMainDenial(); calibrateMainReadCounter(); const upstreamAuth: Array = []; - globalThis.fetch = (async (_input, init) => { - upstreamAuth.push(new Headers(init?.headers).get("authorization")); - return completedResponses(); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname.endsWith("/responses")) { + upstreamAuth.push(new Headers(init?.headers).get("authorization")); + } + return completedCanonicalSse(); }) as typeof fetch; const response = await postSpawn(providerConfig()); expect(response.status).toBe(200); - expect(upstreamAuth).toEqual(["Bearer pool-access-token"]); + expect(upstreamAuth).toEqual([`Bearer ${POOL_ACCESS_TOKEN}`]); expect(denialCacheMainReadStacks()).toEqual([]); }); @@ -313,15 +345,18 @@ describe("preview and final authentication agree on the native-main read fence", seedMainDenial(); calibrateMainReadCounter(); const upstreamAuth: Array = []; - globalThis.fetch = (async (_input, init) => { - upstreamAuth.push(new Headers(init?.headers).get("authorization")); - return completedResponses(); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname.endsWith("/responses")) { + upstreamAuth.push(new Headers(init?.headers).get("authorization")); + } + return completedCanonicalSse(); }) as typeof fetch; const response = await postSpawn(providerConfig()); expect(response.status).toBe(200); - expect(upstreamAuth).toEqual(["Bearer pool-access-token"]); + expect(upstreamAuth).toEqual([`Bearer ${POOL_ACCESS_TOKEN}`]); expect(authJsonReadStacks).toEqual([]); expect(authJsonReads).toBe(0); }); @@ -329,7 +364,7 @@ describe("preview and final authentication agree on the native-main read fence", test("the initial preview also fences main for recovery blocking and selector drain", async () => { seedMainDenial(); calibrateMainReadCounter(); - globalThis.fetch = (async () => completedResponses()) as typeof fetch; + globalThis.fetch = (async () => completedCanonicalSse()) as typeof fetch; const snapshot = nativeMainStartupGateSnapshot(); blockedHomeId = snapshot.homeId ?? testDir; @@ -463,7 +498,7 @@ describe("preview and final authentication agree on the native-main read fence", upstreamUrls.push(String(input)); upstreamBodies.push(typeof init?.body === "string" ? init.body : ""); upstreamAuth.push(new Headers(init?.headers).get("authorization")); - return completedResponses(); + return completedCanonicalSse(); }) as typeof fetch; let finalAuth: CodexAuthContext | undefined; const logCtx: RequestLogContext = { model: "", provider: "" }; @@ -485,7 +520,7 @@ describe("preview and final authentication agree on the native-main read fence", expect((logCtx as unknown as Record).subagentModelFallbackTo).toBeUndefined(); // The caller's own bearer is what main is served from. Neither stored credential may appear, // and the denial-cache validator -- the one read this file exists to fence -- never ran. - expect(upstreamAuth[0]).not.toBe("Bearer pool-access-token"); + expect(upstreamAuth[0]).not.toBe(`Bearer ${POOL_ACCESS_TOKEN}`); expect(upstreamAuth[0]).not.toBe("Bearer physical-main-token"); expect(denialCacheMainReadStacks()).toEqual([]); }); @@ -518,12 +553,6 @@ describe("preview and final authentication agree on the native-main read fence", expect((logCtx as unknown as Record).subagentModelFallbackTo).toBe(FALLBACK_MODEL); }); - // The two cases below are last on purpose. Both let a request reach native main, and observing - // a main credential writes module state in `main-account-cache.ts` that no reset helper in this - // file clears -- `beforeEach` rebuilds `OPENCODEX_HOME` and the read counters, not that cache. - // Running them earlier made the recovery/drain case above see three reads it does not make on - // its own. Keep read-count assertions ahead of them. - /** * The other half of #4850, and the reason the seam is scoped to * `previewRequestScopedMainCredential` instead of being applied to every preview. A fix that @@ -534,7 +563,7 @@ describe("preview and final authentication agree on the native-main read fence", test("a preview that owns no credential still probes physical main liveness", async () => { seedMainDenial(); calibrateMainReadCounter(); - globalThis.fetch = (async () => completedResponses()) as typeof fetch; + globalThis.fetch = (async () => completedCanonicalSse()) as typeof fetch; const response = await postSpawn(providerConfig(), {}, new Headers()); @@ -570,7 +599,7 @@ describe("preview and final authentication agree on the native-main read fence", globalThis.fetch = (async (_input, init) => { upstreamAuth.push(new Headers(init?.headers).get("authorization")); upstreamBodies.push(typeof init?.body === "string" ? init.body : ""); - return completedResponses(); + return completedCanonicalSse(); }) as typeof fetch; let finalAuth: CodexAuthContext | undefined; const logCtx: RequestLogContext = { model: "", provider: "" }; @@ -589,7 +618,7 @@ describe("preview and final authentication agree on the native-main read fence", expect(upstreamBodies[0]).toContain(`"model":"${PREFERRED_MODEL}"`); expect((logCtx as unknown as Record).subagentModelFallbackTo).toBeUndefined(); // The caller's own bearer is forwarded. Neither stored credential may appear. - expect(upstreamAuth[0]).not.toBe("Bearer pool-access-token"); + expect(upstreamAuth[0]).not.toBe(`Bearer ${POOL_ACCESS_TOKEN}`); expect(upstreamAuth[0]).not.toBe("Bearer physical-main-token"); }); }); diff --git a/tests/responses/sse-failed-tail.test.ts b/tests/responses/sse-failed-tail.test.ts index dfa030dd681..6da1461f5c0 100644 --- a/tests/responses/sse-failed-tail.test.ts +++ b/tests/responses/sse-failed-tail.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test"; import { relaySseWithFailedTail, relayWithAbort } from "../../src/server"; import { relaySseEagerBounded, type EagerRelayHooks } from "../../src/server/relay-eager"; -import { MAX_TAIL_ERROR_MESSAGE_CHARS } from "../../src/server/relay"; +import { MAX_TAIL_ERROR_MESSAGE_CHARS, upstreamErrorTailFrame } from "../../src/server/relay"; import { TERMINAL_REFUSAL_FALLBACK_MESSAGE } from "../../src/lib/errors"; import { TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; +import { createOutboundCredentialMask } from "../../src/server/responses/terminal-error-redaction"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -64,6 +65,26 @@ function doneEvents(text: string): string[] { } describe("relaySseWithFailedTail", () => { + test.each(["tee", "eager"] as const)("masks a JSON-escaped selected credential in a %s synthetic failure", async mode => { + const credential = 'fixture"quoted\\token'; + const maskCredential = createOutboundCredentialMask({ authorization: `Bearer ${credential}` }); + const src = sourceStream([], { failAfter: true, error: new Error(`reset after ${credential}`) }); + const out = await drain(mode === "tee" + ? relaySseWithFailedTail(src, new AbortController(), undefined, { maskCredential }) + : relaySseEagerBounded(src, new AbortController(), parityHooks, { maskCredential })); + expect(failedMessage(out)).toContain("[REDACTED]"); + expect(failedMessage(out)).not.toContain(credential); + }); + + test("masks selected credentials in every synthetic refusal field", () => { + const credential = "fixture-selected-credential"; + const maskCredential = createOutboundCredentialMask({ authorization: `Bearer ${credential}` }); + const frame = decoder.decode(upstreamErrorTailFrame( + encoder, `refused ${credential}`, `invalid_${credential}`, maskCredential, + )); + expect(frame).not.toContain(credential); + expect(frame).toContain("[REDACTED]"); + }); test("relays a healthy stream verbatim with no injected frame", async () => { const upstream = new AbortController(); const src = sourceStream(["event: response.completed\n", 'data: {"type":"response.completed"}\n\n', "data: [DONE]\n\n"]); diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index 55680463391..12ca927bc09 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -3005,6 +3005,35 @@ describe("server local API auth", () => { } }); + test("canonical buffered Pool retry remains HTTP-only and relays an alternate 307", async () => { + const redirectTarget = "https://dead.invalid/alternate"; + const harness = await startPoolRetryHarness(accountId => accountId === "acct-pool-a" + ? new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "content-type": "application/json", "retry-after": "60" }, + }) + : new Response(null, { status: 307, headers: { location: redirectTarget } })); + const fixtureWebSocket = globalThis.WebSocket; + let upstreamWebSocketAttempts = 0; + globalThis.WebSocket = new Proxy(fixtureWebSocket, { + construct(target, args, newTarget) { + const url = new URL(String(args[0])); + if (url.protocol === "wss:" && url.hostname === "chatgpt.com") upstreamWebSocketAttempts += 1; + return Reflect.construct(target, args, newTarget); + }, + }); + try { + const response = await harness.request({ redirect: "manual" }); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe(redirectTarget); + expect(harness.dispatches).toEqual(["acct-pool-a", "acct-pool-b"]); + expect(upstreamWebSocketAttempts).toBe(0); + } finally { + globalThis.WebSocket = fixtureWebSocket; + await stopPoolRetryHarness(harness); + } + }); + test.each([429, 402] as const)( "a pre-stream %i from the only Pool account retries once with the validated caller main", async rejection => { diff --git a/tests/usage/request-log-nonstream.test.ts b/tests/usage/request-log-nonstream.test.ts index ac2608a1ca2..bd6c3759008 100644 --- a/tests/usage/request-log-nonstream.test.ts +++ b/tests/usage/request-log-nonstream.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { responseWithDeferredRequestLog } from "../../src/server/relay"; +import { markPreinspectedJsonResponse, responseWithDeferredRequestLog } from "../../src/server/relay"; import { MAX_RESPONSE_LOG_INSPECTION_BYTES } from "../../src/server/response-log-body"; +import { finalizeAccountLease, finalizeOwnedTranslatorBudget } from "../../src/server/responses/core-lifetime"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { RequestLogContext, RequestLogEntry } from "../../src/server/request-log"; const encoder = new TextEncoder(); @@ -45,6 +47,31 @@ describe("deferred non-stream request log integration", () => { expect(entries[0]?.status).toBe(200); }); + test("preinspected JSON keeps EOF logging without reparsing the response body", async () => { + const payload = JSON.stringify({ + model: "must-not-replace", + usage: { input_tokens: 99, output_tokens: 99, total_tokens: 198 }, + }); + const logCtx: RequestLogContext = { + model: "requested-model", + provider: "fixture-provider", + resolvedModel: "already-inspected", + usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 }, + }; + const marked = markPreinspectedJsonResponse(new Response(payload, { + headers: { "content-type": "application/json" }, + })); + const finalized = finalizeAccountLease( + finalizeOwnedTranslatorBudget(marked, createTranslatorBudget()), + () => undefined, + ); + const { result, entries } = tracked(finalized, logCtx); + expect(await result.text()).toBe(payload); + expect(entries).toHaveLength(1); + expect(entries[0]?.resolvedModel).toBe("already-inspected"); + expect(entries[0]?.usage).toEqual({ inputTokens: 3, outputTokens: 2, totalTokens: 5 }); + }); + test("does not overwrite routed model/usage context from oversized JSON", async () => { const payload = JSON.stringify({ model: "do-not-inspect", padding: "x".repeat(MAX_RESPONSE_LOG_INSPECTION_BYTES) }); const { result, entries, logCtx } = tracked(new Response(payload, { headers: { "content-type": "application/json" } }));