Skip to content
Closed
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
15 changes: 12 additions & 3 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,18 @@ If the request has no `reasoning` object, opencodex creates one. If `reasoning`
never overwritten.

When target capability is unknown or does not include the configured effort, opencodex omits the
default and leaves the target's own behavior unchanged. Supported values are `low`, `medium`,
`high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to
the caller and target.
default and leaves the target's own behavior unchanged. Debug logs distinguish `unknown` (no ladder
metadata) from `unsupported` (ladder present without the value). Supported values are `low`,
`medium`, `high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort
entirely to the caller and target.

## Image / multimodal capability

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

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add imageInput to the configuration reference table.

This section documents imageInput, but the field table at Lines 261-268 omits it. Users who rely on the configuration reference will not see the supported values or default. Add the same imageInput row that exists in docs-site/src/content/docs/zh-cn/guides/combos.md Line 217.

As per path instructions, user-facing documentation must stay in sync with actual CLI and API behavior.

🤖 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` around lines 168 - 174, Add the
missing imageInput row to the configuration reference table in combos.md,
matching the corresponding zh-cn table entry and documenting its supported
values and default. Keep the description consistent with the Image / multimodal
capability section and current CLI/API behavior.

Source: Path instructions


## Encrypted v2 sub-agent tasks

Expand Down
15 changes: 10 additions & 5 deletions docs-site/src/content/docs/reference/configuration/routing.md
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 Expand Up @@ -182,13 +183,16 @@ Per-request route-decision traces are recorded when a policy profile executes.
A combo remains directly routable even when it cannot be listed. `ocx sync`, `/v1/models`, and the
Codex picker list it only when every target exposes capabilities that can be intersected:

- a positive `contextWindow`, from live metadata, registry hints, or provider
`modelContextWindows` / `contextWindow`; and
- a positive `contextWindow`, from live metadata, registry hints, provider
`modelContextWindows` / `contextWindow`, or — when the provider is known but every source
omits a window — a conservative 128,000-token fallback (clamped by `providerContextCaps` when
set); and
Comment on lines +186 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Synchronize localized catalog eligibility docs

This English fallback now allows known-provider targets without context metadata to catalog at 128k, but localized routing references still state that a positive contextWindow must come only from live/registry/provider config and that bare relay IDs with no context metadata are omitted (for example docs-site/src/content/docs/ko/reference/configuration/routing.md:81-84 and docs-site/src/content/docs/zh-cn/reference/configuration/routing.md:85-86). Fresh evidence: only the English page changed in this commit, leaving non-English docs to describe the old Needs attention behavior for recovered combos; update the translated routing pages to include the fallback and cap semantics.

AGENTS.md reference: docs-site/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

- 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

Expand Down Expand Up @@ -222,3 +226,4 @@ The history index is disposable - deleting `routing-history.sqlite` triggers
an automatic rebuild from `usage.jsonl` on the next query; `ocx logs
rebuild-index` forces one. Nothing in this system auto-tunes weights,
budgets, or candidate sets.

7 changes: 6 additions & 1 deletion docs-site/src/content/docs/zh-cn/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 子代理任务

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"`;禁用后强制纯文本,且不能在目标不支持时强开图片。 |
| `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"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the Chinese catalog-eligibility text.

Lines 86-92 still state that a target without context metadata removes the combo from the catalog. docs-site/src/content/docs/reference/configuration/routing.md Lines 186-195 now allow a known provider to use a conservative 128,000-token fallback, clamped by providerContextCaps when configured. Update the Chinese section to describe that fallback and retain exclusion only for unknown or disabled providers without a discovery row, or incompatible modalities.

As per path instructions, translated locale pages must not contradict the English source. Based on learnings, flag localized content that conflicts with the English behavior.

🤖 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/zh-cn/reference/configuration/routing.md` at line
60, Update the Chinese catalog-eligibility section to match the English
behavior: known providers may use the conservative 128,000-token context
fallback, clamped by providerContextCaps when configured. State that exclusion
applies only to unknown or disabled providers lacking a discovery row, or to
incompatible modalities, and remove the claim that missing context metadata
alone excludes the combo.

Sources: Path instructions, Learnings

