diff --git a/devlog/_plan/260914_carry_pr4528/000_plan.md b/devlog/_plan/260914_carry_pr4528/000_plan.md new file mode 100644 index 0000000000..bfa36f1783 --- /dev/null +++ b/devlog/_plan/260914_carry_pr4528/000_plan.md @@ -0,0 +1,36 @@ +# 260914 — Carry contributor PR #4528 + +## Why a carry + +PR #4528 (branch fix/codex-forward-user-4527, head d1d8d45f22c8, author RHODIZSECURITY) +fixes issue #4527. It was stuck for reasons unrelated to its code: a contributor draft +whose four-box readiness checklist resets on every push, and a Cross-platform CI run +(34774339026) that failed on exactly one test, "release version line > the in-tree +version is never behind a released one". + +That failure is stale base, not a defect. The test compares package.json against the +highest local release tag. The branch sat at in-tree 2.54.0 while v2.54.0 was already +tagged, so ordering === 0 and the tag does not point at the branch head. dev has since +opened 2.55.0 against a highest tag of v2.54.0, so the same test passes on a fresh base. + +## Defect being fixed + +Claude Code sends metadata.user_id; src/claude/inbound.ts maps it onto the Responses +top-level user field. src/adapters/openai-responses.ts stripped other unsupported +native-forward fields but left that one on the canonical ChatGPT Codex wire, which +rejects it with 400 "Unsupported parameter: user". Because a generic 400 was terminal +for a combo, a request that had already taken a 429 on an earlier target ended the turn +rather than trying the next healthy one. + +## Scope boundary + +Only a clear pre-output, target-local incompatibility may fall through to the next +target. Widening this to retry all 400s would be a defect, not an improvement. +Cancellation, policy refusals, context overflow, other invalid requests and anything +after output commitment must stay terminal. + +## Proof policy + +Local product suite, typecheck, build and install are NOT RUN. Focused runs are +debugging only and are never cited. The only proof is hosted Cross-platform CI at the +exact final head SHA. diff --git a/devlog/_plan/260914_carry_pr4528/010_phase1.md b/devlog/_plan/260914_carry_pr4528/010_phase1.md new file mode 100644 index 0000000000..88da959418 --- /dev/null +++ b/devlog/_plan/260914_carry_pr4528/010_phase1.md @@ -0,0 +1,58 @@ +# Phase 1 — Reimplement on current dev, audit, publish + +## Diff level + +Branch codex/carry-4528-codex-forward-user off origin/dev 246b5cab43. The upstream diff +applies cleanly at that base (39 files, 613+/61-), which is itself the evidence that the +CI red was the version test alone and not a code conflict. + +Source changes carried: + +- src/adapters/openai-responses.ts — stripCanonicalForwardUser, called only inside the + existing isCanonicalOpenAiForwardProvider gate, after the prompt-cache strips. +- src/combos/failover.ts — isRequestLocalTargetIncompatibility: HTTP 400 only, 16,384 + char bound, generic outer code required, strict JSON parse, error object required, + inner code string-or-null and generic, leaf type invalid_request_error, only the exact + "Provider error 400: " wrapper unwrapped with a depth budget of 3. Three accepted + shapes. Wired into both comboFailureDecision (hop) and comboFailureCooldownScope. +- src/vision/plan.ts — requiresVisionPreprocessing replaces the isModelTextOnly call + sites. Proven-negative capability preprocesses; unknown custom models stay sighted. +- src/vision/eligibility.ts — canonical Codex consults the generated openai-codex bundle + before generic row metadata; shallow copy plus cloned vision maps so an injected fetch + survives enrichment. +- scripts/generate-model-metadata.ts and src/generated/model-metadata.ts — openai-codex + retained as a capability-only bundle. +- src/providers/registry.ts — OPENCODE_ZEN_IMAGE_MODELS records mimo-v2.5-free and + longcat-2.0-free as positive modality evidence instead of blacklist absence. +- src/server/responses/core.ts, chat-native.ts, chat-completions.ts and + src/web-search/index.ts — thread providerName through so one gate applies everywhere. + +## Audit plan + +Six parallel read-only recon agents: failover classifier bounds, the sanitation +boundary, vision capability routing and generated-file consistency, structure ownership, +docs-site locale fidelity, plus one adversarial counter-read of both security boundaries. + +## Fold list beyond the upstream diff + +Turkish combos.md wording (cikti baslamadan ONCE sonraki uygun hedefe); the stale +noVisionModels activation row in structure/ops/service-and-sidecars.md; the +transports/responses.md contract pointer and its whitespace churn; a Zen positive-modality +note in transports/inventory.md; the English cross-reference; the French before-output +timing; seven locale failover-table exception rows; and two comment blocks that wrongly +claimed unknown capability fails closed. + +## Known limitation, documented not fixed + +On the combo path the classifier never sees more than 500 characters: consumeComboFailure +passes normalized.safeText, which is redactSecretString(text).slice(0, 500) at +src/server/responses/core.ts:954. The classifier's own 16,384 bound is the outer belt. +An envelope fatter than 500 bytes truncates mid-JSON, fails the parse and does not hop. +That fails closed, and raising it would touch shared redaction and byte-accounting +contracts outside this carry's scope. + +## Exit + +Push --no-verify fast-forward only, open against dev with the full template, report the +PR number, head SHA and the CI run id whose head_sha equals the final head. Do not merge; +do not close #4528 or #4527. diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index dc18f68971..4e9ccda71a 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -200,7 +200,8 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul | --- | --- | | HTTP 401, 403, 404, 408, 429, ou n'importe quel 5xx | Refroidissez la cible et passez à la prochaine cible éligible. | | 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, débordement de contexte ou demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. | +| Annulation client (499), `origin_rejected`, refus de cyber-politique, débordement de contexte ou autre demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. | +| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, ou rejet d'entrée d'image propre à un modèle (`param: input`) | 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. | | Toute autre erreur non classifiée | Arrêtez et renvoyez l'erreur. | Une cible sautée entre en temps de recharge pendant 60 secondes par défaut. Si la réponse en amont inclut un @@ -365,3 +366,9 @@ message de validation. L’erreur était terminale plutôt que spécifique à la cible. Corriger une entrée invalide, réduire un contexte surdimensionné, gérer un refus de politique ou corriger l’origine de la demande rejetée. Les combos ne sautent pas dans ces cas-là. + +## 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`, ou un rejet d’entrée d’image propre à un modèle (`param: input`) 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. + +[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 979a84ffcd..6018d7e1bd 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -213,7 +213,8 @@ Combo failures are divided into **hop** failures and **terminal** failures. | HTTP 401, 403, 404, 408, 429, or any 5xx | Cool the target and hop to the next eligible target. | | HTTP 410 with an explicit model end-of-life, retired, deprecated, sunset, decommissioned, or no-longer-available signal | Cool that target and hop. Unrelated 410 responses remain terminal. | | Classified authentication, subscription, quota, rate-limit, overload, or upstream-server error | Cool the target and hop, even when the status alone is not sufficient. | -| Client cancellation (499), `origin_rejected`, cyber-policy refusal, context overflow, or invalid request | Stop and return the error; another target would not make the request valid. | +| Client cancellation (499), `origin_rejected`, cyber-policy refusal, context overflow, or other invalid request | Stop and return the error; another target would not make the request valid. | +| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, or model-scoped image input | Hop before output commitment without cooling; see request-local target compatibility below. | | Any other unclassified error | Stop and return the error. | When `cooldownMs` is unset, a hopped target uses an upstream fallback: 5 seconds for request-rate @@ -227,8 +228,8 @@ fallback for upstream rate-limit codes `1302`/`1305` → the 60-second default. `Retry-After: 0` remains an immediate upstream directive rather than being replaced by a configured cooldown. -The current request never retries the same attempted target. Later requests skip it until its -cooldown expires. A `Retry-After` HTTP-date that is already in the past is also preserved as an +The current request never retries the same attempted target. Later requests skip a cooled target until its +cooldown expires; request-local compatibility rejections do not cool the target. A `Retry-After` HTTP-date that is already in the past is also preserved as an immediate upstream directive, just like `Retry-After: 0`. Set `waitForCooldownMs` to allow a later request to wait for the earliest eligible target cooldown, up to that cap on each selection attempt, and then make one fresh selection. A request may therefore wait up to `hops × waitForCooldownMs` @@ -254,6 +255,12 @@ another provider, which prevents duplicate text and tool execution. If the pre-o its safety cap without a terminal or output boundary, OpenCodex also commits the current target instead of growing memory without a bound. +## Request-local target compatibility + +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. 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. + ## Default reasoning effort `defaultEffort` fills an absent `reasoning.effort` when the combo has a non-null default and the selected target has a known, nonempty supported ladder. If the target supports the configured value, it is retained; otherwise the highest supported rung at or below it is used, or the lowest supported rung when none is lower. Unknown or empty ladders omit the default. diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d87c5b9513..a99042b94f 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -123,11 +123,9 @@ failures after response headers have started are delivered as `response.failed` ## Vision sidecar -When the routed model is listed in its provider's `noVisionModels` — or declared text-only for -that model via `modelInputModalities` — and a request carries an image, opencodex describes each -image **before** the main call and replaces it with text, provided a vision sidecar plan is -available. Without an available plan the raw image is stripped rather than forwarded to a -text-only backend. The model catalog advertises image input for every sidecar-covered model. +Image routing is capability-aware. Before an image-bearing upstream send, opencodex resolves the selected model's effective input modalities from runtime provider evidence, explicit operator declarations, backend/registry metadata, and generated vendor metadata. A target positively known to be text-only goes through the Vision Sidecar first; the image is described **before** the main call and replaced inline with text. A target positively known to support images receives the image directly. Unknown custom models keep the existing compatibility behavior rather than being guessed text-only. + +For the canonical ChatGPT Codex route, opencodex uses the `openai-codex` metadata bundle rather than public OpenAI API metadata, so backend-specific modality differences are respected. The native Chat fast path uses the same gate and cannot bypass a known text-only verdict. Without an available sidecar plan, raw images are stripped before a proven text-only backend. Combos advertise image input only when every member accepts images, either natively or through a sidecar, and the combo's `imageInput` setting is not disabled, so clients such as the Codex app allow attachments instead of blocking them before the sidecar runs. When @@ -164,10 +162,12 @@ sidecar-backed by default; Zen routes are unchanged and were not probed in this keys (Anthropic keys omit it, since that field is ignored there); mutable `https:` images are not cached. -The management API and Dashboard picker now list models that can actually accept image input. -When the matching backend is available, `gpt-5.6-luna` (OpenAI) and `claude-haiku-4-5` (Anthropic) -are always offered as baseline options. `PUT /api/sidecar-settings` rejects a model known to be -text-only, but still accepts an unknown id so custom or ahead-of-catalog names keep working. +The management API and Dashboard picker list models that can accept image input. When the matching +backend is available, `gpt-5.6-luna` (OpenAI) and `claude-haiku-4-5` (Anthropic) are always offered +as baseline options. `PUT /api/sidecar-settings` may retain an unknown custom/ahead-of-catalog id. +An explicitly configured routed Vision Sidecar is therefore usable unless capability evidence proves +that model cannot accept images; this preserves operator-selected custom sidecars without allowing a +known text-only sidecar to receive image bytes. ```json { diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 7b2c62a9ec..2df05bf1de 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -124,7 +124,8 @@ ocx combo set balanced \ | HTTP 401、403、404、408、429、または任意の 5xx |ターゲットを冷却し、次の適格なターゲットに移動します。 | |モデルのサポート終了、retired、deprecated、sunset、decommissioned、または利用不可を明示する HTTP 410 |そのターゲットをクールダウンし、次へ進みます。無関係な 410 はターミナル エラーのままです。 | |機密認証、サブスクリプション、クォータ、レート制限、過負荷、またはアップストリーム サーバー エラー |ステータスだけでは物足りない場合でもターゲットを冷やしてホップさせましょう。 | -|クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、コンテキスト オーバーフロー、または無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | +|クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、コンテキスト オーバーフロー、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | +|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、またはモデル固有の画像入力拒否(`param: input`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 | |その他の未分類のエラー |停止してエラーを返します。 | `cooldownMs` が未設定の場合、ホップされたターゲットはアップストリームのフォールバックを使用します。アップストリームコード `1302` または `1305` を伴うリクエストレート 429 では 5 秒、それ以外では 60 秒です。設定されている場合、使用可能なアップストリームの `Retry-After` または Codex リセットシグナルが存在しないときは、これらのリクエストレート 429 を含め、`cooldownMs` が適用されます。数値の `Retry-After` 秒数と HTTP-date 値が受け入れられ、すべてのクールダウンは 10 分を上限とします。優先順位は強い順に、明示的な `Retry-After` → Codex リセットヘッダー(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at`、または `x-codex-tertiary-reset-at`)→ コンボの `cooldownMs`(設定時)→ アップストリームのレート制限コード `1302`/`1305` に対する 5 秒のリクエストレート フォールバック → 60 秒のデフォルトです。有効な即時指定 `Retry-After: 0` は、設定されたクールダウンで置き換えられず、即時のアップストリーム指示として維持されます。 @@ -260,3 +261,9 @@ ocx combo remove --yes ### 最初のエラーの後にフェイルオーバーが停止したのはなぜですか? このエラーはターゲット固有のものではなく、最終的なものでした。無効な入力を修正し、大きすぎるコンテキストを削減し、ポリシーの拒否を処理し、拒否されたリクエストの送信元を修正します。コンボはそのような場合には機能しません。 + +## 任意パラメーターの互換性 + +通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、またはモデル固有の画像入力拒否(`param: input`)を示す構造化エラーでは、出力開始前に次の適格なターゲットへ進めます。この不一致ではクールダウンを記録しません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。 + +[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 e507889c6f..b8d4087431 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -130,7 +130,8 @@ ocx combo set balanced \ | HTTP 401, 403, 404, 408, 429, 또는 모든 5xx | 대상을 쿨다운으로 보내고 다음 적합한 대상으로 넘어갑니다. | | 모델 수명 종료, retired, deprecated, sunset, decommissioned, 또는 더 이상 사용할 수 없다는 신호가 명시된 HTTP 410 | 해당 대상만 쿨다운으로 보내고 다음 대상으로 넘어갑니다. 관련 없는 410은 종결 오류로 유지합니다. | | 인증, 구독, 쿼터, 속도 제한, 과부하, 또는 상위 서버 오류로 분류됨 | 상태 코드만으로는 충분하지 않더라도 대상을 쿨다운으로 보내고 넘어갑니다. | -| 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, context overflow, 또는 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | +| 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, context overflow, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | +| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값 또는 모델별 이미지 입력 거부(`param: input`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | | 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. | `cooldownMs`가 설정되지 않으면 홉된 대상은 업스트림 폴백을 사용합니다. 업스트림 코드 `1302` 또는 `1305`인 요청 속도 제한 429는 5초, 그 외에는 60초입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때, 해당 요청 속도 제한 429를 포함해 `cooldownMs`가 적용됩니다. 숫자로 된 `Retry-After` 초와 HTTP-date 값을 허용하며, 모든 쿨다운은 최대 10분으로 제한됩니다. 우선순위는 강한 순서대로 명시적 `Retry-After` → Codex 재설정 헤더(`x-codex-primary-reset-at`, `x-codex-secondary-reset-at`, 또는 `x-codex-tertiary-reset-at`) → 콤보의 `cooldownMs`(설정된 경우) → 업스트림 속도 제한 코드 `1302`/`1305`의 5초 요청 속도 제한 폴백 → 60초 기본값입니다. 유효한 즉시 지시인 `Retry-After: 0`은 설정된 쿨다운으로 대체하지 않고 업스트림의 즉시 지시로 유지합니다. @@ -272,3 +273,9 @@ opencodex 인스턴스에 기록했는지 확인하세요. 대상별 오류가 아니라 종결 오류였기 때문입니다. 잘못된 입력을 수정하고, 너무 큰 context를 줄이고, 정책 거부를 처리하거나, 거부된 요청 origin을 바로잡으세요. combo는 이런 경우 다음 대상으로 넘어가지 않습니다. + +## 선택적 매개변수 호환성 + +일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값 또는 모델별 이미지 입력 거부(`param: input`)를 나타내는 구조화된 오류는 출력 시작 전에 다음 적격 대상으로 넘어갈 수 있습니다. 이 경우 쿨다운을 기록하지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다. + +[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 ddd16f6175..b64d30b2ac 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -161,7 +161,8 @@ ocx combo set balanced \ | HTTP 401, 403, 404, 408, 429, или любой 5xx | Перевести цель в cooldown и перейти к следующей подходящей цели. | | HTTP 410 с явным признаком окончания срока службы модели, retirement, deprecated, sunset, decommissioned или недоступности | Перевести только эту цель в cooldown и перейти дальше. Несвязанные ответы 410 остаются terminal-ошибками. | | Классифицированная ошибка аутентификации, подписки, квоты, rate-limit, перегрузки или upstream-server | Перевести цель в cooldown и переключиться, даже если одного статуса недостаточно. | -| Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy, переполнение контекста или некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. | +| Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy, переполнение контекста или иной некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. | +| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort` или специфичный для модели отказ входного изображения (`param: input`) | До начала вывода переходит к следующей допустимой цели без охлаждения; см. «Совместимость необязательных параметров» ниже. | | Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. | Если `cooldownMs` не задан, цель после hop использует upstream fallback: 5 секунд для 429, ограничивающих частоту запросов, с кодом upstream `1302` или `1305`, и 60 секунд в остальных случаях. Если он задан, `cooldownMs` применяется, когда нет пригодного сигнала upstream `Retry-After` или сигнала сброса Codex, включая такие 429, ограничивающие частоту запросов. Принимаются числовые секунды в `Retry-After` и значения HTTP-date; любой cooldown ограничен 10 минутами. Приоритет от сильного к слабому: явный `Retry-After` → заголовки сброса Codex (`x-codex-primary-reset-at`, `x-codex-secondary-reset-at` или `x-codex-tertiary-reset-at`) → `cooldownMs` этой combo (если задан) → 5-секундный fallback для rate-limit-кодов upstream `1302`/`1305` → стандартные 60 секунд. Корректный немедленный `Retry-After: 0` сохраняется как немедленная директива upstream, а не заменяется настроенным cooldown. @@ -319,3 +320,9 @@ CLI и дашборд показывают точное сообщение ва Ошибка была terminal, а не специфичной для конкретной цели. Исправьте некорректный ввод, сократите слишком большой контекст, обработайте отказ политики или исправьте отклонённое происхождение запроса. В таких случаях combo не выполняют hop. + +## Совместимость необязательных параметров + +Исключение для завершающих ошибок 400: структурированный отказ от `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort` или специфичный для модели отказ входного изображения (`param: input`) позволяет до начала вывода перейти к следующей допустимой цели без периода охлаждения. Отказ политики безопасности, отмена и уже начавшийся вывод по-прежнему запрещают повторное выполнение. + +[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 4ac9e0febf..3116f94978 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -229,7 +229,8 @@ ikiye ayrılır. | --- | --- | | HTTP 401, 403, 404, 408, 429 veya herhangi bir 5xx | Hedefi soğutun ve bir sonraki uygun hedefe atlayın. | | 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, bağlam taşması veya geçersiz istek | Durun ve hatayı döndürün; başka bir hedef isteği geçerli kılmaz. | +| İstemci iptali (499), `origin_rejected`, siber politika reddi, bağlam taşması veya diğer geçersiz istek | Durun ve hatayı döndürün; başka bir hedef isteği geçerli kılmaz. | +| `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren veya modele özgü görüntü girdisini reddeden (`param: input`) 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. | | Diğer sınıflandırılmamış hatalar | Durun ve hatayı döndürün. | Atlanan bir hedef varsayılan olarak 60 saniye boyunca soğuma süresine girer. @@ -404,3 +405,9 @@ Hata hedefe özgü olmaktan ziyade uç (terminal) bir hataydı. Geçersiz girdiy düzeltin, aşırı büyük bir bağlamı azaltın, bir politika reddini işleyin veya reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapmaz. + +## İ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 veya modele özgü görüntü girdisini reddeden (`param: input`) 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. + +[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 7c8c8efd63..2ebc775f0d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -150,7 +150,8 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | HTTP 401、403、404、408、429,或任何 5xx | 使该目标进入冷却,并跳转到下一个合格目标。 | | HTTP 410,并明确表明模型已到生命周期终点、retired、deprecated、sunset、decommissioned 或不再可用 | 仅冷却该目标并继续跳转。无关的 410 仍然是终止错误。 | | 被分类为认证、订阅、配额、速率限制、过载或上游服务器错误 | 即使仅凭状态码不足以判断,也会使该目标进入冷却并跳转。 | -| 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、上下文溢出,或无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | +| 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、上下文溢出,或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | +| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值,或返回模型特定图像输入拒绝(`param: input`) | 在输出开始前跳转到下一个符合条件的目标,且不记录冷却时间;参见下方可选参数兼容性。 | | 任何其他未分类错误 | 停止并返回错误。 | 未设置 `cooldownMs` 时,发生跳转的目标使用上游回退值:对于上游代码为 `1302` 或 `1305` 的请求速率限制 429,等待 5 秒;其他情况等待 60 秒。设置后,只要不存在可用的上游 `Retry-After` 或 Codex 重置信号,就会应用 `cooldownMs`,包括这些请求速率限制 429。接受数字形式的 `Retry-After` 秒数和 HTTP-date 值,每次冷却最多封顶 10 分钟。优先级从强到弱依次为:显式 `Retry-After` → Codex 重置标头(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at` 或 `x-codex-tertiary-reset-at`)→ combo 的 `cooldownMs`(已设置时)→ 上游速率限制代码 `1302`/`1305` 的 5 秒请求速率限制回退值 → 60 秒默认值。有效的即时指令 `Retry-After: 0` 会保留为上游即时指令,不会被配置的冷却替换。 @@ -290,3 +291,9 @@ combo id 不存在。响应是 HTTP 404,类型为 `invalid_request_error`。 ### 为什么故障切换在第一次错误后就停止了? 该错误是终止性的,而不是针对目标的。修复无效输入、缩小过大的上下文、处理策略拒绝,或者纠正被拒绝的请求来源。对于这些情况,combo 不会继续跳转。 + +## 可选参数兼容性 + +一般 400 错误仍会终止请求,但明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值,或返回模型特定图像输入拒绝(`param: input`)的结构化错误,可让 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 be566c77cd..02b5288a4e 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -164,7 +164,8 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 | --- | --- | | HTTP 401、403、404、408、429 或任何 5xx | 冷卻目標並跳到下一個合格目標。 | | 分類為認證、訂閱、配額、限流、過載或上游伺服器錯誤 | 冷卻目標並跳轉,即使單靠狀態碼不足。 | -| 客戶端取消(499)、`origin_rejected`、cyber-policy 拒絕、上下文溢出或無效請求 | 停止並回傳錯誤;另一個目標不會讓請求變為有效。 | +| 客戶端取消(499)、`origin_rejected`、cyber-policy 拒絕、上下文溢出或其他無效請求 | 停止並回傳錯誤;另一個目標不會讓請求變為有效。 | +| 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值,或回傳模型特定影像輸入拒絕(`param: input`) | 在輸出開始前跳轉到下一個符合條件的目標,且不記錄冷卻時間;參見下方選用參數相容性。 | | 任何其他未分類錯誤 | 停止並回傳錯誤。 | 跳轉的目標預設進入 60 秒冷卻。若上游回應包含有效的 `Retry-After` 值,opencodex 改用它。接受數字秒與 HTTP-date 值,且每次冷卻上限為 10 分鐘。 @@ -290,3 +291,9 @@ Combo id 未知。回應為 HTTP 404 並帶 type `invalid_request_error`。執 ### 為什麼 failover 在第一個錯誤後就停止了? 該錯誤是終端的而非目標特定的。修正無效輸入、縮減過大的上下文、處理策略拒絕,或更正被拒的請求來源。Combo 對那些情況不會跳轉。 + +## 選用參數相容性 + +一般 400 錯誤仍會終止請求,但明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值,或回傳模型特定影像輸入拒絕(`param: input`)的結構化錯誤,可讓 combo 在輸出開始前嘗試下一個符合條件的目標,而不記錄冷卻時間。安全政策拒絕、取消及已開始的輸出仍不可重播。 + +[Canonical compatibility details](/guides/combos/#request-local-target-compatibility). diff --git a/scripts/generate-model-metadata.ts b/scripts/generate-model-metadata.ts index 2441e32bc9..fe3b72b960 100644 --- a/scripts/generate-model-metadata.ts +++ b/scripts/generate-model-metadata.ts @@ -44,8 +44,13 @@ const COST_VENDOR_BUNDLES = [ "zai", "mistral", "cerebras", "azure-openai", "amazon-bedrock", "xiaomi", ]; +// Capability-only bundles may describe a transport/backend that is not a public provider preset. +// The canonical ChatGPT Codex forward route has modality differences from the public OpenAI API +// (for example retired Spark is text-only there), so keep that evidence distinct. +const CAPABILITY_ONLY_BUNDLES = ["openai-codex"]; + const allowedProviders = Array.from( - new Set([...Object.values(PROVIDER_ALIASES), ...COST_VENDOR_BUNDLES]), + new Set([...Object.values(PROVIDER_ALIASES), ...COST_VENDOR_BUNDLES, ...CAPABILITY_ONLY_BUNDLES]), ).sort(); // Models the upstream catalogue lists but the provider rejects at request time. Issue #82: diff --git a/src/combos/failover.ts b/src/combos/failover.ts index e592298d88..e60fe83dee 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -351,6 +351,49 @@ const PROVIDER_SCOPED_FAILURE_CODES = new Set([ "insufficient_balance", ]); +/** + * Precise target-local request incompatibilities are request-local, not terminal for a combo. + * Require a bounded, intact provider envelope; never infer compatibility from echoed prompt text. + * Only OpenCodex's exact error wrapper may be unwrapped, with a fixed depth budget. Unknown or + * conflicting codes fail closed. No fields are removed here and no same-target replay is added. + * Image rejection requires `param: input` and an exact model-scoped prefix. + */ +function isRequestLocalTargetIncompatibility(status: number, message: string, code?: string | null): boolean { + 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; + 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); + 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; + 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; + 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; + if (e.message.startsWith("Provider error 400: ") && e.param === undefined + && (errorCode === "" || errorCode === "invalid_request_error")) { + text = e.message; + continue; + } + if (e.type !== "invalid_request_error") return false; + if (e.message === "Unsupported parameter: user") { + return (e.param === undefined || e.param === "user") && errorCode !== "unsupported_value"; + } + if (errorCode === "unsupported_value" + && (e.param === "reasoning.effort" || e.param === "reasoning_effort") + && e.message.startsWith("Unsupported value:") && e.message.includes("not supported")) return true; + return e.param === "input" + && (errorCode === "" || errorCode === "invalid_request_error") + && /^Model '[^']{1,256}' does not support image inputs\./.test(e.message); + } + return false; +} + export function comboFailureCooldownScope( status: number, message: string, @@ -363,6 +406,7 @@ export function comboFailureCooldownScope( || REQUEST_SHAPE_FAILURE_CODES.has(code) || isRequestLocalFreePromptCap(status, message, options?.code) || isProviderTargetContextOverflow(status, message, options?.code) + || isRequestLocalTargetIncompatibility(status, message, options?.code) ) return "none"; if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider"; // A rejected or unpaid credential is provider-wide evidence: every target that routes @@ -465,6 +509,7 @@ export function comboFailureDecision( // `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here. if (failureCode === "free_rate_limited") return "hop"; + if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop"; if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index 7220aa7509..42f92b2d59 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -48,6 +48,7 @@ const DATA: Record = { "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]], "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]], "openai": [["codex-mini-latest",200000,100000,"text",1,null,1.5,6,0.375,0],["gpt-4",8192,8192,"text",0,null,30,60,0,0],["gpt-4-turbo",128000,4096,"text,image",0,null,10,30,0,0],["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4.1-mini",1047576,32768,"text,image",0,null,0.4,1.6,0.1,0],["gpt-4.1-nano",1047576,32768,"text,image",0,null,0.1,0.4,0.025,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-2024-05-13",128000,4096,"text,image",0,null,5,15,0,0],["gpt-4o-2024-08-06",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-2024-11-20",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["gpt-5",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-chat-latest",128000,16384,"text,image",0,null,1.25,10,0.125,0],["gpt-5-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-mini",400000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5-nano",400000,128000,"text,image",1,null,0.05,0.4,0.005,0],["gpt-5-pro",400000,272000,"text,image",1,null,15,120,0,0],["gpt-5.1",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-chat-latest",128000,16384,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-max",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-mini",272000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5.2",400000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-chat-latest",128000,16384,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-pro",400000,128000,"text,image",1,null,21,168,0,0],["gpt-5.3-chat-latest",128000,16384,"text,image",0,null,1.75,14,0.175,0],["gpt-5.3-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.3-codex-spark",128000,32000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.4",1050000,128000,"text,image",1,null,2.5,15,0.25,0],["gpt-5.4-mini",400000,128000,"text,image",1,null,0.75,4.5,0.075,0],["gpt-5.4-nano",400000,128000,"text,image",1,null,0.2,1.25,0.02,0],["gpt-5.4-pro",1050000,128000,"text,image",1,null,30,180,0,0],["gpt-5.5",1050000,128000,"text,image",1,null,5,30,0.5,0],["gpt-5.5-pro",1050000,128000,"text,image",1,null,30,180,0,0],["gpt-5.6",373000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-luna",373000,128000,"text,image",1,null,0.2,1.2,0.02,0.25],["gpt-5.6-sol",373000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-terra",373000,128000,"text,image",1,null,2,12,0.2,2.5],["gpt-realtime-2.1",128000,32000,"text,image",1,null,4,24,0.4,0],["o1",200000,100000,"text,image",1,null,15,60,7.5,0],["o1-pro",200000,100000,"text,image",1,null,150,600,0,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-deep-research",200000,100000,"text,image",1,null,10,40,2.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0],["o3-pro",200000,100000,"text,image",1,null,20,80,0,0],["o4-mini",200000,100000,"text,image",1,null,1.1,4.4,0.275,0],["o4-mini-deep-research",200000,100000,"text,image",1,null,2,8,0.5,0]], + "openai-codex": [["codex-auto-review",1000000,128000,"text,image",1,null,0,0,0,0],["gpt-5",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-codex-mini",272000,128000,"text,image",1,null,0,0,0,0],["gpt-5.1",400000,128000,"text,image",1,null,1.25,10,0.13,0],["gpt-5.1-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-max",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-mini",272000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5.2",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.3-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.3-codex-spark",128000,128000,"text",1,null,1.75,14,0.175,0],["gpt-5.4",1000000,128000,"text,image",1,null,2.5,15,0.25,0],["gpt-5.4-mini",272000,128000,"text,image",1,null,0.75,4.5,0.075,0],["gpt-5.4-nano",272000,128000,"text,image",1,null,0.2,1.25,0.02,0],["gpt-5.5",272000,128000,"text,image",1,null,5,30,0.5,0],["gpt-5.6-luna",373000,128000,"text,image",1,null,0.2,1.2,0.02,0.25],["gpt-5.6-sol",373000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-terra",373000,128000,"text,image",1,null,2,12,0.2,2.5]], "opencode-go": [["deepseek-v4-flash",1000000,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1000000,384000,"text",1,null,1.74,3.48,0.0145,0],["glm-5",204800,131072,"text",1,null,1,3.2,0.2,0],["glm-5.1",200000,131072,"text",1,null,1.4,4.4,0.26,0],["glm-5.2",1000000,131072,"text",1,null,1.4,4.4,0.26,0],["glm-5.3",1000000,131072,"text",1,null,1.4,4.4,0.26,0],["grok-4.5",500000,500000,"text,image",1,null,2,6,0.5,0],["grok-4.6",500000,500000,"text,image",1,null,2,6,0.5,0],["hy3",256000,64000,"text",1,null,0.14,0.58,0.035,0],["kimi-k2.5",262144,262144,"text,image",1,null,0.3,1.9,0,0],["kimi-k2.6",262144,262144,"text,image",1,null,0.95,4,0.2,0],["kimi-k2.7-code",262144,262144,"text,image",1,null,0.95,4,0.19,0],["kimi-k3",1048576,131072,"text,image",1,null,3,15,0.3,0],["mimo-v2-omni",262144,131072,"text,image",1,null,0.4,2,0.08,0],["mimo-v2-pro",1048576,131072,"text",1,null,1,3,0.2,0],["mimo-v2.5",1048576,131072,"text,image",1,null,0.14,0.28,0.0028,0],["mimo-v2.5-pro",1048576,131072,"text",1,null,1.74,3.48,0.0145,0],["minimax-m2.5",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["minimax-m2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.3,1.2,0.06,0],["qwen3.5-plus",1000000,65536,"text,image",1,null,0.4,2.4,0,0],["qwen3.6-plus",1000000,65536,"text,image",1,null,2,6,0.2,2.5],["qwen3.7-max",1000000,65536,"text",1,null,2.5,7.5,0.5,3.125],["qwen3.7-plus",1000000,64000,"text,image",1,null,1.2,4.8,0.12,1.5]], "openrouter": [["~anthropic/claude-fable-latest",1000000,128000,"text,image",1,null,10,50,1,12.5],["~anthropic/claude-haiku-latest",200000,64000,"text,image",1,null,1,5,0.09999999999999999,1.25],["~anthropic/claude-opus-latest",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["~anthropic/claude-sonnet-latest",1000000,128000,"text,image",1,null,2,10,0.19999999999999998,2.5],["~google/gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0.08333333333333334],["~google/gemini-pro-latest",1048576,65536,"text,image",1,null,2,12,0.19999999999999998,0.375],["~moonshotai/kimi-latest",1048576,8888,"text,image",1,null,3,15,0.3,0],["~openai/gpt-latest",1050000,128000,"text,image",1,null,5,30,0.5,6.25],["~openai/gpt-mini-latest",400000,128000,"text,image",1,null,0.75,4.5,0.075,0],["~x-ai/grok-latest",500000,8888,"text,image",1,null,2,6,0.3,0],["ai21/jamba-large-1.7",256000,4096,"text",0,null,2,8,0,0],["aion-labs/aion-2.0",131072,32768,"text",1,null,0.7999999999999999,1.5999999999999999,0.19999999999999998,0],["aion-labs/aion-3.0",131072,32768,"text",1,null,3,6,0.75,0],["aion-labs/aion-3.0-mini",131072,32768,"text",1,null,0.7,1.4,0.18,0],["alibaba/tongyi-deepresearch-30b-a3b",131072,131072,"text",1,null,0.09,0.44999999999999996,0.09,0],["allenai/olmo-3.1-32b-instruct",65536,16384,"text",0,null,0.19999999999999998,0.6,0,0],["amazon/nova-2-lite-v1",1000000,65535,"text,image",1,null,0.3,2.5,0,0],["amazon/nova-lite-v1",300000,5120,"text,image",0,null,0.06,0.24,0,0],["amazon/nova-micro-v1",128000,5120,"text",0,null,0.035,0.14,0,0],["amazon/nova-premier-v1",1000000,32000,"text,image",0,null,2.5,12.5,0.625,0],["amazon/nova-pro-v1",300000,5120,"text,image",0,null,0.7999999999999999,3.1999999999999997,0,0],["anthropic/claude-3-haiku",200000,4096,"text,image",0,null,0.25,1.25,0.03,0.3],["anthropic/claude-3.5-haiku",200000,8192,"text,image",0,null,0.7999999999999999,4,0.08,1],["anthropic/claude-3.5-sonnet",200000,8192,"text,image",0,null,6,30,0.6,7.5],["anthropic/claude-3.7-sonnet",200000,128000,"text,image",1,null,3,15,0.3,3.75],["anthropic/claude-3.7-sonnet:thinking",200000,64000,"text,image",1,null,3,15,0.3,3.75],["anthropic/claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["anthropic/claude-haiku-4.5",200000,64000,"text,image",0,null,1,5,0.09999999999999999,1.25],["anthropic/claude-opus-4",200000,32000,"text,image",1,null,15,75,1.5,18.75],["anthropic/claude-opus-4.1",200000,32000,"text,image",1,null,15,75,1.5,18.75],["anthropic/claude-opus-4.5",200000,64000,"text,image",1,null,5,25,0.5,6.25],["anthropic/claude-opus-4.6",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic/claude-opus-4.6-fast",1000000,128000,"text,image",1,null,30,150,3,37.5],["anthropic/claude-opus-4.7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic/claude-opus-4.7-fast",1000000,128000,"text,image",1,null,30,150,3,37.5],["anthropic/claude-opus-4.8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic/claude-opus-4.8-fast",1000000,128000,"text,image",1,null,10,50,1,12.5],["anthropic/claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic/claude-opus-5-fast",1000000,128000,"text,image",1,null,10,50,1,12.5],["anthropic/claude-sonnet-4",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["anthropic/claude-sonnet-4.5",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["anthropic/claude-sonnet-4.6",1000000,128000,"text,image",1,null,3,15,0.3,3.75],["anthropic/claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.19999999999999998,2.5],["arcee-ai/trinity-large-preview",131000,8888,"text",0,null,0.15,0.44999999999999996,0,0],["arcee-ai/trinity-large-preview:free",131000,8888,"text",0,null,0,0,0,0],["arcee-ai/trinity-large-thinking",262144,262144,"text",1,null,0.22,0.85,0.06,0],["arcee-ai/trinity-large-thinking:free",262144,80000,"text",1,null,0,0,0,0],["arcee-ai/trinity-mini",131072,131072,"text",1,null,0.045,0.15,0,0],["arcee-ai/trinity-mini:free",131072,8888,"text",1,null,0,0,0,0],["arcee-ai/virtuoso-large",131072,64000,"text",0,null,0.75,1.2,0,0],["auto",2000000,30000,"text,image",1,null,0,0,0,0],["baidu/cobuddy:free",131072,65536,"text",1,null,0,0,0,0],["baidu/ernie-4.5-21b-a3b",131072,8000,"text",0,null,0.07,0.28,0,0],["baidu/ernie-4.5-vl-28b-a3b",131072,8000,"text,image",1,null,0.14,0.56,0,0],["bytedance-seed/seed-1.6",262144,32768,"text,image",1,null,0.25,2,0,0],["bytedance-seed/seed-1.6-flash",262144,32768,"text,image",1,null,0.075,0.3,0,0],["bytedance-seed/seed-2.0-lite",262144,131072,"text,image",1,null,0.25,2,0,0],["bytedance-seed/seed-2.0-mini",262144,131072,"text,image",1,null,0.09999999999999999,0.39999999999999997,0,0],["cohere/command-r-08-2024",128000,4000,"text",0,null,0.15,0.6,0,0],["cohere/command-r-plus-08-2024",128000,4000,"text",0,null,2.5,10,0,0],["cohere/north-mini-code:free",256000,64000,"text",1,null,0,0,0,0],["deepseek/deepseek-chat",163840,16000,"text",0,null,0.20020000000000002,0.8000999999999999,0.15,0],["deepseek/deepseek-chat-v3-0324",163840,65536,"text",1,null,0.27,1.12,0.135,0],["deepseek/deepseek-chat-v3.1",163840,32768,"text",1,null,0.25,0.95,0.13,0],["deepseek/deepseek-r1",163840,16000,"text",1,null,0.7,2.5,0,0],["deepseek/deepseek-r1-0528",163840,32768,"text",1,null,0.5,2.1500000000000004,0.35,0],["deepseek/deepseek-v3.1-terminus",163840,32768,"text",1,null,0.27,1,0.135,0],["deepseek/deepseek-v3.1-terminus:exacto",163840,8888,"text",1,null,0.21,0.7899999999999999,0.16799999999999998,0],["deepseek/deepseek-v3.2",163840,65536,"text",1,null,0.26899999999999996,0.39999999999999997,0.13449999999999998,0],["deepseek/deepseek-v3.2-exp",163840,65536,"text",1,null,0.27,0.41,0,0],["deepseek/deepseek-v4-flash",1048576,384000,"text",1,null,0.09380000000000001,0.18760000000000002,0.01876,0],["deepseek/deepseek-v4-flash:free",1048576,384000,"text",1,null,0,0,0,0],["deepseek/deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0],["essentialai/rnj-1-instruct",32768,8888,"text",0,null,0.15,0.15,0,0],["google/gemini-2.0-flash-001",1048576,8192,"text,image",0,null,0.09999999999999999,0.39999999999999997,0.024999999999999998,0.08333333333333334],["google/gemini-2.0-flash-lite-001",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["google/gemini-2.5-flash",1048576,65535,"text,image",1,null,0.3,2.5,0.03,0.08333333333333334],["google/gemini-2.5-flash-lite",1048576,65535,"text,image",0,null,0.09999999999999999,0.39999999999999997,0.01,0.08333333333333334],["google/gemini-2.5-flash-lite-preview-09-2025",1048576,65535,"text,image",1,null,0.09999999999999999,0.39999999999999997,0.01,0.08333333333333334],["google/gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0.08333333333333334],["google/gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0.375],["google/gemini-2.5-pro-preview",1048576,65536,"text,image",1,null,1.25,10,0.125,0.375],["google/gemini-2.5-pro-preview-05-06",1048576,65535,"text,image",1,null,1.25,10,0.125,0.375],["google/gemini-3-flash-preview",1048576,65535,"text,image",1,null,0.5,3,0.049999999999999996,0.08333333333333334],["google/gemini-3-pro-image",131072,32768,"text,image",1,null,2,12,0.19999999999999998,0.375],["google/gemini-3-pro-preview",1048000,64000,"text,image",1,null,2,12,0.19999999999999998,0.375],["google/gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.024999999999999998,0.08333333333333334],["google/gemini-3.1-flash-lite-preview",1048576,65536,"text,image",0,null,0.25,1.5,0.024999999999999998,0.08333333333333334],["google/gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.19999999999999998,0.375],["google/gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.19999999999999998,0.375],["google/gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0.08333333333333334],["google/gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0.08333333333333334],["google/gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0.08333333333333334],["google/gemma-3-12b-it",131072,16384,"text,image",0,null,0.049999999999999996,0.15,0,0],["google/gemma-3-27b-it",262144,131072,"text,image",1,null,0.08,0.44999999999999996,0.04,0],["google/gemma-3-27b-it:free",131072,8192,"text,image",0,null,0,0,0,0],["google/gemma-4-26b-a4b-it",262144,262144,"text,image",1,null,0.12,0.35,0.049999999999999996,0],["google/gemma-4-26b-a4b-it:free",262144,32768,"text,image",1,null,0,0,0,0],["google/gemma-4-31b-it",262144,262144,"text,image",1,null,0.14,0.39999999999999997,0.09,0],["google/gemma-4-31b-it:free",262144,32768,"text,image",1,null,0,0,0,0],["ibm-granite/granite-4.1-8b",131072,131072,"text",0,null,0.049999999999999996,0.09999999999999999,0.049999999999999996,0],["inception/mercury",128000,32000,"text",0,null,0.25,0.75,0.024999999999999998,0],["inception/mercury-2",128000,50000,"text",1,null,0.25,0.75,0.024999999999999998,0],["inception/mercury-coder",128000,32000,"text",0,null,0.25,0.75,0.024999999999999998,0],["inclusionai/ling-2.6-1t",262144,32768,"text",0,null,0.075,0.625,0.015,0],["inclusionai/ling-2.6-1t:free",262144,32768,"text",0,null,0,0,0,0],["inclusionai/ling-2.6-flash",262144,32768,"text",0,null,0.01,0.03,0.002,0],["inclusionai/ling-2.6-flash:free",262144,32768,"text",0,null,0,0,0,0],["inclusionai/ling-3.0-flash:free",262144,32768,"text",1,null,0,0,0,0],["inclusionai/ring-2.6-1t",262144,65536,"text",1,null,0.075,0.625,0.015,0],["inclusionai/ring-2.6-1t:free",262144,65536,"text",1,null,0,0,0,0],["kwaipilot/kat-coder-air-v2.5",256000,80000,"text",0,null,0.15,0.6,0.03,0],["kwaipilot/kat-coder-pro",256000,128000,"text",0,null,0.207,0.828,0.0414,0],["kwaipilot/kat-coder-pro-v2",262144,80000,"text",0,null,0.3,1.2,0.06,0],["kwaipilot/kat-coder-pro-v2.5",256000,80000,"text",0,null,0.74,2.96,0.15,0],["liquid/lfm-2.5-1.2b-thinking:free",32768,8888,"text",1,null,0,0,0,0],["meituan/longcat-2.0",1048756,262144,"text",1,null,0.3,1.2,0.006,0],["meituan/longcat-flash-chat",131072,131072,"text",0,null,0.19999999999999998,0.7999999999999999,0.19999999999999998,0],["meta-llama/llama-3-8b-instruct",8192,16384,"text",0,null,0.03,0.04,0,0],["meta-llama/llama-3.1-405b-instruct",131000,8888,"text",0,null,4,4,0,0],["meta-llama/llama-3.1-70b-instruct",131072,16384,"text",0,null,0.39999999999999997,0.39999999999999997,0,0],["meta-llama/llama-3.1-8b-instruct",131072,131072,"text",0,null,0.049999999999999996,0.08,0.024999999999999998,0],["meta-llama/llama-3.3-70b-instruct",131072,128000,"text",0,null,0.13,0.39999999999999997,0,0],["meta-llama/llama-3.3-70b-instruct:free",131072,8888,"text",0,null,0,0,0,0],["meta-llama/llama-4-maverick",1048576,16384,"text,image",0,null,0.19999999999999998,0.7999999999999999,0,0],["meta-llama/llama-4-scout",1310720,16384,"text,image",0,null,0.09999999999999999,0.3,0,0],["meta/muse-spark-1.1",1048576,8888,"text,image",1,null,1.25,4.25,0.15,0],["minimax/minimax-m1",1000000,40000,"text",1,null,0.55,2.2,0,0],["minimax/minimax-m2",204800,131072,"text",1,null,0.255,1.02,0.03,0],["minimax/minimax-m2.1",204800,131072,"text",1,null,0.3,1.2,0.03,0],["minimax/minimax-m2.5",204800,196608,"text",1,null,0.15,0.8999999999999999,0.049999999999999996,0],["minimax/minimax-m2.5:free",262144,8192,"text",1,null,0,0,0,0],["minimax/minimax-m2.7",204800,131072,"text",1,null,0.25,1,0.049999999999999996,0],["minimax/minimax-m3",1048576,512000,"text,image",1,null,0.3,1.2,0.06,0],["mistralai/codestral-2508",256000,8888,"text",0,null,0.3,0.8999999999999999,0.03,0],["mistralai/devstral-2512",262144,8888,"text",0,null,0.39999999999999997,2,0.04,0],["mistralai/devstral-medium",131072,8888,"text",0,null,0.39999999999999997,2,0.04,0],["mistralai/devstral-small",131072,8888,"text",0,null,0.09999999999999999,0.3,0.01,0],["mistralai/ministral-14b-2512",262144,8888,"text,image",0,null,0.19999999999999998,0.19999999999999998,0.02,0],["mistralai/ministral-3b-2512",131072,8888,"text,image",0,null,0.09999999999999999,0.09999999999999999,0.01,0],["mistralai/ministral-8b-2512",262144,8888,"text,image",0,null,0.15,0.15,0.015,0],["mistralai/mistral-large",128000,8888,"text",0,null,2,6,0.19999999999999998,0],["mistralai/mistral-large-2407",131072,8888,"text",0,null,2,6,0.19999999999999998,0],["mistralai/mistral-large-2411",131072,8888,"text",0,null,2,6,0.19999999999999998,0],["mistralai/mistral-large-2512",262144,8888,"text,image",0,null,0.5,1.5,0.049999999999999996,0],["mistralai/mistral-medium-3",131072,8888,"text,image",0,null,0.39999999999999997,2,0.04,0],["mistralai/mistral-medium-3-5",262144,8888,"text,image",1,null,1.5,7.5,0,0],["mistralai/mistral-medium-3.1",131072,8888,"text,image",0,null,0.39999999999999997,2,0.04,0],["mistralai/mistral-nemo",131072,16384,"text",0,null,0.019000000000000003,0.03,0,0],["mistralai/mistral-saba",32768,8888,"text",0,null,0.19999999999999998,0.6,0.02,0],["mistralai/mistral-small-24b-instruct-2501",32768,16384,"text",0,null,0.049999999999999996,0.08,0,0],["mistralai/mistral-small-2603",262144,8888,"text,image",1,null,0.15,0.6,0.015,0],["mistralai/mistral-small-3.1-24b-instruct",131072,131072,"text,image",0,null,0.03,0.11,0.015,0],["mistralai/mistral-small-3.1-24b-instruct:free",128000,8888,"text,image",0,null,0,0,0,0],["mistralai/mistral-small-3.2-24b-instruct",256000,8888,"text,image",0,null,0.09999999999999999,0.3,0.01,0],["mistralai/mistral-small-creative",32768,8888,"text",0,null,0.09999999999999999,0.3,0.01,0],["mistralai/mixtral-8x22b-instruct",65536,13108,"text",0,null,2,6,0.19999999999999998,0],["mistralai/mixtral-8x7b-instruct",32768,16384,"text",0,null,0.54,0.54,0,0],["mistralai/pixtral-large-2411",131072,8888,"text,image",0,null,2,6,0.19999999999999998,0],["mistralai/voxtral-small-24b-2507",32000,8888,"text",0,null,0.09999999999999999,0.3,0.01,0],["moonshotai/kimi-k2",131072,100352,"text",0,null,0.5700000000000001,2.3,0,0],["moonshotai/kimi-k2-0905",262144,100352,"text",0,null,0.6,2.5,0.15,0],["moonshotai/kimi-k2-0905:exacto",262144,8888,"text",0,null,0.6,2.5,0,0],["moonshotai/kimi-k2-thinking",262144,100352,"text",1,null,0.6,2.5,0.15,0],["moonshotai/kimi-k2.5",262144,262144,"text,image",1,null,0.5700000000000001,2.8499999999999996,0.095,0],["moonshotai/kimi-k2.6",262144,262144,"text,image",1,null,0.646,2.7199999999999998,0.1088,0],["moonshotai/kimi-k2.6:free",262144,8888,"text,image",1,null,0,0,0,0],["moonshotai/kimi-k2.7-code",262144,262144,"text,image",1,null,0.78,3.5,0.15,0],["moonshotai/kimi-k3",1048576,131072,"text,image",1,null,3,15,0.3,0],["nex-agi/deepseek-v3.1-nex-n1",131072,163840,"text",0,null,0.135,0.5,0,0],["nex-agi/nex-n2-mini",262144,262144,"text,image",1,null,0.024999999999999998,0.09999999999999999,0.0025,0],["nex-agi/nex-n2-pro",262144,262144,"text,image",1,null,0.25,1,0.024999999999999998,0],["nex-agi/nex-n2-pro:free",262144,262144,"text,image",1,null,0,0,0,0],["nousresearch/deephermes-3-mistral-24b-preview",32768,32768,"text",1,null,0.02,0.09999999999999999,0.01,0],["nousresearch/hermes-4-70b",131072,131072,"text",1,null,0.11,0.38,0.055,0],["nvidia/llama-3.1-nemotron-70b-instruct",131072,16384,"text",0,null,1.2,1.2,0,0],["nvidia/llama-3.3-nemotron-super-49b-v1.5",131072,16384,"text",1,null,0.39999999999999997,0.39999999999999997,0,0],["nvidia/nemotron-3-nano-30b-a3b",262144,228000,"text",1,null,0.049999999999999996,0.19999999999999998,0,0],["nvidia/nemotron-3-nano-30b-a3b:free",256000,8888,"text",1,null,0,0,0,0],["nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",256000,65536,"text,image",1,null,0,0,0,0],["nvidia/nemotron-3-super-120b-a12b",1000000,16384,"text",1,null,0.08499999999999999,0.39999999999999997,0.09999999999999999,0],["nvidia/nemotron-3-super-120b-a12b:free",262144,262144,"text",1,null,0,0,0,0],["nvidia/nemotron-3-ultra-550b-a55b",512288,65536,"text",1,null,0.6,3.5999999999999996,0.19999999999999998,0],["nvidia/nemotron-3-ultra-550b-a55b:free",1000000,65536,"text",1,null,0,0,0,0],["nvidia/nemotron-nano-12b-v2-vl:free",128000,128000,"text,image",1,null,0,0,0,0],["nvidia/nemotron-nano-9b-v2",131072,16384,"text",1,null,0.04,0.16,0,0],["nvidia/nemotron-nano-9b-v2:free",128000,8888,"text",1,null,0,0,0,0],["openai/gpt-3.5-turbo",16385,4096,"text",0,null,0.5,1.5,0,0],["openai/gpt-3.5-turbo-0613",4095,4096,"text",0,null,1,2,0,0],["openai/gpt-3.5-turbo-16k",16385,4096,"text",0,null,3,4,0,0],["openai/gpt-4",8191,8192,"text",0,null,30,60,0,0],["openai/gpt-4-0314",8191,4096,"text",0,null,30,60,0,0],["openai/gpt-4-1106-preview",128000,4096,"text",0,null,10,30,0,0],["openai/gpt-4-turbo",128000,4096,"text,image",0,null,10,30,0,0],["openai/gpt-4-turbo-preview",128000,4096,"text",0,null,10,30,0,0],["openai/gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["openai/gpt-4.1-mini",1047576,32768,"text,image",0,null,0.39999999999999997,1.5999999999999999,0.09999999999999999,0],["openai/gpt-4.1-nano",1047576,32768,"text,image",0,null,0.09999999999999999,0.39999999999999997,0.024999999999999998,0],["openai/gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["openai/gpt-4o-2024-05-13",128000,4096,"text,image",0,null,5,15,0,0],["openai/gpt-4o-2024-08-06",128000,16384,"text,image",0,null,2.5,10,1.25,0],["openai/gpt-4o-2024-11-20",128000,16384,"text,image",0,null,2.5,10,1.25,0],["openai/gpt-4o-audio-preview",128000,16384,"text",0,null,2.5,10,0,0],["openai/gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["openai/gpt-4o-mini-2024-07-18",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["openai/gpt-4o:extended",128000,64000,"text,image",0,null,6,18,0,0],["openai/gpt-5",400000,128000,"text,image",1,null,1.25,10,0.125,0],["openai/gpt-5-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["openai/gpt-5-image",400000,128000,"text,image",1,null,10,10,1.25,0],["openai/gpt-5-image-mini",400000,128000,"text,image",1,null,2.5,2,0.25,0],["openai/gpt-5-mini",400000,128000,"text,image",1,null,0.25,2,0.024999999999999998,0],["openai/gpt-5-nano",400000,128000,"text,image",1,null,0.049999999999999996,0.39999999999999997,0.005,0],["openai/gpt-5-pro",400000,128000,"text,image",1,null,15,120,0,0],["openai/gpt-5.1",400000,128000,"text,image",1,null,1.25,10,0.125,0],["openai/gpt-5.1-chat",128000,16384,"text,image",0,null,1.25,10,0.125,0],["openai/gpt-5.1-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["openai/gpt-5.1-codex-max",272000,128000,"text,image",1,null,1.25,10,0.125,0],["openai/gpt-5.1-codex-mini",272000,100000,"text,image",1,null,0.25,2,0.024999999999999998,0],["openai/gpt-5.2",400000,128000,"text,image",1,null,1.75,14,0.175,0],["openai/gpt-5.2-chat",128000,16384,"text,image",0,null,1.75,14,0.175,0],["openai/gpt-5.2-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["openai/gpt-5.2-pro",400000,128000,"text,image",1,null,21,168,0,0],["openai/gpt-5.3-chat",128000,16384,"text",0,null,1.75,14,0.175,0],["openai/gpt-5.3-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["openai/gpt-5.4",1050000,128000,"text,image",1,null,2.5,15,0.25,0],["openai/gpt-5.4-mini",400000,128000,"text",0,null,0.75,4.5,0.075,0],["openai/gpt-5.4-nano",400000,128000,"text",0,null,0.19999999999999998,1.25,0.02,0],["openai/gpt-5.4-pro",1050000,128000,"text,image",1,null,30,180,0,0],["openai/gpt-5.5",1050000,128000,"text,image",1,null,5,30,0.5,0],["openai/gpt-5.5-pro",1050000,128000,"text,image",1,null,30,180,0,0],["openai/gpt-5.6-luna",373000,128000,"text,image",1,null,1,6,0.09999999999999999,1.25],["openai/gpt-5.6-luna-pro",373000,128000,"text,image",1,null,1,6,0.09999999999999999,1.25],["openai/gpt-5.6-sol",373000,128000,"text,image",1,null,5,30,0.5,6.25],["openai/gpt-5.6-sol-pro",373000,128000,"text,image",1,null,5,30,0.5,6.25],["openai/gpt-5.6-terra",373000,128000,"text,image",1,null,2.5,15,0.25,3.125],["openai/gpt-5.6-terra-pro",373000,128000,"text,image",1,null,2.5,15,0.25,3.125],["openai/gpt-audio",128000,16384,"text",0,null,2.5,10,0,0],["openai/gpt-audio-mini",128000,16384,"text",0,null,0.6,2.4,0,0],["openai/gpt-chat-latest",400000,128000,"text,image",0,null,5,30,0.5,0],["openai/gpt-oss-120b",131072,131072,"text",1,null,0.037,0.16999999999999998,0,0],["openai/gpt-oss-120b:exacto",131072,8888,"text",1,null,0.039,0.19,0,0],["openai/gpt-oss-120b:free",131072,131072,"text",1,null,0,0,0,0],["openai/gpt-oss-20b",131072,131072,"text",1,null,0.03,0.13,0.03,0],["openai/gpt-oss-20b:free",131072,32768,"text",1,null,0,0,0,0],["openai/gpt-oss-safeguard-20b",131072,65536,"text",1,null,0.075,0.3,0.0375,0],["openai/o1",200000,100000,"text,image",1,null,15,60,7.5,0],["openai/o3",200000,100000,"text,image",1,null,2,8,0.5,0],["openai/o3-deep-research",200000,100000,"text,image",1,null,10,40,2.5,0],["openai/o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0],["openai/o3-mini-high",200000,100000,"text",1,null,1.1,4.4,0.55,0],["openai/o3-pro",200000,100000,"text,image",1,null,20,80,0,0],["openai/o4-mini",200000,100000,"text,image",1,null,1.1,4.4,0.275,0],["openai/o4-mini-deep-research",200000,100000,"text,image",1,null,2,8,0.5,0],["openai/o4-mini-high",200000,100000,"text,image",1,null,1.1,4.4,0.275,0],["openrouter/aurora-alpha",128000,50000,"text",1,null,0,0,0,0],["openrouter/auto",2000000,8888,"text,image",1,null,-1000000,-1000000,0,0],["openrouter/auto-beta",2000000,8888,"text,image",1,null,-1000000,-1000000,0,0],["openrouter/elephant-alpha",262144,32768,"text",0,null,0,0,0,0],["openrouter/free",200000,8888,"text,image",1,null,0,0,0,0],["openrouter/healer-alpha",262144,32000,"text,image",1,null,0,0,0,0],["openrouter/hunter-alpha",1048576,32000,"text",1,null,0,0,0,0],["openrouter/owl-alpha",1048756,262144,"text",0,null,0,0,0,0],["poolside/laguna-m.1",262144,32768,"text",1,null,0.19999999999999998,0.39999999999999997,0.09999999999999999,0],["poolside/laguna-m.1:free",262144,32768,"text",1,null,0,0,0,0],["poolside/laguna-s-2.1",1048576,131072,"text",1,null,0.09999999999999999,0.19999999999999998,0.01,0],["poolside/laguna-s-2.1:free",262144,32768,"text",1,null,0,0,0,0],["poolside/laguna-xs-2.1",262144,32768,"text",1,null,0.06,0.12,0.03,0],["poolside/laguna-xs-2.1:free",262144,32768,"text",1,null,0,0,0,0],["poolside/laguna-xs.2",262144,32768,"text",1,null,0.09999999999999999,0.19999999999999998,0.049999999999999996,0],["poolside/laguna-xs.2:free",262144,32768,"text",1,null,0,0,0,0],["prime-intellect/intellect-3",131072,131072,"text",1,null,0.19999999999999998,1.1,0,0],["qwen/qwen-2.5-72b-instruct",32768,16384,"text",0,null,0.36,0.39999999999999997,0,0],["qwen/qwen-2.5-7b-instruct",32768,32768,"text",0,null,0.04,0.09999999999999999,0,0],["qwen/qwen-max",32768,8192,"text",0,null,1.04,4.16,0.20800000000000002,0],["qwen/qwen-plus",1000000,32768,"text",0,null,0.26,0.78,0.052000000000000005,0.325],["qwen/qwen-plus-2025-07-28",1000000,32768,"text",0,null,0.26,0.78,0,0.325],["qwen/qwen-plus-2025-07-28:thinking",1000000,32768,"text",1,null,0.26,0.78,0,0.325],["qwen/qwen-turbo",131072,8192,"text",0,null,0.0325,0.13,0.006500000000000001,0],["qwen/qwen-vl-max",131072,32768,"text,image",0,null,0.52,2.08,0,0],["qwen/qwen3-14b",131072,8192,"text",1,null,0.22749999999999998,0.9099999999999999,0,0],["qwen/qwen3-235b-a22b",131072,8192,"text",1,null,0.45499999999999996,1.8199999999999998,0,0],["qwen/qwen3-235b-a22b-2507",262144,16384,"text",1,null,0.09,0.55,0,0],["qwen/qwen3-235b-a22b-thinking-2507",262144,32768,"text",1,null,0.3,3,0.09999999999999999,0],["qwen/qwen3-30b-a3b",131072,8192,"text",1,null,0.13,0.52,0,0],["qwen/qwen3-30b-a3b-instruct-2507",262144,32000,"text",0,null,0.04815,0.19305,0,0],["qwen/qwen3-30b-a3b-thinking-2507",81920,32768,"text",1,null,0.13,1.56,0.08,0],["qwen/qwen3-32b",131072,16384,"text",1,null,0.08,0.28,0.04,0],["qwen/qwen3-4b",131072,8192,"text",1,null,0.0715,0.273,0,0],["qwen/qwen3-4b:free",40960,8888,"text",1,null,0,0,0,0],["qwen/qwen3-8b",131072,8192,"text",1,null,0.117,0.45499999999999996,0.049999999999999996,0],["qwen/qwen3-coder",262144,65536,"text",0,null,0.3,1,0.09999999999999999,0],["qwen/qwen3-coder-30b-a3b-instruct",262144,32768,"text",0,null,0.07,0.27,0,0],["qwen/qwen3-coder-flash",1000000,65536,"text",0,null,0.195,0.975,0.039,0.24375],["qwen/qwen3-coder-next",262144,262144,"text",0,null,0.11,0.7999999999999999,0.07,0],["qwen/qwen3-coder-plus",1000000,65536,"text",0,null,0.65,3.25,0.13,0.8125],["qwen/qwen3-coder:exacto",262144,65536,"text",0,null,0.22,1.7999999999999998,0.022,0],["qwen/qwen3-coder:free",1048576,262000,"text",0,null,0,0,0,0],["qwen/qwen3-max",262144,32768,"text",1,null,0.78,3.9,0.156,0.975],["qwen/qwen3-max-thinking",262144,32768,"text",1,null,0.78,3.9,0,0],["qwen/qwen3-next-80b-a3b-instruct",262144,262144,"text",0,null,0.09999999999999999,1.1,0.07,0],["qwen/qwen3-next-80b-a3b-instruct:free",262144,8888,"text",0,null,0,0,0,0],["qwen/qwen3-next-80b-a3b-thinking",262144,32768,"text",1,null,0.0975,0.78,0,0],["qwen/qwen3-vl-235b-a22b-instruct",262144,32768,"text,image",0,null,0.21,1.9,0.09999999999999999,0],["qwen/qwen3-vl-235b-a22b-thinking",131072,32768,"text,image",1,null,0.26,2.6,0,0],["qwen/qwen3-vl-30b-a3b-instruct",262144,16384,"text,image",0,null,0.15,0.6,0,0],["qwen/qwen3-vl-30b-a3b-thinking",262144,32768,"text,image",1,null,0.13,1.56,0,0],["qwen/qwen3-vl-32b-instruct",131072,32768,"text,image",0,null,0.10400000000000001,0.41600000000000004,0,0],["qwen/qwen3-vl-8b-instruct",262144,32768,"text,image",0,null,0.117,0.45499999999999996,0,0],["qwen/qwen3-vl-8b-thinking",131072,32768,"text,image",1,null,0.117,1.365,0,0],["qwen/qwen3.5-122b-a10b",262144,65536,"text,image",1,null,0.26,2.08,0,0],["qwen/qwen3.5-27b",262144,65536,"text,image",1,null,0.195,1.56,0,0],["qwen/qwen3.5-35b-a3b",262144,262144,"text,image",1,null,0.14,1,0.049999999999999996,0],["qwen/qwen3.5-397b-a17b",262144,65536,"text,image",1,null,0.39,2.34,0.111,0],["qwen/qwen3.5-9b",262144,262144,"text,image",1,null,0.09999999999999999,0.15,0,0],["qwen/qwen3.5-flash-02-23",1000000,65536,"text,image",1,null,0.065,0.26,0,0.08125],["qwen/qwen3.5-plus-02-15",1000000,65536,"text,image",1,null,0.26,1.56,0,0.325],["qwen/qwen3.5-plus-20260420",1000000,65536,"text,image",1,null,0.3,1.7999999999999998,0,0.375],["qwen/qwen3.6-27b",262144,131072,"text,image",1,null,0.28900000000000003,2.4,0.15,0],["qwen/qwen3.6-35b-a3b",262144,262144,"text,image",1,null,0.14,1,0.049999999999999996,0],["qwen/qwen3.6-flash",1000000,65536,"text,image",1,null,0.1875,1.125,0,0.234375],["qwen/qwen3.6-max-preview",262144,65536,"text",1,null,1.04,6.24,0,1.3],["qwen/qwen3.6-plus",1000000,65536,"text",1,null,0.325,1.95,0,0.40625],["qwen/qwen3.6-plus-preview:free",1000000,32000,"text",1,null,0,0,0,0],["qwen/qwen3.6-plus:free",1000000,65536,"text,image",1,null,0,0,0,0],["qwen/qwen3.7-max",1000000,65536,"text",1,null,1.475,4.425,0.295,1.84375],["qwen/qwen3.7-plus",1000000,65536,"text,image",1,null,0.32,1.28,0.064,0.39999999999999997],["qwen/qwq-32b",131072,131072,"text",1,null,0.15,0.58,0,0],["reka/reka-edge",16384,16384,"text,image",0,null,0.09999999999999999,0.09999999999999999,0,0],["rekaai/reka-edge",16384,16384,"text,image",0,null,0.09999999999999999,0.09999999999999999,0,0],["relace/relace-search",256000,128000,"text",0,null,1,3,0,0],["sakana/fugu-ultra",1000000,128000,"text,image",1,null,5,30,0.5,0],["sao10k/l3-euryale-70b",8192,8192,"text",0,null,1.48,1.48,0,0],["sao10k/l3.1-euryale-70b",131072,16384,"text",0,null,0.85,0.85,0,0],["stepfun/step-3.5-flash",262144,65536,"text",0,null,0.09999999999999999,0.3,0.02,0],["stepfun/step-3.5-flash:free",256000,256000,"text",1,null,0,0,0,0],["stepfun/step-3.7-flash",262144,256000,"text,image",1,null,0.19999999999999998,1.15,0.04,0],["tencent/hy3",262144,128000,"text",1,null,0.13199999999999998,0.5279999999999999,0.032999999999999995,0],["tencent/hy3-preview",262144,64000,"text",1,null,0.063,0.21,0.020999999999999998,0],["tencent/hy3-preview:free",262144,262144,"text",1,null,0,0,0,0],["tencent/hy3:free",262144,262144,"text",1,null,0,0,0,0],["thedrummer/rocinante-12b",32768,32768,"text",0,null,0.16999999999999998,0.43,0,0],["thedrummer/unslopnemo-12b",32768,32768,"text",0,null,0.39999999999999997,0.39999999999999997,0,0],["thinkingmachines/inkling",1048576,8888,"text,image",1,null,1,4.05,0.16999999999999998,0],["tngtech/deepseek-r1t2-chimera",163840,163840,"text",1,null,0.3,1.1,0.15,0],["tngtech/tng-r1t-chimera",163840,65536,"text",1,null,0.25,0.85,0.125,0],["upstage/solar-pro-3",128000,8888,"text",1,null,0.15,0.6,0.015,0],["upstage/solar-pro-3:free",128000,8888,"text",1,null,0,0,0,0],["x-ai/grok-3",131072,8888,"text",0,null,3,15,0.75,0],["x-ai/grok-3-beta",131072,8888,"text",0,null,3,15,0.75,0],["x-ai/grok-3-mini",131072,8888,"text",1,null,0.3,0.5,0.075,0],["x-ai/grok-3-mini-beta",131072,8888,"text",1,null,0.3,0.5,0.075,0],["x-ai/grok-4",256000,64000,"text,image",1,null,3,15,0.75,0],["x-ai/grok-4-fast",2000000,30000,"text,image",1,null,0.19999999999999998,0.5,0.049999999999999996,0],["x-ai/grok-4.1-fast",2000000,30000,"text,image",1,null,0.19999999999999998,0.5,0.049999999999999996,0],["x-ai/grok-4.20",2000000,8888,"text,image",1,null,1.25,2.5,0.19999999999999998,0],["x-ai/grok-4.20-beta",2000000,8888,"text,image",1,null,2,6,0.19999999999999998,0],["x-ai/grok-4.3",1000000,1000000,"text,image",1,null,1.25,2.5,0.19999999999999998,0],["x-ai/grok-4.5",500000,500000,"text,image",1,null,2,6,0.3,0],["x-ai/grok-4.6",500000,500000,"text,image",1,null,2,6,0.3,0],["x-ai/grok-build-0.1",256000,256000,"text,image",1,null,1,2,0.19999999999999998,0],["x-ai/grok-code-fast-1",256000,10000,"text",1,null,0.19999999999999998,1.5,0.02,0],["xiaomi/mimo-v2-flash",262144,65536,"text",1,null,0.09999999999999999,0.3,0.01,0],["xiaomi/mimo-v2-omni",262144,65536,"text,image",1,null,0.39999999999999997,2,0.08,0],["xiaomi/mimo-v2-pro",1048576,131072,"text",1,null,1,3,0.19999999999999998,0],["xiaomi/mimo-v2.5",1050000,131072,"text,image",1,null,0.14,0.28,0.0028,0],["xiaomi/mimo-v2.5-pro",1050000,131072,"text",1,null,0.435,0.87,0.0036,0],["z-ai/glm-4-32b",128000,8888,"text",0,null,0.09999999999999999,0.09999999999999999,0,0],["z-ai/glm-4.5",131072,98304,"text",1,null,0.6,2.2,0.11,0],["z-ai/glm-4.5-air",131072,98304,"text",1,null,0.13,0.85,0.024999999999999998,0],["z-ai/glm-4.5-air:free",131072,96000,"text",1,null,0,0,0,0],["z-ai/glm-4.5v",65536,16384,"text,image",1,null,0.6,1.7999999999999998,0.11,0],["z-ai/glm-4.6",204800,131072,"text",1,null,0.5,2,0.09999999999999999,0],["z-ai/glm-4.6:exacto",204800,131072,"text",1,null,0.44,1.76,0.11,0],["z-ai/glm-4.6v",131072,32768,"text,image",1,null,0.3,0.8999999999999999,0.055,0],["z-ai/glm-4.7",204800,131072,"text",1,null,0.39999999999999997,1.75,0.08,0],["z-ai/glm-4.7-flash",202752,16384,"text",1,null,0.06,0.39999999999999997,0.01,0],["z-ai/glm-5",204800,131072,"text",1,null,0.95,2.5500000000000003,0.19999999999999998,0],["z-ai/glm-5-turbo",202752,131072,"text",1,null,1.2,4,0.24,0],["z-ai/glm-5.1",204800,128000,"text",1,null,0.966,3.036,0.1794,0],["z-ai/glm-5.2",1048576,131072,"text",1,null,0.707,2.222,0.1313,0],["z-ai/glm-5.3",1048576,131072,"text",1,null,0.707,2.222,0.1313,0],["z-ai/glm-5v-turbo",202752,131072,"text,image",1,null,1.2,4,0.24,0]], "xai": [["grok-2",131072,8192,"text",0,null,2,10,2,0],["grok-2-1212",131072,8192,"text",0,null,2,10,2,0],["grok-2-latest",131072,8192,"text",0,null,2,10,2,0],["grok-2-vision",8192,4096,"text,image",0,null,2,10,2,0],["grok-2-vision-1212",8192,4096,"text,image",0,null,2,10,2,0],["grok-2-vision-latest",8192,4096,"text,image",0,null,2,10,2,0],["grok-3",131072,8192,"text",0,null,3,15,0.75,0],["grok-3-fast",131072,8192,"text",0,null,5,25,1.25,0],["grok-3-fast-latest",131072,8192,"text",0,null,5,25,1.25,0],["grok-3-latest",131072,8192,"text",0,null,3,15,0.75,0],["grok-3-mini",131072,8192,"text",1,null,0.3,0.5,0.075,0],["grok-3-mini-fast",131072,8192,"text",1,null,0.6,4,0.15,0],["grok-3-mini-fast-latest",131072,8192,"text",1,null,0.6,4,0.15,0],["grok-3-mini-latest",131072,8192,"text",1,null,0.3,0.5,0.075,0],["grok-4",256000,64000,"text",1,null,3,15,0.75,0],["grok-4-1-fast",2000000,30000,"text,image",1,null,0.2,0.5,0.05,0],["grok-4-1-fast-non-reasoning",2000000,30000,"text,image",0,null,0.2,0.5,0.05,0],["grok-4-fast",2000000,30000,"text,image",1,null,0.2,0.5,0.05,0],["grok-4-fast-non-reasoning",2000000,30000,"text,image",0,null,0.2,0.5,0.05,0],["grok-4.20-0309-non-reasoning",1000000,30000,"text,image",0,null,1.25,2.5,0.2,0],["grok-4.20-0309-reasoning",1000000,30000,"text,image",1,null,1.25,2.5,0.2,0],["grok-4.20-beta-latest-non-reasoning",2000000,30000,"text,image",0,null,2,6,0.2,0],["grok-4.20-beta-latest-reasoning",2000000,30000,"text,image",1,null,2,6,0.2,0],["grok-4.20-multi-agent-0309",1000000,30000,"text,image",1,null,1.25,2.5,0.2,0],["grok-4.20-multi-agent-beta-latest",2000000,30000,"text,image",1,null,2,6,0.2,0],["grok-4.3",1000000,30000,"text,image",1,null,1.25,2.5,0.2,0],["grok-4.5",500000,500000,"text,image",1,null,2,6,0.3,0],["grok-4.6",500000,500000,"text,image",1,null,2,6,0.3,0],["grok-beta",131072,4096,"text",0,null,5,15,5,0],["grok-build-0.1",256000,256000,"text,image",1,null,1,2,0.2,0],["grok-code-fast-1",256000,10000,"text",1,null,0.2,1.5,0.02,0],["grok-composer-2.5-fast",200000,64000,"text",1,null,0,0,0,0],["grok-vision-beta",8192,4096,"text,image",0,null,5,15,5,0]], diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e724b93533..1d69788ffc 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -722,10 +722,9 @@ const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"]; * `[404] No endpoints found that support image input` and `big-pickle` with the * exact deserialize error quoted in #1043. * - * `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images and are deliberately - * absent. Adding them would silently replace a working image with a caption, - * which is worse than the loud 400 this list exists to prevent — see the negative - * assertion in tests/providers/provider-registry-parity.test.ts. + * `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images. They remain absent + * from the blind list and are recorded separately as positive input-modality evidence, + * so capability-positive dispatch can forward images without relying on blacklist absence. * * Zen's roster is discovered live while this list is static, so it is a dated * exception list, not a capability model. Re-probe before extending it. @@ -739,6 +738,7 @@ const OPENCODE_ZEN_TEXT_ONLY_MODELS = [ "laguna-s-2.1-free", "deepseek-v4-flash-free", ]; +const OPENCODE_ZEN_IMAGE_MODELS = ["mimo-v2.5-free", "longcat-2.0-free"] as const; /* * DeepSeek's Codex ladder is low/high/max. With the V4 Pro GA release * (DeepSeek-V4-Pro-0813) the official thinking-mode table is IDENTICAL for both @@ -3209,6 +3209,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])), }, noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_GATEWAY_THINKING_MODELS], // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry @@ -3250,6 +3251,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])), }, // Same Zen roster behind the same base URL, so it carries the same measured // text-only list rather than only its DeepSeek member (#1043). diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index ee190e4620..d44ac60c74 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -170,7 +170,7 @@ async function handleChatCompletionsWithBudget( if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools)); logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); } - if (!effortRow && isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; + if (!effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route; } catch (err) { if (err instanceof UnknownRoutingPolicyError) { logCtx.requestedModel = requestedModel; diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index c8fc4b30e8..b122467103 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -20,7 +20,7 @@ import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; -import { isModelTextOnly } from "../vision"; +import { isModelTextOnly, requiresVisionPreprocessing } from "../vision"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, @@ -139,7 +139,7 @@ function isRec(value: unknown): value is Rec { return value !== null && typeof value === "object" && !Array.isArray(value); } -export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boolean { +export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec, config?: OcxConfig): boolean { const provider = route.provider; if (provider.adapter !== "openai-chat") return false; if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return false; @@ -160,7 +160,12 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo // site describes or strips the image. The native fast path has no vision // handling, so letting it keep such a request forwards raw pixels to a // model the operator declared blind. - if (isModelTextOnly(provider, route.modelId) && chatBodyCarriesImage(rawBody)) return false; + if (chatBodyCarriesImage(rawBody)) { + const needsVision = config + ? requiresVisionPreprocessing(config, provider, route.modelId, route.providerName) + : isModelTextOnly(provider, route.modelId); + if (needsVision) return false; + } if (Array.isArray(rawBody.tools)) { for (const tool of rawBody.tools) { if (!isRec(tool)) continue; @@ -321,7 +326,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio dispatchOverride: async (_input, init, execute) => { if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) { const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider); - if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody)) { + if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody, config)) { throw new Error("Provider key selection is no longer available for native Chat"); } activeProvider = current; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1dc58f277a..255899f4eb 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -165,7 +165,7 @@ import { shouldResolveOpenAiPassthroughWebSearchBridge, } from "../../web-search/passthrough-bridge"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; -import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; +import { describeImagesInPlace, planVisionSidecar, requiresVisionPreprocessing, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, @@ -4769,7 +4769,7 @@ async function handleResponsesInner( const routedCompaction = parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(route.provider); const needsOpenAiVision = !visionDescribeTerminal - && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); + && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); const needsOpenAiSearch = !routedCompaction && !adapter.runTurn && (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough) || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough)); @@ -4839,7 +4839,7 @@ async function handleResponsesInner( const visionPlan = visionDescribeTerminal ? undefined : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, { - admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName, }); const recordSidecarOutcome = openAiSidecar?.recordOutcome; if (visionPlan) { @@ -4851,9 +4851,9 @@ async function handleResponsesInner( recordSidecarOutcome, translatorBudget, ); - } else if (isModelTextOnly(route.provider, route.modelId)) { - // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar - // disabled): fail closed — never forward raw images to a text-only upstream. + } else if (requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName)) { + // Image capability is not positively proven but no sidecar plan is dispatchable: fail closed. + // Never forward raw image bytes to an unverified upstream. stripImagesInPlace(parsed, translatorBudget); } @@ -6292,7 +6292,7 @@ async function handleResponsesInner( providerApiKey: route.provider.apiKey ?? "", auth: webSearchBridgeAuth, hostedTool: parsed._webSearch, - describeImages: isModelTextOnly(route.provider, route.modelId), + describeImages: requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName), sidecar: config.webSearchSidecar, }), // Appending a search result can push the continuation past the ceiling the first leg @@ -6818,7 +6818,7 @@ async function handleResponsesInner( // can proceed for web-search-only turns const wsPlan = !routedCompaction ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { - admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName, }) : undefined; const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 0b58e7527f..c8bcb81596 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -26,6 +26,7 @@ import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../gen import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import { enrichProviderFromRegistry } from "../providers/derive"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; /** * The wire protocols `planVisionSidecar` can dispatch to (#2188 roadmap 170 @@ -113,7 +114,28 @@ function enrichedProviderForVision( if (cached) return cached; const configured = config.providers?.[providerName]; if (!configured) return undefined; - const enriched = structuredClone(configured); + // Runtime providers may carry non-cloneable hooks (for example a provider-scoped fetch). + // Enrichment mutates top-level fields but does not mutate nested provider values in place, so + // a shallow copy plus private copies of the vision-capability containers is sufficient and + // avoids both config mutation and structuredClone(DataCloneError) on runtime functions. + const enriched: OcxProviderConfig = { + ...configured, + ...(configured.noVisionModels ? { noVisionModels: [...configured.noVisionModels] } : {}), + ...(configured.modelInputModalities ? { + modelInputModalities: Object.fromEntries( + Object.entries(configured.modelInputModalities).map(([id, modalities]) => [id, [...modalities]]), + ), + } : {}), + ...(configured.modelCapabilities ? { + modelCapabilities: Object.fromEntries(Object.entries(configured.modelCapabilities).map(([id, capability]) => [ + id, + { + ...capability, + ...(capability.inputModalities ? { inputModalities: [...capability.inputModalities] } : {}), + }, + ])), + } : {}), + }; enrichProviderFromRegistry(providerName, enriched); cache.set(providerName, enriched); return enriched; @@ -167,6 +189,16 @@ function modelAcceptsImageInputWithCache( const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id) ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined; if (declared !== undefined) return declared.includes("image"); + const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined; + const fromConfiguredModalities = advertisesImageInput(configuredModalities); + if (fromConfiguredModalities !== undefined) return fromConfiguredModalities; + const canonicalCodex = candidate.provider === "openai" + && provider !== undefined + && isCanonicalOpenAiForwardProvider(provider); + if (canonicalCodex) { + const fromCodexBackend = metadataImageInput("openai-codex", candidate.id); + if (fromCodexBackend !== undefined) return fromCodexBackend; + } const fromRow = advertisesImageInput(candidate.inputModalities); if (fromRow !== undefined) return fromRow; return metadataImageInput(candidate.provider, candidate.id); diff --git a/src/vision/index.ts b/src/vision/index.ts index 633e2217c3..f955245ccc 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -35,6 +35,7 @@ export { resolveEffectiveVisionModel, shouldResolveOpenAiVisionSidecar, planVisionSidecar, + requiresVisionPreprocessing, } from "./plan"; export type { AnthropicVisionProvider, VisionPlan } from "./plan"; export { stripImagesInPlace } from "./image-rewrite"; diff --git a/src/vision/plan.ts b/src/vision/plan.ts index fb2aae6d49..166d6bb3b0 100644 --- a/src/vision/plan.ts +++ b/src/vision/plan.ts @@ -1,5 +1,5 @@ import type { OcxConfig, OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../types"; -import type { VisionReasoningEffort } from "../reasoning-effort"; +import { modelRecordValue, type VisionReasoningEffort } from "../reasoning-effort"; import type { VisionSettings } from "./describe"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import type { CodexAuthPolicyConfig } from "../codex/auth-context"; @@ -92,6 +92,34 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean { carriesImages(m.role) && Array.isArray(m.content) && (m.content as OcxContentPart[]).some(p => p.type === "image")); } +/** + * Direct-image admission for a routed target. Returns true when capability evidence proves the + * target cannot accept image input, so the caller must describe or strip the image first. + * Explicit text-only config, an explicit per-model modality list without `image`, and + * proven-negative registry/vendor metadata each require the vision preprocessor. A genuinely + * unknown custom model is NOT guessed blind: it keeps the established pass-through behaviour. + * The provider-only fallback keeps legacy unit callers stable; production dispatch always + * supplies providerName so the complete capability chain is consulted. + */ +export function requiresVisionPreprocessing( + config: Pick, + provider: Pick, + modelId: string, + providerName?: string, +): boolean { + if (isModelTextOnly(provider, modelId)) return true; + const runtimeDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId) + ? provider.modelCapabilities?.[modelId]?.inputModalities + : undefined; + if (runtimeDeclared !== undefined) return !runtimeDeclared.includes("image"); + const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId); + if (Array.isArray(runtimeModalities) && runtimeModalities.length > 0) { + return !runtimeModalities.includes("image"); + } + if (!providerName) return false; + return modelAcceptsImageInput(config, { provider: providerName, id: modelId }) === false; +} + /** Shared by auth admission and planning so a routed describer never borrows OpenAI auth. */ function usableRoutedVisionModel(config: OcxConfig): string | undefined { const cfg = config.visionSidecar; @@ -109,10 +137,11 @@ function usableRoutedVisionModel(config: OcxConfig): string | undefined { export function shouldResolveOpenAiVisionSidecar( config: OcxConfig, provider: OcxProviderConfig, - modelId: string, - parsed: OcxParsedRequest, + modelId: string, + parsed: OcxParsedRequest, + providerName?: string, ): boolean { - if (!isModelTextOnly(provider, modelId) || !messagesHaveImage(parsed)) return false; + if (!requiresVisionPreprocessing(config, provider, modelId, providerName) || !messagesHaveImage(parsed)) return false; const cfg = config.visionSidecar ?? {}; if (cfg.enabled === false) return false; if (usableRoutedVisionModel(config)) return false; @@ -132,10 +161,12 @@ export interface VisionPlan { } /** - * Decide whether the vision sidecar should pre-describe images for this request, returning the plan - * if so. Active when: the routed model is in `provider.noVisionModels`, the request actually carries - * an image, the sidecar isn't disabled, and the selected backend has usable auth. Returns undefined - * otherwise (the caller strips images before sending to a text-only model). + * Decide whether the vision sidecar should pre-describe images for this request. Raw image + * delivery is capability-driven: targets proven text-only are preprocessed, targets proven + * image-capable bypass this planner, and genuinely unknown custom targets retain legacy behavior. + * The request must + * carry an image, the sidecar must be enabled, and the selected backend must be dispatchable. + * Returns undefined otherwise; the caller strips images before any unverified upstream send. */ export function planVisionSidecar( config: OcxConfig, @@ -143,9 +174,13 @@ export function planVisionSidecar( modelId: string, parsed: OcxParsedRequest, openAiSidecar?: ResolvedOpenAiForwardSidecar, - options: { admission?: Pick; codexAuthPolicy?: CodexAuthPolicyConfig } = {}, + options: { + admission?: Pick; + codexAuthPolicy?: CodexAuthPolicyConfig; + providerName?: string; + } = {}, ): VisionPlan | undefined { - if (!isModelTextOnly(provider, modelId)) return undefined; + if (!requiresVisionPreprocessing(config, provider, modelId, options.providerName)) return undefined; if (!messagesHaveImage(parsed)) return undefined; const cfg = config.visionSidecar ?? {}; if (cfg.enabled === false) return undefined; diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 3506ef0b4f..4a942dc453 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -1,6 +1,6 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { modelInList, toolChoiceToolPredicate } from "../types"; -import { isModelTextOnly } from "../vision"; +import { requiresVisionPreprocessing } from "../vision"; import type { SidecarSettings } from "./executor"; import type { CodexAuthPolicyConfig } from "../codex/auth-context"; import { isCodexReserveRequestEligible } from "../codex/loopback-target"; @@ -152,7 +152,11 @@ export function planWebSearch( provider: OcxProviderConfig, modelId: string, openAiSidecar?: ResolvedOpenAiForwardSidecar, - options: { admission?: Pick; codexAuthPolicy?: CodexAuthPolicyConfig } = {}, + options: { + admission?: Pick; + codexAuthPolicy?: CodexAuthPolicyConfig; + providerName?: string; + } = {}, ): SidecarPlan | undefined { if (!parsed._webSearch || isPassthrough) return undefined; if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; @@ -176,8 +180,9 @@ export function planWebSearch( routedModelStallTimeoutMs, timeoutMs, ); - // The routed model being text-only means the search model must verbalize image results (either backend). - const describeImages = isModelTextOnly(provider, modelId); + // A target proven unable to accept image input receives verbalized image results instead of + // search-result images. A genuinely unknown custom target keeps the established pass-through. + const describeImages = requiresVisionPreprocessing(config, provider, modelId, options.providerName); const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING; const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 18fe44394f..f22ae0e577 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -177,3 +177,4 @@ implement legacy call/result pairing. Modern tool-image carriers are unchanged. `tests/adapters/adapter-input-media-guard.test.ts` covers hook ordering, error events and raw passthrough; `tests/responses/chat-media-translation.test.ts` reaches the real HTTP translation boundary and verifies that rejection sends no upstream request. +Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e6e6ecd582..aa9aa15f52 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -327,3 +327,4 @@ also return an explicit error; their call/result pairing is not implemented by t Modern `tool` images continue through the existing following-user carrier. These errors state an OpenCodex conversion limit, not a provider capability claim. Final Responses-to-adapter admission follows the [registry contract](../adapters/registry.md#untranslated-input-media). +Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 8f414780c8..e9d8fb7c00 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -57,7 +57,7 @@ Gemini and Exa remain inert until their executors ship. Selection differs per si | Sidecar | Backend selection | Default model | Activation | | --- | --- | --- | --- | | `web-search/` | Explicit configuration only: unset always resolves to the OpenAI forward path. No backend — Anthropic or otherwise — is auto-selected from credential availability (doing so once sent OpenAI model ids to the Anthropic API). Explicit xAI requires usable stored Grok OAuth and may add hosted `x_search`; explicit Gemini/Exa remain fail-closed until their executors land. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic), `grok-4.6` (xAI) | Hosted `web_search` requested by a non-passthrough routed model. | -| `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.6-luna` (OpenAI) | Input contains images for a model listed in `noVisionModels`. | +| `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.6-luna` (OpenAI) | Request carries images and the routed target is not positively proven image-capable (`requiresVisionPreprocessing`). | The asymmetry is in the unset case only: vision may describe an image with whichever model can see it, while a hosted search tool is tied to a provider-specific tool contract, so search never infers diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index ac37b3e91c..6b33be025f 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -336,3 +336,5 @@ real image blocks rather than flattening them to the text `[image]`, and orders blocks chronologically — history before current — so attachment order matches the prose the model reads beside them. Vendor tool execution stays disabled on both adapters, and Qoder's explicit refusal of original images is unchanged. + +Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 6d4139b5df..5ae38028d8 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -129,3 +129,4 @@ Combo child requests normalize effort and thinking controls against the selected Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. +Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 1ef3d78596..3cdbc28f4b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -377,12 +377,29 @@ Translated Chat request construction uses the [inline-image budget](transports/s OpenCode catalog discovery in `src/cli/opencode.ts` uses the local admin credential and a validated numeric-loopback management origin. It dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings. -The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture. Vision dispatch consumes those declarations together with registry/vendor metadata before any image-bearing upstream send. -Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. +## Capability-aware image admission + +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence from the resolved runtime provider and explicit operator declarations takes precedence, followed by backend-specific/registry/vendor metadata. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. + +Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. + +An explicitly configured routed `visionSidecar.model` is dispatchable unless capability evidence positively proves it cannot accept images; an unknown custom sidecar is not guessed blind. If a proven text-only main target has no usable sidecar plan, image parts are stripped before the upstream request rather than forwarded raw. `modelInputModalities` is symmetric evidence: `["text","image"]` proves image support while `["text"]` triggers preprocessing. Runtime provider hooks such as injected `fetch` functions are preserved without mutation during capability enrichment. + +Regression coverage: `tests/vision/vision-cache.test.ts`, `tests/vision/vision-eligibility.test.ts`, `tests/vision/vision-routed.test.ts`, and `tests/adapters/openai/openai-chat-native-policy.test.ts`. Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. Translated audio/file admission follows the [final-adapter input contract](adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. +## Request-local target compatibility + +`src/adapters/openai-responses.ts` omits only top-level `user` at the canonical ChatGPT Codex forward destination. Claude translation retains its original identity and prompt-cache key; public API and noncanonical gateways retain their `user` field. Input roles, tool-schema properties, safety identifiers and original replay bodies are not changed. + +`src/combos/failover.ts` treats three 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; and `param: input` with a bounded model-scoped `does not support image inputs` message. A null provider code is accepted only for that observed image envelope. 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. + +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. Other invalid requests remain terminal. + +Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index bf30f1740f..e758afeaf2 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -35,3 +35,4 @@ byte sizing retains TextEncoder's coercion behavior for legacy non-string runtim These optimizations do not add request queues, retry policies, or RSS-based admission gates. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. +Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 381d0abdbe..d2c44927db 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -36,7 +36,7 @@ surface is listed here so a maintainer can find the owner without grepping: The registry's first-party `deepseek-flash` row declares native `text` and `image` input, so image requests bypass the vision sidecar by default; explicit `noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` -remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. Zen `mimo-v2.5-free` and `longcat-2.0-free` now carry positive `modelInputModalities` image evidence rather than relying on absence from the text-only list. > Decision record: [ADR-0072](../decisions/ADR-0072-transport-inventory.md) @@ -144,3 +144,4 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. +Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 430598b622..1bc2b4de0d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -536,7 +536,7 @@ combo whose remaining eligible targets use other providers. capability ladders remove effort and thinking controls in every combo mode; adaptive mode also removes those controls for unknown ladders and preserves `reasoning.summary`. Known non-empty ladders retain the existing per-target effort resolution. This request normalization does not -change target order, attempt accounting, or the existing provider-400 failover classification. +change target order or attempt accounting; provider-400 decisions follow the [request-local target compatibility](../runtime.md#request-local-target-compatibility) contract. The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. diff --git a/tests/adapters/openai/openai-chat-native-policy.test.ts b/tests/adapters/openai/openai-chat-native-policy.test.ts index ac6fb80b12..9357cf2215 100644 --- a/tests/adapters/openai/openai-chat-native-policy.test.ts +++ b/tests/adapters/openai/openai-chat-native-policy.test.ts @@ -398,7 +398,7 @@ describe("main and native Chat tier authorization parity", () => { } }); - test("the native lane keeps caller image bytes instead of normalizing them", async () => { + test("the native lane keeps caller image bytes only for positively vision-capable models", async () => { // Scope boundary for the openai-chat inline image budget (see // tests/adapters/openai/openai-chat-image-normalization.test.ts). That budget lives in // the adapter's buildRequest, but an eligible Chat-inbound request is dispatched down @@ -417,7 +417,9 @@ describe("main and native Chat tier authorization parity", () => { }); }) as typeof fetch; - const target = provider(); + const target = provider({ + modelCapabilities: { [MODEL_ID]: { inputModalities: ["text", "image"] } }, + }); const response = await handleChatCompletions( new Request("http://localhost/v1/chat/completions", { method: "POST", @@ -455,6 +457,17 @@ test("explicit text-only capabilities divert image-bearing native Chat requests" const { routeModel } = await import("../../../src/router"); const config = { port: 10100, defaultProvider: "custom", providers: { custom: provider({ modelCapabilities: { model: { inputModalities: ["text"] } } }) } } as OcxConfig; const route = routeModel(config, "custom/model"); - expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }] }] })).toBe(false); - expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: "hello" }] })).toBe(true); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }] }] }, config)).toBe(false); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: "hello" }] }, config)).toBe(true); +}); + +test("unknown image capability retains native Chat compatibility until capability is known", async () => { + const { isNativeChatRouteEligible } = await import("../../../src/server/chat-native"); + const { routeModel } = await import("../../../src/router"); + const config = { port: 10100, defaultProvider: "custom", providers: { custom: provider() } } as OcxConfig; + const route = routeModel(config, "custom/model"); + const imageBody = { messages: [{ role: "user", content: [{ + type: "image_url", image_url: { url: "data:image/png;base64,YQ==" }, + }] }] }; + expect(isNativeChatRouteEligible(route, imageBody, config)).toBe(true); }); diff --git a/tests/codex-integration/bearer-admission-routed-provider.test.ts b/tests/codex-integration/bearer-admission-routed-provider.test.ts index 277f790bd5..f8aafdfec7 100644 --- a/tests/codex-integration/bearer-admission-routed-provider.test.ts +++ b/tests/codex-integration/bearer-admission-routed-provider.test.ts @@ -461,7 +461,11 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => if (scenario === "default-sidecars") delete config.visionSidecar; if (scenario === "disabled-vision") config.visionSidecar.enabled = false; if (scenario === "routed-vision") { - config.providers.gateway = { ...mixedConfig().providers.gateway!, models: ["vision-model"] }; + config.providers.gateway = { + ...mixedConfig().providers.gateway!, + models: ["vision-model"], + modelInputModalities: { "vision-model": ["text", "image"] }, + }; config.visionSidecar = { enabled: true, backend: "routed", model: "gateway/vision-model" }; } if (scenario === "anthropic-vision") config.visionSidecar.backend = "anthropic"; diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index bef18aec4f..c612aed502 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -2282,7 +2282,10 @@ describe("computer screenshot output translation boundary", () => { test("rejects before an otherwise active vision description", async () => { const config = keyProviderConfig({ adapter: "openai-chat", noVisionModels: ["model"] }); config.visionSidecar = { enabled: true, backend: "routed", model: "vision/seeing" }; - config.providers.vision = { adapter: "openai-chat", baseUrl: "https://vision.example/v1", apiKey: "test-key" }; + config.providers.vision = { + adapter: "openai-chat", baseUrl: "https://vision.example/v1", apiKey: "test-key", + modelInputModalities: { seeing: ["text", "image"] }, + }; // Routed vision needs no live OpenAI account for this controlled description dependency. const resolveAuth = spyOn(visionModule, "shouldResolveOpenAiVisionSidecar").mockReturnValue(false); const describe = spyOn(visionModule, "describeImagesInPlace").mockImplementation(async () => {}); diff --git a/tests/responses/responses-forward-prompt-envelope.test.ts b/tests/responses/responses-forward-prompt-envelope.test.ts index 6f0b078818..9ca3578971 100644 --- a/tests/responses/responses-forward-prompt-envelope.test.ts +++ b/tests/responses/responses-forward-prompt-envelope.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; import { createResponsesPassthroughAdapter as createProductionAdapter } from "../../src/adapters/openai-responses"; import type { OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -140,3 +141,56 @@ describe("canonical ChatGPT forward prompt envelope", () => { expect(body.input).toEqual(input); }); }); + + +describe("canonical forward user metadata boundary", () => { + test.each(["gpt-5.3-codex-spark", "gpt-5.6-luna"])("strips only top-level user for %s", model => { + const raw = { + model, user: "synthetic-client", prompt_cache_key: "synthetic-cache", + safety_identifier: "synthetic-safety", stream: true, + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ type: "function", name: "lookup", parameters: { + type: "object", properties: { user: { type: "string" } }, required: ["user"], + } }], + }; + const snapshot = structuredClone(raw); + const body = outboundBody(canonicalForward, raw); + expect(Object.hasOwn(body, "user")).toBe(false); + expect(body.prompt_cache_key).toBe(raw.prompt_cache_key); + expect(body.safety_identifier).toBe(raw.safety_identifier); + expect(body.input).toEqual(raw.input); + expect(body.tools).toEqual(raw.tools); + expect(raw).toEqual(snapshot); + }); + test("Claude translation retains session metadata locally but omits user on the native wire", () => { + const raw = anthropicToResponsesBody({ + model: "gpt-5.3-codex-spark", max_tokens: 32, + metadata: { user_id: "synthetic-claude-session" }, + messages: [{ role: "user", content: "hello" }], + }); + expect(raw.user).toBe("synthetic-claude-session"); + const snapshot = structuredClone(raw); + const body = outboundBody(canonicalForward, raw); + expect(Object.hasOwn(body, "user")).toBe(false); + expect(body.prompt_cache_key).toBe(raw.prompt_cache_key); + expect(String(body.prompt_cache_key)).toMatch(/^[a-f0-9]{32}$/); + expect(raw).toEqual(snapshot); + }); + test.each([ + { ...canonicalForward, baseUrl: "https://api.openai.com/v1", authMode: "key" as const }, + { ...canonicalForward, baseUrl: "https://gateway.example/v1" }, + { ...canonicalForward, baseUrl: "https://chatgpt.com.example/backend-api/codex" }, + { ...canonicalForward, authMode: "key" as const }, + ])("does not strip user from a different destination: %j", provider => { + const body = outboundBody(provider, { model: "model", user: "synthetic-client", input: "hello" }); + expect(body.user).toBe("synthetic-client"); + }); + test("normalizes the canonical trailing slash without changing field-absent requests", () => { + const provider = { ...canonicalForward, baseUrl: canonicalForward.baseUrl + "/" }; + const raw = { model: "gpt-5.3-codex-spark", user: "synthetic-client", input: "hello" }; + expect(Object.hasOwn(outboundBody(provider, raw), "user")).toBe(false); + const without = { model: raw.model, input: raw.input }; + expect(Object.hasOwn(outboundBody(provider, without), "user")).toBe(false); + expect(without).toEqual({ model: raw.model, input: raw.input }); + }); +}); diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 93b58170a6..cb5d765459 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -203,3 +203,98 @@ describe("malformed upstream bytes are a provider failure", () => { }); }); }); + +const unsupportedUser = { type: "invalid_request_error", message: "Unsupported parameter: user" }; +const unsupportedEffort = { + type: "invalid_request_error", code: "unsupported_value", param: "reasoning.effort", + message: "Unsupported value: 'none' is not supported with the 'gpt-5.3-codex-spark' model. Supported values are: 'low', 'medium', 'high', and 'xhigh'.", +}; + +const unsupportedImage = { + type: "invalid_request_error", code: null, param: "input", + message: "Model 'gpt-5.3-codex-spark' does not support image inputs. Try again with a vision model.", +}; + +describe("request-local optional control incompatibility", () => { + test.each([unsupportedUser, unsupportedEffort, unsupportedImage])("hops a structured target-local request rejection without cooling: %j", error => { + const body = JSON.stringify({ error }); + for (const message of [body, `Provider error 400: ${body}`]) { + expect(comboFailureDecision(400, message, { code: "invalid_request_error" })).toBe("hop"); + expect(comboFailureCooldownScope(400, message, { code: "invalid_request_error" })).toBe("none"); + } + }); + test.each([ + { type: "invalid_request_error", message: "Unsupported parameter: tools" }, + { type: "invalid_request_error", message: "Unsupported parameter: safety_identifier" }, + { type: "invalid_request_error", message: "Unsupported parameter: user.name" }, + { ...unsupportedUser, param: "input" }, + { ...unsupportedUser, code: "origin_rejected" }, + { ...unsupportedUser, code: "context_length_exceeded" }, + { ...unsupportedUser, code: "unknown_terminal_code" }, + { ...unsupportedEffort, param: "input" }, + { ...unsupportedEffort, code: "unknown_terminal_code" }, + { ...unsupportedEffort, code: "cyber_policy" }, + ])("does not relax an unrelated or conflicting refusal: %j", error => { + expect(comboFailureDecision(400, JSON.stringify({ error }))).toBe("stop"); + }); + test("reflected text, truncated envelopes and oversized diagnostics stay terminal", () => { + const body = JSON.stringify({ error: unsupportedUser }); + for (const text of [ + `invalid input contains ${body}`, + JSON.stringify({ error: { type: "invalid_request_error", message: body } }), + body.slice(0, -1), + JSON.stringify({ error: unsupportedUser, padding: "x".repeat(16_384) }), + ]) expect(comboFailureDecision(400, text)).toBe("stop"); + }); + test("hard refusal and non-replayable codes take precedence over a compatible message", () => { + const message = JSON.stringify({ error: unsupportedUser }); + for (const code of ["origin_rejected", "context_length_exceeded", "upstream_no_response", "upstream_closed_before_response"]) { + expect(comboFailureDecision(400, message, { code })).toBe("stop"); + } + expect(comboFailureDecision(499, message)).toBe("stop"); + expect(comboFailureDecision(413, message)).toBe("stop"); + }); +}); + +describe("bounded optional-control error envelopes", () => { + const wrapped = (message: string, code = "invalid_request_error") => JSON.stringify({ + error: { type: "invalid_request_error", code, message: `Provider error 400: ${message}` }, + }); + test("accepts the proxy wrapper but not an unrelated message containing JSON", () => { + const raw = JSON.stringify({ error: unsupportedUser }); + expect(comboFailureDecision(400, wrapped(raw))).toBe("hop"); + expect(comboFailureDecision(400, wrapped(wrapped(raw)))).toBe("hop"); + expect(comboFailureDecision(400, wrapped(wrapped(wrapped(raw))))).toBe("stop"); + expect(comboFailureDecision(400, wrapped(raw, "cyber_policy"))).toBe("stop"); + expect(comboFailureDecision(400, wrapped(raw, "context_length_exceeded"))).toBe("stop"); + }); + test("malformed envelope fields do not throw or acquire hop permission", () => { + for (const value of [null, [], "user", { error: null }, { error: [] }, + { error: { ...unsupportedUser, code: {} } }, + { error: { ...unsupportedUser, param: null } }, + { error: { ...unsupportedUser, type: "custom_failure" } }, + ]) expect(comboFailureDecision(400, JSON.stringify(value))).toBe("stop"); + }); + test("the precise reasoning code works without a proxy-generated generic code", () => { + const message = JSON.stringify({ error: unsupportedEffort }); + expect(comboFailureDecision(400, message, { code: "unsupported_value" })).toBe("hop"); + expect(comboFailureCooldownScope(400, message, { code: "unsupported_value" })).toBe("none"); + }); +}); + +describe("image rejection classifier bounds", () => { + test.each([ + { ...unsupportedImage, param: "tools" }, + { ...unsupportedImage, code: "origin_rejected" }, + { ...unsupportedImage, code: "unknown_terminal_code" }, + { ...unsupportedImage, message: "This model does not support image inputs." }, + { ...unsupportedImage, message: "Model 'x' does not support image inputs" }, + ])("does not hop on a lookalike or conflicting image refusal: %j", error => { + expect(comboFailureDecision(400, JSON.stringify({ error }))).toBe("stop"); + }); + test("accepts the observed null-code envelope with an outer generic code", () => { + const message = JSON.stringify({ error: unsupportedImage }); + expect(comboFailureDecision(400, message, { code: "invalid_request_error" })).toBe("hop"); + expect(comboFailureCooldownScope(400, message, { code: "invalid_request_error" })).toBe("none"); + }); +}); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 59400ff921..041ed847c0 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -3027,6 +3027,7 @@ describe("server combo failover 030 activation matrix", () => { }), b: provider("openai-chat", baseUrl(b), "key-b", { reasoningEfforts: ["low", "high"], + modelInputModalities: { m2: ["text", "image"] }, }), }, undefined, { defaultEffort: "high" }); const response = await post(config, { @@ -4083,3 +4084,83 @@ describe("thinking-summary defaults follow the serving combo route", () => { }); } }); + +describe("optional-control rejection failover regression", () => { + for (const stream of [false, true]) { + test.each(["user", "reasoning.effort"])(`429 -> optional %s 400 -> 200, stream=${stream}`, async parameter => { + const hits: string[] = []; + const first = serve(() => { + hits.push("quota"); + return Response.json({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }, { status: 429 }); + }); + const incompatible = serve(async request => { + hits.push("incompatible"); + const raw = await request.json() as Record; + expect(raw.user).toBe("synthetic-client"); + const error = parameter === "user" + ? { type: "invalid_request_error", message: "Unsupported parameter: user" } + : { type: "invalid_request_error", code: "unsupported_value", param: "reasoning.effort", + message: "Unsupported value: 'none' is not supported with this model. Supported values are: 'low', 'medium', 'high', and 'xhigh'." }; + return Response.json({ error }, { status: 400 }); + }); + const backup = serve(() => { + hits.push("backup"); + return stream ? chatStream("recovered optional control") : chatSuccess("recovered optional control", "m3"); + }); + const config = comboConfig({ + a: provider("openai-chat", baseUrl(first), "key-a"), + b: provider("openai-responses", baseUrl(incompatible), "key-b"), + c: provider("openai-chat", baseUrl(backup), "key-c"), + }); + const response = await post(config, { + stream, user: "synthetic-client", prompt_cache_key: "synthetic-cache", + reasoning: { effort: "none" }, + }); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain("recovered optional control"); + expect(text).not.toContain("Unsupported parameter"); + expect(text).not.toContain("Unsupported value"); + expect(hits).toEqual(["quota", "incompatible", "backup"]); + expect(isComboTargetInCooldown("free", { provider: "b", model: "m2" })).toBe(false); + }); + } +}); + +describe("image-capability rejection failover regression", () => { + test("429 -> model-scoped image 400 -> healthy target does not abort the turn", async () => { + const hits: string[] = []; + const quota = serve(() => { + hits.push("quota"); + return Response.json({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }, { status: 429 }); + }); + const textOnly = serve(() => { + hits.push("text-only"); + return Response.json({ error: { + type: "invalid_request_error", code: null, param: "input", + message: "Model 'gpt-5.3-codex-spark' does not support image inputs. Try again with a vision model.", + } }, { status: 400 }); + }); + const vision = serve(() => { + hits.push("vision"); + return chatSuccess("vision fallback recovered", "m3"); + }); + const config = comboConfig({ + a: provider("openai-chat", baseUrl(quota), "key-a"), + b: provider("openai-responses", baseUrl(textOnly), "key-b"), + c: provider("openai-chat", baseUrl(vision), "key-c"), + }); + const response = await post(config, { + input: [{ type: "message", role: "user", content: [ + { type: "input_text", text: "inspect this" }, + { type: "input_image", image_url: "data:image/png;base64,AA==" }, + ] }], + }); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain("vision fallback recovered"); + expect(text).not.toContain("does not support image inputs"); + expect(hits).toEqual(["quota", "text-only", "vision"]); + expect(isComboTargetInCooldown("free", { provider: "b", model: "m2" })).toBe(false); + }); +}); diff --git a/tests/vision/vision-cache.test.ts b/tests/vision/vision-cache.test.ts index 6e482d4255..d62610115c 100644 --- a/tests/vision/vision-cache.test.ts +++ b/tests/vision/vision-cache.test.ts @@ -18,6 +18,7 @@ import { setVisionDescriptionCacheLimitsForTests, shouldResolveOpenAiVisionSidecar, planVisionSidecar, + requiresVisionPreprocessing, visionDescriptionRetainedStoreSnapshot, type VisionPlan, } from "../../src/vision"; @@ -43,6 +44,50 @@ const textOnlyProvider: OcxProviderConfig = { noVisionModels: ["text-model"], }; +test("direct image admission preprocesses proven-negative capability without guessing unknown custom models", () => { + const config = { + port: 10100, defaultProvider: "custom", providers: { + custom: { adapter: "openai-chat", baseUrl: "https://custom.test/v1" }, + declared: { + adapter: "openai-chat", baseUrl: "https://declared.test/v1", + modelInputModalities: { vision: ["text", "image"] }, + }, + openrouter: { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1" }, + }, + } as OcxConfig; + expect(requiresVisionPreprocessing(config, config.providers.custom!, "unknown-model", "custom")).toBe(false); + expect(requiresVisionPreprocessing(config, config.providers.declared!, "vision", "declared")).toBe(false); + const runtimePositive = { + ...config.providers.custom!, + modelCapabilities: { runtimeVision: { inputModalities: ["text", "image"] } }, + }; + expect(requiresVisionPreprocessing(config, runtimePositive, "runtimeVision", "custom")).toBe(false); + expect(requiresVisionPreprocessing( + config, config.providers.openrouter!, "openai/gpt-5.4-mini", "openrouter", + )).toBe(true); +}); + +test("routed vision sidecar rejects proven-blind models without guessing unknown configured models", () => { + const main: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://main.test/v1", noVisionModels: ["blind"], + }; + const helper: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://helper.test/v1" }; + const request = parseRequest({ + model: "main/blind", + input: [{ type: "message", role: "user", content: [{ type: "input_image", image_url: DATA_A }] }], + }); + const config = { + port: 10100, defaultProvider: "main", providers: { main, helper }, + visionSidecar: { enabled: true, backend: "routed", model: "helper/unknown" }, + } as OcxConfig; + expect(planVisionSidecar(config, main, "blind", request, undefined, { providerName: "main" })?.backend).toBe("routed"); + config.providers.helper!.modelInputModalities = { blind: ["text"], vision: ["text", "image"] }; + config.visionSidecar!.model = "helper/blind"; + expect(planVisionSidecar(config, main, "blind", request, undefined, { providerName: "main" })).toBeUndefined(); + config.visionSidecar!.model = "helper/vision"; + expect(planVisionSidecar(config, main, "blind", request, undefined, { providerName: "main" })?.backend).toBe("routed"); +}); + function plan(overrides: Partial = {}): VisionPlan { return { backend: "openai", @@ -78,7 +123,10 @@ test("vision sidecar auth stays lazy for no-image and disabled branches", () => test("vision auth and planning agree on a routed describer and its legacy fallback", () => { const cfg: OcxConfig = { port: 10100, defaultProvider: "routed", providers: { - routed: textOnlyProvider, sighted: { adapter: "openai-chat", baseUrl: "https://vision.test/v1", apiKey: "vision-key" }, + routed: textOnlyProvider, sighted: { + adapter: "openai-chat", baseUrl: "https://vision.test/v1", apiKey: "vision-key", + modelInputModalities: { "vision-model": ["text", "image"] }, + }, }, visionSidecar: { enabled: true, backend: "routed", model: "sighted/vision-model" } }; const request = parseRequest({ model: "routed/text-model", input: [{ type: "message", role: "user", content: [{ type: "input_image", image_url: DATA_A }] }], diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index e8ee753024..28baa98b9a 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -158,6 +158,37 @@ describe("vision eligibility core", () => { })).toBe(false); }); + test("11b. canonical Codex backend metadata overrides a generic image-capable Spark row", () => { + const config = configWithProviders({ + openai: { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + }); + const candidate: VisionCandidateModel = { + provider: "openai", + id: "gpt-5.3-codex-spark", + inputModalities: ["text", "image"], + }; + // No model-specific blacklist is required: backend metadata owns the verdict. + expect(isVisionSidecarConsumer(config, "openai", candidate.id)).toBe(false); + expect(modelAcceptsImageInput(config, candidate)).toBe(false); + expect(isVisionEligibleModel(config, candidate)).toBe(false); + }); + + test("11c. runtime provider hooks do not make capability enrichment uncloneable", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + fetch: (() => Promise.reject(new Error("not called"))) as typeof fetch, + modelInputModalities: { vision: ["text", "image"] }, + } as OcxProviderConfig & { fetch: typeof fetch }; + const config = configWithProviders({ runtime: provider }); + expect(() => modelAcceptsImageInput(config, { provider: "runtime", id: "vision" })).not.toThrow(); + expect(modelAcceptsImageInput(config, { provider: "runtime", id: "vision" })).toBe(true); + }); + test("12. only the selected Anthropic OAuth provider contributes Anthropic options", () => { const config = configWithProviders({ anthropic: { diff --git a/tests/vision/vision-routed.test.ts b/tests/vision/vision-routed.test.ts index c1182a0953..3673793789 100644 --- a/tests/vision/vision-routed.test.ts +++ b/tests/vision/vision-routed.test.ts @@ -147,7 +147,10 @@ describe("describeImageRouted unit", () => { apiKey: "routed-key", noVisionModels: ["text-model"], }; - const vlm: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://vlm.test/v1", apiKey: "k" }; + const vlm: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://vlm.test/v1", apiKey: "k", + modelInputModalities: { "qwen-vl": ["text", "image"] }, + }; const request = parseRequest({ model: "routed/text-model", input: [{ @@ -292,7 +295,7 @@ describe("chat-surface recursion fence (full path)", () => { } }); - test("routed describer end-to-end: image described via loopback before the text-only main call", async () => { + test("routed describer end-to-end: declared text-only main target receives only the caption", async () => { const mainBodies: string[] = []; const describerBodies: string[] = []; upstream = Bun.serve({ @@ -332,7 +335,7 @@ describe("chat-surface recursion fence (full path)", () => { baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "k", - noVisionModels: ["text-only"], + modelInputModalities: { "text-only": ["text"] }, }, vision: { adapter: "openai-chat", diff --git a/tests/vision/vision-sidecar-e2e.test.ts b/tests/vision/vision-sidecar-e2e.test.ts index 29c06c4fe9..0e8b78dc71 100644 --- a/tests/vision/vision-sidecar-e2e.test.ts +++ b/tests/vision/vision-sidecar-e2e.test.ts @@ -377,7 +377,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("models outside noVisionModels keep their image untouched (no sidecar call)", async () => { + test("positively vision-capable models keep their image untouched (no sidecar call)", async () => { let upstreamBody = ""; let sidecarHits = 0; upstream = serveUpstream(b => { upstreamBody = b; }); @@ -392,6 +392,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", noVisionModels: ["blind-model"], + modelInputModalities: { "vision-model": ["text", "image"] }, }, openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, }, @@ -527,6 +528,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", noVisionModels: zen?.noVisionModels, + modelInputModalities: zen?.modelInputModalities, }, openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, },