@qadanm/opencode-smart-router 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,919 @@
1
+ import type {
2
+ CandidateEvaluation,
3
+ CostTier,
4
+ Mode,
5
+ ModelQuotaSnapshot,
6
+ ModelRecord,
7
+ ModelRef,
8
+ PeakPricing,
9
+ PremiumState,
10
+ ProviderQuotaSnapshot,
11
+ RouterSettings,
12
+ RoutingDecision,
13
+ SessionState,
14
+ TaskClass,
15
+ TaskKind,
16
+ } from "../types.js"
17
+ import { keyOfRef, modelKey, nowId, safeError, thousands, usd } from "../util.js"
18
+ import {
19
+ decidePremium,
20
+ evaluateEligibility,
21
+ modelPressure,
22
+ providerPressure,
23
+ quotaMarginalPriceFactor,
24
+ type PremiumDecision,
25
+ } from "../policy/eligibility.js"
26
+ import { mayUseProvider, type InferenceMode } from "../offline/mode.js"
27
+
28
+ export interface EngineInput {
29
+ readonly task: TaskClass
30
+ readonly settings: RouterSettings
31
+ readonly mode: Mode
32
+ readonly runtime: "automatic" | "manual" | "paused"
33
+ readonly models: ReadonlyArray<ModelRecord>
34
+ readonly current?: ModelRef
35
+ readonly session?: SessionState
36
+ readonly quota: ReadonlyArray<ProviderQuotaSnapshot>
37
+ readonly premium: PremiumState
38
+ readonly contextTokens: number
39
+ readonly cachedTokens: number
40
+ readonly dryRun: boolean
41
+ readonly now?: number
42
+ readonly lastError?: string
43
+ readonly modelHistory?: Readonly<Record<string, { requests: number; failures: number }>>
44
+ /** Model key to the epoch millisecond when its failure cooldown ends. */
45
+ readonly cooldowns?: Readonly<Record<string, number>>
46
+ /** Provider id to the epoch millisecond when its outage backoff ends. */
47
+ readonly providerCooldowns?: Readonly<Record<string, number>>
48
+ /**
49
+ * Measured generation speed per model, in output tokens per second. Only
50
+ * consulted once a model has enough samples to be meaningful.
51
+ */
52
+ readonly speed?: Readonly<Record<string, { tokensPerSecond: number; samples: number }>>
53
+ /**
54
+ * The intelligence floor actually in force, when it differs from the
55
+ * configured one.
56
+ *
57
+ * Calibration derives the floor from recorded spend and the controller
58
+ * resolves it before routing. Passing it in keeps the eligibility gate and
59
+ * the success estimate reading the same number; defaulting to the configured
60
+ * value preserves every existing caller and every existing test.
61
+ */
62
+ readonly intelligenceFloor?: number
63
+ }
64
+
65
+ export interface EngineResult {
66
+ readonly decision: RoutingDecision
67
+ readonly chosenModel?: ModelRecord
68
+ readonly premiumDecision: PremiumDecision
69
+ }
70
+
71
+ interface Evaluated {
72
+ model: ModelRecord
73
+ eligibility: ReturnType<typeof evaluateEligibility>
74
+ estimate: CostEstimate
75
+ probability: number
76
+ pressure: number
77
+ candidate: CandidateEvaluation
78
+ }
79
+
80
+ const outputPerStep: Record<TaskKind, number> = {
81
+ deterministic: 200,
82
+ mechanical: 300,
83
+ docs: 800,
84
+ test: 1000,
85
+ debug: 1100,
86
+ feature: 1300,
87
+ refactor: 1500,
88
+ security: 1800,
89
+ architecture: 2000,
90
+ design: 2400,
91
+ unknown: 1000,
92
+ }
93
+
94
+ const stepsFor: Record<TaskKind, number> = {
95
+ deterministic: 1,
96
+ mechanical: 1,
97
+ docs: 1,
98
+ test: 2,
99
+ debug: 3,
100
+ feature: 3,
101
+ refactor: 3,
102
+ security: 3,
103
+ architecture: 3,
104
+ design: 3,
105
+ unknown: 2,
106
+ }
107
+
108
+ /**
109
+ * The share of the context a staying model reads from prompt cache.
110
+ *
111
+ * The remainder is re-sent as fresh input. This is a modelling assumption, not a
112
+ * measured cache-hit rate, and it drives both the ranked estimate and the switch
113
+ * penalty that is reported alongside it, so the two can never disagree about how
114
+ * much a switch really costs.
115
+ */
116
+ const CACHE_REUSE_SHARE = 0.85
117
+
118
+ const selectCostTier = (model: ModelRecord, contextTokens: number): CostTier => {
119
+ const sorted = [...model.cost].sort((a, b) => (a.tier?.size ?? 0) - (b.tier?.size ?? 0))
120
+ let selected = sorted[0] ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }
121
+ for (const tier of sorted) {
122
+ if (tier.tier?.type === "context" && contextTokens >= tier.tier.size) selected = tier
123
+ }
124
+ return selected
125
+ }
126
+
127
+ export interface CostEstimate {
128
+ readonly usd: number
129
+ readonly input: number
130
+ readonly output: number
131
+ readonly cacheRead: number
132
+ readonly cacheWrite: number
133
+ readonly switchPenaltyUsd: number
134
+ readonly costTier: CostTier
135
+ /** 2 during a documented peak window, otherwise 1. */
136
+ readonly priceMultiplier: number
137
+ /** Per-model credit multiplier. One for every plain USD provider. */
138
+ readonly creditMultiplier: number
139
+ }
140
+
141
+ const isPeakPricingActive = (now: number, peak: PeakPricing): boolean => {
142
+ const date = new Date(now)
143
+ const day = date.getUTCDay()
144
+ if (peak.weekdaysOnly && (day === 0 || day === 6)) return false
145
+ const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600
146
+ return peak.windowsUtc.some((window) => hour >= window.startHour && hour < window.endHour)
147
+ }
148
+
149
+ /**
150
+ * Estimate the total request cost of a task across its whole agent loop rather
151
+ * than a single call. The estimate includes later steps and treats a model
152
+ * switch as a cache loss. Documented peak pricing is applied using the supplied
153
+ * time; omitting `now` uses the current time.
154
+ */
155
+ export const estimateTaskCost = (input: {
156
+ model: ModelRecord
157
+ task: TaskClass
158
+ contextTokens: number
159
+ currentModelKey?: string
160
+ now?: number
161
+ }): CostEstimate => {
162
+ const { model, task } = input
163
+ const context = Math.max(input.contextTokens, task.scope.promptTokens)
164
+ const sameModel = input.currentModelKey === modelKey(model.providerID, model.modelID)
165
+ const steps = stepsFor[task.kind]
166
+ const tier = selectCostTier(model, context)
167
+ const peakMultiplier =
168
+ model.peakPricing && isPeakPricingActive(input.now ?? Date.now(), model.peakPricing) && model.peakPricing.multiplier > 0
169
+ ? model.peakPricing.multiplier
170
+ : 1
171
+ const outputPer = Math.min(outputPerStep[task.kind] * (task.tier === 3 ? 1.5 : 1), model.maxOutput || outputPerStep[task.kind])
172
+ const output = outputPer * steps
173
+ // Staying on a model reuses the prompt cache. Switching loses it, so the whole
174
+ // context is re-sent as fresh input instead of being read from cache. Both
175
+ // halves of that trade depend on the same cached share, which is why it is
176
+ // named once and used by the estimate and by the reported penalty alike.
177
+ const cachedShare = sameModel ? CACHE_REUSE_SHARE : 0
178
+ const freshInput = task.scope.promptTokens + context * (1 - cachedShare) + context * 0.05 * (steps - 1)
179
+ const cacheRead = context * cachedShare + context * 0.95 * (steps - 1)
180
+ // A live catalog entry or a hand-written definition can omit cache pricing.
181
+ // Price it as zero rather than letting the router throw and fail open.
182
+ const inputPrice = (Number.isFinite(tier.input) ? tier.input : 0) * peakMultiplier
183
+ const outputPrice = (Number.isFinite(tier.output) ? tier.output : 0) * peakMultiplier
184
+ const cacheReadPrice = (Number.isFinite(tier.cache?.read) ? (tier.cache?.read ?? 0) : 0) * peakMultiplier
185
+ const cost =
186
+ (freshInput * inputPrice) / 1_000_000 +
187
+ (output * outputPrice) / 1_000_000 +
188
+ (cacheRead * cacheReadPrice) / 1_000_000
189
+ // The extra cost a switch actually adds, which is the cached share of the
190
+ // context moving from cache-read price to full input price. Reporting the whole
191
+ // context here charged the reader 17.6% more than the estimate in force.
192
+ const switchPenaltyUsd = sameModel
193
+ ? 0
194
+ : Math.max(0, (context * CACHE_REUSE_SHARE * (inputPrice - cacheReadPrice)) / 1_000_000)
195
+ // OpenDesign converts the dollar value of usage into Design Plan credits
196
+ // with a per-model multiplier. The result is still an estimate of credits,
197
+ // never an observed provider charge.
198
+ const creditMultiplier = model.creditMultiplier && model.creditMultiplier > 0 ? model.creditMultiplier : 1
199
+ return {
200
+ usd: Math.max(0, cost) * creditMultiplier,
201
+ input: freshInput,
202
+ output,
203
+ cacheRead,
204
+ cacheWrite: 0,
205
+ switchPenaltyUsd: switchPenaltyUsd * creditMultiplier,
206
+ costTier: tier,
207
+ priceMultiplier: peakMultiplier,
208
+ creditMultiplier,
209
+ }
210
+ }
211
+
212
+ const observedSuccess = (model: ModelRecord, session: EngineInput["session"], _key: string): number => {
213
+ // Only capability failures should reduce a model's expected success rate.
214
+ // Provider, network, auth, tool, and context-overflow errors are handled by
215
+ // their own recovery paths.
216
+ if (!session) return 0.95
217
+ const capabilityFailures = session.failures.filter((failure) => failure.class === "capability").length
218
+ return Math.max(0.55, 0.95 - capabilityFailures * 0.1)
219
+ }
220
+
221
+ export const estimateSuccess = (input: {
222
+ model: ModelRecord
223
+ task: TaskClass
224
+ contextTokens: number
225
+ session?: SessionState
226
+ currentModelKey?: string
227
+ history?: { requests: number; failures: number }
228
+ /**
229
+ * The floor in force for this request.
230
+ *
231
+ * This used to be a literal `39.5`, which meant raising or lowering the floor
232
+ * moved the eligibility gate but left this quality penalty behind at the old
233
+ * number. Two gates reading the same policy from different sources is exactly
234
+ * the drift the context-margin work removed, so the value is passed in now.
235
+ */
236
+ intelligenceFloor?: number
237
+ }): number => {
238
+ const { model, task } = input
239
+ let probability: number
240
+ if (task.tier <= 1) {
241
+ probability = model.tier === 1 ? 0.93 : 0.97
242
+ } else if (task.tier === 2) {
243
+ probability = model.tier === 2 ? 0.94 : 0.97
244
+ } else {
245
+ probability = model.tier === 3 ? 0.93 : 0.7
246
+ }
247
+ if (model.training) probability -= 0.05
248
+ if (task.designIntensive) {
249
+ const threshold = task.sophisticatedDesign ? 75 : 60
250
+ if (model.design < threshold) probability *= 0.72
251
+ }
252
+ const floor = input.intelligenceFloor ?? 39.5
253
+ if (task.tier >= 2 && model.intelligence < floor) probability *= 0.7
254
+ if (model.context > 0 && input.contextTokens > model.context * 0.8) probability *= 0.9
255
+ if (model.context > 0 && input.contextTokens > model.context * 0.95) probability *= 0.6
256
+ probability *= observedSuccess(model, input.session, modelKey(model.providerID, model.modelID)) / 0.95
257
+ if (input.history && input.history.requests >= 3) {
258
+ // Reliability is weighted more heavily than before. A model that drops
259
+ // streams is not worth a lower token price, because every dropped turn
260
+ // costs a full retry of the accumulated context.
261
+ const total = input.history.requests + input.history.failures
262
+ const successRate = total > 0 ? Math.max(0, input.history.requests) / total : 1
263
+ probability *= 0.6 + 0.4 * successRate
264
+ }
265
+ return Math.max(0.2, Math.min(0.99, probability))
266
+ }
267
+
268
+ const premiumState = (input: EngineInput): PremiumState => input.premium
269
+
270
+ /**
271
+ * Speed penalty for a candidate, as a multiplier on its effective cost.
272
+ *
273
+ * Speed is measured as output tokens per second, which is the only figure
274
+ * that separates a slow provider from a model that simply reasons for longer:
275
+ * a model that emits 3,000 reasoning tokens before answering is not "slow" in
276
+ * the same sense as one that takes four times as long to emit the same
277
+ * answer.
278
+ *
279
+ * With no samples the penalty is neutral. Once a model has been measured it
280
+ * is compared against the fastest eligible route rather than against an
281
+ * absolute number, so the penalty adapts as providers change.
282
+ */
283
+ const speedPenalty = (
284
+ input: EngineInput,
285
+ key: string,
286
+ fastestTokensPerSecond: number,
287
+ ): number => {
288
+ if (!input.settings.speed.enabled) return 1
289
+ const measure = input.speed?.[key]
290
+ if (!measure || measure.samples < input.settings.speed.minSamples) return 1
291
+ if (measure.tokensPerSecond <= 0 || fastestTokensPerSecond <= 0) return 1
292
+ const ratio = measure.tokensPerSecond / fastestTokensPerSecond
293
+ // A route at the fastest observed speed is unpenalised. Below that the
294
+ // configured weight scales the shortfall, so a route half as fast costs
295
+ // more than one at parity but never becomes ineligible.
296
+ const shortfall = Math.max(0, 1 - ratio)
297
+ const weight = input.settings.speed.weightPct / 100
298
+ const penalty = 1 + shortfall * weight * 2
299
+ // A route far slower than the fastest is penalised harder, because the
300
+ // owner's stated preference puts responsiveness ahead of shaving pennies.
301
+ const multiple = ratio > 0 ? 1 / ratio : input.settings.speed.slowRouteMultiple
302
+ if (multiple >= input.settings.speed.slowRouteMultiple) {
303
+ return penalty * (1 + weight)
304
+ }
305
+ return penalty
306
+ }
307
+
308
+ const providerFor = (quota: ReadonlyArray<ProviderQuotaSnapshot>, providerID: string) =>
309
+ quota.find((snapshot) => snapshot.providerID === providerID)
310
+
311
+ const modelQuotaFor = (snapshot: ProviderQuotaSnapshot | undefined, model: ModelRecord): ModelQuotaSnapshot | undefined =>
312
+ snapshot?.models[modelKey(model.providerID, model.modelID)]
313
+
314
+ const toRef = (model: ModelRecord): ModelRef => ({ providerID: model.providerID, id: model.modelID })
315
+
316
+ const candidateFor = (
317
+ input: EngineInput,
318
+ model: ModelRecord,
319
+ eligibility: { eligible: boolean; reason: string; warnings: string[] },
320
+ estimate: CostEstimate,
321
+ probability: number,
322
+ pressure: number,
323
+ currentModelKey: string | undefined,
324
+ speedFactor = 1,
325
+ marginalPriceFactor = 1,
326
+ ): CandidateEvaluation => {
327
+ // Expiring prepaid allowance is priced at the probability it will be wasted,
328
+ // not treated as fresh money. This is applied to the price rather than folded
329
+ // into `pressure` because pressure is a multiplier on an already-ranked cost:
330
+ // no pressure figure can close a large price gap, so quota urgency expressed
331
+ // that way could never change a decision.
332
+ const expectedTotal =
333
+ (estimate.usd * marginalPriceFactor * (1 + (1 - probability) * 2)) * pressure * speedFactor
334
+ return {
335
+ model: { providerID: model.providerID, modelID: model.modelID },
336
+ name: model.name,
337
+ tier: model.tier,
338
+ eligible: eligibility.eligible,
339
+ reason: eligibility.reason,
340
+ estimatedUsd: estimate.usd,
341
+ effectiveCost: expectedTotal,
342
+ successProbability: probability,
343
+ switchPenaltyUsd: estimate.switchPenaltyUsd,
344
+ quotaPressure: pressure,
345
+ retained: keyOfRef(input.current) === modelKey(model.providerID, model.modelID),
346
+ creditMultiplier: estimate.creditMultiplier,
347
+ speedFactor,
348
+ marginalPriceFactor,
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Deterministic routing engine. It never calls a model to classify or decide.
354
+ * Every candidate is evaluated against hard requirements first, then ranked by
355
+ * expected total quota cost, with a cache-aware affinity threshold.
356
+ */
357
+ export const routeTask = (input: EngineInput): EngineResult => {
358
+ const now = input.now ?? Date.now()
359
+ /**
360
+ * Does ANY provider in this decision carry a real reading?
361
+ *
362
+ * Used to tell "this provider is fine" from "nobody has been probed yet".
363
+ * Treating the second as the first is what let a decision route into an
364
+ * exhausted allowance during the ~1.5s before the quota service starts.
365
+ *
366
+ * A snapshot counts as known when it has a source other than "unknown": that
367
+ * is the provider telling us something, whether provider-reported or modelled.
368
+ * Staleness does not disqualify it; see the sticky-exhausted rule.
369
+ */
370
+ const quotaIsKnown = (input.quota ?? []).some(
371
+ (snapshot) =>
372
+ snapshot.monthly.source !== "unknown" ||
373
+ snapshot.weekly.source !== "unknown" ||
374
+ snapshot.rolling.source !== "unknown",
375
+ )
376
+ // One value for the whole decision. The eligibility gate and the success
377
+ // estimate must never disagree about the floor in force.
378
+ const floor = input.intelligenceFloor ?? input.settings.routing.intelligenceFloor
379
+ const warnings: string[] = []
380
+ const reasons: string[] = []
381
+ const currentKey = keyOfRef(input.current)
382
+ const premiumDecision = decidePremium({
383
+ settings: input.settings,
384
+ mode: input.mode,
385
+ task: input.task,
386
+ session: input.session,
387
+ premium: input.premium,
388
+ })
389
+
390
+ /**
391
+ * Offline mode narrows the candidate set to the local provider before any
392
+ * other evaluation runs.
393
+ *
394
+ * This is deliberately the first thing that happens and the only place the
395
+ * restriction is applied. Filtering here means quotas, escalation, premium
396
+ * approval and the cost model all operate on a list that cannot contain a
397
+ * hosted provider, so no later branch can reintroduce one. A refusal is
398
+ * explicit rather than a silent empty result: an offline session with no
399
+ * usable local model must say so.
400
+ */
401
+ const inferenceMode: InferenceMode = input.settings.offline?.enabled ? "offline" : "online"
402
+ let candidateModels = input.models
403
+ if (inferenceMode === "offline") {
404
+ candidateModels = input.models.filter((model) => mayUseProvider(model.providerID, inferenceMode))
405
+ const refused = input.models.filter((model) => !mayUseProvider(model.providerID, inferenceMode))
406
+ if (refused.length > 0) {
407
+ const providers = [...new Set(refused.map((model) => model.providerID))].sort()
408
+ warnings.push(
409
+ `Offline mode excluded ${refused.length} hosted model${refused.length === 1 ? "" : "s"} from ${providers.join(", ")}.`,
410
+ )
411
+ }
412
+ if (candidateModels.length === 0) {
413
+ const decision: RoutingDecision = {
414
+ id: nowId("dec"),
415
+ at: now,
416
+ sessionID: input.session?.sessionID ?? "unknown",
417
+ mode: input.mode,
418
+ dryRun: input.dryRun,
419
+ runtime: input.runtime,
420
+ task: input.task,
421
+ current: input.current,
422
+ switched: false,
423
+ switchReason:
424
+ "Offline mode has no local model available. No hosted provider will be used.",
425
+ reasons: ["Offline mode is enabled and no offline-capable model is in the registry."],
426
+ warnings: [
427
+ ...warnings,
428
+ "Offline mode fails closed: configure a local model, or run `opencode-online` to restore hosted routing.",
429
+ ],
430
+ candidates: [],
431
+ premium: premiumState(input),
432
+ quota: input.quota,
433
+ contextTokens: input.contextTokens,
434
+ cachedTokens: input.cachedTokens,
435
+ estimatedUsd: 0,
436
+ needsPremiumApproval: false,
437
+ manualOverride: false,
438
+ }
439
+ return { decision, premiumDecision }
440
+ }
441
+ }
442
+ if (input.runtime === "paused") {
443
+ const decision: RoutingDecision = {
444
+ id: nowId("dec"),
445
+ at: now,
446
+ sessionID: input.session?.sessionID ?? "unknown",
447
+ mode: input.mode,
448
+ dryRun: input.dryRun,
449
+ runtime: input.runtime,
450
+ task: input.task,
451
+ current: input.current,
452
+ switched: false,
453
+ switchReason: "Routing is paused; the current model is unchanged.",
454
+ reasons: ["Pause switch is active."],
455
+ warnings: [],
456
+ candidates: [],
457
+ premium: premiumState(input),
458
+ quota: input.quota,
459
+ contextTokens: input.contextTokens,
460
+ cachedTokens: input.cachedTokens,
461
+ estimatedUsd: 0,
462
+ needsPremiumApproval: false,
463
+ manualOverride: false,
464
+ }
465
+ return { decision, premiumDecision }
466
+ }
467
+
468
+ if (input.runtime === "manual") {
469
+ const decision: RoutingDecision = {
470
+ id: nowId("dec"),
471
+ at: now,
472
+ sessionID: input.session?.sessionID ?? "unknown",
473
+ mode: input.mode,
474
+ dryRun: input.dryRun,
475
+ runtime: input.runtime,
476
+ task: input.task,
477
+ current: input.current,
478
+ switched: false,
479
+ switchReason: "Manual model selection is active for this session; the router did not override it.",
480
+ reasons: ["Manual override respected."],
481
+ warnings: [],
482
+ candidates: [],
483
+ premium: premiumState(input),
484
+ quota: input.quota,
485
+ contextTokens: input.contextTokens,
486
+ cachedTokens: input.cachedTokens,
487
+ estimatedUsd: 0,
488
+ needsPremiumApproval: false,
489
+ manualOverride: true,
490
+ }
491
+ return { decision, premiumDecision }
492
+ }
493
+
494
+ /**
495
+ * Models that were rejected purely because the transcript would not fit.
496
+ *
497
+ * This is recorded rather than silently skipped because it is the one
498
+ * rejection a user is most likely to misread. A large session quietly stops
499
+ * being routed to the configured default, and without this note the only
500
+ * visible signal is that some other model is suddenly in use.
501
+ */
502
+ const contextRejected: Array<{ model: ModelRecord; required: number; window: number; usd: number; tier: number }> = []
503
+
504
+ const evaluate = (designRelaxation: 0 | 1 | 2) => {
505
+ const taskForEvaluation =
506
+ designRelaxation === 1 ? { ...input.task, sophisticatedDesign: false } : input.task
507
+ // The fastest measured eligible model sets the reference point for the
508
+ // speed penalty, so the comparison is always against what this account
509
+ // can actually achieve right now.
510
+ let fastestTokensPerSecond = 0
511
+ for (const model of candidateModels) {
512
+ const measure = input.speed?.[modelKey(model.providerID, model.modelID)]
513
+ if (!measure || measure.samples < input.settings.speed.minSamples) continue
514
+ if (measure.tokensPerSecond > fastestTokensPerSecond) fastestTokensPerSecond = measure.tokensPerSecond
515
+ }
516
+
517
+ /**
518
+ * Pass one evaluates every candidate without the escalation-pool guard.
519
+ * The guard is only applied once an ordinary-allowance route is known to
520
+ * be available: otherwise a task that genuinely needs escalation would be
521
+ * blocked outright instead of being escalated.
522
+ */
523
+ const run = (protectEscalation: boolean) => {
524
+ const out: Evaluated[] = []
525
+ for (const model of candidateModels) {
526
+ const provider = providerFor(input.quota, model.providerID)
527
+ const modelQuota = modelQuotaFor(provider, model)
528
+ const key = modelKey(model.providerID, model.modelID)
529
+ // A model that just dropped a stream is held out for a cooling period
530
+ // instead of being re-selected on the next prompt.
531
+ const cooldownUntil = input.cooldowns?.[key]
532
+ if (typeof cooldownUntil === "number" && cooldownUntil > now) {
533
+ continue
534
+ }
535
+ // A provider whose own pre-request path has failed several attempts in
536
+ // a row is not serving anyone. Step back from every model it offers
537
+ // and let another provider take the work. If no provider is left the
538
+ // router fails closed and keeps the current model, so this can never
539
+ // block a prompt outright.
540
+ const providerCooldownUntil = input.providerCooldowns?.[model.providerID]
541
+ if (typeof providerCooldownUntil === "number" && providerCooldownUntil > now) {
542
+ continue
543
+ }
544
+ // The estimate is computed first so the subscription-only guard can
545
+ // check the projected request cost against what is left of the
546
+ // subscription allowance.
547
+ const estimate = estimateTaskCost({
548
+ model,
549
+ task: input.task,
550
+ contextTokens: input.contextTokens,
551
+ currentModelKey: currentKey,
552
+ now,
553
+ })
554
+ const eligibility = evaluateEligibility({
555
+ intelligenceFloor: floor,
556
+ model,
557
+ task: taskForEvaluation,
558
+ settings: input.settings,
559
+ mode: input.mode,
560
+ contextTokens: input.contextTokens,
561
+ providerQuota: provider,
562
+ quotaIsKnown,
563
+ modelQuota,
564
+ premium: premiumDecision,
565
+ currentModelKey: currentKey,
566
+ allowDesignRelaxation: designRelaxation === 2,
567
+ projectedUsd: estimate.usd,
568
+ escalationPoolProtected: protectEscalation,
569
+ })
570
+ if (!eligibility.eligible) {
571
+ // Record only a context-capacity rejection. It is deterministic,
572
+ // actionable ("start a new session"), and otherwise invisible.
573
+ if (eligibility.rejectedFor === "context" && eligibility.contextRequiredTokens !== undefined && eligibility.contextWindowTokens !== undefined) {
574
+ contextRejected.push({
575
+ model,
576
+ required: eligibility.contextRequiredTokens,
577
+ window: eligibility.contextWindowTokens,
578
+ usd: estimate.usd,
579
+ tier: model.tier,
580
+ })
581
+ }
582
+ continue
583
+ }
584
+ const eligibleReason =
585
+ designRelaxation === 1 && input.task.sophisticatedDesign
586
+ ? `Standard design route: design ${model.design} is below the sophisticated ${input.settings.routing.sophisticatedDesignFloor} threshold`
587
+ : eligibility.reason
588
+ const displayedEligibility = eligibleReason === eligibility.reason ? eligibility : { ...eligibility, reason: eligibleReason }
589
+ const probability = estimateSuccess({
590
+ model,
591
+ task: input.task,
592
+ contextTokens: input.contextTokens,
593
+ session: input.session,
594
+ currentModelKey: currentKey,
595
+ history: input.modelHistory?.[modelKey(model.providerID, model.modelID)],
596
+ intelligenceFloor: floor,
597
+ })
598
+ const pressure = providerPressure(provider, now) * modelPressure(modelQuota)
599
+ const speedFactor = speedPenalty(input, modelKey(model.providerID, model.modelID), fastestTokensPerSecond)
600
+ const marginalPriceFactor = quotaMarginalPriceFactor(provider, now)
601
+ const candidate = candidateFor(
602
+ input,
603
+ model,
604
+ displayedEligibility,
605
+ estimate,
606
+ probability,
607
+ pressure,
608
+ currentKey,
609
+ speedFactor,
610
+ marginalPriceFactor,
611
+ )
612
+ out.push({ model, eligibility: displayedEligibility, estimate, probability, pressure, candidate })
613
+ }
614
+ return out.sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
615
+ }
616
+
617
+ const unprotected = run(false)
618
+ if (!input.settings.routing.protectEscalationPool) return unprotected
619
+ // Only protect the escalation pool when an ordinary-allowance route can
620
+ // actually serve the task. A task that needs escalation must still be
621
+ // able to escalate.
622
+ const ordinaryAvailable = unprotected.some((entry) => !entry.model.escalationOnly && !entry.model.premium)
623
+ if (!ordinaryAvailable) return unprotected
624
+ return run(true)
625
+ }
626
+
627
+ let evaluated = evaluate(0)
628
+ if (evaluated.length === 0 && input.task.designIntensive) {
629
+ // First fall back to the standard design floor, which keeps a qualified
630
+ // design model instead of jumping straight to the cheapest model overall.
631
+ evaluated = evaluate(1)
632
+ if (evaluated.length > 0) {
633
+ warnings.push("No model met the sophisticated design threshold; using the best qualified standard design route.")
634
+ }
635
+ }
636
+ if (evaluated.length === 0 && input.task.designIntensive) {
637
+ evaluated = evaluate(2)
638
+ if (evaluated.length > 0) {
639
+ warnings.push("No model met the standard design floor; using the best available design-capable fallback.")
640
+ }
641
+ }
642
+
643
+ // Premium tasks without approval still need a deliberately chosen standard
644
+ // fallback rather than silently keeping an unqualified cheap model.
645
+ let premiumFallback = false
646
+ if (evaluated.length === 0 && input.task.tier === 3) {
647
+ const relaxedPremium: PremiumDecision = { allowed: false, requiresApproval: premiumDecision.requiresApproval, reason: premiumDecision.reason }
648
+ const fallbackModels = candidateModels
649
+ .filter((model) => !model.premium)
650
+ .map((model) => {
651
+ const provider = providerFor(input.quota, model.providerID)
652
+ const modelQuota = modelQuotaFor(provider, model)
653
+ const key = modelKey(model.providerID, model.modelID)
654
+ const cooldownUntil = input.cooldowns?.[key]
655
+ if (typeof cooldownUntil === "number" && cooldownUntil > now) return undefined
656
+ const estimate = estimateTaskCost({
657
+ model,
658
+ task: input.task,
659
+ contextTokens: input.contextTokens,
660
+ currentModelKey: currentKey,
661
+ now,
662
+ })
663
+ const eligibility = evaluateEligibility({
664
+ intelligenceFloor: floor,
665
+ model,
666
+ task: { ...input.task, designIntensive: false, sophisticatedDesign: false },
667
+ settings: input.settings,
668
+ mode: input.mode,
669
+ contextTokens: input.contextTokens,
670
+ providerQuota: provider,
671
+ quotaIsKnown,
672
+ modelQuota,
673
+ premium: relaxedPremium,
674
+ currentModelKey: currentKey,
675
+ allowDesignRelaxation: true,
676
+ projectedUsd: estimate.usd,
677
+ })
678
+ if (!eligibility.eligible) return undefined
679
+ const probability = estimateSuccess({
680
+ model,
681
+ task: input.task,
682
+ contextTokens: input.contextTokens,
683
+ session: input.session,
684
+ currentModelKey: currentKey,
685
+ history: input.modelHistory?.[modelKey(model.providerID, model.modelID)],
686
+ intelligenceFloor: floor,
687
+ })
688
+ const pressure = providerPressure(provider, now) * modelPressure(modelQuota)
689
+ const marginalPriceFactor = quotaMarginalPriceFactor(provider, now)
690
+ return {
691
+ model,
692
+ eligibility,
693
+ estimate,
694
+ probability,
695
+ pressure,
696
+ candidate: candidateFor(
697
+ input,
698
+ model,
699
+ eligibility,
700
+ estimate,
701
+ probability,
702
+ pressure,
703
+ currentKey,
704
+ 1,
705
+ marginalPriceFactor,
706
+ ),
707
+ }
708
+ })
709
+ .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
710
+ .sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
711
+ if (fallbackModels.length > 0) {
712
+ evaluated = fallbackModels
713
+ premiumFallback = true
714
+ warnings.push("Premium is unavailable; selected the best qualified standard fallback.")
715
+ }
716
+ }
717
+
718
+ if (evaluated.length === 0) {
719
+ warnings.push("No approved model meets every hard requirement; the current model was left unchanged.")
720
+ const decision: RoutingDecision = {
721
+ id: nowId("dec"),
722
+ at: now,
723
+ sessionID: input.session?.sessionID ?? "unknown",
724
+ mode: input.mode,
725
+ dryRun: input.dryRun,
726
+ runtime: input.runtime,
727
+ task: input.task,
728
+ current: input.current,
729
+ switched: false,
730
+ switchReason: "No eligible route was found; the router failed closed and kept the current model.",
731
+ reasons: [...input.task.reasons, ...warnings],
732
+ warnings,
733
+ candidates: [],
734
+ premium: premiumState(input),
735
+ quota: input.quota,
736
+ contextTokens: input.contextTokens,
737
+ cachedTokens: input.cachedTokens,
738
+ estimatedUsd: 0,
739
+ needsPremiumApproval: premiumDecision.requiresApproval,
740
+ manualOverride: false,
741
+ ...(input.lastError ? { error: safeError(input.lastError) } : {}),
742
+ }
743
+ return { decision, premiumDecision }
744
+ }
745
+
746
+ const best = evaluated[0]!
747
+ const currentEval = currentKey
748
+ ? evaluated.find((entry) => modelKey(entry.model.providerID, entry.model.modelID) === currentKey)
749
+ : undefined
750
+ const largeContext = input.contextTokens >= input.settings.routing.largeContextTokens
751
+ const hysteresis = largeContext
752
+ ? input.settings.routing.largeContextHysteresisPct
753
+ : input.settings.routing.switchHysteresisPct
754
+
755
+ let chosen = best
756
+ let keptForAffinity = false
757
+ if (currentEval && currentEval.model.tier >= input.task.tier && currentEval.candidate.eligible) {
758
+ const savings = currentEval.candidate.effectiveCost - best.candidate.effectiveCost
759
+ const threshold = currentEval.candidate.effectiveCost * (hysteresis / 100)
760
+ if (best.model !== currentEval.model && savings < threshold) {
761
+ chosen = currentEval
762
+ keptForAffinity = true
763
+ }
764
+ }
765
+
766
+ const chosenRef = toRef(chosen.model)
767
+ const sameAsCurrent = currentKey === modelKey(chosen.model.providerID, chosen.model.modelID)
768
+ const wouldSwitch = !sameAsCurrent
769
+ const needsPremiumApproval =
770
+ premiumDecision.requiresApproval &&
771
+ (input.task.tier === 3 || input.task.constraints.requestPremium) &&
772
+ input.settings.premium.mode !== "off" &&
773
+ input.mode !== "efficiency"
774
+
775
+ if (input.task.tier === 3 && !premiumDecision.allowed && input.mode !== "efficiency" && input.settings.premium.mode !== "off") {
776
+ const bestPremium = candidateModels
777
+ .filter((model) => model.premium && model.approved)
778
+ .map((model) => {
779
+ const provider = providerFor(input.quota, model.providerID)
780
+ const modelQuota = modelQuotaFor(provider, model)
781
+ const eligibility = evaluateEligibility({
782
+ intelligenceFloor: floor,
783
+ model,
784
+ task: input.task,
785
+ settings: input.settings,
786
+ mode: input.mode,
787
+ contextTokens: input.contextTokens,
788
+ providerQuota: provider,
789
+ modelQuota,
790
+ premium: { allowed: true, requiresApproval: true, reason: "Premium suggestion" },
791
+ currentModelKey: currentKey,
792
+ allowDesignRelaxation: false,
793
+ })
794
+ if (!eligibility.eligible) return undefined
795
+ const estimate = estimateTaskCost({
796
+ model,
797
+ task: input.task,
798
+ contextTokens: input.contextTokens,
799
+ currentModelKey: currentKey,
800
+ now,
801
+ })
802
+ return { model, estimate }
803
+ })
804
+ .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
805
+ .sort((a, b) => {
806
+ if (input.task.designIntensive || input.task.sophisticatedDesign) {
807
+ if (b.model.design !== a.model.design) return b.model.design - a.model.design
808
+ } else if (b.model.intelligence !== a.model.intelligence) {
809
+ return b.model.intelligence - a.model.intelligence
810
+ }
811
+ return a.estimate.usd - b.estimate.usd
812
+ })[0]
813
+ if (bestPremium) {
814
+ warnings.push(
815
+ `Premium route suggested: ${bestPremium.model.name} (${usd(bestPremium.estimate.usd)} estimated). Approve once with /router-approve or set a premium budget.`,
816
+ )
817
+ }
818
+ }
819
+
820
+ if (premiumFallback) {
821
+ warnings.push("This task is premium-tier, but automatic premium escalation is unavailable.")
822
+ }
823
+ if (largeContext && wouldSwitch) {
824
+ warnings.push("Large context detected; switching increases fresh-input cost because prompt cache is model-specific.")
825
+ }
826
+ // Explain a context-driven switch in terms the user can act on. Without this,
827
+ // a large session silently stops using the configured default and the only
828
+ // visible signal is that some other model is suddenly in use.
829
+ //
830
+ // Two conditions keep this from becoming noise. The model must be at least
831
+ // as capable as what was chosen, and cheaper, so it would have won had it
832
+ // fit. Comparing against the chosen model rather than the task tier matters:
833
+ // a premium task served by a standard fallback should still report that a
834
+ // cheaper standard model was displaced by size. And if its display name
835
+ // matches the chosen model's, the provider is named too, because "DeepSeek
836
+ // was skipped" while DeepSeek is running reads as a bug.
837
+ const viableOnMerit = contextRejected.filter(
838
+ (entry) =>
839
+ (entry.model.providerID !== chosen.model.providerID || entry.model.modelID !== chosen.model.modelID) &&
840
+ entry.tier >= chosen.model.tier &&
841
+ entry.usd < chosen.candidate.estimatedUsd,
842
+ )
843
+ const displaced =
844
+ viableOnMerit.find(
845
+ (entry) =>
846
+ input.current?.providerID === entry.model.providerID && input.current?.id === entry.model.modelID,
847
+ ) ?? [...viableOnMerit].sort((a, b) => a.usd - b.usd)[0]
848
+ if (displaced) {
849
+ const sameName = displaced.model.name === chosen.model.name
850
+ const label = sameName ? `${displaced.model.name} on ${displaced.model.providerID}` : displaced.model.name
851
+ warnings.push(
852
+ `${label} was skipped for this turn: the transcript needs about ${thousands(displaced.required)} tokens of context and that model's window is ${thousands(displaced.window)}. Start a new session to route to it again.`,
853
+ )
854
+ }
855
+ if (chosen.model.premium) {
856
+ reasons.push(`Premium route approved: ${chosen.model.name}.`)
857
+ }
858
+ if (keptForAffinity) {
859
+ reasons.push(`Kept ${currentEval!.model.name} for cache affinity; the estimated savings were below the ${hysteresis}% switch threshold.`)
860
+ } else if (wouldSwitch) {
861
+ reasons.push(
862
+ `Selected ${chosen.model.name} as the least-cost qualified route after expected retries and quota pressure (${usd(chosen.candidate.estimatedUsd)} estimated raw cost, ${usd(chosen.candidate.effectiveCost)} expected).`,
863
+ )
864
+ } else {
865
+ reasons.push(`Already on ${chosen.model.name}, the least-cost qualified route.`)
866
+ }
867
+
868
+ const quotaNotes = input.quota
869
+ .filter((snapshot) => snapshot.source === "provider")
870
+ .map(
871
+ (snapshot) =>
872
+ `${snapshot.label}: ${snapshot.rolling.usedPct.toFixed(0)}% 5h, ${snapshot.weekly.usedPct.toFixed(0)}% weekly, ${snapshot.monthly.usedPct.toFixed(0)}% monthly`,
873
+ )
874
+ reasons.push(...quotaNotes)
875
+
876
+ const candidates = evaluated
877
+ .slice(0, 12)
878
+ .map((entry) => ({
879
+ ...entry.candidate,
880
+ retained: keptForAffinity ? entry.model === currentEval?.model : entry.model === chosen.model,
881
+ }))
882
+ if (currentEval && !candidates.some((candidate) => candidate.model.providerID === currentEval.model.providerID && candidate.model.modelID === currentEval.model.modelID)) {
883
+ candidates.push({ ...currentEval.candidate, retained: keptForAffinity })
884
+ }
885
+
886
+ const switchReason = !wouldSwitch
887
+ ? keptForAffinity
888
+ ? "Kept the current model for cache affinity."
889
+ : "The current model already is the least-cost qualified route."
890
+ : input.dryRun
891
+ ? `Dry run: would switch to ${chosen.model.name}.`
892
+ : `Switch to ${chosen.model.name}.`
893
+
894
+ const decision: RoutingDecision = {
895
+ id: nowId("dec"),
896
+ at: now,
897
+ sessionID: input.session?.sessionID ?? "unknown",
898
+ mode: input.mode,
899
+ dryRun: input.dryRun,
900
+ runtime: input.runtime,
901
+ task: input.task,
902
+ current: input.current,
903
+ chosen: chosenRef,
904
+ switched: wouldSwitch && !input.dryRun,
905
+ switchReason,
906
+ reasons: [...input.task.reasons, ...reasons],
907
+ warnings,
908
+ candidates,
909
+ premium: premiumState(input),
910
+ quota: input.quota,
911
+ contextTokens: input.contextTokens,
912
+ cachedTokens: input.cachedTokens,
913
+ estimatedUsd: chosen.candidate.estimatedUsd,
914
+ needsPremiumApproval,
915
+ manualOverride: false,
916
+ ...(input.lastError ? { error: safeError(input.lastError) } : {}),
917
+ }
918
+ return { decision, chosenModel: chosen.model, premiumDecision }
919
+ }