From 18c71b91dc174317e4fc8fd59bbe4ac55bf078ac Mon Sep 17 00:00:00 2001 From: Jacob Stoner Date: Mon, 31 Aug 2026 13:10:50 -0400 Subject: [PATCH] feat(auto-combo): add weighted score router strategy (#12155) Add a direct low-level mode for users who require explicit control over provider selection. score selects the highest configured weighted score directly while reusing the existing exploration rate. Exact ties preserve configured candidate order. rules and all other strategies remain unchanged. --- .../12155-weighted-score-router-strategy.md | 1 + docs/routing/AUTO-COMBO.md | 4 +- open-sse/services/autoCombo/routerStrategy.ts | 39 ++++++++++++++++++- .../services/combo/resolveAutoStrategy.ts | 2 + src/lib/combos/intelligentRouting.ts | 1 + src/shared/constants/routingStrategies.ts | 1 + tests/unit/router-strategies.test.ts | 32 ++++++++++++++- 7 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 changelog.d/features/12155-weighted-score-router-strategy.md 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}'`); }