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 @@ -211,6 +211,14 @@ default and leaves the target's own behavior unchanged. Supported values are `lo
`high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to
the caller and target.

## Image / multimodal capability

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

## Encrypted v2 sub-agent tasks

There is one important limitation for Codex v2 sub-agents ([issue #92](https://github.com/lidge-jun/opencodex/issues/92)).
Expand Down Expand Up @@ -304,6 +312,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| `strategy` | No | `"failover"` | `"failover"` or `"round-robin"`. |
| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. |
| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. |
| `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"auto"` publishes image support only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
| `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. |
| `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape smooth weighted round-robin. |
| `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Applied only when the caller omits effort and the selected target advertises the requested rung. |
| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` publishes image only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
| `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. |
| `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. |
| `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. |
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 @@ -158,6 +158,10 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。

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

## 图片 / 多模态能力

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

## 加密的 v2 子代理任务

对于 Codex v2 子代理,有一个重要限制([issue #92](https://github.com/lidge-jun/opencodex/issues/92))。原生父进程只能把新启动 worker 的任务,以为原生 ChatGPT 后端生成的密文形式发送出去。外部 provider 无法读取那段负载。
Expand Down Expand Up @@ -241,6 +245,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键:
| `strategy` | 否 | `"failover"` | `"failover"` 或 `"round-robin"`。 |
| `stickyLimit` | 否 | `1` | 每次轮询选择可连续处理的成功请求数,范围为 1 到 100。 |
| `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 |
| `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 |
| `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 |
| `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 |
| `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Codex Auth 页面将此 picker 行为作为选择加入项。关闭它会隐藏
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | 选择策略。目标顺序表示故障切换优先级;权重会影响平滑加权轮询。 |
| `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 仅在调用方省略 effort 且所选目标声明了请求的档位时应用。 |
| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 |
| `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 |
| `nativeAlias?` | `boolean` | `false` | 仅让当前受支持的裸原生 id 对该不带限定前缀的 id 优先;带账号或提供方限定的 OpenAI 路由仍是独立路由。 |
| `displayName?` | `string` | — | 仅用于 catalog 展示的标签;native alias 必须提供非空值。 |
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 @@ -60,6 +60,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 @@ -73,6 +78,7 @@ export interface ComboItem {
strategy: ComboStrategy;
stickyLimit: number;
defaultEffort: ComboEffort | null;
imageInput?: "auto" | "disabled";
targets: ComboTarget[];
}

Expand Down Expand Up @@ -177,6 +183,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
strategy: normalizeStrategy(r.strategy),
stickyLimit: normalizeStickyLimit(r.stickyLimit),
defaultEffort: normalizeDefaultEffort(r.defaultEffort),
imageInput: normalizeImageInput(r.imageInput),
targets,
});
}
Expand Down Expand Up @@ -236,6 +243,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
|| a.strategy !== b.strategy
|| a.stickyLimit !== b.stickyLimit
|| a.defaultEffort !== b.defaultEffort
|| (a.imageInput ?? "auto") !== (b.imageInput ?? "auto")
) return false;
if (a.targets.length !== b.targets.length) return false;
return a.targets.every((t, i) => {
Expand All @@ -252,6 +260,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: ComboStrategy;
stickyLimit?: number;
defaultEffort: ComboEffort | null;
imageInput?: "disabled";
alias?: string;
nativeAlias?: true;
displayName?: string;
Expand All @@ -266,6 +275,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
: { provider: target.provider.trim(), model: target.model.trim() }),
strategy: item.strategy,
defaultEffort: item.defaultEffort,
...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}),
...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}),
...(item.nativeAlias ? { nativeAlias: true } : {}),
Expand Down Expand Up @@ -372,6 +382,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 @@ -12,7 +12,7 @@ import { IconChevron, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";

type DetailTab = "config" | "about";
Expand Down Expand Up @@ -81,7 +81,7 @@ export function DetailPanel({
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [copied, setCopied] = useState(false);
const dirty = !draftEquals(draft, baseline);
const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
const effortMap = useMemo(() => {
const map = new Map<string, string[] | undefined>();
for (const model of models) {
Expand Down Expand Up @@ -354,6 +354,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>
)}
</div>
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 @@ -1770,6 +1770,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 @@ -1804,6 +1804,10 @@ export const en = {
"cws.field.defaultEffort": "Default reasoning",
"cws.field.defaultEffortNone": "None (target default)",
"cws.field.defaultEffortHint": "Used only when the client omits reasoning effort. Options are the intersection of the selected targets' advertised efforts; targets without catalog effort metadata offer none.",
"cws.capability.imageInputUnavailable": "Unavailable until every selected target supports image input.",
"cws.capability.imageInputHint": "On by default when every target supports images. Turn off to accept text only.",
"cws.capability.imageInput": "Image / multimodal",
"cws.capabilities": "Capabilities",
"cws.field.defaultEffortUnsupported": "This effort is not in the targets' common ladder — it will be ignored or snapped at request time.",
"cws.field.defaultEffortUnsupportedOption": "not in intersection",
"cws.targets": "Targets",
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 @@ -1838,6 +1838,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 @@ -1797,6 +1797,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
Loading
Loading