File size: 5,833 Bytes
39e315a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | /**
* Smart model routing — cheap-for-simple, strong-for-hard.
*
* For everyday short chatter ("ok", "thanks", "what does this do?") the
* incremental quality of Opus/GPT-5 over Haiku/Mini is negligible while the
* cost and latency are an order of magnitude worse. Smart routing opts a
* user into routing such "obviously simple" turns to a cheaper model while
* keeping the strong model for the anything-non-trivial path.
*
* This module is a pure primitive: it takes a turn description (the user's
* text + light context) and returns which model to use, based on config.
* It never reads env vars or state directly — caller supplies everything.
*
* Off by default. Users opt in via settings.smartRouting.enabled. Intent:
* make this a copy-paste-small config block rather than a hidden heuristic,
* so the tradeoff is visible and the user controls it.
*/
export type SmartRoutingConfig = {
enabled: boolean
/** Model to use for turns classified as "simple". */
simpleModel: string
/** Model to use for turns classified as "strong" (or when unsure). */
strongModel: string
/** Max characters in user input to qualify as "simple". Default 160. */
simpleMaxChars?: number
/** Max whitespace-separated words to qualify as "simple". Default 28. */
simpleMaxWords?: number
}
export type RoutingDecision = {
model: string
complexity: 'simple' | 'strong'
/** Human-readable reason — useful for the UI indicator and debug logs. */
reason: string
}
export type RoutingInput = {
/** The user's message text for this turn. */
userText: string
/**
* Optional: how many tool-use blocks the assistant has emitted in the
* recent conversation. High values correlate with "continue this work"
* follow-ups that can still be cheap, UNLESS the user also typed code
* or strong-keyword text.
*/
recentToolUses?: number
/**
* Optional: turn number within the current session (1-indexed). The first
* turn is often task-setup and benefits from the strong model even if
* short — a bare "build X" opens the whole task.
*/
turnNumber?: number
}
const DEFAULT_SIMPLE_MAX_CHARS = 160
const DEFAULT_SIMPLE_MAX_WORDS = 28
// Keywords that strongly suggest reasoning/planning/design work.
// Matching is word-boundary / case-insensitive. Must include enough anchors
// that short prompts like "plan the refactor" route to strong even under
// the char/word cutoff.
const STRONG_KEYWORDS = [
'plan',
'design',
'architect',
'architecture',
'refactor',
'debug',
'investigate',
'analyze',
'analyse',
'implement',
'optimize',
'optimise',
'review',
'audit',
'diagnose',
'root cause',
'root-cause',
'why does',
'why is',
'how should',
'why did',
'propose',
'trace',
'reproduce',
]
const STRONG_KEYWORD_RE = new RegExp(
`\\b(?:${STRONG_KEYWORDS.map(k => k.replace(/[-]/g, '[-\\s]')).join('|')})\\b`,
'i',
)
const CODE_FENCE_RE = /```[\s\S]*?```|`[^`\n]+`/
function countWords(text: string): number {
const trimmed = text.trim()
if (!trimmed) return 0
return trimmed.split(/\s+/).length
}
function hasMultiParagraph(text: string): boolean {
return /\n\s*\n/.test(text)
}
function hasCode(text: string): boolean {
return CODE_FENCE_RE.test(text)
}
function hasStrongKeyword(text: string): boolean {
return STRONG_KEYWORD_RE.test(text)
}
/**
* Decide whether to route to the simple or strong model based on heuristics.
* Returns the chosen model + a reason. When routing is disabled or both
* models match, the strong model is used (safe default).
*/
export function routeModel(
input: RoutingInput,
config: SmartRoutingConfig,
): RoutingDecision {
if (!config.enabled) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'smart-routing disabled',
}
}
if (!config.simpleModel || !config.strongModel) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'simpleModel or strongModel missing from config',
}
}
if (config.simpleModel === config.strongModel) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'simpleModel equals strongModel',
}
}
const text = input.userText ?? ''
const trimmed = text.trim()
if (!trimmed) {
// Empty input (e.g. resuming a tool-use chain) — cheap by default.
return {
model: config.simpleModel,
complexity: 'simple',
reason: 'empty user text',
}
}
// First turn of a session is task-setup — always use strong.
if (input.turnNumber === 1) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'first turn of session',
}
}
const maxChars = config.simpleMaxChars ?? DEFAULT_SIMPLE_MAX_CHARS
const maxWords = config.simpleMaxWords ?? DEFAULT_SIMPLE_MAX_WORDS
if (hasCode(trimmed)) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'contains code block or inline code',
}
}
if (hasStrongKeyword(trimmed)) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'contains reasoning/planning keyword',
}
}
if (hasMultiParagraph(trimmed)) {
return {
model: config.strongModel,
complexity: 'strong',
reason: 'multi-paragraph input',
}
}
if (trimmed.length > maxChars) {
return {
model: config.strongModel,
complexity: 'strong',
reason: `input > ${maxChars} chars`,
}
}
if (countWords(trimmed) > maxWords) {
return {
model: config.strongModel,
complexity: 'strong',
reason: `input > ${maxWords} words`,
}
}
return {
model: config.simpleModel,
complexity: 'simple',
reason: `short (${trimmed.length} chars, ${countWords(trimmed)} words)`,
}
}
|