From 4fec400fc8ca83be30edd029fd7079e5f23be2c6 Mon Sep 17 00:00:00 2001
From: GOAT
Date: Thu, 15 Oct 2026 18:36:13 +0900
Subject: [PATCH 1/6] feat(combos): enroll text-only members in vision sidecar
(carry #4932)
Port c9cda1b5a95292bc280ce1ec7dd942820c08cba1 onto current dev, preserving atomic enrollment, runtime consumer classification and rename rollback. Keep new regressions in a registered sibling file.
Co-authored-by: x3M3x <98298256+x3M3x@users.noreply.github.com>
---
docs-site/src/content/docs/guides/combos.md | 19 +
docs-site/src/content/docs/guides/sidecars.md | 6 +
gui/src/combo-capabilities.ts | 69 ++-
gui/src/combo-workspace-data.ts | 7 +-
gui/src/components/ComboWorkspace.tsx | 3 +
.../components/combo-workspace-controls.tsx | 30 +-
.../combo-workspace-detail-panel.tsx | 4 +
gui/src/components/combo-workspace-types.ts | 6 +
gui/src/i18n/de.ts | 5 +-
gui/src/i18n/en.ts | 5 +-
gui/src/i18n/fr.ts | 7 +-
gui/src/i18n/ja.ts | 5 +-
gui/src/i18n/ko.ts | 5 +-
gui/src/i18n/pt.ts | 3 +
gui/src/i18n/ru.ts | 5 +-
gui/src/i18n/tr.ts | 5 +-
gui/src/i18n/vi.ts | 3 +
gui/src/i18n/zh-TW.ts | 5 +-
gui/src/i18n/zh.ts | 5 +-
gui/src/pages/Combos.tsx | 62 ++-
scripts/test-layout/layout.json | 1 +
src/server/management/combo-routes.ts | 195 ++++++--
src/server/management/model-rows.ts | 13 +
structure/catalog.md | 2 +-
structure/config.md | 2 +-
structure/dashboard-and-usage.md | 2 +-
structure/gui-and-management-api.md | 4 +-
tests/fixtures/test-layout-expected.json | 1 +
tests/gui/combo-workspace-data.test.ts | 98 +++-
tests/routing/combo-vision-enrollment.test.ts | 430 ++++++++++++++++++
30 files changed, 925 insertions(+), 82 deletions(-)
create mode 100644 tests/routing/combo-vision-enrollment.test.ts
diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 60e3db08e6b..f644cb8f41c 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -702,6 +702,25 @@ 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 has
+known image or text input capabilities. Native image members stay unchanged; text-only members 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. A member whose modalities are unknown or have no text input (for
+example an audio-only model) cannot be covered by the sidecar; it keeps the switch unavailable and
+is named in the hint. If the [Vision Sidecar](/guides/sidecars/) is disabled globally, enabling the
+switch shows a warning linking to the dashboard: enrollment still saves, but images are replaced
+with an omission note and requests continue without image descriptions until the sidecar is
+re-enabled. 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). Entries that already accept image input or
+are declared without text are rejected with HTTP 400 rather than overwritten, entries the sidecar
+already covers are skipped, and a save refused before publication restores the combo and every declaration together.
+Removing a member from the combo does not remove its text-only declaration; clear it in the
+provider editor if you no longer want it.
+
## 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 3d35989872b..09e52d28db2 100644
--- a/docs-site/src/content/docs/guides/sidecars.md
+++ b/docs-site/src/content/docs/guides/sidecars.md
@@ -148,6 +148,12 @@ 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.
+Enrollment never overwrites an existing image-capable declaration, and members whose modalities are
+unknown or have no text input are not enrolled — the dashboard names them and keeps the combo's
+image switch unavailable.
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..3dc8459134c 100644
--- a/gui/src/combo-capabilities.ts
+++ b/gui/src/combo-capabilities.ts
@@ -1,14 +1,71 @@
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" | "blocked" | "missing";
+
+/**
+ * One combo member's image story. Classification must survive a reload, so it
+ * reads `visionSidecarConsumer` — the runtime predicate's verdict served on the
+ * /api/models row — before falling back to the DECLARED modalities
+ * (`inputModalitiesDeclared`). A model covered only by legacy `noVisionModels`
+ * has no declaration and its advertised modalities are widened to image, so the
+ * flag is what keeps it a sidecar member after reload. Rows with no known
+ * modalities, or modalities without text, cannot be covered and block the combo.
+ */
+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";
+ if (model.visionSidecarConsumer === true) return "sidecar";
+ const declared = model.inputModalitiesDeclared ?? model.inputModalities;
+ if (!declared || declared.length === 0) return "blocked";
+ if (declared.includes("image")) return "vision";
+ return declared.includes("text") ? "sidecar" : "blocked";
+}
+
+/** 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) => {
- 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 kind = imageMemberKind(target, models);
+ return kind === "vision" || kind === "sidecar";
});
}
+
+/**
+ * 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 model = target.model.trim();
+ const key = `${provider}/${model}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ provider, model });
+ }
+ return out;
+}
+
+/**
+ * Exact targets that cannot be covered by the Vision Sidecar: their row is
+ * known but its modalities are unknown or have no text input. Named in the
+ * hint so the operator knows which member blocks the image switch.
+ */
+export function comboImageBlockedTargets(
+ targets: ComboTarget[],
+ models: ModelOption[],
+): Array<{ provider: string; model: string }> {
+ return targets
+ .filter((target) => imageMemberKind(target, models) === "blocked")
+ .map((target) => ({ provider: target.provider.trim(), model: target.model.trim() }));
+}
diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts
index f579a6f2b28..88c9b9b0528 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -504,9 +504,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;
@@ -526,6 +530,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) => ({
provider: target.provider.trim(),
diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx
index fa7ef50230d..1f513da07a6 100644
--- a/gui/src/components/ComboWorkspace.tsx
+++ b/gui/src/components/ComboWorkspace.tsx
@@ -26,6 +26,7 @@ export default function ComboWorkspace({
models,
capabilityModels = models,
cataloguedComboIds,
+ visionEnabled,
loading,
onRefresh,
onSave,
@@ -226,6 +227,7 @@ export default function ComboWorkspace({
providers={providers}
models={models}
capabilityModels={capabilityModels}
+ visionEnabled={visionEnabled}
onBack={() => trySelect(null)}
onSaved={(item) => {
setDetailDirty(false);
@@ -256,6 +258,7 @@ export default function ComboWorkspace({
providers={providers}
models={models}
capabilityModels={capabilityModels}
+ visionEnabled={visionEnabled}
onSaved={(item) => {
setDetailDirty(false);
setSelectedId(item.id);
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx
index 50f8cfb4dc7..5db48b9298b 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 { comboImageBlockedTargets, comboImagesSupported, comboVisionSidecarTargets } from "../combo-capabilities";
import {
COMBO_EFFORTS,
COMBO_STRATEGIES,
@@ -93,6 +93,7 @@ export function ComboCapabilities({
models,
imageInput,
reasoningEffortMode,
+ visionEnabled,
disabled,
onChange,
}: {
@@ -100,13 +101,30 @@ export function ComboCapabilities({
models: ModelOption[];
imageInput: "auto" | "disabled";
reasoningEffortMode: "strict" | "adaptive";
+ /** Vision Sidecar enabled state; undefined = unknown, renders no warning. */
+ visionEnabled?: boolean;
disabled?: boolean;
onChange: (patch: { imageInput?: "auto" | "disabled"; reasoningEffortMode?: "strict" | "adaptive" }) => void;
}) {
const t = useT();
const imagesSupported = comboImagesSupported(targets, models);
+ const blockedTargets = comboImageBlockedTargets(targets, models);
// Default: checked (auto) when supported; force off when any target lacks image.
const effectiveOn = imagesSupported && imageInput !== "disabled";
+ const sidecarTargets = comboVisionSidecarTargets(targets, models);
+ // Enrollment wording only while image input is enabled: a disabled combo is not
+ // enrolling anything, so it gets the plain hint instead of save-time promises.
+ const imageHint = !imagesSupported
+ ? blockedTargets.length > 0
+ ? t("cws.capability.imageInputBlockedHint", {
+ models: blockedTargets.map(({ provider, model }) => `${provider}/${model}`).join(", "),
+ })
+ : t("cws.capability.imageInputUnavailable")
+ : sidecarTargets.length > 0 && imageInput !== "disabled"
+ ? t("cws.capability.imageInputSidecarHint", {
+ models: sidecarTargets.map(({ provider, model }) => `${provider}/${model}`).join(", "),
+ })
+ : t("cws.capability.imageInputHint");
return (
@@ -115,8 +133,16 @@ export function ComboCapabilities({
{t("cws.capability.imageInputSidecarDisabled", {
models: sidecarTargets.map(({ provider, model }) => `${provider}/${model}`).join(", "),
})}{" "}
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 2239bbe5f39..8cdb905ad92 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -3189,7 +3189,7 @@ export const fr: Record = {
"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": "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.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.imageInputSidecarDisabled": "Le Vision Sidecar est désactivé — les images envoyées à {models} sont remplacées par une note d’omission et la requête continue sans elles. Activez-le dans les paramètres du tableau de bord pour rétablir les descriptions.",
"cws.capability.imageInputBlockedHint": "{models} ne peut pas être couvert par le Vision Sidecar : ses modalités d’entrée sont inconnues ou sans texte.",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index 1f3b7e53533..b914c8f3145 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -3266,7 +3266,7 @@ export const pt: Record = {
"cws.field.defaultEffort": "Raciocínio padrão",
"cws.field.defaultEffortNone": "Nenhum (padrão do destino)",
"cws.field.defaultEffortHint": "Usado somente quando o cliente omite o esforço de raciocínio. As opções são a interseção dos esforços informados pelos destinos selecionados; destinos sem metadados de esforço no catálogo não oferecem nenhuma.",
- "cws.capability.imageInputUnavailable": "Indisponível até que todos os destinos selecionados aceitem entrada de imagem.",
+ "cws.capability.imageInputUnavailable": "Escolha primeiro todos os destinos no catálogo — modelos desconhecidos não podem ser cobertos pelo Vision Sidecar.",
"cws.capability.imageInputHint": "Ativado por padrão quando todos os destinos aceitam imagens. Desative para aceitar somente texto.",
"cws.capability.imageInputSidecarHint": "Ativado por padrão. Ao salvar, {models} serão declarados como somente texto e usarão o Vision Sidecar para imagens.",
"cws.capability.imageInputSidecarDisabled": "O Vision Sidecar está desativado — as imagens enviadas a {models} são substituídas por uma nota de omissão e a requisição continua sem elas. Ative-o nas configurações do painel para restaurar as descrições.",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index f9fb4ed677c..6b44701dbfb 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -3234,7 +3234,7 @@ export const vi: Record = {
"cws.field.defaultEffort": "Suy luận (reasoning) mặc định",
"cws.field.defaultEffortNone": "Không (mặc định của mục tiêu)",
"cws.field.defaultEffortHint": "Chỉ sử dụng khi client không chỉ định mức độ suy luận (reasoning effort). Các tùy chọn là giao điểm của các mức độ được công bố từ các mục tiêu đã chọn; những mục tiêu không có metadata về mức độ suy luận trong danh mục sẽ không cung cấp tùy chọn này.",
- "cws.capability.imageInputUnavailable": "Không khả dụng cho đến khi mọi mục tiêu được chọn đều hỗ trợ đầu vào hình ảnh (image input).",
+ "cws.capability.imageInputUnavailable": "Hãy chọn tất cả mục tiêu từ danh mục trước — Vision Sidecar không thể hỗ trợ các mô hình không xác định.",
"cws.capability.imageInputHint": "Được bật theo mặc định khi mọi mục tiêu đều hỗ trợ hình ảnh. Tắt để chỉ chấp nhận văn bản.",
"cws.capability.imageInputSidecarHint": "Bật theo mặc định. {models} sẽ được khai báo chỉ-text khi lưu và dùng Vision Sidecar cho hình ảnh.",
"cws.capability.imageInputSidecarDisabled": "Vision Sidecar đang tắt — hình ảnh gửi tới {models} sẽ được thay bằng ghi chú bỏ qua và yêu cầu vẫn tiếp tục. Bật nó trong cài đặt bảng điều khiển để khôi phục mô tả hình ảnh.",
diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css
index 1423733101d..f7ab49d7cdf 100644
--- a/gui/src/styles-combos-workspace.css
+++ b/gui/src/styles-combos-workspace.css
@@ -474,6 +474,11 @@
font-size: 12px;
}
+/* Requests still continue without the images, so this is a warning (amber), not a failure. */
+.cwi-capability-hint--warn {
+ color: var(--amber);
+}
+
.cwi-target-list {
display: flex;
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index e95e770e7ca..a1940d57242 100644
--- a/src/server/management/combo-routes.ts
+++ b/src/server/management/combo-routes.ts
@@ -53,6 +53,7 @@ import {
type DebugFlag,
} from "../../lib/debug-settings";
import { mergeModelCapabilities, modelCapabilitiesConfigError } from "../../config/provider-validation";
+import { modelRecordValue } from "../../reasoning-effort";
import { commitProviderPatch } from "./provider-patch-transaction";
import { ConfigWritePublishedError } from "../../config/persist-unlocked";
import {
@@ -344,17 +345,22 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise {
{ provider: "a", model: "m1" },
{ provider: "b", model: "m2" },
{ provider: "b", model: "m2" },
- { provider: "c", model: "m3" },
],
models,
)).toEqual([
{ provider: "b", model: "m2" },
]);
+ // A blocked member forces the image switch off, so nothing may be enrolled on save.
+ expect(comboVisionSidecarTargets(
+ [
+ { provider: "b", model: "m2" },
+ { provider: "c", model: "m3" },
+ ],
+ models,
+ )).toEqual([]);
});
test("ignores incomplete or catalog-absent targets", () => {
diff --git a/tests/providers/minimax-combo-image-input.test.ts b/tests/providers/minimax-combo-image-input.test.ts
index 9949fbd37c9..4ae06470c62 100644
--- a/tests/providers/minimax-combo-image-input.test.ts
+++ b/tests/providers/minimax-combo-image-input.test.ts
@@ -77,6 +77,10 @@ describe("MiniMax Coding Plan Combo image input", () => {
expect(comboImagesSupported(
[{ provider: region, model: M3 }, { provider: "other", model: "blind" }],
[...rows, { provider: "other", id: "blind", inputModalities: ["text"] }],
+ )).toBe(true); // a text-only member is covered by the Vision Sidecar
+ expect(comboImagesSupported(
+ [{ provider: region, model: M3 }, { provider: "other", model: "listener" }],
+ [...rows, { provider: "other", id: "listener", inputModalities: ["audio"] }],
)).toBe(false);
}
}
diff --git a/tests/routing/combo-vision-enrollment.test.ts b/tests/routing/combo-vision-enrollment.test.ts
index 3a4fd063ec7..c0202677789 100644
--- a/tests/routing/combo-vision-enrollment.test.ts
+++ b/tests/routing/combo-vision-enrollment.test.ts
@@ -179,6 +179,16 @@ describe("combo Vision Sidecar enrollment", () => {
combos: undefined,
}),
},
+ {
+ name: "provider modality hint declares audio only",
+ config: baseConfig({
+ providers: {
+ ...baseConfig().providers,
+ b: { ...baseConfig().providers.b!, modelInputModalities: { m2: ["audio"] } },
+ },
+ combos: undefined,
+ }),
+ },
];
for (const { config } of cases) {
saveConfig(config);
From c09715c43eedeffd5725a892bd108408e4cd8752 Mon Sep 17 00:00:00 2001
From: GOAT
Date: Thu, 15 Oct 2026 18:48:24 +0900
Subject: [PATCH 3/6] perf(models): share registry enrichment across
sidecar-consumer rows
GET /api/models classified every row with isVisionSidecarConsumer, which
rebuilds the registry-enriched provider per call (~0.1 ms/row, ~170 ms for
a 2000-row listing). A per-listing classifier reuses the eligibility
module's existing enrichment cache, so verdicts are unchanged.
Co-authored-by: x3M3x <98298256+x3M3x@users.noreply.github.com>
---
src/server/management/model-rows.ts | 5 +++--
src/vision/eligibility.ts | 13 +++++++++++++
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts
index 21aed0f5658..479c0c880cc 100644
--- a/src/server/management/model-rows.ts
+++ b/src/server/management/model-rows.ts
@@ -52,7 +52,7 @@ import { reconcileSuccessfulModelDiscoveries } from "../../providers/new-model-p
import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection";
import { catalogFastRowEligible, fastRowId } from "../fast-row";
import { knownEffortRowIds } from "../effort-row";
-import { isVisionSidecarConsumer } from "../../vision/eligibility";
+import { visionSidecarConsumerClassifier } from "../../vision/eligibility";
/**
* One row of the `/api/models` list. Routed rows spread a `CatalogModel`, so the shape is
@@ -506,6 +506,7 @@ export async function listManagementModelRows(
// a hidden real `x--fast` must never become a synthetic selector for another model.
const knownIds = config.fastRows === false ? new Set() : knownEffortRowIds(config);
for (const row of rows) knownIds.add(row.namespaced);
+ const isVisionSidecarConsumer = visionSidecarConsumerClassifier(config);
return rows.map(row => {
const pending = initialModelSelectionPending(config.providers[row.provider]);
const modelCosts = Object.hasOwn(config.providers, row.provider)
@@ -531,7 +532,7 @@ export async function listManagementModelRows(
...row,
// One shared definition with the request path: the GUI must not re-derive consumer
// status from declared-vs-advertised modalities (see the field doc above).
- visionSidecarConsumer: isVisionSidecarConsumer(config, row.provider, row.id),
+ visionSidecarConsumer: isVisionSidecarConsumer(row.provider, row.id),
...(!row.native && modelCosts !== undefined && Object.hasOwn(modelCosts, row.id)
? { manualPricing: true } : {}),
...(pending ? { disabled: true, initialSelectionPending: true } : {}),
diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts
index d772c8f9769..cdeefcc5b33 100644
--- a/src/vision/eligibility.ts
+++ b/src/vision/eligibility.ts
@@ -226,6 +226,19 @@ export function isVisionSidecarConsumer(config: VisionCapabilityConfig, provider
return isVisionSidecarConsumerWithCache(config, providerName, modelId, new Map());
}
+/**
+ * `isVisionSidecarConsumer` for many rows of one config. Rows of the same provider share one
+ * registry enrichment instead of rebuilding it per row, which matters for `/api/models`
+ * listings with thousands of rows. Bind it to a single config snapshot; it does not observe
+ * provider changes made after the first lookup.
+ */
+export function visionSidecarConsumerClassifier(
+ config: VisionCapabilityConfig,
+): (providerName: string, modelId: string) => boolean {
+ const cache: EnrichedProviderCache = new Map();
+ return (providerName, modelId) => isVisionSidecarConsumerWithCache(config, providerName, modelId, cache);
+}
+
/**
* Can this model accept an image on the wire? Sources are consulted in descending
* trustworthiness; the first that speaks wins. `undefined` means nothing knows,
From cf600036403a177f440409489134b084196443ce Mon Sep 17 00:00:00 2001
From: GOAT
Date: Thu, 15 Oct 2026 20:51:19 +0900
Subject: [PATCH 4/6] fix(gui): use hash navigation for the sidecar dashboard
link and a single modality pass
Co-authored-by: x3M3x
---
gui/src/components/combo-workspace-controls.tsx | 5 ++++-
gui/src/pages/Combos.tsx | 8 ++++----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx
index ccdace6affb..d9ad1f6b337 100644
--- a/gui/src/components/combo-workspace-controls.tsx
+++ b/gui/src/components/combo-workspace-controls.tsx
@@ -8,6 +8,7 @@ import {
newComboTarget,
} from "../combo-workspace-data";
import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons";
+import { navigateHash } from "../hash-routing";
import { useT } from "../i18n/shared";
import { Switch } from "../ui";
import { formatProviderDisplayName } from "../provider-icons";
@@ -140,7 +141,9 @@ export function ComboCapabilities({
{t("cws.capability.imageInputSidecarDisabled", {
models: sidecarTargets.map(({ provider, model }) => `${provider}/${model}`).join(", "),
})}{" "}
- {t("nav.dashboard")}
+
) : null}
diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx
index a4493c223c4..5b37b8b4c02 100644
--- a/gui/src/pages/Combos.tsx
+++ b/gui/src/pages/Combos.tsx
@@ -183,10 +183,10 @@ export default function Combos({
const catalogued = new Set();
const parseModalities = (raw: unknown): string[] | undefined =>
Array.isArray(raw)
- ? raw
- .filter((modality): modality is string => typeof modality === "string")
- .map((modality) => modality.trim())
- .filter(Boolean)
+ ? raw.flatMap((modality) => {
+ const trimmed = typeof modality === "string" ? modality.trim() : "";
+ return trimmed ? [trimmed] : [];
+ })
: undefined;
for (const row of modelRows) {
if (!row || typeof row !== "object") continue;
From b7393ffe4eb34e2ba5452e1b72b9f52f9884e160 Mon Sep 17 00:00:00 2001
From: GOAT
Date: Thu, 15 Oct 2026 20:54:29 +0900
Subject: [PATCH 5/6] test(gui): pin sidecar-eligible members keeping the combo
Image switch on
Text-only and mixed image/text members are covered by the Vision Sidecar, so the switch stays enabled; unknown and audio-only members still force it off. The cached capability rows now carry visionSidecarConsumer.
Co-authored-by: x3M3x
---
.../combo-capability-hidden-targets.test.tsx | 26 ++++++++++++++++---
1 file changed, 23 insertions(+), 3 deletions(-)
diff --git a/gui/tests/combo-capability-hidden-targets.test.tsx b/gui/tests/combo-capability-hidden-targets.test.tsx
index e4d7c75bec2..56bf68002c4 100644
--- a/gui/tests/combo-capability-hidden-targets.test.tsx
+++ b/gui/tests/combo-capability-hidden-targets.test.tsx
@@ -32,6 +32,9 @@ const hiddenImage: CatalogRow = {
const textModel: CatalogRow = {
provider: "text", id: "plain", inputModalities: ["text"], reasoningEfforts: ["high", "xhigh"],
};
+const audioModel: CatalogRow = {
+ provider: "audio", id: "listen", inputModalities: ["audio"], reasoningEfforts: ["high"],
+};
const target = (row: CatalogRow): Target => ({ provider: row.provider, model: row.id });
const combo = (targets: Target[], extra: Record = {}) => ({
id: "hidden-test", model: "combo/hidden-test", strategy: "failover", stickyLimit: 1,
@@ -136,15 +139,29 @@ test("a disabled image-capable target enables the Image switch and stays visible
expect(effortValues()).toEqual(["", "low", "high"]);
});
+// Text-capable members are covered by the Vision Sidecar, so they keep images on.
for (const [name, targets] of [
["text-only", [target(textModel)]],
- ["unknown", [{ provider: "vision", model: "missing" }]],
["mixed image/text", [target(hiddenImage), target(textModel)]],
] as const) {
- test(`${name} targets keep the Image switch off`, async () => {
+ test(`${name} targets keep the Image switch on through the Vision Sidecar`, async () => {
mockCatalog([hiddenImage, textModel], [...targets]);
await mount();
await openDetail();
+ expectImages(true);
+ });
+}
+
+// Members the sidecar cannot cover still force the switch off.
+for (const [name, targets] of [
+ ["unknown", [{ provider: "vision", model: "missing" }]],
+ ["audio-only", [target(audioModel)]],
+ ["mixed text/audio-only", [target(textModel), target(audioModel)]],
+] as const) {
+ test(`${name} targets keep the Image switch off`, async () => {
+ mockCatalog([hiddenImage, textModel, audioModel], [...targets]);
+ await mount();
+ await openDetail();
expectImages(false);
});
}
@@ -157,7 +174,9 @@ for (const provider of ["vision", "text"]) {
], [{ provider, model: "shared" }]);
await mount();
await openDetail();
- expectImages(provider === "vision");
+ // The text row is sidecar-eligible, so both resolve to an enabled Image switch;
+ // the provider still decides which reasoning ladder applies.
+ expectImages(true);
expect(effortValues()).toEqual(provider === "vision" ? ["", "low", "high"] : ["", "high", "xhigh"]);
});
}
@@ -221,6 +240,7 @@ test("cache remount and live revalidation preserve hidden-target capabilities",
const cached = readSessionListCacheEntry<{ capabilityModels: CatalogRow[]; models: CatalogRow[] }>(CACHE_KEY)!;
expect(cached.data.capabilityModels).toEqual([{
provider: "vision", id: "hidden", inputModalities: ["text", "image"], reasoningEfforts: ["low", "high"],
+ visionSidecarConsumer: false,
}]);
expect(cached.data.models).toEqual([]);
await act(async () => { root!.unmount(); });
From 7eff9c181f146a3e4d4941f9887cdab4583ca5ca Mon Sep 17 00:00:00 2001
From: GOAT
Date: Thu, 15 Oct 2026 20:57:37 +0900
Subject: [PATCH 6/6] fix(combos): validate the merged modelCapabilities map
before enrolling sidecar members
A provider already at the modelCapabilities cap accepted a valid one-entry patch and persisted an oversized map that load-time sanitization then truncated, dropping the new text-only declaration. The route now validates the merged map (400, nothing written) and re-checks it under the config lock.
Co-authored-by: x3M3x
---
src/server/management/combo-routes.ts | 15 ++++++++---
tests/routing/combo-vision-enrollment.test.ts | 27 +++++++++++++++++++
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index a1940d57242..a798de0fd23 100644
--- a/src/server/management/combo-routes.ts
+++ b/src/server/management/combo-routes.ts
@@ -365,9 +365,13 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise {
expect(config.providers?.a?.modelCapabilities).toBeUndefined();
});
});
+ test("PUT rejects enrollment that would push a provider's modelCapabilities past its limit", async () => {
+ await withTempHome(async () => {
+ const base = baseConfig();
+ // A provider already at the cap: the patch alone is valid, the merged map is not.
+ const full = Object.fromEntries(
+ Array.from({ length: MODEL_DISCOVERY_MAX_MODELS }, (_, i) => [`filler-${i}`, { contextTier: "long_context" as const }]),
+ );
+ const config = baseConfig({
+ providers: { ...base.providers, b: { ...base.providers.b!, modelCapabilities: full } },
+ combos: undefined,
+ });
+ saveConfig(config);
+ const before = readFileSync(getConfigPath(), "utf8");
+ const response = await comboApi(config, "PUT", "/api/combos", {
+ id: "mixed",
+ visionSidecarTargets: [{ provider: "b", model: "m2" }],
+ combo: { targets: [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }] },
+ });
+ expect(response?.status).toBe(400);
+ expect(String((await responseJson(response)).error)).toContain("too many models");
+ // Nothing is published: neither the combo nor a truncation-bound declaration.
+ expect(readFileSync(getConfigPath(), "utf8")).toBe(before);
+ expect(Object.keys(config.providers?.b?.modelCapabilities ?? {})).toHaveLength(MODEL_DISCOVERY_MAX_MODELS);
+ expect(config.combos?.mixed).toBeUndefined();
+ });
+ });
test("PUT rejects already-image-capable and audio-only sidecar targets instead of overwriting", async () => {
await withTempHome(async () => {
const cases: Array<{ name: string; config: OcxConfig }> = [