-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(combos): preserve all five combo strategies in dashboard and docs #2929
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: gui/AGENTS.md:L9-L10 Useful? React with 👍 / 👎. |
||
| : "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"; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a combo using AGENTS.md reference: gui/AGENTS.md:L31-L34 Useful? React with 👍 / 👎. |
||
| > | ||
| {value} | ||
| </button> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
@@ -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" | ||
|
|
||
| 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); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The canonical table now documents five strategies, but all seven translated combo guides and translated routing references still explicitly say that only
failoverandround-robinare 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 👍 / 👎.