diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 040da7ec86..fb7d8adebb 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -164,6 +164,14 @@ default and leaves the target's own behavior unchanged. Supported values are `lo
`high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to
the caller and target.
+## Image / multimodal capability
+
+By default a combo publishes the **intersection** of its targets' input modalities (image is
+enabled only when every target advertises it). Set `imageInput: "disabled"` to force text-only
+even when every target supports images — the catalog drops `image` from `inputModalities`, and
+image-bearing requests are rejected with HTTP 400 before any target is called. `"auto"` (or
+omitting the field) keeps the automatic intersection.
+
## 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)).
@@ -256,6 +264,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| `strategy` | No | `"failover"` | `"failover"` or `"round-robin"`. |
| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. |
| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. |
+| `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"disabled"` drops image from published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |
| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
## Troubleshooting
diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md
index 862dd9867c..6c0f50ad25 100644
--- a/docs-site/src/content/docs/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/reference/configuration/routing.md
@@ -63,6 +63,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape smooth weighted round-robin. |
| `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Applied only when the caller omits effort and the selected target advertises the requested rung. |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"disabled"` drops image from the published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |
| `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. |
```json
diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md
index 0a73d5f3ea..ed9965467b 100644
--- a/docs-site/src/content/docs/zh-cn/guides/combos.md
+++ b/docs-site/src/content/docs/zh-cn/guides/combos.md
@@ -131,6 +131,10 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。
当目标能力未知,或者不包含配置的 effort 时,opencodex 会省略默认值,并保持目标自身行为不变。支持的值是 `low`、`medium`、`high`、`xhigh`、`max` 和 `ultra`;省略该字段或将其设为 `null`,就会把 effort 完全交给调用方和目标。
+## 图片 / 多模态能力
+
+默认情况下,combo 会发布其目标 **input modalities 的交集**(只有当每个目标都声明支持图片时,图片才会启用)。设置 `imageInput: "disabled"` 可在目标均支持图片时仍强制仅文本——目录会从 `inputModalities` 中去掉 `image`,带图请求会在分发前以 HTTP 400 拒绝。`"auto"`(或省略该字段)保持自动交集。
+
## 加密的 v2 子代理任务
对于 Codex v2 子代理,有一个重要限制([issue #92](https://github.com/lidge-jun/opencodex/issues/92))。原生父进程只能把新启动 worker 的任务,以为原生 ChatGPT 后端生成的密文形式发送出去。外部 provider 无法读取那段负载。
@@ -210,6 +214,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键:
| `strategy` | 否 | `"failover"` | `"failover"` 或 `"round-robin"`。 |
| `stickyLimit` | 否 | `1` | 每次轮询选择可连续处理的成功请求数,范围为 1 到 100。 |
| `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 |
+| `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
| `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 |
## 故障排查
diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
index b718ed4ed7..a34ec0d23c 100644
--- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
@@ -57,6 +57,7 @@ selector 校验、冲突规则和隐私说明见[提供方配置](/reference/con
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | 选择策略。目标顺序表示故障切换优先级;权重会影响平滑加权轮询。 |
| `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 仅在调用方省略 effort 且所选目标声明了请求的档位时应用。 |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
| `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 |
```json
diff --git a/gui/src/combo-capabilities.ts b/gui/src/combo-capabilities.ts
new file mode 100644
index 0000000000..32f91f66b9
--- /dev/null
+++ b/gui/src/combo-capabilities.ts
@@ -0,0 +1,14 @@
+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). */
+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");
+ });
+}
diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts
index bc29bff703..d04d93fffa 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -55,6 +55,11 @@ export function newComboTarget(partial: Partial = {}): ComboTarget
};
}
+
+function normalizeImageInput(value: unknown): "auto" | "disabled" {
+ return value === "disabled" ? "disabled" : "auto";
+}
+
export interface ComboItem {
id: string;
/** Wire id shown to clients, e.g. combo/free */
@@ -64,6 +69,7 @@ export interface ComboItem {
strategy: ComboStrategy;
stickyLimit: number;
defaultEffort: ComboEffort | null;
+ imageInput?: "auto" | "disabled";
targets: ComboTarget[];
}
@@ -153,6 +159,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
strategy: normalizeStrategy(r.strategy),
stickyLimit: normalizeStickyLimit(r.stickyLimit),
defaultEffort: normalizeDefaultEffort(r.defaultEffort),
+ imageInput: normalizeImageInput(r.imageInput),
targets,
});
}
@@ -210,6 +217,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
|| a.strategy !== b.strategy
|| a.stickyLimit !== b.stickyLimit
|| a.defaultEffort !== b.defaultEffort
+ || (a.imageInput ?? "auto") !== (b.imageInput ?? "auto")
) return false;
if (a.targets.length !== b.targets.length) return false;
return a.targets.every((t, i) => {
@@ -226,6 +234,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: ComboStrategy;
stickyLimit?: number;
defaultEffort: ComboEffort | null;
+ imageInput?: "disabled";
alias?: string;
};
} {
@@ -238,6 +247,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
: { provider: target.provider.trim(), model: target.model.trim() }),
strategy: item.strategy,
defaultEffort: item.defaultEffort,
+ ...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}),
...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}),
},
@@ -326,6 +336,7 @@ export function emptyDraft(id = ""): ComboItem {
strategy: "failover",
stickyLimit: 1,
defaultEffort: null,
+ imageInput: "auto",
targets: [newComboTarget()],
};
}
diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx
index cff828c5c8..4d334a759f 100644
--- a/gui/src/components/combo-workspace-add-modal.tsx
+++ b/gui/src/components/combo-workspace-add-modal.tsx
@@ -10,7 +10,7 @@ import { IconX } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
-import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
+import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";
export function AddComboModal({
@@ -204,6 +204,13 @@ export function AddComboModal({
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
+ setDraft((d) => ({ ...d, ...patch }))}
+ />
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx
index 8a7277fd48..0a7ecaa65d 100644
--- a/gui/src/components/combo-workspace-controls.tsx
+++ b/gui/src/components/combo-workspace-controls.tsx
@@ -1,8 +1,10 @@
import { useState } from "react";
import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data";
+import { comboImagesSupported } from "../combo-capabilities";
import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data";
import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
+import { Switch } from "../ui";
import { formatProviderDisplayName } from "../provider-icons";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { clampedNumberInput, enabledProviders, modelsForProvider } from "./combo-workspace-utils";
@@ -83,6 +85,49 @@ export function EffortSelect({
);
}
+
+export function ComboCapabilities({
+ targets,
+ models,
+ imageInput,
+ disabled,
+ onChange,
+}: {
+ targets: ComboTarget[];
+ models: ModelOption[];
+ imageInput: "auto" | "disabled";
+ disabled?: boolean;
+ onChange: (patch: { imageInput?: "auto" | "disabled" }) => void;
+}) {
+ const t = useT();
+ const imagesSupported = comboImagesSupported(targets, models);
+ // Default: checked (auto) when supported; force off when any target lacks image.
+ const effectiveOn = imagesSupported && imageInput !== "disabled";
+
+ return (
+
+ {t("cws.capabilities")}
+
+
+
{t("cws.capability.imageInput")}
+
+ {imagesSupported ? t("cws.capability.imageInputHint") : t("cws.capability.imageInputUnavailable")}
+
+
+
{
+ if (!imagesSupported) return;
+ onChange({ imageInput: imageInput === "auto" ? "disabled" : "auto" });
+ }}
+ disabled={disabled || !imagesSupported}
+ label={t("cws.capability.imageInput")}
+ />
+
+
+ );
+}
+
export function TargetEditor({
targets,
strategy,
diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx
index caaa05bed1..c75c59259d 100644
--- a/gui/src/components/combo-workspace-detail-panel.tsx
+++ b/gui/src/components/combo-workspace-detail-panel.tsx
@@ -11,7 +11,7 @@ import { IconChevron, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
-import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
+import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";
type DetailTab = "config" | "about";
@@ -52,7 +52,7 @@ export function DetailPanel({
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [copied, setCopied] = useState(false);
const dirty = !draftEquals(draft, baseline);
- const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
+ const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
const effortMap = useMemo(() => {
const map = new Map
();
for (const model of models) {
@@ -267,6 +267,13 @@ export function DetailPanel({
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
+ updateDraft((d) => ({ ...d, ...patch }))}
+ />
) : (
diff --git a/gui/src/components/combo-workspace-types.ts b/gui/src/components/combo-workspace-types.ts
index 39870e90f2..c4dc1eac6c 100644
--- a/gui/src/components/combo-workspace-types.ts
+++ b/gui/src/components/combo-workspace-types.ts
@@ -13,6 +13,7 @@ export type ModelOption = {
id: string;
namespaced?: string;
reasoningEfforts?: string[];
+ inputModalities?: string[];
};
export interface ComboWorkspaceProps {
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 7703f6c09f..a598cb3e72 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1734,6 +1734,10 @@ export const de: Record = {
"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.imageInputHint": "Standardmäßig aktiv, wenn jedes Ziel Bilder unterstützt. Ausschalten für nur Text.",
+ "cws.capability.imageInput": "Bild / multimodal",
+ "cws.capabilities": "Fähigkeiten",
"cws.field.defaultEffortUnsupported": "Dieser Aufwand liegt nicht in der gemeinsamen Leiter der Ziele — er wird zur Anfragezeit ignoriert oder angepasst.",
"cws.field.defaultEffortUnsupportedOption": "nicht in der Schnittmenge",
"cws.targets": "Ziele",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 9ae8861249..db6576ddee 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1768,6 +1768,10 @@ 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.imageInputHint": "On by default when every target supports images. Turn off to accept text only.",
+ "cws.capability.imageInput": "Image / multimodal",
+ "cws.capabilities": "Capabilities",
"cws.field.defaultEffortUnsupported": "This effort is not in the targets' common ladder — it will be ignored or snapped at request time.",
"cws.field.defaultEffortUnsupportedOption": "not in intersection",
"cws.targets": "Targets",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index d5f7a915b4..95d3dcfecf 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1802,6 +1802,10 @@ export const ja: Record = {
"cws.field.defaultEffort": "デフォルトの推論",
"cws.field.defaultEffortNone": "なし(ターゲットのデフォルト)",
"cws.field.defaultEffortHint": "クライアントが推論負荷を省略した場合のみ使用されます。選択肢は選択ターゲットが広告する負荷の交差です。",
+ "cws.capability.imageInputUnavailable": "選択した全ターゲットが画像入力に対応すると有効になります。",
+ "cws.capability.imageInputHint": "全ターゲットが画像対応なら既定でオン。オフにするとテキストのみ。",
+ "cws.capability.imageInput": "画像 / マルチモーダル",
+ "cws.capabilities": "能力",
"cws.field.defaultEffortUnsupported": "この負荷はターゲット共通の階段にありません — リクエスト時に無視またはスナップされます。",
"cws.field.defaultEffortUnsupportedOption": "交差に含まれない",
"cws.targets": "ターゲット",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index e0ee1af6a4..40bce65d88 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1761,6 +1761,10 @@ export const ko: Record = {
"cws.field.defaultEffort": "기본 추론 수준",
"cws.field.defaultEffortNone": "없음 (대상 기본값)",
"cws.field.defaultEffortHint": "클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 선택한 대상이 광고하는 수준의 교집합입니다.",
+ "cws.capability.imageInputUnavailable": "선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다.",
+ "cws.capability.imageInputHint": "모든 대상이 이미지를 지원하면 기본으로 켜집니다. 끄면 텍스트만 허용합니다.",
+ "cws.capability.imageInput": "이미지 / 멀티모달",
+ "cws.capabilities": "기능",
"cws.field.defaultEffortUnsupported": "이 수준은 대상의 공통 사다리에 없습니다 — 요청 시 무시되거나 스냅됩니다.",
"cws.field.defaultEffortUnsupportedOption": "교집합에 없음",
"cws.targets": "대상",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index f707bb5f5e..21f9374621 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1844,6 +1844,10 @@ export const ru: Record = {
"cws.field.defaultEffort": "Рассуждения по умолчанию",
"cws.field.defaultEffortNone": "Нет (по умолчанию для цели)",
"cws.field.defaultEffortHint": "Используется, только если клиент не указал уровень рассуждений. Варианты — пересечение заявленных уровней выбранных целей.",
+ "cws.capability.imageInputUnavailable": "Доступно, когда все выбранные цели поддерживают ввод изображений.",
+ "cws.capability.imageInputHint": "Включено по умолчанию, если все цели поддерживают изображения. Выключите, чтобы принимать только текст.",
+ "cws.capability.imageInput": "Изображения / мультимодальность",
+ "cws.capabilities": "Возможности",
"cws.field.defaultEffortUnsupported": "Этот уровень не входит в общую лестницу целей — при запросе он будет проигнорирован или снижен.",
"cws.field.defaultEffortUnsupportedOption": "нет в пересечении",
"cws.targets": "Цели",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 14113c6110..cd67e365ea 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1754,6 +1754,10 @@ export const zh: Record = {
"cws.field.defaultEffort": "默认推理级别",
"cws.field.defaultEffortNone": "无(使用目标默认)",
"cws.field.defaultEffortHint": "仅在客户端未指定推理级别时使用。选项为所选目标已公布努力级别的交集。",
+ "cws.capability.imageInputUnavailable": "所有已选目标均支持图片输入后才可用。",
+ "cws.capability.imageInputHint": "所有目标均支持图片时默认开启;关闭后仅接受文本。",
+ "cws.capability.imageInput": "图片 / 多模态",
+ "cws.capabilities": "能力",
"cws.field.defaultEffortUnsupported": "该级别不在目标的公共阶梯中 — 请求时会被忽略或就近映射。",
"cws.field.defaultEffortUnsupportedOption": "不在交集中",
"cws.targets": "目标",
diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx
index 64f671d76c..5088d0a9b2 100644
--- a/gui/src/pages/Combos.tsx
+++ b/gui/src/pages/Combos.tsx
@@ -21,7 +21,7 @@ type ProviderOption = {
adapter?: string;
baseUrl?: string;
};
-type ModelOption = { provider: string; id: string; namespaced?: string; reasoningEfforts?: string[] };
+type ModelOption = { provider: string; id: string; namespaced?: string; reasoningEfforts?: string[]; inputModalities?: string[] };
type ProviderDto = {
adapter: string;
baseUrl: string;
@@ -119,6 +119,7 @@ export default function Combos({ apiBase }: { apiBase: string }) {
namespaced?: unknown;
disabled?: unknown;
reasoningEfforts?: unknown;
+ inputModalities?: unknown;
};
if (typeof model.provider !== "string" || typeof model.id !== "string") continue;
const provider = model.provider.trim();
@@ -132,11 +133,18 @@ export default function Combos({ apiBase }: { apiBase: string }) {
const reasoningEfforts = Array.isArray(model.reasoningEfforts)
? model.reasoningEfforts.filter((effort): effort is string => typeof effort === "string")
: undefined;
+ const inputModalities = Array.isArray(model.inputModalities)
+ ? model.inputModalities
+ .filter((modality): modality is string => typeof modality === "string")
+ .map((modality) => modality.trim())
+ .filter(Boolean)
+ : undefined;
models.push({
provider,
id,
namespaced: typeof model.namespaced === "string" ? model.namespaced : undefined,
...(reasoningEfforts ? { reasoningEfforts } : {}),
+ ...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
});
}
diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css
index f73aa3b8a0..05d0dd9714 100644
--- a/gui/src/styles-combos-workspace.css
+++ b/gui/src/styles-combos-workspace.css
@@ -318,6 +318,34 @@
overflow-wrap: anywhere;
}
+.cwi-capabilities {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius);
+ background: var(--raised);
+}
+
+.cwi-capability-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.cwi-capability-label {
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.cwi-capability-hint {
+ margin: 3px 0 0;
+ font-size: 12px;
+}
+
+
.cwi-target-list {
display: flex;
flex-direction: column;
@@ -485,6 +513,10 @@
border-bottom: 1px solid var(--border);
}
+ .cwi-capability-row {
+ align-items: flex-start;
+ }
+
.cwi-target-row,
.cwi-target-row--failover {
grid-template-columns: 28px auto 1fr auto;
diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts
index 03b0e17c96..7a2d50f8c6 100644
--- a/src/codex/catalog/aggregation.ts
+++ b/src/codex/catalog/aggregation.ts
@@ -128,9 +128,13 @@ export function deriveComboCatalogModel(
): CatalogModel | null {
if (comboCatalogOmissionReason(combo, members) !== null) return null;
- const inputModalities = intersectStrings(
+ const derivedInputModalities = intersectStrings(
members.map(member => member.inputModalities ?? ["text"]),
);
+ const inputModalities = combo.imageInput === "disabled"
+ ? derivedInputModalities.filter(modality => modality !== "image")
+ : derivedInputModalities;
+ if (inputModalities.length === 0) return null;
const reasoningEfforts = intersectStrings(
members.map(member => member.reasoningEfforts ?? []),
);
diff --git a/src/combos/index.ts b/src/combos/index.ts
index f1a0e8ebd0..3b4e857189 100644
--- a/src/combos/index.ts
+++ b/src/combos/index.ts
@@ -36,6 +36,7 @@ export {
} from "./failover";
export {
comboIdFromRawBody,
+ comboRequestHasImageInput,
concreteComboRequestBody,
resetComboEffortWarningStateForTests,
} from "./request";
diff --git a/src/combos/request.ts b/src/combos/request.ts
index c7d535f7c9..fa9abf319f 100644
--- a/src/combos/request.ts
+++ b/src/combos/request.ts
@@ -23,6 +23,17 @@ export function comboIdFromRawBody(body: unknown, config: OcxConfig): string | n
return resolveComboId(config, model);
}
+export function comboRequestHasImageInput(body: unknown): boolean {
+ const visit = (value: unknown): boolean => {
+ if (!value || typeof value !== "object") return false;
+ if (Array.isArray(value)) return value.some(visit);
+ const record = value as Record;
+ if (record.type === "input_image") return true;
+ return Object.values(record).some(visit);
+ };
+ return visit(body);
+}
+
export function concreteComboRequestBody(
body: unknown,
target: Pick,
diff --git a/src/combos/types.ts b/src/combos/types.ts
index c5dcd51b63..e540bea07c 100644
--- a/src/combos/types.ts
+++ b/src/combos/types.ts
@@ -40,6 +40,8 @@ export interface NormalizedComboConfig {
strategy: OcxComboStrategy;
stickyLimit: number;
defaultEffort: OcxComboDefaultEffort | null;
+ /** Disable image input; `auto` preserves the intersection derived from all targets. */
+ imageInput: "auto" | "disabled";
/** Trimmed public alias, or null when the combo keeps the default `combo/` slug. */
alias: string | null;
targets: Array>;
@@ -187,6 +189,9 @@ export function comboConfigIssues(
message: "defaultEffort must be one of: low, medium, high, xhigh, max, ultra",
});
}
+ if (body.imageInput !== undefined && body.imageInput !== "auto" && body.imageInput !== "disabled") {
+ issues.push({ path: ["imageInput"], message: 'imageInput must be "auto" or "disabled"' });
+ }
if (body.alias !== undefined) {
if (typeof body.alias !== "string") {
@@ -275,6 +280,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
strategy: raw.strategy ?? "failover",
stickyLimit: raw.stickyLimit ?? 1,
defaultEffort: raw.defaultEffort ?? null,
+ imageInput: raw.imageInput === "disabled" ? "disabled" : "auto",
alias: alias || null,
targets: raw.targets.map(target => ({
provider: target.provider.trim(),
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index 81b25d7c8e..f9d9000d94 100644
--- a/src/server/management/combo-routes.ts
+++ b/src/server/management/combo-routes.ts
@@ -65,6 +65,16 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
import type { ManagementContext } from "./context";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
+
+/** Management wire shape: omit default imageInput "auto" (persist/response sparse). */
+function sparseComboConfig(combo: T): Omit & { imageInput?: "disabled" } {
+ const { imageInput, ...rest } = combo;
+ return {
+ ...rest,
+ ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
+ };
+}
+
export async function handleComboRoutes(ctx: ManagementContext): Promise {
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
@@ -75,7 +85,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise rest)(normalized)
- : normalized;
+ // Persist only non-default capability fields so config stays sparse.
+ const stored: import("../../types").OcxComboConfig = (({
+ alias,
+ imageInput,
+ ...rest
+ }) => ({
+ ...rest,
+ ...(alias === null ? {} : { alias }),
+ ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
+ }))(normalized);
const sourceId = renameFrom ?? id;
const previous = config.combos?.[sourceId];
const oldPublicModel = previous ? comboPublicModelId(sourceId, previous) : null;
@@ -197,7 +214,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise 0;
+ if (combo.imageInput === "disabled" && unresolvedPrevious) {
+ return formatErrorResponse(
+ 400,
+ "previous_response_not_found",
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
+ );
+ }
+ if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
+ return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
+ }
const adoptFailedChildLog = (childLog: RequestLogContext): void => {
// Attempts remain the complete physical history; the logical row mirrors the most recent
// failed target so an exhausted combo still has useful top-level reasoning diagnostics.
@@ -1019,7 +1046,7 @@ export async function handleComboResponses(
};
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
- (rawBody as { input?: unknown } | undefined)?.input,
+ (body as { input?: unknown } | undefined)?.input,
);
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
const provider = config.providers[target.provider];
@@ -1061,7 +1088,7 @@ export async function handleComboResponses(
};
const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
const childBody = concreteComboRequestBody(
- rawBody,
+ body,
pick.target,
comboDefaultEffort(config, comboId),
supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
diff --git a/src/types.ts b/src/types.ts
index 7e1aff4422..6eef7d04ca 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -864,6 +864,12 @@ export interface OcxComboConfig {
stickyLimit?: number;
/** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
defaultEffort?: OcxComboDefaultEffort | null;
+ /**
+ * Disable image input even when every target supports it.
+ * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when
+ * the target intersection includes image).
+ */
+ imageInput?: "auto" | "disabled";
/**
* Optional public model name replacing the default `combo/` slug. Bare names
* without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a
diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts
index 1f6896f8a2..868f9440fe 100644
--- a/tests/codex-catalog.test.ts
+++ b/tests/codex-catalog.test.ts
@@ -50,6 +50,7 @@ function normalizedCombo(
strategy: "failover",
stickyLimit: 1,
defaultEffort: "medium",
+ imageInput: "auto",
alias: null,
targets: [
{ provider: "a", model: "m1", weight: 1 },
@@ -136,6 +137,18 @@ describe("live model provenance (#448 custom-model misclassification)", () => {
});
describe("combo catalog capability intersection", () => {
+
+ test("imageInput disabled strips image even when every member supports it", () => {
+ const visionMembers = [
+ { provider: "a", id: "m1", contextWindow: 128_000, maxInputTokens: 100_000, inputModalities: ["text", "image"], reasoningEfforts: ["low"] },
+ { provider: "b", id: "m2", contextWindow: 128_000, maxInputTokens: 100_000, inputModalities: ["text", "image"], reasoningEfforts: ["low"] },
+ ];
+ expect(deriveComboCatalogModel("text-only", normalizedCombo({ imageInput: "disabled" }), visionMembers))
+ .toEqual(expect.objectContaining({ inputModalities: ["text"] }));
+ expect(deriveComboCatalogModel("vision", normalizedCombo({ imageInput: "auto" }), visionMembers))
+ .toEqual(expect.objectContaining({ inputModalities: expect.arrayContaining(["text", "image"]) }));
+ });
+
const memberA = {
provider: "a",
id: "m1",
diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts
index fd4701b903..945bc34e56 100644
--- a/tests/combo-management-api.test.ts
+++ b/tests/combo-management-api.test.ts
@@ -268,6 +268,31 @@ describe("combo management API", () => {
const listed = await responseJson(await comboApi(config, "GET", "/api/combos"));
expect((listed.combos as Array<{ id: string }>).map(row => row.id)).toEqual(["alpha", "zeta"]);
expect(listComboIds(config)).toEqual(["alpha", "zeta"]);
+ // Default imageInput is not written to disk — only explicit "disabled" is.
+ expect(config.combos?.zeta).not.toHaveProperty("imageInput");
+ });
+ });
+
+ test("PUT persists explicit imageInput disabled", async () => {
+ await withTempHome(async () => {
+ const config = baseConfig({ combos: undefined });
+ saveConfig(config);
+ const response = await comboApi(config, "PUT", "/api/combos", {
+ id: "limited",
+ combo: {
+ targets: [{ provider: "a", model: "m1" }],
+ imageInput: "disabled",
+ },
+ });
+ expect(response?.status).toBe(200);
+ expect(await responseJson(response)).toMatchObject({
+ combo: { imageInput: "disabled" },
+ });
+ expect(config.combos?.limited).toMatchObject({ imageInput: "disabled" });
+ const listed = await responseJson(await comboApi(config, "GET", "/api/combos"));
+ expect(listed.combos).toEqual([expect.objectContaining({
+ id: "limited", imageInput: "disabled",
+ })]);
});
});
diff --git a/tests/combo-workspace-data.test.ts b/tests/combo-workspace-data.test.ts
index 43a6c00366..5a1a6e6e37 100644
--- a/tests/combo-workspace-data.test.ts
+++ b/tests/combo-workspace-data.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import {
- type ComboItem,
buildComboAttention,
comboPublicModelId,
draftEquals,
@@ -11,8 +10,10 @@ import {
isValidComboId,
parseComboList,
toPutBody,
+ type ComboItem,
validateComboDraft,
} from "../gui/src/combo-workspace-data";
+import { comboImagesSupported } from "../gui/src/combo-capabilities";
const configuredProviders = {
a: {},
@@ -89,6 +90,7 @@ describe("combo-workspace-data", () => {
strategy: "failover",
stickyLimit: 1,
defaultEffort: null,
+ imageInput: "auto",
targets: [{ provider: "a", model: "m1", weight: 1, clientKey: expect.stringMatching(/^ct-\d+$/) }],
},
{
@@ -98,6 +100,7 @@ describe("combo-workspace-data", () => {
strategy: "round-robin",
stickyLimit: 4,
defaultEffort: "high",
+ imageInput: "auto",
targets: [
{ provider: "a", model: "m1", weight: 3, clientKey: expect.stringMatching(/^ct-\d+$/) },
{ provider: "b", model: "m2", weight: 1, clientKey: expect.stringMatching(/^ct-\d+$/) },
@@ -429,3 +432,70 @@ describe("combo-workspace-data", () => {
)).toBe(false);
});
});
+
+
+describe("comboImagesSupported", () => {
+ test("returns false with no targets or incomplete targets", () => {
+ expect(comboImagesSupported([], [])).toBe(false);
+ expect(comboImagesSupported([{ provider: "", model: "" }], [])).toBe(false);
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "vision" }, { provider: "", model: "" }],
+ [{ provider: "a", id: "vision", inputModalities: ["text", "image"] }],
+ )).toBe(false);
+ });
+
+ test("returns true only when every complete target advertises image", () => {
+ const models = [
+ { provider: "a", id: "m1", inputModalities: ["text", "image"] },
+ { provider: "b", id: "m2", inputModalities: ["text", "image"] },
+ ];
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }],
+ models,
+ )).toBe(true);
+ });
+
+ test("returns false when any target is missing from the catalog or lacks image", () => {
+ const models = [
+ { provider: "a", id: "m1", inputModalities: ["text", "image"] },
+ { provider: "b", id: "m2", inputModalities: ["text"] },
+ ];
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }],
+ models,
+ )).toBe(false);
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "m1" }, { provider: "b", model: "ghost" }],
+ models,
+ )).toBe(false);
+ });
+});
+
+describe("combo imageInput draft persistence", () => {
+ test("parseComboList preserves explicit disabled", () => {
+ const items = parseComboList({
+ combos: [{
+ id: "limited",
+ strategy: "failover",
+ imageInput: "disabled",
+ targets: [{ provider: "a", model: "m1" }],
+ }],
+ });
+ expect(items[0]?.imageInput).toBe("disabled");
+ });
+
+ test("draftEquals distinguishes disabled from auto", () => {
+ const base = emptyDraft("x");
+ const disabled = { ...base, imageInput: "disabled" as const };
+ expect(draftEquals(base, { ...base, imageInput: "auto" })).toBe(true);
+ expect(draftEquals(base, disabled)).toBe(false);
+ });
+
+ test("toPutBody emits imageInput only when disabled", () => {
+ const auto = emptyDraft("x");
+ auto.targets = [{ provider: "a", model: "m1" }];
+ expect(toPutBody(auto).combo).not.toHaveProperty("imageInput");
+ const disabled = { ...auto, imageInput: "disabled" as const };
+ expect(toPutBody(disabled).combo.imageInput).toBe("disabled");
+ });
+});
diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts
index 490743f33c..0a75592a78 100644
--- a/tests/server-combo-failover-e2e.test.ts
+++ b/tests/server-combo-failover-e2e.test.ts
@@ -1275,6 +1275,23 @@ describe("server combo failover 030 activation matrix", () => {
expect(bHits).toBe(2);
});
+ test("disabled image input rejects the request before any combo target is called", async () => {
+ let hits = 0;
+ const a = serve(() => {
+ hits += 1;
+ return chatSuccess("unexpected", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }],
+ });
+ expect(response.status).toBe(400);
+ expect(await response.text()).toContain("does not accept image input");
+ expect(hits).toBe(0);
+ });
+
test("fresh child reparsing recomputes vision and effort per target", async () => {
const bodies: Array<{ provider: string; body: Record }> = [];
const a = serve(async request => {