diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index 1fcc91cfec6..ee37d9ef37b 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -88,9 +88,12 @@ les échecs de génération postérieurs à l'envoi des en-têtes sont transmis Lorsqu'un modèle routé figure dans le `noVisionModels` de son fournisseur — ou est déclaré texte seul pour ce modèle via `modelInputModalities` — et qu'une requête porte une image, opencodex décrit chaque image **avant** l'appel principal et la remplace par du texte, à condition qu'un plan de sidecar vision soit disponible. Sans plan disponible, l'image brute -est supprimée au lieu d'être transmise à un backend texte seul. Le catalogue de modèles annonce l'entrée image pour chaque -modèle couvert par le sidecar. Les combos annoncent l'entrée image seulement lorsque chaque membre accepte les images, -nativement ou via un sidecar, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme +est supprimée au lieu d'être transmise à un backend texte seul. Avec `visionSidecar.enabled: false`, le catalogue de modèles +n'ajoute pas d'entrée image fournie par le sidecar aux modèles texte seuls, y compris les entrées personnalisées et les +membres des combos. La prise en charge native des images et les modalités image explicitement configurées sont conservées. +Si `enabled` est omis, l'annonce des capacités du sidecar reste activée ; la présence dans le catalogue ne garantit pas +que les identifiants ou la capacité du sidecar sont disponibles. Les combos annoncent l'entrée image seulement lorsque +chaque membre accepte les images, nativement ou via un sidecar activé, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme l'application Codex autorisent les pièces jointes au lieu de les bloquer avant l'exécution du sidecar. Lorsque `visionSidecar.model` est absent ou vide, le chemin d'exécution OpenAI, le tableau de bord et l'API de gestion utilisent le modèle de repli `gpt-5.6-luna`. Au démarrage, une ancienne valeur `gpt-5.6-luna` explicitement enregistrée @@ -164,6 +167,8 @@ le délai d'attente et la limite précédemment choisis. les clés omises inchangées. `timeoutMs` utilise les limites entières de l'environnement d'exécution (1–2147483647 ms). +Un changement de l'état d'activation effectif de Vision via `PUT /api/sidecar-settings` actualise le catalogue et le cache gérés après l'enregistrement ; `catalogRefreshPending=true` signale que cette actualisation n'a pas abouti et ne constitue pas un accusé de réception du client. + La carte du service auxiliaire de recherche web reprend la même forme de contrôle : la première ligne du sélecteur de modèle est **Désactivé (Off)**. La désactivation arrête l'interception de `web_search` par OpenCodex et l'intégration Codex écrit `web_search = "disabled"` dans diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 3d35989872b..4bed23d7922 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -141,7 +141,11 @@ failures after response headers have started are delivered as `response.failed` 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 +With `visionSidecar.enabled: false`, the model catalog does not add sidecar-backed image input to +text-only models, including custom rows and combo members. Native image support and explicitly +configured image modalities are preserved. Omitting `enabled` keeps sidecar advertisement on; +catalog visibility does not establish that sidecar credentials or capacity are available. +Combos advertise image input only when every member accepts images, either natively or through an enabled 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 `visionSidecar.model` is absent or blank, the OpenAI execution path, Dashboard, and management API @@ -222,6 +226,10 @@ timeout, and limit. omitted keys unchanged. `timeoutMs` uses the runtime integer bounds (1–2147483647 ms). +When the effective Vision switch changes, saving also refreshes the managed Codex catalog and +model cache. The response reports `catalogRefreshPending: true` if that refresh did not complete; +the saved switch is retained, and catalog publication does not confirm that a running client has reloaded it. + The web-search sidecar card carries the same control shape: the model picker's first row is **Off**. Off does two things, and the second one is the reason the row exists. OpenCodex stops intercepting `web_search`, and the Codex integration writes Codex's own diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 4d623877c98..0ffc670f9a8 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -72,8 +72,11 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は ルーティングモデルが該当プロバイダーの `noVisionModels` にある、またはそのモデルが `modelInputModalities` でテキスト専用と宣言され、リクエストに画像が来る場合、opencodex は利用可能な ビジョンサイドカー計画があるときに限り、メイン呼び出し**前に**各画像を説明したテキストに差し替えます。 -計画が利用できない場合は、生の画像をテキスト専用バックエンドへ転送せず削除します。モデルカタログは -サイドカー対象の各モデルに画像入力を広告します。コンボは、すべてのメンバーがネイティブまたはサイドカーを +計画が利用できない場合は、生の画像をテキスト専用バックエンドへ転送せず削除します。`visionSidecar.enabled: false` の場合、 +モデルカタログはカスタムの項目やコンボのメンバーを含むテキスト専用モデルに、サイドカーによる画像入力を追加しません。 +ネイティブの画像対応と明示的に設定された画像モダリティは維持されます。`enabled` を省略した場合、サイドカーによる +画像入力の広告は引き続き有効です。カタログに表示されることは、サイドカーの認証情報や処理容量が利用可能であることを +保証しません。コンボは、すべてのメンバーがネイティブまたは有効化されたサイドカーを 通じて画像を受け入れ、かつコンボの `imageInput` 設定が無効でない場合にのみ画像入力を広告します。これにより Codex アプリなどのクライアントは、サイドカー実行前に添付をブロックせず許可できます。`visionSidecar.model` が未設定または空の場合、 OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` をフォールバックとして使います。起動時には @@ -137,6 +140,8 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` を `PUT /api/sidecar-settings` は同じフィールドを受け付けます。部分更新では省略したキーをそのまま残します。`timeoutMs` はランタイムの整数範囲(1–2147483647 ms)を使います。 +`PUT /api/sidecar-settings` によりビジョンの実効的なオン/オフ状態が変わると、保存後に管理対象のカタログとキャッシュが更新されますが、`catalogRefreshPending=true` は更新が完了しなかったことを示し、クライアントによる受領確認を意味しません。 + Web 検索サイドカーのカードも同じ構成です。モデルピッカーの先頭行が **オフ (Off)** です。オフにすると OpenCodex は `web_search` への介入をやめ、Codex 統合は `~/.codex/config.toml` に `web_search = "disabled"` を書き込みます。Codex は自身のモードがそうなるまでネイティブの diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index b1aca7ee1b1..9716fc27937 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -74,8 +74,11 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 라우팅 모델이 해당 프로바이더의 `noVisionModels`에 있거나 해당 모델이 `modelInputModalities`에서 텍스트 전용으로 선언되어 있고 요청에 이미지가 들어오면, opencodex는 사용 가능한 비전 사이드카 계획이 있을 때에만 메인 호출 **전에** 각 이미지를 설명한 텍스트로 바꿉니다. 사용 가능한 계획이 없으면 원본 이미지는 텍스트 전용 -백엔드로 전달되지 않고 제거됩니다. 모델 카탈로그는 사이드카로 처리되는 모든 모델에 image input을 알립니다. -콤보는 모든 멤버가 네이티브로 또는 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은 +백엔드로 전달되지 않고 제거됩니다. `visionSidecar.enabled: false`이면 모델 카탈로그는 사용자 정의 모델과 +콤보 멤버를 포함한 텍스트 전용 모델에 사이드카 기반 이미지 입력을 추가하지 않습니다. 네이티브 이미지 지원과 +명시적으로 설정한 이미지 입력은 유지됩니다. `enabled`를 생략하면 사이드카 기반 표시는 켜진 상태이며, +카탈로그 표시는 사이드카의 인증 정보나 사용 가능 용량을 보장하지 않습니다. +콤보는 모든 멤버가 네이티브로 또는 활성화된 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은 경우에만 image input을 알립니다. 따라서 Codex 앱 같은 클라이언트는 사이드카가 실행되기 전에 첨부를 차단하지 않고 허용합니다. `visionSidecar.model`이 없거나 빈 값이면 OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 사용합니다. 시작 시 명시적으로 @@ -139,6 +142,10 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 `PUT /api/sidecar-settings`는 같은 필드를 받습니다. 부분 업데이트는 보내지 않은 키를 유지합니다. `timeoutMs`는 런타임 정수 범위(1–2147483647 ms)를 사용합니다. +비전의 실제 활성화 상태가 바뀌면 저장 후 관리되는 Codex 카탈로그와 모델 캐시도 갱신합니다. +갱신을 완료하지 못하면 응답의 `catalogRefreshPending`이 `true`가 되며 저장된 설정은 유지됩니다. +카탈로그 갱신 완료가 실행 중인 클라이언트의 다시 읽기를 확인하는 것은 아닙니다. + 웹 검색 사이드카 카드도 같은 구성입니다. 모델 선택기의 첫 행은 **끔 (Off)** 행입니다. 끄면 OpenCodex가 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/config.toml`에 `web_search = "disabled"`를 씁니다. Codex는 자체 모드가 그렇게 될 때까지 네이티브 호스팅 diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 337c69062cc..57db4fe1022 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -84,9 +84,13 @@ SSE-событие `response.failed`. `modelInputModalities` явно указана только текстовая модальность — и запрос содержит изображение, opencodex описывает каждое изображение **до** основного вызова и заменяет его текстом, если доступен план vision-сайдкара. Без доступного плана исходное изображение удаляется, а не передаётся текстовому -бэкенду. Каталог моделей объявляет вход изображений для каждой модели, покрытой сайдкаром. Комбо -объявляют вход изображений только если каждый участник принимает изображения нативно или через -сайдкар и параметр комбо `imageInput` не отключён; поэтому такие клиенты, как приложение Codex, +бэкенду. При `visionSidecar.enabled: false` каталог моделей не добавляет поддержку ввода изображений +через сайдкар текстовым моделям, включая пользовательские записи и участников комбо. Нативная +поддержка изображений и явно настроенные модальности изображений сохраняются. Если `enabled` +не указан, объявление поддержки через сайдкар остаётся включённым; наличие в каталоге не подтверждает +доступность учётных данных или ресурсов сайдкара. Комбо объявляют вход изображений только если +каждый участник принимает изображения нативно или через включённый сайдкар и параметр комбо +`imageInput` не отключён; поэтому такие клиенты, как приложение Codex, разрешают вложения вместо их блокировки до запуска сайдкара. Если `visionSidecar.model` отсутствует или пуст, путь выполнения OpenAI, дашборд и API управления используют фолбэк `gpt-5.6-luna`. При запуске явно сохранённое устаревшее значение `gpt-5.4-mini` по-прежнему мигрирует на `gpt-5.6-luna`; миграция применяется только к сохранённому @@ -155,6 +159,8 @@ opencodex описывает каждое изображение **до** осн `PUT /api/sidecar-settings` принимает те же поля. Частичное обновление оставляет непереданные ключи без изменений. `timeoutMs` использует целочисленные границы рантайма (1–2147483647 мс). +Если через `PUT /api/sidecar-settings` меняется действующее состояние переключателя Vision, после сохранения обновляются управляемые каталог и кэш; `catalogRefreshPending=true` сообщает, что обновление не завершилось, и не служит подтверждением от клиента. + Карточка сайдкара web-search устроена так же: первая строка выбора модели — **Выкл. (Off)**. Выключение останавливает перехват `web_search` со стороны OpenCodex, а интеграция Codex записывает `web_search = "disabled"` в `~/.codex/config.toml`: до этого Codex diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index fdd53a327ac..a52dab0127f 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -107,9 +107,13 @@ Yönlendirilen model sağlayıcısının `noVisionModels` listesinde yer aldığ bu model için `modelInputModalities` ile salt metin olarak bildirildiğinde ve bir istek görsel taşıdığında, opencodex kullanılabilir bir vision sidecar planı varsa ana çağrıdan **önce** her görseli açıklar ve onu metinle değiştirir. Kullanılabilir -bir plan yoksa ham görsel salt metin arka ucuna iletilmek yerine kaldırılır. Model -kataloğu sidecar kapsamında olan her model için görsel girdisini bildirir. Kombolar, -her üye görselleri yerel olarak veya bir sidecar üzerinden kabul ettiğinde ve kombonun +bir plan yoksa ham görsel salt metin arka ucuna iletilmek yerine kaldırılır. +`visionSidecar.enabled: false` olduğunda model kataloğu, özel kayıtlar ve kombo üyeleri +dahil salt metin modellere sidecar destekli görsel girdisi eklemez. Yerel görsel desteği +ve açıkça yapılandırılmış görsel modaliteleri korunur. `enabled` belirtilmezse sidecar +destekli görsel girdisinin bildirilmesi açık kalır; katalogda görünmesi, sidecar kimlik +bilgilerinin veya kapasitesinin kullanılabilir olduğunu doğrulamaz. Kombolar, +her üye görselleri yerel olarak veya etkinleştirilmiş bir sidecar üzerinden kabul ettiğinde ve kombonun `imageInput` ayarı devre dışı olmadığında görsel girdisini bildirir; böylece Codex uygulaması gibi istemciler, sidecar çalışmadan önce ekleri engellemek yerine kabul eder. `visionSidecar.model` olmadığında veya boş @@ -196,6 +200,8 @@ akıl yürütmeyi, zaman aşımını ve sınırı korur. atlanan anahtarları değiştirmeden bırakır. `timeoutMs` çalışma zamanı tamsayı sınırlarını kullanır (1–2147483647 ms). +Vizyon'un geçerli açma/kapatma durumunu `PUT /api/sidecar-settings` üzerinden değiştirmek, kayıttan sonra yönetilen kataloğu ve önbelleği yeniler; `catalogRefreshPending=true`, yenilemenin tamamlanmadığını bildirir ve istemci onayı anlamına gelmez. + Web arama sidecar kartı aynı denetim yapısını taşır: model seçicisinin ilk satırı **Kapalı (Off)**'dır. Kapatmak OpenCodex'in `web_search` yakalamasını durdurur ve Codex entegrasyonu `~/.codex/config.toml` dosyasına `web_search = "disabled"` yazar; çünkü Codex kendi 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 15605d2dcf9..b959040db09 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -66,8 +66,10 @@ OAuth 账户时使用 `anthropic`,否则使用 `openai`。显式选择 `anthro 当路由模型列在其 provider 的 `noVisionModels` 中,或该模型在 `modelInputModalities` 中被声明为仅文本, 并且请求包含图像时,只要有可用的 vision sidecar plan,opencodex 就会在主调用**之前**描述每张图像并用文字替换图像。 -如果没有可用 plan,原始图像会被移除,而不会继续转发给纯文本后端。模型目录会为每个由 sidecar 覆盖的模型声明图像输入。 -只有当每个 combo 成员都能原生或通过 sidecar 接受图像、且 combo 的 `imageInput` 设置未禁用时,combo 才会声明图像输入; +如果没有可用 plan,原始图像会被移除,而不会继续转发给纯文本后端。当 `visionSidecar.enabled: false` 时, +模型目录不会为纯文本模型添加由 sidecar 提供的图像输入,包括自定义模型和 combo 成员。原生图像支持和显式配置的 +图像输入会保留。省略 `enabled` 时,默认仍会声明 sidecar 图像支持;目录中的声明不保证 sidecar 凭据或容量可用。 +只有当每个 combo 成员都能原生或通过已启用的 sidecar 接受图像、且 combo 的 `imageInput` 设置未禁用时,combo 才会声明图像输入; 这样 Codex 应用等客户端会允许附件,而不会在 sidecar 运行前阻止它们。当 `visionSidecar.model` 缺失或为空时,OpenAI 执行路径、 Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会把明确保存的旧 `gpt-5.4-mini` 值迁移到 `gpt-5.6-luna`;该迁移只作用于已保存值,不适用于缺失的 model 字段。 @@ -126,6 +128,10 @@ Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会 `PUT /api/sidecar-settings` 接受相同字段。部分更新会保留未提交的键。`timeoutMs` 使用运行时整数边界(1–2147483647 毫秒)。 +Vision 的实际启用状态改变时,保存后也会刷新受管理的 Codex 目录和模型缓存。 +若刷新未完成,响应会返回 `catalogRefreshPending: true`,已保存的开关设置仍然保留。 +目录发布成功并不表示运行中的客户端已重新加载目录。 + Web 搜索 sidecar 卡片采用相同的控件形态:模型选择器的第一行是 **关闭 (Off)**。关闭会停止 OpenCodex 对 `web_search` 的拦截,同时 Codex 集成会把 `web_search = "disabled"` 写入 `~/.codex/config.toml`;因为 Codex 在自身模式如此声明前会一直声明其原生托管的 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 fae3319f226..bc200704d04 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -66,8 +66,10 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro 當路由模型列在其 provider 的 `noVisionModels` 中,或該模型在 `modelInputModalities` 中被宣告為僅文字, 且請求包含圖像時,只要有可用的 vision sidecar plan,opencodex 就會在主呼叫**之前**描述每張圖像並用文字替換圖像。 -若沒有可用 plan,原始圖像會被移除,不會繼續轉送給純文字後端。模型目錄會為每個由 sidecar 處理的模型宣告圖像輸入。 -只有當每個 combo 成員都能原生或透過 sidecar 接受圖像,且 combo 的 `imageInput` 設定未停用時,combo 才會宣告圖像輸入; +若沒有可用 plan,原始圖像會被移除,不會繼續轉送給純文字後端。當 `visionSidecar.enabled: false` 時, +模型目錄不會為純文字模型新增由 sidecar 提供的圖像輸入,包括自訂項目與 combo 成員。原生圖像支援及明確設定的圖像模態會保留。 +省略 `enabled` 時,仍會宣告 sidecar 提供的圖像輸入;目錄中顯示這項能力不代表已確認 sidecar 的憑證或處理容量可用。 +只有當每個 combo 成員都能原生或透過已啟用的 sidecar 接受圖像,且 combo 的 `imageInput` 設定未停用時,combo 才會宣告圖像輸入; 如此 Codex 應用程式等用戶端會允許附件,而不會在 sidecar 執行前阻擋它們。Dashboard 和管理 API 目前顯示的預設值是 `gpt-5.6-luna`,啟動時也會把明確儲存的舊 `gpt-5.4-mini` 值遷移到 Luna。只有在 `visionSidecar.model` 欄位不存在或為空字串時,vision 執行路徑才會使用程式碼中的 `gpt-5.6-luna` 回退值。 @@ -121,6 +123,8 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro `PUT /api/sidecar-settings` 接受相同欄位。部分更新會保留未提交的鍵。`timeoutMs` 使用執行時整數邊界(1–2147483647 毫秒)。 +透過 `PUT /api/sidecar-settings` 變更 Vision 開關的實際生效狀態後,儲存完成便會重新整理受管理的目錄與快取;`catalogRefreshPending=true` 表示重新整理未完成,並非用戶端的確認回覆。 + Web 搜尋 sidecar 卡片採用相同的控制項形態:模型選擇器的第一列是 **關閉 (Off)**。關閉會停止 OpenCodex 對 `web_search` 的攔截,同時 Codex 整合會把 `web_search = "disabled"` 寫入 `~/.codex/config.toml`;因為 Codex 在自身模式如此宣告前會一直宣告其原生託管的 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..94b8321ffac 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -289,6 +289,7 @@ "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", + "catalog-vision-sidecar-disabled.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", @@ -1642,6 +1643,7 @@ "sidecar-abort.test.ts": "vision", "sidecar-auth.test.ts": "vision", "sidecar-candidates.test.ts": "vision", + "sidecar-settings-vision-catalog.test.ts": "vision", "sidecar-settings-vision-controls.test.ts": "vision", "sidecar-settings-vision-filter.test.ts": "vision", "sidecar-settings-web-search-gate.test.ts": "vision", diff --git a/src/codex/catalog/combo-member.ts b/src/codex/catalog/combo-member.ts index 1daf8bad13d..75adbb54b2d 100644 --- a/src/codex/catalog/combo-member.ts +++ b/src/codex/catalog/combo-member.ts @@ -100,7 +100,7 @@ import type { CatalogSourceEvidence, CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, positiveSafeInteger } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, positiveSafeInteger, withVisionSidecarInput } from "./model-hints"; /** Model ids each provider must retain for combo catalog derivation (OCX-111). */ export function configuredComboTargetModelsByProvider( @@ -219,6 +219,7 @@ export function resolveComboCatalogMember( callerFallback?: ComboCatalogMemberFallback, metadataModelIdCaseFold?: boolean, metadataConfigDir?: string, + visionSidecarEnabled = true, ): CatalogModel | undefined { const existing = memberByKey.get(targetKey(target)); const prov = providers.get(target.provider); @@ -227,6 +228,16 @@ export function resolveComboCatalogMember( // is unusable for catalog derivation while the provider is off. if (prov?.disabled === true) return undefined; + /** Preserve custom modality overrides while filling missing provider metadata. */ + const withProviderHints = (model: CatalogModel): CatalogModel => { + const hinted = prov + ? applyProviderConfigHints(target.provider, prov, model, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, visionSidecarEnabled) + : model; + return model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND && model.inputModalities !== undefined + ? withVisionSidecarInput({ ...hinted, inputModalities: [...model.inputModalities] }, prov, visionSidecarEnabled) + : hinted; + }; + const withFallbackMetadata = (member: CatalogModel): CatalogModel => { const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 ? member.contextWindow @@ -276,7 +287,7 @@ export function resolveComboCatalogMember( // supplies a vision sidecar. Apply the same provider hints used for thin // rows before deriving a combo from this complete row. const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id) - ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir) + ? withProviderHints(existing) : existing; const capped = applyProviderContextCap(hinted.contextWindow, contextCap); if (capped === undefined || capped === existing.contextWindow) { @@ -298,9 +309,7 @@ export function resolveComboCatalogMember( id: target.model, provider: target.provider, }; - const hinted = prov - ? applyProviderConfigHints(target.provider, prov, base, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir) - : base; + const hinted = withProviderHints(base); const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 ? hinted.contextWindow : undefined; diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index 5ada699e336..5653a08e86c 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -564,6 +564,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco }; } +/** Coalesce only gathers with the same model inventory and effective sidecar visibility policy. */ export function gatherFlightKey(config: OcxConfig): string { const providers = Object.entries(config.providers) .filter(([, prov]) => prov.disabled !== true) @@ -571,6 +572,7 @@ export function gatherFlightKey(config: OcxConfig): string { .sort((a, b) => String(a.n).localeCompare(String(b.n))); const assembly = stableJson({ providers, + visionSidecarEnabled: config.visionSidecar?.enabled !== false, disabledModels: [...(config.disabledModels ?? [])].sort(), combos: config.combos ?? {}, customModels: (config.customModels ?? []).map((cm) => ({ diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index e0a78c7768e..6d9a13d060f 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -252,6 +252,17 @@ export function configuredReasoningSummarySupport(prov: OcxProviderConfig | unde return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined; } +/** Add attachment metadata at publication time, independently of cached provider evidence. */ +export function withVisionSidecarInput( + model: CatalogModel, + provider: OcxProviderConfig | undefined, + enabled = true, +): CatalogModel { + if (!enabled || !provider || !isModelVisionSidecarConsumer(provider, model.id)) return model; + const modalities = model.inputModalities ?? ["text"]; + return modalities.includes("image") ? model : { ...model, inputModalities: [...modalities, "image"] }; +} + /** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ export function applyProviderConfigHints( name: string, @@ -261,6 +272,7 @@ export function applyProviderConfigHints( metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, metadataConfigDir?: string, + includeVisionSidecar = true, ): CatalogModel { const staticPolicy = resolveModelPolicy({ providerName: name, @@ -289,18 +301,7 @@ export function applyProviderConfigHints( // Removing an exact capability row therefore returns this projection to legacy-map inference. const resolvedModalities = staticPolicy.model.inputModalities ?? model.inputModalities ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); - let inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; - // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time - // planning aligned. The catalog must still advertise image input — the Codex app - // gates attachments client-side on input_modalities, and a text-only entry would block images - // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived - // text-only rows stay untouched: the runtime predicate only reads these two config sources, so - // it would not convert those. - const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id); - if (sidecarCovered) { - const base = inputModalities ?? model.inputModalities ?? ["text"]; - inputModalities = base.includes("image") ? [...base] : [...base, "image"]; - } + const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) ?? (model.antigravityEffortWireModelIds !== undefined); @@ -337,7 +338,7 @@ export function applyProviderConfigHints( ?? azureWindow ?? inferredWindow?.contextWindow ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined); - const hinted = { + const hinted = withVisionSidecarInput({ ...modelWithoutServiceTier, ...(!model.contextWindowInference && previousContextCap !== undefined ? { contextCap: previousContextCap, contextCapped: previousContextCapped } : {}), @@ -368,7 +369,7 @@ export function applyProviderConfigHints( ? { parallelToolCalls: true } : {}), ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), - }; + }, prov, includeVisionSidecar); const capped = applyProviderContextCap(hinted.contextWindow, providerCap); const withCap = providerCap !== undefined ? capped !== hinted.contextWindow @@ -397,6 +398,7 @@ export function applyProviderConfigHints( }; } +/** Project a configured row, optionally deferring sidecar attachment metadata until publication. */ export function catalogHintsFromProviderConfig( name: string, prov: OcxProviderConfig, @@ -405,12 +407,14 @@ export function catalogHintsFromProviderConfig( metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, metadataConfigDir?: string, + includeVisionSidecar = true, ): Partial { - const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir); + const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir, includeVisionSidecar); const { provider: _provider, id: _id, ...hints } = hinted; return hints; } +/** Reapply current hints to cached rows without mutating their provider evidence. */ export function applyConfigHintsToCachedModels( name: string, prov: OcxProviderConfig, @@ -419,8 +423,9 @@ export function applyConfigHintsToCachedModels( metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, metadataConfigDir?: string, + includeVisionSidecar = true, ): CatalogModel[] { - return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir)); + return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir, includeVisionSidecar)); } /** Catalog slugs whose configured model must not gain a missing synthetic max rung. */ diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts index 0e92a019d4e..0f0cc2f6cc6 100644 --- a/src/codex/catalog/model-visibility.ts +++ b/src/codex/catalog/model-visibility.ts @@ -231,6 +231,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { retainComboTargets?: boolean; metadataModelIdCaseFold?: boolean; metadataConfigDir?: string; + includeVisionSidecar?: boolean; }): { models: CatalogModel[]; droppedConfiguredIds: string[] } { const { name, @@ -242,6 +243,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { retainComboTargets = true, metadataModelIdCaseFold, metadataConfigDir, + includeVisionSidecar, } = opts; const out = [...opts.models]; const present = new Set(out.map(model => model.id)); @@ -250,7 +252,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { if (present.has(candidate.id)) continue; const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); if (dated) { - out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir)); + out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, includeVisionSidecar)); present.add(candidate.id); continue; } diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index c2340cc6b62..a2d680ce29b 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -117,7 +117,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture"; -import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig } from "./model-hints"; +import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig, withVisionSidecarInput } from "./model-hints"; import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility"; import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture"; @@ -168,7 +168,7 @@ export function observedModelsAuthResolver( }, }; } -/** Project captured discovery through config hints and retention; Auto-only Copilot exposes only Auto. */ +/** Project provider evidence without synthetic sidecar image input, which belongs to each gather. */ export async function fetchProviderModelsWithAuth( captured: CapturedProviderGather, ttlMs: number, @@ -211,7 +211,7 @@ export async function fetchProviderModelsWithAuth( const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), })); const withConfiguredRetention = ( models: CatalogModel[], @@ -228,6 +228,7 @@ export async function fetchProviderModelsWithAuth( retainComboTargets: options?.retainComboTargets, metadataModelIdCaseFold, metadataConfigDir, + includeVisionSidecar: false, }); if ( options?.warnDrops === true @@ -260,7 +261,7 @@ export async function fetchProviderModelsWithAuth( } return observed(ids.map(id => { const hints = catalogHintsFromProviderConfig(name, prov, id, contextCap, - metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir); + metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false); const observedWindow = kiroObservedContextWindow(id); return { id, provider: name, ...hints, ...(observedWindow !== undefined @@ -296,7 +297,7 @@ export async function fetchProviderModelsWithAuth( model => applyProviderConfigHints(name, prov, { id: model.id, provider: name, ...catalogHintsFromModelsApiItem(name, model as ProviderModelsApiItem), - }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)); + }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, undefined, false)); if (!isCurrentCacheGeneration()) return observed(configured, "degraded"); markProviderDiscoveryOk(name, picker.length); // Retain callable manual/combo selectors, but do not advertise named selection @@ -326,7 +327,7 @@ export async function fetchProviderModelsWithAuth( : [{ id: prov.defaultModel, provider: name, - ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), }]; const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined; const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => ( @@ -348,13 +349,13 @@ export async function fetchProviderModelsWithAuth( const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (fresh) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "authoritative"); } const scopedStale = getStaleCached(name, authorityIdentity); if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && scopedStale) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "degraded"); } const live = await fetchCodeBuddyModels(profile, apiKey); @@ -362,7 +363,7 @@ export async function fetchProviderModelsWithAuth( const discovered = live.models.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), })); const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { @@ -378,7 +379,7 @@ export async function fetchProviderModelsWithAuth( } const stale = getStaleCached(name, authorityIdentity); return observed(withConfiguredRetention( - stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded"); } if (name === "zed" && prov.adapter === "zed") { @@ -391,14 +392,14 @@ export async function fetchProviderModelsWithAuth( const cached = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cached) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cached, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cached, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), "authoritative", ); } const stale = getStaleCached(name, authorityIdentity); if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && stale) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), "degraded", ); } @@ -418,7 +419,7 @@ export async function fetchProviderModelsWithAuth( ...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}), ...(model.supportsImages ? { inputModalities: ["text", "image"] } : {}), ...(reasoningEfforts?.length ? { reasoningEfforts } : {}), - ...catalogHintsFromProviderConfig(name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), } as CatalogModel; }); const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); @@ -433,7 +434,7 @@ export async function fetchProviderModelsWithAuth( markProviderDiscoveryFailed(name, { reason: "provider" }); } return observed( - withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured), + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured), "degraded", ); } @@ -449,13 +450,13 @@ export async function fetchProviderModelsWithAuth( const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (fresh) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "authoritative"); } const scopedStale = getStaleCached(name, authorityIdentity); if (isModelsFetchCoolingDown(name) && scopedStale) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "degraded"); } const live = await fetchQoderModels(profile, apiKey); @@ -463,7 +464,7 @@ export async function fetchProviderModelsWithAuth( const discovered = live.models.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), })); const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { @@ -479,7 +480,7 @@ export async function fetchProviderModelsWithAuth( } const stale = getStaleCached(name, authorityIdentity); return observed(withConfiguredRetention( - stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded"); } if (prov.adapter === "devin") { @@ -494,7 +495,7 @@ export async function fetchProviderModelsWithAuth( const cachedDevin = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cachedDevin) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin, undefined, undefined, undefined, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin, undefined, undefined, undefined, metadataConfigDir, false)), "authoritative", ); } @@ -502,7 +503,7 @@ export async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, undefined, undefined, metadataConfigDir) : configured, + cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, undefined, undefined, metadataConfigDir, false) : configured, ), "degraded", ); @@ -540,11 +541,11 @@ export async function fetchProviderModelsWithAuth( ...(liveResult.defaultEfforts[id] ? { defaultReasoningEffort: liveResult.defaultEfforts[id] } : {}), // The account catalog's per-base supportsImages vote collapses to one // modalities value. It spreads before the hints so exact - // modelCapabilities declarations, the legacy modelInputModalities - // record and the vision-sidecar rewrite keep winning — the live - // value survives only when none of them applies. + // modelCapabilities declarations and the legacy modelInputModalities + // record keep winning. Sidecar advertisement is added after discovery; + // the live value survives only when no explicit declaration applies. ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}), - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), } as CatalogModel; }); const forCache = withConfiguredRetention(result, { retainComboTargets: false }); @@ -560,7 +561,7 @@ export async function fetchProviderModelsWithAuth( } const stale = getStaleCached(name, authorityIdentity); return observed( - withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, undefined, undefined, undefined, metadataConfigDir) : configured), + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, undefined, undefined, undefined, metadataConfigDir, false) : configured), "degraded", ); } @@ -577,7 +578,7 @@ export async function fetchProviderModelsWithAuth( const cachedCursor = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cachedCursor) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), "authoritative", ); } @@ -585,7 +586,7 @@ export async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded", ); @@ -631,7 +632,7 @@ export async function fetchProviderModelsWithAuth( const staleCursor = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( - staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded", ); @@ -649,7 +650,7 @@ export async function fetchProviderModelsWithAuth( if (fresh) { return observed( withConfiguredRetention( - withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), ), "authoritative", ); // dedups Codex's frequent /v1/models polling within the TTL @@ -661,7 +662,7 @@ export async function fetchProviderModelsWithAuth( return observed( withConfiguredRetention( stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)) : failedDiscoveryConfigured, ), "degraded", @@ -706,7 +707,7 @@ export async function fetchProviderModelsWithAuth( return { models: withConfiguredRetention( stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)) : failedDiscoveryConfigured, ), fallback: stale ? "stale" : "configured", @@ -797,7 +798,7 @@ export async function fetchProviderModelsWithAuth( ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), })); - const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir); + const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false); const forCache = azureMetadata ? upstream : withConfiguredRetention(live, { retainComboTargets: false }); if (!isCurrentCacheGeneration()) { return observed(withConfiguredRetention(configured), "degraded"); @@ -878,7 +879,7 @@ export async function fetchProviderModelsWithAuth( }; }) .filter(m => shouldExposeProviderModel(name, m.id)); - const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir); + const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false); // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into // `live`; otherwise configured entries would be reported as discovered ones. const liveModelCount = live.length; @@ -925,6 +926,7 @@ export async function fetchProviderModelsWithAuth( } } +/** Preserve the provider-only API's default-enabled projection without widening cached evidence. */ export async function fetchProviderModels( name: string, prov: OcxProviderConfig, @@ -937,5 +939,5 @@ export async function fetchProviderModels( ttlMs, contextCap, refreshingModelsAuthResolver, - )).models; + )).models.map(model => withVisionSidecarInput(model, captured.provider)); } diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 790556a8c6b..99f7e64d3db 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -35,7 +35,6 @@ import { import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; -import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { @@ -103,7 +102,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; import { resolveComboCatalogMember } from "./combo-member"; import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture"; import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models"; @@ -330,6 +329,7 @@ async function gatherRoutedModelsUncached( const localProviderAuthOutcomes = capture.providerAuthOutcomes; const resolveAuth = capture.authResolver; const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; + const visionSidecarEnabled = config.visionSidecar?.enabled !== false; // Persisted provider entries can predate newer registry fields (noVisionModels, // modelInputModalities, ...). The ROUTER merges registry seeds at request time // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the @@ -337,6 +337,8 @@ async function gatherRoutedModelsUncached( // vision-sidecar model advertised text-only, blocking image attachments app-side). // Enrich a CLONE: hydrated defaults must never leak into the persisted config. const activeProviders = capture.providers; + // Use admission-time provider clones for sidecar advertisement, custom rows and combo members. + const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); const providerResults = await Promise.all( activeProviders.map(provider => fetchProviderModelsWithAuth( provider, @@ -378,7 +380,9 @@ async function gatherRoutedModelsUncached( config, metadataModelIdCaseFoldByProvider, metadataConfigDirByProvider, + false, ) + .map(model => withVisionSidecarInput(model, enrichedByName.get(model.provider), visionSidecarEnabled)) // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog // intentionally mirrors Cursor's public model table, including Gemini image preview, so the // exposure decision goes through shouldExposeRoutedModel (single choke point). @@ -482,9 +486,6 @@ async function gatherRoutedModelsUncached( ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}), }); } - // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and - // custom-model vision-sidecar inheritance so both see the same merged registry view. - const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); for (const id of listComboIds(config)) { const combo = getCombo(config, id); if (!combo) continue; @@ -521,6 +522,7 @@ async function gatherRoutedModelsUncached( nativeAliasFallback, metadataModelIdCaseFoldByProvider.get(target.provider), metadataConfigDirByProvider.get(target.provider), + visionSidecarEnabled, )) .filter((member): member is CatalogModel => member !== undefined); const derived = deriveComboCatalogModel(id, combo, members); @@ -714,7 +716,7 @@ async function gatherRoutedModelsUncached( const customContextHints = merged.contextWindow === undefined && effectiveProvider && canInferVendorContext(cm.provider, effectiveProvider) ? applyProviderConfigHints(cm.provider, effectiveProvider, { provider: cm.provider, id: cm.modelId }, providerContextCap(config, cm.provider), metadataModelIdCaseFoldByProvider.get(cm.provider), - undefined, metadataConfigDirByProvider.get(cm.provider)) + undefined, metadataConfigDirByProvider.get(cm.provider), false) : undefined; const contextResolved: CatalogModel = customContextHints?.contextWindow !== undefined ? { ...merged, contextWindow: customContextHints.contextWindow, @@ -762,13 +764,7 @@ async function gatherRoutedModelsUncached( : mergedWithHardBounds; const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints. - if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) { - const current = mergedWithAutoCompact.inputModalities ?? ["text"]; - if (!current.includes("image")) { - return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] }; - } - } - return mergedWithAutoCompact; + return withVisionSidecarInput(mergedWithAutoCompact, enrichedProvider, visionSidecarEnabled); }); // Custom rows override discovered rows that encode to the same Codex-facing slug. A provider whose // captured discovery declares an order (preferFirst) keeps each replacement in the discovered row's @@ -948,6 +944,7 @@ export function augmentRoutedModelsWithMetadata( caps?: Pick, metadataModelIdCaseFoldByProvider?: ReadonlyMap, metadataConfigDirByProvider?: ReadonlyMap, + includeVisionSidecar = true, ): CatalogModel[] { const out = [...models]; const indexByKey = new Map(out.map((model, index) => [`${model.provider}/${model.id}`, index])); @@ -977,6 +974,7 @@ export function augmentRoutedModelsWithMetadata( metadataModelIdCaseFoldByProvider?.get(provider), undefined, metadataConfigDirByProvider?.get(provider), + includeVisionSidecar, ) : seeded; continue; @@ -1001,6 +999,7 @@ export function augmentRoutedModelsWithMetadata( metadataModelIdCaseFoldByProvider?.get(provider), undefined, metadataConfigDirByProvider?.get(provider), + includeVisionSidecar, ) : {}), }); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ce6de3b2d52..a1c10ca4a25 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -353,6 +353,7 @@ function publicVisionSidecarSettings( }; } +/** Serve validated settings and publish catalog-affecting changes after persistence. */ export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; const readStartupHealth = deps.getCachedStartupHealth ?? getCachedStartupHealth; @@ -1058,6 +1059,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise [id, 200_000])), + }, + }, + }); +} + +/** Supply local model-list responses; no provider or user configuration is contacted. */ +function modelsResponse(): Response { + return Response.json({ data: ids.map(id => ({ id, input_modalities: ["text"] })) }); +} + +/** Compare published Codex modalities rather than only the intermediate gather rows. */ +function modalities(models: CatalogModel[], id: string): unknown { + return buildCatalogEntries(null, [], models) + .find(row => row.slug === `${providerName}/${id}`)?.input_modalities; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(providerName); +}); + +describe("catalog respects the disabled vision sidecar", () => { + test.each([false, true, undefined])("static rows use enabled=%s without widening native authority", async enabled => { + globalThis.fetch = (() => { throw new Error("static catalog must not fetch"); }) as typeof fetch; + const rows = await gatherRoutedModels(config(enabled)); + expect(modalities(rows, "declared-text")).toEqual(enabled === false ? ["text"] : ["text", "image"]); + expect(modalities(rows, "no-vision")).toEqual(enabled === false ? ["text"] : ["text", "image"]); + expect(modalities(rows, "vision-native")).toEqual(["text", "image"]); + expect(modalities(rows, "vision-exact")).toEqual(["text", "image"]); + }); + + test.each([false, true])("custom rows preserve explicit modalities with enabled=%s", async enabled => { + const fixture = config(enabled); + fixture.customModels = [ + { id: "custom", provider: providerName, modelId: "custom-text", inputModalities: ["text"], addedAt: "2026-01-01" }, + { id: "override", provider: providerName, modelId: "no-vision", inputModalities: ["text", "image"], addedAt: "2026-01-01" }, + ]; + const rows = await gatherRoutedModels(fixture); + expect(modalities(rows, "custom-text")).toEqual(enabled ? ["text", "image"] : ["text"]); + expect(modalities(rows, "no-vision")).toEqual(["text", "image"]); + }); + + test.each([false, true])("missing combo members use enabled=%s", enabled => { + const fixture = config(enabled); + const member = resolveComboCatalogMember( + { provider: providerName, model: "declared-text" }, new Map(), + new Map(Object.entries(fixture.providers)), undefined, undefined, undefined, undefined, enabled, + ); + expect(member?.inputModalities).toEqual(enabled ? ["text", "image"] : ["text"]); + }); + + test.each([false, true])("gathered combos follow enabled=%s", async enabled => { + const fixture = config(enabled); + fixture.combos = { selected: { targets: [{ provider: providerName, model: "declared-text" }] } }; + const rows = buildCatalogEntries(null, [], await gatherRoutedModels(fixture)); + expect(rows.find(row => row.slug === "combo/selected")?.input_modalities) + .toEqual(enabled ? ["text", "image"] : ["text"]); + }); + + test.each(["declared-text", "custom-text"])("disabled sidecar preserves explicit custom image input in combo target %s", async modelId => { + const fixture = config(false); + fixture.customModels = [ + { id: "custom", provider: providerName, modelId, inputModalities: ["text", "image"], addedAt: "2026-01-01" }, + ]; + fixture.combos = { selected: { targets: [{ provider: providerName, model: modelId }] } }; + const models = await gatherRoutedModels(fixture); + expect(modalities(models, modelId)).toEqual(["text", "image"]); + expect(buildCatalogEntries(null, [], models).find(row => row.slug === "combo/selected")?.input_modalities) + .toEqual(["text", "image"]); + }); + + test("warm live cache does not retain synthetic image input across disable and re-enable", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return modelsResponse(); }) as typeof fetch; + const on = await gatherRoutedModels(config(true, true)); + const off = await gatherRoutedModels(config(false, true)); + const onAgain = await gatherRoutedModels(config(undefined, true)); + for (const id of ["declared-text", "no-vision"]) { + expect(modalities(on, id)).toEqual(["text", "image"]); + expect(modalities(off, id)).toEqual(["text"]); + expect(modalities(onAgain, id)).toEqual(["text", "image"]); + } + expect(modalities(off, "vision-native")).toEqual(["text", "image"]); + expect(calls).toBe(1); + }); + + test("provider-only default advertisement cannot widen a later disabled gather", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return modelsResponse(); }) as typeof fetch; + const fixture = config(false, true); + const providerRows = await fetchProviderModels(providerName, fixture.providers[providerName]!, 60_000); + expect(modalities(providerRows, "no-vision")).toEqual(["text", "image"]); + expect(modalities(await gatherRoutedModels(fixture), "no-vision")).toEqual(["text"]); + expect(calls).toBe(1); + }); + + test("stale cache and failure cooldown reproject the current sidecar setting", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return calls === 1 ? modelsResponse() : new Response("unavailable", { status: 503 }); + }) as typeof fetch; + const on = config(true, true); + on.modelCacheTtlMs = 0; + expect(modalities(await gatherRoutedModels(on), "no-vision")).toEqual(["text", "image"]); + const off = config(false, true); + off.modelCacheTtlMs = 0; + expect(modalities(await gatherRoutedModels(off), "no-vision")).toEqual(["text"]); + expect(modalities(await gatherRoutedModels(on), "no-vision")).toEqual(["text", "image"]); + expect(modalities(await gatherRoutedModels(off), "no-vision")).toEqual(["text"]); + expect(calls).toBe(2); + }); + + test("disabled projection preserves live native image evidence without widening discovery-only text rows", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "live-native", input_modalities: ["text", "image"] }, + { id: "live-text", input_modalities: ["text"] }, + ] })) as typeof fetch; + const off = await gatherRoutedModels(config(false, true)); + const on = await gatherRoutedModels(config(true, true)); + for (const rows of [off, on]) { + expect(modalities(rows, "live-native")).toEqual(["text", "image"]); + expect(modalities(rows, "live-text")).toEqual(["text"]); + } + }); + + test.each([false, true])("mixed native and exact text-only combo uses enabled=%s", async enabled => { + const fixture = config(enabled); + fixture.providers[providerName]!.modelCapabilities!["declared-text"] = { inputModalities: ["text"] }; + fixture.combos = { mixed: { targets: [ + { provider: providerName, model: "declared-text" }, + { provider: providerName, model: "vision-native" }, + ] } }; + const entries = buildCatalogEntries(null, [], await gatherRoutedModels(fixture)); + expect(entries.find(row => row.slug === "combo/mixed")?.input_modalities) + .toEqual(enabled ? ["text", "image"] : ["text"]); + }); + + test("opposite settings cannot join the same pending gather", async () => { + let started!: () => void; + let release!: () => void; + const firstStarted = new Promise(resolve => { started = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + let calls = 0; + globalThis.fetch = (async () => { + if (++calls === 1) { started(); await pending; } + return modelsResponse(); + }) as typeof fetch; + const onPromise = gatherRoutedModels(config(true, true)); + await firstStarted; + const offPromise = gatherRoutedModels(config(false, true)); + release(); + const [on, off] = await Promise.all([onPromise, offPromise]); + expect(modalities(on, "no-vision")).toEqual(["text", "image"]); + expect(modalities(off, "no-vision")).toEqual(["text"]); + }); + + test("a pending gather keeps the enabled setting it admitted", async () => { + let started!: () => void; + let release!: () => void; + const firstStarted = new Promise(resolve => { started = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async () => { started(); await pending; return modelsResponse(); }) as typeof fetch; + const fixture = config(true, true); + const result = gatherRoutedModels(fixture); + await firstStarted; + fixture.visionSidecar!.enabled = false; + release(); + expect(modalities(await result, "no-vision")).toEqual(["text", "image"]); + }); + + test("gather identity distinguishes disabled from the default enabled policy", () => { + expect(gatherFlightKey(config(false))).not.toBe(gatherFlightKey(config(true))); + expect(gatherFlightKey(config())).toBe(gatherFlightKey(config(true))); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..0d88a37e6ed 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -243,6 +243,7 @@ "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", + "catalog-vision-sidecar-disabled.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses", @@ -1071,6 +1072,7 @@ "shutdown-launcher.test.ts": "service", "sidebar-routes.test.ts": "server", "sidebar-star-state.test.ts": "server", "sidecar-abort.test.ts": "vision", "sidecar-auth.test.ts": "vision", "sidecar-candidates.test.ts": "vision", + "sidecar-settings-vision-catalog.test.ts": "vision", "sidecar-settings-vision-controls.test.ts": "vision", "sidecar-settings-vision-filter.test.ts": "vision", "sidecar-settings-web-search-gate.test.ts": "vision", "sidecar-settings-web-search-off.test.ts": "vision", "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", diff --git a/tests/vision/sidecar-settings-vision-catalog.test.ts b/tests/vision/sidecar-settings-vision-catalog.test.ts new file mode 100644 index 00000000000..fb13b9c6ce2 --- /dev/null +++ b/tests/vision/sidecar-settings-vision-catalog.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { clearGatherRoutedModelsInflight, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; +import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; +import type { CatalogDisposition } from "../../src/codex/convergence-types"; +import { projectCatalogOnlyOutcome } from "../../src/codex/management-convergence"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { resetSiblingStartForTests } from "../../src/codex/sibling-start"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { ManagementApiDeps } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +let previousCli: string | undefined; +const originalFetch = globalThis.fetch; +const native = { slug: "gpt-5.5", display_name: "Native", priority: 1, visibility: "list", + base_instructions: "You are Codex.", supported_reasoning_levels: [{ effort: "medium", description: "Medium" }] }; + +/** A static text-only route needs no live credential or provider discovery. */ +function fixture(enabled?: boolean): OcxConfig { + return { port: 10100, defaultProvider: "vision-settings-fixture", providers: { + "vision-settings-fixture": { adapter: "openai-chat", baseUrl: "https://vision-settings.example/v1", + apiKey: "fixture-key", authMode: "key", liveModels: false, models: ["planner"], + modelInputModalities: { planner: ["text"] }, modelContextWindows: { planner: 200_000 } }, + }, ...(enabled === undefined ? {} : { visionSidecar: { enabled } }) }; +} + +/** Exercise the authenticated management dispatcher and its catalog-only callback. */ +async function put(config: OcxConfig, body: unknown, deps: ManagementApiDeps = {}): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new ManagementRequest(url, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, deps); + if (!response) throw new Error("Sidecar settings route was not handled."); + return response; +} + +beforeEach(() => { + home = createTempHome("ocx-vision-settings-catalog-"); + mkdirSync(home.codexHome); + previousCli = process.env.CODEX_CLI_PATH; + process.env.CODEX_CLI_PATH = home.path("must-not-execute"); + globalThis.fetch = (async () => { throw new Error("Unexpected upstream request."); }) as typeof fetch; + resetSiblingStartForTests(); + clearModelCache(); clearGatherRoutedModelsInflight(); resetCatalogRuntimeStateForTests(); + resetCodexModelEntitlementCacheForTests(); resetCodexRuntimeResolveCacheForTests(); + const runtime = { command: process.env.CODEX_CLI_PATH, version: "0.145.0", source: "fallback" as const }; + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }, { discoverAlternatives: false }); + setBundledCatalogCacheForTests(runtime, { models: [native] }); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n'); + writeFileSync(join(home.codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [native] })); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); clearGatherRoutedModelsInflight(); resetCatalogRuntimeStateForTests(); + resetCodexModelEntitlementCacheForTests(); resetCodexRuntimeResolveCacheForTests(); resetSiblingStartForTests(); + if (previousCli === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCli; + home.remove(); +}); + +test.each([[undefined, false], [true, false], [false, true]] as const)("an effective Vision change converges only after persistence: %j -> %j", async (before, enabled) => { + const config = fixture(before); + saveConfig(config); + let calls = 0; + const response = await put(config, { vision: { enabled } }, { + createManagementConvergeCodex: captured => async request => { + calls++; + expect(captured).toBe(config); + expect(request).toMatchObject({ scope: "catalog", reason: "management-mutation", deadlineMs: 1_000 }); + const saved = JSON.parse(readFileSync(home.path("config.json"), "utf8")) as OcxConfig; + expect(saved.visionSidecar?.enabled !== false).toBe(enabled); + return projectCatalogOnlyOutcome({ changed: true, + catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] } }); + }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ vision: { enabled }, catalogRefreshPending: false }); + expect(calls).toBe(1); +}); + +test.each([[undefined, true], [true, true], [false, false]] as const)("the same effective Vision setting does not converge: %j -> %j", async (before, enabled) => { + const response = await put(fixture(before), { vision: { enabled } }, { + createManagementConvergeCodex: () => { throw new Error("Unchanged setting must not converge."); }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ catalogRefreshPending: false }); +}); + +test("partial and invalid writes do not refresh the catalog", async () => { + const config = fixture(false); + let calls = 0; + const deps: ManagementApiDeps = { createManagementConvergeCodex: () => { + calls++; throw new Error("Unrelated writes must not converge."); + } }; + for (const body of [{ vision: { timeoutMs: 12_000 } }, { webSearch: { streamRoutedModelOutput: true } }]) { + expect((await put(config, body, deps)).status).toBe(200); + } + expect((await put(config, { vision: { enabled: "false" } }, deps)).status).toBe(400); + expect(calls).toBe(0); +}); + +test.each([ + { status: "skipped", reason: "busy", retryable: true }, + { status: "skipped", reason: "catalog-unavailable", retryable: false }, + { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite: false }, +] satisfies CatalogDisposition[])("an incomplete catalog refresh is reported without undoing the saved switch: %j", async disposition => { + const response = await put(fixture(), { vision: { enabled: false } }, { + createManagementConvergeCodex: () => async () => projectCatalogOnlyOutcome({ changed: false, catalogRefresh: disposition }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ vision: { enabled: false }, catalogRefreshPending: true }); + expect(JSON.parse(readFileSync(home.path("config.json"), "utf8")).visionSidecar.enabled).toBe(false); +}); + +test("a live settings toggle publishes new modalities to both managed catalog and Codex cache", async () => { + const config = fixture(); + saveConfig(config); + const paths = ["opencodex-catalog.json", "models_cache.json"].map(name => join(home.codexHome, name)); + const stale = { models: [native, { slug: "vision-settings-fixture/planner", input_modalities: ["text", "image"] }] }; + for (const path of paths) writeFileSync(path, JSON.stringify(stale)); + for (const enabled of [false, true]) { + const response = await put(config, { vision: { enabled } }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ catalogRefreshPending: false }); + for (const path of paths) { + const rows = JSON.parse(readFileSync(path, "utf8")).models as Array<{ slug: string; input_modalities?: string[] }>; + expect(rows.find(row => row.slug === "vision-settings-fixture/planner")?.input_modalities) + .toEqual(enabled ? ["text", "image"] : ["text"]); + } + } +});