diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 040da7ec86..9dda2843c7 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -160,9 +160,18 @@ If the request has no `reasoning` object, opencodex creates one. If `reasoning` never overwritten. When target capability is unknown or does not include the configured effort, opencodex omits the -default and leaves the target's own behavior unchanged. Supported values are `low`, `medium`, -`high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to -the caller and target. +default and leaves the target's own behavior unchanged. Debug logs distinguish `unknown` (no ladder +metadata) from `unsupported` (ladder present without the value). Supported values are `low`, +`medium`, `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 diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 862dd9867c..fa90f25fa0 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 @@ -182,13 +183,16 @@ Per-request route-decision traces are recorded when a policy profile executes. A combo remains directly routable even when it cannot be listed. `ocx sync`, `/v1/models`, and the Codex picker list it only when every target exposes capabilities that can be intersected: -- a positive `contextWindow`, from live metadata, registry hints, or provider - `modelContextWindows` / `contextWindow`; and +- a positive `contextWindow`, from live metadata, registry hints, provider + `modelContextWindows` / `contextWindow`, or — when the provider is known but every source + omits a window — a conservative 128,000-token fallback (clamped by `providerContextCaps` when + set); and - a non-empty `inputModalities` intersection, treating an omitted member value as `["text"]`. -A bare relay id with no context metadata or targets with disjoint modalities removes the combo from -the catalog. Sync emits a summary warning and the dashboard marks it **Needs attention**. Add context -metadata, align modalities, or target models with discoverable compatible capabilities. +A target on an unknown/disabled provider with no discovery row, or targets with disjoint modalities, +removes the combo from the catalog. Sync emits a summary warning and the dashboard marks it +**Needs attention**. Add context metadata, align modalities, or target models with discoverable +compatible capabilities. ## Request history and routing analytics @@ -222,3 +226,4 @@ The history index is disposable - deleting `routing-history.sqlite` triggers an automatic rebuild from `usage.jsonl` on the next query; `ocx logs rebuild-index` forces one. Nothing in this system auto-tunes weights, budgets, or candidate sets. + 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..89d125aa75 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -129,7 +129,11 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 如果请求没有 `reasoning` 对象,opencodex 会创建一个。如果 `reasoning` 存在但没有 `effort` 属性,它会保留其他字段并添加默认值。调用方提供的 effort 永远不会被覆盖。 -当目标能力未知,或者不包含配置的 effort 时,opencodex 会省略默认值,并保持目标自身行为不变。支持的值是 `low`、`medium`、`high`、`xhigh`、`max` 和 `ultra`;省略该字段或将其设为 `null`,就会把 effort 完全交给调用方和目标。 +当目标能力未知,或者不包含配置的 effort 时,opencodex 会省略默认值,并保持目标自身行为不变。调试日志会区分 `unknown`(无阶梯元数据)与 `unsupported`(有阶梯但不含该值)。支持的值是 `low`、`medium`、`high`、`xhigh`、`max` 和 `ultra`;省略该字段或将其设为 `null`,就会把 effort 完全交给调用方和目标。 + +## 图片 / 多模态能力 + +默认情况下,combo 对外发布其目标输入模态的**交集**(只有每个目标都声明 image 时才启用图片)。设置 `imageInput: "disabled"` 可在目标都支持图片时仍强制纯文本——目录会从 `inputModalities` 去掉 `image`,带图请求会在调用任何目标前以 HTTP 400 拒绝。`"auto"`(或省略该字段)保留自动交集。 ## 加密的 v2 子代理任务 @@ -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"`;禁用后强制纯文本,且不能在目标不支持时强开图片。 | | `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..4499416fe8 --- /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 complete target advertises image input. */ +export function comboImagesSupported(targets: ComboTarget[], models: ModelOption[]): boolean { + const complete = targets.filter((target) => target.provider.trim() && target.model.trim()); + if (complete.length === 0) return false; + return complete.every((target) => { + const model = models.find( + (row) => row.provider === target.provider.trim() && row.id === target.model.trim(), + ); + return !!model?.inputModalities?.includes("image"); + }); +} diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index bc29bff703..43f6332238 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -8,7 +8,15 @@ export type ComboEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; export const COMBO_EFFORTS: ComboEffort[] = ["low", "medium", "high", "xhigh", "max", "ultra"]; -/** Intersection of per-member effort ladders; unknown ladders contribute no selectable efforts. */ +/** + * Intersection of per-member effort ladders for the default-effort picker. + * - `undefined` or empty ladder = no advertised rungs — does not constrain + * (failover combos often mix unknown / no-reasoning members with models that + * do publish a ladder; a single empty ladder must not zero the picker). + * - non-empty listed efforts = intersect as usual (filtered to COMBO_EFFORTS). + * When no complete target advertises a non-empty ladder, returns the full ladder. + * Runtime still omits injection per-target when a concrete ladder rejects the value. + */ export function intersectComboEfforts( targets: readonly ComboTarget[], modelEfforts: ReadonlyMap, @@ -20,11 +28,10 @@ export function intersectComboEfforts( for (const target of complete) { const key = `${target.provider.trim()}/${target.model.trim()}`; const listed = modelEfforts.get(key); - // Missing metadata must not invent a full ladder — runtime omits the combo default when - // supportedLadderFor is undefined (#488 / Codex review). - const member: string[] = listed === undefined - ? [] - : listed.filter((effort) => effortSet.has(effort)); + // Skip unknown / empty ladders so they do not empty the picker. + if (listed === undefined || listed.length === 0) continue; + const member = listed.filter((effort) => effortSet.has(effort)); + if (member.length === 0) continue; if (common === null) { common = member; } else { @@ -32,10 +39,30 @@ export function intersectComboEfforts( common = common.filter((effort) => memberSet.has(effort)); } } - const commonSet = new Set(common ?? []); + // No constraining ladders among complete targets → full selectable set. + if (common === null) return [...COMBO_EFFORTS]; + const commonSet = new Set(common); return COMBO_EFFORTS.filter((effort) => commonSet.has(effort)); } +/** + * True when any complete target has no advertised effort ladder (undefined or + * empty). The picker treats those as wildcards, but runtime still fails closed + * for unknown ladders — surface that so options are not presented as fully known. + */ +export function comboHasUnknownEffortTargets( + targets: readonly ComboTarget[], + modelEfforts: ReadonlyMap, +): boolean { + const complete = targets.filter((t) => t.provider.trim() && t.model.trim()); + if (complete.length === 0) return false; + return complete.some((target) => { + const key = `${target.provider.trim()}/${target.model.trim()}`; + const listed = modelEfforts.get(key); + return listed === undefined || listed.length === 0; + }); +} + export interface ComboTarget { provider: string; model: string; @@ -64,6 +91,11 @@ export interface ComboItem { strategy: ComboStrategy; stickyLimit: number; defaultEffort: ComboEffort | null; + /** + * Image/multimodal policy. Default `auto` (checked) keeps the target + * intersection; `disabled` forces text-only. + */ + imageInput?: "auto" | "disabled"; targets: ComboTarget[]; } @@ -123,6 +155,10 @@ export function normalizeWeight(raw: unknown): number | undefined { : undefined; } +export function normalizeImageInput(raw: unknown): "auto" | "disabled" { + return raw === "disabled" ? "disabled" : "auto"; +} + export function parseComboList(payload: unknown): ComboItem[] { if (!payload || typeof payload !== "object") return []; const rows = (payload as { combos?: unknown }).combos; @@ -153,6 +189,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 +247,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 +264,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} strategy: ComboStrategy; stickyLimit?: number; defaultEffort: ComboEffort | null; + imageInput?: "disabled"; alias?: string; }; } { @@ -239,6 +278,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} strategy: item.strategy, defaultEffort: item.defaultEffort, ...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}), + ...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}), ...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}), }, }; @@ -326,6 +366,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..d7391203de 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ComboItem, + comboHasUnknownEffortTargets, comboPublicModelId, emptyDraft, intersectComboEfforts, @@ -10,7 +11,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, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { clampedNumberInput } from "./combo-workspace-utils"; export function AddComboModal({ @@ -46,6 +47,10 @@ export function AddComboModal({ () => intersectComboEfforts(draft.targets, effortMap), [draft.targets, effortMap], ); + const hasUnknownEffortTargets = useMemo( + () => comboHasUnknownEffortTargets(draft.targets, effortMap), + [draft.targets, effortMap], + ); useEffect(() => { const dialog = dialogRef.current; @@ -139,11 +144,9 @@ export function AddComboModal({

{t("cws.field.aliasHint")}

-

- {t("cws.field.idHint", { - model: draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : "…", - })} -

+
{t("cws.strategy")} @@ -163,6 +166,7 @@ export function AddComboModal({ value={draft.defaultEffort} disabled={busy} allowedEfforts={allowedEfforts} + hasUnknownTargets={hasUnknownEffortTargets} onChange={(defaultEffort) => setDraft((d) => ({ ...d, defaultEffort }))} />

@@ -204,6 +208,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..4712077c3a 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -1,11 +1,14 @@ import { useState } from "react"; import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data"; import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data"; +import { comboImagesSupported } from "../combo-capabilities"; import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons"; import { useT } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; +import { Switch } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { clampedNumberInput, enabledProviders, modelsForProvider } from "./combo-workspace-utils"; +import { useCopyFeedback } from "./use-copy-feedback"; export function StrategySeg({ value, @@ -45,6 +48,7 @@ export function EffortSelect({ onChange, disabled, allowedEfforts, + hasUnknownTargets, }: { id: string; value: ComboEffort | null; @@ -52,6 +56,8 @@ export function EffortSelect({ disabled?: boolean; /** When set, only these efforts (plus None) are offered. */ allowedEfforts?: readonly ComboEffort[]; + /** Some targets have no catalog ladder — runtime may omit the default for them. */ + hasUnknownTargets?: boolean; }) { const t = useT(); const options = allowedEfforts ?? COMBO_EFFORTS; @@ -71,18 +77,68 @@ export function EffortSelect({ ) : null} {options.map((effort) => ( - + ))} {unsupported ? (

{t("cws.field.defaultEffortUnsupported")}

+ ) : hasUnknownTargets ? ( +

+ {t("cws.field.defaultEffortUnknown")} +

) : null} ); } +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, @@ -262,3 +318,36 @@ export function TargetEditor({
); } + +/** Effective public model id clients will request — mono value + copy. */ +export function PublicModelPreview({ model }: { model: string }) { + const t = useT(); + const { outcomeFor, copy } = useCopyFeedback(); + const canCopy = model.trim().length > 0 && model !== "…"; + const outcome = outcomeFor(model); + const copyLabel = outcome === "copied" ? t("cws.copiedPublicModel") : t("cws.copyPublicModel"); + // Split around a sentinel so the model token stays mono in any locale word order. + const sentinel = "\u0001"; + const [before, after = ""] = t("cws.field.publicModelPreview", { model: sentinel }).split(sentinel); + + return ( +
+

+ {before} + {model} + {after} +

+ +
+ ); +} diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index caaa05bed1..3eff4dbdb8 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { type ComboItem, + comboHasUnknownEffortTargets, comboModelId, comboPublicModelId, draftEquals, @@ -11,8 +12,9 @@ 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, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { clampedNumberInput } from "./combo-workspace-utils"; +import { useCopyFeedback } from "./use-copy-feedback"; type DetailTab = "config" | "about"; @@ -46,13 +48,13 @@ export function DetailPanel({ onDirtyChange: (dirty: boolean) => void; }) { const t = useT(); + const { outcomeFor, copy } = useCopyFeedback(); const [tab, setTab] = useState("config"); const [draft, setDraft] = useState(baseline); const [busy, setBusy] = useState(false); 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) { @@ -64,6 +66,10 @@ export function DetailPanel({ () => intersectComboEfforts(draft.targets, effortMap), [draft.targets, effortMap], ); + const hasUnknownEffortTargets = useMemo( + () => comboHasUnknownEffortTargets(draft.targets, effortMap), + [draft.targets, effortMap], + ); const updateDraft = useCallback((updater: (prev: ComboItem) => ComboItem) => { const next = updater(draft); @@ -82,16 +88,6 @@ export function DetailPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: key captures baseline payload }, [baselineSyncKey]); - const copyModel = async () => { - try { - await navigator.clipboard.writeText(baseline.model); - setCopied(true); - window.setTimeout(() => setCopied(false), 1200); - } catch { - /* ignore */ - } - }; - const save = async () => { const code = validateComboDraft(draft, { existingIds: otherIds, @@ -132,6 +128,14 @@ export function DetailPanel({ const headerModel = isCreate ? (draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : t("cws.addTitle")) : baseline.model; + // Public model id clients request — same string PublicModelPreview copies. + const copyModelId = baseline.model; + const copyOutcome = outcomeFor(copyModelId); + const copyLabel = copyOutcome === "copied" + ? t("cws.copied") + : copyOutcome === "unavailable" + ? t("cws.copyUnavailable") + : t("cws.copyModel"); return (
@@ -144,8 +148,13 @@ export function DetailPanel({ )}

{headerModel}

{!isCreate && ( - )}
@@ -188,9 +197,7 @@ export function DetailPanel({ }))} />

- {isCreate - ? t("cws.field.idInternalHint") - : t("cws.field.idHintEdit", { model: comboPublicModelId(draft.id, draft.alias) })} + {isCreate ? t("cws.field.idInternalHint") : t("cws.field.idHintEdit")}

@@ -210,6 +217,9 @@ export function DetailPanel({

{t("cws.field.aliasHint")}

+
{t("cws.strategy")} @@ -229,6 +239,7 @@ export function DetailPanel({ value={draft.defaultEffort} disabled={busy} allowedEfforts={allowedEfforts} + hasUnknownTargets={hasUnknownEffortTargets} onChange={(defaultEffort) => updateDraft((d) => ({ ...d, defaultEffort }))} />

@@ -267,6 +278,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 a4d633e10a..87f9918920 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1676,6 +1676,9 @@ export const de: Record = { "cws.allCombos": "Alle Combos", "cws.copyModel": "ID kopieren", "cws.copied": "Kopiert", + "cws.copyUnavailable": "Zwischenablage nicht verfügbar", + "cws.copyPublicModel": "Kopieren", + "cws.copiedPublicModel": "Kopiert", "cws.tab.config": "Konfiguration", "cws.tab.about": "Info", "cws.strategy": "Strategie", @@ -1684,19 +1687,26 @@ export const de: Record = { "cws.strategy.failoverHint": "Ziele der Reihe nach versuchen. Bei einem wiederholbaren Fehler (Limit, Ausfall, Abo-Sperre) zum nächsten springen.", "cws.strategy.roundRobinHint": "Datenverkehr deterministisch nach Gewicht verteilen. Das gewählte Ziel für einen Block erfolgreicher Anfragen behalten und dann weiterschalten.", "cws.field.id": "Combo-ID", - "cws.field.idHintEdit": "Das Ändern der ID benennt die Combo um. Clients fordern {model} an.", + "cws.field.idHintEdit": "Das Ändern der ID benennt die Combo um. Ohne öffentlichen Modellnamen fordern Clients combo/ an.", "cws.field.alias": "Öffentlicher Modellname", "cws.field.aliasPlaceholder": "deepseek-v4-flash oder vendor/model", - "cws.field.aliasHint": "Optional. Verwenden Sie einen Namen ohne Präfix, ein eigenes Präfix wie vendor/model oder lassen Sie das Feld leer für combo/.", - "cws.field.idHint": "Clients fordern {model} an", - "cws.field.idInternalHint": "Interne Combo-ID. Sie kann nach dem Erstellen geändert werden.", + "cws.field.aliasHint": "Optional. Überschreibt den Namen, den Clients anfordern. Name ohne Präfix, eigenes Präfix wie vendor/model, oder leer lassen für combo/.", + "cws.field.idHint": "Clients fordern an: {model}", + "cws.field.idInternalHint": "Interne Combo-ID. Ohne öffentlichen Modellnamen fordern Clients combo/ an.", + "cws.field.publicModelPreview": "Clients fordern an: {model}", "cws.field.stickyLimit": "Sticky-Erfolge vor Rotation", "cws.field.stickyLimitHint": "Das gewählte Ziel für so viele erfolgreiche Anfragen behalten, bevor die gewichtete Auswahl weiterschaltet.", "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.field.defaultEffortHint": "Nur verwendet, wenn der Client keinen Reasoning-Aufwand sendet. Optionen sind die Schnittmenge der beworbenen Aufwände von Zielen, die eine Leiter veröffentlichen; Ziele ohne Katalog-Aufwandsmetadaten schränken die Liste nicht ein.", "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.field.defaultEffortUnknownMarker": "unbekannte Ziele", + "cws.field.defaultEffortUnknown": "Einige Ziele haben keine Katalog-Aufwandsleiter. Optionen bleiben wählbar, aber die Laufzeit lässt den Standard für unbekannte Ziele weg (fail closed).", + "cws.capabilities": "Fähigkeiten", + "cws.capability.imageInput": "Bild / multimodal", + "cws.capability.imageInputHint": "Standardmäßig aktiv, wenn jedes Ziel Bilder unterstützt. Ausschalten für nur Text.", + "cws.capability.imageInputUnavailable": "Erst verfügbar, wenn jedes gewählte Ziel Bildeingabe unterstützt.", "cws.targets": "Ziele", "cws.targets.failoverHint": "Reihenfolge zählt — das erste ist primär.", "cws.targets.roundRobinHint": "Gewichte steuern die deterministische relative Auswahl; die Reihenfolge löst Gleichstände im Rotationsring.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index df67c75023..3dde76a71a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1709,6 +1709,9 @@ export const en = { "cws.allCombos": "All combos", "cws.copyModel": "Copy id", "cws.copied": "Copied", + "cws.copyUnavailable": "Clipboard unavailable", + "cws.copyPublicModel": "Copy", + "cws.copiedPublicModel": "Copied", "cws.tab.config": "Config", "cws.tab.about": "About", "cws.strategy": "Strategy", @@ -1717,19 +1720,26 @@ export const en = { "cws.strategy.failoverHint": "Try targets in order. If the first fails with a retryable error (rate limit, outage, subscription gate), hop to the next.", "cws.strategy.roundRobinHint": "Deterministically balance traffic by weight. Keep each selected target for a batch of successful requests, then advance.", "cws.field.id": "Combo id", - "cws.field.idHint": "Clients will request {model}", - "cws.field.idInternalHint": "Internal combo id. You can change it after creation.", - "cws.field.idHintEdit": "Renaming moves the combo to a new id. Clients request {model}.", + "cws.field.idHint": "Clients request: {model}", + "cws.field.idInternalHint": "Internal id for this combo. When no public model name is set, clients use combo/.", + "cws.field.idHintEdit": "Changing the id renames the combo. When no public model name is set, clients use combo/.", "cws.field.alias": "Public model name", "cws.field.aliasPlaceholder": "deepseek-v4-flash or vendor/model", - "cws.field.aliasHint": "Optional. Use a bare name with no prefix, a custom prefix like vendor/model, or leave blank to use combo/.", + "cws.field.aliasHint": "Optional override for the name clients request. Use a bare name, a custom prefix like vendor/model, or leave blank for combo/.", + "cws.field.publicModelPreview": "Clients request: {model}", "cws.field.stickyLimit": "Sticky successes before rotate", "cws.field.stickyLimitHint": "Retain the selected target for this many successful requests before the weighted selector advances.", "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.field.defaultEffortHint": "Used only when the client omits reasoning effort. Options are the intersection of advertised efforts from targets that publish a ladder; targets without catalog effort metadata do not restrict the list.", "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.field.defaultEffortUnknownMarker": "unknown targets", + "cws.field.defaultEffortUnknown": "Some targets have no catalog effort ladder. Options stay selectable, but runtime omits the default for those unknown targets (fails closed).", + "cws.capabilities": "Capabilities", + "cws.capability.imageInput": "Image / multimodal", + "cws.capability.imageInputHint": "On by default when every target supports images. Turn off to accept text only.", + "cws.capability.imageInputUnavailable": "Unavailable until every selected target supports image input.", "cws.targets": "Targets", "cws.targets.failoverHint": "Order matters — first is primary.", "cws.targets.roundRobinHint": "Weights control deterministic relative selection; order breaks ties in the rotation ring.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index eb98a71eb6..d803b25675 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1740,6 +1740,9 @@ export const ja: Record = { "cws.allCombos": "すべてのコンボ", "cws.copyModel": "ID をコピー", "cws.copied": "コピーしました", + "cws.copyUnavailable": "クリップボードを利用できません", + "cws.copyPublicModel": "コピー", + "cws.copiedPublicModel": "コピーしました", "cws.renamed": "{from} を {to} に変更しました。", "cws.tab.config": "設定", "cws.tab.about": "概要", @@ -1749,19 +1752,26 @@ export const ja: Record = { "cws.strategy.failoverHint": "ターゲットを順に試します。最初が再試行可能なエラー(レート制限、障害、サブスクリプションゲート)で失敗した場合、次へホップします。", "cws.strategy.roundRobinHint": "重みで決定論的にトラフィックを分散します。選んだターゲットを成功リクエストのバッチ分保持し、次へ進みます。", "cws.field.id": "コンボ ID", - "cws.field.idHint": "クライアントは {model} をリクエストします", - "cws.field.idInternalHint": "コンボの内部 ID。作成後も変更できます。", - "cws.field.idHintEdit": "ID を変更するとコンボの名前が変更されます。クライアントは {model} をリクエストします。", + "cws.field.idHint": "クライアントのリクエスト: {model}", + "cws.field.idInternalHint": "コンボの内部 ID。公開モデル名を設定しない場合、クライアントは combo/ を使用します。", + "cws.field.idHintEdit": "ID を変更するとコンボの名前が変更されます。公開モデル名を設定しない場合、クライアントは combo/ を使用します。", "cws.field.alias": "公開モデル名", "cws.field.aliasPlaceholder": "deepseek-v4-flash または vendor/model", - "cws.field.aliasHint": "任意。プレフィックスなしの名前、vendor/model のようなカスタムプレフィックスを指定するか、空欄のままにすると combo/ を使用します。", + "cws.field.aliasHint": "任意。クライアントがリクエストする名前の上書き。プレフィックスなしの名前、vendor/model のようなカスタムプレフィックス、または空欄で combo/。", + "cws.field.publicModelPreview": "クライアントのリクエスト: {model}", "cws.field.stickyLimit": "ローテーション前の固定成功数", "cws.field.stickyLimitHint": "重み付きセレクタが進む前に、選んだターゲットをこの回数の成功リクエスト分保持します。", "cws.field.defaultEffort": "デフォルトの推論", "cws.field.defaultEffortNone": "なし(ターゲットのデフォルト)", - "cws.field.defaultEffortHint": "クライアントが推論負荷を省略した場合のみ使用されます。選択肢は選択ターゲットが広告する負荷の交差です。", + "cws.field.defaultEffortHint": "クライアントが推論負荷を省略した場合のみ使用されます。選択肢は負荷ラダーを公開しているターゲットが広告する負荷の交差です。カタログに負荷メタデータがないターゲットはリストを制限しません。", "cws.field.defaultEffortUnsupported": "この負荷はターゲット共通の階段にありません — リクエスト時に無視またはスナップされます。", "cws.field.defaultEffortUnsupportedOption": "交差に含まれない", + "cws.field.defaultEffortUnknownMarker": "未知ターゲットあり", + "cws.field.defaultEffortUnknown": "一部のターゲットにカタログ負荷ラダーがありません。選択肢は選べますが、未知ターゲットではランタイムがデフォルトを省略します(フェイルクローズ)。", + "cws.capabilities": "能力", + "cws.capability.imageInput": "画像 / マルチモーダル", + "cws.capability.imageInputHint": "全ターゲットが画像対応なら既定でオン。オフにするとテキストのみ。", + "cws.capability.imageInputUnavailable": "選択した全ターゲットが画像入力に対応すると有効になります。", "cws.targets": "ターゲット", "cws.targets.failoverHint": "順序が重要 — 最初がプライマリです。", "cws.targets.roundRobinHint": "重みが決定論的な相対選択を制御し、順序がローテーションリングの同点を解消します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 339cdbe970..77cd183c77 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1703,6 +1703,9 @@ export const ko: Record = { "cws.allCombos": "모든 콤보", "cws.copyModel": "ID 복사", "cws.copied": "복사됨", + "cws.copyUnavailable": "클립보드를 사용할 수 없음", + "cws.copyPublicModel": "복사", + "cws.copiedPublicModel": "복사됨", "cws.tab.config": "설정", "cws.tab.about": "정보", "cws.strategy": "전략", @@ -1711,19 +1714,26 @@ export const ko: Record = { "cws.strategy.failoverHint": "대상을 순서대로 시도합니다. 재시도 가능한 오류(한도, 장애, 구독 게이트)면 다음으로 넘어갑니다.", "cws.strategy.roundRobinHint": "가중치에 따라 트래픽을 결정적으로 분배합니다. 선택된 대상을 성공 요청 묶음 동안 유지한 뒤 다음 대상으로 진행합니다.", "cws.field.id": "콤보 ID", - "cws.field.idHintEdit": "ID를 변경하면 콤보 이름이 바뀝니다. 클라이언트는 {model}을(를) 요청합니다.", + "cws.field.idHintEdit": "ID를 변경하면 콤보 이름이 바뀝니다. 공개 모델 이름을 설정하지 않으면 클라이언트는 combo/를 사용합니다.", "cws.field.alias": "공개 모델 이름", "cws.field.aliasPlaceholder": "deepseek-v4-flash 또는 vendor/model", - "cws.field.aliasHint": "선택 사항입니다. 접두사 없는 이름, vendor/model 같은 사용자 지정 접두사를 사용하거나 비워 두어 combo/를 사용할 수 있습니다.", - "cws.field.idHint": "클라이언트는 {model}을(를) 요청합니다", - "cws.field.idInternalHint": "내부 콤보 ID입니다. 생성 후에도 변경할 수 있습니다.", + "cws.field.aliasHint": "선택 사항입니다. 클라이언트가 요청하는 이름을 재정의합니다. 접두사 없는 이름, vendor/model 같은 사용자 지정 접두사, 또는 비워 두면 combo/.", + "cws.field.idHint": "클라이언트 요청: {model}", + "cws.field.idInternalHint": "내부 콤보 ID입니다. 공개 모델 이름을 설정하지 않으면 클라이언트는 combo/를 사용합니다.", + "cws.field.publicModelPreview": "클라이언트 요청: {model}", "cws.field.stickyLimit": "회전 전 sticky 성공 횟수", "cws.field.stickyLimitHint": "가중 선택기가 다음 대상으로 진행하기 전에 선택된 대상을 이 성공 요청 횟수만큼 유지합니다.", "cws.field.defaultEffort": "기본 추론 수준", "cws.field.defaultEffortNone": "없음 (대상 기본값)", - "cws.field.defaultEffortHint": "클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 선택한 대상이 광고하는 수준의 교집합입니다.", + "cws.field.defaultEffortHint": "클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 노력 사다리를 공개하는 대상이 광고하는 수준의 교집합입니다. 카탈로그 노력 메타데이터가 없는 대상은 목록을 제한하지 않습니다.", "cws.field.defaultEffortUnsupported": "이 수준은 대상의 공통 사다리에 없습니다 — 요청 시 무시되거나 스냅됩니다.", "cws.field.defaultEffortUnsupportedOption": "교집합에 없음", + "cws.field.defaultEffortUnknownMarker": "알 수 없는 대상", + "cws.field.defaultEffortUnknown": "일부 대상에 카탈로그 노력 사다리가 없습니다. 옵션은 선택 가능하지만, 런타임은 알 수 없는 대상에 대해 기본값을 생략합니다(실패 시 닫힘).", + "cws.capabilities": "기능", + "cws.capability.imageInput": "이미지 / 멀티모달", + "cws.capability.imageInputHint": "모든 대상이 이미지를 지원하면 기본으로 켜집니다. 끄면 텍스트만 허용합니다.", + "cws.capability.imageInputUnavailable": "선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다.", "cws.targets": "대상", "cws.targets.failoverHint": "순서가 중요합니다 — 첫 번째가 기본입니다.", "cws.targets.roundRobinHint": "가중치는 결정적 상대 선택을 제어하고, 순서는 회전 고리의 동률을 결정합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7904e27c3e..8d2fcd0bb9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1783,6 +1783,9 @@ export const ru: Record = { "cws.allCombos": "Все комбо", "cws.copyModel": "Копировать id", "cws.copied": "Скопировано", + "cws.copyUnavailable": "Буфер обмена недоступен", + "cws.copyPublicModel": "Копировать", + "cws.copiedPublicModel": "Скопировано", "cws.tab.config": "Конфигурация", "cws.tab.about": "О комбо", "cws.strategy": "Стратегия", @@ -1791,19 +1794,26 @@ export const ru: Record = { "cws.strategy.failoverHint": "Цели перебираются по порядку. Если первая завершается ошибкой, допускающей повтор (лимит запросов, сбой, ограничение подписки), происходит переключение на следующую.", "cws.strategy.roundRobinHint": "Детерминированное распределение трафика по весам. Выбранная цель удерживается на серию успешных запросов, затем селектор переходит к следующей.", "cws.field.id": "Id комбо", - "cws.field.idHint": "Клиенты будут запрашивать {model}", - "cws.field.idInternalHint": "Внутренний id комбо. Его можно изменить после создания.", - "cws.field.idHintEdit": "При переименовании комбо будет перенесено на новый id. Клиенты запрашивают {model}.", + "cws.field.idHint": "Клиенты запрашивают: {model}", + "cws.field.idInternalHint": "Внутренний id комбо. Без публичного имени модели клиенты используют combo/.", + "cws.field.idHintEdit": "При изменении id комбо будет переименовано. Без публичного имени модели клиенты используют combo/.", "cws.field.alias": "Публичное имя модели", "cws.field.aliasPlaceholder": "deepseek-v4-flash или vendor/model", - "cws.field.aliasHint": "Необязательно. Используйте имя без префикса, собственный префикс вроде vendor/model или оставьте поле пустым для combo/.", + "cws.field.aliasHint": "Необязательно. Переопределяет имя, которое запрашивают клиенты. Имя без префикса, свой префикс вроде vendor/model или пустое поле для combo/.", + "cws.field.publicModelPreview": "Клиенты запрашивают: {model}", "cws.field.stickyLimit": "Успешных запросов до ротации", "cws.field.stickyLimitHint": "Выбранная цель удерживается на указанное число успешных запросов, прежде чем взвешенный селектор перейдёт к следующей.", "cws.field.defaultEffort": "Рассуждения по умолчанию", "cws.field.defaultEffortNone": "Нет (по умолчанию для цели)", - "cws.field.defaultEffortHint": "Используется, только если клиент не указал уровень рассуждений. Варианты — пересечение заявленных уровней выбранных целей.", + "cws.field.defaultEffortHint": "Используется, только если клиент не указал уровень рассуждений. Варианты — пересечение заявленных уровней целей, публикующих шкалу; цели без метаданных усилий в каталоге список не ограничивают.", "cws.field.defaultEffortUnsupported": "Этот уровень не входит в общую лестницу целей — при запросе он будет проигнорирован или снижен.", "cws.field.defaultEffortUnsupportedOption": "нет в пересечении", + "cws.field.defaultEffortUnknownMarker": "неизвестные цели", + "cws.field.defaultEffortUnknown": "У части целей нет лестницы усилий в каталоге. Варианты остаются доступны, но для неизвестных целей runtime не подставляет значение по умолчанию (fail closed).", + "cws.capabilities": "Возможности", + "cws.capability.imageInput": "Изображения / мультимодальность", + "cws.capability.imageInputHint": "Включено по умолчанию, если все цели поддерживают изображения. Выключите, чтобы принимать только текст.", + "cws.capability.imageInputUnavailable": "Доступно, когда все выбранные цели поддерживают ввод изображений.", "cws.targets": "Цели", "cws.targets.failoverHint": "Порядок важен — первая цель основная.", "cws.targets.roundRobinHint": "Веса задают детерминированный относительный выбор; при равных весах порядок определяет очерёдность в кольце ротации.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8b16dbe6c8..d38ddcc65a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1696,6 +1696,9 @@ export const zh: Record = { "cws.allCombos": "全部组合", "cws.copyModel": "复制 ID", "cws.copied": "已复制", + "cws.copyUnavailable": "剪贴板不可用", + "cws.copyPublicModel": "复制", + "cws.copiedPublicModel": "已复制", "cws.tab.config": "配置", "cws.tab.about": "关于", "cws.strategy": "策略", @@ -1704,19 +1707,26 @@ export const zh: Record = { "cws.strategy.failoverHint": "按顺序尝试目标。若出现可重试错误(限流、故障、订阅门控),则跳到下一个。", "cws.strategy.roundRobinHint": "按权重确定性地分配流量。将所选目标保留一批成功请求后,再推进到下一个目标。", "cws.field.id": "组合 ID", - "cws.field.idHint": "客户端将请求 {model}", - "cws.field.idInternalHint": "组合的内部 ID,创建后仍可修改。", - "cws.field.idHintEdit": "修改 ID 即重命名组合。客户端将请求 {model}。", + "cws.field.idHint": "客户端请求:{model}", + "cws.field.idInternalHint": "组合的内部 ID。未设置公开模型名时,客户端使用 combo/。", + "cws.field.idHintEdit": "修改 ID 即重命名组合。未设置公开模型名时,客户端使用 combo/。", "cws.field.alias": "公开模型名称", "cws.field.aliasPlaceholder": "deepseek-v4-flash 或 vendor/model", - "cws.field.aliasHint": "可选。可填无前缀裸名称、自定义前缀(如 vendor/model),或留空使用 combo/。", + "cws.field.aliasHint": "可选。覆盖客户端请求的模型名;可填裸名称、自定义前缀(如 vendor/model),或留空使用 combo/。", + "cws.field.publicModelPreview": "客户端请求:{model}", "cws.field.stickyLimit": "轮换前的粘性成功次数", "cws.field.stickyLimitHint": "加权选择器推进前,将所选目标保留这么多次成功请求。", "cws.field.defaultEffort": "默认推理级别", "cws.field.defaultEffortNone": "无(使用目标默认)", - "cws.field.defaultEffortHint": "仅在客户端未指定推理级别时使用。选项为所选目标已公布努力级别的交集。", + "cws.field.defaultEffortHint": "仅在客户端未指定推理级别时使用。选项为已公布努力阶梯的目标所声明级别的交集;没有目录努力元数据的目标不会限制列表。", "cws.field.defaultEffortUnsupported": "该级别不在目标的公共阶梯中 — 请求时会被忽略或就近映射。", "cws.field.defaultEffortUnsupportedOption": "不在交集中", + "cws.field.defaultEffortUnknownMarker": "含未知目标", + "cws.field.defaultEffortUnknown": "部分目标没有目录努力阶梯。列表仍可选,但运行时对这些未知目标会省略默认值(失败关闭)。", + "cws.capabilities": "能力", + "cws.capability.imageInput": "图片 / 多模态", + "cws.capability.imageInputHint": "所有目标均支持图片时默认开启;关闭后仅接受文本。", + "cws.capability.imageInputUnavailable": "所有已选目标均支持图片输入后才可用。", "cws.targets": "目标", "cws.targets.failoverHint": "顺序很重要 — 第一个为主。", "cws.targets.roundRobinHint": "权重控制确定性的相对选择;顺序用于打破轮换环中的平局。", diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 64f671d76c..45973c3334 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -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..13d18bcce3 100644 --- a/gui/src/styles-combos-workspace.css +++ b/gui/src/styles-combos-workspace.css @@ -318,6 +318,60 @@ 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; +} + +/* Effective public model clients request — mono id + copy under id/alias fields. */ +.cwi-public-model-preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.cwi-public-model-preview-text { + margin: 0; + font-size: 12px; + max-width: var(--prose-measure); + overflow-wrap: anywhere; +} + +.cwi-public-model-preview-value { + font-size: inherit; + padding: 1px 5px; + border-radius: var(--radius); + background: var(--surface-soft, var(--raised)); +} + +.cwi-public-model-preview-copy { + flex-shrink: 0; +} + .cwi-target-list { display: flex; flex-direction: column; @@ -485,6 +539,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.ts b/src/codex/catalog.ts index 3ee398ecf3..ca8f48659d 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -5,7 +5,7 @@ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; -export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; +export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata, resolveComboCatalogMember } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; 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/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b88220c156..befb5349a6 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -607,6 +607,98 @@ export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderCo return models.map(model => applyProviderConfigHints(name, prov, model, contextCap)); } +/** + * Last-resort context window for combo member synthesis when discovery and + * provider config both omit one. Matches the catalog entry default in + * `normalizeRoutedCatalogEntry` so incomplete live rows still catalog. + */ +const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000; + +/** + * Resolve a combo target to a catalog member for derivation. + * Prefer discovery metadata; when the target is missing from the gather map or + * lacks a positive contextWindow, synthesize from the (registry-enriched) + * provider config so combos remain catalogued when targets are configured but + * discovery metadata is incomplete. Disabled/missing providers stay unresolved. + * When hints still omit contextWindow, apply COMBO_MEMBER_CONTEXT_FALLBACK so a + * live row without ctx (common for LiteLLM / custom xai ids) does not drop the + * whole combo from the public catalog. + */ +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + if ( + existing + && typeof existing.contextWindow === "number" + && existing.contextWindow > 0 + ) { + return existing; + } + + const prov = providers.get(target.provider); + // Disabled providers cannot contribute usable members. Missing provider with + // an incomplete existing row still needs the conservative fallback below so + // a configured target is not silently dropped solely for missing ctx. + if (prov?.disabled === true) return existing; + + const base: CatalogModel = existing ?? { + id: target.model, + provider: target.provider, + }; + // Reuse the same hint path configured/static catalog rows use when a provider + // config exists; otherwise keep the base (possibly incomplete) discovery row. + const hinted = prov + ? applyProviderConfigHints(target.provider, prov, base, contextCap) + : base; + const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 + ? hinted.contextWindow + : undefined; + // Prefer config/discovery; fall back so incomplete live rows still catalog. + // Fully missing target + no provider still cannot be invented without a name + // in providers — but once the target is listed on a known provider (or already + // present in the gather map), a conservative window is enough for derivation. + // Pure ghosts (no discovery row) on a known provider still synthesize: live + // discovery is often incomplete for LiteLLM/custom relays, and dropping the + // whole combo solely for a missing /v1/models row is worse than a 128k stub. + // Tightening to "models list / modelContextWindows / modelReasoningEfforts + // only" would re-break that recovery path. + const uncappedContext = hintedContext + ?? (existing || prov ? COMBO_MEMBER_CONTEXT_FALLBACK : undefined); + if (uncappedContext === undefined) return undefined; + // Fallback bypasses applyProviderConfigHints' cap path — clamp here so a + // providerContextCaps value below 128k still wins (same semantics as hints). + const usedFallback = hintedContext === undefined; + const cappedContext = applyProviderContextCap(uncappedContext, contextCap); + const contextWindow = cappedContext ?? uncappedContext; + const fallbackCapped = usedFallback + && contextCap !== undefined + && cappedContext !== undefined + && cappedContext !== uncappedContext; + + const inputModalities = hinted.inputModalities ?? base.inputModalities ?? ["text"]; + // applyProviderConfigHints already folds configuredReasoningEfforts; keep an + // explicit fallback for stubs that only had base fields. + const reasoningEfforts = hinted.reasoningEfforts + ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) + ?? base.reasoningEfforts; + const maxInputTokens = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 + ? Math.min(hinted.maxInputTokens, contextWindow) + : contextWindow; + + return { + ...hinted, + inputModalities, + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + contextWindow, + maxInputTokens, + ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), + }; +} + export function isDatedVariantId(liveId: string, configuredId: string): boolean { if (!liveId.startsWith(`${configuredId}-`)) return false; return /^\d{8}$/.test(liveId.slice(configuredId.length + 1)); @@ -1262,11 +1354,19 @@ async function gatherRoutedModelsUncached( if (!memberByKey.has(key)) memberByKey.set(key, synthetic); } } + // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and + // custom-model vision-sidecar inheritance so both see the same merged registry view. + const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); for (const id of listComboIds(config)) { const combo = getCombo(config, id); if (!combo) continue; const members = combo.targets - .map(target => memberByKey.get(targetKey(target))) + .map(target => resolveComboCatalogMember( + target, + memberByKey, + enrichedByName, + providerContextCap(config, target.provider), + )) .filter((member): member is CatalogModel => member !== undefined); const derived = deriveComboCatalogModel(id, combo, members); if (derived) all.push(derived); @@ -1274,9 +1374,6 @@ async function gatherRoutedModelsUncached( } replaceLastComboCatalogOmissions(localOmissions); all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); - // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so - // custom rows get the same noVisionModels / inputModalities treatment as discovered rows. - const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row // with the same slug below, so that row's provider capability metadata is the inheritance source. const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); 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..930c9f63f9 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, @@ -40,6 +51,9 @@ export function concreteComboRequestBody( && !Object.prototype.hasOwnProperty.call(reasoning, "effort") ); if (!needsDefault) return clone; + // Fail closed for both unknown (`undefined`) and known-but-missing ladders. + // Unknown is kept distinct in debug so we can measure thin catalog rows without + // guessing the provider accepts a reasoning field. if (!targetReasoningEfforts?.includes(defaultEffort)) { const key = `${target.provider}/${target.model}:${defaultEffort}`; if (!warnedUnsupportedDefaults.has(key)) { 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..bd8b7d297c 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -122,9 +122,16 @@ 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; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2d00ab0c43..5aec9a7124 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -23,6 +23,7 @@ import { comboDefaultEffort, comboFailureDecision, comboIdFromRawBody, + comboRequestHasImageInput, concreteComboRequestBody, getCombo, isComboTargetInCooldown, @@ -990,6 +991,9 @@ export async function handleComboResponses( if (!combo) { return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); } + if (combo.imageInput === "disabled" && comboRequestHasImageInput(rawBody)) { + 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. diff --git a/src/types.ts b/src/types.ts index 171628caff..ac4b8e174d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -827,6 +827,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..01e13ff6ec 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, resolveComboCatalogMember, shouldExposeRoutedModel } from "../src/codex/catalog"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { CURSOR_STATIC_MODELS, @@ -50,6 +50,7 @@ function normalizedCombo( strategy: "failover", stickyLimit: 1, defaultEffort: "medium", + imageInput: "auto", alias: null, targets: [ { provider: "a", model: "m1", weight: 1 }, @@ -197,6 +198,17 @@ describe("combo catalog capability intersection", () => { ])?.defaultReasoningEffort).toBe("medium"); }); + test("imageInput disabled strips image even when every member supports it", () => { + expect(deriveComboCatalogModel("text-only", normalizedCombo({ imageInput: "disabled" }), [ + memberA, + { ...memberB, inputModalities: ["text", "image"] }, + ])?.inputModalities).toEqual(["text"]); + expect(deriveComboCatalogModel("text-only", normalizedCombo({ imageInput: "auto" }), [ + memberA, + { ...memberB, inputModalities: ["text", "image"] }, + ])?.inputModalities).toEqual(["text", "image"]); + }); + test("fails closed for missing members, unknown context, duplicate targets, and empty modalities", () => { expect(deriveComboCatalogModel("missing", normalizedCombo(), [memberA])).toBeNull(); expect(deriveComboCatalogModel("context", normalizedCombo(), [ @@ -499,7 +511,9 @@ describe("combo catalog capability intersection", () => { }, combos: { mixed: { targets: [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }] }, - hidden: { targets: [{ provider: "a", model: warningSentinel }] }, + // Unknown provider (not just unlisted model) — synthesis cannot invent a member, + // and the secret in the model id must still be redacted in the omission warning. + hidden: { targets: [{ provider: warningSentinel, model: "m1" }] }, }, disabledModels: ["combo/mixed"], }; @@ -671,6 +685,199 @@ describe("combo catalog capability intersection", () => { expect(openaiRows).toEqual([]); expect(rows.some(r => r.provider === "combo" && r.id === "solo")).toBe(true); }); + + test("synthesizes missing combo targets from provider config metadata", async () => { + // Target model is not in models[] (so never lands in memberByKey) but provider + // config carries context/modalities/efforts — combo derivation must still catalog. + const config: OcxConfig = { + port: 10100, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + liveModels: false, + models: ["listed"], + modelContextWindows: { listed: 200_000, unlisted: 128_000 }, + modelInputModalities: { unlisted: ["text"] }, + modelReasoningEfforts: { unlisted: ["low", "medium", "high"] }, + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + liveModels: false, + models: ["m2"], + modelContextWindows: { m2: 100_000 }, + modelReasoningEfforts: { m2: ["low", "medium"] }, + }, + }, + combos: { + recovered: { + targets: [ + { provider: "a", model: "unlisted" }, + { provider: "b", model: "m2" }, + ], + }, + }, + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + resetCatalogRuntimeStateForTests(); + const rows = await gatherRoutedModels(config); + const combo = rows.find(r => r.provider === "combo" && r.id === "recovered"); + expect(combo).toBeDefined(); + expect(combo!.contextWindow).toBe(100_000); + expect(combo!.inputModalities).toEqual(["text"]); + expect(combo!.reasoningEfforts).toEqual(["low", "medium"]); + // Synthesized member must not leak as a standalone routed row. + expect(rows.some(r => r.provider === "a" && r.id === "unlisted")).toBe(false); + const { getLastComboCatalogOmissions } = await import("../src/codex/catalog"); + expect(getLastComboCatalogOmissions().some(item => item.id === "recovered")).toBe(false); + } finally { + warn.mockRestore(); + } + }, 15_000); + + test("resolveComboCatalogMember fills incomplete members from provider config", () => { + // Member is present but lacks contextWindow; provider.contextWindow + efforts complete it. + const incomplete = { + provider: "a", + id: "m1", + // no contextWindow — the incomplete_metadata trigger + }; + const memberByKey = new Map([["a/m1", incomplete]]); + const providers = new Map([ + ["a", { + adapter: "openai-chat" as const, + baseUrl: "https://a.example/v1", + contextWindow: 200_000, + modelReasoningEfforts: { m1: ["low", "medium", "high"] }, + }], + ]); + const resolved = resolveComboCatalogMember( + { provider: "a", model: "m1" }, + memberByKey, + providers, + ); + expect(resolved).toMatchObject({ + provider: "a", + id: "m1", + contextWindow: 200_000, + maxInputTokens: 200_000, + inputModalities: ["text"], + reasoningEfforts: ["low", "medium", "high"], + }); + // Complete members are returned as-is without re-synthesis side effects. + const complete = { + provider: "a", + id: "m1", + contextWindow: 99_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["high"], + }; + expect(resolveComboCatalogMember( + { provider: "a", model: "m1" }, + new Map([["a/m1", complete]]), + providers, + )).toBe(complete); + // Known provider without context metadata still gets the conservative fallback. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + )).toMatchObject({ + provider: "a", + id: "ghost", + contextWindow: 128_000, + maxInputTokens: 128_000, + inputModalities: ["text"], + }); + // Provider contextCap below the 128k fallback clamps the synthesized window. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + 64_000, + )).toMatchObject({ + provider: "a", + id: "ghost", + contextWindow: 64_000, + maxInputTokens: 64_000, + contextCap: 64_000, + contextCapped: true, + }); + // Cap above the fallback leaves 128k (no artificial raise, no capped flag). + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + 200_000, + )).toMatchObject({ + contextWindow: 128_000, + maxInputTokens: 128_000, + }); + // No provider entry and no discovery row — cannot invent a member. + expect(resolveComboCatalogMember( + { provider: "missing", model: "ghost" }, + new Map(), + new Map(), + )).toBeUndefined(); + }); + + test("still omits combos when synthesis cannot recover hard failures", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + liveModels: false, + models: ["m1"], + modelContextWindows: { m1: 128_000 }, + // Disjoint modalities with b → empty intersection (incompatible_modalities). + modelInputModalities: { m1: ["image"] }, + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + liveModels: false, + models: ["m2"], + modelContextWindows: { m2: 128_000 }, + modelInputModalities: { m2: ["audio"] }, + }, + }, + combos: { + disjoint: { + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + ghost: { + // Provider does not exist — synthesis cannot invent a member. + targets: [{ provider: "missing-provider", model: "never-configured" }], + }, + }, + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + resetCatalogRuntimeStateForTests(); + const rows = await gatherRoutedModels(config); + expect(rows.some(r => r.provider === "combo" && r.id === "disjoint")).toBe(false); + expect(rows.some(r => r.provider === "combo" && r.id === "ghost")).toBe(false); + const { getLastComboCatalogOmissions } = await import("../src/codex/catalog"); + const omissions = getLastComboCatalogOmissions(); + expect(omissions.find(item => item.id === "disjoint")).toMatchObject({ + reason: "incompatible_modalities", + }); + expect(omissions.find(item => item.id === "ghost")).toMatchObject({ + reason: "incomplete_metadata", + }); + } finally { + warn.mockRestore(); + } + }, 15_000); }); describe("Google Gemini catalog metadata", () => { diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index fd4701b903..0151c41a0f 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -254,7 +254,7 @@ describe("combo management API", () => { success: true, id: "zeta", model: "combo/zeta", - combo: { strategy: "failover", stickyLimit: 1, defaultEffort: null }, + combo: { strategy: "failover", stickyLimit: 1, defaultEffort: null, imageInput: "auto" }, }); const updated = await comboApi(config, "PUT", "/api/combos", { id: "zeta", @@ -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..2ad5ebba45 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; import { type ComboItem, + COMBO_EFFORTS, buildComboAttention, comboPublicModelId, draftEquals, emptyDraft, filterCombos, groupCombos, + comboHasUnknownEffortTargets, intersectComboEfforts, isValidComboId, parseComboList, @@ -89,6 +91,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 +101,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+$/) }, @@ -171,14 +175,51 @@ describe("combo-workspace-data", () => { )).toEqual(["medium", "high"]); }); - test("intersectComboEfforts treats unknown members as having no selectable efforts", () => { + test("intersectComboEfforts ignores unknown members (wildcard) and keeps known intersection", () => { const map = new Map([ - ["a/m1", ["low", "medium"]], + ["a/m1", ["low", "medium", "high"]], + // b/unknown missing → undefined wildcard, must not empty the picker ]); expect(intersectComboEfforts( [{ provider: "a", model: "m1" }, { provider: "b", model: "unknown" }], map, - )).toEqual([]); + )).toEqual(["low", "medium", "high"]); + }); + + test("intersectComboEfforts returns full ladder when all members are unknown", () => { + const map = new Map([ + ["a/m1", undefined], + ]); + expect(intersectComboEfforts( + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + map, + )).toEqual([...COMBO_EFFORTS]); + }); + + test("intersectComboEfforts ignores empty ladders so failover combos stay selectable", () => { + const map = new Map([ + ["a/m1", ["low", "medium"]], + ["b/m2", []], + ]); + expect(intersectComboEfforts( + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + map, + )).toEqual(["low", "medium"]); + }); + + test("comboHasUnknownEffortTargets flags missing or empty ladders", () => { + const map = new Map([ + ["a/m1", ["low", "medium"]], + ["b/m2", []], + ]); + expect(comboHasUnknownEffortTargets( + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + map, + )).toBe(true); + expect(comboHasUnknownEffortTargets( + [{ provider: "a", model: "m1" }], + map, + )).toBe(false); }); test("attention flags zero-target and one-target defensive rows", () => { diff --git a/tests/combos.test.ts b/tests/combos.test.ts index f2705d6437..a5f9e01557 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -14,6 +14,7 @@ import { comboIdFromRawBody, comboModelId, comboPublicModelId, + comboRequestHasImageInput, concreteComboRequestBody, coolComboTarget, getCombo, @@ -191,6 +192,16 @@ describe("combo request cloning", () => { afterEach(() => resetComboEffortWarningStateForTests()); + test("detects image input without treating ordinary text as media", () => { + expect(comboRequestHasImageInput({ input: [{ type: "input_text", text: "hello" }] })).toBe(false); + expect(comboRequestHasImageInput({ + input: [{ type: "input_image", image_url: "data:image/png;base64,AA==" }], + })).toBe(true); + expect(comboRequestHasImageInput({ + input: [{ role: "user", content: [{ type: "input_image", image_url: "https://x/y.png" }] }], + })).toBe(true); + }); + test("detects canonical and alias combo model ids in raw request records", () => { const config = baseConfig({ combos: { free: { ...VALID_COMBO, alias: "deepseek-v4-flash" } } }); expect(comboIdFromRawBody({ model: "combo/free" }, config)).toBe("free"); @@ -235,7 +246,7 @@ describe("combo request cloning", () => { expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", ["low", "medium"]).reasoning).toBeUndefined(); }); - test("debug-warns once per unsupported combo default", () => { + test("debug-warns once per unsupported or unknown combo default", () => { const debug = spyOn(console, "debug").mockImplementation(() => {}); concreteComboRequestBody({ model: "combo/x" }, target, "high", []); concreteComboRequestBody({ model: "combo/x" }, target, "high", []); @@ -246,6 +257,13 @@ describe("combo request cloning", () => { requestedEffort: "high", capability: "unsupported", }); + concreteComboRequestBody({ model: "combo/x" }, target, "medium", undefined); + concreteComboRequestBody({ model: "combo/x" }, target, "medium", undefined); + expect(debug).toHaveBeenCalledTimes(2); + expect(debug.mock.calls[1]?.[1]).toMatchObject({ + requestedEffort: "medium", + capability: "unknown", + }); debug.mockRestore(); }); }); @@ -501,10 +519,16 @@ describe("combo validation and normalization", () => { strategy: "failover", stickyLimit: 1, defaultEffort: "high", + imageInput: "auto", alias: null, targets: [{ provider: "a", model: "m1", weight: 2 }], }); + expect(normalizeComboConfig({ + imageInput: "disabled", + targets: [{ provider: "a", model: "m1" }], + }).imageInput).toBe("disabled"); expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).defaultEffort).toBeNull(); + expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).imageInput).toBe("auto"); expect(comboDefaultEffort(baseConfig(), "free")).toBeNull(); const aliased = baseConfig({ combos: { free: { ...VALID_COMBO, alias: " deepseek-v4-flash " } }, 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 => {