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
1 change: 1 addition & 0 deletions docs-site/src/content/docs/tr/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,4 @@ Hata hedefe özgü olmaktan ziyade uç (terminal) bir hataydı. Geçersiz girdiy
düzeltin, aşırı büyük bir bağlamı azaltın, bir politika reddini işleyin veya
reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapmaz.


13 changes: 8 additions & 5 deletions src/cli/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const USAGE = `Usage:
ocx combo [list] [--json]
ocx combo show <id> [--json]
ocx combo set <id> --targets <provider/model[:weight],...>
[--strategy <failover|round-robin>] [--sticky <1-100>]
[--strategy <failover|round-robin|random|least-used|reset-window>] [--sticky <1-100>]
[--effort <low|medium|high|xhigh|max|ultra|->] [--alias <name|->]
[--native-alias] [--display-name <label|->]
[--rename-from <id>] [--json]
Expand Down Expand Up @@ -73,9 +73,12 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const targetsRaw = takeOption(args, "--targets");
if (!targetsRaw) throw new CliUsageError("--targets is required", USAGE);
const strategy = takeOption(args, "--strategy") ?? "failover";
if (strategy !== "failover" && strategy !== "round-robin") throw new CliUsageError("--strategy must be failover or round-robin", USAGE);
const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 }) ?? 1;
if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
if (strategy !== "failover" && strategy !== "round-robin" && strategy !== "random" && strategy !== "least-used" && strategy !== "reset-window") throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, or reset-window", USAGE);
const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 });
if (stickyLimit !== undefined) {
if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
if (strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE);
}
const effort = takeOption(args, "--effort");
const alias = takeOption(args, "--alias");
const nativeAlias = takeFlag(args, "--native-alias");
Expand All @@ -84,7 +87,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
rejectArgs(args, USAGE);
const combo: Record<string, unknown> = {
strategy,
stickyLimit,
stickyLimit: stickyLimit ?? 1,
targets: parseTargets(targetsRaw),
};
if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Usage:
ocx account <sub> Accounts, login/reauth, key pools, and quota controls
ocx models <sub> Live/custom models, visibility, context, and shadow calls
ocx alias <sub> Short names for providers and models (list, set, rm, defaults)
ocx combo <sub> Combo failover/round-robin routing
ocx combo <sub> Combo routing strategies and failover
ocx agent <sub> Subagents, injection, effort caps, and sidecars
ocx observe <sub> Logs, usage, storage, memory, and debug data
ocx inspect <sub> Effective config, catalog, analytics, pacing, client-config
Expand Down
2 changes: 1 addition & 1 deletion src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
{
name: "combo",
usage: "ocx combo <list|show|set|remove> ...",
summary: "Manage combo failover and round-robin virtual models.",
summary: "Manage combo virtual models and routing strategies.",
details: ["Alias hierarchy: ocx route combo ...", "Use --targets provider/model[:weight],provider/model[:weight]."],
},
{
Expand Down
1 change: 1 addition & 0 deletions src/combos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ export {
concreteComboRequestBody,
resetComboEffortWarningStateForTests,
} from "./request";
export { earliestQuotaResetAt, quotaResetRemainingMs } from "./reset-window";
46 changes: 46 additions & 0 deletions src/combos/reset-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ProviderQuota } from "../providers/quota";

function collectResetCandidates(quota: ProviderQuota): number[] {
const candidates: number[] = [];
const push = (value: number | undefined): void => {
if (value !== undefined && Number.isFinite(value)) candidates.push(value);
};
push(quota.fiveHourResetAt);
push(quota.weeklyResetAt);
push(quota.monthlyResetAt);
if (quota.customWindows) {
for (const w of quota.customWindows) {
push(w.resetAt);
}
}
return candidates;
}

/**
* Earliest future reset timestamp from a cached provider quota snapshot,
* or null when no fresh quota data exists or all resets have elapsed.
*/
export function earliestQuotaResetAt(
quota: ProviderQuota | null,
now: number,
): number | null {
if (!quota) return null;
const future = collectResetCandidates(quota).filter(ts => ts > now);
if (future.length > 0) return Math.min(...future);
return null;
}

/**
* Milliseconds until the soonest known quota-window reset.
* Returns Infinity when no quota data exists, quota is stale, or all known
* reset timestamps have elapsed. An elapsed reset is stale evidence — it
* does not prove the next request has fresh capacity.
*/
export function quotaResetRemainingMs(
quota: ProviderQuota | null,
now: number,
): number {
const nearest = earliestQuotaResetAt(quota, now);
if (nearest === null) return Number.POSITIVE_INFINITY;
return nearest - now;
}
86 changes: 84 additions & 2 deletions src/combos/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { OcxComboTarget, OcxConfig } from "../types";
import { getCachedProviderQuota } from "../providers/quota-routing-cache";
import { coolComboTarget, isComboTargetInCooldown } from "./failover";
import { quotaResetRemainingMs } from "./reset-window";
import { getCombo, resolveComboId, targetKey } from "./types";
import type { NormalizedComboConfig } from "./types";
import {
Expand All @@ -19,6 +21,7 @@ interface SelectionState {
activeKey?: string;
successes: number;
currentWeights: Map<string, number>;
successfulUses: Map<string, number>;
}

const selectionState = new Map<string, SelectionState>();
Expand Down Expand Up @@ -82,6 +85,36 @@ function smoothWeightedIndex(
return best;
}

/**
* Select the eligible target whose earliest known quota reset is nearest.
*
* Only reads the last successfully cached provider-quota snapshot; it never
* triggers an upstream quota probe. When no target has fresh reset data,
* every remaining value is Infinity and configured order becomes the
* fallback. Targets with elapsed or stale reset timestamps are treated as
* unknown (Infinity).
*/
function resetWindowIndex(
targets: Required<OcxComboTarget>[],
eligible: (target: Required<OcxComboTarget>) => boolean,
now = Date.now(),
): number {
let selected = -1;
let smallestRemaining = Number.POSITIVE_INFINITY;
for (let index = 0; index < targets.length; index++) {
const target = targets[index]!;
if (!eligible(target)) continue;
const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now);
// Strict comparison deliberately retains configured order for ties,
// including the no-snapshot fallback where every value is Infinity.
if (selected < 0 || remaining < smallestRemaining) {
selected = index;
smallestRemaining = remaining;
}
}
return selected;
}

export function pickComboTarget(
config: OcxConfig,
comboId: string,
Expand All @@ -103,7 +136,7 @@ export function pickComboTarget(
if (combo.strategy === "round-robin") {
let state = selectionState.get(comboId);
if (!state) {
state = { successes: 0, currentWeights: new Map() };
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
selectionState.set(comboId, state);
}
if (state.activeKey) {
Expand All @@ -120,6 +153,41 @@ export function pickComboTarget(
state.successes = 0;
}
}
} else if (combo.strategy === "random") {
// Weighted random selection happens independently for every request.
const eligibleTargets = combo.targets
.map((target, index) => ({ target, index }))
.filter(({ target }) => eligible(target));
if (eligibleTargets.length > 0) {
const totalWeight = eligibleTargets.reduce((sum, entry) => sum + entry.target.weight, 0);
let random = Math.random() * totalWeight;
for (const entry of eligibleTargets) {
random -= entry.target.weight;
if (random <= 0) {
targetIndex = entry.index;
break;
}
}
if (targetIndex < 0) targetIndex = eligibleTargets[eligibleTargets.length - 1]!.index;
}
} else if (combo.strategy === "least-used") {
let state = selectionState.get(comboId);
if (!state) {
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
selectionState.set(comboId, state);
}
let fewestUses = Number.POSITIVE_INFINITY;
for (let index = 0; index < combo.targets.length; index++) {
const target = combo.targets[index]!;
if (!eligible(target)) continue;
const uses = state.successfulUses.get(targetKey(target)) ?? 0;
if (targetIndex < 0 || uses < fewestUses) {
targetIndex = index;
fewestUses = uses;
}
}
} else if (combo.strategy === "reset-window") {
targetIndex = resetWindowIndex(combo.targets, eligible);
} else {
targetIndex = combo.targets.findIndex(eligible);
}
Expand All @@ -141,9 +209,18 @@ export function noteComboSuccess(
target: Required<OcxComboTarget>,
writerGeneration = captureConfigGeneration(),
): void {
if (combo.strategy !== "round-robin") return;
const key = targetKey(target);
if (!mayCommitComboState(comboId, key, writerGeneration)) return;
if (combo.strategy === "least-used") {
let state = selectionState.get(comboId);
if (!state) {
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
selectionState.set(comboId, state);
}
state.successfulUses.set(key, (state.successfulUses.get(key) ?? 0) + 1);
return;
}
if (combo.strategy !== "round-robin") return;
const state = selectionState.get(comboId);
if (!state || state.activeKey !== key) return;
state.successes += 1;
Expand Down Expand Up @@ -206,6 +283,11 @@ export function reconcileComboRotationState(context: GenerationContext): number
state.currentWeights.delete(key);
removed += 1;
}
for (const key of state.successfulUses.keys()) {
if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue;
state.successfulUses.delete(key);
removed += 1;
}
}
liveComboTargets = new Set(context.comboTargets);
lastReconciledGeneration = context.generation;
Expand Down
7 changes: 5 additions & 2 deletions src/combos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,11 @@ export function comboConfigIssues(
}
if (body.strategy !== undefined
&& body.strategy !== "failover"
&& body.strategy !== "round-robin") {
issues.push({ path: ["strategy"], message: 'strategy must be "failover" or "round-robin"' });
&& body.strategy !== "round-robin"
&& body.strategy !== "random"
&& body.strategy !== "least-used"
&& body.strategy !== "reset-window") {
issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", or "reset-window"' });
}
if (body.stickyLimit !== undefined
&& (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit)
Expand Down
32 changes: 32 additions & 0 deletions src/providers/quota-routing-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ProviderQuota, ProviderQuotaReport } from "./quota";

const quotaCache = new Map<string, ProviderQuota>();

export function clearCachedProviderQuotas(): void {
quotaCache.clear();
}

export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void {
quotaCache.clear();
for (const report of reports) {
quotaCache.set(report.provider, report.quota);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function getCachedProviderQuota(
provider: string,
now: number,
maxAgeMs = 30 * 60_000,
): ProviderQuota | null {
const quota = quotaCache.get(provider);
if (!quota) return null;
if (now - quota.updatedAt > maxAgeMs) return null;
return quota;
}

export function setCachedProviderQuotaForTests(
provider: string,
quota: ProviderQuota,
): void {
quotaCache.set(provider, quota);
}
7 changes: 7 additions & 0 deletions src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
type GenerationContext,
} from "../lib/state-store-sweeper";
import { readBoundedResponseBody } from "../lib/bounded-body";
import {
clearCachedProviderQuotas,
replaceCachedProviderQuotas,
} from "./quota-routing-cache";
import {
aggregateCodexPoolCapacity,
CODEX_CAPACITY_MAX_QUOTA_AGE_MS,
Expand Down Expand Up @@ -125,6 +129,7 @@ let invalidationEpoch = 0;
/** Invalidate the report cache (e.g. after switching a provider's active account). */
export function clearProviderQuotaCache(): void {
cache = null;
clearCachedProviderQuotas();
invalidationEpoch += 1;
}

Expand Down Expand Up @@ -1504,6 +1509,7 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n
const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider));
removed += cache.response.reports.length - reports.length;
cache = { ...cache, response: { ...cache.response, reports } };
replaceCachedProviderQuotas(reports);
}
liveAccountQuotaKeys = new Set(context.oauthAccountKeys);
liveProviderQuotaKeys = new Set(context.providerNames);
Expand Down Expand Up @@ -2341,6 +2347,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
) {
const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
cache = { key, ts: Date.now(), response: { ...response, reports } };
replaceCachedProviderQuotas(reports);
}
return response;
})();
Expand Down
4 changes: 2 additions & 2 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ export function comboRouteDecisionTrace(
reason: "combo-pick",
candidateIndex: pick.targetIndex,
...(combo
? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" }
? { tieBreak: combo.strategy }
: {}),
},
candidates: combo ? comboRouteCandidates(config, pick, combo) : undefined,
Expand Down Expand Up @@ -763,7 +763,7 @@ export function routeModel(
reason: route.routeReason,
...(route.combo ? { candidateIndex: route.combo.targetIndex } : {}),
...(combo
? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" }
? { tieBreak: combo.strategy }
: {}),
},
candidates: route.routeKind === "combo" && route.combo && combo
Expand Down
6 changes: 3 additions & 3 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,19 +654,19 @@ export interface OcxConfig {

export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first";

export type OcxComboStrategy = "failover" | "round-robin";
export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve new strategies when editing in the dashboard

When a combo configured through the CLI or API uses random, least-used, or reset-window, the existing dashboard parser in gui/src/combo-workspace-data.ts:138-140 normalizes it to failover, and toPutBody at lines 456-480 sends that value back. Consequently, saving any unrelated dashboard edit silently changes the combo's routing behavior; extend the GUI strategy union/parser and controls, or at minimum preserve unsupported wire values without allowing a destructive save.

Useful? React with 👍 / 👎.

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 Document the newly accepted strategy values

Users consulting the canonical documentation are still told that strategy accepts only failover or round-robin in docs-site/src/content/docs/guides/combos.md:327 and docs-site/src/content/docs/reference/configuration/routing.md:76, with the translated pages repeating the same obsolete restriction. Update the English guide/reference with the behavior and prerequisites of all three new strategies, then synchronize the directly affected translations.

AGENTS.md reference: AGENTS.md:L340-L341

Useful? React with 👍 / 👎.

export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";

export interface OcxComboTarget {
provider: string;
model: string;
/** Relative SWRR batch weight. Default 1; valid range 1..10000. */
/** Relative target weight for round-robin batches and random selection. Default 1; valid range 1..10000. */
weight?: number;
}

export interface OcxComboConfig {
targets: OcxComboTarget[];
/** Ordered failover (default) or deterministic smooth weighted round-robin. */
/** Ordered failover (default), round-robin, weighted random, least-used, or quota reset-window selection. */
strategy?: OcxComboStrategy;
/** Successful requests retained on one RR selection batch. Default 1; range 1..100. */
stickyLimit?: number;
Expand Down
14 changes: 14 additions & 0 deletions tests/cli-headless-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,20 @@ describe("headless GUI parity CLI", () => {
});
});

test("combo set rejects --sticky outside round-robin instead of dropping it", async () => {
const runtime = fakeRuntime();
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
try {
const code = await handleComboCommand([
"set", "demo", "--targets", "a/m1", "--strategy", "random", "--sticky", "5",
], runtime.deps);
expect(code).toBe(2);
expect(runtime.requests).toEqual([]);
} finally {
errorSpy.mockRestore();
}
});

test("combo set forwards the explicit native-alias compatibility contract", async () => {
const runtime = fakeRuntime();
const code = await handleComboCommand([
Expand Down
Loading
Loading