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 47029ee45b..096002a266 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -114,6 +114,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | | `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` authentifiés par clé uniquement. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. | | `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. | | `preserveReasoningContentModels?` | `string[]` | Modèles nécessitant un assistant préalable `reasoning_content` dans l'historique des discussions. | | `requiresReasoningPlaceholderModels?` | `string[]` | Modèles dont le service en amont rejette une continuation tool_call dépourvue de `reasoning_content`, notamment en mode de réflexion DeepSeek ; un contenu de remplacement minimal est injecté en cas d'absence dans le cache de relecture. La valeur par défaut est `preserveReasoningContentModels` ; définissez `[]` pour désactiver ce comportement. | 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 b51e8a2e32..41a9b0bd93 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -101,6 +101,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` プロバイダーのみ。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | | `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。 | | `requiresReasoningPlaceholderModels?` | `string[]` | `reasoning_content` を欠いた tool_call 継続を上流が拒否するモデル(DeepSeek thinking モード)。リプレイキャッシュが外れた場合に最小プレースホルダーを注入。未設定時は `preserveReasoningContentModels` を引き継ぎ、`[]` で明示的に無効化。 | 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 b9eb746726..27859fdbef 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -101,6 +101,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 프로바이더 전용입니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. | | `requiresReasoningPlaceholderModels?` | `string[]` | `reasoning_content`가 없는 tool_call 연속을 업스트림이 거부하는 모델(DeepSeek thinking 모드). 리플레이 캐시 미스 시 최소 플레이스홀더를 주입합니다. 미설정 시 `preserveReasoningContentModels`를 따르며 `[]`로 명시적 해제 가능. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8840acf900..07cac9a592 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -118,6 +118,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` providers only. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `requiresReasoningPlaceholderModels?` | `string[]` | Models whose upstream rejects a tool_call continuation missing `reasoning_content` (DeepSeek thinking mode); a minimal placeholder is injected when the replay cache misses. Defaults to `preserveReasoningContentModels`; set `[]` to opt out. | 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 94334dae06..e2a607b243 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -114,6 +114,7 @@ cross-route credential fallback не существует. Строки API GPT- | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Только для провайдеров `openai-chat` с аутентификацией по ключу. Опциональный повтор при временных статусах апстрима до начала потока (500, 502, 503, 504, 520, 521, 522): если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает исходный запрос `Responses`, продолжение терминального предохранителя, нативный `/v1/chat/completions`, а также повторные запросы при восстановлении после 429 или ошибки учётной записи. `attempts` — ОБЩЕЕ число разрешённых отправок в апстрим для одного запроса, включая первую (1..10, по умолчанию 3). Это единый бюджет на запрос, общий с восстановлением после сброса соединения, поэтому `3` означает, что до провайдера дойдут не более трёх реальных запросов. Ожидание использует экспоненциальную задержку с фиксированной начальной величиной 400 мс, ограниченную 5 с, и учитывает `Retry-After`. Параметр не связан с `retryOn429`, который обрабатывает ограничение частоты запросов; сбои после начала потока никогда не воспроизводятся. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | | `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. | | `requiresReasoningPlaceholderModels?` | `string[]` | Модели, чей upstream отклоняет tool_call-продолжение без `reasoning_content` (DeepSeek thinking mode); при промахе replay-кэша подставляется минимальный placeholder. По умолчанию наследует `preserveReasoningContentModels`; `[]` отключает явно. | 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 e4f5497f8c..d6235eac9a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -120,6 +120,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarı sağlayıcıları (`authMode: "key"`). İsteğe bağlı aynı hedef 429 yeniden denemesi: `retryOn429` olmadığında özellik kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece özelliği etkinleştirir. 429'da proxy bekler (yukarı akış `Retry-After` veya sabit aralık) ve herhangi bir anahtar yük devretmesinden önce aynı istek üzerinde aynı anahtarla aynı isteği yeniden oynatır — ana metin turu kurtarma döngüsü, Responses doğrudan geçiş hattı, görsel/video köprüsü, web araması sidecar'ı ve terminal devamları genelinde. Yalnızca akış öncesi HTTP 429 yanıtları yeniden oynatma için uygundur; özel `runTurn` aktarımları HTTP yeniden deneme döngüsünün dışındadır. `attempts`, ilk 429'dan sonraki aynı anahtar yeniden oynatmalarını sayar (toplam gönderim = `attempts` + 1) ve ana kurtarma döngüsü, terminal koruma devamı ve köprü yeniden denemeleri tarafından paylaşılan tek bir istek genelinde bütçedir. `attempts`'ı tüketmek yalnızca daha fazla aynı anahtar yeniden oynatmasını durdurur: normal anahtar yük devretmesi veya nihai hata işleme daha sonra kullanılabilir hedeflere göre geçerli olur — anahtar kimlik doğrulamalı doğrudan geçiş hattında yük devretme yoktur, bu nedenle tükenen 429 olduğu gibi görünür. Codex'in kendisi 429'u asla yeniden denemez, bu nedenle tek anahtarlı sağlayıcılar için tek savunma budur. Varsayılanlar: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (tek bir bekleme `maxIntervalMs` ile sınırlandırılır, kendisi de 600000 ile sınırlandırılır), `respectRetryAfter: true`. | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Yalnızca anahtarla kimlik doğrulanan `openai-chat` sağlayıcıları. Akış öncesi geçici yukarı akış durumları (500, 502, 503, 504, 520, 521, 522) için isteğe bağlı yeniden deneme: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İlk Responses isteğini, terminal koruma devamını, yerel `/v1/chat/completions` isteklerini ve 429/hesap kurtarma yeniden getirmelerini kapsar. `attempts`, bir istek için ilk gönderim dahil izin verilen yukarı akış gönderimlerinin TOPLAM sayısıdır (1..10, varsayılan 3) — bağlantı sıfırlama kurtarmasıyla paylaşılan, istek kapsamlı tek bütçedir; dolayısıyla `3`, sağlayıcıya en fazla üç gerçek isteğin ulaşması anlamına gelir. Beklemelerde 400 ms'lik sabit üstel geri çekilme uygulanır, süre 5 sn ile sınırlandırılır ve `Retry-After` dikkate alınır. Hız sınırlamasını işleyen `retryOn429` seçeneğinden ayrıdır; akış ortası hataları hiçbir zaman yeniden oynatılmaz. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`'u yalnızca `auto` veya `none` kabul eden modeller; zorunlu seçimlerin derecesi düşürülür. | | `preserveReasoningContentModels?` | `string[]` | Sohbet geçmişinde önceki asistan `reasoning_content`'ini gerektiren modeller. | | `requiresReasoningPlaceholderModels?` | `string[]` | Yukarı akışı `reasoning_content` eksik olan bir tool_call devamını reddeden modeller (DeepSeek düşünme modu); yeniden oynatma önbelleği kaçırdığında minimum bir yer tutucu enjekte edilir. Varsayılan olarak `preserveReasoningContentModels`; devre dışı bırakmak için `[]` ayarlayın. | 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 ef827e9d32..8e7f9491d0 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 @@ -101,6 +101,7 @@ selector,而不是分配一个新名称。 | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 仅限使用 key 认证的 `openai-chat` 提供商。可选的流开始前上游瞬态状态码(500、502、503、504、520、521、522)重试:未配置时关闭;对象存在即启用,除非 `enabled: false`。覆盖初始 Responses 请求、终结守卫续接、原生 `/v1/chat/completions`,以及 429/账户恢复重新获取。`attempts` 是单个请求允许向上游发送的总次数,包含首次发送(1..10,默认 3);它是与连接重置恢复共享的按请求预算,因此 `3` 表示最多只有三个实际请求到达提供商。等待采用固定 400 毫秒的指数退避,上限为 5 秒,并遵循 `Retry-After`。此选项独立于处理速率限制的 `retryOn429`;流开始后的故障绝不会重放。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | | `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。 | | `requiresReasoningPlaceholderModels?` | `string[]` | 上游会拒绝缺少 `reasoning_content` 的 tool_call 续接消息的模型(DeepSeek thinking 模式);重放缓存 miss 时注入最小占位符。缺省沿用 `preserveReasoningContentModels`;设为 `[]` 可显式关闭。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 398a396dc7..98f6e7a6eb 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -78,6 +78,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | +| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 供應商。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | | `preserveReasoningContentModels?` | `string[]` | 需要在 chat 歷史中保留先前 assistant `reasoning_content` 的模型。 | | `thinkingToggleModels?` | `string[]` | 使用 `thinking.enabled` 而非 effort 階梯的 chat 模型。 | diff --git a/src/config.ts b/src/config.ts index 7f373e57c5..b6ab165657 100644 --- a/src/config.ts +++ b/src/config.ts @@ -446,6 +446,16 @@ const retryOn429PolicySchema = z.object({ respectRetryAfter: z.boolean().optional(), }).strict(); +/** + * `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by + * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends + * against an already-failing provider is already generous. + */ +const transientRetryOn5xxPolicySchema = z.object({ + enabled: z.boolean().optional(), + attempts: z.number().int().min(1).max(10).optional(), +}).strict(); + const requestPacingRuleSchema = z.object({ // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), @@ -518,6 +528,7 @@ const providerConfigSchema = z.object({ .transform(normalizeNonBlankStringArray) .optional(), retryOn429: retryOn429PolicySchema.optional(), + transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), // Validated rather than passed through: this schema ends in `.passthrough()`, so an // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index aea1177493..49049dbeb2 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -239,12 +239,19 @@ export interface ResetRetryOptions { abortSignal?: AbortSignal; /** Short host/path label for the retry warn log (no secrets/query strings). */ label?: string; + /** Total upstream sends allowed, including the first one. Not a per-layer retry count. */ attempts?: number; } export interface TransientRetryOptions extends ResetRetryOptions { /** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */ slowAttemptMs?: number; + /** + * Reports how many upstream sends this call actually consumed, so a caller that spans + * several legs of one request (initial send, then a 429/account-recovery refetch) can + * keep them on ONE budget instead of handing each leg a fresh one. + */ + onSendsConsumed?: (sends: number) => void; } export type UpstreamSendRecovery = "connection-reset" | "transient-5xx"; @@ -353,23 +360,47 @@ export async function fetchWithResetRetry( * its body intact. Honors Retry-After via retryBackoffDelayMs. * * A failed attempt slower than the slow budget is returned as-is (slow-502 shape); - * note `opts.attempts` is shared with the inner reset layer (no caller passes it today). + * `opts.attempts` is ONE total-send budget covering this layer and the inner reset layer + * together, so it bounds the real number of upstream requests rather than multiplying. */ export async function fetchWithTransientRetry( doFetch: ReplayableFetch, opts: TransientRetryOptions = {}, ): Promise { - const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS); + const budget = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS); const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS; const transientStatuses: number[] = []; + // `attempts` is ONE total-send budget shared with the inner reset layer, not a per-layer + // count. Forwarding it into every `fetchWithResetRetry` made the two multiply: with + // `attempts: 3` the outer loop ran 3 transient rounds and each round independently retried + // 3 connection resets, so a single call could emit 9 upstream sends — and 10 could emit 100. + // That was harmless only because no caller passed `attempts`; the provider-level + // `transientRetryOn5xx` policy is the first one that does, and multiplying load against an + // already-failing provider is worse than not retrying at all. + let sent = 0; + const countedFetch: ReplayableFetch = (recovery) => { + // Incremented BEFORE the await so a rejected send still consumes budget; counting only + // successes would let a reset storm loop without bound. + sent += 1; + return doFetch(recovery); + }; + // Floor of 1 keeps the inner call legal once the budget is spent; the loop condition, not a + // zero-attempt inner call, is what actually stops the retries. + const remaining = () => Math.max(1, budget - sent); + // Reported in `finally` rather than at each exit: this function returns from five places + // and throws from one, and a caller sharing the budget across request legs must be told the + // real count on every one of them. + try { let attemptStart = Date.now(); - let res = await fetchWithResetRetry(doFetch, opts); - for (let attempt = 0; attempt < attempts - 1; attempt++) { + let res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() }); + for (let attempt = 0; sent < budget; attempt++) { if (res.ok || !isTransientUpstreamStatus(res.status)) return res; + // Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives + // a response whose body we just cancelled. if (opts.abortSignal?.aborted) return res; if (Date.now() - attemptStart > slowAttemptMs) return res; console.warn( - `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`, + `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${sent + 1}/${budget})`, ); const delay = retryBackoffDelayMs(attempt, { baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS, @@ -377,16 +408,22 @@ export async function fetchWithTransientRetry( headers: res.headers, }); cancelResponseBodyBestEffort(res); + // Throws on abort (see sleepWithAbort): the rejection propagates, and the body we just + // cancelled belonged to a response we were discarding anyway. await sleepWithAbort(delay, opts.abortSignal); attemptStart = Date.now(); transientStatuses.push(res.status); try { - res = await fetchWithResetRetry(doFetch, opts, "transient-5xx"); + res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() }, "transient-5xx"); } catch (err) { // Keep the prior 5xx evidence attached: the origin already responded, so // this rejection is not pre-connection and must not classify as neutral. throw new UpstreamRetryEvidenceError(transientStatuses, err); } } + // Budget exhausted: the last response is returned with its body intact. return res; + } finally { + opts.onSendsConsumed?.(sent); + } } diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index 7a2d2d330d..4e9f2e60a4 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -9,7 +9,7 @@ * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools. */ import { saveConfigPreservingClaudeCode } from "../config"; -import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy } from "../types"; +import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types"; import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; @@ -34,6 +34,15 @@ const DEFAULT_RATE_LIMIT_RETRY = { respectRetryAfter: true, } as const satisfies Required; +/** + * Default transient-5xx retry used when a provider opts in with a bare + * `transientRetryOn5xx: {}`. `attempts` is a TOTAL send budget, not extra retries. + */ +const DEFAULT_TRANSIENT_RETRY = { + enabled: true, + attempts: 3, +} as const satisfies Required; + /** Map<`${providerName}\0${keyId}`, KeyCooldown> */ const keyCooldowns = new Map(); @@ -114,6 +123,29 @@ export function rateLimitRetryPolicyFor( }; } +/** + * Normalize a provider's `transientRetryOn5xx` policy, or return null when it is absent, + * explicitly disabled, not key-auth, or not the `openai-chat` adapter. + * + * The adapter gate is part of the accepted scope, not incidental: this first version covers + * key-auth `openai-chat` only, and without an explicit check any generic key-auth adapter + * could opt in. Auth mode follows the same fail-closed rule as `rateLimitRetryPolicyFor` — + * explicit `key` or the documented omitted default, never OAuth, forward, local, or an + * unknown value. + */ +export function transientRetryPolicyFor( + provider: Pick, +): Required | null { + const policy = provider.transientRetryOn5xx; + if (!policy || policy.enabled === false) return null; + if (provider.adapter !== "openai-chat") return null; + if (provider.authMode !== undefined && provider.authMode !== "key") return null; + return { + enabled: policy.enabled ?? DEFAULT_TRANSIENT_RETRY.enabled, + attempts: policy.attempts ?? DEFAULT_TRANSIENT_RETRY.attempts, + }; +} + /** * Wait before the next same-target replay: upstream Retry-After (seconds or HTTP-date) when * `respectRetryAfter` is on and the header parses, capped at `maxIntervalMs`; otherwise the diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 2e84dc68d3..a05e7dd289 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -21,6 +21,7 @@ import { isModelTextOnly } from "../vision"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, + fetchWithTransientRetry, prepareSameTarget429Wait, type UpstreamSendRecovery, } from "../lib/upstream-retry"; @@ -33,6 +34,7 @@ import { rateLimitRetryDelayMs, rateLimitRetryPolicyFor, rotateProviderTransportOn429, + transientRetryPolicyFor, } from "../providers/key-failover"; import { fastPolicyForModel } from "../providers/service-tier"; import type { RouteResult } from "../router"; @@ -204,7 +206,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise => { try { - return await fetchWithResetRetry( + // #2643: opted-in key-auth openai-chat providers retry pre-stream transient statuses on + // the native chat lane too; everyone else keeps reset-only semantics. + const transientPolicy = transientRetryPolicyFor(activeProvider); + const fetchWithPolicy = transientPolicy ? fetchWithTransientRetry : fetchWithResetRetry; + return await fetchWithPolicy( (transportRecovery?: UpstreamSendRecovery) => { noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery); return fetchWithHeaderTimeout( @@ -223,7 +229,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio }), ); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + { + abortSignal: upstream.signal, + label: safeHostLabel(request.url), + ...(transientPolicy ? { attempts: transientPolicy.attempts } : {}), + }, ); } finally { request.releaseBodyObservation?.(); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index eb9b603d23..96bb1f8ce5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -227,6 +227,7 @@ import { rateLimitRetryDelayMs, rateLimitRetryPolicyFor, rotateProviderTransportOn429, + transientRetryPolicyFor, } from "../../providers/key-failover"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport"; @@ -5504,6 +5505,13 @@ async function handleResponsesInner( { headers: { "Content-Type": "application/json" } }, ); } + // One request-scoped transient-retry budget owner, declared here so BOTH the initial send + // and the later recovery refetches (429, key/account rotation, OAuth replay) share it. A + // per-leg budget would let a request that recovers several times multiply upstream load. + let transientSendsUsed = 0; + const noteTransientSends = (used: number): void => { transientSendsUsed += Math.max(0, used); }; + const remainingTransientSendBudget = (budget: number): number => + Math.max(1, budget - transientSendsUsed); try { initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); refreshRoutedNamespaceToolAliases(initialRequest); @@ -5558,7 +5566,13 @@ async function handleResponsesInner( // direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other // adapters keep reset-only retry so combo failover still hops on the first 5xx // instead of burning ~1.2s of same-target retries per hop. - const fetchWithRetryPolicy = route.provider.adapter === "google" ? fetchWithTransientRetry : fetchWithResetRetry; + // #2643: an opted-in key-auth openai-chat provider also gets transient-5xx retry. The + // legacy direct-Google exception is preserved exactly; every other adapter still keeps + // reset-only semantics so combo failover hops on the first 5xx. + const transientPolicy = transientRetryPolicyFor(route.provider); + const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; upstreamResponse = await fetchWithRetryPolicy( recovery => { noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery); @@ -5572,7 +5586,13 @@ async function handleResponsesInner( modelId: route.modelId, })); }, - { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) }, + { + abortSignal: upstream.signal, + label: safeHostLabel(builtInitialRequest.url), + ...(transientPolicy + ? { attempts: transientPolicy.attempts, onSendsConsumed: noteTransientSends } + : {}), + }, ); } } catch (err) { @@ -5661,13 +5681,36 @@ async function handleResponsesInner( }), }); } - return await fetchWithHeaderTimeout(retryRequest.url, { - method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, - }, upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - providerName: route.providerName, - modelId: route.modelId, - })); + // #2643 review: this leg used to call fetchWithHeaderTimeout directly, so an + // opted-in provider's transient-5xx policy applied to the initial send and to + // native chat but was silently bypassed here — a 429 that recovered into a + // retryable 503 got no retry on the Responses path. Route it through the same + // selection, and pass what is LEFT of the request-scoped budget rather than a + // fresh one, so a recovery loop cannot multiply total upstream sends. + const refetchTransientPolicy = transientRetryPolicyFor(route.provider); + const refetchWithPolicy = (route.provider.adapter === "google" || refetchTransientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + return await refetchWithPolicy( + recoveryKind => fetchWithHeaderTimeout(retryRequest.url, + applyUpstreamRecoveryInit({ + method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, + }, recoveryKind), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + })), + { + abortSignal: upstream.signal, + label: safeHostLabel(retryRequest.url), + ...(refetchTransientPolicy + ? { + attempts: remainingTransientSendBudget(refetchTransientPolicy.attempts), + onSendsConsumed: noteTransientSends, + } + : {}), + }, + ); } finally { retryRequest.releaseBodyObservation?.(); } @@ -6075,7 +6118,10 @@ async function handleResponsesInner( } // Same #1851 scope guard as the initial send: transient-5xx retry only for direct // Google AI Studio; every other adapter keeps reset-only semantics here. - const fetchContinuationWithRetryPolicy = route.provider.adapter === "google" ? fetchWithTransientRetry : fetchWithResetRetry; + const continuationTransientPolicy = transientRetryPolicyFor(route.provider); + const fetchContinuationWithRetryPolicy = (route.provider.adapter === "google" || continuationTransientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; return await fetchContinuationWithRetryPolicy( recovery => { noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind); @@ -6095,7 +6141,11 @@ async function handleResponsesInner( }), ); }, - { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) }, + { + abortSignal: upstream.signal, + label: safeHostLabel(builtContinuationRequest.url), + ...(continuationTransientPolicy ? { attempts: continuationTransientPolicy.attempts } : {}), + }, ); } finally { builtContinuationRequest.releaseBodyObservation?.(); diff --git a/src/types.ts b/src/types.ts index 08880878df..1a5ab5e83d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -92,6 +92,7 @@ export type { VercelGatewayRouting, ResponsesItemIdRepairConfig, RateLimitRetryPolicy, + TransientRetryPolicy, ProviderCostOverlay, RequestPacingRule, ProviderRequestPacingConfig, diff --git a/src/types/provider.ts b/src/types/provider.ts index fa5da0415a..2dc0748467 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -41,6 +41,25 @@ export interface ResponsesItemIdRepairConfig { repairInvalidIds?: boolean; } +/** + * Opt-in retry for pre-stream transient upstream statuses (500/502/503/504/520/521/522) on + * `providers..transientRetryOn5xx`. + * + * Disabled unless the object is present; a bare `{}` opts in with defaults. Separate from + * `retryOn429`, which handles rate limiting with its own waits. + */ +export interface TransientRetryPolicy { + /** Master switch. Presence of the object also enables the policy (default true). */ + enabled?: boolean; + /** + * TOTAL upstream sends allowed for one request, including the first (1..10, default 3). + * + * Not a per-layer retry count: the connection-reset and transient-status recovery layers + * share this single budget, so `3` means at most three real requests reach the provider. + */ + attempts?: number; +} + /** * Same-target 429 wait-and-retry policy (`providers..retryOn429`). When present and not * explicitly disabled, the proxy waits and replays the identical request on the same key before @@ -556,6 +575,12 @@ export interface OcxProviderConfig { * before any response bytes are relayed, so the replay is lossless. */ retryOn429?: RateLimitRetryPolicy; + /** + * Opt-in retry for pre-stream transient upstream statuses + * (`providers..transientRetryOn5xx`). Disabled unless present; a bare `{}` opts in + * with defaults. Key-auth `openai-chat` only. + */ + transientRetryOn5xx?: TransientRetryPolicy; /** * Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content. diff --git a/tests/upstream-transient-retry.test.ts b/tests/upstream-transient-retry.test.ts index 0f63038138..420060b02e 100644 --- a/tests/upstream-transient-retry.test.ts +++ b/tests/upstream-transient-retry.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { fetchWithTransientRetry, isTransientUpstreamStatus } from "../src/lib/upstream-retry"; +import { transientRetryPolicyFor } from "../src/providers/key-failover"; +import type { OcxProviderConfig } from "../src/types"; function bodyResponse(status: number, headers?: Record): Response { // ReadableStream body so cancel() is observable. @@ -18,7 +20,128 @@ describe("isTransientUpstreamStatus", () => { }); }); +describe("transientRetryPolicyFor", () => { + const base = { adapter: "openai-chat", authMode: "key" } as unknown as OcxProviderConfig; + + test("is off unless the provider opts in", () => { + expect(transientRetryPolicyFor(base)).toBeNull(); + expect(transientRetryPolicyFor({ ...base, transientRetryOn5xx: { enabled: false } })).toBeNull(); + }); + + test("a bare object opts in with defaults", () => { + expect(transientRetryPolicyFor({ ...base, transientRetryOn5xx: {} })).toEqual({ enabled: true, attempts: 3 }); + expect(transientRetryPolicyFor({ ...base, transientRetryOn5xx: { attempts: 5 } })).toEqual({ enabled: true, attempts: 5 }); + }); + + test("only key-auth openai-chat qualifies", () => { + // The adapter gate is the accepted scope, not an incidental detail: without it any + // generic key-auth provider would inherit the policy. + for (const adapter of ["openai-responses", "anthropic", "google"]) { + expect(transientRetryPolicyFor({ ...base, adapter, transientRetryOn5xx: {} } as unknown as OcxProviderConfig)).toBeNull(); + } + // Fail closed on credential shape: OAuth/forward/local are never replayed here. + for (const authMode of ["oauth", "forward", "local"]) { + expect(transientRetryPolicyFor({ ...base, authMode, transientRetryOn5xx: {} } as unknown as OcxProviderConfig)).toBeNull(); + } + // An omitted authMode is the documented key-auth default for custom providers. + expect(transientRetryPolicyFor({ adapter: "openai-chat", transientRetryOn5xx: {} } as unknown as OcxProviderConfig)) + .toEqual({ enabled: true, attempts: 3 }); + }); +}); + describe("fetchWithTransientRetry", () => { + test("attempts is one total-send budget, not a per-layer multiplier", async () => { + // The two layers used to multiply: attempts:3 meant 3 transient rounds each independently + // retrying 3 connection resets, so a single call could emit up to 9 upstream sends. All + // 503s here, so a per-layer count would keep going well past the budget. + let sends = 0; + const res = await fetchWithTransientRetry(async () => { + sends += 1; + return bodyResponse(503); + }, { attempts: 3, slowAttemptMs: 60_000 }); + + // Exactly the budget: 3 real upstream requests, never 9. + expect(sends).toBe(3); + expect(res.status).toBe(503); + // Exhaustion returns the last response with its body intact. + expect((res as Response & { __wasCancelled: () => boolean }).__wasCancelled()).toBe(false); + }); + + test("onSendsConsumed reports the real count so callers can share one budget", async () => { + // A Responses request can send several times across legs: the initial send, then a + // 429/account-recovery refetch. Each leg calls this helper separately, so the only way + // the total stays bounded is if the helper reports what it spent and the next leg + // receives the remainder. Without this the legs each get a fresh budget. + const reported: number[] = []; + let sends = 0; + + // Leg 1: the initial send burns two of three (503 then 200). + await fetchWithTransientRetry(async () => { + sends += 1; + return bodyResponse(sends === 1 ? 503 : 200); + }, { attempts: 3, slowAttemptMs: 60_000, onSendsConsumed: n => reported.push(n) }); + expect(reported).toEqual([2]); + + // Leg 2 (the recovery refetch) gets only the remaining budget: 3 - 2 = 1 send. + const remaining = Math.max(1, 3 - reported[0]!); + expect(remaining).toBe(1); + let legTwoSends = 0; + const res = await fetchWithTransientRetry(async () => { + legTwoSends += 1; + return bodyResponse(503); + }, { attempts: remaining, slowAttemptMs: 60_000, onSendsConsumed: n => reported.push(n) }); + + // One send, not a fresh three: the request-scoped total stays at the configured 3. + expect(legTwoSends).toBe(1); + expect(reported).toEqual([2, 1]); + expect(reported.reduce((a, b) => a + b, 0)).toBe(3); + expect(res.status).toBe(503); + }); + + test("onSendsConsumed still reports when the helper throws", async () => { + // The evidence-error path is a throw, not a return. If it skipped reporting, a caller + // sharing the budget would under-count and hand the next leg too much. + const reported: number[] = []; + let sends = 0; + await expect(fetchWithTransientRetry(async () => { + sends += 1; + if (sends === 1) return bodyResponse(503); + const err = new Error("socket hang up") as Error & { code?: string }; + err.code = "ECONNRESET"; + throw err; + }, { attempts: 3, slowAttemptMs: 60_000, onSendsConsumed: n => reported.push(n) })).rejects.toThrow(); + expect(reported.length).toBe(1); + expect(reported[0]!).toBeGreaterThan(0); + }); + + test("a connection reset and a transient status share the same budget", async () => { + // Mixed recovery: the reset layer and the transient layer draw from one pool. With a + // per-layer count the reset retries would have been free, so this would emit more than 3. + let sends = 0; + const res = await fetchWithTransientRetry(async () => { + sends += 1; + if (sends === 1) { + const err = new Error("socket hang up") as Error & { code?: string }; + err.code = "ECONNRESET"; + throw err; + } + return bodyResponse(sends === 3 ? 200 : 503); + }, { attempts: 3, slowAttemptMs: 60_000 }); + + expect(sends).toBe(3); + expect(res.status).toBe(200); + }); + + test("a clean sequence still spends only what it needs", async () => { + let sends = 0; + const responses = [bodyResponse(503), bodyResponse(503), bodyResponse(200)]; + const res = await fetchWithTransientRetry(async () => { + return responses[sends++]!; + }, { attempts: 3, slowAttemptMs: 60_000 }); + expect(sends).toBe(3); + expect(res.status).toBe(200); + }); + test("retries a 502 then returns the 200; failed body is cancelled", async () => { const first = bodyResponse(502) as Response & { __wasCancelled: () => boolean }; const responses = [first, bodyResponse(200)];