diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 350911638a5..e3dd0a32982 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -202,7 +202,7 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul | Erreur classée comme erreur d’authentification, d’abonnement, de quota, de limitation de débit, de surcharge ou de serveur en amont | Place la cible en période de refroidissement et bascule, même si le statut seul ne suffit pas. | | Annulation client (499), `origin_rejected`, refus de cyber-politique ou autre demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. | | Dépassement de contexte confirmé avant la sortie, y compris HTTP 400 | Essaie la prochaine cible admissible non tentée sans refroidissement ; à épuisement, renvoie la première erreur de contexte HTTP 400 confirmée (un refus de contexte avec un autre statut fait aussi passer à la cible suivante, sans être conservé). | -| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | Bascule vers la cible admissible suivante avant le début de la sortie, sans délai de refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | +| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | En cas de refus d'effort, tente le niveau publié immédiatement inférieur sur la même cible tant que le budget d'envoi le permet ; si aucun niveau ne fonctionne, bascule avant l'engagement de sortie sans refroidissement si le refus correspond à une enveloppe de compatibilité reconnue, sinon reste terminal. Les autres incompatibilités répertoriées basculent sans refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | | Premier appel d'outil d'un tour Responses exécuté par un adaptateur interne (`runTurn`) que la requête courante n'a pas déclaré, avant toute sortie et tout effet de bord non rejouable | Met la cible en refroidissement et bascule avec le même catalogue d'outils. Après une sortie visible ou un effet de bord non rejouable, le refus est définitif. Les requêtes Chat Completions et Anthropic Messages ne changent pas. | | Toute autre erreur non classifiée | Arrêtez et renvoyez l'erreur. | @@ -378,7 +378,7 @@ gérer un refus de politique ou corriger l’origine de la demande rejetée. Les ## Compatibilité des paramètres facultatifs -Exception aux erreurs 400 terminales : un rejet structuré de `user`, une valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, un rejet d’entrée d’image propre à un modèle (`param: input`), ou le refus d’un média d’entrée que la route ne peut pas lire (`unsupported_input_modality`) peut faire passer le combo à la cible admissible suivante avant le début de la sortie, sans délai de refroidissement. Le refus de sécurité, l’annulation et une sortie déjà commencée restent non rejouables. +Exception aux erreurs 400 terminales : un rejet structuré de `user`, une valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, un rejet d’entrée d’image propre à un modèle (`param: input`), ou le refus d’un média d’entrée que la route ne peut pas lire (`unsupported_input_modality`), ainsi qu'après l'enveloppe fournisseur exacte `{"code":"invalid-argument","error":"Invalid reasoning effort."}`. Lorsqu'une cible de combo refuse l'effort de raisonnement demandé, le combo réessaye d'abord cette cible au niveau d'effort publié immédiatement inférieur, un niveau par refus et dans la limite du budget d'envoi partagé ; cela s'applique à tout refus d'effort reconnu. Si aucun niveau inférieur ne réussit, un refus correspondant à l'une des formes ci-dessus passe à une autre cible sans refroidir la cible rejetée, en conservant le 400 initial de cette cible lorsqu'il ne reste plus d'envoi disponible pour le niveau suivant ; les autres refus d'effort restent terminaux. Une route qui refuse le média de la requête avec le code `unsupported_input_modality` avance de la même façon. Ce sont des incompatibilités pour cette requête, et non la preuve que la cible est défaillante. Les requêtes directes conservent leur réparation unique par rétrogradation d'effort. La récupération de compatibilité ne transforme pas silencieusement `none` en un effort différent et n'élargit pas le saut de combo à des requêtes invalides arbitraires. Le refus de sécurité, l’annulation et une sortie déjà commencée restent non rejouables. Une requête à cible unique renvoie toujours un rejet non résolu en amont. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index a5d99236f6c..770f16c139f 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -685,7 +685,7 @@ provider, see [Fallback off](/guides/fallback-off/). | Confirmed context-window overflow before output, including HTTP 400 | Try the next untried eligible target without cooling this target; another model may have a larger window. Exhaustion returns the first confirmed HTTP 400 context-length error; a context refusal sent with another status still advances but is not retained. | | Client cancellation (499), `origin_rejected`, cyber-policy refusal, or other invalid request | Stop and return the error; another target would not make the request valid. | | Exact HTTP 400 saying a model is not supported when using Codex with a ChatGPT account | Try the next declared target before output commitment. The message must be the whole refusal in `detail`, `error.message`, or bare text; competing `detail` and `error` envelopes stay terminal. This does not quarantine the account or cool every model on the provider. | -| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | Hop before output commitment without cooling; see request-local target compatibility below. | +| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | For an effort refusal, try the next lower published rung on the same target while the send budget allows; if no rung works, hop before output commitment without cooling if the refusal matches a recognized compatibility shape, otherwise stay terminal. Other listed mismatches hop without cooling; see request-local target compatibility below. | | First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. | | Any other unclassified error | Stop and return the error. | @@ -832,7 +832,7 @@ OpenCodex also commits the current target instead of growing memory without a bo When routing Claude Code to the canonical ChatGPT Codex backend, OpenCodex removes the unsupported top-level `user` metadata field without changing the session/cache key, input messages, tool schemas, or safety identifiers. Public Responses API and noncanonical forward gateways keep that field. -A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. This is a mismatch for that request, not evidence that the target is unhealthy, so it records no cooldown. This compatibility recovery does not silently change `none` into a different effort or broaden this exception to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. +A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection, and after the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}`. When a combo target refuses the requested reasoning effort, the combo first retries that target at the next lower published effort rung, one rung per refusal and within the shared send budget; this applies to any recognized effort refusal. If no lower rung succeeds, a refusal in one of the shapes above advances to another target without cooling the rejected one, keeping that target's original 400 when the next rung has no send left; other effort refusals stay terminal. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. These are mismatches for this request, not evidence that the target is unhealthy. Direct requests retain their single effort-downgrade repair. Compatibility recovery does not silently change `none` into a different effort or broaden the combo hop to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. ## Tool calls stay on one target diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 7e2de37adb2..c7aa6361f64 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -129,7 +129,7 @@ ocx combo set balanced \ |クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | | 出力前に確認したコンテキスト超過(HTTP 400 を含む) | クールダウンせず次の未試行の適格な対象へ進み、使い切った場合は最初に確認した HTTP 400 のコンテキストエラーを返します(他のステータスのコンテキスト拒否も次へ進みますが、保持されません)。 | | ChatGPT アカウントで Codex を使う際に、そのモデルをサポートしないことを示す正確な HTTP 400 の拒否 | 出力を確定する前に、次の設定済みターゲットを試します。拒否文全体が `detail`、`error.message`、または本文そのものにある場合に限ります。`detail` と `error` が競合する応答は終了扱いです。この拒否だけでアカウントを再認証待ちにしたり、プロバイダーの全モデルをクールダウンしたりしません。 | -|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 | +|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |推論エフォートの拒否の場合、送信バジェットが許す限り同じターゲットで次に低い公開ラングを試行します。どのラングも機能しない場合は、認識された互換性のある形式に一致すれば冷却せずに出力確定前に次のターゲットへ進み、それ以外は終了します。その他の記載された不一致はクールダウンなしで進みます。任意パラメーターの互換性を参照してください。 | |インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 | |その他の未分類のエラー |停止してエラーを返します。 | @@ -275,7 +275,7 @@ ocx combo remove --yes ## 任意パラメーターの互換性 -通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化エラーでは、出力開始前に次の適格なターゲットへ進めます。この不一致ではクールダウンを記録しません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。 +通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化エラー、およびベンダーエンベロープ `{"code":"invalid-argument","error":"Invalid reasoning effort."}` では、出力確定前に次の適格なターゲットへ進めます。コンボターゲットが要求された推論エフォートを拒否した場合、コンボはまずそのターゲットで次に低い公開エフォートラングを、拒否ごとに1ラングずつ、共有送信バジェットの範囲内で再試行します。これは認識されたすべてのエフォート拒否に適用されます。より低いラングがいずれも成功しない場合、上記のいずれかの形式の拒否は拒否されたターゲットを冷却することなく別のターゲットへ進み、次のラングに送信枠が残っていない場合はそのターゲットの元の 400 を保持します。その他のエフォート拒否は終了扱いのままです。メディアを `unsupported_input_modality` で拒否するルートも同様に進みます。これらはこのリクエストに対する不一致であり、ターゲットが異常である証拠ではありません。直接リクエストは単一のエフォートダウングレード修復を保持します。互換性回復によって `none` が別のエフォートに暗黙に変更されたり、任意の無効なリクエストにコンボホップが拡大されたりすることはありません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。単一ターゲットのリクエストは未解決の上流拒否を返します。 [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b21bd2543b7..87c76bab7a8 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -135,7 +135,7 @@ ocx combo set balanced \ | 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | | 출력 전 확인된 컨텍스트 초과 오류(HTTP 400 포함) | 쿨다운 없이 다음 미시도 적격 대상을 시도합니다. 모두 실패하면 처음 확인된 HTTP 400 컨텍스트 오류를 반환합니다(다른 상태 코드의 컨텍스트 거부도 다음 대상으로 넘어가지만 보관되지 않습니다). | | ChatGPT 계정으로 Codex를 사용할 때 해당 모델을 지원하지 않는다는 정확한 HTTP 400 거부 | 출력이 시작되기 전에 다음 설정 대상으로 넘어갑니다. 거부 문장 전체가 `detail`, `error.message` 또는 일반 텍스트에 있어야 하며, `detail`과 `error`가 충돌하면 중단합니다. 이 사유만으로 계정을 재인증 상태로 바꾸거나 공급자의 모든 모델에 쿨다운을 걸지는 않습니다. | -| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | +| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 추론 노력 거부의 경우 전송 예산이 허용하는 동안 동일한 대상에서 다음으로 낮은 게시된 단계를 시도합니다. 작동하는 단계가 없으면 거부가 인지된 호환성 형태와 일치할 경우 출력 확정 전에 쿨다운 없이 다음 대상으로 넘어가며, 그렇지 않으면 종료됩니다. 기타 나열된 불일치는 쿨다운 없이 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | | 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. | | 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. | @@ -291,7 +291,7 @@ opencodex 인스턴스에 기록했는지 확인하세요. ## 선택적 매개변수 호환성 -일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 오류는 출력 시작 전에 다음 적격 대상으로 넘어갈 수 있습니다. 이 경우 쿨다운을 기록하지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다. +일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 오류 및 정확한 벤더 엔벨로프 `{"code":"invalid-argument","error":"Invalid reasoning effort."}`의 경우 출력 확정 전에 다음 적격 대상으로 넘어갈 수 있습니다. 콤보 대상이 요청된 추론 노력을 거부하면, 콤보는 먼저 해당 대상에 대해 공유 전송 예산 내에서 거부당 한 단계씩 다음으로 낮은 게시된 노력 단계로 재시도합니다. 이는 인식된 모든 노력 거부에 적용됩니다. 더 낮은 단계가 모두 성공하지 못하면 위의 형태 중 하나에 해당하는 거부는 거부된 대상을 냉각하지 않고 다른 대상으로 넘어가며, 다음 단계에 남은 전송이 없을 때 해당 대상의 원래 400을 유지합니다. 기타 노력 거부는 종료 상태로 유지됩니다. `unsupported_input_modality` 코드로 요청의 미디어를 거부하는 경로도 동일하게 진행됩니다. 이는 이 요청에 대한 불일치일 뿐 대상이 비정상이라는 증거가 아닙니다. 직접 요청은 단일 노력 다운그레이드 복구를 유지합니다. 호환성 복구는 `none`을 다른 노력으로 자동으로 변경하거나 임의의 잘못된 요청으로 콤보 홉을 넓히지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다. 단일 대상 요청은 여전히 해결되지 않은 업스트림 거부를 반환합니다. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 4ad78e53649..cca045801d9 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -166,7 +166,7 @@ ocx combo set balanced \ | Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy или иной некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. | | Подтверждённое превышение контекста до вывода, включая HTTP 400 | Попробовать следующую допустимую цель без cooldown; при исчерпании вернуть первую подтверждённую ошибку контекста HTTP 400 (отказ по контексту с другим статусом тоже переводит к следующей цели, но не сохраняется). | | Точный отказ HTTP 400 о том, что модель не поддерживается в Codex с аккаунтом ChatGPT | До начала вывода пробует следующую настроенную цель. Полная фраза отказа должна находиться в `detail`, `error.message` или обычном тексте; смешанные объекты `detail` и `error` завершают запрос. Сам этот отказ не переводит аккаунт в состояние повторного входа и не включает период ожидания для всех моделей провайдера. | -| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) | До начала вывода переходит к следующей допустимой цели без охлаждения; см. «Совместимость необязательных параметров» ниже. | +| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) | При отказе в усилиях рассуждения (reasoning effort) пробует следующую более низкую опубликованную ступень на той же цели, пока позволяет бюджет отправок; если ни одна ступень не подошла, переходит к следующей цели до фиксации вывода без охлаждения, если отказ соответствует распознанному формату совместимости, иначе остается завершающим. Другие перечисленные несоответствия переходят без охлаждения; см. «Совместимость необязательных параметров» ниже. | | Первый вызов инструмента в Responses-ходе внутрипроцессного адаптера (`runTurn`), который текущий запрос не объявлял, до любого вывода и до побочного эффекта, небезопасного для повтора | Охлаждает цель и переходит к следующей с тем же каталогом инструментов. После видимого вывода или небезопасного для повтора побочного эффекта отказ окончателен. Запросы Chat Completions и Anthropic Messages не меняются. | | Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. | @@ -336,7 +336,7 @@ CLI и дашборд показывают точное сообщение ва ## Совместимость необязательных параметров -Исключение для завершающих ошибок 400: структурированный отказ от `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`) позволяет до начала вывода перейти к следующей допустимой цели без периода охлаждения. Отказ политики безопасности, отмена и уже начавшийся вывод по-прежнему запрещают повторное выполнение. +Исключение для завершающих ошибок 400: структурированный отказ от `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort`, специфичный для модели отказ входного изображения (`param: input`) или отказ маршрута, который не может прочитать входные медиаданные (`unsupported_input_modality`), а также точный конверт поставщика `{"code":"invalid-argument","error":"Invalid reasoning effort."}` позволяют до начала вывода перейти к следующей допустимой цели. Когда цель combo отклоняет запрошенное усилие рассуждения, combo сначала повторяет попытку для этой цели на следующей более низкой опубликованной ступени усилий (по одной ступени на отказ в рамках общего бюджета отправок); это относится к любому распознанному отказу в усилиях. Если ни одна более низкая ступень не увенчалась успехом, отказ одной из указанных выше форм переходит к другой цели без охлаждения отклоненной цели, сохраняя исходную ошибку 400 этой цели, когда для следующей ступени не осталось отправок; другие отказы в усилиях остаются завершающими. Маршрут, отклоняющий медиаданные запроса с кодом `unsupported_input_modality`, продвигается точно так же. Это несоответствия для данного запроса, а не признак неработоспособности цели. Прямые запросы сохраняют единичное восстановление с понижением усилий. Восстановление совместимости не заменяет неявно `none` на другое усилие и не расширяет переход combo на произвольные недействительные запросы. Отказ политики безопасности, отмена и уже начавшийся вывод по-прежнему запрещают повторное выполнение. Запрос к одной цели по-прежнему возвращает неразрешенный отказ вышестоящего сервиса. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index da74bcfd061..81a2551284a 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -231,7 +231,7 @@ ikiye ayrılır. | Sınıflandırılmış kimlik doğrulama, abonelik, kota, hız sınırı, aşırı yük veya yukarı akış sunucu hatası | Yalnızca durum yeterli olmadığında bile hedefi soğutun ve atlayın. | | İstemci iptali (499), `origin_rejected`, siber politika reddi veya diğer geçersiz istek | Durun ve hatayı döndürün; başka bir hedef isteği geçerli kılmaz. | | Çıktıdan önce doğrulanan bağlam aşımı (HTTP 400 dahil) | Soğutma olmadan denenmemiş uygun hedefe geçer; hedefler tükenirse ilk doğrulanan HTTP 400 bağlam hatasını döndürür (başka durum koduyla gelen bağlam reddi de sonraki hedefe geçirir ama saklanmaz). | -| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış HTTP 400 | Çıktı başlamadan önce bekleme süresi kaydetmeden sonraki uygun hedefe atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | +| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış HTTP 400 | Bir efor reddi için, gönderim bütçesi izin verdiği sürece aynı hedefte bir sonraki daha düşük yayınlanmış basamağı deneyin; hiçbir basamak çalışmazsa, ret tanınan bir uyumluluk kalıbıyla eşleşiyorsa çıktı taahhüt edilmeden önce bekleme süresi kaydetmeden atlayın, aksi takdirde sonlandırıcı kalın. Listelenen diğer uyuşmazlıklar bekleme süresi olmadan atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | | Süreç içi bir bağdaştırıcının (`runTurn`) yürüttüğü Responses turunda, geçerli isteğin bildirmediği ilk araç çağrısı (herhangi bir çıktıdan ve yeniden oynatılamaz yan etkiden önce) | Hedefi bekleme süresine alır ve aynı araç kataloğuyla sonraki hedefe atlar. Görünür çıktıdan veya yeniden oynatılamaz bir yan etkiden sonra ret kesindir. Chat Completions ve Anthropic Messages istekleri değişmez. | | Diğer sınıflandırılmamış hatalar | Durun ve hatayı döndürün. | @@ -417,7 +417,7 @@ reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapma ## İsteğe bağlı parametre uyumluluğu -Sonlandırıcı 400 hatalarının dar bir istisnası vardır: `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış hata, çıktı başlamadan önce sonraki uygun hedefe geçebilir. Bu uyumsuzluk için bekleme süresi kaydedilmez. Güvenlik politikası reddi, iptal ve başlamış çıktı yeniden yürütülmez. +Sonlandırıcı 400 hatalarının dar bir istisnası vardır: `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren, modele özgü görüntü girdisini reddeden (`param: input`) veya rotanın okuyamadığı girdi medyasını reddeden (`unsupported_input_modality`) yapılandırılmış hata ile tam satıcı zarfı `{"code":"invalid-argument","error":"Invalid reasoning effort."}` , çıktı başlamadan önce sonraki uygun hedefe geçebilir. Bir combo hedefi istenen akıl yürütme eforunu reddettiğinde, combo önce paylaşılan gönderim bütçesi dahilinde her ret için bir basamak olmak üzere bu hedefi bir sonraki daha düşük yayınlanmış efor basamağında yeniden dener; bu tanınan tüm efor retleri için geçerlidir. Daha düşük hiçbir basamak başarılı olmazsa, yukarıdaki şekillerden birindeki ret, reddedilen hedefi soğutmadan başka bir hedefe ilerler ve bir sonraki basamağın gönderim hakkı kalmadığında hedefin orijinal 400 hatasını korur; diğer efor retleri sonlandırıcı kalır. Medyayı `unsupported_input_modality` ile reddeden bir rota da aynı şekilde ilerler. Bunlar bu istek için uyuşmazlıklardır, hedefin sağlıksız olduğuna dair kanıt değildir. Doğrudan istekler tek efor düşürme onarımlarını korur. Uyumluluk kurtarması sessizce `none` değerini farklı bir efora dönüştürmez veya combo atlamasını rastgele geçersiz isteklere genişletmez. Güvenlik politikası reddi, iptal ve başlamış çıktı yeniden yürütülmez. Tek hedefli bir istek yine de çözülmemiş bir yukarı akış reddi döndürür. [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 2ad62ced0b9..42c83108686 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -155,7 +155,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | | 输出前确认的上下文超限(包括 HTTP 400) | 不冷却该目标,尝试下一个尚未尝试的合格目标;耗尽后返回第一个确认的 HTTP 400 上下文错误(其他状态码的上下文拒绝也会切换到下一个目标,但不会被保留)。 | | HTTP 400 明确表示使用 ChatGPT 账户运行 Codex 时不支持该模型 | 在输出提交前尝试下一个已配置目标。完整拒绝句必须位于 `detail`、`error.message` 或纯文本中;同时包含 `detail` 与 `error` 的混合封装仍会终止请求。此原因本身不会将账户标为需要重新登录,也不会让供应商的所有模型进入冷却。 | -| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`) | 在输出开始前跳转到下一个符合条件的目标,且不记录冷却时间;参见下方可选参数兼容性。 | +| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`) | 对于 reasoning effort 拒绝,在发送预算允许的情况下先在同一目标上尝试下一个更低的已发布档位;若所有档位均未成功,在拒绝符合已识别的兼容形式时在提交输出前跳转且不记录冷却时间,否则保持终止。其他列出的不匹配直接跳转且不记录冷却时间;参见下方可选参数兼容性。 | | 由进程内适配器(`runTurn`)执行的 Responses 回合中,当前请求未声明的第一个工具调用(在任何输出和不可重放的副作用之前) | 让该目标进入冷却,并以相同的工具目录跳转到下一个目标。出现可见输出或不可重放的副作用之后,拒绝即为最终结果。Chat Completions 和 Anthropic Messages 请求不受影响。 | | 任何其他未分类错误 | 停止并返回错误。 | @@ -305,7 +305,7 @@ combo id 不存在。响应是 HTTP 404,类型为 `invalid_request_error`。 ## 可选参数兼容性 -一般 400 错误仍会终止请求,但明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`)的结构化错误,可让 combo 在输出开始前尝试下一个符合条件的目标,而不记录冷却时间。安全策略拒绝、取消以及已经开始的输出仍不可重放。 +一般 400 错误仍会终止请求,但明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值、返回模型特定图像输入拒绝(`param: input`),或拒绝该路由无法读取的输入媒体(`unsupported_input_modality`)的结构化错误,以及完全匹配的供应商包 `{"code":"invalid-argument","error":"Invalid reasoning effort."}`,可在输出提交前跳转。当 combo 目标拒绝所请求的 reasoning effort 时,combo 首先在共享发送预算内、每次拒绝退一个档位地在该目标上重试下一个更低的已发布 effort 档位;这适用于所有识别出的 effort 拒绝。如果更低档位均未成功,符合上述形式之一的拒绝将前进到另一个目标而不冷却被拒绝的目标,在下一档位无剩余发送额度时保留该目标的原始 400;其他 effort 拒绝保持终止。以 `unsupported_input_modality` 拒绝媒体的路由以相同方式跳转。这些是针对该请求的不匹配,并非目标不健康的证据。直接请求保留其单次 effort 降级修复。兼容性恢复不会隐式将 `none` 改为其他 effort,也不会将 combo 跳转扩大到任意无效请求。安全策略拒绝、取消以及已经开始的输出仍不可重放。单目标请求仍返回未解决的上游拒绝。 [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 6d90897006d..0f9396e2008 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -166,7 +166,7 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 | 分類為認證、訂閱、配額、限流、過載或上游伺服器錯誤 | 冷卻目標並跳轉,即使單靠狀態碼不足。 | | 客戶端取消(499)、`origin_rejected`、cyber-policy 拒絕、其他無效請求 | 停止並回傳錯誤;另一個目標不會讓請求變為有效。 | | 輸出前確認的上下文超限(包括 HTTP 400) | 不冷卻該目標,嘗試下一個尚未嘗試的合格目標;耗盡後回傳第一個確認的 HTTP 400 上下文錯誤(其他狀態碼的上下文拒絕也會切換到下一個目標,但不會被保留)。 | -| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`) | 在輸出開始前跳轉到下一個符合條件的目標,且不記錄冷卻時間;參見下方選用參數相容性。 | +| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`) | 對於 reasoning effort 拒絕,在發送預算允許的情況下先在同一目標上嘗試下一個更低的已發布檔位;若所有檔位均未成功,在拒絕符合已識別的相容形式時於提交輸出前跳轉且不記錄冷卻時間,否則保持終止。其他列出的不相容直接跳轉且不記錄冷卻時間;參見下方選用參數相容性。 | | 由行程內轉接器(`runTurn`)執行的 Responses 回合中,目前請求未宣告的第一個工具呼叫(在任何輸出與不可重播的副作用之前) | 讓該目標進入冷卻,並以相同的工具目錄跳轉到下一個目標。出現可見輸出或不可重播的副作用之後,拒絕即為最終結果。Chat Completions 與 Anthropic Messages 請求不受影響。 | | 任何其他未分類錯誤 | 停止並回傳錯誤。 | @@ -300,7 +300,7 @@ Combo id 未知。回應為 HTTP 404 並帶 type `invalid_request_error`。執 ## 選用參數相容性 -一般 400 錯誤仍會終止請求,但明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`)的結構化錯誤,可讓 combo 在輸出開始前嘗試下一個符合條件的目標,而不記錄冷卻時間。安全政策拒絕、取消及已開始的輸出仍不可重播。 +一般 400 錯誤仍會終止請求,但明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值、回傳模型特定影像輸入拒絕(`param: input`),或拒絕該路由無法讀取的輸入媒體(`unsupported_input_modality`)的結構化錯誤,以及完全相符的供應商包 `{"code":"invalid-argument","error":"Invalid reasoning effort."}`,可在輸出提交前跳轉。當 combo 目標拒絕所請求的 reasoning effort 時,combo 首先在共用發送預算內、每次拒絕退一個檔位地在該目標上重試下一個更低的已發布 effort 檔位;這適用於所有識別出的 effort 拒絕。如果更低檔位均未成功,符合上述形式之一的拒絕將前進到另一個目標而不冷卻被拒絕的目標,在下一檔位無剩餘發送額度時保留該目標的原始 400;其他 effort 拒絕保持終止。以 `unsupported_input_modality` 拒絕媒體的路由以相同方式跳轉。這些是針對該請求的不相容,並非目標不健康的證據。直接請求保留其單次 effort 降級修復。相容性復原不會隱式將 `none` 改為其他 effort,也不會將 combo 跳轉擴大到任意無效請求。安全政策拒絕、取消及已開始的輸出仍不可重播。單目標請求仍回傳未解決的上游拒絕。 [Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bcb8d32afe0..54e5a77e833 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1579,7 +1579,7 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", - "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", + "responses-reasoning-effort-diagnostics.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", "responses-translated-reset.test.ts": "responses", diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 99849ad694c..32c6096879a 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -1,7 +1,9 @@ import { parseResetCooldownMs } from "../codex/routing"; import { classifyError, isCyberPolicyCode } from "../lib/errors"; +import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal"; +import { hasConflictingReasoningEffortCode } from "../providers/reasoning-metadata"; import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal"; import type { OcxComboTarget } from "../types"; import { targetKey } from "./types"; @@ -384,6 +386,160 @@ function normalizedFailureCode(code?: string | null): string { return code?.trim().toLowerCase().replaceAll("-", "_") ?? ""; } +/** + * xAI can reject a model/account effort rung with this exact vendor envelope. It is target-local + * evidence for a combo: the same model may accept a lower rung, or a later target may accept the + * request. Keep the match exact so unrelated invalid arguments stay terminal. + */ +const EXACT_EFFORT_COMPATIBLE_CODES = new Set(["", "invalid_request_error", "invalid_argument"]); +const EXACT_EFFORT_COMPATIBLE_TYPES = new Set(["", "invalid_request_error", "upstream_error"]); + +function hasIncompatibleDiagnosticCarrier(val: unknown, depth = 0): boolean { + if (val === undefined || val === null) return false; + if (depth > 8) return true; + if (Array.isArray(val)) { + if (val.length > 32) return true; + for (const item of val) { + if (hasIncompatibleDiagnosticCarrier(item, depth + 1)) return true; + } + return false; + } + if (typeof val === "string") { + try { + const parsed = JSON.parse(val); + if (parsed && typeof parsed === "object") { + return hasIncompatibleDiagnosticCarrier(parsed, depth + 1); + } + return true; + } catch { + return true; + } + } + if (typeof val === "object") { + const rec = val as Record; + if (rec.param !== undefined && rec.param !== null) { + if (typeof rec.param !== "string") return true; + const param = rec.param.trim().toLowerCase(); + if (param.length > 0 && param !== "reasoning_effort" && param !== "reasoning.effort") return true; + } + if (rec.code !== undefined && rec.code !== null) { + if (typeof rec.code !== "string") return true; + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(normalizedFailureCode(rec.code))) return true; + } + if (rec.type !== undefined && rec.type !== null) { + if (typeof rec.type !== "string") return true; + if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(normalizedFailureCode(rec.type))) return true; + } + if (rec.error !== undefined && rec.error !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.error, depth + 1)) return true; + } + if (rec.response !== undefined && rec.response !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.response, depth + 1)) return true; + } + if (rec.last_error !== undefined && rec.last_error !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.last_error, depth + 1)) return true; + } + if (rec.details !== undefined && rec.details !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.details, depth + 1)) return true; + } + if (rec.detail !== undefined && rec.detail !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.detail, depth + 1)) return true; + } + if (rec.errors !== undefined && rec.errors !== null) { + if (hasIncompatibleDiagnosticCarrier(rec.errors, depth + 1)) return true; + } + if (rec.message !== undefined && rec.message !== null) { + if (typeof rec.message !== "string" || rec.message !== "Invalid reasoning effort.") return true; + } + return false; + } + return false; +} + +function isExactInvalidReasoningEffortRefusal(status: number, message: string, code?: string | null): boolean { + if (status !== 400 || message.length > 16_384) return false; + if (code !== undefined && code !== null && typeof code !== "string") return false; + const outerCode = normalizedFailureCode(code); + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(outerCode)) return false; + if (isHardPolicyOrOriginRefusal(message) || hasConflictingReasoningEffortCode(message)) return false; + let text = message.trim(); + for (let depth = 0; depth < 3; depth += 1) { + if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length).trim(); + if (text.startsWith("data:") && !text.includes("\n")) text = text.slice("data:".length).trim(); + let payload: unknown; + try { payload = JSON.parse(text); } catch { return false; }; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const root = payload as Record; + if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; + if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; + if (root.message !== undefined && root.message !== null && typeof root.message !== "string") return false; + if (root.param !== undefined && root.param !== null) { + if (typeof root.param !== "string") return false; + const param = root.param.trim().toLowerCase(); + if (param !== "reasoning_effort" && param !== "reasoning.effort") return false; + } + const rootCode = normalizedFailureCode(root.code as string | undefined); + const rootType = normalizedFailureCode(root.type as string | undefined); + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(rootCode)) return false; + if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(rootType)) return false; + if (root.response !== undefined && root.response !== null) return false; + if (root.last_error !== undefined && root.last_error !== null) return false; + if (hasIncompatibleDiagnosticCarrier(root.details)) return false; + if (hasIncompatibleDiagnosticCarrier(root.detail)) return false; + if (hasIncompatibleDiagnosticCarrier(root.errors)) return false; + if (rootCode === "invalid_argument" + && root.error === "Invalid reasoning effort." + && (root.type === undefined || root.type === "invalid_request_error") + && (root.param === undefined || root.param === "reasoning_effort" || root.param === "reasoning.effort")) { + if (root.message !== undefined && root.message !== null && root.message !== "Invalid reasoning effort.") return false; + return true; + } + const error = root.error; + if (!error || typeof error !== "object" || Array.isArray(error)) return false; + const record = error as Record; + if (record.code !== undefined && record.code !== null && typeof record.code !== "string") return false; + if (record.type !== undefined && record.type !== null && typeof record.type !== "string") return false; + if (record.message !== undefined && record.message !== null && typeof record.message !== "string") return false; + if (record.error !== undefined && record.error !== null && typeof record.error !== "string") return false; + if (record.param !== undefined && record.param !== null) { + if (typeof record.param !== "string") return false; + const param = record.param.trim().toLowerCase(); + if (param !== "reasoning_effort" && param !== "reasoning.effort") return false; + } + if (root.param !== undefined && root.param !== null && record.param !== undefined && record.param !== null) { + if (root.param.trim().toLowerCase() !== record.param.trim().toLowerCase()) return false; + } + const errorCode = normalizedFailureCode(record.code as string | undefined); + const errorType = normalizedFailureCode(record.type as string | undefined); + if (!EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) return false; + if (!EXACT_EFFORT_COMPATIBLE_TYPES.has(errorType)) return false; + if (record.response !== undefined && record.response !== null) return false; + if (record.last_error !== undefined && record.last_error !== null) return false; + if (hasIncompatibleDiagnosticCarrier(record.details)) return false; + if (hasIncompatibleDiagnosticCarrier(record.detail)) return false; + if (hasIncompatibleDiagnosticCarrier(record.errors)) return false; + if (record.type === "upstream_error" && typeof record.message === "string" + && record.message.startsWith("Provider error 400: ") + && EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode)) { + text = record.message; + continue; + } + if (root.type === "upstream_error" && typeof root.message === "string" + && root.message.startsWith("Provider error 400: ") + && EXACT_EFFORT_COMPATIBLE_CODES.has(rootCode)) { + text = root.message; + continue; + } + if (root.message !== undefined && root.message !== null && root.message !== "Invalid reasoning effort.") return false; + if (record.error !== undefined && record.error !== null && record.error !== "Invalid reasoning effort.") return false; + return record.type === "invalid_request_error" + && record.message === "Invalid reasoning effort." + && EXACT_EFFORT_COMPATIBLE_CODES.has(errorCode) + && (record.param === undefined || record.param === "reasoning_effort" || record.param === "reasoning.effort"); + } + return false; +} + /** * A spent account window, by structured code or upstream prose. Status is deliberately not * consulted: the ChatGPT Codex backend reports a depleted plan window as HTTP 502 @@ -514,6 +670,7 @@ const PROVIDER_SCOPED_FAILURE_CODES = new Set([ * Image rejection requires `param: input` and an exact model-scoped prefix. */ function isRequestLocalTargetIncompatibility(status: number, message: string, code?: string | null): boolean { + if (isExactInvalidReasoningEffortRefusal(status, message, code)) return true; if (status !== 400 || message.length > 16_384) return false; const genericCodes = new Set(["", "invalid_request_error", "unsupported_parameter", "unsupported_value"]); if (!genericCodes.has(normalizedFailureCode(code))) return false; @@ -523,10 +680,20 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co let payload: unknown; try { payload = JSON.parse(text); } catch { return false; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; - const error = (payload as Record).error; + const root = payload as Record; + if (root.code !== undefined && root.code !== null && typeof root.code !== "string") return false; + if (root.type !== undefined && root.type !== null && typeof root.type !== "string") return false; + if (root.param !== undefined && root.param !== null && typeof root.param !== "string") return false; + const rootCode = normalizedFailureCode(root.code as string | undefined); + if (!genericCodes.has(rootCode)) return false; + if (root.type !== undefined && root.type !== null && root.type !== "invalid_request_error" && root.type !== "upstream_error") return false; + const error = root.error; if (!error || typeof error !== "object" || Array.isArray(error)) return false; const e = error as Record; if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false; + if (root.param !== undefined && root.param !== null && typeof e.param === "string") { + if (root.param.trim().toLowerCase() !== e.param.trim().toLowerCase()) return false; + } const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined); if (!genericCodes.has(errorCode) || typeof e.message !== "string") return false; if (e.type !== "invalid_request_error" && e.type !== "upstream_error") return false; diff --git a/src/lib/hard-refusal.ts b/src/lib/hard-refusal.ts new file mode 100644 index 00000000000..c7202db0c5a --- /dev/null +++ b/src/lib/hard-refusal.ts @@ -0,0 +1,259 @@ +import { + isCyberPolicyCode, + isCyberPolicyMessage, + isTerminalRefusalCode, + safetyRefusalCodeFromMessage, +} from "./errors"; +import { isNonReplayableUpstreamCode } from "./upstream-retry"; + +const SERIALIZED_HARD_CODE = + /\bcode\s*[:=]\s*["']?(?:cyber_policy|origin_rejected|bio_policy|misalignment_policy_violation|invalid_prompt|upstream_no_response|upstream_closed_before_response|upstream_reset_replay_refused)\b/i; + +export interface ExtractedJsonEnvelope { + value: unknown; + prefix: string; + suffix: string; +} + +const HARD_REFUSAL_TOKEN = + /\b(?:cyber_policy|origin_rejected|bio_policy|misalignment_policy_violation|invalid_prompt)\b/i; + +function isAuthoritativeHardRefusalProse(str: string | undefined): boolean { + if (!str) return false; + const s = str.trim(); + if (!s) return false; + if (isCyberPolicyMessage(s)) return true; + if (/origin_rejected/i.test(s)) return true; + if (HARD_REFUSAL_TOKEN.test(s)) return true; + if (SERIALIZED_HARD_CODE.test(s)) return true; + if (safetyRefusalCodeFromMessage(s)) return true; + return false; +} + +/** + * Extract a balanced or prefixed JSON object/array envelope from error text, + * capturing any authoritative diagnostic prefix and suffix outside the JSON delimiters. + */ +export function extractJsonEnvelopeWithContext(text: string): ExtractedJsonEnvelope | undefined { + let s = text.trim(); + let strippedPrefix = ""; + for (let d = 0; d < 3; d += 1) { + if (s.startsWith("Provider error 400: ")) { + strippedPrefix += s.slice(0, "Provider error 400: ".length); + s = s.slice("Provider error 400: ".length).trim(); + } else if (s.startsWith("Provider error: ")) { + strippedPrefix += s.slice(0, "Provider error: ".length); + s = s.slice("Provider error: ".length).trim(); + } else if (s.startsWith("Gateway error 400: ")) { + strippedPrefix += s.slice(0, "Gateway error 400: ".length); + s = s.slice("Gateway error 400: ".length).trim(); + } else if (s.startsWith("Gateway error: ")) { + strippedPrefix += s.slice(0, "Gateway error: ".length); + s = s.slice("Gateway error: ".length).trim(); + } + if (s.startsWith("data:") && !s.includes("\n")) { + strippedPrefix += s.slice(0, "data:".length); + s = s.slice("data:".length).trim(); + } + } + const firstBrace = s.indexOf("{"); + const lastBrace = s.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace > firstBrace) { + try { + const value = JSON.parse(s.slice(firstBrace, lastBrace + 1)); + return { + value, + prefix: (strippedPrefix + " " + s.slice(0, firstBrace)).trim(), + suffix: s.slice(lastBrace + 1).trim(), + }; + } catch { + // ignore + } + } + const firstBracket = s.indexOf("["); + const lastBracket = s.lastIndexOf("]"); + if (firstBracket !== -1 && lastBracket > firstBracket) { + try { + const value = JSON.parse(s.slice(firstBracket, lastBracket + 1)); + return { + value, + prefix: (strippedPrefix + " " + s.slice(0, firstBracket)).trim(), + suffix: s.slice(lastBracket + 1).trim(), + }; + } catch { + // ignore + } + } + return undefined; +} + +/** + * Extract a balanced or prefixed JSON object/array envelope from error text. + * Strips common gateway prefixes and extracts content between the outermost JSON delimiters. + */ +export function extractJsonEnvelope(text: string): unknown | undefined { + return extractJsonEnvelopeWithContext(text)?.value; +} + +/** + * Detect a hard policy or origin refusal across known upstream envelope shapes and diagnostic text. + * When an error is a hard refusal (e.g. cyber_policy, origin_rejected, bio_policy), recovery must + * stop immediately without retrying or walking any parameter ladders (#8173). + * + * Traverses bounded nested wrappers (e.g. Provider error 400, data:, nested error JSON) so wrapped + * policy refusals are recognized rather than treated as prose. + */ +export function isHardPolicyOrOriginRefusal(text: string | undefined, depth = 0): boolean { + if (!text || depth > 4) return false; + + let unescaped = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (unescaped.startsWith("Provider error 400: ")) { + unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } else if (unescaped.startsWith("Provider error: ")) { + unescaped = unescaped.slice("Provider error: ".length).trim(); + } else if (unescaped.startsWith("Gateway error 400: ")) { + unescaped = unescaped.slice("Gateway error 400: ".length).trim(); + } else if (unescaped.startsWith("Gateway error: ")) { + unescaped = unescaped.slice("Gateway error: ".length).trim(); + } + if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { + unescaped = unescaped.slice("data:".length).trim(); + } + } + + // Restrict structured policy/origin checks to authoritative diagnostic carriers + // so echoed user request content (e.g. request.input, prompt, echoed body) does not + // suppress valid recoveries (#8173). + let parsed: unknown; + let hasOuterRefusal = false; + try { + parsed = JSON.parse(unescaped); + } catch { + const extracted = extractJsonEnvelopeWithContext(unescaped); + if (extracted) { + parsed = extracted.value; + if ( + isAuthoritativeHardRefusalProse(extracted.prefix) + || isAuthoritativeHardRefusalProse(extracted.suffix) + ) { + hasOuterRefusal = true; + } + } + } + + if (hasOuterRefusal) { + return true; + } + + if (parsed && typeof parsed === "object") { + const queue: unknown[] = [parsed]; + let examined = 0; + while (queue.length > 0 && examined < 32) { + const candidate = queue.shift(); + examined += 1; + if (!candidate || typeof candidate !== "object") continue; + if (Array.isArray(candidate)) { + if (candidate.length > 32) return true; + for (const item of candidate) { + if (item && typeof item === "object") { + queue.push(item); + } else if (typeof item === "string") { + let itemParsed: unknown; + let itemHasOuterRefusal = false; + try { + itemParsed = JSON.parse(item); + } catch { + const itemExtracted = extractJsonEnvelopeWithContext(item); + if (itemExtracted) { + itemParsed = itemExtracted.value; + if ( + isAuthoritativeHardRefusalProse(itemExtracted.prefix) + || isAuthoritativeHardRefusalProse(itemExtracted.suffix) + ) { + itemHasOuterRefusal = true; + } + } + } + + if (itemHasOuterRefusal) { + return true; + } + + if (itemParsed && typeof itemParsed === "object") { + queue.push(itemParsed); + } else if (isAuthoritativeHardRefusalProse(item)) { + return true; + } + } + } + continue; + } + const rec = candidate as Record; + + const code = typeof rec.code === "string" ? rec.code.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (code) { + if ( + isCyberPolicyCode(code) + || code === "origin_rejected" + || isTerminalRefusalCode(code) + || isNonReplayableUpstreamCode(code) + ) { + return true; + } + } + + const type = typeof rec.type === "string" ? rec.type.trim().toLowerCase().replaceAll("-", "_") : undefined; + if (type) { + if ( + isCyberPolicyCode(type) + || type === "origin_rejected" + || isTerminalRefusalCode(type) + ) { + return true; + } + } + + // Authoritative diagnostic carriers only (avoid request echo fields like input, prompt, request, body, reason). + // Parse structured diagnostic strings first so nested request echo is not matched by prose rules (#8173). + for (const field of ["message", "error", "details", "detail", "last_error", "response", "errors"] as const) { + const val = rec[field]; + if (typeof val === "string") { + let innerParsed: unknown; + let innerHasOuterRefusal = false; + try { + innerParsed = JSON.parse(val); + } catch { + const innerExtracted = extractJsonEnvelopeWithContext(val); + if (innerExtracted) { + innerParsed = innerExtracted.value; + if ( + isAuthoritativeHardRefusalProse(innerExtracted.prefix) + || isAuthoritativeHardRefusalProse(innerExtracted.suffix) + ) { + innerHasOuterRefusal = true; + } + } + } + + if (innerHasOuterRefusal) { + return true; + } + + if (innerParsed && typeof innerParsed === "object") { + queue.push(innerParsed); + } else if (isAuthoritativeHardRefusalProse(val)) { + return true; + } + } else if (val && typeof val === "object") { + queue.push(val); + } + } + } + return false; + } + + if (isAuthoritativeHardRefusalProse(unescaped)) return true; + + return false; +} diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 6c3cef10005..ba4e281b962 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -26,6 +26,12 @@ import { join } from "node:path"; // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts. import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; +import { + extractJsonEnvelope, + extractJsonEnvelopeWithContext, + isHardPolicyOrOriginRefusal, +} from "../lib/hard-refusal"; +export { extractJsonEnvelope, extractJsonEnvelopeWithContext, isHardPolicyOrOriginRefusal }; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -428,7 +434,7 @@ export async function flushReasoningSupportCache(): Promise { * these beside the effort term is what separates "the gateway rejected reasoning effort" from * "the gateway rejected something else and echoed the request back". */ -const REJECTION_LANGUAGE = /unsupported|not supported|does not support|invalid|unrecognized|unknown|not allowed|not permitted|must be|requires|required|cannot|can't|out of range/i; +const REJECTION_LANGUAGE = /unsupported|not supported|does not support|invalid|not valid|unrecognized|unknown|not allowed|not permitted|must be|requires|required|cannot|can't|out of range/i; /** * How far from the effort term the rejection language may sit and still be about it. Kept @@ -445,29 +451,1061 @@ const REJECTION_WINDOW = 48; */ const GENERIC_ERROR_TYPE = /invalid_request_error/gi; +const EFFORT_COMPATIBLE_CODES = new Set([ + "", + "invalid_request_error", + "invalid_argument", + "invalid_parameter", + "unsupported_parameter", + "unsupported_value", + "invalid_parameter_error", +]); +const EFFORT_COMPATIBLE_TYPES = new Set([ + "", + "invalid_request_error", + "upstream_error", + "bad_request", +]); + /** - * Evidence test for a rejection body: does it blame reasoning effort? + * Error codes and types arrive as snake_case, kebab-case or PascalCase depending on the gateway + * (Volcengine Ark sends `InvalidParameter` / `BadRequest`). Fold all three to snake_case before the + * compatibility lookup so a generic vendor code is not read as naming some other field. + */ +function normalizeErrorTag(value: string): string { + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replaceAll("-", "_"); +} + +function isIncompleteStructuredWrapper(text: string): boolean { + let s = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (s.startsWith("Provider error 400: ")) { + s = s.slice("Provider error 400: ".length).trim(); + } else if (s.startsWith("Provider error: ")) { + s = s.slice("Provider error: ".length).trim(); + } else if (s.startsWith("Gateway error 400: ")) { + s = s.slice("Gateway error 400: ".length).trim(); + } else if (s.startsWith("Gateway error: ")) { + s = s.slice("Gateway error: ".length).trim(); + } + if (s.startsWith("data:") && !s.includes("\n")) { + s = s.slice("data:".length).trim(); + } + } + if (s.startsWith("{") || s.startsWith("[")) { + try { + JSON.parse(s); + return false; + } catch { + return true; + } + } + if (/^(?:(?:Provider|Gateway) error (?:400:\s*)?|data:\s*)[{\[]/i.test(text.trim())) { + return true; + } + const firstBrace = s.indexOf("{"); + const lastBrace = s.lastIndexOf("}"); + if (firstBrace !== -1) { + let openBraces = 0; + let inString = false; + let escape = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (!inString) { + if (ch === "{") openBraces += 1; + else if (ch === "}") openBraces -= 1; + } + } + if (openBraces !== 0) return true; + if (lastBrace > firstBrace && s.slice(firstBrace, lastBrace + 1).includes(":")) { + try { + JSON.parse(s.slice(firstBrace, lastBrace + 1)); + } catch { + return true; + } + } + } + const firstBracket = s.indexOf("["); + const lastBracket = s.lastIndexOf("]"); + if (firstBracket !== -1) { + let openBrackets = 0; + let inString = false; + let escape = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (!inString) { + if (ch === "[") openBrackets += 1; + else if (ch === "]") openBrackets -= 1; + } + } + if (openBrackets !== 0) return true; + const bracketSlice = s.slice(firstBracket, lastBracket + 1); + if ( + lastBracket > firstBracket && + (bracketSlice.includes('"') || bracketSlice.includes("'") || bracketSlice.includes(":") || bracketSlice.includes(",")) + ) { + try { + JSON.parse(bracketSlice); + } catch { + // "reasoning_effort must be one of ['low','medium','high']" is a value list in prose, not + // a truncated or reflected envelope (#8173). + if (!isEffortValueEnumeration(bracketSlice)) return true; + } + } + } + return false; +} + +const EFFORT_PARAM_NAMES = new Set([ + "reasoning_effort", + "reasoning.effort", + "reasoning", + "reasoning_parameters", + "effort", + // A thinking-budget field is the same subject on the Anthropic-shaped wires: a refusal that + // names it is about the reasoning control, not about some other parameter. + "thinking_budget", + "thinking.budget", + "thinking.budget_tokens", +]); + +function cleanParam(str: string): string { + return str.trim().toLowerCase().replaceAll("-", "_").replace(/[^a-z0-9_]+$/gi, "").replace(/^[^a-z0-9_]+/gi, ""); +} + +const KNOWN_NON_EFFORT_PARAMS = new Set([ + "temperature", + "top_p", + "top_k", + "max_tokens", + "max_completion_tokens", + "max_output_tokens", + "min_tokens", + "stop", + "stop_sequences", + "presence_penalty", + "frequency_penalty", + "repetition_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "seed", + "tools", + "tool_choice", + "functions", + "function_call", + "response_format", + "stream", + "stream_options", + "user", + "service_tier", + "modalities", + "prediction", + "parallel_tool_calls", + "messages", + "prompt", + "n", + "best_of", +]); + +const GENERIC_PARAM_WORDS = new Set([ + "request", + "requests", + "argument", + "arguments", + "parameter", + "parameters", + "param", + "params", + "payload", + "body", + "input", + "inputs", + "json", + "format", + "data", + "content", + "value", + "values", + "field", + "fields", + "call", + "type", + "error", + "message", + "model", + "url", + "header", + "headers", + "auth", + "api", + "key", + "token", + "status", + "code", + "response", + "gateway", + "provider", + "invalid", + "unsupported", + "unknown", + "unrecognized", + "unexpected", + "unallowed", + "it", + "this", + "that", + "these", + "those", + "which", + "what", + "all", + "each", + "every", + "both", + "either", + "neither", + "one", + "they", + "there", + "here", + "string", + "number", + "boolean", + "integer", + "array", + "object", + "null", + "none", +]); + +const EFFORT_RUNGS = new Set([ + "none", + "low", + "medium", + "high", + "max", + "xhigh", + "ultra", + "minimal", + "standard", + "default", +]); + +/** + * Words that describe a diagnostic, never name a request field. + * + * "Unsupported parameter: 'reasoning_effort'" puts an adjective exactly where a field name sits + * in "`field` parameter", and "Unsupported value: 'max'" puts the refused value under a noun. + * Reading either as the competing subject made genuine effort refusals terminal (#8173). + */ +const DIAGNOSTIC_NOISE_WORDS = new Set([ + "unsupported", + "invalid", + "unrecognized", + "unknown", + "unexpected", + "bad", + "malformed", + "value", + "values", + "parameter", + "parameters", + "param", + "params", + "field", + "fields", + "argument", + "arguments", + "arg", + "property", + "request", + "response", + "payload", + "body", + "error", + // Determiners and participles that introduce the subject: "The following parameter is not + // supported: reasoning_effort" puts one where a field name would sit. + "following", + "specified", + "given", + "provided", + "requested", + "supplied", + "above", + "a", + "an", + "the", + "same", +]); + +/** + * A bare number (or numeric literal) is a value in every diagnostic that names a limit, never a + * field name, so "thinking budget 32000 is not supported" is not a competing-parameter refusal. + */ +const NUMERIC_VALUE_TOKEN = /^[0-9]+(?:\.[0-9]+)?$/; + +/** Connectives and glue words allowed inside a published-value enumeration. */ +const EFFORT_ENUMERATION_FILLER = new Set([ + "and", + "or", + "values", + "value", + "supported", + "allowed", + "available", + "one", + "of", + "are", + "is", +]); + +/** + * Whether a bracket slice is just a list of effort rungs, e.g. `['low','medium','high']`. + * + * "reasoning_effort must be one of ['low','medium','high']" is prose that lists the ladder. The + * structured-wrapper checks treat any bracket payload with commas and quotes as a malformed or + * reflected envelope, which made this genuine refusal terminal (#8173). A payload composed only + * of rung names is a value list, not an envelope. + */ +function isEffortValueEnumeration(slice: string): boolean { + const tokens = slice.match(/[A-Za-z_][A-Za-z0-9_.\-]*|[0-9]+(?:\.[0-9]+)?/g); + if (!tokens || tokens.length === 0) return false; + return tokens.every(token => { + const normalized = token.toLowerCase(); + return EFFORT_RUNGS.has(normalized) || EFFORT_ENUMERATION_FILLER.has(normalized); + }); +} + +/** True when every bracketed group in the text is an effort-value enumeration. */ +function carriesOnlyEffortEnumerations(text: string): boolean { + const groups = text.match(/\[[^\]]*\]/g); + return !!groups && groups.length > 0 && groups.every(isEffortValueEnumeration); +} + +const STRUCTURAL_EFFORT_WORDS = new Set([ + "level", + "mode", + "rung", + "rungs", +]); + +const SUBJECT_FOLLOWING_VERBS = new Set([ + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "has", + "have", + "had", + "does", + "do", + "did", + "cannot", + "cant", + "can", + "could", + "will", + "would", + "shall", + "should", + "may", + "might", + "must", +]); + +const REASONING_EFFORT_CONTEXT_REGEX = /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\b/i; + +/** How far from a rung value its own clause may name effort and still describe it. */ +const EFFORT_DESCRIPTOR_WINDOW = 64; + +function isExplicitFieldOrParameter(text: string, matchIndex: number, param: string): boolean { + if (SUBJECT_FOLLOWING_VERBS.has(param)) return false; + // "Unsupported parameter", "Invalid value", "Missing field" put a diagnostic word exactly + // where a field name sits. It is never the subject the sentence is about (#8173). + if (DIAGNOSTIC_NOISE_WORDS.has(param)) return false; + // A rung value is never a field name: "Invalid reasoning effort. The max field is invalid." + // names its subject explicitly in the sentence before, and no diagnostic says "the max field" + // about an unrelated field named max. Judging the value on its own words first keeps the + // quoted-field windows below from being applied to a ladder rung. + if (EFFORT_RUNGS.has(param) && !namesRungAsDeclaredField(text, matchIndex, param)) return false; + const pre = text.slice(Math.max(0, matchIndex - 48), matchIndex); + if ( + /\b(?:reasoning[._\s]+effort|reasoning[._\s]+parameters|reasoning|effort)\s+(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test( + pre + ) + ) { + return false; + } + if (/\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test(pre)) return true; + const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + 32)); + if (/^\s*['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/i.test(post)) return true; + return false; +} + +/** + * Whether the rung at `matchIndex` is introduced as a field in its own right — the shapes + * "the max field", "Invalid field: max", "parameter max" — rather than as a value under a + * diagnostic noun such as "Unsupported value: 'max'". + */ +function namesRungAsDeclaredField(text: string, matchIndex: number, param: string): boolean { + const pre = text.slice(Math.max(0, matchIndex - 48), matchIndex); + if (/\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)\s*$/i.test(pre)) return true; + const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + 32)); + return /^\s*['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/i.test(post); +} + +function isEffortDescriptorInContext(text: string, matchIndex: number, param: string): boolean { + if (EFFORT_RUNGS.has(param)) { + if (namesRungAsDeclaredField(text, matchIndex, param)) return false; + // A rung is an effort descriptor when the surrounding words tie it to effort. The context + // window is the whole diagnostic minus OTHER parameters: a message that ends + // "Supported values are: 'low','medium','high'" must not turn the refused 'max' into a + // competing field merely because the same sentence lists a ladder. + const pre = text.slice(Math.max(0, matchIndex - EFFORT_DESCRIPTOR_WINDOW), matchIndex); + if (REASONING_EFFORT_CONTEXT_REGEX.test(pre)) return true; + const post = text.slice(matchIndex + param.length, Math.min(text.length, matchIndex + param.length + EFFORT_DESCRIPTOR_WINDOW)); + return REASONING_EFFORT_CONTEXT_REGEX.test(post); + } + if (STRUCTURAL_EFFORT_WORDS.has(param)) { + const preText = text.slice(Math.max(0, matchIndex - 32), matchIndex); + return /(?:reasoning[._\s]+effort|reasoning|effort)\s*[:=]?\s*['"`]?$/i.test(preText); + } + return false; +} + +function tokenStartIndex(m: RegExpExecArray, rawToken: string, isPrefixToken: boolean): number { + const offset = isPrefixToken ? m[0].indexOf(rawToken) : m[0].lastIndexOf(rawToken); + return offset >= 0 ? m.index + offset : m.index; +} + +function isCompetingParameterToken(text: string, tokenIndex: number, rawToken: string): boolean { + const p = cleanParam(rawToken); + if (!p || EFFORT_PARAM_NAMES.has(p) || SUBJECT_FOLLOWING_VERBS.has(p)) return false; + // A diagnostic adjective or noun ("Unsupported", "invalid", "value", "field") occupies the + // syntactic slot of a field name without naming one. GENERIC_PARAM_WORDS covers most of them; + // "bad" and "malformed" were missing there, so the two sets stay separate. + if (DIAGNOSTIC_NOISE_WORDS.has(p)) return false; + // A bare number names a limit, not a request field ("thinking budget 32000 is not supported"). + if (NUMERIC_VALUE_TOKEN.test(p)) return false; + if (isExplicitFieldOrParameter(text, tokenIndex, p)) return true; + if (GENERIC_PARAM_WORDS.has(p)) return false; + if (isEffortDescriptorInContext(text, tokenIndex, p)) return false; + return true; +} + +/** + * Concrete request fields a diagnostic can name instead of effort. Unlike the heuristic subject + * patterns below, a member of this list is a real wire field, so naming one beside an + * authoritative effort param really is a different subject. + */ +const CONCRETE_FIELD_TERM = + /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|safety_identifier|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt|input|image_url)\b/gi; + +/** + * Whether the text names a concrete non-effort request field while rejecting something. * - * The parameter name on its own is not evidence. A 400 that refuses `max_tokens` may still - * echo the whole request body back, `reasoning_effort` included, and treating that as a - * refusal spends this request's one downgrade replay on a rung the upstream never objected to - * — and persists a false refusal that clamps every later turn for thirty days. + * This is the only inference allowed to override a structured `param` that already names + * reasoning effort. Everything heuristic in `diagnosticTextNamesOtherParameter` — adjective + * subjects, quoted rungs, "value:" prefixes — must not, or it would re-break every canonical + * effort refusal whose prose happens to quote the refused value (#8173). + */ +function diagnosticTextNamesConcreteOtherField(text: string): boolean { + for (let m = CONCRETE_FIELD_TERM.exec(text); m; m = CONCRETE_FIELD_TERM.exec(text)) { + const from = Math.max(0, m.index - REJECTION_WINDOW); + const to = Math.min(text.length, m.index + m[0].length + REJECTION_WINDOW); + if (REJECTION_LANGUAGE.test(text.slice(from, to))) return true; + } + return false; +} + +function diagnosticTextNamesOtherParameter(text: string, authoritativeEffortSubject = false): boolean { + if (!text) return false; + const s = text.replace(GENERIC_ERROR_TYPE, " "); + if (authoritativeEffortSubject) return diagnosticTextNamesConcreteOtherField(s); + + const paramPattern = /\b(?:parameter|param|argument|arg|field|property)\s*(?:[:=]\s*['"`]?|['"`]|\s+)['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; + for (let m = paramPattern.exec(s); m; m = paramPattern.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], false); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + + const prefixParamPattern = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:parameter|param|argument|arg|field|property)\b/gi; + for (let m = prefixParamPattern.exec(s); m; m = prefixParamPattern.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + + const subjectMustBe = /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+must\s+be\b/gi; + for (let m = subjectMustBe.exec(s); m; m = subjectMustBe.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + + const subjectIsRejection = + /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s+(?:is|are)\s+(?:invalid|not\s+valid|unsupported|unrecognized|unknown|not\s+allowed|not\s+permitted|not\s+supported|required|missing)\b/gi; + for (let m = subjectIsRejection.exec(s); m; m = subjectIsRejection.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + + const subjectColon = + /\b['"`]?([a-zA-Z0-9_\-\.]+)['"`]?\s*:\s*(?:must\s+be|invalid|unsupported|unrecognized|unknown|expected|required|missing|not\s+allowed)\b/gi; + for (let m = subjectColon.exec(s); m; m = subjectColon.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], true); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + + const rejectionExtra = + /\b(?:unexpected|missing|cannot\s+(?:specify|use|set|pass|provide))\s+(?:field|param|parameter|argument|property)?\s*['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; + for (let m = rejectionExtra.exec(s); m; m = rejectionExtra.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], false); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + + const nonEffortTerm = /\b(?:temperature|top_p|top_k|max_tokens|max_completion_tokens|max_output_tokens|min_tokens|stop|stop_sequences|presence_penalty|frequency_penalty|repetition_penalty|logit_bias|logprobs|top_logprobs|seed|tools|tool_choice|functions|function_call|response_format|stream|stream_options|user|service_tier|modalities|prediction|parallel_tool_calls|messages|prompt)\b/gi; + for (let m = nonEffortTerm.exec(s); m; m = nonEffortTerm.exec(s)) { + const from = Math.max(0, m.index - REJECTION_WINDOW); + const to = Math.min(s.length, m.index + m[0].length + REJECTION_WINDOW); + if (REJECTION_LANGUAGE.test(s.slice(from, to))) return true; + } + + const rejectionDirect = /\b(?:invalid|unsupported|unrecognized|unknown)\s+['"`]?([a-zA-Z0-9_\-\.]+)['"`]?/gi; + for (let m = rejectionDirect.exec(s); m; m = rejectionDirect.exec(s)) { + const tokenIndex = tokenStartIndex(m, m[1], false); + if (isCompetingParameterToken(s, tokenIndex, m[1])) return true; + } + return false; +} + +function isConflictingDiagnosticProse(str: string | undefined, authoritativeEffortSubject = false): boolean { + if (!str) return false; + const s = str.trim(); + if (!s) return false; + if ( + /\b(?:permission[-_]denied|insufficient[-_]quota|rate[-_]limit[-_]exceeded|model[-_]not[-_]found|context[-_]length[-_]exceeded|access[-_]denied|unauthorized|invalid[-_]api[-_]key|account[-_]deactivated|billing[-_]hard[-_]limit)\b/i.test(s) + ) { + return true; + } + const codeMatch = /\bcode\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); + if (codeMatch && codeMatch[1]) { + const code = normalizeErrorTag(codeMatch[1]); + if (!EFFORT_COMPATIBLE_CODES.has(code) && !isHttpStatusText(code)) return true; + } + const typeMatch = /\btype\s*[:=]\s*["']?([a-zA-Z0-9_\-]+)["']?/i.exec(s); + if (typeMatch && typeMatch[1]) { + const type = normalizeErrorTag(typeMatch[1]); + if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; + } + if (diagnosticTextNamesOtherParameter(s, authoritativeEffortSubject)) { + return true; + } + return false; +} + +/** + * Whether a numeric code is the HTTP status echoed back rather than a vendor error code. + * + * Google answers a bad reasoning effort with `{"code":400,"error":{"message":"…"}}`, where the + * numeric field is the response status. Treating it as an unrecognized semantic code made that + * genuine refusal terminal (#8173). Only 4xx is accepted: a numeric 5xx is a server failure and + * must keep its terminal verdict. + */ +function isHttpStatusText(token: string): boolean { + if (!NUMERIC_VALUE_TOKEN.test(token)) return false; + const value = Number(token); + return Number.isInteger(value) && value >= 400 && value <= 499; +} + +function isHttpStatusNumber(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 499; +} + +/** + * Rejects error envelopes that carry a conflicting code (e.g. permission_denied, insufficient_quota, + * rate_limit_exceeded, model_not_found, malformed non-string code) or competing diagnostic parameters + * (e.g. reasoning_effort at root with an inner error identifying temperature) so they stay terminal + * or proceed to combo failover without false reasoning-effort learning or same-target ladder retries (#8173). */ -export function isReasoningEffortRejection(text: string | undefined): boolean { +export function hasConflictingReasoningEffortCode(text: string | undefined): boolean { if (!text) return false; + if (isIncompleteStructuredWrapper(text)) return true; + let unescaped = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (unescaped.startsWith("Provider error 400: ")) { + unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } + if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { + unescaped = unescaped.slice("data:".length).trim(); + } + } + let parsed: unknown; + let outerPrefix = ""; + let outerSuffix = ""; + try { + parsed = JSON.parse(unescaped); + } catch { + const extracted = extractJsonEnvelopeWithContext(unescaped); + if (extracted) { + parsed = extracted.value; + outerPrefix = extracted.prefix; + outerSuffix = extracted.suffix; + } + } + if (isConflictingDiagnosticProse(outerPrefix) || isConflictingDiagnosticProse(outerSuffix)) { + return true; + } + if (!parsed || typeof parsed !== "object") { + if (isIncompleteStructuredWrapper(unescaped)) return true; + const hasBraces = unescaped.includes("{") && unescaped.includes("}"); + const hasBrackets = + unescaped.includes('["') || + (unescaped.includes("[") && + unescaped.includes("]") && + (unescaped.includes('"') || unescaped.includes("'") || unescaped.includes(","))); + if (hasBraces || (hasBrackets && !carriesOnlyEffortEnumerations(unescaped))) return true; + return isConflictingDiagnosticProse(unescaped); + } + try { + const queue: unknown[] = [parsed]; + let examined = 0; + while (queue.length > 0 && examined < 32) { + const candidate = queue.shift(); + examined += 1; + if (!candidate || typeof candidate !== "object") continue; + if (Array.isArray(candidate)) { + if (candidate.length > 32) return true; + for (const item of candidate) { + if (item && typeof item === "object") { + queue.push(item); + } else if (typeof item === "string") { + if (item.length > 16_384) return true; + let innerItem: unknown; + let innerPrefix = ""; + let innerSuffix = ""; + try { + innerItem = JSON.parse(item); + } catch { + const extractedItem = extractJsonEnvelopeWithContext(item); + if (extractedItem) { + innerItem = extractedItem.value; + innerPrefix = extractedItem.prefix; + innerSuffix = extractedItem.suffix; + } + } + if (innerItem && typeof innerItem === "object") { + if (isConflictingDiagnosticProse(innerPrefix) || isConflictingDiagnosticProse(innerSuffix)) { + return true; + } + queue.push(innerItem); + } else if (isIncompleteStructuredWrapper(item)) { + return true; + } else { + const trimmed = item.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsedItem = JSON.parse(item); + if (parsedItem && typeof parsedItem === "object") { + queue.push(parsedItem); + } else { + return true; + } + } catch { + return true; + } + } else { + const codeLike = normalizeErrorTag(trimmed); + if ( + !codeLike.includes(" ") && + (codeLike.includes("_") || codeLike.length < 32) && + !EFFORT_COMPATIBLE_CODES.has(codeLike) && + !EFFORT_COMPATIBLE_TYPES.has(codeLike) + ) { + return true; + } + if (isConflictingDiagnosticProse(trimmed)) { + return true; + } + } + } + } else { + return true; + } + } + continue; + } + const rec = candidate as Record; + // A structured param/field naming effort is authoritative about the subject. Text + // inference may only override it by naming a concrete other request field, never by + // reading a diagnostic adjective or the refused value as a field name (#8173). + const authoritativeEffortSubject = typeof rec.param === "string" && EFFORT_PARAM_NAMES.has(rec.param.trim().toLowerCase()); + if (rec.param !== undefined && rec.param !== null) { + if (typeof rec.param !== "string") return true; + const param = rec.param.trim().toLowerCase(); + if (param.length > 0 && !EFFORT_PARAM_NAMES.has(param)) return true; + } + if (rec.code !== undefined && rec.code !== null) { + if (isHttpStatusNumber(rec.code)) { + // An HTTP status echoed into `code` says nothing about which field was refused. + } else if (typeof rec.code !== "string") return true; + else { + const code = normalizeErrorTag(rec.code); + if (!EFFORT_COMPATIBLE_CODES.has(code)) return true; + } + } + if (rec.type !== undefined && rec.type !== null) { + if (typeof rec.type !== "string") return true; + const type = normalizeErrorTag(rec.type); + if (!EFFORT_COMPATIBLE_TYPES.has(type)) return true; + } + for (const field of ["message", "error", "details", "detail", "response", "last_error", "errors"] as const) { + const val = rec[field]; + if (val && typeof val === "object") { + queue.push(val); + } else if (typeof val === "string") { + let inner: unknown; + let innerPrefix = ""; + let innerSuffix = ""; + try { + inner = JSON.parse(val); + } catch { + const extracted = extractJsonEnvelopeWithContext(val); + if (extracted) { + inner = extracted.value; + innerPrefix = extracted.prefix; + innerSuffix = extracted.suffix; + } + } + if (inner && typeof inner === "object") { + if (isConflictingDiagnosticProse(innerPrefix, authoritativeEffortSubject) || isConflictingDiagnosticProse(innerSuffix, authoritativeEffortSubject)) { + return true; + } + queue.push(inner); + } else if (isIncompleteStructuredWrapper(val)) { + return true; + } else if (isConflictingDiagnosticProse(val, authoritativeEffortSubject)) { + return true; + } + } + } + } + if (queue.length > 0) { + return true; + } + } catch { + return false; + } + return false; +} + +function diagnosticTextBlamesReasoningEffort(text: string): boolean { + if (diagnosticTextNamesOtherParameter(text)) return false; if (/unsupported.{0,24}effort/i.test(text)) return true; - // An upstream that names the offending parameter has already said which one it means. - if (/["']?param["']?\s*[:=]\s*["']?(?:reasoning[._ ]effort|reasoning)/i.test(text)) return true; const scanned = text.replace(GENERIC_ERROR_TYPE, " "); const term = /reasoning\.effort|reasoning_effort|reasoning effort|thinking budget|reasoning_parameters/gi; for (let match = term.exec(scanned); match; match = term.exec(scanned)) { - const from = Math.max(0, match.index - REJECTION_WINDOW); - const to = Math.min(scanned.length, match.index + match[0].length + REJECTION_WINDOW); - if (REJECTION_LANGUAGE.test(scanned.slice(from, to))) return true; + const preText = scanned.slice(0, match.index); + const postText = scanned.slice(match.index + match[0].length); + const lastBoundary = Math.max( + preText.lastIndexOf(";"), + preText.lastIndexOf("\n"), + preText.lastIndexOf("|"), + preText.search(/\.(?:\s+|$)(?=[^.]*$)/) + ); + const clauseStart = lastBoundary !== -1 ? lastBoundary + 1 : 0; + const nextBoundary = postText.search(/[;\n|]|\.(?:\s+|$)/); + const clauseEnd = nextBoundary !== -1 ? match.index + match[0].length + nextBoundary : scanned.length; + + const from = Math.max(clauseStart, match.index - REJECTION_WINDOW); + const to = Math.min(clauseEnd, match.index + match[0].length + REJECTION_WINDOW); + const windowText = scanned.slice(from, to); + if (REJECTION_LANGUAGE.test(windowText)) { + if (!diagnosticTextNamesOtherParameter(windowText)) { + return true; + } + } + } + return false; +} + +function diagnosticRecordBlamesReasoningEffort(rec: Record, depth = 0): boolean { + if (depth > 4) return false; + if (typeof rec.param === "string") { + const param = rec.param.trim().toLowerCase(); + if (EFFORT_PARAM_NAMES.has(param)) return true; + return false; + } + if (typeof rec.message === "string") { + if (rec.message.includes("{") || rec.message.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.message, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.message)) return true; + } + if (typeof rec.error === "string") { + if (rec.error.includes("{") || rec.error.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.error, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.error)) return true; + } + if (typeof rec.detail === "string") { + if (rec.detail.includes("{") || rec.detail.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.detail, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.detail)) return true; + } else if (Array.isArray(rec.detail)) { + for (const item of rec.detail) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (rec.detail && typeof rec.detail === "object") { + if (diagnosticRecordBlamesReasoningEffort(rec.detail as Record, depth + 1)) return true; + } + if (typeof rec.details === "string") { + if (rec.details.includes("{") || rec.details.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.details, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.details)) return true; + } else if (Array.isArray(rec.details)) { + for (const item of rec.details) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (rec.details && typeof rec.details === "object") { + if (diagnosticRecordBlamesReasoningEffort(rec.details as Record, depth + 1)) return true; + } + if (typeof rec.last_error === "string") { + if (rec.last_error.includes("{") || rec.last_error.startsWith("Provider error 400: ")) { + return isReasoningEffortRejection(rec.last_error, depth + 1); + } + if (diagnosticTextBlamesReasoningEffort(rec.last_error)) return true; + } else if (rec.last_error && typeof rec.last_error === "object") { + if (diagnosticRecordBlamesReasoningEffort(rec.last_error as Record, depth + 1)) return true; } return false; } +/** + * Evidence test for a rejection body: does it blame reasoning effort? + * + * Restricts inspections to authoritative diagnostic carriers (error, errors, details, detail, last_error, response) + * so echoed request context (e.g. request.param: "reasoning_effort") does not falsely classify unrelated 400s + * (such as temperature or max_tokens errors) as effort refusals (#8173). + */ +export function isReasoningEffortRejection(text: string | undefined, depth = 0): boolean { + if (!text || depth > 4) return false; + if (isHardPolicyOrOriginRefusal(text)) return false; + if (hasConflictingReasoningEffortCode(text)) return false; + + let unescaped = text.trim(); + for (let d = 0; d < 3; d += 1) { + if (unescaped.startsWith("Provider error 400: ")) { + unescaped = unescaped.slice("Provider error 400: ".length).trim(); + } + if (unescaped.startsWith("data:") && !unescaped.includes("\n")) { + unescaped = unescaped.slice("data:".length).trim(); + } + } + + let parsed: unknown; + try { + parsed = JSON.parse(unescaped); + } catch { + parsed = extractJsonEnvelope(unescaped); + } + + if (parsed && typeof parsed === "object") { + if (Array.isArray(parsed)) { + if (hasConflictingReasoningEffortCode(unescaped)) return false; + for (const item of parsed) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + return false; + } + + const root = parsed as Record; + + if (root.error !== undefined && root.error !== null) { + if (typeof root.error === "object" && !Array.isArray(root.error)) { + if (diagnosticRecordBlamesReasoningEffort(root.error as Record, depth + 1)) return true; + } else if (typeof root.error === "string") { + if (root.error.includes("{") || root.error.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.error, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.error)) { + return true; + } + } + } + + if (Array.isArray(root.errors)) { + for (const item of root.errors) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } + + if (Array.isArray(root.details)) { + for (const item of root.details) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (typeof root.details === "string") { + if (root.details.includes("{") || root.details.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.details, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.details)) { + return true; + } + } else if (root.details && typeof root.details === "object") { + if (diagnosticRecordBlamesReasoningEffort(root.details as Record, depth + 1)) return true; + } + + if (typeof root.detail === "string") { + if (root.detail.includes("{") || root.detail.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.detail, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.detail)) { + return true; + } + } else if (Array.isArray(root.detail)) { + for (const item of root.detail) { + if (item && typeof item === "object") { + if (diagnosticRecordBlamesReasoningEffort(item as Record, depth + 1)) return true; + } else if (typeof item === "string") { + if (item.includes("{") || item.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(item, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(item)) { + return true; + } + } + } + } else if (root.detail && typeof root.detail === "object") { + if (diagnosticRecordBlamesReasoningEffort(root.detail as Record, depth + 1)) return true; + } + + if (typeof root.last_error === "string") { + if (root.last_error.includes("{") || root.last_error.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(root.last_error, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(root.last_error)) { + return true; + } + } else if (root.last_error && typeof root.last_error === "object") { + if (diagnosticRecordBlamesReasoningEffort(root.last_error as Record, depth + 1)) return true; + } + + if (root.response && typeof root.response === "object") { + const resp = root.response as Record; + if (resp.error && typeof resp.error === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.error as Record, depth + 1)) return true; + } + if (typeof resp.last_error === "string") { + if (resp.last_error.includes("{") || resp.last_error.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(resp.last_error, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(resp.last_error)) { + return true; + } + } else if (resp.last_error && typeof resp.last_error === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.last_error as Record, depth + 1)) return true; + } + if (typeof resp.details === "string") { + if (resp.details.includes("{") || resp.details.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(resp.details, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(resp.details)) { + return true; + } + } else if (resp.details && typeof resp.details === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.details as Record, depth + 1)) return true; + } + if (typeof resp.detail === "string") { + if (resp.detail.includes("{") || resp.detail.startsWith("Provider error 400: ")) { + if (isReasoningEffortRejection(resp.detail, depth + 1)) return true; + } else if (diagnosticTextBlamesReasoningEffort(resp.detail)) { + return true; + } + } else if (resp.detail && typeof resp.detail === "object") { + if (diagnosticRecordBlamesReasoningEffort(resp.detail as Record, depth + 1)) return true; + } + } + + if (root.error === undefined || typeof root.error !== "object") { + if (diagnosticRecordBlamesReasoningEffort(root, depth + 1)) return true; + } + + return false; + } + + const hasBraces = unescaped.includes("{") && unescaped.includes("}"); + const hasBrackets = + unescaped.includes('["') || + (unescaped.includes("[") && + unescaped.includes("]") && + (unescaped.includes('"') || unescaped.includes("'") || unescaped.includes(","))); + if (hasBraces || (hasBrackets && !carriesOnlyEffortEnumerations(unescaped))) return false; + if (isIncompleteStructuredWrapper(unescaped)) return false; + + return diagnosticTextBlamesReasoningEffort(unescaped); +} + /** * Plan a single-rung downgrade for a rejected request: records the refusal (so later turns * clamp before dispatch) and returns the next lower rung the model does publish. @@ -478,6 +1516,7 @@ export function planReasoningEffortDowngrade(args: { requested?: string; rejectionText?: string; }): { effort: string; recorded: boolean } | undefined { + if (args.rejectionText && (isHardPolicyOrOriginRefusal(args.rejectionText) || hasConflictingReasoningEffortCode(args.rejectionText))) return undefined; const requested = args.requested === "ultra" ? "max" : args.requested; if (!requested || !RANKED.includes(requested)) return undefined; const recorded = recordUnsupportedReasoningEffort(args.provider, args.modelId, requested, args.rejectionText); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 5954eedd158..f0ae939f3d1 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -96,13 +96,12 @@ import { attemptOpaqueBlobRecovery, consoleGoUploadRejectionBody, CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, - reasoningEffortRejectionText, + refusedReasoningEffortDowngrade, anthropicFastRefused, } from "./core-opaque-recovery"; import { createAnthropicThinkingRecovery } from "./anthropic-thinking-recovery"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; -import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { formatAdapterUpstreamFailure } from "./adapter-failure-message"; import { consumeComboFailure } from "./core-combo-failure"; import { streamingContextOverflowResponse, jsonContextOverflowResponse, lateAttributionLimitResponse } from "./context-overflow"; @@ -613,9 +612,8 @@ export async function prepareAdapterExchange( const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false }; let oauth401ReplayAttempted = transportState.oauth401ReplayAttempted; let antigravityAuthRotationAttempted = false; - // At most one reasoning-effort downgrade per request. This sits outside the recovery loop - // below for the same reason the two guards above do: a guard declared inside it is reset by - // every `continue recovery`, which would let one turn walk the whole ladder down. + // Direct requests get one reasoning-effort downgrade. Combo candidates may walk down the + // published ladder one refused rung at a time; the request send budget bounds those retries. const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false }; // At most one Anthropic fast-mode downgrade per request, for the same reason. const anthropicFastDowngradeGuard: { attempted: boolean } = { attempted: false }; @@ -663,20 +661,19 @@ export async function prepareAdapterExchange( const authRefusal = options.abortSignal?.aborted ? undefined : codexAdapterBuildRefusal(err, admissionState.authCtx, route.codexAccountNamespace, () => { cleanupUpstreamAbort(); upstream.abort(); }); if (authRefusal) return { failed: authRefusal }; // A rotated credential that cannot be shaped keeps the refusal it was meant to - // replace. The image-tier rebuild is different: the request itself failed to shape, - // and its established 413/400 build-error contract stays authoritative. - if (preserveFailureResponse && recovery !== "image-413" && !options.abortSignal?.aborted) { + // replace. The image-tier and reasoning-effort rebuilds are different: the request itself + // failed to shape, and their established build-error contracts stay authoritative. + if (preserveFailureResponse && recovery !== "image-413" && recovery !== "reasoning-effort-downgrade" && !options.abortSignal?.aborted) { return { failed: preserveFailureResponse }; } // A rotated/rebuilt adapter build failure is a request-shaping error, not an - // upstream connect failure: tear the abort link down and map it as 400. A - // translator-budget refusal keeps its established 413 here as well — adapters - // can throw it while sizing generated content (e.g. Ollama late-tool - // attribution), not only parseRequest/buildToolBridgeMaps. + // upstream connect failure: tear the abort link down and map it as 400. cleanupUpstreamAbort(); upstream.abort(); if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; - if (err instanceof OllamaLateAttributionBudgetError) return { failed: lateAttributionLimitResponse() }; + if (err instanceof OllamaLateAttributionBudgetError) { + return { failed: lateAttributionLimitResponse() }; + } if (isTranslatorBudgetExceededError(err)) { return { failed: formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { code: "translation_buffer_limit", @@ -847,7 +844,7 @@ export async function prepareAdapterExchange( releaseCodexAuthContextProbeLease(admissionState.authCtx); return { failed: codexRefusal }; } - if (preserveFailureResponse && !replacementAdmitted && !options.abortSignal?.aborted) + if (preserveFailureResponse && (recovery !== "reasoning-effort-downgrade" || err instanceof SendBudgetExhaustedError) && !replacementAdmitted && !options.abortSignal?.aborted) return { failed: preserveFailureResponse }; cleanupUpstreamAbort(); upstream.abort(); @@ -1826,33 +1823,26 @@ export async function prepareAdapterExchange( continue recovery; } } - // Reasoning-effort downgrade, mirroring the passthroughRecovery loop above: learn the - // refused rung, then replay once at the next published one. - if (!reasoningEffortDowngradeGuard.attempted) { - const rejectionText = await reasoningEffortRejectionText( - upstreamResponse, - reasoningEffortDowngradeGuard.attempted, - upstream.signal, - ); - const downgrade = rejectionText === undefined - ? undefined - : planReasoningEffortDowngrade({ - provider: route.provider, - modelId: parsed.modelId, - requested: parsed.options.reasoning, - rejectionText, - }); - if (downgrade) { - reasoningEffortDowngradeGuard.attempted = true; - parsed.options.reasoning = downgrade.effort; - // The same-target cache keys on parsed identity, so a mutated effort needs a token bump. - invalidateSameTargetRequest(); - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - const result = await rebuildAndRefetch("reasoning-effort-downgrade"); - if ("failed" in result) return result.failed; - upstreamResponse = result; - continue recovery; + // Learn a refused rung and retry at the next published one. Direct requests are limited + // to one repair; combo candidates may continue down the finite ladder within the send budget. + const downgrade = await refusedReasoningEffortDowngrade(upstreamResponse, upstream.signal, reasoningEffortDowngradeGuard, + options.comboAttempt, { provider: route.provider, modelId: parsed.modelId, requested: parsed.options.reasoning }); + if (downgrade) { + reasoningEffortDowngradeGuard.attempted = true; + parsed.options.reasoning = downgrade.effort; + // The same-target cache keys on parsed identity, so a mutated effort needs a token bump. + invalidateSameTargetRequest(); + // A combo keeps the refusal, so an unfunded rung still reaches combo failover as the original 400. + const refusal = options.comboAttempt ? await retainFailureResponse() : undefined; + if (!refusal) try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("reasoning-effort-downgrade", undefined, refusal); + if ("failed" in result) { + if (result.failed !== refusal) return result.failed; + break recovery; } + if (refusal) try { void refusal.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + upstreamResponse = result; + continue recovery; } break; } diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 0a829ebee94..91c1f28bdf4 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -1,6 +1,12 @@ import { ENCRYPTED_FUNCTION_OUTPUT_REJECTION, upstreamErrorMessageFromPayload } from "../../lib/errors"; import { readBoundedResponseBody } from "../../lib/bounded-body"; -import { isReasoningEffortRejection } from "../../providers/reasoning-metadata"; +import { isHardPolicyOrOriginRefusal } from "../../lib/hard-refusal"; +import { isPlainObject } from "../../lib/plain-data"; +import { + hasConflictingReasoningEffortCode, + isReasoningEffortRejection, + planReasoningEffortDowngrade, +} from "../../providers/reasoning-metadata"; import { isAnthropicFastRefusal } from "../../providers/anthropic-fast"; import type { AdapterRequest } from "../../adapters/base"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; @@ -64,6 +70,43 @@ export function outboundResponsesInput(bodyText: string | undefined): unknown[] } } +/** + * The next published rung after an upstream reasoning-effort refusal, or undefined when no replay + * applies. A direct request repairs once. A combo candidate may walk the finite ladder one refused + * rung at a time: each refusal is learned, so a rung is never offered twice, and the request send + * budget bounds the walk (#8173). The caller sets `guard.attempted` when it actually replays. + */ +export async function refusedReasoningEffortDowngrade( + response: Response, + signal: AbortSignal, + guard: { attempted: boolean }, + comboAttempt: boolean | undefined, + plan: Omit[0], "rejectionText">, +): Promise> { + if (guard.attempted && !comboAttempt) return undefined; + const rejectionText = await reasoningEffortRejectionText(response, false, signal); + return rejectionText === undefined + ? undefined + : planReasoningEffortDowngrade({ ...plan, rejectionText }); +} + +/** + * The passthrough rebuild serializes `_rawBody`, so a downgraded rung has to be written there as + * well or the replay resends the refused effort. + * + * Only shapes that already carry the control are rewritten. An absent `reasoning`, or one the + * caller sent as a string or `null`, is left exactly as it was: inventing `{ effort }` here would + * add a reasoning control to a body that never asked for one, and overriding a string would + * replace whatever that wire meant by it. `reasoning_effort` is kept in step only when present. + */ +export function withRawBodyReasoningEffort(rawBody: unknown, effort: string): unknown { + if (!isPlainObject(rawBody)) return rawBody; + const next: Record = { ...rawBody }; + if (isPlainObject(rawBody.reasoning)) next.reasoning = { ...rawBody.reasoning, effort }; + if ("reasoning_effort" in rawBody) next.reasoning_effort = effort; + return next; +} + export function outboundResponsesBodyCarriesEncryptedFunctionOutput(bodyText: string | undefined): boolean { const input = outboundResponsesInput(bodyText); @@ -365,7 +408,7 @@ export async function reasoningEffortRejectionText( if (response.status !== 400 && response.status !== 403) return undefined; try { const body = await readBoundedResponseBody(response.clone(), { signal }); - if (!body.displaySafe || body.truncated) return undefined; + if (!body.displaySafe || body.truncated || isHardPolicyOrOriginRefusal(body.text) || hasConflictingReasoningEffortCode(body.text)) return undefined; return isReasoningEffortRejection(body.text) ? body.text : undefined; } catch { return undefined; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index f5142d76fe8..e2ced147906 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -165,14 +165,13 @@ import { resetStreamedOpaqueBlobLogContext, consoleGoUploadRejectionBody, CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, - reasoningEffortRejectionText, + refusedReasoningEffortDowngrade, withRawBodyReasoningEffort, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight"; import { ambiguousResendAllowanceFor, ambiguousResendAuthorizer, nativePassthroughReplayJudgmentBody, selfContainedResponsesBody } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; -import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { createNativeContinuationDispatch } from "./native-continuation-send"; import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion"; @@ -1062,7 +1061,8 @@ export async function preparePassthroughExchange( } const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; - // At most one reasoning-effort downgrade per request. + // Direct requests get one reasoning-effort downgrade. Combo candidates may walk down the + // published ladder one refused rung at a time; the request send budget bounds those retries. const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false }; /** Rebind the refreshed wire before transferring this request to the Chat pipeline. */ function bindRefreshedAdapter(refreshedAdapter: typeof transportState.adapter) { @@ -1096,6 +1096,7 @@ export async function preparePassthroughExchange( let rateLimitRetries = 0; const rebuildAndRefetch = async ( recovery: AttemptRecoveryKind, + preserveFailureResponse?: Response, ): Promise => { const retryAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy), @@ -1146,6 +1147,8 @@ export async function preparePassthroughExchange( { allowFinalRecoveryReserve: transientSendPolicy() === null }, ); if (recovery === "empty-completion") allowance.attempts = Math.min(1, allowance.attempts); + // An unfunded combo rung keeps the upstream refusal for combo failover instead of a budget 429. + if (preserveFailureResponse && allowance.attempts < 1) { request.releaseBodyObservation?.(); return { failed: preserveFailureResponse }; } const recoveryAccounting = copilotSendAccounting(allowance.permit); try { return await fetchWithTransientRetry( @@ -1826,34 +1829,24 @@ export async function preparePassthroughExchange( continue passthroughRecovery; } } - // Reasoning-effort downgrade: a rung the catalog still advertises can be refused upstream -- - // the metadata records the model's ladder, not this account's entitlement (a Muse Code - // subscription gates max on muse-spark-1.3-contributor, for example). Learn the refusal so - // later turns clamp before dispatch, then replay once at the next lower published rung - // instead of failing the turn; requestedEffort/effectiveEffort keep both values in usage. - if (!reasoningEffortDowngradeGuard.attempted) { - const rejectionText = await reasoningEffortRejectionText( - upstreamResponse, - reasoningEffortDowngradeGuard.attempted, - upstream.signal, - ); - const downgrade = rejectionText === undefined - ? undefined - : planReasoningEffortDowngrade({ - provider: route.provider, - modelId: parsed.modelId, - requested: parsed.options.reasoning, - rejectionText, - }); - if (downgrade && !configuredTransientSendBudgetExhausted()) { - reasoningEffortDowngradeGuard.attempted = true; - parsed.options.reasoning = downgrade.effort; - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - const result = await rebuildAndRefetch("reasoning-effort-downgrade"); - if ("failed" in result) return result.failed; - upstreamResponse = result; - continue passthroughRecovery; - } + // A rung the catalog advertises can still be refused upstream -- metadata describes the + // model's ladder, not this account's entitlement. Learn each refusal so later turns clamp + // before dispatch. Direct requests repair once; combo candidates may retry the next lower + // published rung until it works, the ladder ends, or the send budget is spent. + const downgrade = await refusedReasoningEffortDowngrade(upstreamResponse, upstream.signal, reasoningEffortDowngradeGuard, + options.comboAttempt, { provider: route.provider, modelId: parsed.modelId, requested: parsed.options.reasoning }); + if (downgrade && !configuredTransientSendBudgetExhausted()) { + reasoningEffortDowngradeGuard.attempted = true; + parsed.options.reasoning = downgrade.effort; + parsed._rawBody = withRawBodyReasoningEffort(parsed._rawBody, downgrade.effort); + // A combo keeps the refusal, so an unfunded rung still reaches combo failover as the original 400. + const refusal = options.comboAttempt ? upstreamResponse : undefined; + if (!refusal) try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("reasoning-effort-downgrade", refusal); + if ("failed" in result) return result.failed; + if (refusal) try { void refusal.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + upstreamResponse = result; + continue passthroughRecovery; } // The WebSocket row of the same table (#4191). A Codex socket that closed or failed under its diff --git a/structure/runtime.md b/structure/runtime.md index 0fe3aad0782..3fd2efe1eca 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -492,9 +492,9 @@ Translated audio/file admission follows the [final-adapter input contract](adapt `src/adapters/openai-responses.ts` omits top-level `user` and `safety_identifier` at the canonical ChatGPT Codex forward destination. The backend rejects both with its strict-allowlist `{"detail":"Unsupported parameter: …"}` envelope, and canonical forwarding removes `safety_identifier` regardless of which client supplied it — third-party clients (droid's registry-matched BYOK) attach it from the model name alone, but any Responses client may send the public OpenAI-platform parameter explicitly. Claude translation retains its original identity and prompt-cache key; public API and noncanonical gateways retain their `user` and `safety_identifier` fields. Input roles, tool-schema properties and original replay bodies are not changed. -`src/combos/failover.ts` treats four intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; `param: input` with a bounded model-scoped `does not support image inputs` message; and the exact null-code `gpt-6-astra` function-tool routing mismatch that tells an existing Responses request to use `/v1/responses`, allowing only the bare model name or its strict `YYYY-MM-DD` deployment suffix. A null provider code is accepted only for the two observed envelopes that require it. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. +`src/combos/failover.ts` treats four intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; `param: input` with a bounded model-scoped `does not support image inputs` message; and the exact null-code `gpt-6-astra` function-tool routing mismatch that tells an existing Responses request to use `/v1/responses`, allowing only the bare model name or its strict `YYYY-MM-DD` deployment suffix. It also accepts the exact vendor envelope `{"code":"invalid-argument","error":"Invalid reasoning effort."}` and the matching structured `invalid_request_error` message. A combo first retries that target at each next lower published reasoning rung, one step per refusal and within the shared send budget; if no lower rung works, it advances to another eligible target without cooling the refused one when the refusal matches a recognized compatibility envelope, and remains terminal otherwise. For the vendor envelope, the inner code and exact message remain required even when an outer provider code is absent. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. -A `response_format` capability refusal is a fifth envelope, kept separate because it needs one code and one frame the four above do not admit. The refusal must name `response_format` AND state that it is unavailable or unsupported; a message that merely names the field, such as an invalid-schema complaint, stays terminal, because replaying a malformed request at every later target is the outcome this distinction exists to avoid. `param` may be absent or explicitly null and a param naming another field fails closed. Its code set is the shared generic one plus `invalid_parameter_error`, held separately so the `user` and image branches are not widened by it. It also unwraps a single `data:` SSE frame on a one-line body — the reported gateway answers on the stream, so the error object is never extracted and the structured code arrives undefined — while a multi-event body is left alone. The next target receives the same request with `response_format` intact: no field is dropped and the output contract the caller asked for is unchanged. Traversal stays finite because combo excludes each attempted target. This verdict records no cooldown, and cancellation, origin/cyber-policy rejection and the non-replayable post-send codes are all tested before it (#4903). +A `response_format` capability refusal is a sixth envelope, kept separate because it needs one code and one frame the earlier cases do not admit. The refusal must name `response_format` AND state that it is unavailable or unsupported; a message that merely names the field, such as an invalid-schema complaint, stays terminal, because replaying a malformed request at every later target is the outcome this distinction exists to avoid. `param` may be absent or explicitly null and a param naming another field fails closed. Its code set is the shared generic one plus `invalid_parameter_error`, held separately so the `user` and image branches are not widened by it. It also unwraps a single `data:` SSE frame on a one-line body — the reported gateway answers on the stream, so the error object is never extracted and the structured code arrives undefined — while a multi-event body is left alone. The next target receives the same request with `response_format` intact: no field is dropped and the output contract the caller asked for is unchanged. Traversal stays finite because combo excludes each attempted target. This verdict records no cooldown, and cancellation, origin/cyber-policy rejection and the non-replayable post-send codes are all tested before it (#4903). The combo may advance to its next eligible unattempted target before output commitment. It records no target/provider cooldown for these request-local mismatches and does not silently drop reasoning controls or raise `none` to a supported rung. Cancellation, origin/cyber-policy rejection, non-replayable post-send errors and the existing streaming commit boundary stay authoritative. Apart from the definite context overflow below, other invalid requests remain terminal. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7cd630b82da..814373b4371 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1309,7 +1309,7 @@ "responses-parser-agent-message.test.ts": "responses", "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", - "responses-preview-main-read-fence.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", + "responses-preview-main-read-fence.test.ts": "responses", "responses-reasoning-effort-diagnostics.test.ts": "responses", "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-native-ultra-effort-wire.test.ts": "responses", "responses-url-citation-title-repair.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", "responses-reset-replay-chained.test.ts": "responses", "responses-translated-reset.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", diff --git a/tests/responses/responses-reasoning-effort-diagnostics.test.ts b/tests/responses/responses-reasoning-effort-diagnostics.test.ts new file mode 100644 index 00000000000..85dff429c41 --- /dev/null +++ b/tests/responses/responses-reasoning-effort-diagnostics.test.ts @@ -0,0 +1,1690 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses } from "../../src/server/responses/core"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { + hasConflictingReasoningEffortCode, + isReasoningEffortLearnedUnsupported, + isReasoningEffortRejection, + resetReasoningMetadataCachesForTests, +} from "../../src/providers/reasoning-metadata"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { chatSuccess } from "../helpers/combo-failover-upstream"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +/** + * Diagnostic envelope and boundary tests for reasoning effort refusals (#8173). + * + * Verifies robust extraction, handling of conflicting codes, incomplete/malformed envelopes, + * echoed request context, and competing diagnostic prose. + */ + +const originalFetch = globalThis.fetch; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +const MODEL = "muse-spark-1.3-contributor"; + +const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); +const CONFLICTING_CODE_REFUSAL = JSON.stringify({ + code: "permission_denied", + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, +}); +const SERIALIZED_WRAPPER_CONFLICTING_REFUSAL = JSON.stringify({ + type: "upstream_error", + param: "reasoning_effort", + message: 'Provider error 400: {"code":"permission_denied","error":"Invalid reasoning effort."}', +}); +const UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + details: [ + ...Array.from({ length: 32 }, (_, i) => ({ id: i, note: "ordinary candidate object" })), + { code: "permission_denied", message: "Denied after candidate limit" }, + ], +}); +const INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + message: 'Provider error 400: {"code":"permission_denied"', +}); +const BALANCED_MALFORMED_JSON_REFUSAL = + 'Gateway error 400: {"code":"unknown_terminal_code","message":"Unsupported value for reasoning_effort", invalid_json: }'; +const BALANCED_MALFORMED_BRACKET_REFUSAL = + 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort",]'; +const VALID_BRACKET_CONFLICTING_REFUSAL = + 'Gateway error: ["unknown_terminal_code", "unsupported reasoning effort"]'; +const INCOMPLETE_OUTER_OBJECT_REFUSAL = ' {"param":"reasoning_effort","error":{"code":"unknown_terminal_code"}'; +const ERRORS_BIO_POLICY_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: [{ code: "bio_policy" }], +}); +const SERIALIZED_ARRAY_BIO_POLICY_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: ['{"code":"bio_policy"}'], +}); +const ERRORS_CONFLICTING_CODE_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: [{ code: "permission_denied" }], +}); +const SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + errors: ['{"code":"permission_denied"}'], +}); +const ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL = JSON.stringify({ + error: { + param: "temperature", + message: "Invalid temperature.", + }, + request: { + param: "reasoning_effort", + }, +}); +const SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL = JSON.stringify({ + error: { + type: "upstream_error", + message: `Provider error 400: ${JSON.stringify({ + error: { param: "temperature", message: "Invalid temperature." }, + request: { param: "reasoning_effort" }, + })}`, + }, +}); +const DETAIL_REASONING_EFFORT_REFUSAL = JSON.stringify({ + detail: "Unsupported reasoning effort", +}); +const DETAIL_ARRAY_REASONING_EFFORT_REFUSAL = JSON.stringify({ + detail: ["Unsupported reasoning effort"], +}); +const CONFLICTING_PARAM_UNRELATED_REFUSAL = JSON.stringify({ + param: "reasoning_effort", + error: { + param: "temperature", + message: "Invalid temperature.", + }, +}); +const NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL = JSON.stringify({ + error: { + param: "temperature", + message: "Invalid temperature.", + }, + detail: "Unsupported reasoning effort", +}); +const DETAILS_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ + details: "Unsupported reasoning effort", +}); +const LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL = JSON.stringify({ + last_error: "Unsupported reasoning effort", +}); +const ECHOED_REQUEST_CONTENT_REFUSAL = JSON.stringify({ + error: { + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, + request: { + input: "origin_rejected", + reason: "testing cybersecurity policy", + }, +}); +const PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"origin_rejected"}}'; +const PREFIXED_ECHOED_CYBER_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"Request blocked by upstream cybersecurity policy"}}'; +const PREFIXED_ECHOED_BIO_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"bio_policy violation"}}'; +const PREFIXED_POLICY_REFUSAL = + 'Gateway error 400: {"error":{"code":"cyber_policy","param":"reasoning_effort","message":"Request blocked by upstream cybersecurity policy"},"request":{"input":"ordinary text"}}'; +const HARD_REFUSAL_PREFIX_ENVELOPE = + 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; +const NESTED_WRAPPED_ECHO_ORIGIN_REJECTED = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "origin_rejected" }, + }), + }, +}); +const NESTED_WRAPPED_ECHO_ORDINARY = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "ordinary input prompt" }, + }), + }, +}); +const NESTED_WRAPPED_HARD_REFUSAL_PREFIX = JSON.stringify({ + error: { + type: "upstream_error", + message: 'origin_rejected: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', + }, +}); +const CONFLICTING_PERMISSION_PREFIX_REFUSAL = + 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}'; +const PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL = + 'Gateway error 400: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."},"request":{"input":"permission_denied"}}'; +const NESTED_WRAPPED_ECHO_PERMISSION_DENIED = JSON.stringify({ + error: { + type: "upstream_error", + message: JSON.stringify({ + error: { param: "reasoning_effort", message: "Invalid reasoning effort." }, + request: { input: "permission_denied" }, + }), + }, +}); +const NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX = JSON.stringify({ + error: { + type: "upstream_error", + message: 'permission_denied: {"error":{"param":"reasoning_effort","message":"Invalid reasoning effort."}}', + }, +}); +const COMPETING_DIAGNOSTIC_PROSE_REFUSAL = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: "Invalid temperature.", +}); + +let testDir = ""; +let releaseSpendHome: (() => void) | undefined; + +function writeSnapshot(values: string[]): void { + writeFileSync(join(testDir, "reasoning-metadata-cache.json"), JSON.stringify({ + version: 1, + fetchedAt: Date.now(), + source: "test", + providers: { "opencode-go": { [MODEL]: { reasoning: true, options: [{ type: "effort", values }] } } }, + })); +} + +function config(): OcxConfig { + return { + defaultProvider: "first", + providers: { + first: { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig; +} + +function passthroughConfig(): OcxConfig { + return { + defaultProvider: "first", + providers: { + first: { + adapter: "openai-responses", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig; +} + +function effortOf(body: Record | undefined): unknown { + if (!body) return undefined; + const reasoning = body.reasoning; + if (reasoning && typeof reasoning === "object" && "effort" in reasoning) { + return (reasoning as { effort?: unknown }).effort; + } + return body.reasoning_effort; +} + +function request(stream = false, model = "first/" + MODEL): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + stream, + store: false, + reasoning: { effort: "max" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }], + }), + }); +} + +beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + testDir = mkdtempSync(join(tmpdir(), "ocx-reasoning-diag-")); + process.env.OPENCODEX_HOME = testDir; + releaseSpendHome = acquireOwnedSpendHome(); + resetReasoningMetadataCachesForTests(); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); + resetReasoningMetadataCachesForTests(); + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("reasoning effort diagnostic envelopes and boundaries", () => { + test("hasConflictingReasoningEffortCode unit checks", () => { + expect(hasConflictingReasoningEffortCode(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(BALANCED_MALFORMED_JSON_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(BALANCED_MALFORMED_BRACKET_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(VALID_BRACKET_CONFLICTING_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(CONFLICTING_PERMISSION_PREFIX_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(false); + expect(isReasoningEffortRejection(PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL)).toBe(true); + expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(true); + expect(isReasoningEffortRejection(NESTED_WRAPPED_CONFLICTING_PERMISSION_PREFIX)).toBe(false); + expect(hasConflictingReasoningEffortCode(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(false); + expect(isReasoningEffortRejection(NESTED_WRAPPED_ECHO_PERMISSION_DENIED)).toBe(true); + expect(hasConflictingReasoningEffortCode(VENDOR_EFFORT_REFUSAL)).toBe(false); + expect(hasConflictingReasoningEffortCode(JSON.stringify({ + param: "reasoning_effort", + details: [{ id: 1 }, { id: 2 }], + }))).toBe(false); + expect(hasConflictingReasoningEffortCode(COMPETING_DIAGNOSTIC_PROSE_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(COMPETING_DIAGNOSTIC_PROSE_REFUSAL)).toBe(false); + }); + + test("stops before effort replay on serialized provider wrapper conflicting code (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized provider wrapper conflicting code (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when candidate queue leaves unexamined objects (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("reasoning_effort"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when candidate queue leaves unexamined objects (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNEXHAUSTED_QUEUE_CANDIDATES_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete structured wrapper (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete structured wrapper (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_STRUCTURED_WRAPPER_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when request content echoes origin_rejected or cyber wording (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(ECHOED_REQUEST_CONTENT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test.each([ + ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], + ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], + ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], + ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-chat)", async (_kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test.each([ + ["origin_rejected", PREFIXED_ECHOED_ORIGIN_REJECTED_REFUSAL], + ["cyber_policy", PREFIXED_ECHOED_CYBER_POLICY_REFUSAL], + ["bio_policy", PREFIXED_ECHOED_BIO_POLICY_REFUSAL], + ["permission_denied", PREFIXED_ECHOED_PERMISSION_DENIED_REFUSAL], + ])("replays downgrade when prefixed JSON diagnostic echoes %s in request input (openai-responses)", async (_kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on genuine policy refusal inside prefixed diagnostic carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(PREFIXED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on outer hard-refusal prefix outside extracted envelope (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(HARD_REFUSAL_PREFIX_ENVELOPE, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting permission diagnostic prefix outside extracted envelope (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PERMISSION_PREFIX_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting permission diagnostic prefix outside extracted envelope (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PERMISSION_PREFIX_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message echoes origin_rejected in request input (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORIGIN_REJECTED, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message has ordinary request input (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("replays downgrade when nested upstream_error message has ordinary request input (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(NESTED_WRAPPED_ECHO_ORDINARY, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay when nested upstream_error message has hard-refusal prefix (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_WRAPPED_HARD_REFUSAL_PREFIX, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("origin_rejected"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete outer object ending with inner brace (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on incomplete outer object ending with inner brace (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(INCOMPLETE_OUTER_OBJECT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on hard policy refusal in errors carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on hard policy refusal in errors carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting code in errors carrier (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting code in errors carrier (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ERRORS_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized hard policy refusal inside errors array (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized hard policy refusal inside errors array (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_BIO_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized conflicting code inside errors array (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized conflicting code inside errors array (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_ARRAY_CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on unrelated refusal with echoed request param (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on unrelated refusal with echoed request param (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(ECHOED_REQUEST_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized wrapper containing echoed request param (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on serialized wrapper containing echoed request param (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(SERIALIZED_WRAPPER_ECHOED_PARAM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative detail reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on conflicting diagnostic parameters (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting diagnostic parameters (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_PARAM_UNRELATED_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on competing diagnostic prose (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(COMPETING_DIAGNOSTIC_PROSE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on competing diagnostic prose (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(COMPETING_DIAGNOSTIC_PROSE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative detail array reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAIL_ARRAY_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on non-effort parameter with effort claim in detail (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on non-effort parameter with effort claim in detail (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NON_EFFORT_PARAM_WITH_EFFORT_CLAIM_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("Invalid temperature."); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative details string reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(DETAILS_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "chat.completion", choices: [{ message: { content: "ok" } }] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("recovers and replays lower rung on authoritative last_error string reasoning effort refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(LAST_ERROR_STRING_REASONING_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + + expect(response.status).toBe(200); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }); + + test("stops before effort replay on balanced malformed JSON refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed bracket refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on valid bracket with conflicting code refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(VALID_BRACKET_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed JSON refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_JSON_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on balanced malformed bracket refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(BALANCED_MALFORMED_BRACKET_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on valid bracket with conflicting code refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(VALID_BRACKET_CONFLICTING_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("unknown_terminal_code"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test.each([ + ["JSON", BALANCED_MALFORMED_JSON_REFUSAL, "unknown_terminal_code"], + ["bracket", BALANCED_MALFORMED_BRACKET_REFUSAL, "unknown_terminal_code"], + ["valid bracket conflicting", VALID_BRACKET_CONFLICTING_REFUSAL, "unknown_terminal_code"], + ["competing diagnostic prose", COMPETING_DIAGNOSTIC_PROSE_REFUSAL, "Invalid temperature."], + ])("combo stops immediately on %s refusal without walking ladder", async (_label, refusal, expectedText) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain(expectedText); + expect(attempts).toEqual([{ host: "opencode.ai", effort: "max" }]); + }); + + test("combo preserves 413 translation-budget refusal on rebuild without trying fallback", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return url.host === "fallback.example" + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); + + try { + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + expect(response.status).toBe(413); + const json = await response.json() as Record; + expect(json.error?.code).toBe("context_length_exceeded"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + } finally { + spy.mockRestore(); + } + }); + + test("direct Chat gives cancellation precedence over rebuild budget error returning 499 rather than 413", async () => { + writeSnapshot(["low", "high", "max"]); + const controller = new AbortController(); + let sends = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => { + sends += 1; + return new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const cfg = config(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + controller.abort(); + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); + + try { + const response = await handleResponses(request(), cfg, logCtx, { + abortSignal: controller.signal, + inboundWire: "chat", + }); + expect(response.status).toBe(499); + expect(sends).toBe(1); + } finally { + spy.mockRestore(); + } + }); + + test("direct Chat returns 413 when rebuild budget error occurs without client cancellation", async () => { + writeSnapshot(["low", "high", "max"]); + let sends = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => { + sends += 1; + return new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const cfg = config(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const adapterRegistry = await import("../../src/adapters/registry"); + const originalCreate = adapterRegistry.createRegisteredAdapter; + let buildAttempts = 0; + const spy = spyOn(adapterRegistry, "createRegisteredAdapter").mockImplementation((wire, provider, ctx) => { + const adapter = originalCreate(wire, provider, ctx); + const originalBuild = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (parsed, opts) => { + buildAttempts += 1; + if (buildAttempts === 2) { + const { TranslatorBudgetExceededError } = await import("../../src/lib/translator-budget"); + throw new TranslatorBudgetExceededError("live_transient", 1); + } + return originalBuild(parsed, opts); + }; + return adapter; + }); + + try { + const response = await handleResponses(request(), cfg, logCtx, { + inboundWire: "chat", + }); + expect(response.status).toBe(413); + expect(sends).toBe(1); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/responses/responses-reasoning-effort-downgrade.test.ts b/tests/responses/responses-reasoning-effort-downgrade.test.ts index e609d2f6e80..04fe9a87f6d 100644 --- a/tests/responses/responses-reasoning-effort-downgrade.test.ts +++ b/tests/responses/responses-reasoning-effort-downgrade.test.ts @@ -1,11 +1,19 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses } from "../../src/server/responses/core"; -import { resetReasoningMetadataCachesForTests } from "../../src/providers/reasoning-metadata"; +import { withRawBodyReasoningEffort } from "../../src/server/responses/core-opaque-recovery"; +import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; +import { + hasConflictingReasoningEffortCode, + isReasoningEffortLearnedUnsupported, + isReasoningEffortRejection, + resetReasoningMetadataCachesForTests, +} from "../../src/providers/reasoning-metadata"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; +import { chatSuccess } from "../helpers/combo-failover-upstream"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; /** @@ -25,6 +33,113 @@ const REFUSAL = JSON.stringify({ message: "Error from provider (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an active Muse Code subscription for model muse-spark-1.3-contributor.", }, }); +const POLICY_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + code: "cyber_policy", + param: "reasoning_effort", + message: "Request blocked by upstream cybersecurity policy.", + }, +}); +const NESTED_POLICY_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: 'Provider error 400: {"error":{"code":"bio_policy","message":"Request refused"}}', + }, +}); +const OBJECT_DETAILS_POLICY_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + param: "reasoning_effort", + details: { code: "bio_policy", message: "Request refused" }, + }, +}); +const CONFLICTING_CODE_REFUSAL = JSON.stringify({ + code: "permission_denied", + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, +}); +const UNLISTED_PARAM_DIAGNOSTIC_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "custom_field must be a string; reasoning_effort is high.", + }, +}); +const MODE_INVALID_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "reasoning effort is unsupported; mode is invalid", + }, +}); +const LEVEL_INVALID_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "reasoning effort is unsupported; level is invalid", + }, +}); +const SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. Max is not supported for this model.", + }, +}); +const VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort. Max is not supported for this model.", +}); +const EXPLICIT_FIELD_EFFORT_WORD_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. The max field is invalid.", + }, +}); +const INVALID_FIELD_COLON_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. Invalid field: max.", + }, +}); +const THE_NONE_FIELD_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. The none field is invalid.", + }, +}); +const UNQUOTED_PARAM_MAX_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "parameter max cannot be negative; reasoning effort high is unsupported", + }, +}); +const ULTRA_EFFORT_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Reasoning effort ultra is invalid", + }, +}); +const THE_ULTRA_FIELD_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. The ultra field is invalid.", + }, +}); +const INVALID_FIELD_ULTRA_COLON_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort. Invalid field: ultra.", + }, +}); +const REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "The reasoning effort parameter is unsupported", + }, +}); +const VENDOR_EFFORT_REFUSAL = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); const UNRELATED = JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid upload request." } }); let testDir = ""; @@ -82,12 +197,12 @@ function effortOf(body: Record | undefined): unknown { return body.reasoning_effort; } -function request(stream = false): Request { +function request(stream = false, model = "first/" + MODEL): Request { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "first/" + MODEL, + model, stream, store: false, reasoning: { effort: "max" }, @@ -101,6 +216,8 @@ function success(): Response { } beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); testDir = mkdtempSync(join(tmpdir(), "ocx-reasoning-downgrade-")); process.env.OPENCODEX_HOME = testDir; // Direct handler dispatches need the writer lease that startServer normally holds. @@ -113,6 +230,8 @@ afterEach(() => { releaseSpendHome?.(); releaseSpendHome = undefined; globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); resetReasoningMetadataCachesForTests(); if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = originalOpenCodexHome; @@ -212,6 +331,66 @@ describe("rejected reasoning rungs", () => { expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); }); + test.each(["openai-chat", "openai-responses"] as const)( + "replays once on requested reasoning effort not supported phrasing (%s)", + async (adapter) => { + writeSnapshot(["low", "high", "max"]); + const requestedEffortRefusal = JSON.stringify({ + error: { + message: "The requested reasoning effort is not supported for this model", + }, + }); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(requestedEffortRefusal, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(true), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + }, + ); + + test.each(["openai-chat", "openai-responses"] as const)( + "replays once on translated param effort not supported phrasing (%s)", + async (adapter) => { + writeSnapshot(["low", "high", "max"]); + const paramEffortRefusal = JSON.stringify({ + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "reasoning_effort max is not supported for this model", + }, + }); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? new Response(paramEffortRefusal, { status: 400, headers: { "content-type": "application/json" } }) + : success(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(true), cfg, logCtx); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + }, + ); + // The guard used to live inside the generic `recovery:` loop, so every `continue recovery` // handed the turn a fresh downgrade budget and one request could walk the ladder down. test("downgrades at most once even when the replay is refused again", async () => { @@ -231,4 +410,936 @@ describe("rejected reasoning rungs", () => { expect(effortOf(outbound[1])).toBe("high"); expect(response.ok).toBe(false); }); + + for (const adapter of ["openai-chat", "openai-responses"] as const) { + test(`combo tries every lower effort before falling back (${adapter})`, async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return url.host === "fallback.example" + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("fallback served the turn"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + { host: "opencode.ai", effort: "low" }, + { host: "fallback.example", effort: "max" }, + ]); + }); + + test.each([ + ["serves the turn", true], + ["also refuses", false], + ])(`unfunded downgrade keeps the refusal and hops to fallback, which %s (${adapter})`, async (_label, fallbackServes) => { + // Five ranked rungs need five sends to walk, but the first target's send scope holds one back + // for the declared fallback, so the fifth rung (low) is unfunded. The combo must keep that + // target's original 400 -- no send-budget 429 and no cooldown -- and hop to the fallback. + writeSnapshot(["low", "medium", "high", "xhigh", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return url.host === "fallback.example" && fallbackServes + ? chatSuccess("fallback served the turn", MODEL) + : new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + if (fallbackServes) { + expect(response.status, result).toBe(200); + expect(result).toContain("fallback served the turn"); + } else { + expect(response.status, result).toBe(400); + expect(result).toContain("Invalid reasoning effort."); + expect(result).not.toContain("request_send_budget_exhausted"); + } + expect(isComboTargetInCooldown("effort", { provider: "first", model: MODEL })).toBe(false); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "xhigh" }, + { host: "opencode.ai", effort: "high" }, + { host: "opencode.ai", effort: "medium" }, + { host: "fallback.example", effort: "max" }, + ]); + }); + } + + test("passthrough retry applies downgraded effort for custom destination outside metadata list", async () => { + const CUSTOM_MODEL = "custom-external-reasoner"; + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(VENDOR_EFFORT_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("custom destination served high effort", CUSTOM_MODEL); + }) as typeof fetch; + const comboConfig = { + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://custom.example/v1", + authMode: "key", + apiKey: "custom-key", + reasoningEfforts: ["low", "high", "max"], + }, + fallback: { + adapter: "openai-chat", + baseUrl: "https://fallback.example/v1", + authMode: "key", + apiKey: "fallback-key", + }, + }, + combos: { + custom_effort: { + strategy: "failover", + targets: [{ provider: "custom", model: CUSTOM_MODEL }, { provider: "fallback", model: CUSTOM_MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/custom_effort", + store: false, + reasoning: { effort: "max" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }], + }), + }); + + const response = await handleResponses(req, comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("custom destination served high effort"); + expect(attempts).toEqual([ + { host: "custom.example", effort: "max" }, + { host: "custom.example", effort: "high" }, + ]); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + }); + + test("stops before walking effort ladder on policy refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before walking effort ladder on policy refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("cyber_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("combo stops immediately on policy refusal carrying reasoning_effort without walking ladder", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("cyber_policy"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + }); + + test("stops before walking effort ladder on nested structured refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before walking effort ladder on nested structured refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(NESTED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("combo stops immediately on nested policy refusal without walking ladder", async () => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(NESTED_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("bio_policy"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + }); + + test("stops before walking effort ladder on nested object details refusal (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(OBJECT_DETAILS_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before walking effort ladder on nested object details refusal (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(OBJECT_DETAILS_POLICY_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("bio_policy"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting root code (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("stops before effort replay on conflicting root code (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CONFLICTING_CODE_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("permission_denied"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("classifies unlisted parameter predicate as conflicting diagnostic subject", () => { + expect(hasConflictingReasoningEffortCode(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL)).toBe(true); + expect(isReasoningEffortRejection(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL)).toBe(false); + }); + + test("does not replay effort or learn rejection when unlisted field is diagnostic subject (openai-chat)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = config(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("custom_field must be a string"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test("does not replay effort or learn rejection when unlisted field is diagnostic subject (openai-responses)", async () => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain("custom_field must be a string"); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }); + + test.each(["openai-chat", "openai-responses"] as const)( + "combo stops immediately without walking ladder when unlisted field is diagnostic subject (%s)", + async (adapter) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(UNLISTED_PARAM_DIAGNOSTIC_REFUSAL, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain("custom_field must be a string"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); + } + ); + + test.each([ + ["mode", MODE_INVALID_REFUSAL], + ["level", LEVEL_INVALID_REFUSAL], + ])("classifies explicitly invalid %s as conflicting diagnostic subject", (_field, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(true); + expect(isReasoningEffortRejection(refusal)).toBe(false); + }); + + test.each([ + ["openai-chat", "mode", MODE_INVALID_REFUSAL], + ["openai-chat", "level", LEVEL_INVALID_REFUSAL], + ["openai-responses", "mode", MODE_INVALID_REFUSAL], + ["openai-responses", "level", LEVEL_INVALID_REFUSAL], + ] as const)( + "stops after one send without effort replay or learned rejection when %s is invalid (%s)", + async (adapter, field, refusal) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain(`${field} is invalid`); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }, + ); + + test.each([ + ["openai-chat", "mode", MODE_INVALID_REFUSAL], + ["openai-chat", "level", LEVEL_INVALID_REFUSAL], + ["openai-responses", "mode", MODE_INVALID_REFUSAL], + ["openai-responses", "level", LEVEL_INVALID_REFUSAL], + ] as const)( + "combo stops immediately without walking ladder when %s is invalid (%s)", + async (adapter, field, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain(`${field} is invalid`); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); + }, + ); + + test.each([ + ["standard error envelope", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["vendor string error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["reasoning effort parameter is unsupported", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], + ])("classifies named effort value or descriptor as valid effort rejection (%s)", (_label, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(false); + expect(isReasoningEffortRejection(refusal)).toBe(true); + }); + + test.each([ + ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-chat", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], + ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-responses", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], + ] as const)( + "replays and downgrades from max to high when effort value is in separate sentence, ultra descriptor, or verb follows effort parameter (%s, %s)", + async (adapter, _kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("served at high effort", MODEL); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).toContain("served at high effort"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + ]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }, + ); + + test.each([ + ["openai-chat", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-chat", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-chat", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], + ["openai-responses", "standard error", SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "vendor error", VENDOR_SEPARATE_SENTENCE_EFFORT_VALUE_REFUSAL], + ["openai-responses", "ultra descriptor", ULTRA_EFFORT_REFUSAL], + ["openai-responses", "verb following effort parameter", REASONING_EFFORT_PARAMETER_IS_UNSUPPORTED_REFUSAL], + ] as const)( + "combo probe downgrades effort and succeeds on lower rung when effort value is in separate sentence (%s, %s)", + async (adapter, _kind, refusal) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("served at high effort", MODEL); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("served at high effort"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(true); + }, + ); + + test.each([ + ["The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL], + ["Invalid field: max", INVALID_FIELD_COLON_REFUSAL], + ["The none field is invalid", THE_NONE_FIELD_REFUSAL], + ["parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL], + ["The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL], + ["Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL], + ])("classifies explicit invalid field (%s) as conflicting diagnostic subject", (_desc, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(true); + expect(isReasoningEffortRejection(refusal)).toBe(false); + }); + + test.each([ + ["openai-chat", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-chat", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-chat", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-chat", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-chat", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-chat", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], + ["openai-responses", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-responses", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-responses", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-responses", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-responses", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-responses", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], + ] as const)( + "stops after one send without effort replay or learned rejection when effort word is an explicit invalid field (%s, %s)", + async (adapter, _desc, refusal, expectedSubstring) => { + writeSnapshot(["low", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(outbound).toHaveLength(1); + expect(effortOf(outbound[0])).toBe("max"); + expect(response.status).toBe(400); + expect(text).toContain(expectedSubstring); + expect(logCtx.activeAttempt?.recoveryKinds ?? []).toEqual([]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(false); + }, + ); + + test.each([ + ["openai-chat", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-chat", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-chat", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-chat", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-chat", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-chat", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], + ["openai-responses", "The max field is invalid", EXPLICIT_FIELD_EFFORT_WORD_REFUSAL, "max field is invalid"], + ["openai-responses", "Invalid field: max", INVALID_FIELD_COLON_REFUSAL, "Invalid field: max"], + ["openai-responses", "The none field is invalid", THE_NONE_FIELD_REFUSAL, "none field is invalid"], + ["openai-responses", "parameter max cannot be negative", UNQUOTED_PARAM_MAX_REFUSAL, "parameter max cannot be negative"], + ["openai-responses", "The ultra field is invalid", THE_ULTRA_FIELD_REFUSAL, "ultra field is invalid"], + ["openai-responses", "Invalid field: ultra", INVALID_FIELD_ULTRA_COLON_REFUSAL, "Invalid field: ultra"], + ] as const)( + "combo stops after one send without walking ladder when effort word is an explicit invalid field (%s, %s)", + async (adapter, _desc, refusal, expectedSubstring) => { + writeSnapshot(["low", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(400); + expect(result).toContain(expectedSubstring); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(false); + }, + ); +}); + +/** + * Canonical effort refusals from real upstreams (#8173). Each one names reasoning effort through + * its structured param, its prose, or both, and quotes the refused rung. The classifier used to + * read the leading diagnostic adjective ("Unsupported parameter") or the quoted rung + * ("Unsupported value: 'max'") as a competing field, so every one of these was terminal even + * with a structured `param: reasoning_effort`. + */ +const CANONICAL_EFFORT_REFUSALS = [ + [ + "openai unsupported_value for a max rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'max' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_value for an xhigh rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'xhigh' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_value for a low rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'low' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_value for a minimal rung", + JSON.stringify({ + error: { + message: "Unsupported value: 'minimal' is not supported with this model. Supported values are: 'low','medium','high'.", + param: "reasoning_effort", + code: "unsupported_value", + }, + }), + ], + [ + "openai unsupported_parameter", + JSON.stringify({ + error: { + message: "Unsupported parameter: 'reasoning_effort' is not supported with this model.", + param: "reasoning_effort", + code: "unsupported_parameter", + }, + }), + ], + [ + "anthropic thinking budget", + JSON.stringify({ error: { message: "thinking budget 32000 is not supported for this model" } }), + ], + [ + "vllm enumeration prose", + "reasoning_effort must be one of ['low','medium','high']", + ], + [ + "google numeric status", + JSON.stringify({ code: 400, error: { message: "reasoning effort max is unsupported" } }), + ], + [ + "volcengine ark PascalCase code", + JSON.stringify({ + error: { + code: "InvalidParameter", + message: "The parameter reasoning_effort specified in the request are not valid: max", + }, + }), + ], +] as const; + +describe("canonical upstream effort refusals", () => { + test.each(CANONICAL_EFFORT_REFUSALS)("classifies %s as an effort rejection", (_label, refusal) => { + expect(hasConflictingReasoningEffortCode(refusal)).toBe(false); + expect(isReasoningEffortRejection(refusal)).toBe(true); + }); + + test.each(CANONICAL_EFFORT_REFUSALS.flatMap(([label, refusal]) => ([ + ["openai-chat", label, refusal] as const, + ["openai-responses", label, refusal] as const, + ])))( + "repairs once from max to high on a direct request (%s, %s)", + async (adapter, _label, refusal) => { + writeSnapshot(["low", "medium", "high", "max"]); + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + if (outbound.length === 1) { + return new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }); + } + return adapter === "openai-chat" + ? chatSuccess("served at high effort", MODEL) + : Response.json({ id: "resp-ok", object: "response", status: "completed", model: MODEL, output: [] }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const cfg = adapter === "openai-chat" ? config() : passthroughConfig(); + const response = await handleResponses(request(), cfg, logCtx); + const text = await response.text(); + + expect(response.status).toBe(200); + if (adapter === "openai-chat") expect(text).toContain("served at high effort"); + expect(outbound).toHaveLength(2); + expect(effortOf(outbound[0])).toBe("max"); + expect(effortOf(outbound[1])).toBe("high"); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["reasoning-effort-downgrade"]); + expect(isReasoningEffortLearnedUnsupported(cfg.providers.first, MODEL, "max")).toBe(true); + }, + ); + + test.each(CANONICAL_EFFORT_REFUSALS.flatMap(([label, refusal]) => ([ + ["openai-chat", label, refusal] as const, + ["openai-responses", label, refusal] as const, + ])))( + "combo walks max to high on the same target (%s, %s)", + async (adapter, _label, refusal) => { + writeSnapshot(["low", "medium", "high", "max"]); + const attempts: Array<{ host: string; effort: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)) as Record; + attempts.push({ host: url.host, effort: effortOf(body) }); + return attempts.length === 1 + ? new Response(refusal, { status: 400, headers: { "content-type": "application/json" } }) + : chatSuccess("served at high effort", MODEL); + }) as typeof fetch; + const comboConfig = { + providers: { + first: { adapter, baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "test-key" }, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", authMode: "key", apiKey: "fallback-key" }, + }, + combos: { + effort: { + strategy: "failover", + targets: [{ provider: "first", model: MODEL }, { provider: "fallback", model: MODEL }], + }, + }, + } as OcxConfig; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(false, "combo/effort"), comboConfig, logCtx); + const result = await response.text(); + + expect(response.status).toBe(200); + expect(result).toContain("served at high effort"); + expect(attempts).toEqual([ + { host: "opencode.ai", effort: "max" }, + { host: "opencode.ai", effort: "high" }, + ]); + expect(isReasoningEffortLearnedUnsupported(comboConfig.providers.first, MODEL, "max")).toBe(true); + }, + ); +}); + +describe("raw body effort rewrite", () => { + test("writes reasoning.effort only when the incoming body carried an object reasoning", () => { + expect(withRawBodyReasoningEffort({ input: [], reasoning: { summary: "auto" } }, "high")) + .toEqual({ input: [], reasoning: { summary: "auto", effort: "high" } }); + }); + + test("keeps a reasoning_effort field in step when the caller sent one", () => { + expect(withRawBodyReasoningEffort({ input: [], reasoning_effort: "max" }, "high")) + .toEqual({ input: [], reasoning_effort: "high" }); + }); + + test("does not invent a reasoning object when the body carried none", () => { + expect(withRawBodyReasoningEffort({ input: [], model: "m" }, "high")) + .toEqual({ input: [], model: "m" }); + }); + + test("leaves a string or null reasoning untouched", () => { + expect(withRawBodyReasoningEffort({ input: [], reasoning: "max" }, "high")) + .toEqual({ input: [], reasoning: "max" }); + expect(withRawBodyReasoningEffort({ input: [], reasoning: null }, "high")) + .toEqual({ input: [], reasoning: null }); + }); }); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 2b513f72418..8fa06b2809f 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -534,6 +534,181 @@ describe("bounded optional-control error envelopes", () => { expect(comboFailureDecision(400, message, { code: "unsupported_value" })).toBe("hop"); expect(comboFailureCooldownScope(400, message, { code: "unsupported_value" })).toBe("none"); }); + test("exact reasoning effort refusal hops when wrapped in single-line data: prefix", () => { + const payload = JSON.stringify({ code: "invalid-argument", error: "Invalid reasoning effort." }); + for (const msg of [ + payload, + `data: ${payload}`, + `Provider error 400: data: ${payload}`, + ]) { + expect(comboFailureDecision(400, msg)).toBe("hop"); + expect(comboFailureCooldownScope(400, msg)).toBe("none"); + } + }); + test("rejects malformed error codes before granting a combo hop for reasoning effort", () => { + for (const malformedCode of [42, true, {}, []]) { + const payloadWithCode = JSON.stringify({ + error: { type: "invalid_request_error", code: malformedCode, message: "Invalid reasoning effort." }, + }); + expect(comboFailureDecision(400, payloadWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, payloadWithCode)).toBe("target"); + + const rootWithCode = JSON.stringify({ + code: malformedCode, + error: "Invalid reasoning effort.", + }); + expect(comboFailureDecision(400, rootWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, rootWithCode)).toBe("target"); + } + }); + test("rejects conflicting root and nested codes before granting a combo hop for reasoning effort", () => { + for (const conflictingCode of ["context_length_exceeded", "origin_rejected", "unknown_terminal_code"]) { + const payloadWithCode = JSON.stringify({ + error: { type: "invalid_request_error", code: conflictingCode, message: "Invalid reasoning effort." }, + }); + expect(comboFailureDecision(400, payloadWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, payloadWithCode)).toBe("target"); + + const rootWithCode = JSON.stringify({ + code: conflictingCode, + error: { type: "invalid_request_error", message: "Invalid reasoning effort." }, + }); + expect(comboFailureDecision(400, rootWithCode)).toBe("stop"); + expect(comboFailureCooldownScope(400, rootWithCode)).toBe("target"); + } + }); + test("pins root code behavior against reasoning effort matcher (dev regression)", () => { + for (const code of ["insufficient_quota", "model_not_found", "rate_limit_exceeded"]) { + const body = JSON.stringify({ code, error: { type: "invalid_request_error", message: "Invalid reasoning effort." } }); + expect(comboFailureDecision(400, body)).toBe("stop"); + expect(comboFailureCooldownScope(400, body)).toBe("target"); + } + const permBody = JSON.stringify({ code: "permission_denied", error: { type: "invalid_request_error", message: "Invalid reasoning effort." } }); + expect(comboFailureDecision(400, permBody)).toBe("hop"); + expect(comboFailureCooldownScope(400, permBody)).toBe("target"); + }); + + test("rejects competing error carriers before granting a target-local combo hop for reasoning effort", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + response: { code: "permission_denied" }, + }); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("rejects competing diagnostic arrays before granting a target-local combo hop for reasoning effort", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: [{ code: "unknown_terminal_code" }], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("fails closed when diagnostic array exceeds bounded scan budget", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: [...Array(32).fill({}), { code: "unknown_terminal_code" }], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("rejects nested error carriers in details before granting combo hop", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: { error: { code: "unknown_terminal_code" } }, + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("rejects serialized diagnostic entries in details before granting combo hop", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: ['{"code":"unknown_terminal_code"}'], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("fails closed on unclassifiable string entries in details array", () => { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + details: ["unrecognized_plain_text_error"], + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("rejects conflicting named parameters before exact-effort combo hop", () => { + const payload = JSON.stringify({ + param: "temperature", + error: { + type: "invalid_request_error", + param: "reasoning_effort", + message: "Invalid reasoning effort.", + }, + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + }); + + test("rejects competing root or nested diagnostic messages before granting exact-effort hop", () => { + // Competing root message on vendor envelope elevates to target cooldown or stop, never cooldown none + const permPayload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: "permission_denied", + }); + expect(comboFailureDecision(400, permPayload)).toBe("hop"); + expect(comboFailureCooldownScope(400, permPayload)).toBe("target"); + + for (const competing of ["unknown_terminal_code", "invalid_request_error", "unsupported parameter: user"]) { + const payload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: competing, + }); + expect(comboFailureDecision(400, payload)).toBe("stop"); + expect(comboFailureCooldownScope(400, payload)).toBe("target"); + + const nestedPayload = JSON.stringify({ + message: competing, + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort.", + }, + }); + expect(comboFailureDecision(400, nestedPayload)).toBe("stop"); + expect(comboFailureCooldownScope(400, nestedPayload)).toBe("target"); + + const competingNestedError = JSON.stringify({ + error: { + type: "invalid_request_error", + message: "Invalid reasoning effort.", + error: competing, + }, + }); + expect(comboFailureDecision(400, competingNestedError)).toBe("stop"); + expect(comboFailureCooldownScope(400, competingNestedError)).toBe("target"); + } + + // Reconciled matching message grants hop without cooldown + const matchingPayload = JSON.stringify({ + code: "invalid-argument", + error: "Invalid reasoning effort.", + message: "Invalid reasoning effort.", + }); + expect(comboFailureDecision(400, matchingPayload)).toBe("hop"); + expect(comboFailureCooldownScope(400, matchingPayload)).toBe("none"); + }); }); describe("image rejection classifier bounds", () => {