From 14cb43b0ce0d0a5db611d5e417895c6712b1855e Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 29 Aug 2026 19:05:14 +0400 Subject: [PATCH] fix(combos): preserve all five combo strategies in dashboard and docs PR #2050 added random, least-used, and reset-window strategies, but the dashboard parser still collapsed them to failover, so an untouched save silently rewrote the configured strategy and stripped weights that random honors. Parse and PUT now round-trip all five runtime strategies, weights are serialized for round-robin and random, and the strategy picker shows the preserved value when it is outside the two quick options. Also documents all five strategies, weight, and stickyLimit scoping in the English combos guide and routing reference. Validation: focused bun test (6 combo test files, 20 pass incl. 4 new), gui lint:i18n, gui build (tsc -b + vite), docs-site build. --- docs-site/src/content/docs/guides/combos.md | 25 ++++++- .../docs/reference/configuration/routing.md | 9 +-- gui/src/combo-workspace-data.ts | 21 +++++- .../components/combo-workspace-add-modal.tsx | 12 +++- .../components/combo-workspace-controls.tsx | 13 +++- .../combo-workspace-detail-panel.tsx | 12 +++- gui/tests/combo-strategy-roundtrip.test.ts | 66 +++++++++++++++++++ 7 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 gui/tests/combo-strategy-roundtrip.test.ts diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index bc2365af29a..020c5f8d398 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 795d2210e45..7429371c5ea 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 39a70f4281d..07943dc7f00 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 32cfe8b6195..ed18aaacfe2 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); +});