From 0b867b0f8e28a198a57bdef75eb6024c94865bcb Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 1 Sep 2026 00:24:37 -0700 Subject: [PATCH 1/2] feat(responses): refuse oversized outbound bodies before dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex backend drops any WS message of 16 MiB or more, and #2473 keeps such turns off the WS transport — but the rerouted body still goes upstream over HTTP, where the same unpublished ceiling eventually answers with an opaque upstream failure the user cannot act on. The measured failure point sits around 16.7 MB; the default limit here is 15 MiB so transport overhead does not round the observed threshold up into the unsafe range. `maxUpstreamBodyBytes` (top-level config, default 15 MiB, 0 disables) measures the serialized native Responses passthrough body before any send and answers a local `413 outbound_body_too_large` instead. The refusal is diagnostic, not just a wall: when the parsed body carries `input_image` items, the error reports how many and roughly how many decoded megabytes of embedded image data they represent — accumulated replayed images are the common cause — and says what actually clears the state: start a new session or compact the conversation. The guard runs at every point a body is (re)built: the initial build, the undeclared-tool-guard rebuild, the rebuild-and-refetch lane, and the Codex pool alternate-account retry. A refusal releases what the fetch path would have owned — the translator-budget body observation, the upstream host admission lease, and the auth-context probe lease (idempotent, so overlapping release on the retry lane is harmless) — and stamps `errorCode` on the request log so finalization does not re-infer a cause from the synthetic 413. Unit coverage pins byte-accurate UTF-8 measurement, the exact boundary, the data-URI size approximation, degradation on malformed/unparseable bodies, and the disabled path short-circuiting before measurement. Integration coverage pins the local 413 with zero upstream fetches and exactly one observation release, the admitted path, and the 0-disables path. Co-Authored-By: Claude Fable 5 --- .../fr/reference/configuration/providers.md | 1 + .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../ru/reference/configuration/providers.md | 1 + .../tr/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + src/config.ts | 1 + src/server/request-log.ts | 4 +- src/server/responses/core.ts | 49 +++++++- src/server/responses/outbound-body-guard.ts | 90 ++++++++++++++ src/types/config.ts | 3 + tests/empty-completion-core.test.ts | 105 +++++++++++++++-- tests/outbound-body-guard.test.ts | 110 ++++++++++++++++++ 14 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 src/server/responses/outbound-body-guard.ts create mode 100644 tests/outbound-body-guard.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index f8a67cef3f..179b384784 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -27,6 +27,7 @@ authentifiées. | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Seuil facultatif du coupe-circuit pour les échecs DNS/TCP avérés avant connexion sur les requêtes Responses OpenAI natives en mode transfert et les envois compacts. `0` le désactive ; `1`–`20` ouvre, après ce nombre de requêtes logiques arrivées à leur terme, une temporisation de 30 secondes propre à l'origine du fournisseur. Tant que le circuit est ouvert, les requêtes reçoivent `503` avec `Retry-After` avant la sélection du compte ou l'envoi en amont ; après la temporisation, une requête est admise en état semi-ouvert. Les délais d'attente et les réponses HTTP ne sont jamais comptabilisés, et toute réponse HTTP ferme le circuit. Ce mécanisme s'applique uniquement au routage du pool Codex sans compte épinglé ; il reste inactif pour `codexAccountMode: "direct"` et les sélecteurs qualifiés par compte. | +| `maxUpstreamBodyBytes?` | `number` | `15728640` (15 MiB) | Refuse localement avec `413 outbound_body_too_large`, avant tout envoi en amont, un corps sérialisé du passthrough Responses natif qui dépasse cette limite. L'erreur indique la taille mesurée et, le cas échéant, les éléments `input_image` accumulés lors des relectures ; démarrez une nouvelle session ou compactez la conversation pour les supprimer. Les requêtes sous la limite ne sont pas modifiées. `0` désactive le contrôle. | | `modelCacheTtlMs?` | `number` | `300000` | Fenêtre de fraîcheur pour le cache `/models` par fournisseur. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Politique Anthropic de mise en cache des invites : désactivée, éphémère pendant 5 minutes ou étendue à 1 heure. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | désactivé | Politique facultative d'actualisation proactive OAuth et de préchauffage des comptes Codex. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index fd369105d9..408469cf1e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -25,6 +25,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | ネイティブOpenAI forwardのResponses送信とcompact送信で、実証済みの接続前DNS/TCP障害に適用するオプトインのサーキットしきい値です。`0`で無効、`1`〜`20`ではその回数の終端論理リクエストが失敗するとprovider-originを30秒間遮断します。遮断中はアカウント選択やupstream送信の前に`Retry-After`付き`503`を返し、時間経過後はhalf-openリクエストを1件だけ許可します。タイムアウトとHTTP応答は数えず、HTTP応答が1件でもあれば回路を閉じます。 Codex Pool ルーティングでアカウントが固定されていない場合にのみ適用され、`codexAccountMode: "direct"` とアカウント修飾セレクターでは動作しません。 | +| `maxUpstreamBodyBytes?` | `number` | `15728640`(15 MiB) | ネイティブ Responses passthrough のシリアライズ済み本文がこの上限を超えると、upstream へ送信する前にローカルの `413 outbound_body_too_large` で拒否します。エラーには実測サイズと、存在する場合は蓄積された再生済み `input_image` 項目が表示されます。新しいセッションを開始するか会話を compact すると削除できます。上限内のリクエストは変更されません。`0` で無効になります。 | | `modelCacheTtlMs?` | `number` | `300000` |プロバイダーごとの `/models` キャッシュの鮮度ウィンドウ。 | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic プロンプト キャッシュ ポリシー: 無効、5 分間の一時的、または 1 時間の延長。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` |オフ |オプションのプロアクティブな OAuth 更新および Codex アカウントのウォームアップ ポリシー。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 7c599e9b8f..5a29a75bd3 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -25,6 +25,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | | `upstreamHostCircuitThreshold?` | `number` | `0` | 네이티브 OpenAI forward Responses와 compact 전송에서 입증된 연결 전 DNS/TCP 실패에 적용하는 선택적 회로 차단 임계값입니다. `0`은 비활성화하며, `1`~`20`은 이 횟수만큼 최종 논리 요청이 실패하면 provider-origin을 30초 동안 차단합니다. 차단 중에는 계정 선택이나 업스트림 전송 전에 `Retry-After`가 포함된 `503`을 반환하고, 시간이 지나면 반개방 요청 하나만 허용합니다. 타임아웃과 HTTP 응답은 집계하지 않으며, HTTP 응답이 하나라도 오면 회로를 닫습니다. Codex Pool 라우팅에서 계정이 고정되지 않은 경우에만 적용되며, `codexAccountMode: "direct"` 및 계정 한정 선택자에서는 동작하지 않습니다. | +| `maxUpstreamBodyBytes?` | `number` | `15728640`(15 MiB) | 네이티브 Responses passthrough의 직렬화된 본문이 이 한도를 넘으면 업스트림 전송 전에 로컬 `413 outbound_body_too_large`로 거부합니다. 오류에는 측정된 크기와, 존재하는 경우 누적 재생된 `input_image` 항목이 표시됩니다. 새 세션을 시작하거나 대화를 압축하면 이를 제거할 수 있습니다. 한도 안의 요청은 변경되지 않습니다. `0`으로 설정하면 비활성화됩니다. | | `modelCacheTtlMs?` | `number` | `300000` | 공급자별 `/models` 캐시의 최신성 창입니다. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 프롬프트 캐시 정책입니다. 비활성, 5분짜리 임시, 1시간짜리 확장 중 하나입니다. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 꺼짐 | 선택적 선제 OAuth 갱신과 Codex 계정 워밍업 정책입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d666ca972d..8099ed92d1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -27,6 +27,7 @@ authenticated. | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. | +| `maxUpstreamBodyBytes?` | `number` | `15728640` (15 MiB) | Refuses a serialized native Responses passthrough body above this limit with local `413 outbound_body_too_large` before any upstream send. The error reports the measured size and identifies accumulated replayed `input_image` items when present; start a new session or compact the conversation to clear them. Fitting requests are unchanged. Set `0` to disable. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index c013b96855..83bea6ead2 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -26,6 +26,7 @@ description: Записи провайдеров, аутентификация, | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Опциональный порог circuit breaker для доказанных DNS/TCP-сбоев до соединения в нативных OpenAI forward Responses- и compact-отправках. `0` отключает его; `1`–`20` открывает 30-секундный cooldown для provider-origin после такого числа завершившихся логических запросов. Пока circuit открыт, до выбора аккаунта и upstream-отправки возвращается `503` с `Retry-After`; после cooldown допускается один half-open запрос. Таймауты и HTTP-ответы не учитываются, а любой HTTP-ответ закрывает circuit. Применяется только к маршрутизации Codex Pool без закреплённого аккаунта; при `codexAccountMode: "direct"` и для селекторов с указанием аккаунта схема не активна. | +| `maxUpstreamBodyBytes?` | `number` | `15728640` (15 MiB) | Если сериализованное тело нативного Responses passthrough превышает этот предел, запрос отклоняется локально с `413 outbound_body_too_large` до отправки upstream. Ошибка сообщает измеренный размер и, при наличии, накопленные повторно воспроизводимые элементы `input_image`; очистить их можно новой сессией или сжатием разговора. Запросы в пределах лимита не изменяются. `0` отключает проверку. | | `modelCacheTtlMs?` | `number` | `300000` | Окно свежести для кэша `/models` на уровне провайдера. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика prompt-cache Anthropic: отключено, 5-минутный ephemeral или 1-часовой extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Необязательная политика proactive OAuth refresh и warmup'а аккаунтов Codex. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index bba5ca850f..06756133bd 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -27,6 +27,7 @@ konuştuğunu ve isteklerin nasıl doğrulandığını söyler. | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Yerel OpenAI iletme Responses ve sıkıştırma gönderimlerinde kanıtlanmış bağlantı öncesi DNS/TCP arızaları için isteğe bağlı devre eşiği. `0` devre dışı bırakır; `1`–`20`, bu kadar terminal mantıksal istekten sonra 30 saniyelik bir sağlayıcı-kaynak soğuma süresi açar. Açıkken istekler, hesap seçiminden veya yukarı akış gönderiminden önce `Retry-After` ile `503` alır; soğuma süresinden sonra bir yarı açık isteğe izin verilir. Zaman aşımları ve HTTP yanıtları asla sayılmaz ve herhangi bir HTTP yanıtı devreyi kapatır. Yalnızca sabitlenmiş hesabı olmayan Codex Havuz yönlendirmesi için geçerlidir; `codexAccountMode: "direct"` ve hesap nitelikli seçiciler için etkisizdir. | +| `maxUpstreamBodyBytes?` | `number` | `15728640` (15 MiB) | Yerel Responses passthrough için serileştirilmiş gövde bu sınırı aşarsa upstream gönderiminden önce yerel `413 outbound_body_too_large` ile reddedilir. Hata, ölçülen boyutu ve varsa birikmiş yeniden oynatılan `input_image` öğelerini bildirir; bunları temizlemek için yeni bir oturum başlatın veya konuşmayı sıkıştırın. Sınıra uyan istekler değiştirilmez. `0` denetimi devre dışı bırakır. | | `modelCacheTtlMs?` | `number` | `300000` | Sağlayıcı başına `/models` önbelleği için tazelik penceresi. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic istem önbelleği politikası: devre dışı, 5 dakikalık kısa ömürlü veya 1 saatlik uzatılmış. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | kapalı | İsteğe bağlı proaktif OAuth yenileme ve Codex hesabı ısınma politikası. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 754e77e220..48af80ff6a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -25,6 +25,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | 原生 OpenAI forward Responses 与 compact 发送的可选断路器阈值,仅统计已证明的连接前 DNS/TCP 故障。`0` 表示禁用;`1`–`20` 表示在这么多个终止逻辑请求失败后,对 provider-origin 冷却 30 秒。断路期间会在账户选择和上游发送之前返回带 `Retry-After` 的 `503`;冷却结束后只允许一个半开请求。超时和 HTTP 响应不计数,任意 HTTP 响应都会关闭断路器。 仅适用于未固定账户的 Codex Pool 路由;在 `codexAccountMode: "direct"` 或使用账户限定选择器时不会启用。 | +| `maxUpstreamBodyBytes?` | `number` | `15728640`(15 MiB) | 原生 Responses passthrough 的序列化请求体超过此上限时,会在任何上游发送之前返回本地 `413 outbound_body_too_large`。错误会报告实测大小,并在存在时指出累积重放的 `input_image`;新建会话或压缩对话即可清除它们。未超限的请求不会被修改。设为 `0` 可禁用。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个提供者 `/models` 缓存的新鲜度窗口。 | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 提示缓存策略:禁用、5 分钟临时缓存,或 1 小时扩展缓存。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的主动 OAuth 刷新与 Codex 账户预热策略。 | diff --git a/src/config.ts b/src/config.ts index 11d88af91d..6d7d0011d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -870,6 +870,7 @@ const configSchema = z.object({ .max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) .optional() .catch(undefined), + maxUpstreamBodyBytes: z.number().int().min(0).optional().catch(undefined), appOwnedMemoryBudgetMb: z.number().int() .min(MIN_APP_OWNED_MEMORY_BUDGET_MB) .max(MAX_APP_OWNED_MEMORY_BUDGET_MB) diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 4194b103d9..63a5fcf775 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -129,6 +129,8 @@ export interface RequestLogContext { affinity?: "reused" | "new_bind" | "rebound" | "cleared"; transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; terminalSource?: "upstream" | "synthetic"; + /** Explicit local refusal code; takes precedence over status/message inference at finalization. */ + errorCode?: string; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; } @@ -927,7 +929,7 @@ export function addFinalRequestLog( const effectiveStatus = status >= 500 && logCtx.upstreamError && isClientClosedMessage(logCtx.upstreamError) ? 499 : status; - const errorCode = requestLogErrorCode( + const errorCode = logCtx.errorCode ?? requestLogErrorCode( effectiveStatus, logCtx.upstreamError, logCtx.terminalErrorCode, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1f56ed979a..eb3d881770 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -7,6 +7,11 @@ import { backfillResponsesFieldsJson, } from "./responses-field-backfill"; import { checkInputAdmission } from "./input-admission"; +import { + checkOutboundBodySize, + DEFAULT_MAX_UPSTREAM_BODY_BYTES, + describeOutboundBodyRefusal, +} from "./outbound-body-guard"; import { nativeContextLimits } from "../../codex/catalog"; import { describeUpstreamConnectFailure } from "./upstream-error"; import { @@ -948,6 +953,10 @@ interface CodexPoolAccountRetryArgs { authCtx: Extract, request: Awaited["buildRequest"]>>, ) => void; + refuseOversizedOutboundBody?: ( + request: AdapterRequest, + authCtx: Extract, + ) => Response | undefined; } type CodexPoolAccountRetryResult = @@ -963,7 +972,8 @@ type CodexPoolAccountRetryResult = kind: "transport"; error: unknown; authCtx: Extract; - }; + } + | { kind: "failed"; response: Response }; /** Keep retry-stage entitlement snapshots inside the native-main selection fence. */ async function resolveCodexRetryModelEntitlements( @@ -1187,6 +1197,8 @@ async function retryCodexPoolOnAlternateAccount( recordAdapterTier(logCtx, request); await firstResponse.body?.cancel().catch(() => undefined); + const bodyRefusal = args.refuseOversizedOutboundBody?.(request, retryAuthCtx); + if (bodyRefusal) return { kind: "failed", response: bodyRefusal }; options.onCodexAuthContextResolved?.(retryAuthCtx); route.provider = retryProvider; logCtx.provider = formatCodexProviderForLog( @@ -3818,6 +3830,33 @@ async function handleResponsesInner( const upstream = new AbortController(); linkAbortSignal(upstream, options.abortSignal); const connectMs = config.connectTimeoutMs ?? 200_000; + const maxUpstreamBodyBytes = config.maxUpstreamBodyBytes ?? DEFAULT_MAX_UPSTREAM_BODY_BYTES; + const refuseOversizedOutboundBody = ( + builtRequest: AdapterRequest, + refusalAuthCtx: CodexAuthContext = authCtx, + ): Response | undefined => { + const result = checkOutboundBodySize(builtRequest.body, maxUpstreamBodyBytes); + if (result.admitted) return undefined; + + // This return happens before the surrounding fetch/finally owns the observation. + // Release it here so one refused body cannot occupy translator budget indefinitely. + builtRequest.releaseBodyObservation?.(); + upstream.abort(); + releaseUpstreamHostAdmission(hostAdmissionLease); + hostAdmissionLease = null; + releaseCodexAuthContextProbeLease(refusalAuthCtx); + logCtx.errorCode = "outbound_body_too_large"; + console.warn( + `[responses] refused an oversized outbound body: bytes=${result.bytes} limit=${result.limit} ` + + `input_images=${result.imageCount} image_bytes=${result.imageBytes} ` + + `model=${JSON.stringify(parsed.modelId)} host=${JSON.stringify(safeHostLabel(builtRequest.url))}`, + ); + return formatErrorResponse( + 413, + "outbound_body_too_large", + describeOutboundBodyRefusal(result), + ); + }; let upstreamResponse: Response; const transportFailureResponse = (err: unknown): Response => { upstream.abort(); @@ -3861,6 +3900,8 @@ async function handleResponsesInner( : describeUpstreamConnectFailure(err, connectMs); return formatErrorResponse(502, "upstream_error", msg); }; + const initialBodyRefusal = refuseOversizedOutboundBody(request); + if (initialBodyRefusal) return initialBodyRefusal; try { // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010): // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs. @@ -3935,6 +3976,8 @@ async function handleResponsesInner( retryAdapter.name, logCtx.accountLogLabel, ); + const rebuiltBodyRefusal = refuseOversizedOutboundBody(request); + if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal }; try { return await fetchWithTransientRetry( innerRecovery => { @@ -4136,6 +4179,8 @@ async function handleResponsesInner( return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)); } refreshUndeclaredToolGuard(request); + const refreshedBodyRefusal = refuseOversizedOutboundBody(request); + if (refreshedBodyRefusal) return refreshedBodyRefusal; try { upstreamResponse = await fetchWithTransientRetry( recovery => { @@ -4287,7 +4332,9 @@ async function handleResponsesInner( onResponse: (response, retryAuthCtx, retryRequest) => { captureAffinityResponse(response, retryAuthCtx, retryRequest, true); }, + refuseOversizedOutboundBody, }); + if (retry.kind === "failed") return retry.response; if (retry.kind === "transport") { authCtx = retry.authCtx; return transportFailureResponse(retry.error); diff --git a/src/server/responses/outbound-body-guard.ts b/src/server/responses/outbound-body-guard.ts new file mode 100644 index 0000000000..a37545ab21 --- /dev/null +++ b/src/server/responses/outbound-body-guard.ts @@ -0,0 +1,90 @@ +/** + * The measured upstream failure starts around 16.7 MB, but that ceiling is unpublished and may + * vary by deployment. Keep the default at 15 MiB to leave room for transport overhead instead of + * rounding the observed failure point up into the unsafe range. + */ +export const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 15 * 1024 * 1024; + +export interface OutboundBodyGuardResult { + admitted: boolean; + /** Serialized UTF-8 bytes. Zero when the guard is disabled before measurement. */ + bytes: number; + limit: number; + imageCount: number; + /** Approximate decoded bytes represented by embedded input_image data URIs. */ + imageBytes: number; +} + +const MAX_DIAGNOSTIC_DEPTH = 64; + +function decodedDataUriBytes(value: unknown): number { + if (typeof value !== "string" || !value.startsWith("data:")) return 0; + const comma = value.indexOf(","); + if (comma < 0) return 0; + const payload = value.length - comma - 1; + return payload > 0 ? Math.floor((payload * 3) / 4) : 0; +} + +function imageDiagnostics(value: unknown): { imageCount: number; imageBytes: number } { + let imageCount = 0; + let imageBytes = 0; + const seen = new WeakSet(); + + const visit = (entry: unknown, depth: number): void => { + if (depth > MAX_DIAGNOSTIC_DEPTH || entry === null || typeof entry !== "object") return; + if (seen.has(entry)) return; + seen.add(entry); + + if (!Array.isArray(entry) && (entry as Record).type === "input_image") { + imageCount += 1; + imageBytes += decodedDataUriBytes((entry as Record).image_url); + return; + } + + if (Array.isArray(entry)) { + for (const item of entry) visit(item, depth + 1); + return; + } + for (const item of Object.values(entry)) visit(item, depth + 1); + }; + + visit(value, 0); + return { imageCount, imageBytes }; +} + +export function checkOutboundBodySize( + body: string, + limitBytes: number | undefined, +): OutboundBodyGuardResult { + const limit = limitBytes ?? DEFAULT_MAX_UPSTREAM_BODY_BYTES; + if (limit === 0) { + return { admitted: true, bytes: 0, limit, imageCount: 0, imageBytes: 0 }; + } + + const bytes = new TextEncoder().encode(body).byteLength; + if (bytes <= limit) { + return { admitted: true, bytes, limit, imageCount: 0, imageBytes: 0 }; + } + + try { + const diagnostics = imageDiagnostics(JSON.parse(body) as unknown); + return { admitted: false, bytes, limit, ...diagnostics }; + } catch { + return { admitted: false, bytes, limit, imageCount: 0, imageBytes: 0 }; + } +} + +function megabytes(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1); +} + +export function describeOutboundBodyRefusal(result: OutboundBodyGuardResult): string { + const imageDetail = result.imageCount > 0 + ? ` It contains ${result.imageCount} input_image item${result.imageCount === 1 ? "" : "s"} ` + + `representing about ${megabytes(result.imageBytes)} MB of decoded embedded image data; ` + + "accumulated replayed images are the likely cause." + : " Large inputs accumulated across replayed turns can cause this." + return `The serialized outbound request is ${megabytes(result.bytes)} MB, ` + + `above the configured ${megabytes(result.limit)} MB limit.${imageDetail} ` + + "Start a new session or compact the conversation before retrying." +} diff --git a/src/types/config.ts b/src/types/config.ts index 53fca2809b..ce9b71576d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -615,6 +615,9 @@ export interface OcxConfig { * Default 0 (disabled); range 0..20. The circuit never counts timeouts or HTTP responses. */ upstreamHostCircuitThreshold?: number; + /** Refuse an outbound provider request whose serialized body exceeds this many bytes. + * Default 15728640 (15 MiB). 0 = disabled. */ + maxUpstreamBodyBytes?: number; /** * Opt-in Anthropic OAuth account pool (#294). Default OFF. * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage. diff --git a/tests/empty-completion-core.test.ts b/tests/empty-completion-core.test.ts index 6d538bf54c..de0f6c9e57 100644 --- a/tests/empty-completion-core.test.ts +++ b/tests/empty-completion-core.test.ts @@ -18,24 +18,47 @@ let httpCalls = 0; let parsedAttempts: OcxParsedRequest[] = []; let builtBodies: string[] = []; let customRunTurn: ProviderAdapter["runTurn"] | undefined; +let passthroughFetchCalls = 0; +let bodyObservationReleaseCalls = 0; function attemptAt(index: number): AdapterEvent[] { return attemptEvents[index] ?? [{ type: "error", message: `missing fixture attempt ${index}` }]; } -function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { +function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough?: true } { const runTurn = provider.adapter === "test-run-turn"; + const passthrough = provider.adapter === "test-passthrough"; return { - name: runTurn ? "test-run-turn" : "openai-chat", - buildRequest(parsed) { + name: runTurn ? "test-run-turn" : passthrough ? "test-passthrough" : "openai-chat", + ...(passthrough ? { passthrough: true as const } : {}), + buildRequest(parsed, incoming) { const rawBody = parsed._rawBody as { service_tier?: unknown } | undefined; - const body = JSON.stringify({ - model: parsed.modelId, - messages: parsed.context.messages, - ...(rawBody?.service_tier !== undefined ? { service_tier: rawBody.service_tier } : {}), - }); + const body = passthrough + ? JSON.stringify(parsed._rawBody) + : JSON.stringify({ + model: parsed.modelId, + messages: parsed.context.messages, + ...(rawBody?.service_tier !== undefined ? { service_tier: rawBody.service_tier } : {}), + }); builtBodies.push(body); - return { url: provider.baseUrl, method: "POST", headers: {}, body }; + const release = passthrough + ? incoming.translatorBudget.observeExternallyCapped( + "passthrough_serialization", + new TextEncoder().encode(body).byteLength, + ) + : undefined; + return { + url: provider.baseUrl, + method: "POST", + headers: {}, + body, + ...(release ? { + releaseBodyObservation: () => { + bodyObservationReleaseCalls += 1; + release(); + }, + } : {}), + }; }, async fetchResponse() { const index = httpCalls; @@ -68,7 +91,11 @@ function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { mock.module("../src/server/adapter-resolve", () => ({ ...actualResolver, resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { - if (provider.adapter === "test-run-turn" || provider.adapter === "test-http") { + if ( + provider.adapter === "test-run-turn" + || provider.adapter === "test-http" + || provider.adapter === "test-passthrough" + ) { return fixtureAdapter(provider); } return actualResolveAdapter(provider, cacheRetention); @@ -77,8 +104,8 @@ mock.module("../src/server/adapter-resolve", () => ({ const { handleResponses } = await import("../src/server/responses"); -function config(adapter: "test-run-turn" | "test-http", extra: Partial = {}): OcxConfig { - return { +function config(adapter: "test-run-turn" | "test-http" | "test-passthrough", extra: Partial = {}): OcxConfig { + const result = { port: 0, defaultProvider: "fixture", emptyCompletionRetry: true, @@ -93,6 +120,18 @@ function config(adapter: "test-run-turn" | "test-http", extra: Partial { + passthroughFetchCalls += 1; + return Response.json({ + id: "resp_fixture", + object: "response", + status: "completed", + output: [], + }); + }; + } + return result; } function request( @@ -114,6 +153,8 @@ beforeEach(() => { parsedAttempts = []; builtBodies = []; customRunTurn = undefined; + passthroughFetchCalls = 0; + bodyObservationReleaseCalls = 0; }); afterEach(() => { @@ -122,6 +163,46 @@ afterEach(() => { }); describe("empty-completion core integration", () => { + test("an oversized passthrough body is refused locally and releases its observation", async () => { + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses( + request(false, "x".repeat(512)), + config("test-passthrough", { maxUpstreamBodyBytes: 128 }), + logCtx, + ); + const body = await response.json() as { error?: { code?: string } }; + + expect(response.status).toBe(413); + expect(body.error?.code).toBe("outbound_body_too_large"); + expect(logCtx.errorCode).toBe("outbound_body_too_large"); + expect(passthroughFetchCalls).toBe(0); + expect(bodyObservationReleaseCalls).toBe(1); + }); + + test("a normal-sized passthrough body still reaches upstream", async () => { + const response = await handleResponses( + request(false), + config("test-passthrough", { maxUpstreamBodyBytes: 4_096 }), + { model: "", provider: "" }, + ); + + expect(response.status).toBe(200); + expect(passthroughFetchCalls).toBe(1); + expect(bodyObservationReleaseCalls).toBe(1); + }); + + test("a zero passthrough body limit lets an oversized turn reach upstream", async () => { + const response = await handleResponses( + request(false, "x".repeat(512)), + config("test-passthrough", { maxUpstreamBodyBytes: 0 }), + { model: "", provider: "" }, + ); + + expect(response.status).toBe(200); + expect(passthroughFetchCalls).toBe(1); + expect(bodyObservationReleaseCalls).toBe(1); + }); + test("streaming runTurn returns the local 429 contract when initial pacing admission is rejected", async () => { setProviderRequestPacingLimitsForTest({ maxQueueDepth: 0 }); const overloaded = config("test-run-turn"); diff --git a/tests/outbound-body-guard.test.ts b/tests/outbound-body-guard.test.ts new file mode 100644 index 0000000000..e88b86f3d0 --- /dev/null +++ b/tests/outbound-body-guard.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { + checkOutboundBodySize, + DEFAULT_MAX_UPSTREAM_BODY_BYTES, + describeOutboundBodyRefusal, +} from "../src/server/responses/outbound-body-guard"; + +const utf8Bytes = (value: string): number => new TextEncoder().encode(value).byteLength; + +describe("checkOutboundBodySize", () => { + test("admits a small body without running image diagnostics", () => { + const body = JSON.stringify({ input: [{ type: "input_image", image_url: "data:image/png;base64,AAAA" }] }); + const result = checkOutboundBodySize(body, utf8Bytes(body) + 1); + + expect(result).toEqual({ + admitted: true, + bytes: utf8Bytes(body), + limit: utf8Bytes(body) + 1, + imageCount: 0, + imageBytes: 0, + }); + }); + + test("refuses by real UTF-8 byte length", () => { + const body = JSON.stringify({ input: "界" }); + const result = checkOutboundBodySize(body, body.length); + + expect(result.admitted).toBe(false); + expect(result.bytes).toBe(utf8Bytes(body)); + expect(result.bytes).toBeGreaterThan(body.length); + }); + + test("counts nested data-URI images by approximate decoded size", () => { + const body = JSON.stringify({ + input: [{ + type: "message", + role: "user", + content: [ + { type: "input_image", image_url: "data:image/png;base64,AAAAAAAA" }, + { type: "input_image", image_url: "data:image/jpeg;base64,AAAA" }, + ], + }], + }); + const result = checkOutboundBodySize(body, 1); + + expect(result.imageCount).toBe(2); + expect(result.imageBytes).toBe(Math.floor((8 * 3) / 4) + Math.floor((4 * 3) / 4)); + }); + + test("counts remote images without attributing their remote bytes", () => { + const body = JSON.stringify({ input: [{ type: "input_image", image_url: "https://example.test/image.png" }] }); + const result = checkOutboundBodySize(body, 1); + + expect(result.imageCount).toBe(1); + expect(result.imageBytes).toBe(0); + }); + + test("a malformed data URI degrades to zero decoded bytes", () => { + const body = JSON.stringify({ input: [{ type: "input_image", image_url: "data:image/png;base64" }] }); + const result = checkOutboundBodySize(body, 1); + + expect(result.imageCount).toBe(1); + expect(result.imageBytes).toBe(0); + }); + + test("an unparseable oversized body still refuses", () => { + const result = checkOutboundBodySize("{not-json", 1); + + expect(result.admitted).toBe(false); + expect(result.imageCount).toBe(0); + expect(result.imageBytes).toBe(0); + }); + + test("zero disables the guard before measuring or scanning a large body", () => { + const result = checkOutboundBodySize("x".repeat(20 * 1024 * 1024), 0); + + expect(result).toEqual({ admitted: true, bytes: 0, limit: 0, imageCount: 0, imageBytes: 0 }); + }); + + test("an undefined limit uses the default", () => { + const result = checkOutboundBodySize("{}", undefined); + + expect(result.admitted).toBe(true); + expect(result.limit).toBe(DEFAULT_MAX_UPSTREAM_BODY_BYTES); + }); + + test("admits the exact boundary and refuses one byte over it", () => { + expect(checkOutboundBodySize("1234", 4).admitted).toBe(true); + expect(checkOutboundBodySize("12345", 4).admitted).toBe(false); + }); +}); + +test("describeOutboundBodyRefusal is actionable without token-limit wording or body excerpts", () => { + const message = describeOutboundBodyRefusal({ + admitted: false, + bytes: 16 * 1024 * 1024, + limit: 15 * 1024 * 1024, + imageCount: 11, + imageBytes: 15 * 1024 * 1024, + }, "context window / too many tokens"); + + expect(message).toContain("16.0 MB"); + expect(message).toContain("15.0 MB"); + expect(message).toContain("11 input_image items"); + expect(message).toContain("Start a new session or compact the conversation"); + expect(message).not.toContain("context window"); + expect(message).not.toContain("context length"); + expect(message).not.toContain("too many tokens"); + expect(message).not.toContain("data:image"); +}); From df94500b6b21b31e2663a0efcee3baff3f45322b Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 1 Sep 2026 02:34:57 -0700 Subject: [PATCH 2/2] fix(responses): review follow-ups on the outbound body guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, each verified against the code before changing it. The retry-lane refusal skipped recordFirstOutcome() when the first outcome was deferred, and recording is what would have surrendered the first account's probe lease — so a refusal on the alternate-account rebuild left the first account reserved after the logical request ended. The refusal now releases that lease directly (idempotent by lease id) instead of inventing an outcome for a send that never happened. The maxUpstreamBodyBytes doc comment claimed "any outbound provider request"; the guard covers the native Responses passthrough only, and the type comment now says so, so translated-adapter consumers do not expect refusals there. The describeOutboundBodyRefusal test still passed a second argument left over from the dropped modelId parameter — invisible to `bun run typecheck`, whose include is src-only. Removed. Not changed: the vision sidecar still runs before the guard. Pre-sidecar refusal on inbound size would be unsound — description exists to shrink the body by replacing image data with text, so an inbound-oversized request can be outbound-fitting. The sound variant (refuse when non-image bytes alone exceed the limit) buys a narrow saving at the cost of a second, approximate measurement contract; left out deliberately. Co-Authored-By: Claude Fable 5 --- src/server/responses/core.ts | 8 +++++++- src/types/config.ts | 4 ++-- tests/outbound-body-guard.test.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index eb3d881770..709a0ddd76 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1198,7 +1198,13 @@ async function retryCodexPoolOnAlternateAccount( await firstResponse.body?.cancel().catch(() => undefined); const bodyRefusal = args.refuseOversizedOutboundBody?.(request, retryAuthCtx); - if (bodyRefusal) return { kind: "failed", response: bodyRefusal }; + if (bodyRefusal) { + // A deferred first outcome is never recorded once the request ends locally, and recording + // is what would have surrendered the first account's probe lease. Release it directly — + // idempotent by lease id — instead of inventing an outcome for a send that never happened. + if (deferFirstOutcome) releaseCodexAuthContextProbeLease(firstAuthCtx); + return { kind: "failed", response: bodyRefusal }; + } options.onCodexAuthContextResolved?.(retryAuthCtx); route.provider = retryProvider; logCtx.provider = formatCodexProviderForLog( diff --git a/src/types/config.ts b/src/types/config.ts index ce9b71576d..13a70b1e80 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -615,8 +615,8 @@ export interface OcxConfig { * Default 0 (disabled); range 0..20. The circuit never counts timeouts or HTTP responses. */ upstreamHostCircuitThreshold?: number; - /** Refuse an outbound provider request whose serialized body exceeds this many bytes. - * Default 15728640 (15 MiB). 0 = disabled. */ + /** Refuse a native Responses passthrough request whose serialized body exceeds this many + * bytes; translated adapter paths are not covered. Default 15728640 (15 MiB). 0 = disabled. */ maxUpstreamBodyBytes?: number; /** * Opt-in Anthropic OAuth account pool (#294). Default OFF. diff --git a/tests/outbound-body-guard.test.ts b/tests/outbound-body-guard.test.ts index e88b86f3d0..306bf6f115 100644 --- a/tests/outbound-body-guard.test.ts +++ b/tests/outbound-body-guard.test.ts @@ -97,7 +97,7 @@ test("describeOutboundBodyRefusal is actionable without token-limit wording or b limit: 15 * 1024 * 1024, imageCount: 11, imageBytes: 15 * 1024 * 1024, - }, "context window / too many tokens"); + }); expect(message).toContain("16.0 MB"); expect(message).toContain("15.0 MB");