Skip to content
Merged
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
25 changes: 22 additions & 3 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,25 @@ Weights are relative, not percentages. Weights `2,1` and `200,100` express the s
small values that communicate intent.
:::

### Random: weighted draw per request

`random` draws one eligible target per request, with odds proportional to `weight`. Every request
is an independent draw, so traffic spreads across targets without the deterministic pattern or
stickiness of round-robin. `stickyLimit` does not affect this strategy.

### Least-used: favor the target with fewest successes

`least-used` routes each request to the eligible target with the fewest successful requests
recorded by this opencodex process. Counts start at zero on restart, and ties keep configuration
order. Weights and `stickyLimit` do not affect this strategy.

### Reset-window: follow the soonest quota reset

`reset-window` routes each request to the eligible target whose cached provider quota snapshot
shows the soonest upcoming window reset (five-hour, weekly, monthly, or custom). This spends the
provider that refreshes first. Targets without fresh quota data, and ties, keep configuration
order. Weights and `stickyLimit` do not affect this strategy.

## What happens when a target fails

Combo failures are divided into **hop** failures and **terminal** failures.
Expand Down Expand Up @@ -323,9 +342,9 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| Field | Required | Default | Rules |
| --- | --- | --- | --- |
| `targets` | Yes | — | Non-empty ordered array of configured `{ provider, model, weight? }` targets. Duplicate provider/model pairs are rejected. |
| `targets[].weight` | No | `1` | Integer from 1 to 10,000. Used by round-robin; ignored by failover. |
| `strategy` | No | `"failover"` | `"failover"` or `"round-robin"`. |
| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. |
| `targets[].weight` | No | `1` | Integer from 1 to 10,000. Used by round-robin and random; ignored by failover, least-used, and reset-window. |
| `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, or `"reset-window"`. |
Comment on lines +345 to +346

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 translated strategy tables

The canonical table now documents five strategies, but all seven translated combo guides and translated routing references still explicitly say that only failover and round-robin are valid and that weights apply only to round-robin. Users browsing those locales therefore receive configuration guidance that contradicts both this page and the runtime; update the directly affected localized tables and strategy descriptions alongside the English source.

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

Useful? React with 👍 / 👎.

| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. |
| `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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `targets` | `{ provider: string; model: string; weight?: number }[]` | required | Ordered concrete routes. `weight` is 1–10000 and defaults to `1`. |
| `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. |
| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape round-robin and random draws; least-used follows recorded successes; reset-window follows the soonest quota reset. |
| `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. Applies only to round-robin. |
| `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. |
Expand Down Expand Up @@ -183,8 +183,9 @@ echoed as given. The CLI dry-run cannot supply these per-candidate account field

### Combos vs policy profiles

- A **combo** is explicit ordered/weighted target routing and failover: the configured order (or
smooth weighted round-robin) decides, and failures advance through the list.
- A **combo** is explicit target routing with a selectable strategy (ordered failover, smooth
weighted or random balancing, least-used, or reset-window): the configured strategy decides,
and retryable failures advance through the list.
- A **policy profile** is evidence-based selection among configured candidates: hard capability
requirements filter first, then deterministic scoring ranks the survivors.

Expand Down
21 changes: 18 additions & 3 deletions gui/src/combo-workspace-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-mo

export { SUPPORTED_NATIVE_OPENAI_SLUGS };

export type ComboStrategy = "failover" | "round-robin";
export type ComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window";
export type ComboEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";

export const COMBO_EFFORTS: ComboEffort[] = ["low", "medium", "high", "xhigh", "max", "ultra"];
/** Mirrors OcxComboStrategy in src/types/config.ts. */
export const COMBO_STRATEGIES: readonly ComboStrategy[] = [
"failover",
"round-robin",
"random",
"least-used",
"reset-window",
] as const;

const COMBO_STRATEGY_SET = new Set<string>(COMBO_STRATEGIES);

/**
* Intersection of advertised effort ladders for picker availability.
Expand Down Expand Up @@ -136,7 +146,9 @@ function normalizeAlias(raw: unknown): string | null {
}

export function normalizeStrategy(raw: unknown): ComboStrategy {
return raw === "round-robin" ? "round-robin" : "failover";
return typeof raw === "string" && COMBO_STRATEGY_SET.has(raw)
? raw as ComboStrategy
Comment on lines +149 to +150

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 advanced strategies out of the failover count

When /api/combos returns random, least-used, or reset-window, this normalization now preserves the value, but groupCombos() still places every non-round-robin item in its failover bucket; consequently, OverviewPanel reports all three advanced strategies as Failover. Add strategy-specific buckets/counts or otherwise exclude these values from the failover total so the dashboard reflects the management configuration model.

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

Useful? React with 👍 / 👎.

: "failover";
}

export function normalizeStickyLimit(raw: unknown): number {
Expand Down Expand Up @@ -467,11 +479,12 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
displayName?: string;
};
} {
const weighted = item.strategy === "round-robin" || item.strategy === "random";
return {
id: item.id.trim(),
...(options.renameFrom ? { renameFrom: options.renameFrom } : {}),
combo: {
targets: item.targets.map((target) => item.strategy === "round-robin"
targets: item.targets.map((target) => weighted
? { provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1 }
: { provider: target.provider.trim(), model: target.model.trim() }),
strategy: item.strategy,
Expand Down Expand Up @@ -561,6 +574,8 @@ export function validateComboDraft(
if (!Number.isInteger(item.stickyLimit) || item.stickyLimit < 1 || item.stickyLimit > 100) {
return "invalidStickyLimit";
}
}
if (item.strategy === "round-robin" || item.strategy === "random") {
for (const target of item.targets) {
const weight = target.weight ?? 1;
if (!Number.isInteger(weight) || weight < 1 || weight > 10000) return "invalidWeight";
Expand Down
12 changes: 10 additions & 2 deletions gui/src/components/combo-workspace-add-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ export function AddComboModal({
onChange={(strategy) => setDraft((d) => ({ ...d, strategy }))}
/>
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
{draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")}
{draft.strategy === "failover"
? t("cws.strategy.failoverHint")
: draft.strategy === "round-robin"
? t("cws.strategy.roundRobinHint")
: null}
</p>
</div>
<div className="cwi-field">
Expand Down Expand Up @@ -212,7 +216,11 @@ export function AddComboModal({
onChange={(targets) => setDraft((d) => ({ ...d, targets }))}
/>
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
{draft.strategy === "failover"
? t("cws.targets.failoverHint")
: draft.strategy === "round-robin"
? t("cws.targets.roundRobinHint")
: null}
</p>
</div>
<ComboCapabilities
Expand Down
13 changes: 12 additions & 1 deletion gui/src/components/combo-workspace-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ export function StrategySeg({
{t(key)}
</button>
))}
{value !== "failover" && value !== "round-robin" ? (
<button
type="button"
role="radio"
aria-checked={true}
className="btn btn-sm btn-primary"
disabled
Comment on lines +42 to +46

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 the selected advanced strategy keyboard-focusable

For a combo using random, least-used, or reset-window, the only radio marked checked is unconditionally disabled, so keyboard focus skips the current selection and lands on an unchecked quick option instead. Render the preserved selection as a focusable checked radio (using aria-disabled if it must remain read-only) so keyboard and assistive-technology users can reach and identify the active strategy.

AGENTS.md reference: gui/AGENTS.md:L31-L34

Useful? React with 👍 / 👎.

>
{value}
</button>
) : null}
</div>
);
}
Expand Down Expand Up @@ -270,7 +281,7 @@ export function TargetEditor({
<option key={id} value={id}>{id}</option>
))}
</select>
{strategy === "round-robin" && (
{(strategy === "round-robin" || strategy === "random") && (
<input
className="input mono"
type="number"
Expand Down
12 changes: 10 additions & 2 deletions gui/src/components/combo-workspace-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,11 @@ export function DetailPanel({
onChange={(strategy) => updateDraft((d) => ({ ...d, strategy }))}
/>
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
{draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")}
{draft.strategy === "failover"
? t("cws.strategy.failoverHint")
: draft.strategy === "round-robin"
? t("cws.strategy.roundRobinHint")
: null}
</p>
</div>
<div className="cwi-field">
Expand Down Expand Up @@ -363,7 +367,11 @@ export function DetailPanel({
onChange={(targets) => updateDraft((d) => ({ ...d, targets }))}
/>
<p className="muted" style={{ fontSize: 12, margin: "8px 0 0" }}>
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
{draft.strategy === "failover"
? t("cws.targets.failoverHint")
: draft.strategy === "round-robin"
? t("cws.targets.roundRobinHint")
: null}
</p>
</div>
<ComboCapabilities
Expand Down
66 changes: 66 additions & 0 deletions gui/tests/combo-strategy-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Dashboard load -> save must not rewrite a combo's strategy.
*
* The runtime and management API accept five strategies. The GUI parser used to
* collapse random/least-used/reset-window to failover, so saving an untouched
* combo silently rewrote its strategy (and stripped weights for random).
*/
import { expect, test } from "bun:test";
import { parseComboList, toPutBody } from "../src/combo-workspace-data";

const strategies = ["failover", "round-robin", "random", "least-used", "reset-window"] as const;

function payloadWith(strategy: unknown, weight?: number) {
return {
combos: [
{
id: "demo",
model: "combo/demo",
strategy,
stickyLimit: 3,
targets: [
weight !== undefined
? { provider: "openai", model: "gpt-5", weight }
: { provider: "openai", model: "gpt-5" },
],
},
],
};
}

test("parse preserves every runtime strategy", () => {
for (const strategy of strategies) {
const [item] = parseComboList(payloadWith(strategy));
expect(item?.strategy).toBe(strategy);
}
});

test("unknown or missing strategies still normalize to failover", () => {
for (const raw of [undefined, "sticky", 42]) {
const [item] = parseComboList(payloadWith(raw));
expect(item?.strategy).toBe("failover");
}
});

test("saving an untouched combo round-trips merged strategies and random weights", () => {
const [randomCombo] = parseComboList(payloadWith("random", 7));
expect(randomCombo).toBeDefined();
const randomBody = toPutBody(randomCombo!);
expect(randomBody.combo.strategy).toBe("random");
expect(randomBody.combo.targets[0]).toEqual({ provider: "openai", model: "gpt-5", weight: 7 });
expect(randomBody.combo.stickyLimit).toBeUndefined();

const [leastUsed] = parseComboList(payloadWith("least-used"));
expect(toPutBody(leastUsed!).combo.strategy).toBe("least-used");

const [resetWindow] = parseComboList(payloadWith("reset-window"));
expect(toPutBody(resetWindow!).combo.strategy).toBe("reset-window");
});

test("round-robin still sends weights and stickyLimit", () => {
const [roundRobin] = parseComboList(payloadWith("round-robin", 2));
const body = toPutBody(roundRobin!);
expect(body.combo.strategy).toBe("round-robin");
expect(body.combo.targets[0]).toEqual({ provider: "openai", model: "gpt-5", weight: 2 });
expect(body.combo.stickyLimit).toBe(3);
});
Loading