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

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 (62) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/CONTRIBUTING.md +103 -0
  3. package/LICENSE +21 -0
  4. package/README.md +425 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +107 -0
  7. package/dist/npm/index.js +40 -0
  8. package/docs/ARCHITECTURE.md +104 -0
  9. package/docs/DEBUGGING-GUI.md +70 -0
  10. package/docs/INSTALL.md +151 -0
  11. package/docs/LIMITATIONS.md +230 -0
  12. package/docs/MODELS.md +124 -0
  13. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  14. package/docs/QA.md +142 -0
  15. package/docs/SHARED-CORE.md +114 -0
  16. package/install +75 -0
  17. package/package.json +94 -4
  18. package/scripts/install.sh +324 -0
  19. package/scripts/uninstall.sh +91 -0
  20. package/src/allowance.ts +135 -0
  21. package/src/core/classify/classifier.ts +305 -0
  22. package/src/core/config-writer.ts +167 -0
  23. package/src/core/config.ts +1047 -0
  24. package/src/core/context/strategy.ts +188 -0
  25. package/src/core/context-health.ts +39 -0
  26. package/src/core/failures.ts +398 -0
  27. package/src/core/history/opencode.ts +173 -0
  28. package/src/core/models/protocol.ts +22 -0
  29. package/src/core/models/registry.ts +204 -0
  30. package/src/core/models/seed.ts +781 -0
  31. package/src/core/offline/mode.ts +321 -0
  32. package/src/core/plan-page.ts +390 -0
  33. package/src/core/plans/catalog.ts +487 -0
  34. package/src/core/policy/calibration.ts +259 -0
  35. package/src/core/policy/eligibility.ts +630 -0
  36. package/src/core/providers/enablement.ts +143 -0
  37. package/src/core/providers/opendesign-credits.ts +206 -0
  38. package/src/core/providers/opendesign-models.ts +654 -0
  39. package/src/core/providers/opendesign.ts +474 -0
  40. package/src/core/quota/calibration.ts +166 -0
  41. package/src/core/quota/commandcode.ts +471 -0
  42. package/src/core/quota/credentials.ts +490 -0
  43. package/src/core/quota/ledger.ts +1304 -0
  44. package/src/core/quota/opencode-go.ts +148 -0
  45. package/src/core/quota/opendesign.ts +1043 -0
  46. package/src/core/quota/reconcile.ts +155 -0
  47. package/src/core/quota/report.ts +261 -0
  48. package/src/core/quota/service.ts +273 -0
  49. package/src/core/reprompt.ts +48 -0
  50. package/src/core/routing/engine.ts +978 -0
  51. package/src/core/types.ts +866 -0
  52. package/src/core/ui.ts +236 -0
  53. package/src/core/util.ts +114 -0
  54. package/src/gui/index.tsx +1675 -0
  55. package/src/gui/viewmodel.ts +456 -0
  56. package/src/index.ts +33 -0
  57. package/src/rpc.ts +204 -0
  58. package/src/server/containment.ts +131 -0
  59. package/src/server/controller.ts +3405 -0
  60. package/src/server/route-tracker.ts +60 -0
  61. package/src/server/shared-core.ts +701 -0
  62. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,978 @@
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
+ /**
213
+ * Which capability axis each task kind reads, and the benchmark-neutral point
214
+ * on that axis. Kinds with no axis get no nudge. Neutral points sit at the
215
+ * middle of the observed benchmark band, so a score above the band's middle
216
+ * helps and one below it hurts - by at most CAPABILITY_MAX_NUDGE.
217
+ */
218
+ const CAPABILITY_AXIS: Partial<Record<TaskClass["kind"], keyof NonNullable<ModelRecord["capabilities"]>>> = {
219
+ feature: "coding",
220
+ debug: "debugging",
221
+ refactor: "coding",
222
+ design: "architecture",
223
+ architecture: "architecture",
224
+ security: "architecture",
225
+ test: "coding",
226
+ docs: undefined,
227
+ deterministic: undefined,
228
+ mechanical: undefined,
229
+ unknown: undefined,
230
+ }
231
+
232
+ const CAPABILITY_NEUTRAL: Record<"coding" | "debugging" | "agentic" | "architecture", number> = {
233
+ coding: 60,
234
+ debugging: 68,
235
+ agentic: 55,
236
+ architecture: 80,
237
+ }
238
+
239
+ const CAPABILITY_MAX_NUDGE = 0.04
240
+
241
+ const observedSuccess = (model: ModelRecord, session: EngineInput["session"], _key: string): number => {
242
+ // Only capability failures should reduce a model's expected success rate.
243
+ // Provider, network, auth, tool, and context-overflow errors are handled by
244
+ // their own recovery paths.
245
+ if (!session) return 0.95
246
+ const capabilityFailures = session.failures.filter((failure) => failure.class === "capability").length
247
+ return Math.max(0.55, 0.95 - capabilityFailures * 0.1)
248
+ }
249
+
250
+ export const estimateSuccess = (input: {
251
+ model: ModelRecord
252
+ task: TaskClass
253
+ contextTokens: number
254
+ session?: SessionState
255
+ currentModelKey?: string
256
+ history?: { requests: number; failures: number }
257
+ /**
258
+ * The floor in force for this request.
259
+ *
260
+ * This used to be a literal `39.5`, which meant raising or lowering the floor
261
+ * moved the eligibility gate but left this quality penalty behind at the old
262
+ * number. Two gates reading the same policy from different sources is exactly
263
+ * the drift the context-margin work removed, so the value is passed in now.
264
+ */
265
+ intelligenceFloor?: number
266
+ }): number => {
267
+ const { model, task } = input
268
+ let probability: number
269
+ if (task.tier <= 1) {
270
+ probability = model.tier === 1 ? 0.93 : 0.97
271
+ } else if (task.tier === 2) {
272
+ probability = model.tier === 2 ? 0.94 : 0.97
273
+ } else {
274
+ probability = model.tier === 3 ? 0.93 : 0.7
275
+ }
276
+ if (model.training) probability -= 0.05
277
+ if (task.designIntensive) {
278
+ const threshold = task.sophisticatedDesign ? 75 : 60
279
+ if (model.design < threshold) probability *= 0.72
280
+ }
281
+ // Per-kind capability evidence. A model with no measured capability for the
282
+ // task's kind stays neutral: an unscored model is not a zero-capability
283
+ // model, and inventing a score would poison every ranking it entered. With
284
+ // evidence, the benchmark gap moves the probability by at most a few points
285
+ // either way - enough to break a close ranking in favor of the model that
286
+ // measurably does this kind of work better, never enough to overrule a
287
+ // context, quota, or floor gate.
288
+ if (model.capabilities && CAPABILITY_AXIS[task.kind]) {
289
+ const axis = CAPABILITY_AXIS[task.kind]!
290
+ const score = model.capabilities[axis]
291
+ if (typeof score === "number") {
292
+ const edge = score - CAPABILITY_NEUTRAL[axis]
293
+ probability += Math.max(-CAPABILITY_MAX_NUDGE, Math.min(CAPABILITY_MAX_NUDGE, edge / 500))
294
+ }
295
+ }
296
+ const floor = input.intelligenceFloor ?? 39.5
297
+ if (task.tier >= 2 && model.intelligence < floor) probability *= 0.7
298
+ if (model.context > 0 && input.contextTokens > model.context * 0.8) probability *= 0.9
299
+ if (model.context > 0 && input.contextTokens > model.context * 0.95) probability *= 0.6
300
+ probability *= observedSuccess(model, input.session, modelKey(model.providerID, model.modelID)) / 0.95
301
+ if (input.history && input.history.requests >= 3) {
302
+ // Reliability is weighted more heavily than before. A model that drops
303
+ // streams is not worth a lower token price, because every dropped turn
304
+ // costs a full retry of the accumulated context.
305
+ const total = input.history.requests + input.history.failures
306
+ const successRate = total > 0 ? Math.max(0, input.history.requests) / total : 1
307
+ probability *= 0.6 + 0.4 * successRate
308
+ }
309
+ return Math.max(0.2, Math.min(0.99, probability))
310
+ }
311
+
312
+ const premiumState = (input: EngineInput): PremiumState => input.premium
313
+
314
+ /**
315
+ * Speed penalty for a candidate, as a multiplier on its effective cost.
316
+ *
317
+ * Speed is measured as output tokens per second, which is the only figure
318
+ * that separates a slow provider from a model that simply reasons for longer:
319
+ * a model that emits 3,000 reasoning tokens before answering is not "slow" in
320
+ * the same sense as one that takes four times as long to emit the same
321
+ * answer.
322
+ *
323
+ * With no samples the penalty is neutral. Once a model has been measured it
324
+ * is compared against the fastest eligible route rather than against an
325
+ * absolute number, so the penalty adapts as providers change.
326
+ */
327
+ const speedPenalty = (
328
+ input: EngineInput,
329
+ key: string,
330
+ fastestTokensPerSecond: number,
331
+ ): number => {
332
+ if (!input.settings.speed.enabled) return 1
333
+ const measure = input.speed?.[key]
334
+ if (!measure || measure.samples < input.settings.speed.minSamples) return 1
335
+ if (measure.tokensPerSecond <= 0 || fastestTokensPerSecond <= 0) return 1
336
+ const ratio = measure.tokensPerSecond / fastestTokensPerSecond
337
+ // A route at the fastest observed speed is unpenalised. Below that the
338
+ // configured weight scales the shortfall, so a route half as fast costs
339
+ // more than one at parity but never becomes ineligible.
340
+ const shortfall = Math.max(0, 1 - ratio)
341
+ const weight = input.settings.speed.weightPct / 100
342
+ const penalty = 1 + shortfall * weight * 2
343
+ // A route far slower than the fastest is penalised harder, because the
344
+ // owner's stated preference puts responsiveness ahead of shaving pennies.
345
+ const multiple = ratio > 0 ? 1 / ratio : input.settings.speed.slowRouteMultiple
346
+ if (multiple >= input.settings.speed.slowRouteMultiple) {
347
+ return penalty * (1 + weight)
348
+ }
349
+ return penalty
350
+ }
351
+
352
+ const providerFor = (quota: ReadonlyArray<ProviderQuotaSnapshot>, providerID: string) =>
353
+ quota.find((snapshot) => snapshot.providerID === providerID)
354
+
355
+ const modelQuotaFor = (snapshot: ProviderQuotaSnapshot | undefined, model: ModelRecord): ModelQuotaSnapshot | undefined =>
356
+ snapshot?.models[modelKey(model.providerID, model.modelID)]
357
+
358
+ const toRef = (model: ModelRecord): ModelRef => ({ providerID: model.providerID, id: model.modelID })
359
+
360
+ const candidateFor = (
361
+ input: EngineInput,
362
+ model: ModelRecord,
363
+ eligibility: { eligible: boolean; reason: string; warnings: string[] },
364
+ estimate: CostEstimate,
365
+ probability: number,
366
+ pressure: number,
367
+ currentModelKey: string | undefined,
368
+ speedFactor = 1,
369
+ marginalPriceFactor = 1,
370
+ ): CandidateEvaluation => {
371
+ // Expiring prepaid allowance is priced at the probability it will be wasted,
372
+ // not treated as fresh money. This is applied to the price rather than folded
373
+ // into `pressure` because pressure is a multiplier on an already-ranked cost:
374
+ // no pressure figure can close a large price gap, so quota urgency expressed
375
+ // that way could never change a decision.
376
+ const expectedTotal =
377
+ (estimate.usd * marginalPriceFactor * (1 + (1 - probability) * 2)) * pressure * speedFactor
378
+ return {
379
+ model: { providerID: model.providerID, modelID: model.modelID },
380
+ name: model.name,
381
+ tier: model.tier,
382
+ eligible: eligibility.eligible,
383
+ reason: eligibility.reason,
384
+ estimatedUsd: estimate.usd,
385
+ effectiveCost: expectedTotal,
386
+ successProbability: probability,
387
+ switchPenaltyUsd: estimate.switchPenaltyUsd,
388
+ quotaPressure: pressure,
389
+ retained: keyOfRef(input.current) === modelKey(model.providerID, model.modelID),
390
+ creditMultiplier: estimate.creditMultiplier,
391
+ speedFactor,
392
+ marginalPriceFactor,
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Deterministic routing engine. It never calls a model to classify or decide.
398
+ * Every candidate is evaluated against hard requirements first, then ranked by
399
+ * expected total quota cost, with a cache-aware affinity threshold.
400
+ */
401
+ export const routeTask = (input: EngineInput): EngineResult => {
402
+ const now = input.now ?? Date.now()
403
+ /**
404
+ * Does ANY provider in this decision carry a real reading?
405
+ *
406
+ * Used to tell "this provider is fine" from "nobody has been probed yet".
407
+ * Treating the second as the first is what let a decision route into an
408
+ * exhausted allowance during the ~1.5s before the quota service starts.
409
+ *
410
+ * A snapshot counts as known when it has a source other than "unknown": that
411
+ * is the provider telling us something, whether provider-reported or modelled.
412
+ * Staleness does not disqualify it; see the sticky-exhausted rule.
413
+ */
414
+ const quotaIsKnown = (input.quota ?? []).some(
415
+ (snapshot) =>
416
+ snapshot.monthly.source !== "unknown" ||
417
+ snapshot.weekly.source !== "unknown" ||
418
+ snapshot.rolling.source !== "unknown",
419
+ )
420
+ // One value for the whole decision. The eligibility gate and the success
421
+ // estimate must never disagree about the floor in force.
422
+ const floor = input.intelligenceFloor ?? input.settings.routing.intelligenceFloor
423
+ const warnings: string[] = []
424
+ const reasons: string[] = []
425
+ const currentKey = keyOfRef(input.current)
426
+ const premiumDecision = decidePremium({
427
+ settings: input.settings,
428
+ mode: input.mode,
429
+ task: input.task,
430
+ session: input.session,
431
+ premium: input.premium,
432
+ })
433
+
434
+ /**
435
+ * Offline mode narrows the candidate set to the local provider before any
436
+ * other evaluation runs.
437
+ *
438
+ * This is deliberately the first thing that happens and the only place the
439
+ * restriction is applied. Filtering here means quotas, escalation, premium
440
+ * approval and the cost model all operate on a list that cannot contain a
441
+ * hosted provider, so no later branch can reintroduce one. A refusal is
442
+ * explicit rather than a silent empty result: an offline session with no
443
+ * usable local model must say so.
444
+ */
445
+ const inferenceMode: InferenceMode = input.settings.offline?.enabled ? "offline" : "online"
446
+ let candidateModels = input.models
447
+ if (inferenceMode === "offline") {
448
+ candidateModels = input.models.filter((model) => mayUseProvider(model.providerID, inferenceMode))
449
+ const refused = input.models.filter((model) => !mayUseProvider(model.providerID, inferenceMode))
450
+ if (refused.length > 0) {
451
+ const providers = [...new Set(refused.map((model) => model.providerID))].sort()
452
+ warnings.push(
453
+ `Offline mode excluded ${refused.length} hosted model${refused.length === 1 ? "" : "s"} from ${providers.join(", ")}.`,
454
+ )
455
+ }
456
+ if (candidateModels.length === 0) {
457
+ const decision: RoutingDecision = {
458
+ id: nowId("dec"),
459
+ at: now,
460
+ sessionID: input.session?.sessionID ?? "unknown",
461
+ mode: input.mode,
462
+ dryRun: input.dryRun,
463
+ runtime: input.runtime,
464
+ task: input.task,
465
+ current: input.current,
466
+ switched: false,
467
+ switchReason:
468
+ "Offline mode has no local model available. No hosted provider will be used.",
469
+ reasons: ["Offline mode is enabled and no offline-capable model is in the registry."],
470
+ warnings: [
471
+ ...warnings,
472
+ "Offline mode fails closed: configure a local model, or run `opencode-online` to restore hosted routing.",
473
+ ],
474
+ candidates: [],
475
+ premium: premiumState(input),
476
+ quota: input.quota,
477
+ contextTokens: input.contextTokens,
478
+ cachedTokens: input.cachedTokens,
479
+ estimatedUsd: 0,
480
+ needsPremiumApproval: false,
481
+ manualOverride: false,
482
+ }
483
+ return { decision, premiumDecision }
484
+ }
485
+ }
486
+ if (input.runtime === "paused") {
487
+ const decision: RoutingDecision = {
488
+ id: nowId("dec"),
489
+ at: now,
490
+ sessionID: input.session?.sessionID ?? "unknown",
491
+ mode: input.mode,
492
+ dryRun: input.dryRun,
493
+ runtime: input.runtime,
494
+ task: input.task,
495
+ current: input.current,
496
+ switched: false,
497
+ switchReason: "Routing is paused; the current model is unchanged.",
498
+ reasons: ["Pause switch is active."],
499
+ warnings: [],
500
+ candidates: [],
501
+ premium: premiumState(input),
502
+ quota: input.quota,
503
+ contextTokens: input.contextTokens,
504
+ cachedTokens: input.cachedTokens,
505
+ estimatedUsd: 0,
506
+ needsPremiumApproval: false,
507
+ manualOverride: false,
508
+ }
509
+ return { decision, premiumDecision }
510
+ }
511
+
512
+ if (input.runtime === "manual") {
513
+ const decision: RoutingDecision = {
514
+ id: nowId("dec"),
515
+ at: now,
516
+ sessionID: input.session?.sessionID ?? "unknown",
517
+ mode: input.mode,
518
+ dryRun: input.dryRun,
519
+ runtime: input.runtime,
520
+ task: input.task,
521
+ current: input.current,
522
+ switched: false,
523
+ switchReason: "Manual model selection is active for this session; the router did not override it.",
524
+ reasons: ["Manual override respected."],
525
+ warnings: [],
526
+ candidates: [],
527
+ premium: premiumState(input),
528
+ quota: input.quota,
529
+ contextTokens: input.contextTokens,
530
+ cachedTokens: input.cachedTokens,
531
+ estimatedUsd: 0,
532
+ needsPremiumApproval: false,
533
+ manualOverride: true,
534
+ }
535
+ return { decision, premiumDecision }
536
+ }
537
+
538
+ /**
539
+ * Models that were rejected purely because the transcript would not fit.
540
+ *
541
+ * This is recorded rather than silently skipped because it is the one
542
+ * rejection a user is most likely to misread. A large session quietly stops
543
+ * being routed to the configured default, and without this note the only
544
+ * visible signal is that some other model is suddenly in use.
545
+ */
546
+ const contextRejected: Array<{ model: ModelRecord; required: number; window: number; usd: number; tier: number }> = []
547
+
548
+ const evaluate = (designRelaxation: 0 | 1 | 2) => {
549
+ const taskForEvaluation =
550
+ designRelaxation === 1 ? { ...input.task, sophisticatedDesign: false } : input.task
551
+ // The fastest measured eligible model sets the reference point for the
552
+ // speed penalty, so the comparison is always against what this account
553
+ // can actually achieve right now.
554
+ let fastestTokensPerSecond = 0
555
+ for (const model of candidateModels) {
556
+ const measure = input.speed?.[modelKey(model.providerID, model.modelID)]
557
+ if (!measure || measure.samples < input.settings.speed.minSamples) continue
558
+ if (measure.tokensPerSecond > fastestTokensPerSecond) fastestTokensPerSecond = measure.tokensPerSecond
559
+ }
560
+
561
+ /**
562
+ * Pass one evaluates every candidate without the escalation-pool guard.
563
+ * The guard is only applied once an ordinary-allowance route is known to
564
+ * be available: otherwise a task that genuinely needs escalation would be
565
+ * blocked outright instead of being escalated.
566
+ */
567
+ const run = (protectEscalation: boolean) => {
568
+ const out: Evaluated[] = []
569
+ for (const model of candidateModels) {
570
+ const provider = providerFor(input.quota, model.providerID)
571
+ const modelQuota = modelQuotaFor(provider, model)
572
+ const key = modelKey(model.providerID, model.modelID)
573
+ // A model that just dropped a stream is held out for a cooling period
574
+ // instead of being re-selected on the next prompt.
575
+ const cooldownUntil = input.cooldowns?.[key]
576
+ if (typeof cooldownUntil === "number" && cooldownUntil > now) {
577
+ continue
578
+ }
579
+ // A provider whose own pre-request path has failed several attempts in
580
+ // a row is not serving anyone. Step back from every model it offers
581
+ // and let another provider take the work. If no provider is left the
582
+ // router fails closed and keeps the current model, so this can never
583
+ // block a prompt outright.
584
+ const providerCooldownUntil = input.providerCooldowns?.[model.providerID]
585
+ if (typeof providerCooldownUntil === "number" && providerCooldownUntil > now) {
586
+ continue
587
+ }
588
+ // The estimate is computed first so the subscription-only guard can
589
+ // check the projected request cost against what is left of the
590
+ // subscription allowance.
591
+ const estimate = estimateTaskCost({
592
+ model,
593
+ task: input.task,
594
+ contextTokens: input.contextTokens,
595
+ currentModelKey: currentKey,
596
+ now,
597
+ })
598
+ const eligibility = evaluateEligibility({
599
+ intelligenceFloor: floor,
600
+ model,
601
+ task: taskForEvaluation,
602
+ settings: input.settings,
603
+ mode: input.mode,
604
+ contextTokens: input.contextTokens,
605
+ providerQuota: provider,
606
+ quotaIsKnown,
607
+ modelQuota,
608
+ premium: premiumDecision,
609
+ currentModelKey: currentKey,
610
+ allowDesignRelaxation: designRelaxation === 2,
611
+ projectedUsd: estimate.usd,
612
+ escalationPoolProtected: protectEscalation,
613
+ })
614
+ if (!eligibility.eligible) {
615
+ // Record only a context-capacity rejection. It is deterministic,
616
+ // actionable ("start a new session"), and otherwise invisible.
617
+ if (eligibility.rejectedFor === "context" && eligibility.contextRequiredTokens !== undefined && eligibility.contextWindowTokens !== undefined) {
618
+ contextRejected.push({
619
+ model,
620
+ required: eligibility.contextRequiredTokens,
621
+ window: eligibility.contextWindowTokens,
622
+ usd: estimate.usd,
623
+ tier: model.tier,
624
+ })
625
+ }
626
+ continue
627
+ }
628
+ const eligibleReason =
629
+ designRelaxation === 1 && input.task.sophisticatedDesign
630
+ ? `Standard design route: design ${model.design} is below the sophisticated ${input.settings.routing.sophisticatedDesignFloor} threshold`
631
+ : eligibility.reason
632
+ const displayedEligibility = eligibleReason === eligibility.reason ? eligibility : { ...eligibility, reason: eligibleReason }
633
+ const probability = estimateSuccess({
634
+ model,
635
+ task: input.task,
636
+ contextTokens: input.contextTokens,
637
+ session: input.session,
638
+ currentModelKey: currentKey,
639
+ history: input.modelHistory?.[modelKey(model.providerID, model.modelID)],
640
+ intelligenceFloor: floor,
641
+ })
642
+ const pressure = providerPressure(provider, now) * modelPressure(modelQuota)
643
+ const speedFactor = speedPenalty(input, modelKey(model.providerID, model.modelID), fastestTokensPerSecond)
644
+ const marginalPriceFactor = quotaMarginalPriceFactor(provider, now)
645
+ const candidate = candidateFor(
646
+ input,
647
+ model,
648
+ displayedEligibility,
649
+ estimate,
650
+ probability,
651
+ pressure,
652
+ currentKey,
653
+ speedFactor,
654
+ marginalPriceFactor,
655
+ )
656
+ out.push({ model, eligibility: displayedEligibility, estimate, probability, pressure, candidate })
657
+ }
658
+ return out.sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
659
+ }
660
+
661
+ const unprotected = run(false)
662
+ if (!input.settings.routing.protectEscalationPool) return unprotected
663
+ // Only protect the escalation pool when an ordinary-allowance route can
664
+ // actually serve the task. A task that needs escalation must still be
665
+ // able to escalate.
666
+ // A tier-3 task served by a standard model runs at ~0.7 expected
667
+ // success by construction (estimateSuccess). That is below the bar a
668
+ // tier-3 model meets, so for tier-3 work no ordinary route "serves" the
669
+ // task and the pool stays available for it. Tier 1/2 work meets the bar
670
+ // at 0.93+, so the pool stays protected for ordinary tasks and can never
671
+ // be drained by routine work.
672
+ if (input.task.tier >= 3) return unprotected
673
+ const ordinaryAvailable = unprotected.some(
674
+ (entry) => !entry.model.escalationOnly && !entry.model.premium && entry.probability >= 0.85,
675
+ )
676
+ if (!ordinaryAvailable) return unprotected
677
+ return run(true)
678
+ }
679
+
680
+ let evaluated = evaluate(0)
681
+ if (evaluated.length === 0 && input.task.designIntensive) {
682
+ // First fall back to the standard design floor, which keeps a qualified
683
+ // design model instead of jumping straight to the cheapest model overall.
684
+ evaluated = evaluate(1)
685
+ if (evaluated.length > 0) {
686
+ warnings.push("No model met the sophisticated design threshold; using the best qualified standard design route.")
687
+ }
688
+ }
689
+ if (evaluated.length === 0 && input.task.designIntensive) {
690
+ evaluated = evaluate(2)
691
+ if (evaluated.length > 0) {
692
+ warnings.push("No model met the standard design floor; using the best available design-capable fallback.")
693
+ }
694
+ }
695
+
696
+ // Premium tasks without approval still need a deliberately chosen standard
697
+ // fallback rather than silently keeping an unqualified cheap model.
698
+ let premiumFallback = false
699
+ if (evaluated.length === 0 && input.task.tier === 3) {
700
+ const relaxedPremium: PremiumDecision = { allowed: false, requiresApproval: premiumDecision.requiresApproval, reason: premiumDecision.reason }
701
+ const fallbackModels = candidateModels
702
+ .filter((model) => !model.premium)
703
+ .map((model) => {
704
+ const provider = providerFor(input.quota, model.providerID)
705
+ const modelQuota = modelQuotaFor(provider, model)
706
+ const key = modelKey(model.providerID, model.modelID)
707
+ const cooldownUntil = input.cooldowns?.[key]
708
+ if (typeof cooldownUntil === "number" && cooldownUntil > now) return undefined
709
+ const estimate = estimateTaskCost({
710
+ model,
711
+ task: input.task,
712
+ contextTokens: input.contextTokens,
713
+ currentModelKey: currentKey,
714
+ now,
715
+ })
716
+ const eligibility = evaluateEligibility({
717
+ intelligenceFloor: floor,
718
+ model,
719
+ task: { ...input.task, designIntensive: false, sophisticatedDesign: false },
720
+ settings: input.settings,
721
+ mode: input.mode,
722
+ contextTokens: input.contextTokens,
723
+ providerQuota: provider,
724
+ quotaIsKnown,
725
+ modelQuota,
726
+ premium: relaxedPremium,
727
+ currentModelKey: currentKey,
728
+ allowDesignRelaxation: true,
729
+ projectedUsd: estimate.usd,
730
+ })
731
+ if (!eligibility.eligible) return undefined
732
+ const probability = estimateSuccess({
733
+ model,
734
+ task: input.task,
735
+ contextTokens: input.contextTokens,
736
+ session: input.session,
737
+ currentModelKey: currentKey,
738
+ history: input.modelHistory?.[modelKey(model.providerID, model.modelID)],
739
+ intelligenceFloor: floor,
740
+ })
741
+ const pressure = providerPressure(provider, now) * modelPressure(modelQuota)
742
+ const marginalPriceFactor = quotaMarginalPriceFactor(provider, now)
743
+ return {
744
+ model,
745
+ eligibility,
746
+ estimate,
747
+ probability,
748
+ pressure,
749
+ candidate: candidateFor(
750
+ input,
751
+ model,
752
+ eligibility,
753
+ estimate,
754
+ probability,
755
+ pressure,
756
+ currentKey,
757
+ 1,
758
+ marginalPriceFactor,
759
+ ),
760
+ }
761
+ })
762
+ .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
763
+ .sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
764
+ if (fallbackModels.length > 0) {
765
+ evaluated = fallbackModels
766
+ premiumFallback = true
767
+ warnings.push("Premium is unavailable; selected the best qualified standard fallback.")
768
+ }
769
+ }
770
+
771
+ if (evaluated.length === 0) {
772
+ warnings.push("No approved model meets every hard requirement; the current model was left unchanged.")
773
+ const decision: RoutingDecision = {
774
+ id: nowId("dec"),
775
+ at: now,
776
+ sessionID: input.session?.sessionID ?? "unknown",
777
+ mode: input.mode,
778
+ dryRun: input.dryRun,
779
+ runtime: input.runtime,
780
+ task: input.task,
781
+ current: input.current,
782
+ switched: false,
783
+ switchReason: "No eligible route was found; the router failed closed and kept the current model.",
784
+ reasons: [...input.task.reasons, ...warnings],
785
+ warnings,
786
+ candidates: [],
787
+ premium: premiumState(input),
788
+ quota: input.quota,
789
+ contextTokens: input.contextTokens,
790
+ cachedTokens: input.cachedTokens,
791
+ estimatedUsd: 0,
792
+ needsPremiumApproval: premiumDecision.requiresApproval,
793
+ manualOverride: false,
794
+ ...(input.lastError ? { error: safeError(input.lastError) } : {}),
795
+ }
796
+ return { decision, premiumDecision }
797
+ }
798
+
799
+ const best = evaluated[0]!
800
+ const currentEval = currentKey
801
+ ? evaluated.find((entry) => modelKey(entry.model.providerID, entry.model.modelID) === currentKey)
802
+ : undefined
803
+ const largeContext = input.contextTokens >= input.settings.routing.largeContextTokens
804
+ const hysteresis = largeContext
805
+ ? input.settings.routing.largeContextHysteresisPct
806
+ : input.settings.routing.switchHysteresisPct
807
+
808
+ let chosen = best
809
+ let keptForAffinity = false
810
+ if (currentEval && currentEval.model.tier >= input.task.tier && currentEval.candidate.eligible) {
811
+ const savings = currentEval.candidate.effectiveCost - best.candidate.effectiveCost
812
+ const threshold = currentEval.candidate.effectiveCost * (hysteresis / 100)
813
+ if (best.model !== currentEval.model && savings < threshold) {
814
+ chosen = currentEval
815
+ keptForAffinity = true
816
+ }
817
+ }
818
+
819
+ const chosenRef = toRef(chosen.model)
820
+ const sameAsCurrent = currentKey === modelKey(chosen.model.providerID, chosen.model.modelID)
821
+ const wouldSwitch = !sameAsCurrent
822
+ const needsPremiumApproval =
823
+ premiumDecision.requiresApproval &&
824
+ (input.task.tier === 3 || input.task.constraints.requestPremium) &&
825
+ input.settings.premium.mode !== "off" &&
826
+ input.mode !== "efficiency"
827
+
828
+ if (input.task.tier === 3 && !premiumDecision.allowed && input.mode !== "efficiency" && input.settings.premium.mode !== "off") {
829
+ const bestPremium = candidateModels
830
+ .filter((model) => model.premium && model.approved)
831
+ .map((model) => {
832
+ const provider = providerFor(input.quota, model.providerID)
833
+ const modelQuota = modelQuotaFor(provider, model)
834
+ const eligibility = evaluateEligibility({
835
+ intelligenceFloor: floor,
836
+ model,
837
+ task: input.task,
838
+ settings: input.settings,
839
+ mode: input.mode,
840
+ contextTokens: input.contextTokens,
841
+ providerQuota: provider,
842
+ modelQuota,
843
+ premium: { allowed: true, requiresApproval: true, reason: "Premium suggestion" },
844
+ currentModelKey: currentKey,
845
+ allowDesignRelaxation: false,
846
+ })
847
+ if (!eligibility.eligible) return undefined
848
+ const estimate = estimateTaskCost({
849
+ model,
850
+ task: input.task,
851
+ contextTokens: input.contextTokens,
852
+ currentModelKey: currentKey,
853
+ now,
854
+ })
855
+ return { model, estimate }
856
+ })
857
+ .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
858
+ .sort((a, b) => {
859
+ if (input.task.designIntensive || input.task.sophisticatedDesign) {
860
+ if (b.model.design !== a.model.design) return b.model.design - a.model.design
861
+ } else if (b.model.intelligence !== a.model.intelligence) {
862
+ return b.model.intelligence - a.model.intelligence
863
+ }
864
+ return a.estimate.usd - b.estimate.usd
865
+ })[0]
866
+ if (bestPremium) {
867
+ warnings.push(
868
+ `Premium route suggested: ${bestPremium.model.name} (${usd(bestPremium.estimate.usd)} estimated). Approve once with /router-approve or set a premium budget.`,
869
+ )
870
+ }
871
+ }
872
+
873
+ if (premiumFallback) {
874
+ warnings.push("This task is premium-tier, but automatic premium escalation is unavailable.")
875
+ }
876
+ if (largeContext && wouldSwitch) {
877
+ warnings.push("Large context detected; switching increases fresh-input cost because prompt cache is model-specific.")
878
+ }
879
+ // Explain a context-driven switch in terms the user can act on. Without this,
880
+ // a large session silently stops using the configured default and the only
881
+ // visible signal is that some other model is suddenly in use.
882
+ //
883
+ // Two conditions keep this from becoming noise. The model must be at least
884
+ // as capable as what was chosen, and cheaper, so it would have won had it
885
+ // fit. Comparing against the chosen model rather than the task tier matters:
886
+ // a premium task served by a standard fallback should still report that a
887
+ // cheaper standard model was displaced by size. And if its display name
888
+ // matches the chosen model's, the provider is named too, because "DeepSeek
889
+ // was skipped" while DeepSeek is running reads as a bug.
890
+ const viableOnMerit = contextRejected.filter(
891
+ (entry) =>
892
+ (entry.model.providerID !== chosen.model.providerID || entry.model.modelID !== chosen.model.modelID) &&
893
+ entry.tier >= chosen.model.tier &&
894
+ entry.usd < chosen.candidate.estimatedUsd,
895
+ )
896
+ const displaced =
897
+ viableOnMerit.find(
898
+ (entry) =>
899
+ input.current?.providerID === entry.model.providerID && input.current?.id === entry.model.modelID,
900
+ ) ?? [...viableOnMerit].sort((a, b) => a.usd - b.usd)[0]
901
+ if (displaced) {
902
+ const sameName = displaced.model.name === chosen.model.name
903
+ const label = sameName ? `${displaced.model.name} on ${displaced.model.providerID}` : displaced.model.name
904
+ warnings.push(
905
+ `${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.`,
906
+ )
907
+ }
908
+ if (chosen.model.premium) {
909
+ reasons.push(`Premium route approved: ${chosen.model.name}.`)
910
+ }
911
+ if (keptForAffinity) {
912
+ reasons.push(`Kept ${currentEval!.model.name} for cache affinity; the estimated savings were below the ${hysteresis}% switch threshold.`)
913
+ } else if (wouldSwitch) {
914
+ reasons.push(
915
+ `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).`,
916
+ )
917
+ } else {
918
+ reasons.push(`Already on ${chosen.model.name}, the least-cost qualified route.`)
919
+ }
920
+
921
+ const quotaNotes = input.quota
922
+ .filter((snapshot) => snapshot.source === "provider")
923
+ .map(
924
+ (snapshot) =>
925
+ `${snapshot.label}: ${snapshot.rolling.usedPct.toFixed(0)}% 5h, ${snapshot.weekly.usedPct.toFixed(0)}% weekly, ${snapshot.monthly.usedPct.toFixed(0)}% monthly`,
926
+ )
927
+ reasons.push(...quotaNotes)
928
+
929
+ // The trace keeps the twelve cheapest eligible routes plus, always, the
930
+ // chosen model: on an escalation the flagship is the most expensive row, so
931
+ // a bare cost-ordered slice would truncate exactly the entry an escalation
932
+ // exists to show.
933
+ const chosenEntry = chosen
934
+ const candidates = evaluated
935
+ .sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
936
+ .filter((entry, index) => index < 12 || entry === chosenEntry)
937
+ .map((entry) => ({
938
+ ...entry.candidate,
939
+ retained: keptForAffinity ? entry.model === currentEval?.model : entry.model === chosen.model,
940
+ }))
941
+ if (currentEval && !candidates.some((candidate) => candidate.model.providerID === currentEval.model.providerID && candidate.model.modelID === currentEval.model.modelID)) {
942
+ candidates.push({ ...currentEval.candidate, retained: keptForAffinity })
943
+ }
944
+
945
+ const switchReason = !wouldSwitch
946
+ ? keptForAffinity
947
+ ? "Kept the current model for cache affinity."
948
+ : "The current model already is the least-cost qualified route."
949
+ : input.dryRun
950
+ ? `Dry run: would switch to ${chosen.model.name}.`
951
+ : `Switch to ${chosen.model.name}.`
952
+
953
+ const decision: RoutingDecision = {
954
+ id: nowId("dec"),
955
+ at: now,
956
+ sessionID: input.session?.sessionID ?? "unknown",
957
+ mode: input.mode,
958
+ dryRun: input.dryRun,
959
+ runtime: input.runtime,
960
+ task: input.task,
961
+ current: input.current,
962
+ chosen: chosenRef,
963
+ switched: wouldSwitch && !input.dryRun,
964
+ switchReason,
965
+ reasons: [...input.task.reasons, ...reasons],
966
+ warnings,
967
+ candidates,
968
+ premium: premiumState(input),
969
+ quota: input.quota,
970
+ contextTokens: input.contextTokens,
971
+ cachedTokens: input.cachedTokens,
972
+ estimatedUsd: chosen.candidate.estimatedUsd,
973
+ needsPremiumApproval,
974
+ manualOverride: false,
975
+ ...(input.lastError ? { error: safeError(input.lastError) } : {}),
976
+ }
977
+ return { decision, chosenModel: chosen.model, premiumDecision }
978
+ }