diff --git a/changelog.d/features/12155-weighted-score-router-strategy.md b/changelog.d/features/12155-weighted-score-router-strategy.md new file mode 100644 index 0000000000..fd1fdc3ed6 --- /dev/null +++ b/changelog.d/features/12155-weighted-score-router-strategy.md @@ -0,0 +1 @@ +- **feat(routing):** add a `score` Auto router strategy that selects the highest configured weighted score and reuses `explorationRate`. diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 13a359bb6d..d5e0d16925 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -414,6 +414,8 @@ Persisted `strategy: "auto"` combos can set `config.routerStrategy` (or legacy `config.auto.routerStrategy`) to one of: - `rules` — default weighted scoring +- `score` — selects the highest configured weighted score. Exact ties preserve configured + candidate order; the existing `explorationRate` samples from the full ranked pool. - `cost` / `eco` — cheapest healthy provider - `latency` / `fast` — lowest p95 latency with reliability penalty - `sla-aware` / `sla` — prefer candidates that satisfy p95 latency, error-rate, and optional @@ -422,7 +424,7 @@ Persisted `strategy: "auto"` combos can set `config.routerStrategy` (or legacy ### Router strategies in detail -The auto-combo engine exposes 5 pluggable **RouterStrategy** implementations that +The auto-combo engine exposes 6 pluggable **RouterStrategy** implementations that you can swap via `config.routerStrategy` (or the legacy `config.auto.routerStrategy`). Each strategy picks one provider from the candidate pool, given a `RoutingContext` (task type, tool/vision hints, token estimate, optional SLA policy, optional diff --git a/open-sse/services/autoCombo/routerStrategy.ts b/open-sse/services/autoCombo/routerStrategy.ts index fdadc815b7..97d37a2c5d 100644 --- a/open-sse/services/autoCombo/routerStrategy.ts +++ b/open-sse/services/autoCombo/routerStrategy.ts @@ -4,13 +4,14 @@ * Inspired by ClawRouter commit 14c83c258 "refactor: extract routing into pluggable RouterStrategy system". * Provides a RouterStrategy interface and built-in implementations: * - RulesStrategy (default): wraps the existing 15-factor scoring engine + * - ScoreStrategy: highest configured weighted score, with explicit exploration * - CostStrategy: always picks cheapest available model * - LatencyStrategy: prioritizes low p95 latency with reliability weighting * - SLAStrategy: prefers candidates that satisfy latency/error/cost SLOs * - LKGPStrategy: tries last known good provider first */ -import type { ProviderCandidate, ScoredProvider } from "./scoring.ts"; +import type { ProviderCandidate, ScoredProvider, ScoringWeights } from "./scoring.ts"; import { scorePool } from "./scoring.ts"; import { getTaskFitness } from "./taskFitness.ts"; import { clamp01 } from "../../utils/number.ts"; @@ -32,6 +33,8 @@ export interface RoutingContext { lastKnownGoodProvider?: string; lkgpEnabled?: boolean; sla?: SlaRoutingPolicy; + weights?: ScoringWeights; + explorationRate?: number; } export interface RoutingDecision { @@ -108,6 +111,38 @@ class RulesStrategyImpl implements RouterStrategy { } } +// ── ScoreStrategy: configured score wins, with explicit exploration ────────── + +class ScoreStrategyImpl implements RouterStrategy { + readonly name = "score"; + readonly description = "Selects the highest configured weighted score, with explicit exploration"; + + select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision { + const eligible = pool.filter((candidate) => candidate.circuitBreakerState !== "OPEN"); + const ranked = scorePool( + eligible.length > 0 ? eligible : pool, + context.taskType, + context.weights, + getTaskFitness + ); + if (ranked.length === 0) throw new Error("[ScoreStrategy] No candidates to score"); + + const explorationRate = Math.min(1, Math.max(0, context.explorationRate ?? 0)); + const isExploration = Math.random() < explorationRate && ranked.length > 1; + const selected = isExploration ? ranked[Math.floor(Math.random() * ranked.length)] : ranked[0]; + + return { + provider: selected.provider, + model: selected.model, + strategy: this.name, + reason: `ScoreStrategy: score=${selected.score.toFixed(3)}${isExploration ? " (exploration)" : ""}`, + candidatesConsidered: ranked.length, + finalScore: selected.score, + connectionId: selected.connectionId, + }; + } +} + // ── CostStrategy: always picks cheapest healthy provider ───────────────────── class CostStrategyImpl implements RouterStrategy { @@ -337,12 +372,14 @@ class LKGPStrategyImpl implements RouterStrategy { const strategyRegistry = new Map(); const rulesStrategy = new RulesStrategyImpl(); +const scoreStrategy = new ScoreStrategyImpl(); const costStrategy = new CostStrategyImpl(); const latencyStrategy = new LatencyStrategyImpl(); const slaStrategy = new SLAStrategyImpl(); const lkgpStrategy = new LKGPStrategyImpl(); strategyRegistry.set("rules", rulesStrategy); +strategyRegistry.set("score", scoreStrategy); strategyRegistry.set("cost", costStrategy); strategyRegistry.set("eco", costStrategy); // alias strategyRegistry.set("latency", latencyStrategy); diff --git a/open-sse/services/combo/resolveAutoStrategy.ts b/open-sse/services/combo/resolveAutoStrategy.ts index bafdd29502..9882f2da4e 100644 --- a/open-sse/services/combo/resolveAutoStrategy.ts +++ b/open-sse/services/combo/resolveAutoStrategy.ts @@ -377,6 +377,8 @@ export async function resolveAutoStrategyOrder( boolean | undefined, estimatedInputTokens, sla: slaPolicy, + weights, + explorationRate, }, routingStrategy ); diff --git a/src/lib/combos/intelligentRouting.ts b/src/lib/combos/intelligentRouting.ts index 4fa6c86b84..0c966fdc84 100644 --- a/src/lib/combos/intelligentRouting.ts +++ b/src/lib/combos/intelligentRouting.ts @@ -67,6 +67,7 @@ export const MODE_PACK_OPTIONS = [ export const ROUTER_STRATEGY_OPTIONS = [ { id: "rules", label: "Rules (6-Factor Scoring)" }, + { id: "score", label: "Highest Weighted Score" }, { id: "cost", label: "Cost Optimized" }, { id: "latency", label: "Latency Optimized" }, { id: "sla-aware", label: "SLA-aware" }, diff --git a/src/shared/constants/routingStrategies.ts b/src/shared/constants/routingStrategies.ts index b14b91d8fe..1705795020 100644 --- a/src/shared/constants/routingStrategies.ts +++ b/src/shared/constants/routingStrategies.ts @@ -37,6 +37,7 @@ export type AnyRoutingStrategyValue = RoutingStrategyValue | InternalRoutingStra export const AUTO_ROUTING_STRATEGY_VALUES = [ "rules", + "score", "cost", "eco", "latency", diff --git a/tests/unit/router-strategies.test.ts b/tests/unit/router-strategies.test.ts index db88737ba6..d71535880b 100644 --- a/tests/unit/router-strategies.test.ts +++ b/tests/unit/router-strategies.test.ts @@ -15,7 +15,10 @@ import { listStrategies, type RoutingContext, } from "../../open-sse/services/autoCombo/routerStrategy.ts"; -import type { ProviderCandidate } from "../../open-sse/services/autoCombo/scoring.ts"; +import { + DEFAULT_WEIGHTS, + type ProviderCandidate, +} from "../../open-sse/services/autoCombo/scoring.ts"; function cand(p: Partial & { provider: string }): ProviderCandidate { return { @@ -33,6 +36,32 @@ function cand(p: Partial & { provider: string }): ProviderCan const ctx: RoutingContext = { taskType: "default" }; +// ── score ──────────────────────────────────────────────────────────────────── +test("score — exploits the configured winner and uses explorationRate", (t) => { + const pool = [ + cand({ provider: "cheap", costPer1MTokens: 1 }), + cand({ provider: "expensive", costPer1MTokens: 9 }), + ]; + const weights = { ...DEFAULT_WEIGHTS, costInv: 1, quota: 0, health: 0, latencyInv: 0 }; + + t.mock.method(Math, "random", () => 0.99); + + assert.equal( + getStrategy("score").select(pool, { ...ctx, weights, explorationRate: 0 }).provider, + "cheap" + ); + assert.equal( + getStrategy("score").select(pool, { ...ctx, weights, explorationRate: 1 }).provider, + "expensive" + ); +}); + +test("score — exact ties preserve configured candidate order", () => { + const pool = [cand({ provider: "first" }), cand({ provider: "second" })]; + + assert.equal(getStrategy("score").select(pool, { ...ctx, explorationRate: 0 }).provider, "first"); +}); + // ── cost ───────────────────────────────────────────────────────────────────── test("cost — selects the cheapest healthy candidate", () => { const pool = [ @@ -307,6 +336,7 @@ test("selectWithStrategy — unknown strategy silently falls back to rules", () test("listStrategies — exposes every registered strategy + aliases", () => { const names = listStrategies().map((s) => s.name); + assert.ok(names.includes("score"), "listStrategies missing 'score'"); for (const n of ["rules", "cost", "eco", "latency", "fast", "sla-aware", "sla", "lkgp"]) { assert.ok(names.includes(n), `listStrategies missing '${n}'`); }