diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index 367b98d9c3e..415dd1338ef 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -97,6 +97,18 @@ utilisent le modèle de repli `gpt-5.6-luna`. Au démarrage, une ancienne valeur est toujours migrée vers `gpt-5.6-luna` ; cette migration s'applique à une valeur stockée, et non à l'absence du champ du modèle. +Sur OpenCode Go, `deepseek-v4.1-flash` accepte les entrées `image_url` sur la passerelle Zen Go et déclare +désormais les modalités natives `text` et `image` : les nouvelles lignes `opencode-go` lui envoient donc les +images directement au lieu de passer par ce sidecar. Son jumeau `deepseek-v4-flash` sur la même passerelle +répond toujours HTTP 400 « Model only supports text input » et reste couvert par le sidecar ; les paliers +`opencode-zen` et `opencode-free` conservent leur classification existante. Les métadonnées du registre ne +remplissent que les valeurs manquantes : une ligne `opencode-go` enregistrée avant cette correction garde ses +surcharges enregistrées et continue de router `deepseek-v4.1-flash` via ce sidecar. Pour activer la vision +native sur une telle ligne, retirez `deepseek-v4.1-flash` de la liste `noVisionModels` du fournisseur et +supprimez son entrée `modelInputModalities` `["text"]` enregistrée dans `~/.opencodex/config.json` — ou +supprimez puis recréez le preset. `ocx provider edit opencode-go --model deepseek-v4.1-flash --text-only` +restaure la restriction via `modelCapabilities`, qui l'emporte sur toutes les déclarations ci-dessus. + - Les images peuvent provenir de messages utilisateur, développeur et de résultats d’outils, y compris de `view_image` dans Codex. - Sur le chemin OpenAI (ChatGPT-login passthrough), chaque image est envoyée au modèle de vision configuré au point de terminaison Responses avec la valeur `reasoning.effort` sélectionnée (`low` par défaut) ; sa diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index a99042b94fd..13affd4acb8 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -138,6 +138,18 @@ not use this sidecar by default. Explicit `noVisionModels` or text-only declarat authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` remain sidecar-backed by default; Zen routes are unchanged and were not probed in this update. +OpenCode Go's `deepseek-v4.1-flash` accepts `image_url` input on the Zen Go gateway and now +declares native `text` and `image` modalities, so new `opencode-go` rows send it images directly +instead of through this sidecar. Its sibling `deepseek-v4-flash` on the same gateway still answers +HTTP 400 "Model only supports text input" and stays sidecar-backed, and the `opencode-zen` / +`opencode-free` tiers keep their existing classification. Registry metadata only fills missing +values, so an `opencode-go` provider row saved before this correction keeps its saved overrides and +continues routing `deepseek-v4.1-flash` through this sidecar. To opt into native vision on such a +row, remove `deepseek-v4.1-flash` from the provider's `noVisionModels` list and delete its saved +`modelInputModalities` `["text"]` entry in `~/.opencodex/config.json`, or delete and re-add the +preset. `ocx provider edit opencode-go --model deepseek-v4.1-flash --text-only` restores the +restriction through `modelCapabilities`, which outranks every declaration above. + - Images can come from user, developer, and tool-result messages, including Codex's `view_image`. - On the OpenAI path (ChatGPT-login passthrough), each image is sent to the configured vision model over the Responses endpoint with the selected `reasoning.effort` (`low` by default), and its diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 30a7f5a404a..1c21dcf12fe 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -80,6 +80,18 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` を 明示的に保存された旧 `gpt-5.4-mini` 値を引き続き `gpt-5.6-luna` にマイグレーションしますが、この マイグレーションは保存済みの値だけが対象で、モデルフィールドがない場合には適用されません。 +OpenCode Go の `deepseek-v4.1-flash` は Zen Go ゲートウェイで `image_url` 入力を受け付け、ネイティブな +`text` と `image` モダリティを宣言するようになったため、新しい `opencode-go` 行はこの sidecar を +経由せず画像を直接送信します。同じゲートウェイの兄弟モデル `deepseek-v4-flash` は引き続き HTTP 400 +「Model only supports text input」を返し sidecar 経由のままで、`opencode-zen` と `opencode-free` +ティアは既存の分類を維持します。レジストリのメタデータは欠損値だけを補完するため、この修正以前に +保存された `opencode-go` プロバイダー行は保存済みのオーバーライドを保持し、`deepseek-v4.1-flash` +をこの sidecar 経由でルーティングし続けます。そのような行でネイティブ vision を有効にするには、 +`~/.opencodex/config.json` でプロバイダーの `noVisionModels` リストから `deepseek-v4.1-flash` を +削除し、保存済みの `modelInputModalities` `["text"]` エントリを消去するか、プリセットを削除して +追加し直してください。`ocx provider edit opencode-go --model deepseek-v4.1-flash --text-only` は +`modelCapabilities` を通じて制限を復元し、これは上記のすべての宣言に優先します。 + - 画像はユーザー、developer、ツール結果メッセージから来ます。Codex の `view_image` 結果も 含まれます。 - OpenAI パス(ChatGPT ログインパススルー)では、各画像は選択した `reasoning.effort`(デフォルト diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index b3d4309dbf1..883c786ac1f 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -82,6 +82,18 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 저장된 기존 `gpt-5.4-mini` 값은 계속 `gpt-5.6-luna`로 마이그레이션되지만, 이 마이그레이션은 저장된 값에만 적용되고 모델 필드가 없는 경우에는 적용되지 않습니다. +OpenCode Go의 `deepseek-v4.1-flash`는 Zen Go 게이트웨이에서 `image_url` 입력을 받아들이고 이제 +네이티브 `text`와 `image` 모달리티를 선언하므로, 새 `opencode-go` 행은 이 sidecar를 거치지 않고 +이미지를 직접 보냅니다. 같은 게이트웨이의 형제 모델 `deepseek-v4-flash`는 여전히 HTTP 400 "Model only +supports text input"을 반환하며 sidecar 지원 상태를 유지하고, `opencode-zen`과 `opencode-free` 티어는 +기존 분류를 유지합니다. 레지스트리 메타데이터는 비어 있는 값만 채우므로, 이 수정 이전에 저장된 +`opencode-go` provider 행은 저장된 오버라이드를 그대로 유지한 채 `deepseek-v4.1-flash`를 계속 이 +sidecar로 라우팅합니다. 이러한 행에서 네이티브 vision을 사용하려면 `~/.opencodex/config.json`에서 +해당 provider의 `noVisionModels` 목록에서 `deepseek-v4.1-flash`를 제거하고 저장된 +`modelInputModalities` `["text"]` 항목을 삭제하거나, 프리셋을 삭제한 후 다시 추가하세요. `ocx +provider edit opencode-go --model deepseek-v4.1-flash --text-only`는 위의 모든 선언보다 우선하는 +`modelCapabilities`를 통해 제한을 복원합니다. + - 이미지는 사용자, developer, 도구 결과 메시지에서 올 수 있습니다. Codex의 `view_image` 결과도 포함됩니다. - OpenAI 경로(ChatGPT 로그인 패스스루)에서는 각 이미지가 선택한 `reasoning.effort`(기본값 diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 54cf37020e6..17156a230b0 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -92,6 +92,19 @@ opencodex описывает каждое изображение **до** осн `gpt-5.4-mini` по-прежнему мигрирует на `gpt-5.6-luna`; миграция применяется только к сохранённому значению, а не к отсутствующему полю модели. +`deepseek-v4.1-flash` на OpenCode Go принимает части `image_url` на шлюзе Zen Go и теперь объявляет +нативные модальности `text` и `image`, поэтому новые строки `opencode-go` отправляют ему изображения +напрямую, минуя этот сайдкар. Родственная `deepseek-v4-flash` на том же шлюзе по-прежнему отвечает +HTTP 400 «Model only supports text input» и остаётся на сайдкаре, а уровни `opencode-zen` и +`opencode-free` сохраняют прежнюю классификацию. Метаданные реестра заполняют только отсутствующие +значения, поэтому строка `opencode-go`, сохранённая до этого исправления, хранит свои сохранённые +переопределения и продолжает маршрутизировать `deepseek-v4.1-flash` через этот сайдкар. Чтобы включить +нативное vision на такой строке, удалите `deepseek-v4.1-flash` из списка `noVisionModels` провайдера и +уберите его сохранённую запись `modelInputModalities` `["text"]` в `~/.opencodex/config.json` — или +удалите и заново добавьте пресет. `ocx provider edit opencode-go --model deepseek-v4.1-flash +--text-only` восстанавливает ограничение через `modelCapabilities`, которое имеет приоритет над всеми +перечисленными выше объявлениями. + - Изображения могут приходить из сообщений пользователя, разработчика и результатов инструментов, включая `view_image` из Codex. - На пути OpenAI (passthrough с логином ChatGPT) каждое изображение отправляется в настроенную diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 19c7f16ee8c..8063806b7b0 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -118,6 +118,20 @@ geri dönüşünü kullanır. Başlangıç hala açıkça kalıcı hale getirilm `gpt-5.4-mini` değerini `gpt-5.6-luna`'ya geçirir; bu geçiş, bulunmayan bir model alanına değil, saklanan bir değere uygulanır. +OpenCode Go'nun `deepseek-v4.1-flash` modeli Zen Go ağ geçidinde `image_url` parçalarını kabul eder ve +artık yerel `text` ile `image` modalitelerini bildirir; bu nedenle yeni `opencode-go` satırları +görselleri bu sidecar yerine doğrudan ona gönderir. Aynı ağ geçidindeki kardeş model +`deepseek-v4-flash` hâlâ HTTP 400 "Model only supports text input" yanıtı verir ve sidecar destekli +kalır; `opencode-zen` ve `opencode-free` katmanları ise mevcut sınıflandırmalarını korur. Kayıt defteri +meta verileri yalnızca eksik değerleri doldurur; bu nedenle bu düzeltmeden önce kaydedilmiş bir +`opencode-go` satırı, kayıtlı geçersiz kılmalarını korur ve `deepseek-v4.1-flash`'i bu sidecar +üzerinden yönlendirmeye devam eder. Böyle bir satırda yerel vision'ı etkinleştirmek için +`deepseek-v4.1-flash`'i sağlayıcının `noVisionModels` listesinden kaldırın ve +`~/.opencodex/config.json` içindeki kayıtlı `modelInputModalities` `["text"]` girdisini silin — ya da +ön ayarı silip yeniden ekleyin. `ocx provider edit opencode-go --model deepseek-v4.1-flash +--text-only`, kısıtlamayı yukarıdaki tüm bildirimlerin önüne geçen `modelCapabilities` üzerinden geri +yükler. + - Görseller, Codex'in `view_image`'ı da dahil olmak üzere kullanıcı, geliştirici ve araç sonucu mesajlarından gelebilir. - OpenAI yolunda (ChatGPT girişi doğrudan geçişi), her görsel seçilen diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 2ffeb8ff1e1..edf184b60a4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -72,6 +72,16 @@ OAuth 账户时使用 `anthropic`,否则使用 `openai`。显式选择 `anthro Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会把明确保存的旧 `gpt-5.4-mini` 值迁移到 `gpt-5.6-luna`;该迁移只作用于已保存值,不适用于缺失的 model 字段。 +OpenCode Go 的 `deepseek-v4.1-flash` 在 Zen Go 网关上接受 `image_url` 输入,现已声明原生 `text` 与 +`image` 模态,因此新的 `opencode-go` 行会直接向它发送图像,而不是经过此 sidecar。同一网关上的兄弟 +模型 `deepseek-v4-flash` 仍返回 HTTP 400 "Model only supports text input",继续由 sidecar 支持; +`opencode-zen` 与 `opencode-free` 档位保持既有分类。registry 元数据只填补缺失值,因此在此修正之前 +保存的 `opencode-go` provider 行会保留其已保存的 override,并继续把 `deepseek-v4.1-flash` 路由到此 +sidecar。要在这类行上启用原生视觉,请从该 provider 的 `noVisionModels` 列表中移除 +`deepseek-v4.1-flash`,并删除 `~/.opencodex/config.json` 中其已保存的 `modelInputModalities` +`["text"]` 条目,或者删除并重新添加该 preset。`ocx provider edit opencode-go --model +deepseek-v4.1-flash --text-only` 会通过 `modelCapabilities` 恢复该限制,其优先级高于上述所有声明。 + - 图像可以来自 user、developer 和 tool-result message,也包括 Codex 的 `view_image` 结果。 - OpenAI 路径(ChatGPT 登录透传)会通过 Responses 端点把每张图像发送给配置的视觉模型,并携带所选 的 `reasoning.effort`(默认为 `low`),描述结果就地替换图像部分。Anthropic 路径走 Messages diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 97c073d3bad..34b2d6de6b2 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -72,6 +72,16 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro `gpt-5.6-luna`,啟動時也會把明確儲存的舊 `gpt-5.4-mini` 值遷移到 Luna。只有在 `visionSidecar.model` 欄位不存在或為空字串時,vision 執行路徑才會使用程式碼中的 `gpt-5.6-luna` 回退值。 +OpenCode Go 的 `deepseek-v4.1-flash` 在 Zen Go 閘道上接受 `image_url` 輸入,現已宣告原生 `text` 與 +`image` 模態,因此新的 `opencode-go` 列會直接向它傳送圖像,而不是經過此 sidecar。同一閘道上的兄弟 +模型 `deepseek-v4-flash` 仍回應 HTTP 400 "Model only supports text input",繼續由 sidecar 支援; +`opencode-zen` 與 `opencode-free` 層級維持既有分類。registry 元資料只填補缺失值,因此在此修正之前 +儲存的 `opencode-go` provider 列會保留其已儲存的 override,並繼續把 `deepseek-v4.1-flash` 路由到此 +sidecar。要在這類列上啟用原生視覺,請從該 provider 的 `noVisionModels` 清單中移除 +`deepseek-v4.1-flash`,並刪除 `~/.opencodex/config.json` 中其已儲存的 `modelInputModalities` +`["text"]` 條目,或刪除並重新加入該 preset。`ocx provider edit opencode-go --model +deepseek-v4.1-flash --text-only` 會透過 `modelCapabilities` 還原該限制,其優先序高於上述所有宣告。 + - 圖像可以來自 user、developer 和 tool-result message,也包括 Codex 的 `view_image` 結果。 - 每張圖像會以 `reasoning.effort: "low"` 傳送給設定的原生 vision 模型,描述結果會就地替換 圖像部分。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 81c34dee6c4..7e5a5665cfa 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -479,6 +479,7 @@ "codex-auth-context.test.ts": "codex-integration", "codex-auth-modal-status.test.ts": "gui", "codex-catalog-admission.test.ts": "codex-integration", + "codex-catalog-exclusions.test.ts": "codex-integration", "codex-catalog-golden.test.ts": "codex-integration", "codex-catalog-model-picker-order.test.ts": "codex-integration", "codex-catalog-refresh-status.test.ts": "codex-integration", @@ -979,6 +980,7 @@ "management-provider-proto-override.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-validation.test.ts": "server", + "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "memory-watchdog.test.ts": "server", diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 96f8c0270e5..f9adaa28d87 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -413,6 +413,10 @@ export interface CatalogEffortCompatibility { readonly affectedModels: readonly string[]; } +// These parser-valid sentinels do not appear in native model ladders, so absence from an +// observed bundled catalog is not evidence that the selected Codex runtime rejects them. +const CODEX_PARSER_SENTINEL_EFFORTS = new Set(["none", "minimal"]); + /** * Report which reasoning efforts in a catalog the local Codex runtime would reject, without * changing anything. @@ -432,9 +436,11 @@ export function catalogEffortCompatibility( const unsupported = new Set(); const affected: string[] = []; for (const entry of models) { - const rejected = catalogEntryEfforts(entry).filter(effort => !supported.has(effort)); + const accepts = (effort: string): boolean => supported.has(effort) + || CODEX_PARSER_SENTINEL_EFFORTS.has(effort); + const rejected = catalogEntryEfforts(entry).filter(effort => !accepts(effort)); const fallback = typeof entry.default_reasoning_level === "string" - && !supported.has(entry.default_reasoning_level) + && !accepts(entry.default_reasoning_level) ? [entry.default_reasoning_level] : []; if (rejected.length === 0 && fallback.length === 0) continue; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index d802284aeef..f76953e1851 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -194,6 +194,8 @@ export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([ * keep appearing with its capabilities broken. Excluding the slug is what actually takes * it out of the routed catalog. */ + "deepseek/deepseek-v4-pro", + "opencode-go/deepseek-v4-pro", "command-code/deepseek-deepseek-v4-pro", "commandcode/deepseek-deepseek-v4-pro", "orcarouter/deepseek-deepseek-v4-pro", diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 299b95cc413..e03d65a9b9a 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -113,13 +113,17 @@ function cloneRecordOfArrays(input: Record): Record, user: Record | undefined, ): Record { - return { ...cloneRecordOfArrays(seed), ...(user ? cloneRecordOfArrays(user) : {}) }; + const userKeys = new Set(Object.keys(user ?? {}).map(key => key.toLowerCase())); + const defaults = Object.fromEntries( + Object.entries(seed).filter(([key]) => !userKeys.has(key.toLowerCase())), + ); + return { ...cloneRecordOfArrays(defaults), ...(user ? cloneRecordOfArrays(user) : {}) }; } function cloneNestedRecord(input: Record>): Record> { @@ -534,7 +538,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // Per-model fill for the same reason as modelInputModalities above: an all-or-nothing // copy let ONE customized model hide the registry's ladder for every other model on the // provider. That split the two planes apart — routing merges these maps per key - // (mergeRecordFill in src/router.ts), so the wire honored the effort while /v1/models and + // (mapFill in src/providers/resolved-model-policy-merge.ts), so the wire honored the effort while /v1/models and // every client export showed no effort control at all. if (resolvedStatic.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(resolvedStatic.modelReasoningEfforts); if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts }; diff --git a/src/providers/model-rename-startup.ts b/src/providers/model-rename-startup.ts index 0f15b695cd6..f846b6b9acc 100644 --- a/src/providers/model-rename-startup.ts +++ b/src/providers/model-rename-startup.ts @@ -1,13 +1,12 @@ import { mutatePersistedConfig } from "../config"; import { projectModelRenames } from "./model-rename-migration"; import { projectStaleContextWindows } from "./stale-context-window-migration"; -import { projectStaleVisionClassifications } from "./stale-vision-classification-migration"; import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration"; import type { OcxConfig } from "../types"; /** - * The startup projection: registry model renames, then the context-window and - * vision-classification repairs. All three fix a saved row the registry can no longer reach on its own — + * The startup projection: registry model renames, then context-window and auth-mode repairs. + * These fix a saved row the registry can no longer reach on its own — * `enrichProviderFromRegistry` backfills a missing field and never rewrites a * present one — so they share this pass rather than adding a second boot step * with its own persistence, adopt, and failure handling. @@ -15,12 +14,11 @@ import type { OcxConfig } from "../types"; export function projectStartupConfigRepairs(config: OcxConfig): ReturnType { const renames = projectModelRenames(config); const windows = projectStaleContextWindows(renames.config); - const vision = projectStaleVisionClassifications(windows.config); - const devinCli = projectDevinCliAuthMode(vision.config); + const devinCli = projectDevinCliAuthMode(windows.config); return { config: devinCli.config, - changed: renames.changed || windows.changed || vision.changed || devinCli.changed, - warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...devinCli.warnings], + changed: renames.changed || windows.changed || devinCli.changed, + warnings: [...renames.warnings, ...windows.warnings, ...devinCli.warnings], }; } diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 3a956081ddc..de31c854909 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -724,12 +724,9 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // HTTP 400 "Model only supports text input", which is what keeps the two // distinct here rather than collapsing them. // - // The declaration is what reaches an EXISTING install: derive.ts fills - // noVisionModels all-or-nothing, so a config persisted while the stale list - // was current keeps it forever, and modelInputModalities is filled per-key - // BENEATH the saved value. Both halves are repaired by - // stale-vision-classification-migration.ts; correcting the registry alone - // would fix new installs and leave existing ones stripping images. + // derive.ts fills missing registry metadata but does not replace saved values. + // That makes this declaration the default for new rows while preserving an + // existing install's operator-editable image-routing restrictions. "deepseek-v4.1-flash": ["text", "image"], // Muse Spark Contributor is natively multimodal on Zen Go: it accepts input_image // parts over /responses (probed 2026-08-26). Without this declaration the catalog diff --git a/src/providers/resolved-model-policy-merge.ts b/src/providers/resolved-model-policy-merge.ts index b767d903c36..a8646009292 100644 --- a/src/providers/resolved-model-policy-merge.ts +++ b/src/providers/resolved-model-policy-merge.ts @@ -32,7 +32,15 @@ export function mapFill( operator: Readonly> | undefined, ): [Record | undefined, StaticPolicySource] { if (!registry && !operator) return [undefined, "unknown"]; - return [detachedClone({ ...(registry ?? {}), ...(operator ?? {}) }), operator ? "operator" : "registry"]; + // Per-model lookups fold case (legacyModelValue), so a case-varied operator key must claim the + // registry row here; leaving both keys lets the earlier registry entry shadow the override. + const claimed = new Set(Object.keys(operator ?? {}).map(key => key.toLowerCase())); + const merged: Record = {}; + for (const [key, value] of Object.entries(registry ?? {})) { + if (!claimed.has(key.toLowerCase())) merged[key] = value; + } + for (const [key, value] of Object.entries(operator ?? {})) merged[key] = value; + return [detachedClone(merged), operator ? "operator" : "registry"]; } export function nestedMapFill( @@ -40,10 +48,19 @@ export function nestedMapFill( operator: Readonly>> | undefined, ): [Record> | undefined, StaticPolicySource] { if (!registry && !operator) return [undefined, "unknown"]; + // The outer model key folds case like mapFill: a case-varied operator key claims the registry + // row instead of shadowing behind it, while the claimed row's inner entries still fill + // underneath the operator's inner map. + const claimed = new Set(Object.keys(operator ?? {}).map(key => key.toLowerCase())); const merged: Record> = {}; - for (const [key, value] of Object.entries(registry ?? {})) merged[key] = { ...value }; + const claimedInner: Record> = {}; + for (const [key, value] of Object.entries(registry ?? {})) { + const folded = key.toLowerCase(); + if (claimed.has(folded)) claimedInner[folded] = { ...(claimedInner[folded] ?? {}), ...value }; + else merged[key] = { ...value }; + } for (const [key, value] of Object.entries(operator ?? {})) { - merged[key] = { ...(merged[key] ?? {}), ...value }; + merged[key] = { ...(claimedInner[key.toLowerCase()] ?? merged[key] ?? {}), ...value }; } return [merged, operator ? "operator" : "registry"]; } @@ -52,12 +69,19 @@ export function positiveCapMap( registry: Readonly> | undefined, operator: Readonly> | undefined, ): [Record | undefined, StaticPolicySource] { - if (!registry && !operator) return [undefined, "unknown"]; - const merged = { ...(registry ?? {}) }; + const [merged, source] = mapFill(registry, operator); + if (!merged) return [undefined, source]; + // The operator owns a case-equal row, as in mapFill, but cannot widen its registry cap. + const registryCaps = new Map(); + for (const [key, value] of Object.entries(registry ?? {})) { + const folded = key.toLowerCase(); + registryCaps.set(folded, Math.min(registryCaps.get(folded) ?? value, value)); + } for (const [key, value] of Object.entries(operator ?? {})) { - merged[key] = typeof merged[key] === "number" ? Math.min(merged[key]!, value) : value; + const cap = registryCaps.get(key.toLowerCase()); + merged[key] = cap === undefined ? value : Math.min(cap, value); } - return [merged, operator ? "operator" : "registry"]; + return [merged, source]; } export function stableUnion( @@ -118,7 +142,16 @@ export function legacyModelSource( registry: Readonly> | undefined, modelId: string, ): StaticPolicySource { - const merged = { ...(registry ?? {}), ...(operator ?? {}) }; + // Fold case-equal registry keys into the operator row exactly like mapFill, so + // provenance answers from the same merged map the value lookup sees: a + // registry-spelled winning key whose only difference from an operator key is + // case still reports operator, not registry. + const claimed = new Set(Object.keys(operator ?? {}).map(key => key.toLowerCase())); + const merged: Record = {}; + for (const [key, value] of Object.entries(registry ?? {})) { + if (!claimed.has(key.toLowerCase())) merged[key] = value; + } + for (const [key, value] of Object.entries(operator ?? {})) merged[key] = value; let winningKey: string | undefined; if (Object.hasOwn(merged, modelId)) winningKey = modelId; const colon = modelId.indexOf(":"); diff --git a/src/providers/stale-vision-classification-migration.ts b/src/providers/stale-vision-classification-migration.ts deleted file mode 100644 index e27cb186268..00000000000 --- a/src/providers/stale-vision-classification-migration.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Repair vision classifications a saved config inherited from a stale registry seed. - * - * `enrichProviderFromRegistry` is fill-only and asymmetric, which is right for a hand-tuned - * value but freezes a wrong one: - * - * - `noVisionModels` is filled ALL-OR-NOTHING (`if (!prov.noVisionModels && seed.noVisionModels)`), - * so a config saved while the list was current keeps that list forever. - * - `modelInputModalities` is filled per-key BENEATH the saved value, so a saved `["text"]` for - * one id survives every later registry correction. - * - * Correcting the registry therefore fixes new installs only. This projection is the other half. - * It repairs the two saved values a stale seed left behind, in both states that reach a running - * process: - * - * - the full pair: modalities still exactly the stale declaration, and the id in the list. Both - * are rewritten. - * - the half-repaired row: modalities already corrected but the id still in the list. The - * sidecar predicate reads `noVisionModels` BEFORE the modality list, so that row keeps - * stripping images until the name goes as well; this projection removes it and leaves the - * modalities untouched. - * - * The paired modalities value is the guard in both states, which is also why it has to be - * readable: a name in the list with no modality declaration beside it is ambiguous — either a - * half-finished repair or an entry the operator added on purpose — and this projection does not - * guess which. It leaves that row alone. - * - * Identity is the same rule enrichment uses, read from the same helper: the registry must still - * own the row (`providerMatchesRegistryTransport`, the check `enrichProviderFromRegistry` makes - * before it writes registry metadata) and the row must still be on the entry's adapter. - * `opencode-go` is an existing key preset without `preserveCustomDestination`, so the id alone - * claims a row — exactly as it does for enrichment — while an entry that opts into destination - * preservation narrows this projection for free. - * - * Nothing here writes `modelCapabilities`. That is the dedicated per-model axis, it outranks - * every source this file touches, and it is where a deliberate text-only override belongs - * (`ocx provider edit --model --text-only` writes it). - * - * Scope is deliberately one entry. The Go gateway's `deepseek-v4.1-flash` was declared text-only - * from jawcode metadata and was measured natively multimodal on 2026-09-19 (see the note at that - * entry in `registry/entries-core.ts`). Its sibling `deepseek-v4-flash` still rejects images - * upstream, and the sibling Zen tiers (`opencode-zen`, `opencode-free`) could not be probed at - * all — an unverified tier is not evidence, so neither is touched here. - */ -import { PROVIDER_REGISTRY, providerMatchesRegistryTransport } from "./registry"; -import type { OcxConfig } from "../types"; - -export interface StaleVisionClassification { - /** Registry provider id whose saved rows may carry the wrong classification. */ - provider: string; - model: string; - /** The stale saved modalities this migration is allowed to replace, and nothing else. */ - fromModalities: string[]; - toModalities: string[]; -} - -export interface StaleVisionClassificationProjection { - config: OcxConfig; - changed: boolean; - warnings: string[]; -} - -export const STALE_VISION_CLASSIFICATIONS: readonly StaleVisionClassification[] = [ - { - provider: "opencode-go", - model: "deepseek-v4.1-flash", - fromModalities: ["text"], - toModalities: ["text", "image"], - }, -]; - -/** - * Whether the registry still owns the row whose saved values would be rewritten. - * - * `providerMatchesRegistryTransport` is the rule enrichment applies, so this projection and the - * fill that put the stale values there answer identity the same way. Adapter equality stays as - * an additional tightening: a row moved onto another wire is not the row the seed described, - * however the registry entry is pinned. - */ -function providerStillMatchesRegistry(id: string, prov: OcxConfig["providers"][string]): boolean { - const entry = PROVIDER_REGISTRY.find(row => row.id === id); - if (entry === undefined || entry.adapter !== prov.adapter) return false; - return providerMatchesRegistryTransport(id, prov); -} - -function sameModalities(current: unknown, expected: readonly string[]): boolean { - return Array.isArray(current) - && current.length === expected.length - && current.every((value, index) => value === expected[index]); -} - -/** Pure projection. The caller decides whether to persist. */ -export function projectStaleVisionClassifications( - config: OcxConfig, - entries: readonly StaleVisionClassification[] = STALE_VISION_CLASSIFICATIONS, -): StaleVisionClassificationProjection { - const warnings: string[] = []; - const repaired = new Map(); - - for (const entry of entries) { - const prov = config.providers?.[entry.provider]; - if (!prov) continue; - if (!providerStillMatchesRegistry(entry.provider, prov)) continue; - const modalities = prov.modelInputModalities; - if (!modalities) continue; - // The paired modalities value is the guard, and it decides which of the two states above this - // row is in. Anything else is a declaration this file does not own. - const saved = modalities[entry.model]; - const stale = sameModalities(saved, entry.fromModalities); - const alreadyMigrated = sameModalities(saved, entry.toModalities); - if (!stale && !alreadyMigrated) continue; - const visionList = Array.isArray(prov.noVisionModels) ? prov.noVisionModels : undefined; - const listed = visionList !== undefined && visionList.includes(entry.model); - // Half-repaired rows have nothing left to do once the name is gone; the full pair is rewritten - // whether or not the name was ever listed, because `derive.ts` fills the two fields - // independently and a per-key modality fill can land without the all-or-nothing list. - if (!stale && !listed) continue; - if (stale) modalities[entry.model] = [...entry.toModalities]; - if (visionList !== undefined && listed) { - prov.noVisionModels = visionList.filter(id => id !== entry.model); - } - const repairedList = repaired.get(entry.provider) ?? []; - repairedList.push(stale - ? `${entry.model} ${entry.fromModalities.join("+")} -> ${entry.toModalities.join("+")}` - : `${entry.model} dropped from noVisionModels (modalities already ${entry.toModalities.join("+")})`); - repaired.set(entry.provider, repairedList); - } - - for (const [provider, list] of repaired) { - warnings.push( - `repaired the stale registry vision seed for ${list.length} model(s) on "${provider}": ` - + `${list.join(", ")}.`, - ); - } - - return { config, changed: repaired.size > 0, warnings }; -} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a0ef077dd5d..f9f775955c1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -818,6 +818,8 @@ export function providerManagementConfigError( if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; const suppressSyntheticMaxError = booleanRecordConfigError(raw.modelSuppressSyntheticMax, "modelSuppressSyntheticMax"); if (suppressSyntheticMaxError) return `provider ${name} ${suppressSyntheticMaxError}`; + const verbositySupportError = booleanRecordConfigError(raw.modelSupportsVerbosity, "modelSupportsVerbosity"); + if (verbositySupportError) return `provider ${name} ${verbositySupportError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( raw.modelReasoningSummaryDelivery, raw.modelSupportsReasoningSummaries, diff --git a/structure/catalog.md b/structure/catalog.md index 8732010ccfb..151af951b62 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -58,7 +58,8 @@ provider-wide fallback. Exact model output limits precede the provider default o account-qualified native ids hide only that selector row; BARE native slugs hide the bare row and all account-selector clones and drop that model family from raw `/v1/models`); - applies exact provider/model compatibility exclusions after live discovery and metadata - augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers; + augmentation, so upstream-advertised but uncallable rows—including retired aliases retained in + generated metadata for historical accounting—never enter dashboard or Codex pickers; - strips native-only service tier and WebSocket metadata unless the final routed provider/model explicitly enables the verified OpenAI-compatible service tier; - backs up the pristine catalog once per catalog: the copy is keyed by a hash of the catalog path @@ -522,7 +523,7 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara ## Renamed destination reasoning metadata -`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. +`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win, matched case-insensitively. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. diff --git a/structure/config.md b/structure/config.md index 7488970b4e7..930d13d572d 100644 --- a/structure/config.md +++ b/structure/config.md @@ -348,11 +348,12 @@ and `routeModel`, but user config overrides registry defaults per field/key. `src/providers/resolved-model-policy.ts` is the detached static-policy authority for this merge contract. It preserves each field's existing rule rather than assigning one global priority: -operator scalars and explicit booleans fill over registry defaults, per-model maps fill per key, +operator scalars and explicit booleans fill over registry defaults, per-model maps fill per key with a case-varied operator key claiming the registry row, restriction lists form a stable union, and hard wire pins precede valid operator overrides and registry wire defaults. Only the canonical `openai-apikey` provider merges -`modelContextWindows` and `modelMaxInputTokens` by taking the lower positive value; other -providers use ordinary operator-per-key fill. Its output is recursively +`modelContextWindows` and `modelMaxInputTokens` by taking the lower positive value across +case-equal keys, retaining the operator's row spelling and provenance even when registry-clamped; +other providers use ordinary operator-per-key fill. Its output is recursively frozen and carries field/model provenance. It never persists resolved policy and excludes API keys, account selection, quota, health, cooldowns, discovered availability, and request-owned evidence. Observed context/input/output values are combined only in a call-local projection that can narrow a diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 132265964a9..f31349b04ab 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -83,27 +83,13 @@ answers HTTP 400 "Model only supports text input" and stays sidecar-backed. The (`opencode-zen`, `opencode-free`) were not measurable (HTTP 402) and keep their existing classification — an unverified tier is not evidence. -Because `enrichProviderFromRegistry` fills `noVisionModels` all-or-nothing and fills -`modelInputModalities` per-key beneath the saved value, both halves of a stale classification are -frozen into any config saved while it was current. `src/providers/stale-vision-classification-migration.ts` -repairs exactly those two saved values and runs inside the shared startup repair pass in -`src/providers/model-rename-startup.ts`. Correcting the registry alone fixes new installs only. - -It covers both states that reach a running process, because the sidecar predicate reads -`noVisionModels` before `modelInputModalities`: the full stale pair (modalities still the stale -declaration and the id listed, both rewritten) and the half-repaired row (modalities already -corrected but the id still listed, where removing the name is what stops the image from being -stripped). The paired modality declaration is the guard in both cases, which is why a name listed -without one is left alone — that row is either a half-finished repair or a deliberate operator -entry, and the projection does not guess which. The row must also still be the registry's own: -identity resolves through `providerMatchesRegistryTransport`, the rule `enrichProviderFromRegistry` -applies before it writes registry metadata, plus the entry's adapter. `opencode-go` is a pinned -key preset without `preserveCustomDestination`, so its id alone claims a row — exactly as it does -for enrichment — and an entry that opts into destination preservation narrows the projection with -it. `modelCapabilities` is never written: it is the -axis that outranks every source here, so it is where a deliberate text-only override belongs -(`ocx provider edit --model --text-only` writes it) and the one declaration a -restart cannot take back. +Because `enrichProviderFromRegistry` is fill-only, the corrected classification applies to new +rows and missing values. Startup does not rewrite an existing `noVisionModels` entry or +`modelInputModalities` value: those fields are operator-editable request-routing policy, and a +saved registry seed is indistinguishable from an intentional restriction without provenance. +Existing users can opt into the corrected native-vision classification by removing those saved +overrides. `modelCapabilities` remains the highest-precedence per-model axis, and +`ocx provider edit --model --text-only` writes an explicit restriction there. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row diff --git a/tests/clients/client-catalog-compatibility.test.ts b/tests/clients/client-catalog-compatibility.test.ts index 454ff14c67c..b27f764af49 100644 --- a/tests/clients/client-catalog-compatibility.test.ts +++ b/tests/clients/client-catalog-compatibility.test.ts @@ -67,6 +67,19 @@ describe("#4207 catalog effort compatibility", () => { }); }); + test("parser sentinels are accepted even when absent from observed native ladders", () => { + // Native bundled rows do not advertise these values, but the Codex catalog parser accepts + // both. The observed rows therefore cannot be treated as an exhaustive parser enum. + const observedNativeLadder = new Set(["low", "medium", "high", "xhigh"]); + const models = JSON.parse(catalogBody(["none", "minimal"], "provider/model", "minimal")).models; + + expect(catalogEffortCompatibility(models, observedNativeLadder)).toEqual({ + compatible: true, + unsupportedEfforts: [], + affectedModels: [], + }); + }); + test("an unobservable runtime ladder is not evidence of incompatibility", () => { const models = JSON.parse(catalogBody(["low", "max"])).models; expect(catalogEffortCompatibility(models, null).compatible).toBe(true); diff --git a/tests/codex-integration/codex-catalog-exclusions.test.ts b/tests/codex-integration/codex-catalog-exclusions.test.ts new file mode 100644 index 00000000000..6c076d7000c --- /dev/null +++ b/tests/codex-integration/codex-catalog-exclusions.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { shouldExposeRoutedModel } from "../../src/codex/catalog"; + +describe("shouldExposeRoutedModel — Gemini image-capable exemption", () => { + test("still filters compatibility-excluded slugs", () => { + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "hy3-preview" })).toBe(false); + // Issue #2330: uncallable or stale OpenCode Go models + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "mimo-v2-omni" })).toBe(false); + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "mimo-v2-pro" })).toBe(false); + expect(shouldExposeRoutedModel({ provider: "deepseek", id: "deepseek-v4-pro" })).toBe(false); + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "deepseek-v4-pro" })).toBe(false); + // Control / live models are exposed + expect(shouldExposeRoutedModel({ provider: "opencode-free", id: "deepseek-v4-flash-free" })).toBe(true); + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "grok-4.6" })).toBe(true); + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "glm-5.2" })).toBe(true); + }); +}); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index fe46b224b25..1143d8cdd73 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -7105,16 +7105,6 @@ describe("shouldExposeRoutedModel — Gemini image-capable exemption", () => { } }); - test("still filters compatibility-excluded slugs", () => { - expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "hy3-preview" })).toBe(false); - // Issue #2330: uncallable or stale OpenCode Go models - expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "mimo-v2-omni" })).toBe(false); - expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "mimo-v2-pro" })).toBe(false); - // Control / live models are exposed - expect(shouldExposeRoutedModel({ provider: "opencode-free", id: "deepseek-v4-flash-free" })).toBe(true); - expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "grok-4.6" })).toBe(true); - expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "glm-5.2" })).toBe(true); - }); }); describe("Codex reasoning-effort capability clamp", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9fd4a11a6ad..279d8b9db53 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -310,6 +310,7 @@ "codex-auth-context.test.ts": "codex-integration", "codex-auth-modal-status.test.ts": "gui", "codex-catalog-admission.test.ts": "codex-integration", + "codex-catalog-exclusions.test.ts": "codex-integration", "codex-catalog-golden.test.ts": "codex-integration", "codex-catalog-model-picker-order.test.ts": "codex-integration", "codex-catalog-refresh-status.test.ts": "codex-integration", @@ -805,6 +806,7 @@ "management-provider-proto-override.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-validation.test.ts": "server", + "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "memory-watchdog.test.ts": "server", diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index cf4d5b3bd78..4d0c0597dc9 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -931,6 +931,37 @@ describe("provider registry parity", () => { expect(apiKey.modelReasoningEfforts).toEqual(anthropicOauth?.modelReasoningEfforts); }); + test("case-varied Anthropic effort overrides replace registry defaults", () => { + const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + modelReasoningEfforts: { "Claude-Opus-5": [] }, + }; + + const enriched = structuredClone(provider); + enrichProviderFromRegistry("anthropic", enriched); + expect(enriched.modelReasoningEfforts).not.toHaveProperty("claude-opus-5"); + expect(enriched.modelReasoningEfforts?.["Claude-Opus-5"]).toEqual([]); + + const config: OcxConfig = { port: 10100, defaultProvider: "anthropic", providers: { anthropic: provider } }; + const routed = routeModel(config, "anthropic/claude-opus-5"); + expect(routed.provider.modelReasoningEfforts).not.toHaveProperty("claude-opus-5"); + expect(routed.provider.modelReasoningEfforts?.["Claude-Opus-5"]).toEqual([]); + + // A renamed row reaches the same fill through the destination fallback + // (fillRecordOfArrays), which must claim registry keys case-insensitively too. + const customNamed: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + modelReasoningEfforts: { "Claude-Opus-5": [] }, + }; + enrichProviderFromRegistry("my-claude", customNamed); + expect(customNamed.modelReasoningEfforts).not.toHaveProperty("claude-opus-5"); + expect(customNamed.modelReasoningEfforts?.["Claude-Opus-5"]).toEqual([]); + }); + test("the Anthropic ladder omits rungs the adapter cannot honor distinctly", () => { const anthropicOauth = PROVIDER_REGISTRY.find(entry => entry.id === "anthropic"); for (const efforts of Object.values(anthropicOauth?.modelReasoningEfforts ?? {})) { diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index 99ecc9411bc..6344aedcec4 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -13,6 +13,7 @@ import { clampObservedModelLimits, resolveModelPolicy, } from "../../src/providers/resolved-model-policy"; +import { modelRecordValue } from "../../src/reasoning-effort"; const MODEL = "vendor/model-a"; @@ -796,4 +797,62 @@ describe("resolved static model policy parity", () => { expect(JSON.stringify(policy)).not.toContain("secret-value"); expect(JSON.stringify(policy)).not.toContain("account-like-private-id"); }); + + test("case-varied modelReasoningEffortMap override claims the registry row", () => { + const entry = registry({ + modelReasoningEffortMap: { [MODEL]: { low: "registry-low", xhigh: "registry-xhigh" } }, + }); + const configured = provider({ + modelReasoningEffortMap: { "VENDOR/Model-A": { xhigh: "custom" } }, + }); + const policy = resolve(configured, entry); + const map = policy.provider.modelReasoningEffortMap; + expect(map).not.toHaveProperty(MODEL); + expect(map?.["VENDOR/Model-A"]).toEqual({ low: "registry-low", xhigh: "custom" }); + // The folded runtime lookup must resolve the operator row, not a registry-spelled shadow. + expect(modelRecordValue(map, MODEL)).toEqual({ low: "registry-low", xhigh: "custom" }); + }); + + test("case-varied operator key reports operator provenance for the folded model id", () => { + const entry = registry({ + modelContextWindows: { "claude-opus-5": 200_000 }, + }); + const configured = provider({ + modelContextWindows: { "Claude-Opus-5": 150_000 }, + }); + const policy = resolveModelPolicy({ + providerName: "anthropic", + modelId: "claude-opus-5", + provider: configured, + registryEntry: entry, + transportMatchedRegistry: true, + }); + expect(policy.model.contextWindow).toBe(150_000); + expect(policy.provenance.model.contextWindow).toBe("operator"); + }); + + test.each([50_000, 150_000])("case-varied API-key caps retain the lower limit for operator cap %i", (cap) => { + const operatorKey = "VENDOR/Model-A"; + const entry = registry({ id: "openai-apikey" }); + const configured = provider({ + modelContextWindows: { [operatorKey]: cap }, + modelMaxInputTokens: { [operatorKey]: cap - 10_000 }, + }); + for (const modelId of [MODEL, operatorKey, "Vendor/model-a"]) { + const policy = resolveModelPolicy({ + providerName: "openai-apikey", modelId, provider: configured, + registryEntry: entry, transportMatchedRegistry: true, + }); + expect(policy.model.contextWindow).toBe(Math.min(100_000, cap)); + expect(policy.model.maxInputTokens).toBe(Math.min(80_000, cap - 10_000)); + expect(policy.provider.modelContextWindows).not.toHaveProperty(MODEL); + expect(policy.provider.modelMaxInputTokens).not.toHaveProperty(MODEL); + // Provenance names the owner of the merged row, including a registry-clamped override. + expect(policy.provenance.model.contextWindow).toBe("operator"); + expect(policy.provenance.model.maxInputTokens).toBe("operator"); + expect(policy.provider.modelContextWindows?.["registry-only"]).toBe(90_000); + } + expect(configured.modelContextWindows).toEqual({ [operatorKey]: cap }); + expect(entry.modelContextWindows?.[MODEL]).toBe(100_000); + }); }); diff --git a/tests/providers/vision-classification-seed-repair.test.ts b/tests/providers/vision-classification-seed-repair.test.ts index 88cea682c9e..642eeb2f357 100644 --- a/tests/providers/vision-classification-seed-repair.test.ts +++ b/tests/providers/vision-classification-seed-repair.test.ts @@ -1,158 +1,43 @@ -/** - * The saved-config half of the OpenCode Go DeepSeek reclassification. - * - * `enrichProviderFromRegistry` is fill-only and asymmetric: `noVisionModels` is filled - * all-or-nothing and `modelInputModalities` is filled per-key BENEATH the saved value. A config - * saved while the registry called `deepseek-v4.1-flash` text-only therefore keeps BOTH halves of - * that claim forever, and images are stripped for a route that reads them (probed 2026-09-19). - * Correcting the registry alone fixes new installs only. - */ import { describe, expect, test } from "bun:test"; -import { - projectStaleVisionClassifications, - STALE_VISION_CLASSIFICATIONS, -} from "../../src/providers/stale-vision-classification-migration"; -import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { projectStartupConfigRepairs } from "../../src/providers/model-rename-startup"; import { requiresVisionPreprocessing } from "../../src/vision/plan"; import type { OcxConfig } from "../../src/types"; const MODEL = "deepseek-v4.1-flash"; -const SIBLING = "deepseek-v4-flash"; -/** A config saved while the stale seed was current. */ -function staleConfig( - modalities: Record = { [MODEL]: ["text"], [SIBLING]: ["text"] }, - noVisionModels: string[] = [MODEL, SIBLING], - adapter = "openai-chat", - baseUrl = "https://opencode.ai/zen/go/v1", -): OcxConfig { +function configuredPolicy(modalities: string[], noVisionModels: string[]): OcxConfig { return { providers: { - "opencode-go": { adapter, baseUrl, modelInputModalities: { ...modalities }, noVisionModels: [...noVisionModels] }, + "opencode-go": { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + modelInputModalities: { [MODEL]: modalities }, + noVisionModels, + }, }, - } as unknown as OcxConfig; + } as OcxConfig; } -describe("stale vision classification migration", () => { - test("repairs both halves of the stale claim", () => { - // Modalities alone would not be enough: the sidecar predicate checks noVisionModels FIRST and - // short-circuits, so a row left in that list stays text-only however it is declared. - const config = staleConfig(); - const projection = projectStaleVisionClassifications(config); - expect(projection.changed).toBe(true); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![MODEL]).toEqual(["text", "image"]); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).not.toContain(MODEL); - expect(projection.warnings.join(" ")).toContain(MODEL); - }); - - test("leaves the sibling route classified text-only", () => { - // deepseek-v4-flash still answers HTTP 400 "Model only supports text input" on this gateway. - // A migration that widened the whole list would strip a real protection. - const projection = projectStaleVisionClassifications(staleConfig()); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![SIBLING]).toEqual(["text"]); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).toContain(SIBLING); - }); - - test("leaves a modality value the operator chose alone", () => { - // The guard is an exact match on the stale declaration. Anything else is a deliberate - // override and outranks this migration. - const projection = projectStaleVisionClassifications( - staleConfig({ [MODEL]: ["text", "audio"], [SIBLING]: ["text"] }), - ); - expect(projection.changed).toBe(false); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![MODEL]).toEqual(["text", "audio"]); - }); - - test("finishes a half-repaired row whose name is still listed", () => { - // The sidecar predicate reads noVisionModels BEFORE the modality list, so a row whose - // modalities were already corrected but whose name is still listed keeps stripping images. - // Leaving it alone was the gap the maintainer review found on #5164. - const projection = projectStaleVisionClassifications( - staleConfig({ [MODEL]: ["text", "image"], [SIBLING]: ["text"] }), - ); - expect(projection.changed).toBe(true); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![MODEL]).toEqual(["text", "image"]); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).not.toContain(MODEL); - // The sibling keeps both halves: its modalities are not the migrated value, so nothing fires. - expect(projection.config.providers!["opencode-go"]!.noVisionModels).toContain(SIBLING); - expect(projection.warnings.join(" ")).toContain("dropped from noVisionModels"); - }); - - test("leaves a listed name without a readable modality declaration alone", () => { - // Ambiguous on purpose: a half-finished repair and an entry the operator added by hand look - // identical without the paired value, so the projection does not guess. Flagged to the - // maintainer as an open question rather than decided here. - const config = staleConfig({ [SIBLING]: ["text"] }); - const projection = projectStaleVisionClassifications(config); - expect(projection.changed).toBe(false); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).toContain(MODEL); - }); +describe("startup vision classification preservation", () => { + test("preserves an explicit text-only policy", () => { + const config = configuredPolicy(["text"], [MODEL]); + const projection = projectStartupConfigRepairs(config); + const provider = projection.config.providers["opencode-go"]!; - test("never writes the dedicated modelCapabilities axis", () => { - // `modelCapabilities` outranks every source this projection touches, so it is where a - // deliberate text-only override survives a restart. A repair that also rewrote it would make - // the operator's own `--text-only` decision unrecoverable. - const config = staleConfig(); - config.providers!["opencode-go"]!.modelCapabilities = { [MODEL]: { inputModalities: ["text"] } }; - const projection = projectStaleVisionClassifications(config); - expect(projection.config.providers!["opencode-go"]!.modelCapabilities![MODEL]!.inputModalities).toEqual(["text"]); - // The axis only matters because it is read FIRST on the request path: the repair rewrites the - // modality list and drops the name from `noVisionModels` around it, and the operator's - // declaration still routes the image through the vision sidecar rather than to the model. - const row = projection.config.providers!["opencode-go"]!; - expect(row.modelInputModalities![MODEL]).toEqual(["text", "image"]); - expect(row.noVisionModels ?? []).not.toContain(MODEL); - expect(requiresVisionPreprocessing({ providers: { "opencode-go": row } }, row, MODEL, "opencode-go")).toBe(true); - }); - - test("skips a row that no longer carries the registry adapter", () => { - const projection = projectStaleVisionClassifications(staleConfig(undefined, undefined, "anthropic")); expect(projection.changed).toBe(false); + expect(provider.modelInputModalities?.[MODEL]).toEqual(["text"]); + expect(provider.noVisionModels).toContain(MODEL); + expect(requiresVisionPreprocessing(projection.config, provider, MODEL, "opencode-go")).toBe(true); }); - test("follows the registry's destination rule where a preset opts into it", () => { - // baseten is a key preset with preserveCustomDestination, so the registry owns a same-named row - // only while it still points at the registry destination — the rule enrichProviderFromRegistry - // applies before it writes registry metadata. Claiming such a row by name alone would rewrite - // capability for an endpoint the registry does not describe. - const entry = { provider: "baseten", model: MODEL, fromModalities: ["text"], toModalities: ["text", "image"] }; - const atRegistry = { - adapter: "openai-chat", - baseUrl: "https://inference.baseten.co/v1", - modelInputModalities: { [MODEL]: ["text"] }, - noVisionModels: [MODEL], - }; - const atOwnHost = { ...atRegistry, baseUrl: "https://operator-gateway.example/v1" }; - const config = (row: typeof atRegistry): OcxConfig => ({ providers: { baseten: row } } as unknown as OcxConfig); - expect(projectStaleVisionClassifications(config(atRegistry), [entry]).changed).toBe(true); - expect(projectStaleVisionClassifications(config(atOwnHost), [entry]).changed).toBe(false); - }); + test("preserves an explicit noVisionModels policy beside native modalities", () => { + const config = configuredPolicy(["text", "image"], [MODEL]); + const projection = projectStartupConfigRepairs(config); + const provider = projection.config.providers["opencode-go"]!; - test("still repairs a pinned preset row at any destination", () => { - // opencode-go is an existing key preset without preserveCustomDestination: the registry claims - // that id itself, and enrichment fills its seed into such a row for the same reason. The - // projection follows that policy instead of inventing a narrower one of its own. - const projection = projectStaleVisionClassifications( - staleConfig(undefined, undefined, "openai-chat", "https://operator-gateway.example/v1"), - ); - expect(projection.changed).toBe(true); - }); - - test("is a no-op on a config without the provider", () => { - const projection = projectStaleVisionClassifications({ providers: {} } as unknown as OcxConfig); expect(projection.changed).toBe(false); - expect(projection.warnings).toEqual([]); - }); - - test("every entry names a real correction the registry now carries", () => { - // Guards against an entry that repairs a value the registry never claimed, or one whose - // target the registry does not declare — either would be a silent no-op forever. - for (const entry of STALE_VISION_CLASSIFICATIONS) { - const registry = PROVIDER_REGISTRY.find(row => row.id === entry.provider); - expect(registry, entry.provider).toBeDefined(); - expect(registry?.modelInputModalities?.[entry.model], entry.model).toEqual(entry.toModalities); - expect(registry?.noVisionModels ?? [], entry.model).not.toContain(entry.model); - expect(entry.fromModalities).not.toEqual(entry.toModalities); - } + expect(provider.modelInputModalities?.[MODEL]).toEqual(["text", "image"]); + expect(provider.noVisionModels).toContain(MODEL); + expect(requiresVisionPreprocessing(projection.config, provider, MODEL, "opencode-go")).toBe(true); }); }); diff --git a/tests/server/management-provider-verbosity.test.ts b/tests/server/management-provider-verbosity.test.ts new file mode 100644 index 00000000000..75f8e65deea --- /dev/null +++ b/tests/server/management-provider-verbosity.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { managementFetch as fetch } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("provider management validates verbosity records without replacing valid config", async () => { + const testDir = mkdtempSync(join(tmpdir(), "ocx-provider-verbosity-")); + const previousHome = process.env.OPENCODEX_HOME; + const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; + const isolatedCodexHome = installIsolatedCodexHome("ocx-provider-verbosity-codex-"); + process.env.OPENCODEX_HOME = testDir; + let server: ReturnType | undefined; + try { + saveConfig({ + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + server = startServer(0); + const acceptedVerbosityCapability = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-verbosity-capability", + provider: { + adapter: "openai-responses", + baseUrl: "https://api.example.test/v1", + modelSupportsVerbosity: { terse: false }, + }, + }), + }); + expect(acceptedVerbosityCapability.status).toBe(200); + for (const invalid of [[], { terse: "false" }, { "": false }]) { + const rejected = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-verbosity-capability", + provider: { + adapter: "openai-responses", + baseUrl: "https://api.example.test/v1", + modelSupportsVerbosity: invalid, + }, + }), + }); + expect(rejected.status).toBe(400); + } + expect(loadConfig().providers["custom-verbosity-capability"].modelSupportsVerbosity).toEqual({ terse: false }); + } finally { + await server?.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + isolatedCodexHome.restore(); + removeTreeWithRetry(testDir); + } +}, 60_000);