diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index bc2365af29..020c5f8d39 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -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. @@ -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"`. | +| `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. | diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 795d2210e4..7429371c5e 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -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. | @@ -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. diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index 39a70f4281..07943dc7f0 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -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(COMBO_STRATEGIES); /** * Intersection of advertised effort ladders for picker availability. @@ -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 + : "failover"; } export function normalizeStickyLimit(raw: unknown): number { @@ -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, @@ -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"; diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index 32cfe8b619..ed18aaacfe 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -163,7 +163,11 @@ export function AddComboModal({ onChange={(strategy) => setDraft((d) => ({ ...d, strategy }))} />

- {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}

@@ -212,7 +216,11 @@ export function AddComboModal({ onChange={(targets) => setDraft((d) => ({ ...d, targets }))} />

- {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}

))} + {value !== "failover" && value !== "round-robin" ? ( + + ) : null} ); } @@ -270,7 +281,7 @@ export function TargetEditor({ ))} - {strategy === "round-robin" && ( + {(strategy === "round-robin" || strategy === "random") && ( updateDraft((d) => ({ ...d, strategy }))} />

- {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}

@@ -363,7 +367,11 @@ export function DetailPanel({ onChange={(targets) => updateDraft((d) => ({ ...d, targets }))} />

- {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}

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); +});