/** * Routing table — exact port of cortiq-gateway/src/routing.rs (Rust → TypeScript). * * Model selection: (task_label, complexity_tier) → ordered list of provider model IDs. * The list serves as both a priority order and a fallback chain. * * Order of preference: * 1. the task's models FOR THIS TIER (the user said exactly which model * serves this task at this complexity); * 2. at tier=high — the global high chain (escalation: any-tier task * models must not undercut it), then the task's any-tier models; * otherwise the task's any-tier models, then the global tier chain; * 3. `defaultModel` as the last resort. Deduplicated, empties dropped. */ /** Per-task rule: per-tier model lists + any-tier models that lead regardless. */ export interface TaskRule { /** Models that lead at ANY tier (after the tier-specific list). */ any: string[]; /** Models keyed by complexity tier: `{ low: [...], medium: [...], high: [...] }` */ per: Record; } /** Normalized form of a single task/tier value. */ function asList(val: string | string[] | undefined): string[] { if (val === undefined) return []; return Array.isArray(val) ? val : [val]; } /** * Parse a user-supplied task value into a TaskRule. * Accepts: * - a plain array: `["model-a", "model-b"]` → `{ any: [...], per: {} }` * - a single string: `"model-a"` → `{ any: ["model-a"], per: {} }` * - a map: `{ low: [...], high: [...], any: [...] }` → `{ per: { low: [...], high: [...] }, any: [...] }` */ export function parseTaskRule(val: unknown): TaskRule { if (val === null || val === undefined) return { any: [], per: {} }; if (typeof val === 'string') { return { any: [val], per: {} }; } if (Array.isArray(val)) { return { any: val.map(String), per: {} }; } if (typeof val === 'object') { const obj = val as Record; const any = asList((obj['any'] ?? obj['models']) as string | string[] | undefined); const per: Record = {}; for (const tier of ['low', 'medium', 'high']) { if (tier in obj) { per[tier] = asList(obj[tier] as string | string[] | undefined); } } return { any, per }; } return { any: [String(val)], per: {} }; } /** Complexity band boundaries — override the router's default low/medium/high. */ export interface ComplexityBands { /** Score ≤ low → "low" */ low: number; /** Score ≤ medium → "medium"; else → "high" */ medium: number; } /** * Resolve the effective tier from a complexity score using optional custom bands. * If bands is null, return the router's tier as-is. */ export function resolveTier(score: number, routerTier: string, bands?: ComplexityBands | null): string { if (!bands) return routerTier; if (score <= bands.low) return 'low'; if (score <= bands.medium) return 'medium'; return 'high'; } /** The full routing table. */ export class RoutingTable { /** * @param globalTiers Ordered model lists per complexity tier. * `{ low: ["model-c"], medium: ["model-b", "model-c"], high: ["model-a"] }` * @param taskRules Per-task rules. * @param defaultModel Fallback when the router is unavailable. * @param bands Optional custom complexity bands (override router's tier). */ constructor( private globalTiers: Record, private taskRules: Record, private defaultModel: string, private bands?: ComplexityBands | null, ) {} /** * Recompute the effective complexity tier — the router's tier stands unless * custom bands override it. */ tierOf(score: number, routerTier: string): string { return resolveTier(score, routerTier, this.bands); } /** * Build the candidate chain for (task, tier). * Returns the ordered list of model provider IDs and whether a task rule * shaped the head of the chain. */ candidatesFor(task: string, tier: string): { models: string[]; taskRuleMatched: boolean } { const out: string[] = []; const push = (id: string) => { if (id.length > 0 && !out.includes(id)) { out.push(id); } }; const rule = this.taskRules[task]; const tierChain = this.globalTiers[tier] ?? (this.defaultModel ? [this.defaultModel] : []); // 1. Task's models FOR THIS TIER if (rule) { const tierModels = rule.per[tier]; if (tierModels) { for (const id of tierModels) push(id); } } const escalate = tier === 'high' && this.globalTiers['high'] !== undefined; if (escalate) { // 2. HIGH escalation: global high chain first, then task's any-tier models for (const id of tierChain) push(id); if (rule) { for (const id of rule.any) push(id); } } else { // 2. low/medium: task's any-tier models first, then global tier chain if (rule) { for (const id of rule.any) push(id); } for (const id of tierChain) push(id); } // 3. defaultModel as last resort push(this.defaultModel); // Determine if a task rule shaped the head const head = out[0]; let taskRuleMatched = false; if (rule && head) { const tierHead = rule.per[tier]?.[0]; const anyHead = rule.any[0]; taskRuleMatched = tierHead === head || anyHead === head; } return { models: out, taskRuleMatched }; } /** * Build candidate chain without a task context (pure complexity routing). * If the tier is not defined, the sole candidate is `defaultModel`. */ candidates(tier: string): string[] { return this.candidatesFor('', tier).models; } } /** * Create a RoutingTable from a user-friendly config shape. */ export function createRoutingTable(config: { globalTiers?: Record; taskRules?: Record; defaultModel?: string; complexityBands?: ComplexityBands | null; }): RoutingTable { const globalTiers = config.globalTiers ?? {}; const rawRules = config.taskRules ?? {}; const taskRules: Record = {}; for (const [task, val] of Object.entries(rawRules)) { const rule = parseTaskRule(val); if (rule.any.length > 0 || Object.keys(rule.per).length > 0) { taskRules[task] = rule; } } return new RoutingTable( globalTiers, taskRules, config.defaultModel ?? '', config.complexityBands ?? null, ); }