From 53bcc3ad762f4a956ded34e6d32d80d774e4235f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Thu, 6 Aug 2026 09:15:14 +0800 Subject: [PATCH 1/4] fix(gui,combos): restore default effort picker and catalog combos Unknown or empty member effort ladders no longer empty the combo default effort picker, and runtime injects the default when the target ladder is unknown. Incomplete discovery rows get a conservative context-window fallback so multi-target combos stay in the public catalog. Combo ID and public model fields now show the effective client model name with copy. --- gui/src/combo-workspace-data.ts | 23 ++- .../components/combo-workspace-add-modal.tsx | 10 +- .../components/combo-workspace-controls.tsx | 34 ++++ .../combo-workspace-detail-panel.tsx | 9 +- gui/src/i18n/de.ts | 13 +- gui/src/i18n/en.ts | 13 +- gui/src/i18n/ja.ts | 13 +- gui/src/i18n/ko.ts | 13 +- gui/src/i18n/ru.ts | 13 +- gui/src/i18n/zh.ts | 13 +- gui/src/styles-combos-workspace.css | 27 +++ src/codex/catalog.ts | 2 +- src/codex/catalog/provider-fetch.ts | 90 ++++++++- src/combos/request.ts | 7 +- tests/codex-catalog.test.ts | 175 +++++++++++++++++- tests/combo-workspace-data.test.ts | 29 ++- tests/combos.test.ts | 16 +- 17 files changed, 439 insertions(+), 61 deletions(-) diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index bc29bff703..f92a7f265e 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,7 +39,9 @@ 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)); } diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index cff828c5c8..e0049a8ad7 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 { EffortSelect, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { clampedNumberInput } from "./combo-workspace-utils"; export function AddComboModal({ @@ -139,11 +139,9 @@ export function AddComboModal({

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

-

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

+
{t("cws.strategy")} diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index 8a7277fd48..bd5a44f5aa 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -6,6 +6,7 @@ import { useT } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; 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, @@ -262,3 +263,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..1c4f505497 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -11,7 +11,7 @@ import { IconChevron, IconTrash } from "../icons"; import { useT } from "../i18n/shared"; import { Notice } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; -import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; +import { EffortSelect, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { clampedNumberInput } from "./combo-workspace-utils"; type DetailTab = "config" | "about"; @@ -188,9 +188,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 +208,9 @@ export function DetailPanel({

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

+
{t("cws.strategy")} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4d633e10a..8433be8564 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1676,6 +1676,8 @@ export const de: Record = { "cws.allCombos": "Alle Combos", "cws.copyModel": "ID kopieren", "cws.copied": "Kopiert", + "cws.copyPublicModel": "Kopieren", + "cws.copiedPublicModel": "Kopiert", "cws.tab.config": "Konfiguration", "cws.tab.about": "Info", "cws.strategy": "Strategie", @@ -1684,17 +1686,18 @@ 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.targets": "Ziele", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index df67c75023..7482f5fe40 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1709,6 +1709,8 @@ export const en = { "cws.allCombos": "All combos", "cws.copyModel": "Copy id", "cws.copied": "Copied", + "cws.copyPublicModel": "Copy", + "cws.copiedPublicModel": "Copied", "cws.tab.config": "Config", "cws.tab.about": "About", "cws.strategy": "Strategy", @@ -1717,17 +1719,18 @@ 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.targets": "Targets", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index eb98a71eb6..9dc6087510 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1740,6 +1740,8 @@ export const ja: Record = { "cws.allCombos": "すべてのコンボ", "cws.copyModel": "ID をコピー", "cws.copied": "コピーしました", + "cws.copyPublicModel": "コピー", + "cws.copiedPublicModel": "コピーしました", "cws.renamed": "{from} を {to} に変更しました。", "cws.tab.config": "設定", "cws.tab.about": "概要", @@ -1749,17 +1751,18 @@ 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.targets": "ターゲット", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 339cdbe970..c80ffcb8f9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1703,6 +1703,8 @@ export const ko: Record = { "cws.allCombos": "모든 콤보", "cws.copyModel": "ID 복사", "cws.copied": "복사됨", + "cws.copyPublicModel": "복사", + "cws.copiedPublicModel": "복사됨", "cws.tab.config": "설정", "cws.tab.about": "정보", "cws.strategy": "전략", @@ -1711,17 +1713,18 @@ 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.targets": "대상", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7904e27c3e..7a6b5bfeee 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1783,6 +1783,8 @@ export const ru: Record = { "cws.allCombos": "Все комбо", "cws.copyModel": "Копировать id", "cws.copied": "Скопировано", + "cws.copyPublicModel": "Копировать", + "cws.copiedPublicModel": "Скопировано", "cws.tab.config": "Конфигурация", "cws.tab.about": "О комбо", "cws.strategy": "Стратегия", @@ -1791,17 +1793,18 @@ 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.targets": "Цели", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8b16dbe6c8..313bb9c510 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1696,6 +1696,8 @@ export const zh: Record = { "cws.allCombos": "全部组合", "cws.copyModel": "复制 ID", "cws.copied": "已复制", + "cws.copyPublicModel": "复制", + "cws.copiedPublicModel": "已复制", "cws.tab.config": "配置", "cws.tab.about": "关于", "cws.strategy": "策略", @@ -1704,17 +1706,18 @@ 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.targets": "目标", diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css index f73aa3b8a0..5af5ef71f7 100644 --- a/gui/src/styles-combos-workspace.css +++ b/gui/src/styles-combos-workspace.css @@ -318,6 +318,33 @@ overflow-wrap: anywhere; } +/* 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; 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/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b88220c156..d941e9b642 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -607,6 +607,83 @@ 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. + const contextWindow = hintedContext + ?? (existing || prov ? COMBO_MEMBER_CONTEXT_FALLBACK : undefined); + if (contextWindow === undefined) return undefined; + + 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 + ? hinted.maxInputTokens + : contextWindow; + + return { + ...hinted, + inputModalities, + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + contextWindow, + maxInputTokens, + }; +} + 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 +1339,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 +1359,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/request.ts b/src/combos/request.ts index c7d535f7c9..cbd2cc50e3 100644 --- a/src/combos/request.ts +++ b/src/combos/request.ts @@ -40,7 +40,10 @@ export function concreteComboRequestBody( && !Object.prototype.hasOwnProperty.call(reasoning, "effort") ); if (!needsDefault) return clone; - if (!targetReasoningEfforts?.includes(defaultEffort)) { + // Unknown ladder (`undefined`): inject optimistically — catalog metadata is + // incomplete more often than the provider truly lacks the effort. Explicit + // arrays still gate: missing default → omit + debug warn. + if (targetReasoningEfforts !== undefined && !targetReasoningEfforts.includes(defaultEffort)) { const key = `${target.provider}/${target.model}:${defaultEffort}`; if (!warnedUnsupportedDefaults.has(key)) { warnedUnsupportedDefaults.add(key); @@ -48,7 +51,7 @@ export function concreteComboRequestBody( provider: target.provider, model: target.model, requestedEffort: defaultEffort, - capability: targetReasoningEfforts === undefined ? "unknown" : "unsupported", + capability: "unsupported", }); } return clone; diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a2..609b076d59 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, @@ -499,7 +499,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 +673,175 @@ 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"], + }); + // 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-workspace-data.test.ts b/tests/combo-workspace-data.test.ts index 43a6c00366..7dc170cd72 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { type ComboItem, + COMBO_EFFORTS, buildComboAttention, comboPublicModelId, draftEquals, @@ -171,14 +172,36 @@ 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("attention flags zero-target and one-target defensive rows", () => { diff --git a/tests/combos.test.ts b/tests/combos.test.ts index f2705d6437..8bb54898c0 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -228,13 +228,25 @@ describe("combo request cloning", () => { ).reasoning).toEqual({ summary: "concise", effort: "high" }); }); - test("omits combo defaults for unset, unsupported, and unknown target capabilities", () => { + test("omits combo defaults for unset and unsupported target capabilities", () => { expect(concreteComboRequestBody({ model: "combo/x" }, target, null, ["high"]).reasoning).toBeUndefined(); expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", []).reasoning).toBeUndefined(); - expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", undefined).reasoning).toBeUndefined(); expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", ["low", "medium"]).reasoning).toBeUndefined(); }); + test("injects combo default optimistically when target ladder is unknown", () => { + expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", undefined)).toEqual({ + model: "a/m1", + reasoning: { effort: "high" }, + }); + expect(concreteComboRequestBody( + { model: "combo/x", reasoning: { summary: "auto" } }, + target, + "medium", + undefined, + ).reasoning).toEqual({ summary: "auto", effort: "medium" }); + }); + test("debug-warns once per unsupported combo default", () => { const debug = spyOn(console, "debug").mockImplementation(() => {}); concreteComboRequestBody({ model: "combo/x" }, target, "high", []); From 098039a11850f76f661a57c63ccf91b8cea9f946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Thu, 6 Aug 2026 09:35:40 +0800 Subject: [PATCH 2/4] fix(gui,catalog): address PR #1092 review on copy feedback and context cap Use shared useCopyFeedback for the combo detail header chip (copied + unavailable), clamp COMBO_MEMBER_CONTEXT_FALLBACK with provider contextCap, and document optimistic defaultEffort injection for unknown ladders. --- docs-site/src/content/docs/guides/combos.md | 12 ++++---- .../docs/reference/configuration/routing.md | 14 +++++---- .../combo-workspace-detail-panel.tsx | 30 +++++++++++-------- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + src/codex/catalog/provider-fetch.ts | 21 +++++++++++-- tests/codex-catalog.test.ts | 24 +++++++++++++++ 11 files changed, 81 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 040da7ec86..1eefdfe86e 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -153,16 +153,18 @@ quota, and overload failures; it does not hide caller errors or policy refusals. 1. the combo has a non-null default; 2. the caller did not set an effort; and -3. the selected target's catalog advertises that exact effort. +3. the selected target does not advertise a known effort ladder that rejects the value. If the request has no `reasoning` object, opencodex creates one. If `reasoning` exists without an `effort` property, it preserves the other fields and adds the default. A caller-provided effort is 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. +When the target's catalog effort ladder is unknown (metadata missing), the default is injected +optimistically — incomplete discovery is more common than a provider that truly lacks the effort. +When a *known* ladder is present and does not include the configured value, opencodex omits the +default, leaves the target's own behavior unchanged, and logs a one-shot debug note. 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. ## 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..be11527efb 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -182,13 +182,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 +225,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/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index 1c4f505497..8dc3094654 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -13,6 +13,7 @@ import { Notice } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { 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,11 +47,11 @@ 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 effortMap = useMemo(() => { @@ -82,16 +83,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 +123,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 +143,13 @@ export function DetailPanel({ )}

{headerModel}

{!isCreate && ( - )}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8433be8564..c50aad1090 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1676,6 +1676,7 @@ 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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7482f5fe40..4bd0a89cd5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1709,6 +1709,7 @@ 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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9dc6087510..7713fc7249 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1740,6 +1740,7 @@ export const ja: Record = { "cws.allCombos": "すべてのコンボ", "cws.copyModel": "ID をコピー", "cws.copied": "コピーしました", + "cws.copyUnavailable": "クリップボードを利用できません", "cws.copyPublicModel": "コピー", "cws.copiedPublicModel": "コピーしました", "cws.renamed": "{from} を {to} に変更しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c80ffcb8f9..f04744c032 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1703,6 +1703,7 @@ export const ko: Record = { "cws.allCombos": "모든 콤보", "cws.copyModel": "ID 복사", "cws.copied": "복사됨", + "cws.copyUnavailable": "클립보드를 사용할 수 없음", "cws.copyPublicModel": "복사", "cws.copiedPublicModel": "복사됨", "cws.tab.config": "설정", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7a6b5bfeee..46620745e1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1783,6 +1783,7 @@ export const ru: Record = { "cws.allCombos": "Все комбо", "cws.copyModel": "Копировать id", "cws.copied": "Скопировано", + "cws.copyUnavailable": "Буфер обмена недоступен", "cws.copyPublicModel": "Копировать", "cws.copiedPublicModel": "Скопировано", "cws.tab.config": "Конфигурация", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 313bb9c510..99350e95d8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1696,6 +1696,7 @@ export const zh: Record = { "cws.allCombos": "全部组合", "cws.copyModel": "复制 ID", "cws.copied": "已复制", + "cws.copyUnavailable": "剪贴板不可用", "cws.copyPublicModel": "复制", "cws.copiedPublicModel": "已复制", "cws.tab.config": "配置", diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index d941e9b642..befb5349a6 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -661,9 +661,23 @@ export function resolveComboCatalogMember( // 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. - const contextWindow = hintedContext + // 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 (contextWindow === undefined) return 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 @@ -672,7 +686,7 @@ export function resolveComboCatalogMember( ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) ?? base.reasoningEfforts; const maxInputTokens = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 - ? hinted.maxInputTokens + ? Math.min(hinted.maxInputTokens, contextWindow) : contextWindow; return { @@ -681,6 +695,7 @@ export function resolveComboCatalogMember( ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), contextWindow, maxInputTokens, + ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), }; } diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 609b076d59..4dcc57a678 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -780,6 +780,30 @@ describe("combo catalog capability intersection", () => { 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" }, From a89b409f5fb89f6d2f580874ab48844917e617c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Thu, 6 Aug 2026 09:45:32 +0800 Subject: [PATCH 3/4] chore: retrigger PR checks against base dev From 0f9c7a042aa95cf2f5d12a9f5f6752d9674a4bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Thu, 6 Aug 2026 17:38:19 +0800 Subject: [PATCH 4/4] fix(combos): fail closed on unknown effort; add imageInput capability Address PR #1092 maintainer feedback: restore request-time fail-closed defaultEffort injection for unknown ladders while keeping unknown vs unsupported debug discrimination, and mark unknown-capability targets in the GUI effort picker. Also add per-combo image/multimodal control (imageInput auto|disabled, default auto/checked): catalog strips image when disabled, management API persists only explicit disabled, and requests with image content are rejected before dispatch. --- docs-site/src/content/docs/guides/combos.md | 21 ++++--- .../docs/reference/configuration/routing.md | 1 + .../src/content/docs/zh-cn/guides/combos.md | 7 ++- .../zh-cn/reference/configuration/routing.md | 1 + gui/src/combo-capabilities.ts | 14 +++++ gui/src/combo-workspace-data.ts | 32 +++++++++++ .../components/combo-workspace-add-modal.tsx | 15 ++++- .../components/combo-workspace-controls.tsx | 57 ++++++++++++++++++- .../combo-workspace-detail-panel.tsx | 17 +++++- gui/src/components/combo-workspace-types.ts | 1 + gui/src/i18n/de.ts | 6 ++ gui/src/i18n/en.ts | 6 ++ gui/src/i18n/ja.ts | 6 ++ gui/src/i18n/ko.ts | 6 ++ gui/src/i18n/ru.ts | 6 ++ gui/src/i18n/zh.ts | 6 ++ gui/src/pages/Combos.tsx | 8 +++ gui/src/styles-combos-workspace.css | 31 ++++++++++ src/codex/catalog/aggregation.ts | 6 +- src/combos/index.ts | 1 + src/combos/request.ts | 21 +++++-- src/combos/types.ts | 6 ++ src/server/management/combo-routes.ts | 13 ++++- src/server/responses/core.ts | 4 ++ src/types.ts | 6 ++ tests/codex-catalog.test.ts | 12 ++++ tests/combo-management-api.test.ts | 27 ++++++++- tests/combo-workspace-data.test.ts | 18 ++++++ tests/combos.test.ts | 42 +++++++++----- tests/server-combo-failover-e2e.test.ts | 17 ++++++ 30 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 gui/src/combo-capabilities.ts diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 1eefdfe86e..9dda2843c7 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -153,18 +153,25 @@ quota, and overload failures; it does not hide caller errors or policy refusals. 1. the combo has a non-null default; 2. the caller did not set an effort; and -3. the selected target does not advertise a known effort ladder that rejects the value. +3. the selected target's catalog advertises that exact effort. If the request has no `reasoning` object, opencodex creates one. If `reasoning` exists without an `effort` property, it preserves the other fields and adds the default. A caller-provided effort is never overwritten. -When the target's catalog effort ladder is unknown (metadata missing), the default is injected -optimistically — incomplete discovery is more common than a provider that truly lacks the effort. -When a *known* ladder is present and does not include the configured value, opencodex omits the -default, leaves the target's own behavior unchanged, and logs a one-shot debug note. 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. +When target capability is unknown or does not include the configured effort, opencodex omits the +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 be11527efb..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 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 f92a7f265e..43f6332238 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -45,6 +45,24 @@ export function intersectComboEfforts( 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; @@ -73,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[]; } @@ -132,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; @@ -162,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, }); } @@ -219,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) => { @@ -235,6 +264,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} strategy: ComboStrategy; stickyLimit?: number; defaultEffort: ComboEffort | null; + imageInput?: "disabled"; alias?: string; }; } { @@ -248,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() } : {}), }, }; @@ -335,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 e0049a8ad7..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, PublicModelPreview, 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; @@ -161,6 +166,7 @@ export function AddComboModal({ value={draft.defaultEffort} disabled={busy} allowedEfforts={allowedEfforts} + hasUnknownTargets={hasUnknownEffortTargets} onChange={(defaultEffort) => setDraft((d) => ({ ...d, defaultEffort }))} />

@@ -202,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 bd5a44f5aa..4712077c3a 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -1,9 +1,11 @@ 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"; @@ -46,6 +48,7 @@ export function EffortSelect({ onChange, disabled, allowedEfforts, + hasUnknownTargets, }: { id: string; value: ComboEffort | null; @@ -53,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; @@ -72,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, diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index 8dc3094654..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,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, PublicModelPreview, 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"; @@ -53,7 +54,7 @@ export function DetailPanel({ const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); 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) { @@ -65,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); @@ -234,6 +239,7 @@ export function DetailPanel({ value={draft.defaultEffort} disabled={busy} allowedEfforts={allowedEfforts} + hasUnknownTargets={hasUnknownEffortTargets} onChange={(defaultEffort) => updateDraft((d) => ({ ...d, defaultEffort }))} />

@@ -272,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 c50aad1090..87f9918920 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1701,6 +1701,12 @@ export const de: Record = { "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 4bd0a89cd5..3dde76a71a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1734,6 +1734,12 @@ export const en = { "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 7713fc7249..d803b25675 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1766,6 +1766,12 @@ export const ja: Record = { "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 f04744c032..77cd183c77 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1728,6 +1728,12 @@ export const ko: Record = { "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 46620745e1..8d2fcd0bb9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1808,6 +1808,12 @@ export const ru: Record = { "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 99350e95d8..d38ddcc65a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1721,6 +1721,12 @@ export const zh: Record = { "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 5af5ef71f7..13d18bcce3 100644 --- a/gui/src/styles-combos-workspace.css +++ b/gui/src/styles-combos-workspace.css @@ -318,6 +318,33 @@ 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; @@ -512,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/aggregation.ts b/src/codex/catalog/aggregation.ts index 03b0e17c96..7a2d50f8c6 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -128,9 +128,13 @@ export function deriveComboCatalogModel( ): CatalogModel | null { if (comboCatalogOmissionReason(combo, members) !== null) return null; - const inputModalities = intersectStrings( + const derivedInputModalities = intersectStrings( members.map(member => member.inputModalities ?? ["text"]), ); + const inputModalities = combo.imageInput === "disabled" + ? derivedInputModalities.filter(modality => modality !== "image") + : derivedInputModalities; + if (inputModalities.length === 0) return null; const reasoningEfforts = intersectStrings( members.map(member => member.reasoningEfforts ?? []), ); diff --git a/src/combos/index.ts b/src/combos/index.ts index f1a0e8ebd0..3b4e857189 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -36,6 +36,7 @@ export { } from "./failover"; export { comboIdFromRawBody, + comboRequestHasImageInput, concreteComboRequestBody, resetComboEffortWarningStateForTests, } from "./request"; diff --git a/src/combos/request.ts b/src/combos/request.ts index cbd2cc50e3..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,10 +51,10 @@ export function concreteComboRequestBody( && !Object.prototype.hasOwnProperty.call(reasoning, "effort") ); if (!needsDefault) return clone; - // Unknown ladder (`undefined`): inject optimistically — catalog metadata is - // incomplete more often than the provider truly lacks the effort. Explicit - // arrays still gate: missing default → omit + debug warn. - if (targetReasoningEfforts !== undefined && !targetReasoningEfforts.includes(defaultEffort)) { + // 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)) { warnedUnsupportedDefaults.add(key); @@ -51,7 +62,7 @@ export function concreteComboRequestBody( provider: target.provider, model: target.model, requestedEffort: defaultEffort, - capability: "unsupported", + capability: targetReasoningEfforts === undefined ? "unknown" : "unsupported", }); } return clone; 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 4dcc57a678..01e13ff6ec 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -50,6 +50,7 @@ function normalizedCombo( strategy: "failover", stickyLimit: 1, defaultEffort: "medium", + imageInput: "auto", alias: null, targets: [ { provider: "a", model: "m1", weight: 1 }, @@ -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(), [ 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 7dc170cd72..2ad5ebba45 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -8,6 +8,7 @@ import { emptyDraft, filterCombos, groupCombos, + comboHasUnknownEffortTargets, intersectComboEfforts, isValidComboId, parseComboList, @@ -90,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+$/) }], }, { @@ -99,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+$/) }, @@ -204,6 +207,21 @@ describe("combo-workspace-data", () => { )).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", () => { const attention = buildComboAttention([ combo({ id: "empty", model: "combo/empty", targets: [] }), diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 8bb54898c0..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"); @@ -228,26 +239,14 @@ describe("combo request cloning", () => { ).reasoning).toEqual({ summary: "concise", effort: "high" }); }); - test("omits combo defaults for unset and unsupported target capabilities", () => { + test("omits combo defaults for unset, unsupported, and unknown target capabilities", () => { expect(concreteComboRequestBody({ model: "combo/x" }, target, null, ["high"]).reasoning).toBeUndefined(); expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", []).reasoning).toBeUndefined(); + expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", undefined).reasoning).toBeUndefined(); expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", ["low", "medium"]).reasoning).toBeUndefined(); }); - test("injects combo default optimistically when target ladder is unknown", () => { - expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", undefined)).toEqual({ - model: "a/m1", - reasoning: { effort: "high" }, - }); - expect(concreteComboRequestBody( - { model: "combo/x", reasoning: { summary: "auto" } }, - target, - "medium", - undefined, - ).reasoning).toEqual({ summary: "auto", effort: "medium" }); - }); - - 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", []); @@ -258,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(); }); }); @@ -513,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 => {