diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 434cdcbb46..6bc5f3b6ee 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -211,6 +211,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)).
@@ -304,6 +312,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"`. `"auto"` publishes image support only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
| `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. |
| `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. |
diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md
index 18ca8a3690..fd18aed5ad 100644
--- a/docs-site/src/content/docs/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/reference/configuration/routing.md
@@ -70,6 +70,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"` | `"auto"` publishes image only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
| `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. |
| `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. |
| `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. |
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 244d3d71ba..fe89fc04c7 100644
--- a/docs-site/src/content/docs/zh-cn/guides/combos.md
+++ b/docs-site/src/content/docs/zh-cn/guides/combos.md
@@ -158,6 +158,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 无法读取那段负载。
@@ -241,6 +245,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"`。`"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 |
| `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 |
| `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 |
| `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 |
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 166a2ce17b..11a3ad97ce 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
@@ -64,6 +64,7 @@ Codex Auth 页面将此 picker 行为作为选择加入项。关闭它会隐藏
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | 选择策略。目标顺序表示故障切换优先级;权重会影响平滑加权轮询。 |
| `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 仅在调用方省略 effort 且所选目标声明了请求的档位时应用。 |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 |
| `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 |
| `nativeAlias?` | `boolean` | `false` | 仅让当前受支持的裸原生 id 对该不带限定前缀的 id 优先;带账号或提供方限定的 OpenAI 路由仍是独立路由。 |
| `displayName?` | `string` | — | 仅用于 catalog 展示的标签;native alias 必须提供非空值。 |
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 56ce088238..4f5e96e0b7 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -60,6 +60,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 */
@@ -73,6 +78,7 @@ export interface ComboItem {
strategy: ComboStrategy;
stickyLimit: number;
defaultEffort: ComboEffort | null;
+ imageInput?: "auto" | "disabled";
targets: ComboTarget[];
}
@@ -177,6 +183,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
strategy: normalizeStrategy(r.strategy),
stickyLimit: normalizeStickyLimit(r.stickyLimit),
defaultEffort: normalizeDefaultEffort(r.defaultEffort),
+ imageInput: normalizeImageInput(r.imageInput),
targets,
});
}
@@ -236,6 +243,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) => {
@@ -252,6 +260,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: ComboStrategy;
stickyLimit?: number;
defaultEffort: ComboEffort | null;
+ imageInput?: "disabled";
alias?: string;
nativeAlias?: true;
displayName?: string;
@@ -266,6 +275,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() } : {}),
...(item.nativeAlias ? { nativeAlias: true } : {}),
@@ -372,6 +382,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 1c6f9e0593..16b266c513 100644
--- a/gui/src/components/combo-workspace-detail-panel.tsx
+++ b/gui/src/components/combo-workspace-detail-panel.tsx
@@ -12,7 +12,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";
@@ -81,7 +81,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.nativeAlias}:${baseline.displayName ?? ""}:${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.nativeAlias}:${baseline.displayName ?? ""}:${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) {
@@ -354,6 +354,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 a53882bef9..0408e61082 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1770,6 +1770,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 6d4347316f..1933e29d4b 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1804,6 +1804,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 3770c07224..fce4f5196f 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1838,6 +1838,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 1021288915..bfd58fffba 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1797,6 +1797,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 2f9fc958d1..ceffb75922 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1880,6 +1880,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 2ae8b6ac2c..b9869d0c8c 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1790,6 +1790,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 7b4fc86d67..691ba1146c 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;
@@ -148,6 +148,7 @@ export default function Combos({
namespaced?: unknown;
disabled?: unknown;
reasoningEfforts?: unknown;
+ inputModalities?: unknown;
};
if (typeof model.provider !== "string" || typeof model.id !== "string") continue;
const provider = model.provider.trim();
@@ -161,11 +162,18 @@ export default function Combos({
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 58279c128f..837f362d2f 100644
--- a/gui/src/styles-combos-workspace.css
+++ b/gui/src/styles-combos-workspace.css
@@ -322,6 +322,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;
@@ -489,6 +517,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 1031f073a1..7fb2c04892 100644
--- a/src/codex/catalog/aggregation.ts
+++ b/src/codex/catalog/aggregation.ts
@@ -129,9 +129,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;
// Unknown ladders (`undefined`) are wildcards for catalog derivation — same
// boundary as the GUI picker. An explicit empty ladder still constrains.
const advertisedLadders = members
diff --git a/src/combos/index.ts b/src/combos/index.ts
index 6041427a92..571eb540d5 100644
--- a/src/combos/index.ts
+++ b/src/combos/index.ts
@@ -39,6 +39,7 @@ export {
} from "./failover";
export {
comboIdFromRawBody,
+ comboRequestHasImageInput,
concreteComboRequestBody,
resetComboEffortWarningStateForTests,
} from "./request";
diff --git a/src/combos/request.ts b/src/combos/request.ts
index 7727b7689b..2b198aae7a 100644
--- a/src/combos/request.ts
+++ b/src/combos/request.ts
@@ -23,6 +23,36 @@ export function comboIdFromRawBody(body: unknown, config: OcxConfig): string | n
return resolveComboId(config, model);
}
+/**
+ * Detect image-bearing Responses *input* only.
+ *
+ * Must not walk the full request body: tool JSON schemas, metadata, or extension
+ * payloads can legally contain `{ "type": "input_image" }` without any image
+ * being dispatched. After previous_response_id expansion, scan the materialised
+ * `input` tree (message content and function_call_output.output).
+ */
+export function comboRequestHasImageInput(body: unknown): boolean {
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
+ return responsesInputHasImage((body as { input?: unknown }).input);
+}
+
+function responsesInputHasImage(input: unknown): boolean {
+ if (typeof input === "string" || input == null) return false;
+ if (!Array.isArray(input)) return false;
+ return input.some(responsesInputNodeHasImage);
+}
+
+function responsesInputNodeHasImage(value: unknown): boolean {
+ if (!value || typeof value !== "object") return false;
+ if (Array.isArray(value)) return value.some(responsesInputNodeHasImage);
+ const record = value as Record;
+ if (record.type === "input_image") return true;
+ // Message content parts and nested function_call_output content/output arrays.
+ if (record.content !== undefined && responsesInputNodeHasImage(record.content)) return true;
+ if (record.output !== undefined && responsesInputNodeHasImage(record.output)) return true;
+ return false;
+}
+
export function concreteComboRequestBody(
body: unknown,
target: Pick,
diff --git a/src/combos/types.ts b/src/combos/types.ts
index c82c861a02..d1ac034096 100644
--- a/src/combos/types.ts
+++ b/src/combos/types.ts
@@ -37,6 +37,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;
/** Explicit native-family alias opt-in. */
@@ -220,6 +222,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") {
@@ -339,6 +344,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,
nativeAlias: raw.nativeAlias === true,
displayName: displayName || null,
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index 9c197d0d74..d347bef449 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 0 && body === rawBody;
+ 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`);
+ }
+ // Expansion already materialised prior input. Drop the id so the child
+ // handleResponses path does not expand again and double-prepend history.
+ if (body !== rawBody && body && typeof body === "object" && !Array.isArray(body)) {
+ delete (body as Record).previous_response_id;
+ }
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.
@@ -1083,7 +1119,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];
@@ -1125,7 +1161,7 @@ export async function handleComboResponses(
};
const targetRoute = routeConcreteModel(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 fb45843a4d..b90a2d93e7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -923,6 +923,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 d654fd8960..4b7b0ecc86 100644
--- a/tests/codex-catalog.test.ts
+++ b/tests/codex-catalog.test.ts
@@ -66,6 +66,7 @@ function normalizedCombo(
strategy: "failover",
stickyLimit: 1,
defaultEffort: "medium",
+ imageInput: "auto",
alias: null,
nativeAlias: false,
displayName: null,
@@ -154,6 +155,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 68d439a2a6..75c19f8916 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 c67fd4cac3..d621375162 100644
--- a/tests/combo-workspace-data.test.ts
+++ b/tests/combo-workspace-data.test.ts
@@ -15,6 +15,7 @@ import {
updateComboAliasDraft,
validateComboDraft,
} from "../gui/src/combo-workspace-data";
+import { comboImagesSupported } from "../gui/src/combo-capabilities";
const configuredProviders = {
a: {},
@@ -95,6 +96,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+$/) }],
},
{
@@ -106,6 +108,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+$/) },
@@ -530,3 +533,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/combos.test.ts b/tests/combos.test.ts
index e46f10a8d3..b20f65efc7 100644
--- a/tests/combos.test.ts
+++ b/tests/combos.test.ts
@@ -16,6 +16,7 @@ import {
comboIdFromRawBody,
comboModelId,
comboPublicModelId,
+ comboRequestHasImageInput,
concreteComboRequestBody,
coolComboTarget,
getCombo,
@@ -214,6 +215,48 @@ describe("combo request cloning", () => {
expect(comboIdFromRawBody(null, config)).toBeNull();
});
+ test("comboRequestHasImageInput scans Responses input only, not tools or metadata", () => {
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }],
+ })).toBe(true);
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{ type: "input_image", image_url: "https://example.test/i.png" }],
+ })).toBe(true);
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{
+ type: "function_call_output",
+ call_id: "call_1",
+ output: [{ type: "input_image", image_url: "https://example.test/tool.png" }],
+ }],
+ })).toBe(true);
+ // Tool schemas / metadata may legally mention the same type string without
+ // carrying image content for the model.
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{ role: "user", content: "text only" }],
+ tools: [{
+ type: "function",
+ name: "describe",
+ parameters: {
+ type: "object",
+ properties: {
+ kind: { type: "string", enum: ["input_image", "input_text"] },
+ example: { type: "input_image" },
+ },
+ },
+ }],
+ metadata: { note: { type: "input_image" } },
+ })).toBe(false);
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: "plain text",
+ tools: [{ type: "function", function: { name: "x", parameters: { type: "input_image" } } }],
+ })).toBe(false);
+ });
+
test("clones the untouched body and injects an omitted combo default", () => {
const raw = { model: "combo/free", input: [{ role: "user", content: "hi" }] };
const concrete = concreteComboRequestBody(raw, target, "high", ["low", "high"]);
@@ -589,6 +632,7 @@ describe("combo validation and normalization", () => {
strategy: "failover",
stickyLimit: 1,
defaultEffort: "high",
+ imageInput: "auto",
alias: null,
nativeAlias: false,
displayName: null,
diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts
index 311d672507..36b304deaa 100644
--- a/tests/server-combo-failover-e2e.test.ts
+++ b/tests/server-combo-failover-e2e.test.ts
@@ -1304,6 +1304,105 @@ 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("disabled image input ignores tool schemas that only mention input_image", async () => {
+ let hits = 0;
+ const bodies: Array> = [];
+ const a = serve(async request => {
+ hits += 1;
+ bodies.push(await request.json() as Record);
+ return chatSuccess("text only", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ input: [{ role: "user", content: "describe without images" }],
+ tools: [{
+ type: "function",
+ name: "classify",
+ parameters: {
+ type: "object",
+ properties: {
+ part: { type: "string", enum: ["input_image", "input_text"] },
+ example: { type: "input_image" },
+ },
+ },
+ }],
+ metadata: { sample: { type: "input_image" } },
+ });
+ expect(response.status).toBe(200);
+ expect(hits).toBe(1);
+ // openai-chat upstream receives the bare model id after concrete routing.
+ expect(bodies[0]?.model).toBe("m1");
+ });
+
+ test("disabled image input rejects unavailable previous_response_id before dispatch", 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, {
+ previous_response_id: "resp_missing_local_state",
+ input: [{ role: "user", content: "continue" }],
+ });
+ expect(response.status).toBe(400);
+ expect(await response.text()).toContain("Continuation state is unavailable");
+ expect(hits).toBe(0);
+ });
+
+ test("disabled image input expands text-only previous_response_id and dispatches once", async () => {
+ const { rememberResponseState } = await import("../src/responses/state");
+ rememberResponseState(
+ { model: "combo/free", input: [{ role: "user", content: "earlier text" }] },
+ {
+ id: "resp_combo_text_prev",
+ status: "completed",
+ output: [{ type: "message", role: "assistant", content: "ack" }],
+ },
+ );
+ const bodies: Array> = [];
+ const a = serve(async request => {
+ bodies.push(await request.json() as Record);
+ return chatSuccess("continued", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ previous_response_id: "resp_combo_text_prev",
+ input: [{ role: "user", content: "next turn" }],
+ });
+ expect(response.status).toBe(200);
+ expect(bodies).toHaveLength(1);
+ const child = bodies[0]!;
+ // Parent already expanded; child must not keep previous_response_id (would double-prepend).
+ expect(child.previous_response_id).toBeUndefined();
+ const inputText = JSON.stringify(child.input ?? child.messages ?? child);
+ expect(inputText).toContain("earlier text");
+ expect(inputText).toContain("next turn");
+ });
+
test("fresh child reparsing recomputes vision and effort per target", async () => {
const bodies: Array<{ provider: string; body: Record }> = [];
const a = serve(async request => {