| `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 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");
});
}
Comment on lines +5 to +14

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a unit test for the fail-closed branches of comboImagesSupported.

No test file covers this function in the current batch. This function gates whether the image-input toggle appears enabled in the workspace UI, so a regression here (for example, accidentally using .some instead of .every, or dropping the complete.length === 0 guard) would silently misreport capability support. Add a small test with three cases: no targets, all targets supporting images, and one target missing from the catalog.

🤖 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 `@gui/src/combo-capabilities.ts` around lines 5 - 14, Add unit coverage for
comboImagesSupported with three cases: return false for no targets, true when
every complete target’s catalog model supports image input, and false when any
target is absent from the catalog. Keep the tests focused on validating the
empty guard and all-targets requirement.

55 changes: 48 additions & 7 deletions gui/src/combo-workspace-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly string[] | undefined>,
Expand All @@ -20,22 +28,41 @@ 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;
Comment on lines +31 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep no-reasoning targets from offering efforts

When a selected model has a known empty ladder (for example a noReasoningModels catalog row, where runtime supportedLadderFor returns [] and concreteComboRequestBody omits any combo default), this branch skips it the same as unknown metadata. If all complete targets are known no-reasoning, common stays null and the picker offers the full ladder even though every saved default will be dropped at request time; distinguish undefined from [] so known empty ladders produce no selectable efforts.

AGENTS.md reference: gui/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

const member = listed.filter((effort) => effortSet.has(effort));
if (member.length === 0) continue;
if (common === null) {
common = member;
} else {
const memberSet = new Set(member);
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];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const commonSet = new Set(common);
return COMBO_EFFORTS.filter((effort) => commonSet.has(effort));
}

/**
* True when any complete target has no advertised effort ladder (undefined or
* empty). The picker treats those as wildcards, but runtime still fails closed
* for unknown ladders — surface that so options are not presented as fully known.
*/
export function comboHasUnknownEffortTargets(
targets: readonly ComboTarget[],
modelEfforts: ReadonlyMap<string, readonly string[] | undefined>,
): 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;
Expand Down Expand Up @@ -64,6 +91,11 @@ export interface ComboItem {
strategy: ComboStrategy;
stickyLimit: number;
defaultEffort: ComboEffort | null;
/**
* Image/multimodal policy. Default `auto` (checked) keeps the target
* intersection; `disabled` forces text-only.
*/
imageInput?: "auto" | "disabled";
targets: ComboTarget[];
}

Expand Down Expand Up @@ -123,6 +155,10 @@ export function normalizeWeight(raw: unknown): number | undefined {
: undefined;
}

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

export function parseComboList(payload: unknown): ComboItem[] {
if (!payload || typeof payload !== "object") return [];
const rows = (payload as { combos?: unknown }).combos;
Expand Down Expand Up @@ -153,6 +189,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
strategy: normalizeStrategy(r.strategy),
stickyLimit: normalizeStickyLimit(r.stickyLimit),
defaultEffort: normalizeDefaultEffort(r.defaultEffort),
imageInput: normalizeImageInput(r.imageInput),
targets,
});
}
Expand Down Expand Up @@ -210,6 +247,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
|| a.strategy !== b.strategy
|| a.stickyLimit !== b.stickyLimit
|| a.defaultEffort !== b.defaultEffort
|| (a.imageInput ?? "auto") !== (b.imageInput ?? "auto")
) return false;
if (a.targets.length !== b.targets.length) return false;
return a.targets.every((t, i) => {
Expand All @@ -226,6 +264,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: ComboStrategy;
stickyLimit?: number;
defaultEffort: ComboEffort | null;
imageInput?: "disabled";
alias?: string;
};
} {
Expand All @@ -239,6 +278,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: item.strategy,
defaultEffort: item.defaultEffort,
...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}),
...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}),
},
};
Expand Down Expand Up @@ -326,6 +366,7 @@ export function emptyDraft(id = ""): ComboItem {
strategy: "failover",
stickyLimit: 1,
defaultEffort: null,
imageInput: "auto",
targets: [newComboTarget()],
};
}
23 changes: 17 additions & 6 deletions gui/src/components/combo-workspace-add-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type ComboItem,
comboHasUnknownEffortTargets,
comboPublicModelId,
emptyDraft,
intersectComboEfforts,
Expand All @@ -10,7 +11,7 @@ import { IconX } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { ComboCapabilities, EffortSelect, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";

export function AddComboModal({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -139,11 +144,9 @@ export function AddComboModal({
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
{t("cws.field.aliasHint")}
</p>
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
{t("cws.field.idHint", {
model: draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : "…",
})}
</p>
<PublicModelPreview
model={draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : "…"}
/>
</div>
<div className="cwi-field">
<span className="field-label">{t("cws.strategy")}</span>
Expand All @@ -163,6 +166,7 @@ export function AddComboModal({
value={draft.defaultEffort}
disabled={busy}
allowedEfforts={allowedEfforts}
hasUnknownTargets={hasUnknownEffortTargets}
onChange={(defaultEffort) => setDraft((d) => ({ ...d, defaultEffort }))}
/>
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
Expand Down Expand Up @@ -204,6 +208,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
Loading
Loading