|
| 1 | +// Auto Model Routing (issue #48) |
| 2 | +// |
| 3 | +// Classifies each task in an agent workflow by complexity and routes it to the |
| 4 | +// most appropriate model: lightweight tasks (summarize/extract/classify/ |
| 5 | +// transform) go to a cheap model, while reasoning-intensive tasks (search, |
| 6 | +// planning, decision-making, tool orchestration, complex problem solving) |
| 7 | +// stay on the configured reasoning model. Explicit per-step / per-skill model |
| 8 | +// settings always win, and anything unresolved falls back to the primary model. |
| 9 | + |
| 10 | +export type ModelTier = "lightweight" | "reasoning"; |
| 11 | + |
| 12 | +export interface RoutingConfig { |
| 13 | + /** Master switch. Defaults to true when a routing block is present. */ |
| 14 | + enabled?: boolean; |
| 15 | + /** Concrete model id for lightweight tasks, e.g. "openai:gpt-4o-mini". */ |
| 16 | + lightweight?: string; |
| 17 | + /** Concrete model id for reasoning tasks, e.g. "openai:gpt-4o". */ |
| 18 | + reasoning?: string; |
| 19 | + /** User overrides for classification — first matching rule wins. */ |
| 20 | + rules?: Array<{ tier: ModelTier; match: string[] }>; |
| 21 | +} |
| 22 | + |
| 23 | +export interface RouteInput { |
| 24 | + /** Explicit per-step model (highest priority). May be an alias or model id. */ |
| 25 | + stepModel?: string; |
| 26 | + /** Per-skill default model from SKILL.md frontmatter. May be an alias or id. */ |
| 27 | + skillModel?: string; |
| 28 | + /** Text used to classify the task (typically skill name + step prompt). */ |
| 29 | + classifyText: string; |
| 30 | + /** Routing configuration from agent.yaml (model.routing). */ |
| 31 | + routing?: RoutingConfig; |
| 32 | + /** The agent's primary/preferred model — the ultimate fallback. */ |
| 33 | + primaryModel?: string; |
| 34 | +} |
| 35 | + |
| 36 | +export interface RouteResult { |
| 37 | + /** Resolved concrete "provider:model" string (undefined → let runtime decide). */ |
| 38 | + model?: string; |
| 39 | + /** The complexity tier, when the model came from automatic classification. */ |
| 40 | + tier: ModelTier | null; |
| 41 | + /** Where the decision came from. */ |
| 42 | + source: "step" | "skill" | "auto" | "fallback"; |
| 43 | +} |
| 44 | + |
| 45 | +// Default task-to-tier keyword framework, derived directly from the issue's |
| 46 | +// recommended task-type table. Matched against word starts so "summarize", |
| 47 | +// "summary" and "summarization" all hit "summ", without false positives like |
| 48 | +// "already" matching "read". |
| 49 | +const DEFAULT_LIGHTWEIGHT = [ |
| 50 | + "summ", "extract", "classif", "transform", "format", "convert", |
| 51 | + "parse", "fetch", "read", "load", "lookup", "normaliz", "translat", |
| 52 | + "rephrase", "rewrite", "tag", "label", "render", |
| 53 | +]; |
| 54 | +const DEFAULT_REASONING = [ |
| 55 | + "search", "analy", "plan", "decid", "decision", "orchestrat", "solve", |
| 56 | + "reason", "validat", "evaluat", "review", "audit", "diagnos", "debug", |
| 57 | + "architect", "design", "strateg", "investigat", "assess", "judge", |
| 58 | + "verify", "critique", "infer", "deduc", |
| 59 | +]; |
| 60 | + |
| 61 | +function matchesAny(text: string, keywords: string[]): boolean { |
| 62 | + for (const kw of keywords) { |
| 63 | + // Word-start boundary: keyword must begin a word. |
| 64 | + const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"); |
| 65 | + if (re.test(text)) return true; |
| 66 | + } |
| 67 | + return false; |
| 68 | +} |
| 69 | + |
| 70 | +/** |
| 71 | + * Classify a task into a complexity tier. User-defined rules (from |
| 72 | + * model.routing.rules) take precedence over the built-in defaults. When a task |
| 73 | + * matches neither — or matches both — it defaults to "reasoning" so that |
| 74 | + * reasoning quality is never sacrificed to save cost. |
| 75 | + */ |
| 76 | +export function classifyTaskTier( |
| 77 | + classifyText: string, |
| 78 | + rules?: Array<{ tier: ModelTier; match: string[] }>, |
| 79 | +): ModelTier { |
| 80 | + const text = classifyText || ""; |
| 81 | + |
| 82 | + // User overrides first, in declaration order. |
| 83 | + if (rules) { |
| 84 | + for (const rule of rules) { |
| 85 | + if (Array.isArray(rule.match) && matchesAny(text, rule.match)) { |
| 86 | + return rule.tier; |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + const hasReasoning = matchesAny(text, DEFAULT_REASONING); |
| 92 | + if (hasReasoning) return "reasoning"; |
| 93 | + const hasLightweight = matchesAny(text, DEFAULT_LIGHTWEIGHT); |
| 94 | + if (hasLightweight) return "lightweight"; |
| 95 | + |
| 96 | + // Unknown → keep quality high. |
| 97 | + return "reasoning"; |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Resolve a model reference that may be a routing-tier alias |
| 102 | + * ("lightweight"/"reasoning") or a literal "provider:model" id. |
| 103 | + */ |
| 104 | +export function resolveModelAlias(ref: string | undefined, routing?: RoutingConfig): string | undefined { |
| 105 | + if (!ref) return undefined; |
| 106 | + if (ref === "lightweight") return routing?.lightweight || undefined; |
| 107 | + if (ref === "reasoning") return routing?.reasoning || undefined; |
| 108 | + return ref; |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Decide which model a task should run on. Precedence: |
| 113 | + * 1. explicit per-step model (source: "step") |
| 114 | + * 2. per-skill declared model (source: "skill") |
| 115 | + * 3. automatic classification (source: "auto") — when routing is enabled |
| 116 | + * 4. primary/preferred model (source: "fallback") |
| 117 | + * |
| 118 | + * Automatic routing is active only when a routing block is present and not |
| 119 | + * disabled. If classification picks a tier with no configured model, it falls |
| 120 | + * through to the primary model (fallback on routing failure). |
| 121 | + */ |
| 122 | +export function resolveRoutedModel(input: RouteInput): RouteResult { |
| 123 | + const { stepModel, skillModel, classifyText, routing, primaryModel } = input; |
| 124 | + |
| 125 | + // 1. Explicit per-step override. |
| 126 | + const fromStep = resolveModelAlias(stepModel, routing); |
| 127 | + if (fromStep) return { model: fromStep, tier: null, source: "step" }; |
| 128 | + |
| 129 | + // 2. Per-skill declared default. |
| 130 | + const fromSkill = resolveModelAlias(skillModel, routing); |
| 131 | + if (fromSkill) return { model: fromSkill, tier: null, source: "skill" }; |
| 132 | + |
| 133 | + // 3. Automatic classification (opt-in via a routing block). |
| 134 | + const autoEnabled = !!routing && routing.enabled !== false && !!(routing.lightweight || routing.reasoning); |
| 135 | + if (autoEnabled) { |
| 136 | + const tier = classifyTaskTier(classifyText, routing!.rules); |
| 137 | + const model = tier === "lightweight" ? routing!.lightweight : routing!.reasoning; |
| 138 | + if (model) return { model, tier, source: "auto" }; |
| 139 | + } |
| 140 | + |
| 141 | + // 4. Fallback to the primary model. |
| 142 | + return { model: primaryModel, tier: null, source: "fallback" }; |
| 143 | +} |
0 commit comments