Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ default and leaves the target's own behavior unchanged. Supported values are `lo
`high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to
the caller and target.

## Image / multimodal capability

By default a combo publishes the **intersection** of its targets' input modalities (image is
enabled only when every target advertises it). Set `imageInput: "disabled"` to force text-only
even when every target supports images — the catalog drops `image` from `inputModalities`, and
image-bearing requests are rejected with HTTP 400 before any target is called. `"auto"` (or
omitting the field) keeps the automatic intersection.

## Encrypted v2 sub-agent tasks

There is one important limitation for Codex v2 sub-agents ([issue #92](https://github.com/lidge-jun/opencodex/issues/92)).
Expand Down Expand Up @@ -256,6 +264,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| `strategy` | No | `"failover"` | `"failover"` or `"round-robin"`. |
| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. |
| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. |
| `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"disabled"` drops image from published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use capability-intersection wording in all configuration tables.

imageInput has no "enabled" value. "auto" derives image support from the intersection of target modalities, while "disabled" forces text-only behavior. The current wording can imply an unsupported configuration mode or an additional validation rule.

  • docs-site/src/content/docs/guides/combos.md#L267-L267: State that "auto" publishes image support only when every target supports images.
  • docs-site/src/content/docs/reference/configuration/routing.md#L66-L66: Replace the enablement wording with the automatic intersection rule.
  • docs-site/src/content/docs/zh-cn/guides/combos.md#L217-L217: Use the same capability-intersection wording in Chinese.
  • docs-site/src/content/docs/zh-cn/reference/configuration/routing.md#L60-L60: Use the same capability-intersection wording in the localized reference table.
📍 Affects 4 files
  • docs-site/src/content/docs/guides/combos.md#L267-L267 (this comment)
  • docs-site/src/content/docs/reference/configuration/routing.md#L66-L66
  • docs-site/src/content/docs/zh-cn/guides/combos.md#L217-L217
  • docs-site/src/content/docs/zh-cn/reference/configuration/routing.md#L60-L60
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/combos.md` at line 267, Update the
imageInput descriptions to use capability-intersection wording: in
docs-site/src/content/docs/guides/combos.md:267, state that "auto" publishes
image support only when every target supports images; apply the equivalent
automatic intersection rule in
docs-site/src/content/docs/reference/configuration/routing.md:66; use the same
meaning in Chinese at docs-site/src/content/docs/zh-cn/guides/combos.md:217 and
docs-site/src/content/docs/zh-cn/reference/configuration/routing.md:60. Do not
imply an "enabled" mode or add a separate validation rule.

Source: Path instructions

| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |

## Troubleshooting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。

当目标能力未知,或者不包含配置的 effort 时,opencodex 会省略默认值,并保持目标自身行为不变。支持的值是 `low`、`medium`、`high`、`xhigh`、`max` 和 `ultra`;省略该字段或将其设为 `null`,就会把 effort 完全交给调用方和目标。

## 图片 / 多模态能力

默认情况下,combo 会发布其目标 **input modalities 的交集**(只有当每个目标都声明支持图片时,图片才会启用)。设置 `imageInput: "disabled"` 可在目标均支持图片时仍强制仅文本——目录会从 `inputModalities` 中去掉 `image`,带图请求会在分发前以 HTTP 400 拒绝。`"auto"`(或省略该字段)保持自动交集。

## 加密的 v2 子代理任务

