From 99f3abc39f05df9d0f62602bf223991a62a5a14a Mon Sep 17 00:00:00 2001 From: x3M3x Date: Thu, 17 Sep 2026 23:28:42 +0400 Subject: [PATCH] feat(combos): enroll text-only members in vision sidecar from the UI --- docs-site/src/content/docs/guides/combos.md | 10 +++ docs-site/src/content/docs/guides/sidecars.md | 3 + gui/src/combo-capabilities.ts | 47 ++++++++++++-- gui/src/combo-workspace-data.ts | 7 +- .../components/combo-workspace-controls.tsx | 12 +++- gui/src/i18n/de.ts | 3 +- gui/src/i18n/en.ts | 3 +- gui/src/i18n/fr.ts | 5 +- gui/src/i18n/ja.ts | 3 +- gui/src/i18n/ko.ts | 3 +- gui/src/i18n/ru.ts | 3 +- gui/src/i18n/tr.ts | 3 +- gui/src/i18n/zh-TW.ts | 3 +- gui/src/i18n/zh.ts | 3 +- gui/src/pages/Combos.tsx | 10 ++- src/server/management/combo-routes.ts | 65 +++++++++++++++++++ structure/catalog.md | 2 +- structure/config.md | 2 +- structure/dashboard-and-usage.md | 2 +- tests/gui/combo-workspace-data.test.ts | 54 +++++++++++++-- tests/routing/combo-management-api.test.ts | 65 +++++++++++++++++++ 21 files changed, 280 insertions(+), 28 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 166ed292f56..01ad6454128 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -319,6 +319,16 @@ even when every target supports images — the catalog drops `image` from `input image-bearing requests are rejected with HTTP 400 before any target is called. `"auto"` (or omitting the field) keeps the automatic intersection. +In the dashboard, the **Image / multimodal** switch can be enabled whenever every target is a +known catalog row. Members that advertise image input stay unchanged; members that do not are +automatically declared text-only on save (`modelCapabilities[model].inputModalities = ["text"]`) +so the [Vision Sidecar](/guides/sidecars/) describes their images. The switch's hint names the +members that will be enrolled. Turning the switch off disables image input for the combo but +keeps those provider declarations, and `PUT /api/combos` accepts the same enrollment as an +optional top-level `visionSidecarTargets` array of exact `{ provider, model }` targets +(request-only; it is never stored on the combo and is rejected while `imageInput` is +`"disabled"` when the enrollment list is non-empty). + ## Encrypted v2 sub-agent tasks There is one important limitation for Codex v2 sub-agents ([issue #92](https://github.com/lidge-jun/opencodex/issues/92)). diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 1f80f4fbc95..d7f77b16ad6 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -133,6 +133,9 @@ allow attachments instead of blocking them before the sidecar runs. When use the `gpt-5.6-luna` fallback. Startup still migrates an explicitly persisted legacy `gpt-5.4-mini` value to `gpt-5.6-luna`; that migration applies to a stored value, not to an absent model field. +Saving a multimodal combo from the dashboard enrolls its non-image members automatically: the +Combos page sends them as `visionSidecarTargets` on `PUT /api/combos`, which writes the exact +text-only declaration on the member's provider, so no manual config edit and reload is needed. The first-party DeepSeek `deepseek-flash` model is native multimodal (`text` and `image`) and does not use this sidecar by default. Explicit `noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` remain diff --git a/gui/src/combo-capabilities.ts b/gui/src/combo-capabilities.ts index 32f91f66b98..9bc8170308f 100644 --- a/gui/src/combo-capabilities.ts +++ b/gui/src/combo-capabilities.ts @@ -1,14 +1,47 @@ import type { ComboTarget } from "./combo-workspace-data"; import type { ModelOption } from "./components/combo-workspace-types"; -/** Whether every selected target advertises image input (incomplete rows fail closed). */ +type ComboImageMemberKind = "vision" | "sidecar" | "missing"; + +/** + * One combo member's image story. A row advertising image is either natively + * multimodal or an already-declared Vision Sidecar consumer (the catalog widens + * both). A catalog row without image can be declared text-only on save so the + * sidecar covers it. Rows absent from the catalog fail closed. + */ +function imageMemberKind(target: ComboTarget, models: ModelOption[]): ComboImageMemberKind { + const provider = target.provider.trim(); + const modelId = target.model.trim(); + if (!provider || !modelId) return "missing"; + const model = models.find((row) => row.provider === provider && row.id === modelId); + if (!model) return "missing"; + return model.inputModalities?.includes("image") ? "vision" : "sidecar"; +} + +/** Whether images can be enabled: every target is known and either images natively or can be declared text-only. */ export function comboImagesSupported(targets: ComboTarget[], models: ModelOption[]): boolean { if (targets.length === 0) return false; - return targets.every((target) => { + return targets.every((target) => imageMemberKind(target, models) !== "missing"); +} + +/** + * Exact targets that need a text-only declaration so the Vision Sidecar covers + * them when the combo accepts images. Deduplicated in submission order. + */ +export function comboVisionSidecarTargets( + targets: ComboTarget[], + models: ModelOption[], +): Array<{ provider: string; model: string }> { + const seen = new Set(); + const out: Array<{ provider: string; model: string }> = []; + for (const target of targets) { + if (imageMemberKind(target, models) !== "sidecar") continue; const provider = target.provider.trim(); - const modelId = target.model.trim(); - if (!provider || !modelId) return false; - const model = models.find((row) => row.provider === provider && row.id === modelId); - return !!model?.inputModalities?.includes("image"); - }); + const model = target.model.trim(); + const key = `${provider}/${model}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ provider, model }); + } + return out; } diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index b89bff656fb..ea4a484862d 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -412,9 +412,13 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean { }); } -export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}): { +export function toPutBody( + item: ComboItem, + options: { renameFrom?: string; visionSidecarTargets?: Array<{ provider: string; model: string }> } = {}, +): { id: string; renameFrom?: string; + visionSidecarTargets?: Array<{ provider: string; model: string }>; combo: { targets: ComboTarget[]; strategy: ComboStrategy; @@ -431,6 +435,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} return { id: item.id.trim(), ...(options.renameFrom ? { renameFrom: options.renameFrom } : {}), + ...(options.visionSidecarTargets?.length ? { visionSidecarTargets: options.visionSidecarTargets } : {}), combo: { targets: item.targets.map((target) => weighted ? { provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1 } diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index 8e4a8108bcc..214ad74794b 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { ComboEffort, ComboStrategy, ComboTarget, ProviderQuotaStates } from "../combo-workspace-data"; -import { comboImagesSupported } from "../combo-capabilities"; +import { comboImagesSupported, comboVisionSidecarTargets } from "../combo-capabilities"; import { COMBO_EFFORTS, COMBO_STRATEGIES, COMBO_STRATEGY_LABEL_KEYS, newComboTarget } from "../combo-workspace-data"; import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons"; import { useT } from "../i18n/shared"; @@ -102,6 +102,14 @@ export function ComboCapabilities({ const imagesSupported = comboImagesSupported(targets, models); // Default: checked (auto) when supported; force off when any target lacks image. const effectiveOn = imagesSupported && imageInput !== "disabled"; + const sidecarTargets = comboVisionSidecarTargets(targets, models); + const imageHint = !imagesSupported + ? t("cws.capability.imageInputUnavailable") + : sidecarTargets.length > 0 + ? t("cws.capability.imageInputSidecarHint", { + models: sidecarTargets.map(({ provider, model }) => `${provider}/${model}`).join(", "), + }) + : t("cws.capability.imageInputHint"); return (
@@ -110,7 +118,7 @@ export function ComboCapabilities({
{t("cws.capability.imageInput")}

- {imagesSupported ? t("cws.capability.imageInputHint") : t("cws.capability.imageInputUnavailable")} + {imageHint}

= { "cws.field.defaultEffort": "Standard-Reasoning", "cws.field.defaultEffortNone": "Keine (Ziel-Standard)", "cws.field.defaultEffortHint": "Nur verwendet, wenn der Client keinen Reasoning-Aufwand sendet. Optionen sind die Schnittmenge der beworbenen Aufwände der gewählten Ziele.", - "cws.capability.imageInputUnavailable": "Erst verfügbar, wenn jedes gewählte Ziel Bildeingabe unterstützt.", + "cws.capability.imageInputUnavailable": "Wähle zuerst alle Ziele aus dem Katalog — unbekannte Modelle kann der Vision Sidecar nicht abdecken.", "cws.capability.imageInputHint": "Standardmäßig aktiv, wenn jedes Ziel Bilder unterstützt. Ausschalten für nur Text.", + "cws.capability.imageInputSidecarHint": "Standardmäßig aktiv. {models} wird beim Speichern als text-only deklariert und nutzt den Vision Sidecar für Bilder.", "cws.capability.imageInput": "Bild / multimodal", "cws.capability.adaptiveEffort": "Adaptive Denkstufen", "cws.capability.adaptiveEffortHint": "Aus: Ziele ohne Denkstufen-Regelung blenden die Auswahl für die gesamte Kombination aus. An: Solche Ziele bleiben nutzbar, und die Auswahl zeigt weiterhin die Stufen der übrigen Ziele.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d48d0ed10bb..a17c1140a7b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2782,8 +2782,9 @@ export const en = { "cws.field.defaultEffort": "Default reasoning", "cws.field.defaultEffortNone": "None (target default)", "cws.field.defaultEffortHint": "Used only when the client omits reasoning effort. Options are the intersection of the selected targets' advertised efforts; targets without catalog effort metadata offer none.", - "cws.capability.imageInputUnavailable": "Unavailable until every selected target supports image input.", + "cws.capability.imageInputUnavailable": "Pick every target from the catalog first — unknown models can't be covered by the Vision Sidecar.", "cws.capability.imageInputHint": "On by default when every target supports images. Turn off to accept text only.", + "cws.capability.imageInputSidecarHint": "On by default. {models} will be declared text-only on save and use the Vision Sidecar for images.", "cws.capability.imageInput": "Image / multimodal", "cws.capability.adaptiveEffort": "Adaptive reasoning ladder", "cws.capability.adaptiveEffortHint": "Off: a target with no reasoning control hides the effort picker for the whole combo. On: those targets stay usable and the picker keeps the levels the remaining targets share.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 659ea434de2..61542048343 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2667,8 +2667,9 @@ export const fr: Record = { "cws.emptyTitle": "Créer votre première combinaison", "cws.empty.createDesc": "Nommez un modèle virtuel et enchaînez au moins deux services principaux.", "cws.backToAll": "Retour à toutes les combinaisons", - "cws.capability.imageInputUnavailable": "Indisponible tant que toutes les cibles sélectionnées ne prennent pas en charge les images.", - "cws.capability.imageInputHint": "Activé par défaut lorsque toutes les cibles prennent en charge les images. Désactivez cette option pour n’accepter que du texte.", + "cws.capability.imageInputUnavailable": "Sélectionnez d'abord chaque cible dans le catalogue — les modèles inconnus ne peuvent pas être couverts par le Vision Sidecar.", + "cws.capability.imageInputHint": "Activé par défaut lorsque toutes les cibles prennent en charge les images. Désactivez cette option pour n'accepter que du texte.", + "cws.capability.imageInputSidecarHint": "Activé par défaut. Lors de l’enregistrement, les cibles {models} seront déclarées en mode texte uniquement et utiliseront le Vision Sidecar pour les images.", "cws.capability.imageInput": "Images / multimodal", "cws.capability.adaptiveEffort": "Échelle de raisonnement adaptative", "cws.capability.adaptiveEffortHint": "Désactivé : une cible sans réglage de raisonnement masque le sélecteur pour toute la combinaison. Activé : ces cibles restent utilisables et le sélecteur conserve les niveaux communs aux autres cibles.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2136f3322e1..8a8b931d29f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2734,8 +2734,9 @@ export const ja: Record = { "cws.field.defaultEffort": "デフォルトの推論", "cws.field.defaultEffortNone": "なし(ターゲットのデフォルト)", "cws.field.defaultEffortHint": "クライアントが推論負荷を省略した場合のみ使用されます。選択肢は選択ターゲットが広告する負荷の交差です。", - "cws.capability.imageInputUnavailable": "選択した全ターゲットが画像入力に対応すると有効になります。", + "cws.capability.imageInputUnavailable": "まずすべてのターゲットをカタログから選択してください。不明なモデルはVision Sidecarでカバーできません。", "cws.capability.imageInputHint": "全ターゲットが画像対応なら既定でオン。オフにするとテキストのみ。", + "cws.capability.imageInputSidecarHint": "既定でオン。{models} は保存時にテキスト専用として宣言され、画像にはVision Sidecarを使用します。", "cws.capability.imageInput": "画像 / マルチモーダル", "cws.capability.adaptiveEffort": "適応的な推論レベル", "cws.capability.adaptiveEffortHint": "オフ: 推論レベルを持たない対象があると、コンボ全体のセレクターが消えます。オン: その対象はそのまま使え、セレクターには残りの対象で共通するレベルが表示されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b7a459d52de..3b5ad9fdf24 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2721,8 +2721,9 @@ export const ko: Record = { "cws.field.defaultEffort": "기본 추론 수준", "cws.field.defaultEffortNone": "없음 (대상 기본값)", "cws.field.defaultEffortHint": "클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 선택한 대상이 광고하는 수준의 교집합입니다.", - "cws.capability.imageInputUnavailable": "선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다.", + "cws.capability.imageInputUnavailable": "먼저 모든 대상을 카탈로그에서 선택하세요. 알 수 없는 모델은 Vision Sidecar로 처리할 수 없습니다.", "cws.capability.imageInputHint": "모든 대상이 이미지를 지원하면 기본으로 켜집니다. 끄면 텍스트만 허용합니다.", + "cws.capability.imageInputSidecarHint": "기본으로 켜짐. {models}은(는) 저장 시 텍스트 전용으로 선언되어 이미지에 Vision Sidecar를 사용합니다.", "cws.capability.imageInput": "이미지 / 멀티모달", "cws.capability.adaptiveEffort": "적응형 추론 단계", "cws.capability.adaptiveEffortHint": "끔: 추론 단계를 조절할 수 없는 대상이 하나라도 있으면 콤보 전체의 선택기가 사라집니다. 켬: 그런 대상도 그대로 쓰면서, 선택기에는 나머지 대상이 공통으로 지원하는 단계가 남습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 93965606aa7..c6a5b0096e8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2805,8 +2805,9 @@ export const ru: Record = { "cws.field.defaultEffort": "Рассуждения по умолчанию", "cws.field.defaultEffortNone": "Нет (по умолчанию для цели)", "cws.field.defaultEffortHint": "Используется, только если клиент не указал уровень рассуждений. Варианты — пересечение заявленных уровней выбранных целей.", - "cws.capability.imageInputUnavailable": "Доступно, когда все выбранные цели поддерживают ввод изображений.", + "cws.capability.imageInputUnavailable": "Сначала выберите все цели из каталога — неизвестные модели нельзя обработать через Vision Sidecar.", "cws.capability.imageInputHint": "Включено по умолчанию, если все цели поддерживают изображения. Выключите, чтобы принимать только текст.", + "cws.capability.imageInputSidecarHint": "Включено по умолчанию. При сохранении для {models} будет указана только текстовая модальность; изображения будут обрабатываться через Vision Sidecar.", "cws.capability.imageInput": "Изображения / мультимодальность", "cws.capability.adaptiveEffort": "Адаптивная шкала рассуждений", "cws.capability.adaptiveEffortHint": "Выкл.: цель без настройки рассуждений скрывает выбор уровня для всей комбинации. Вкл.: такие цели остаются доступными, а в выборе сохраняются уровни, общие для остальных целей.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1856735a5bc..88e5f46248a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2724,8 +2724,9 @@ export const tr: Record = { "cws.field.defaultEffort": "Varsayılan akıl yürütme", "cws.field.defaultEffortNone": "Yok (hedef varsayılanı)", "cws.field.defaultEffortHint": "Yalnızca istemci akıl yürütme çabasını belirtmediğinde (atladığında) kullanılır. Seçenekler, seçilen hedeflerin duyurulan çabalarının kesişimidir; katalog çaba meta verisi olmayan hedefler hiçbir seçenek sunmaz.", - "cws.capability.imageInputUnavailable": "Seçilen tüm hedefler görsel girişini destekleyene kadar kullanılamaz.", + "cws.capability.imageInputUnavailable": "Önce tüm hedefleri katalogdan seçin — bilinmeyen modeller Vision Sidecar ile kapsanamaz.", "cws.capability.imageInputHint": "Tüm hedefler görselleri desteklediğinde varsayılan olarak açıktır. Yalnızca metin kabul etmek için kapatın.", + "cws.capability.imageInputSidecarHint": "Varsayılan olarak açık. {models} kaydedildiğinde yalnızca metin olarak bildirilir ve görseller için Vision Sidecar kullanır.", "cws.capability.imageInput": "Görsel / çok modlu", "cws.capability.adaptiveEffort": "Uyarlanabilir akıl yürütme düzeyi", "cws.capability.adaptiveEffortHint": "Kapalı: akıl yürütme denetimi olmayan bir hedef, tüm kombinasyonun seçicisini gizler. Açık: bu hedefler kullanılabilir kalır ve seçici, kalan hedeflerin ortak düzeylerini gösterir.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index eba5ce1b2ea..e561278722e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2077,8 +2077,9 @@ export const zhTW: Record = { "cws.field.defaultEffort": "預設推理級別", "cws.field.defaultEffortNone": "無(使用目標預設)", "cws.field.defaultEffortHint": "僅在客戶端未指定推理級別時使用。客戶端值優先,每個目標會按自身能力進行處理。", - "cws.capability.imageInputUnavailable": "所有已選目標都支援圖片輸入後才可使用。", + "cws.capability.imageInputUnavailable": "請先從目錄選擇所有目標——未知模型無法由 Vision Sidecar 處理。", "cws.capability.imageInputHint": "所有目標都支援圖片時預設開啟;關閉後僅接受文字。", + "cws.capability.imageInputSidecarHint": "預設開啟。{models} 將在儲存時宣告為純文字,並使用 Vision Sidecar 處理圖片。", "cws.capability.imageInput": "圖片 / 多模態", "cws.capability.adaptiveEffort": "自適應推理層級", "cws.capability.adaptiveEffortHint": "關閉:只要有一個目標不支援推理層級,整個組合的選擇器都會消失。開啟:這些目標仍可使用,選擇器保留其餘目標共有的層級。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fe9424b8151..18ed953c036 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2702,8 +2702,9 @@ export const zh: Record = { "cws.field.defaultEffort": "默认推理级别", "cws.field.defaultEffortNone": "无(使用目标默认)", "cws.field.defaultEffortHint": "仅在客户端未指定推理级别时使用。选项为所选目标已公布努力级别的交集。", - "cws.capability.imageInputUnavailable": "所有已选目标均支持图片输入后才可用。", + "cws.capability.imageInputUnavailable": "请先从目录中选择所有目标——未知模型无法由 Vision Sidecar 处理。", "cws.capability.imageInputHint": "所有目标均支持图片时默认开启;关闭后仅接受文本。", + "cws.capability.imageInputSidecarHint": "默认开启。{models} 将在保存时声明为纯文本,并使用 Vision Sidecar 处理图片。", "cws.capability.imageInput": "图片 / 多模态", "cws.capability.adaptiveEffort": "自适应推理档位", "cws.capability.adaptiveEffortHint": "关闭:只要有一个目标不支持推理档位,整个组合的选择器都会消失。开启:这些目标仍可使用,选择器保留其余目标共有的档位。", diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 18b997a4b50..cc151d1d65b 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import ComboWorkspace from "../components/ComboWorkspace"; +import { comboVisionSidecarTargets } from "../combo-capabilities"; import { type ComboItem, comboModelId, @@ -280,7 +281,14 @@ export default function Combos({ const res = await fetch(`${apiBase}/api/combos`, { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify(toPutBody(item, renameFrom ? { renameFrom } : {})), + body: JSON.stringify(toPutBody(item, { + ...(renameFrom ? { renameFrom } : {}), + // Enabling images declares text-only members for the Vision Sidecar so the + // operator never has to hand-edit provider config for a multimodal combo. + ...(item.imageInput !== "disabled" + ? { visionSidecarTargets: comboVisionSidecarTargets(item.targets, models) } + : {}), + })), }); const data = res.ok ? await res.json() as unknown diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index cdc567a6ebd..67772103dfc 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -52,6 +52,7 @@ import { setDebugSettings, type DebugFlag, } from "../../lib/debug-settings"; +import { mergeModelCapabilities, modelCapabilitiesConfigError } from "../../config/provider-validation"; import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; import { drainAndShutdown } from "../lifecycle"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; @@ -108,6 +109,43 @@ function sparseComboConfig, +): { targets?: VisionSidecarTarget[]; error?: string } { + if (raw === undefined) return {}; + if (!Array.isArray(raw)) return { error: "visionSidecarTargets must be an array" }; + const seen = new Set(); + const targets: VisionSidecarTarget[] = []; + for (const entry of raw) { + if (!isPlainRecord(entry)) return { error: "visionSidecarTargets entries must be objects" }; + const provider = typeof entry.provider === "string" ? entry.provider.trim() : ""; + const model = typeof entry.model === "string" ? entry.model.trim() : ""; + if (!provider || !model) { + return { error: "visionSidecarTargets entries must have nonblank provider and model" }; + } + const key = `${provider}/${model}`; + if (!comboTargetKeys.has(key)) { + return { error: `visionSidecarTargets entry "${key}" is not a target of this combo` }; + } + if (seen.has(key)) continue; + seen.add(key); + targets.push({ provider, model }); + } + return { targets }; +} + export async function handleComboRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; @@ -187,6 +225,26 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise `${target.provider}/${target.model}`)), + ); + if (sidecarParsed.error) return jsonResponse({ error: sidecarParsed.error }, 400); + const sidecarPatches = new Map>(); + if (sidecarParsed.targets?.length) { + if (normalized.imageInput === "disabled") { + return jsonResponse({ error: "visionSidecarTargets requires imageInput auto" }, 400); + } + for (const { provider, model } of sidecarParsed.targets) { + const patch = sidecarPatches.get(provider) ?? {}; + patch[model] = { inputModalities: ["text"] }; + sidecarPatches.set(provider, patch); + } + for (const patch of sidecarPatches.values()) { + const error = modelCapabilitiesConfigError(patch); + if (error) return jsonResponse({ error }, 400); + } + } // Persist only non-default identity/capability fields so config stays sparse. // Capability defaults (`imageInput`, `reasoningEffortMode`) go through the same // helper the GET/PUT responses use, so the wire shape and the stored shape cannot drift. @@ -283,6 +341,13 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise { )).toBe(false); }); - test("returns true only when every complete target advertises image", () => { + test("returns true when every known target advertises image", () => { const models = [ { provider: "a", id: "m1", inputModalities: ["text", "image"] }, { provider: "b", id: "m2", inputModalities: ["text", "image"] }, @@ -732,15 +732,21 @@ describe("comboImagesSupported", () => { )).toBe(true); }); - test("returns false when any target is missing from the catalog or lacks image", () => { + test("allows text-only members for sidecar coverage but fails closed on unknown rows", () => { const models = [ { provider: "a", id: "m1", inputModalities: ["text", "image"] }, { provider: "b", id: "m2", inputModalities: ["text"] }, ]; + // Known text-only rows can be declared text-only on save; only catalog-absent rows block. expect(comboImagesSupported( [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], models, - )).toBe(false); + )).toBe(true); + // A row with no modality data is still known and enrollable. + expect(comboImagesSupported( + [{ provider: "a", model: "m1" }, { provider: "c", model: "m3" }], + [...models, { provider: "c", id: "m3" }], + )).toBe(true); expect(comboImagesSupported( [{ provider: "a", model: "m1" }, { provider: "b", model: "ghost" }], models, @@ -748,6 +754,35 @@ describe("comboImagesSupported", () => { }); }); +describe("comboVisionSidecarTargets", () => { + test("returns only members not advertising image, deduplicated", () => { + const models = [ + { provider: "a", id: "m1", inputModalities: ["text", "image"] }, + { provider: "b", id: "m2", inputModalities: ["text"] }, + { provider: "c", id: "m3" }, + ]; + expect(comboVisionSidecarTargets( + [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], + models, + )).toEqual([ + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ]); + }); + + test("ignores incomplete or catalog-absent targets", () => { + expect(comboVisionSidecarTargets( + [{ provider: "", model: "" }, { provider: "b", model: "ghost" }], + [{ provider: "b", id: "m2", inputModalities: ["text"] }], + )).toEqual([]); + }); +}); + describe("combo imageInput draft persistence", () => { test("parseComboList preserves explicit disabled", () => { const items = parseComboList({ @@ -775,4 +810,15 @@ describe("combo imageInput draft persistence", () => { const disabled = { ...auto, imageInput: "disabled" as const }; expect(toPutBody(disabled).combo.imageInput).toBe("disabled"); }); + + test("toPutBody carries visionSidecarTargets as a top-level request-only field", () => { + const auto = emptyDraft("x"); + auto.targets = [{ provider: "a", model: "m1" }]; + const sidecar = [{ provider: "a", model: "m1" }]; + const body = toPutBody(auto, { visionSidecarTargets: sidecar }); + expect(body.visionSidecarTargets).toEqual(sidecar); + expect(body.combo).not.toHaveProperty("visionSidecarTargets"); + // Empty lists stay off the wire. + expect(toPutBody(auto, { visionSidecarTargets: [] })).not.toHaveProperty("visionSidecarTargets"); + }); }); diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts index 7bdd2fb69e1..9238b8ef122 100644 --- a/tests/routing/combo-management-api.test.ts +++ b/tests/routing/combo-management-api.test.ts @@ -428,6 +428,71 @@ describe("combo management API", () => { }); }); + test("PUT visionSidecarTargets declares exact members text-only and never persists the request field", async () => { + await withTempHome(async () => { + const base = baseConfig(); + const config = baseConfig({ + providers: { + ...base.providers, + b: { + ...base.providers.b!, + modelCapabilities: { m2: { contextTier: "long_context" } }, + }, + }, + combos: undefined, + }); + saveConfig(config); + const response = await comboApi(config, "PUT", "/api/combos", { + id: "mixed", + visionSidecarTargets: [ + { provider: "b", model: "m2" }, + { provider: "b", model: "m2" }, // exact duplicates are deduplicated + ], + combo: { + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + }); + expect(response?.status).toBe(200); + // Exact text-only declaration added; the sibling capability axis survives. + expect(config.providers?.b?.modelCapabilities).toEqual({ + m2: { contextTier: "long_context", inputModalities: ["text"] }, + }); + // Untouched member provider gains no declaration. + expect(config.providers?.a?.modelCapabilities).toBeUndefined(); + // Request-only field never leaks into the persisted combo. + expect(config.combos?.mixed).not.toHaveProperty("visionSidecarTargets"); + }); + }); + + test("PUT rejects invalid visionSidecarTargets without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + const before = readFileSync(getConfigPath(), "utf8"); + const bodies: unknown[] = [ + { id: "x", visionSidecarTargets: "nope", combo: VALID_COMBO }, + { id: "x", visionSidecarTargets: [{ provider: "a" }], combo: VALID_COMBO }, + // Not a target of the submitted combo. + { id: "x", visionSidecarTargets: [{ provider: "c", model: "m3" }], combo: VALID_COMBO }, + // Enrollment only makes sense while images are accepted. + { + id: "x", + visionSidecarTargets: [{ provider: "a", model: "m1" }], + combo: { targets: [{ provider: "a", model: "m1" }], imageInput: "disabled" }, + }, + ]; + for (const body of bodies) { + const response = await comboApi(config, "PUT", "/api/combos", body); + expect(response?.status).toBe(400); + } + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(config.providers?.a?.modelCapabilities).toBeUndefined(); + }); + }); + test("reasoningEffortMode survives a management round-trip and stays sparse when strict", async () => { await withTempHome(async () => { const config = baseConfig({ combos: undefined });