对于 Codex v2 子代理,有一个重要限制([issue #92](https://github.com/lidge-jun/opencodex/issues/92))。原生父进程只能把新启动 worker 的任务,以为原生 ChatGPT 后端生成的密文形式发送出去。外部 provider 无法读取那段负载。
Expand Down Expand Up @@ -210,6 +214,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键:
| `strategy` | 否 | `"failover"` | `"failover"` 或 `"round-robin"`。 |
| `stickyLimit` | 否 | `1` | 每次轮询选择可连续处理的成功请求数,范围为 1 到 100。 |
| `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 |
| `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
| `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 |

## 故障排查
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions gui/src/combo-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ComboTarget } from "./combo-workspace-data";
import type { ModelOption } from "./components/combo-workspace-types";

/** Whether every selected target advertises image input (incomplete rows fail closed). */
export function comboImagesSupported(targets: ComboTarget[], models: ModelOption[]): boolean {
if (targets.length === 0) return false;
return targets.every((target) => {
const provider = target.provider.trim();
const modelId = target.model.trim();
if (!provider || !modelId) return false;
const model = models.find((row) => row.provider === provider && row.id === modelId);
return !!model?.inputModalities?.includes("image");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}
11 changes: 11 additions & 0 deletions gui/src/combo-workspace-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export function newComboTarget(partial: Partial<ComboTarget> = {}): ComboTarget
};
}


function normalizeImageInput(value: unknown): "auto" | "disabled" {
return value === "disabled" ? "disabled" : "auto";
}

export interface ComboItem {
id: string;
/** Wire id shown to clients, e.g. combo/free */
Expand All @@ -64,6 +69,7 @@ export interface ComboItem {
strategy: ComboStrategy;
stickyLimit: number;
defaultEffort: ComboEffort | null;
imageInput?: "auto" | "disabled";
targets: ComboTarget[];
}

Expand Down Expand Up @@ -153,6 +159,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
strategy: normalizeStrategy(r.strategy),
stickyLimit: normalizeStickyLimit(r.stickyLimit),
defaultEffort: normalizeDefaultEffort(r.defaultEffort),
imageInput: normalizeImageInput(r.imageInput),
targets,
});
}
Expand Down Expand Up @@ -210,6 +217,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
|| a.strategy !== b.strategy
|| a.stickyLimit !== b.stickyLimit
|| a.defaultEffort !== b.defaultEffort
|| (a.imageInput ?? "auto") !== (b.imageInput ?? "auto")
) return false;
if (a.targets.length !== b.targets.length) return false;
return a.targets.every((t, i) => {
Expand All @@ -226,6 +234,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: ComboStrategy;
stickyLimit?: number;
defaultEffort: ComboEffort | null;
imageInput?: "disabled";
alias?: string;
};
} {
Expand All @@ -238,6 +247,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
: { provider: target.provider.trim(), model: target.model.trim() }),
strategy: item.strategy,
defaultEffort: item.defaultEffort,
...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}),
...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}),
},
Expand Down Expand Up @@ -326,6 +336,7 @@ export function emptyDraft(id = ""): ComboItem {
strategy: "failover",
stickyLimit: 1,
defaultEffort: null,
imageInput: "auto",
targets: [newComboTarget()],
};
}
9 changes: 8 additions & 1 deletion gui/src/components/combo-workspace-add-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { IconX } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";

export function AddComboModal({
Expand Down Expand Up @@ -204,6 +204,13 @@ export function AddComboModal({
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
</p>
</div>
<ComboCapabilities
targets={draft.targets}
models={models}
imageInput={draft.imageInput ?? "auto"}
disabled={busy}
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
/>
</div>
<div className="cwi-modal-actions">
<button type="button" className="btn btn-ghost" onClick={requestClose} disabled={busy}>{t("common.cancel")}</button>
Expand Down
45 changes: 45 additions & 0 deletions gui/src/components/combo-workspace-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState } from "react";
import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data";
import { comboImagesSupported } from "../combo-capabilities";
import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data";
import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
import { Switch } from "../ui";
import { formatProviderDisplayName } from "../provider-icons";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { clampedNumberInput, enabledProviders, modelsForProvider } from "./combo-workspace-utils";
Expand Down Expand Up @@ -83,6 +85,49 @@ export function EffortSelect({
);
}


export function ComboCapabilities({
targets,
models,
imageInput,
disabled,
onChange,
}: {
targets: ComboTarget[];
models: ModelOption[];
imageInput: "auto" | "disabled";
disabled?: boolean;
onChange: (patch: { imageInput?: "auto" | "disabled" }) => void;
}) {
const t = useT();
const imagesSupported = comboImagesSupported(targets, models);
// Default: checked (auto) when supported; force off when any target lacks image.
const effectiveOn = imagesSupported && imageInput !== "disabled";

return (
<section className="cwi-capabilities" aria-label={t("cws.capabilities")}>
<span className="field-label">{t("cws.capabilities")}</span>
<div className="cwi-capability-row">
<div>
<span className="cwi-capability-label">{t("cws.capability.imageInput")}</span>
<p className="muted cwi-capability-hint">
{imagesSupported ? t("cws.capability.imageInputHint") : t("cws.capability.imageInputUnavailable")}
</p>
</div>
<Switch
on={effectiveOn}
onClick={() => {
if (!imagesSupported) return;
onChange({ imageInput: imageInput === "auto" ? "disabled" : "auto" });
}}
disabled={disabled || !imagesSupported}
label={t("cws.capability.imageInput")}
/>
</div>
</section>
);
}

export function TargetEditor({
targets,
strategy,
Expand Down
11 changes: 9 additions & 2 deletions gui/src/components/combo-workspace-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { IconChevron, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";

type DetailTab = "config" | "about";
Expand Down Expand Up @@ -52,7 +52,7 @@ export function DetailPanel({
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [copied, setCopied] = useState(false);
const dirty = !draftEquals(draft, baseline);
const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
const effortMap = useMemo(() => {
const map = new Map<string, string[] | undefined>();
for (const model of models) {
Expand Down Expand Up @@ -267,6 +267,13 @@ export function DetailPanel({
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
</p>
</div>
<ComboCapabilities
targets={draft.targets}
models={models}
imageInput={draft.imageInput ?? "auto"}
disabled={busy}
onChange={(patch) => updateDraft((d) => ({ ...d, ...patch }))}
/>
</div>
) : (
<section className="pwi-section">
Expand Down
1 change: 1 addition & 0 deletions gui/src/components/combo-workspace-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type ModelOption = {
id: string;
namespaced?: string;
reasoningEfforts?: string[];
inputModalities?: string[];
};

export interface ComboWorkspaceProps {
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,10 @@ export const de: Record<TKey, string> = {
"cws.field.defaultEffort": "Standard-Reasoning",
"cws.field.defaultEffortNone": "Keine (Ziel-Standard)",
"cws.field.defaultEffortHint": "Nur verwendet, wenn der Client keinen Reasoning-Aufwand sendet. Optionen sind die Schnittmenge der beworbenen Aufwände der gewählten Ziele.",
"cws.capability.imageInputUnavailable": "Erst verfügbar, wenn jedes gewählte Ziel Bildeingabe unterstützt.",
"cws.capability.imageInputHint": "Standardmäßig aktiv, wenn jedes Ziel Bilder unterstützt. Ausschalten für nur Text.",
"cws.capability.imageInput": "Bild / multimodal",
"cws.capabilities": "Fähigkeiten",
"cws.field.defaultEffortUnsupported": "Dieser Aufwand liegt nicht in der gemeinsamen Leiter der Ziele — er wird zur Anfragezeit ignoriert oder angepasst.",
"cws.field.defaultEffortUnsupportedOption": "nicht in der Schnittmenge",
"cws.targets": "Ziele",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1768,6 +1768,10 @@ export const en = {
"cws.field.defaultEffort": "Default reasoning",
"cws.field.defaultEffortNone": "None (target default)",
"cws.field.defaultEffortHint": "Used only when the client omits reasoning effort. Options are the intersection of the selected targets' advertised efforts; targets without catalog effort metadata offer none.",
"cws.capability.imageInputUnavailable": "Unavailable until every selected target supports image input.",
"cws.capability.imageInputHint": "On by default when every target supports images. Turn off to accept text only.",
"cws.capability.imageInput": "Image / multimodal",
"cws.capabilities": "Capabilities",
"cws.field.defaultEffortUnsupported": "This effort is not in the targets' common ladder — it will be ignored or snapped at request time.",
"cws.field.defaultEffortUnsupportedOption": "not in intersection",
"cws.targets": "Targets",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,10 @@ export const ja: Record<TKey, string> = {
"cws.field.defaultEffort": "デフォルトの推論",
"cws.field.defaultEffortNone": "なし(ターゲットのデフォルト)",
"cws.field.defaultEffortHint": "クライアントが推論負荷を省略した場合のみ使用されます。選択肢は選択ターゲットが広告する負荷の交差です。",
"cws.capability.imageInputUnavailable": "選択した全ターゲットが画像入力に対応すると有効になります。",
"cws.capability.imageInputHint": "全ターゲットが画像対応なら既定でオン。オフにするとテキストのみ。",
"cws.capability.imageInput": "画像 / マルチモーダル",
"cws.capabilities": "能力",
"cws.field.defaultEffortUnsupported": "この負荷はターゲット共通の階段にありません — リクエスト時に無視またはスナップされます。",
"cws.field.defaultEffortUnsupportedOption": "交差に含まれない",
"cws.targets": "ターゲット",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,10 @@ export const ko: Record<TKey, string> = {
"cws.field.defaultEffort": "기본 추론 수준",
"cws.field.defaultEffortNone": "없음 (대상 기본값)",
"cws.field.defaultEffortHint": "클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 선택한 대상이 광고하는 수준의 교집합입니다.",
"cws.capability.imageInputUnavailable": "선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다.",
"cws.capability.imageInputHint": "모든 대상이 이미지를 지원하면 기본으로 켜집니다. 끄면 텍스트만 허용합니다.",
"cws.capability.imageInput": "이미지 / 멀티모달",
"cws.capabilities": "기능",
"cws.field.defaultEffortUnsupported": "이 수준은 대상의 공통 사다리에 없습니다 — 요청 시 무시되거나 스냅됩니다.",
"cws.field.defaultEffortUnsupportedOption": "교집합에 없음",
"cws.targets": "대상",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,10 @@ export const ru: Record<TKey, string> = {
"cws.field.defaultEffort": "Рассуждения по умолчанию",
"cws.field.defaultEffortNone": "Нет (по умолчанию для цели)",
"cws.field.defaultEffortHint": "Используется, только если клиент не указал уровень рассуждений. Варианты — пересечение заявленных уровней выбранных целей.",
"cws.capability.imageInputUnavailable": "Доступно, когда все выбранные цели поддерживают ввод изображений.",
"cws.capability.imageInputHint": "Включено по умолчанию, если все цели поддерживают изображения. Выключите, чтобы принимать только текст.",
"cws.capability.imageInput": "Изображения / мультимодальность",
"cws.capabilities": "Возможности",
"cws.field.defaultEffortUnsupported": "Этот уровень не входит в общую лестницу целей — при запросе он будет проигнорирован или снижен.",
"cws.field.defaultEffortUnsupportedOption": "нет в пересечении",
"cws.targets": "Цели",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,10 @@ export const zh: Record<TKey, string> = {
"cws.field.defaultEffort": "默认推理级别",
"cws.field.defaultEffortNone": "无(使用目标默认)",
"cws.field.defaultEffortHint": "仅在客户端未指定推理级别时使用。选项为所选目标已公布努力级别的交集。",
"cws.capability.imageInputUnavailable": "所有已选目标均支持图片输入后才可用。",
"cws.capability.imageInputHint": "所有目标均支持图片时默认开启;关闭后仅接受文本。",
"cws.capability.imageInput": "图片 / 多模态",
"cws.capabilities": "能力",
"cws.field.defaultEffortUnsupported": "该级别不在目标的公共阶梯中 — 请求时会被忽略或就近映射。",
"cws.field.defaultEffortUnsupportedOption": "不在交集中",
"cws.targets": "目标",
Expand Down
Loading
Loading