@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,3405 @@
1
+ import { appendFileSync, existsSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { resolve } from "node:path"
4
+ import type {
5
+ ModelRecord,
6
+ RuntimeMode,
7
+ ModelRef,
8
+ PremiumState,
9
+ ProviderQuotaSnapshot,
10
+ RouterSettings,
11
+ RouterStatus,
12
+ RoutingDecision,
13
+ SessionState,
14
+ TaskClass,
15
+ } from "../core/types.js"
16
+ import { classifyTask, type ClassifierFile } from "../core/classify/classifier.js"
17
+ import { computeContextHealth } from "../core/context-health.js"
18
+ import { decideCompaction, describeMode } from "../core/context/strategy.js"
19
+ import { repairMessages } from "../core/reprompt.js"
20
+ import { providerProtocol } from "../core/models/protocol.js"
21
+ import { PromptRouteTracker } from "./route-tracker.js"
22
+ import { buildProviderUsageReport } from "../core/quota/report.js"
23
+ import {
24
+ OPENDESIGN_DESIGN_PLAN_MODELS,
25
+ OPENDESIGN_PLAN_MODEL_POOL,
26
+ resolvePlan,
27
+ resolvePlanWithReason,
28
+ } from "../core/plans/catalog.js"
29
+ import { loadSettings, validateSettings, type SettingsLoadResult } from "../core/config.js"
30
+ import { writeSettingsPatch } from "../core/config-writer.js"
31
+ import { describeConsoleReading, parseConsolePage, type UsageReading } from "../core/plan-page.js"
32
+ import { reconcileUsage } from "../core/quota/reconcile.js"
33
+ import {
34
+ applyCredentialDefaults,
35
+ credentialEnvVars,
36
+ credentialFileCandidates,
37
+ describeEnablement,
38
+ type ProviderEvidence,
39
+ } from "../core/providers/enablement.js"
40
+ import { classifyFailure } from "../core/failures.js"
41
+ import { buildRegistry } from "../core/models/registry.js"
42
+ import type { QuotaService } from "../core/quota/service.js"
43
+ import { LedgerStore, type BootstrapSession } from "../core/quota/ledger.js"
44
+ import {
45
+ discoverOpenDesignControlKey,
46
+ discoverOpenDesignCredentials,
47
+ } from "../core/quota/credentials.js"
48
+ import { routeTask } from "../core/routing/engine.js"
49
+ import {
50
+ CALIBRATION_WINDOW_MS,
51
+ MIN_REQUESTS_FOR_COST_EVIDENCE,
52
+ resolveIntelligenceFloor,
53
+ type CalibrationResult,
54
+ } from "../core/policy/calibration.js"
55
+ import { decidePremium, evaluateEligibility } from "../core/policy/eligibility.js"
56
+ import { sharedCore, type RuntimeOverrides } from "./shared-core.js"
57
+ import {
58
+ ChangeNotifier,
59
+ formatQuotaLines,
60
+ indicatorState,
61
+ providerShort,
62
+ quotaStateLabel,
63
+ stripTitlePrefix,
64
+ type Indicator,
65
+ } from "../core/ui.js"
66
+ import {
67
+ discoverOpenDesignModels,
68
+ normalizeOpenDesignBaseURL,
69
+ } from "../core/providers/opendesign.js"
70
+ import { OPEN_DESIGN_MODEL_BY_ID } from "../core/providers/opendesign-models.js"
71
+ import { creditMultiplierFor } from "../core/providers/opendesign-credits.js"
72
+ import { SmartRouterRpc } from "../rpc.js"
73
+ import {
74
+ describePlan,
75
+ keyOfRef,
76
+ modelKey,
77
+ nowId,
78
+ parseModelKey,
79
+ percent,
80
+ redact,
81
+ safeError,
82
+ truncate,
83
+ unique,
84
+ usd,
85
+ } from "../core/util.js"
86
+ import { containHook, withDeadline } from "./containment.js"
87
+
88
+ interface ControllerOptions {
89
+ readonly ctx: any
90
+ readonly pluginOptions?: unknown
91
+ }
92
+
93
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
94
+ typeof value === "object" && value !== null && !Array.isArray(value)
95
+
96
+ /**
97
+ * How long one reported fault is treated as the same fault. The host reports
98
+ * a failed attempt through several channels in quick succession, so this only
99
+ * needs to span one attempt, not one session.
100
+ */
101
+ /**
102
+ * How long a computed floor is reused.
103
+ *
104
+ * Calibration reads the usage rollup, which changes per request rather than per
105
+ * candidate. Recomputing it for every routing decision would be wasted work in a
106
+ * multi-step loop; this is short enough that a spend change is picked up within
107
+ * a session.
108
+ */
109
+ const FLOOR_CACHE_MS = 30_000
110
+
111
+ const FAULT_DEDUPE_WINDOW_MS = 30_000
112
+
113
+ /**
114
+ * How long the router waits after triggering a compaction before it may
115
+ * trigger another for the same session. The strategy's configured cooldown
116
+ * supersedes this; it is retained as the documented upper bound.
117
+ */
118
+ const ROUTER_COMPACT_COOLDOWN_MS = 10 * 60_000
119
+
120
+ /**
121
+ * Fallbacks for the context constants. The strategy module owns the real
122
+ * values; these names are kept so existing call sites read the same way.
123
+ */
124
+ const CONTEXT_ELIGIBILITY_MARGIN_TOKENS = 20_000
125
+ const CONTEXT_SAFE_WINDOW_FRACTION = 0.95
126
+
127
+ /**
128
+ * How many attempts a provider-scoped failure may have before the router
129
+ * stops retrying. Each attempt is refused before a model runs, so it costs
130
+ * no allowance. The delay is the only price, and it is far cheaper than a
131
+ * turn dying and waiting for the owner to restart it.
132
+ */
133
+ const PROVIDER_SCOPED_RETRY_ATTEMPTS = 5
134
+
135
+ /**
136
+ * How long to avoid a provider whose allowance is spent, when nothing is known
137
+ * about when it resets.
138
+ *
139
+ * Deliberately longer than a transient backoff: an empty allowance does not
140
+ * recover in seconds, and re-probing it on every prompt costs a failed turn
141
+ * each time to relearn the same fact. A known reset time always wins over this.
142
+ */
143
+ const QUOTA_EXHAUSTED_FALLBACK_MS = 30 * 60 * 1000
144
+
145
+ const unwrap = <T>(value: unknown): T[] => {
146
+ if (Array.isArray(value)) return value as T[]
147
+ if (isRecord(value) && Array.isArray(value.data)) return value.data as T[]
148
+ return []
149
+ }
150
+
151
+ const modelRefFrom = (value: unknown): ModelRef | undefined => {
152
+ if (!isRecord(value)) return undefined
153
+ if (typeof value.providerID !== "string" || typeof value.id !== "string") return undefined
154
+ return {
155
+ providerID: value.providerID,
156
+ id: value.id,
157
+ ...(typeof value.variant === "string" ? { variant: value.variant } : {}),
158
+ }
159
+ }
160
+
161
+ const tokensFrom = (value: unknown) => {
162
+ if (!isRecord(value)) return { input: 0, output: 0, cache: { read: 0, write: 0 } }
163
+ const cache = isRecord(value.cache) ? value.cache : {}
164
+ return {
165
+ input: typeof value.input === "number" ? value.input : 0,
166
+ output: typeof value.output === "number" ? value.output : 0,
167
+ cache: {
168
+ read: typeof cache.read === "number" ? cache.read : 0,
169
+ write: typeof cache.write === "number" ? cache.write : 0,
170
+ },
171
+ }
172
+ }
173
+
174
+ const applyRuntime = (base: RouterSettings, runtime: RuntimeOverrides): RouterSettings => {
175
+ const next = structuredClone(base) as any
176
+ if (runtime.mode) next.mode = runtime.mode
177
+ if (runtime.paused === true) next.enabled = false
178
+ if (typeof runtime.dryRun === "boolean") next.dryRun = runtime.dryRun
179
+ if (typeof runtime.compactMode === "boolean") {
180
+ next.ui.compactMode = runtime.compactMode
181
+ next.ui.compact = runtime.compactMode
182
+ next.compactMode = runtime.compactMode
183
+ next.compact = runtime.compactMode
184
+ }
185
+ if (typeof runtime.compact === "boolean") {
186
+ next.ui.compactMode = runtime.compact
187
+ next.ui.compact = runtime.compact
188
+ next.compactMode = runtime.compact
189
+ next.compact = runtime.compact
190
+ }
191
+ if (typeof runtime.premiumBudgetUsd === "number") next.premium.monthlyBudgetUsd = runtime.premiumBudgetUsd
192
+ if (runtime.reserves) {
193
+ for (const [providerID, reserve] of Object.entries(runtime.reserves)) {
194
+ const provider = next.providers[providerID]
195
+ if (!provider) continue
196
+ if (typeof reserve.monthlyPct === "number") provider.reserveMonthlyPct = reserve.monthlyPct
197
+ if (typeof reserve.weeklyPct === "number") provider.reserveWeeklyPct = reserve.weeklyPct
198
+ if (typeof reserve.fiveHourPct === "number") provider.reserveFiveHourPct = reserve.fiveHourPct
199
+ }
200
+ }
201
+ const excluded = unique([...(next.models.excluded as string[]), ...(runtime.excluded ?? [])])
202
+ const approved = unique([...(next.models.approved as string[]), ...(runtime.approved ?? [])])
203
+ next.models.excluded = excluded
204
+ next.models.approved = approved
205
+ return next as RouterSettings
206
+ }
207
+
208
+ export class RouterBlockedError extends Error {
209
+ constructor(message: string) {
210
+ super(message)
211
+ this.name = "RouterBlockedError"
212
+ }
213
+ }
214
+
215
+ export class RouterController {
216
+ private readonly ctx: any
217
+ private settingsBase: RouterSettings
218
+ private settingsWarnings: string[]
219
+ private settingsSources: string[]
220
+ private runtime: RuntimeOverrides = {}
221
+ private ledger: LedgerStore
222
+ private registry = buildRegistry({
223
+ live: [],
224
+ settings: loadSettings({ options: {} }).settings,
225
+ })
226
+ private quota!: QuotaService
227
+ private bridgeID = ""
228
+ private readonly expectedSwitch = new Map<string, { key: string; at: number }>()
229
+ /**
230
+ * Request-kind latch keyed by session, written by the `model.request` hook
231
+ * and consumed one-shot by the `context` hook (whose event carries no kind).
232
+ * The agent loop folds prompts; title and compaction runs do not.
233
+ */
234
+ private readonly requestKinds = new Map<string, { kind: string; at: number }>()
235
+ /** The routed task per session's current prompt, for prompt-rejection failover. */
236
+ private readonly promptRoutes = new PromptRouteTracker()
237
+ /**
238
+ * Failovers parked because the session was still busy at rejection time,
239
+ * flushed once the host marks the turn over. One entry, one prompt.
240
+ */
241
+ private readonly pendingHeals = new Map<string, { chosen: ModelRecord; base: RoutingDecision; rejectedLabel: string }>()
242
+ /** Memoized calibrated floor; see routingFloor(). */
243
+ private floorCache?: { at: number; value: { floor: number; calibration?: CalibrationResult } }
244
+ /**
245
+ * Guards against counting one logical provider failure several times.
246
+ *
247
+ * The host reports a single failed attempt through several channels: the
248
+ * retry hook, `session.step.failed`, `session.execution.failed` and
249
+ * `session.idle`. Without this guard each channel added its own strike,
250
+ * so one fault could count three times and inflate the failure tally and
251
+ * the cooldown that is derived from it.
252
+ *
253
+ * The key is the session plus the identity of the attempt itself, so a
254
+ * genuinely new failure later in the session still counts separately.
255
+ */
256
+ private readonly recordedFaults = new Map<string, number>()
257
+ /**
258
+ * Every model a session has been seen on, keyed by model key.
259
+ *
260
+ * The desktop client sends the model it has saved for a session with each
261
+ * prompt. When the router has switched that session to something else, the
262
+ * client's saved model is stale, so the next prompt switches the session
263
+ * back and the host reports a model change the router did not make. That
264
+ * change is always to a model the session has already been on, which is
265
+ * what distinguishes it from the owner picking a model they have not used.
266
+ */
267
+ private readonly modelsSeen = new Map<string, Set<string>>()
268
+ /** When the router last triggered a compaction, per session. */
269
+ private readonly routerCompactAt = new Map<string, number>()
270
+ /** Last context-management decision per session, for diagnostics. */
271
+ private readonly lastCompactionDecision = new Map<
272
+ string,
273
+ ReturnType<typeof decideCompaction>
274
+ >()
275
+ private readonly inflight = new Set<string>()
276
+ private readonly promptSeen = new Set<string>()
277
+ private readonly created = new Map<string, number>()
278
+ private readonly stepStarted = new Map<string, number>()
279
+ private readonly contextLoaded = new Set<string>()
280
+ private readonly contextInflight = new Map<string, Promise<void>>()
281
+ /** What credential detection found on this machine, for the first-run screen. */
282
+ private detectedProviders: ReadonlyArray<{ providerID: string; hasCredential: boolean; source?: string }> = []
283
+ /** Set once the user has seen the first-run screen. */
284
+ private onboardingComplete = false
285
+ private readonly explicitProviderEnable: ReadonlySet<string>
286
+ private readonly titleQueue = new Map<string, Promise<void>>()
287
+ private readonly notifier = new ChangeNotifier()
288
+ private rpcRegistration: any
289
+ private readonly registrations: any[] = []
290
+ private readonly dirCache = new Map<string, string>()
291
+ private readonly localDir: string
292
+ private cleanupDone = false
293
+
294
+ constructor(options: ControllerOptions) {
295
+ this.ctx = options.ctx
296
+ this.localDir = this.canonicalDir(options.ctx.location?.directory)
297
+ const loaded = loadSettings({
298
+ options: options.pluginOptions,
299
+ directory: this.ctx.location?.directory,
300
+ projectDirectory: this.ctx.location?.directory,
301
+ })
302
+ this.settingsBase = loaded.settings
303
+ this.settingsWarnings = loaded.warnings
304
+ this.settingsSources = loaded.sources
305
+ // Which provider enable/disable flags the user wrote themselves. Detection
306
+ // fills in the ones they never mentioned and never overrules these.
307
+ this.explicitProviderEnable = loaded.explicitProviderEnable
308
+ this.ledger = new LedgerStore()
309
+
310
+ // A plan id that matches nothing must never resolve silently to whatever
311
+ // tier this repository defaults to: that could overstate the allowance a
312
+ // routing decision is allowed to spend.
313
+ for (const [providerID, provider] of Object.entries(loaded.settings.providers)) {
314
+ if (!provider?.enabled) continue
315
+ const resolved = resolvePlanWithReason(providerID, provider.plan)
316
+ if (resolved.resolution === "unknown") {
317
+ this.settingsWarnings.push(
318
+ `providers.${providerID}.plan is "${provider.plan}", which is not a known plan for ${providerID}. Using ${resolved.planID ?? "no plan"}, so quota may be miscalculated. Set a plan id from the catalog.`,
319
+ )
320
+ }
321
+ }
322
+ }
323
+
324
+ private get settings(): RouterSettings {
325
+ return applyRuntime(this.settingsBase, this.runtime)
326
+ }
327
+
328
+ /**
329
+ * The runtime mode for one session.
330
+ *
331
+ * A session can be paused on its own, without touching any other session and
332
+ * without disabling the plugin. `SessionState.paused` existed but nothing ever
333
+ * read it, so the switch in the panel would have done nothing. Keeping the
334
+ * rule in one place is what stops the prompt path, the status panel and the
335
+ * compaction path from disagreeing about whether a session is routed.
336
+ */
337
+ private runtimeFor(session: SessionState | undefined): RuntimeMode {
338
+ if (!this.settings.enabled) return "paused"
339
+ if (this.runtime.paused === true) return "paused"
340
+ if (session?.paused === true) return "paused"
341
+ return session?.manualOverride ? "manual" : "automatic"
342
+ }
343
+
344
+ /** RPC and storage require plain JSON values; optional undefined is not JSON. */
345
+ private safe<T>(value: T): any {
346
+ return JSON.parse(JSON.stringify(value))
347
+ }
348
+
349
+ private persistLedgerSoon() {
350
+ sharedCore.persistSoon()
351
+ }
352
+
353
+ private persistRuntime() {
354
+ sharedCore.runtime = this.runtime
355
+ sharedCore.persistRuntimeSoon()
356
+ }
357
+
358
+ private async emit(name: "decision" | "status" | "quota", data: Record<string, unknown>) {
359
+ try {
360
+ await this.rpcRegistration?.events.emit(name, this.safe(data))
361
+ } catch {
362
+ // Events are advisory; a missing GUI connection must not affect routing.
363
+ }
364
+ }
365
+
366
+ private async loadModels() {
367
+ try {
368
+ this.modelList = await sharedCore.getLiveModels()
369
+ this.rebuildRegistry()
370
+ } catch (error) {
371
+ this.settingsWarnings.push(`Could not refresh the live model catalog: ${safeError(error)}`)
372
+ }
373
+ }
374
+
375
+ private rebuildRegistry() {
376
+ const live = unwrap<any>(this.modelList ?? [])
377
+ this.registry = buildRegistry({
378
+ live,
379
+ settings: this.settings,
380
+ definitions: this.settings.models.definitions as any,
381
+ })
382
+ }
383
+
384
+ private modelList: unknown = []
385
+ /** Shared in-flight discovery; see ensureModelsLoaded(). */
386
+ private modelLoadPromise?: Promise<void>
387
+
388
+ async initialize(): Promise<() => void | Promise<void>> {
389
+ // Before anything reads settings: a provider the user never configured
390
+ // should be on only if this machine can actually reach it.
391
+ await this.applyProviderDetection()
392
+ await sharedCore.init({
393
+ ctx: this.ctx,
394
+ getSettings: () => this.settings,
395
+ getCredentials: () => this.resolveQuotaCredentials(),
396
+ })
397
+ this.ledger = sharedCore.ledger
398
+ this.runtime = sharedCore.runtime
399
+ const quota = sharedCore.quota
400
+ if (!quota) throw new Error("Shared quota service is not initialized")
401
+ this.quota = quota
402
+ this.bridgeID = sharedCore.addBridge(this.ctx.location?.directory ?? "", this)
403
+ this.modelList = []
404
+ this.rebuildRegistry()
405
+
406
+ // The shared core owns quota probes, session bootstrap, and ledger writes.
407
+ // Register the user-facing surface without awaiting any network work.
408
+ await this.registerRpc()
409
+ await this.registerCommands()
410
+ await this.registerHooks()
411
+ void this.loadModels()
412
+
413
+ return () => this.dispose()
414
+ }
415
+
416
+ /**
417
+ * Decide which providers this machine can actually reach.
418
+ *
419
+ * Runs before the shared core reads settings. A provider the user never
420
+ * mentioned is enabled only when a credential or connection exists for it, so
421
+ * a stranger stops getting "authentication is failing" for an account they
422
+ * never claimed. A provider the user did mention is left exactly as they set
423
+ * it, and any disagreement is reported rather than acted on.
424
+ *
425
+ * Nothing here reads a secret. Existence is enough to answer the question.
426
+ */
427
+ private async applyProviderDetection(): Promise<void> {
428
+ try {
429
+ const evidence: ProviderEvidence[] = []
430
+
431
+ const connected = async (integration: string): Promise<boolean> => {
432
+ try {
433
+ const connection = await this.ctx.integration.connection.active(integration as never)
434
+ return Boolean(connection)
435
+ } catch {
436
+ return false
437
+ }
438
+ }
439
+
440
+ const providerKey = async (providerID: string): Promise<boolean> => {
441
+ try {
442
+ const provider: any = await this.ctx.provider.get({ providerID })
443
+ const info = provider?.data ?? provider
444
+ return typeof info?.settings?.apiKey === "string" && info.settings.apiKey.length > 0
445
+ } catch {
446
+ return false
447
+ }
448
+ }
449
+
450
+ const fileOrEnv = (providerID: string): { has: boolean; source?: string } => {
451
+ for (const name of credentialEnvVars(providerID)) {
452
+ if (process.env[name]?.trim()) return { has: true, source: name }
453
+ }
454
+ for (const path of credentialFileCandidates(providerID, homedir())) {
455
+ try {
456
+ if (existsSync(path)) return { has: true, source: path.replace(homedir(), "~") }
457
+ } catch {
458
+ // Unreadable path is not evidence either way.
459
+ }
460
+ }
461
+ return { has: false }
462
+ }
463
+
464
+ const goConnected = await connected("opencode")
465
+ if (goConnected) evidence.push({ providerID: "opencode-go", hasCredential: true, source: "OpenCode connection" })
466
+
467
+ const commandCodeConnection = await connected("commandcode")
468
+ const commandCodeFiles = fileOrEnv("commandcode")
469
+ const commandCodeKey = await providerKey("commandcode")
470
+ evidence.push({
471
+ providerID: "commandcode",
472
+ hasCredential: commandCodeConnection || commandCodeFiles.has || commandCodeKey,
473
+ ...(commandCodeConnection
474
+ ? { source: "Command Code integration" }
475
+ : commandCodeFiles.source
476
+ ? { source: commandCodeFiles.source }
477
+ : commandCodeKey
478
+ ? { source: "OpenCode provider settings" }
479
+ : {}),
480
+ })
481
+
482
+ const openDesignFiles = fileOrEnv("opendesign")
483
+ const openDesignKey = await providerKey("opendesign")
484
+ evidence.push({
485
+ providerID: "opendesign",
486
+ hasCredential: openDesignFiles.has || openDesignKey,
487
+ ...(openDesignFiles.source
488
+ ? { source: openDesignFiles.source }
489
+ : openDesignKey
490
+ ? { source: "OpenCode provider settings" }
491
+ : {}),
492
+ })
493
+
494
+ // Keep the evidence: onboarding shows what was found, not a form.
495
+ this.detectedProviders = evidence
496
+ const result = applyCredentialDefaults(this.settingsBase, this.explicitProviderEnable, evidence)
497
+ if (result.settings !== this.settingsBase) {
498
+ this.settingsBase = result.settings
499
+ this.rebuildRegistry()
500
+ }
501
+ for (const line of describeEnablement(result)) {
502
+ if (!this.settingsWarnings.includes(line)) this.settingsWarnings.push(line)
503
+ }
504
+ for (const providerID of result.keptOnByChoice) {
505
+ const line = `providers.${providerID} stays enabled with no credential found — you asked for it`
506
+ if (!this.settingsWarnings.includes(line)) this.settingsWarnings.push(line)
507
+ }
508
+ for (const providerID of result.keptOffByChoice) {
509
+ const line = `providers.${providerID} credentials found but the provider stays disabled — you asked for it`
510
+ if (!this.settingsWarnings.includes(line)) this.settingsWarnings.push(line)
511
+ }
512
+ } catch (error) {
513
+ // Detection is a convenience. A failure must never stop routing.
514
+ this.settingsWarnings.push(`Provider detection skipped: ${safeError(error)}`)
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Everything a first-run screen needs, in one call.
520
+ *
521
+ * The shape is deliberate: evidence first, then a single action. A new user
522
+ * should be able to read this and know three things without clicking — what
523
+ * the router found on their machine, what it will do with it, and whether it
524
+ * needs anything from them (it usually does not).
525
+ */
526
+ private onboardingState(): {
527
+ firstRun: boolean
528
+ providers: ReadonlyArray<{ providerID: string; label: string; found: boolean; source?: string; enabled: boolean }>
529
+ historyImported: number
530
+ hasHistory: boolean
531
+ needsReading: boolean
532
+ mode: string
533
+ version: string
534
+ } {
535
+ const enabled = this.settings.providers
536
+ const providers = this.detectedProviders.map((entry) => ({
537
+ providerID: entry.providerID,
538
+ label: entry.providerID === "opencode-go" ? "OpenCode Go" : entry.providerID === "commandcode" ? "Command Code GOAT" : entry.providerID === "opendesign" ? "OpenDesign" : entry.providerID,
539
+ found: entry.hasCredential,
540
+ ...(entry.source ? { source: entry.source } : {}),
541
+ enabled: enabled[entry.providerID]?.enabled ?? false,
542
+ }))
543
+
544
+ const imported = this.ledger
545
+ .usageEvents()
546
+ .filter((event) => String(event.id).startsWith("hist:"))
547
+ return {
548
+ // First run is "nothing has been chosen yet": no baseline, no explicit
549
+ // provider flags. Reading a file nobody wrote is not a first run.
550
+ firstRun: !this.onboardingComplete && this.explicitProviderEnable.size === 0,
551
+ providers,
552
+ historyImported: imported.length,
553
+ hasHistory: imported.length > 0,
554
+ needsReading: this.quotaSnapshots().some((snapshot) => snapshot.source === "estimate"),
555
+ mode: this.settings.mode,
556
+ version: "0.2.0",
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Record what the account page showed.
562
+ *
563
+ * The whole point is that the user pastes what they see. They should not have
564
+ * to know that Pro's seven-day window is $90, or divide 42% by anything — the
565
+ * server owns the caps, so the server does the arithmetic.
566
+ *
567
+ * Writes through config-writer, which preserves every other key, validates the
568
+ * result and keeps a backup, so a mistake here is recoverable.
569
+ */
570
+ /**
571
+ * Read whatever console page was pasted.
572
+ *
573
+ * One entry point for both page types, because the user should not have to
574
+ * decide which page we want. An allowance page seeds the estimate; a usage
575
+ * page checks the cost model against what was actually charged. Both preview
576
+ * before writing, because a paste is not a promise that it was understood.
577
+ */
578
+ private async recordPlanPage(input: {
579
+ page?: string
580
+ confirm?: boolean
581
+ }): Promise<{
582
+ ok: boolean
583
+ message: string
584
+ preview?: {
585
+ kind: "allowance" | "usage"
586
+ summary: string
587
+ found: string[]
588
+ missing: string[]
589
+ /** For a usage page: whether the catalog agrees with what was charged. */
590
+ reconciliation?: { summary: string; agree: number; differ: number; rows: ReadonlyArray<unknown> }
591
+ apply: Record<string, unknown>
592
+ }
593
+ backup?: string
594
+ }> {
595
+ const reading = parseConsolePage(input.page ?? "")
596
+ if (!reading) {
597
+ return {
598
+ ok: false,
599
+ message:
600
+ "That page could not be read. Paste either the Plan & balance page or the Usage page, including its model table.",
601
+ }
602
+ }
603
+
604
+ if (reading.kind === "usage") {
605
+ const reconciliation = reconcileUsage(reading as UsageReading)
606
+ const summary = describeConsoleReading(reading)
607
+ // A usage page tells us whether our arithmetic is right. It does not tell
608
+ // us how much allowance is left, so nothing is written from it — writing a
609
+ // baseline from a token table would be inventing a percentage.
610
+ return {
611
+ ok: true,
612
+ message: reconciliation.differ > 0 ? `${summary} ${reconciliation.summary}` : summary,
613
+ preview: {
614
+ kind: "usage",
615
+ summary,
616
+ found: [...reading.found],
617
+ missing: [...reading.missing],
618
+ reconciliation: {
619
+ summary: reconciliation.summary,
620
+ agree: reconciliation.agree,
621
+ differ: reconciliation.differ,
622
+ rows: [...reconciliation.rows] as ReadonlyArray<unknown>,
623
+ },
624
+ apply: {},
625
+ },
626
+ }
627
+ }
628
+
629
+ if (reading.designPlanUsedPct === undefined) {
630
+ return { ok: false, message: describeConsoleReading(reading) }
631
+ }
632
+
633
+ const at = Date.now()
634
+ const baseline: Record<string, unknown> = {
635
+ at,
636
+ designPlanUsedPct: reading.designPlanUsedPct,
637
+ pctOf: reading.pctOf ?? "weekly",
638
+ }
639
+ if (reading.resetsAt !== undefined) baseline.weeklyResetsAt = reading.resetsAt
640
+ if (reading.periodEndsAt !== undefined) baseline.monthlyResetsAt = reading.periodEndsAt
641
+
642
+ /**
643
+ * Pasting a balance is an opt-in.
644
+ *
645
+ * Nobody records their OpenDesign usage in order to leave it switched off, so
646
+ * the write states `enabled: true` explicitly. That also makes the intent
647
+ * survive: an explicit flag is something detection will never overrule, which
648
+ * is the correct relationship between a config file and a guess.
649
+ */
650
+ const patch: Record<string, unknown> = { providers: { opendesign: { baseline, enabled: true } } }
651
+ if (reading.plan && resolvePlan("opendesign", reading.plan.toLowerCase())) {
652
+ ;(patch.providers as any).opendesign.plan = reading.plan.toLowerCase()
653
+ }
654
+
655
+ const preview = {
656
+ kind: "allowance" as const,
657
+ summary: describeConsoleReading(reading),
658
+ found: [...reading.found],
659
+ missing: [...reading.missing],
660
+ apply: patch,
661
+ }
662
+
663
+ if (input.confirm !== true) {
664
+ return { ok: true, message: preview.summary, preview }
665
+ }
666
+
667
+ try {
668
+ const result = writeSettingsPatch({
669
+ patch,
670
+ validate: (merged) => {
671
+ const loaded = validateSettings(merged)
672
+ if (loaded.settings.providers.opendesign?.baseline === undefined) {
673
+ return "That reading could not be applied. Check the page and try again."
674
+ }
675
+ return undefined
676
+ },
677
+ })
678
+ await this.reloadSettingsFromDisk()
679
+ return {
680
+ ok: true,
681
+ message: `Recorded. ${preview.summary}.`,
682
+ preview,
683
+ ...(result.backup ? { backup: result.backup } : {}),
684
+ }
685
+ } catch (error) {
686
+ return { ok: false, message: safeError(error) }
687
+ }
688
+ }
689
+
690
+ /** Re-read settings after the GUI writes, so the panel reflects the change at once. */
691
+ private async reloadSettingsFromDisk(): Promise<void> {
692
+ const loaded = loadSettings({
693
+ options: this.ctx.options,
694
+ directory: this.ctx.location?.directory,
695
+ projectDirectory: this.ctx.location?.directory,
696
+ })
697
+ this.settingsBase = loaded.settings
698
+ this.settingsWarnings = loaded.warnings
699
+ this.settingsSources = loaded.sources
700
+
701
+ /**
702
+ * Detection must run again after a reload.
703
+ *
704
+ * Without this, writing any config fragment that omits a provider's
705
+ * `enabled` flag silently disables it: the file no longer says "enabled",
706
+ * DEFAULT_SETTINGS says disabled, and the only thing that had been turning it
707
+ * back on was startup detection. The visible symptom was a provider showing
708
+ * as disabled immediately after the user pasted their balance into it.
709
+ */
710
+ await this.applyProviderDetection()
711
+ this.rebuildRegistry()
712
+ // Snapshots in the cache were computed from the settings that just changed,
713
+ // so they must go before the re-probe rather than being served as stale.
714
+ this.quota?.invalidate()
715
+ // Re-probe what can be probed, so the panel reflects the new reading at once.
716
+ await Promise.allSettled([
717
+ this.quota?.refresh("opencode-go", true),
718
+ this.quota?.refresh("commandcode", true),
719
+ this.quota?.refresh("opendesign", true),
720
+ ])
721
+ }
722
+
723
+ /**
724
+ * When this provider's next window opens, from the best data available.
725
+ *
726
+ * Prefers the tightest window that has a reset time, because that is the one
727
+ * that would unblock the account soonest. Falls back to a modest hold when
728
+ * nothing is known, which is far better than re-probing on every prompt.
729
+ */
730
+ private providerResetAt(providerID: string, now = Date.now()): number {
731
+ const fallback = now + QUOTA_EXHAUSTED_FALLBACK_MS
732
+ const snapshot = this.quotaSnapshots().find((entry) => entry.providerID === providerID)
733
+ if (!snapshot) return fallback
734
+ const resets = [snapshot.rolling?.resetsAt, snapshot.weekly?.resetsAt, snapshot.monthly?.resetsAt]
735
+ .filter((value): value is number => typeof value === "number" && value > now)
736
+ .sort((left, right) => left - right)
737
+ return resets[0] ?? fallback
738
+ }
739
+
740
+ private async resolveQuotaCredentials() { let goAccessToken: string | undefined
741
+ let commandCodeIntegrationKey: string | undefined
742
+ try {
743
+ const connection = await this.ctx.integration.connection.active("opencode")
744
+ if (connection) {
745
+ const credential: any = await this.ctx.integration.connection.resolve(connection)
746
+ goAccessToken = credential?.type === "oauth" ? credential.access : credential?.type === "key" ? credential.key : undefined
747
+ }
748
+ } catch {
749
+ // The Go connection is optional.
750
+ }
751
+ try {
752
+ const connection = await this.ctx.integration.connection.active("commandcode")
753
+ if (connection) {
754
+ const credential: any = await this.ctx.integration.connection.resolve(connection)
755
+ commandCodeIntegrationKey = credential?.type === "key" ? credential.key : credential?.type === "oauth" ? credential.access : undefined
756
+ }
757
+ } catch {
758
+ // The Command Code integration may not be connected; file discovery remains.
759
+ }
760
+ let commandCodeProviderKey: string | undefined
761
+ try {
762
+ const provider = await this.ctx.provider.get({ providerID: "commandcode" })
763
+ const info: any = (provider as any)?.data ?? provider
764
+ commandCodeProviderKey = info?.settings?.apiKey
765
+ } catch {
766
+ // Ignore.
767
+ }
768
+ const { openDesignApiKey, openDesignControlKey } = await this.resolveOpenDesignCredentials()
769
+ return {
770
+ goAccessToken,
771
+ commandCodeIntegrationKey,
772
+ commandCodeProviderKey,
773
+ openDesignApiKey,
774
+ openDesignControlKey,
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Resolve the OpenDesign inference key and, only when the owner configured
780
+ * one explicitly, the account-control key.
781
+ *
782
+ * The inference key can call the model gateway but cannot read quota. The
783
+ * control key can read the account's usage windows and is a privileged
784
+ * credential, so it is never discovered automatically and never requested.
785
+ */
786
+ private async resolveOpenDesignCredentials(): Promise<{ openDesignApiKey?: string; openDesignControlKey?: string }> {
787
+ const providerSettings = this.settings.providers.opendesign
788
+ if (!providerSettings?.enabled) return {}
789
+
790
+ let providerSettingsKey: string | undefined
791
+ try {
792
+ const provider: any = await this.ctx.provider.get({ providerID: "opendesign" })
793
+ const info: any = provider?.data ?? provider
794
+ providerSettingsKey = info?.settings?.apiKey
795
+ } catch {
796
+ // The provider may not be configured in OpenCode; file discovery remains.
797
+ }
798
+
799
+ const discovered = discoverOpenDesignCredentials({
800
+ explicitPath: providerSettings.credentialPath,
801
+ providerSettingsKey,
802
+ credentialSource: providerSettings.credentialSource,
803
+ })
804
+ for (const warning of discovered.warnings) {
805
+ if (!this.settingsWarnings.includes(warning)) this.settingsWarnings.push(warning)
806
+ }
807
+
808
+ const controls = discoverOpenDesignControlKey({
809
+ explicitPath: providerSettings.controlKeyPath,
810
+ })
811
+ for (const warning of controls.warnings) {
812
+ if (!this.settingsWarnings.includes(warning)) this.settingsWarnings.push(warning)
813
+ }
814
+
815
+ return {
816
+ openDesignApiKey: discovered.candidates[0]?.key,
817
+ openDesignControlKey: controls.candidates[0]?.key,
818
+ }
819
+ }
820
+
821
+ private sessionState(sessionID: string): SessionState {
822
+ return this.ledger.ensureSession(sessionID)
823
+ }
824
+
825
+ private canonicalDir(value?: string): string {
826
+ if (!value) return ""
827
+ const cached = this.dirCache.get(value)
828
+ if (cached) return cached
829
+ let out = resolve(value)
830
+ if (out.startsWith("/private/")) out = out.slice("/private".length)
831
+ if (this.dirCache.size >= 500) {
832
+ const oldest = this.dirCache.keys().next().value
833
+ if (oldest) this.dirCache.delete(oldest)
834
+ }
835
+ this.dirCache.set(value, out)
836
+ return out
837
+ }
838
+
839
+ /**
840
+ * A location owns a session when the project ids match, or when the
841
+ * session's directory is the location directory or inside it. The second
842
+ * check matters when a location is opened at a subdirectory of a project:
843
+ * the session then carries the parent project id but still belongs here.
844
+ */
845
+ projectMatches(projectID?: string, directory?: string) {
846
+ const localID = this.ctx.location?.project?.id
847
+ if (projectID && localID && projectID === localID) return true
848
+ const remote = this.canonicalDir(directory)
849
+ if (this.localDir && remote) {
850
+ return remote === this.localDir || remote.startsWith(`${this.localDir}/`)
851
+ }
852
+ return !projectID || !localID
853
+ }
854
+
855
+ private ownsSession(session: SessionState) {
856
+ if (session.directory) return this.projectMatches(undefined, session.directory)
857
+ const localID = this.ctx.location?.project?.id
858
+ return !localID || !session.projectID || session.projectID === localID
859
+ }
860
+
861
+ private remember(set: Set<string>, id: string, max = 1_000) {
862
+ if (!set.has(id) && set.size >= max) {
863
+ const oldest = set.values().next().value
864
+ if (oldest) set.delete(oldest)
865
+ }
866
+ set.add(id)
867
+ }
868
+
869
+ private async loadSessionInfo(sessionID: string) {
870
+ try {
871
+ const info: any = await this.ctx.session.get({ sessionID })
872
+ if (!this.projectMatches(info?.projectID, info?.location?.directory)) return
873
+ sharedCore.claimSession(sessionID, this.bridgeID)
874
+ const model = modelRefFrom(info?.model)
875
+ const parentID = typeof info?.parentID === "string" ? info.parentID : undefined
876
+ const title = typeof info?.title === "string" ? info.title : undefined
877
+ const session = this.ledger.ensureSession(sessionID, parentID, title)
878
+ if (info?.projectID) this.ledger.patchSession(sessionID, { projectID: String(info.projectID) })
879
+ if (info?.location?.directory) {
880
+ this.ledger.patchSession(sessionID, { directory: String(info.location.directory) })
881
+ }
882
+ const baseTitle = stripTitlePrefix(typeof info?.title === "string" ? info.title : title)
883
+ if (baseTitle && !session.baseTitle) {
884
+ this.ledger.patchSession(sessionID, { baseTitle })
885
+ }
886
+ if (model) {
887
+ this.ledger.patchSession(sessionID, {
888
+ lastModel: model,
889
+ ...(session.manualOverride ? { manualModel: model } : {}),
890
+ })
891
+ }
892
+ // The context size decides which providers may serve this session, so
893
+ // it has to be the size of the transcript the next request carries.
894
+ //
895
+ // `info.tokens` is the session's CUMULATIVE usage across its whole
896
+ // life, not that size. It grows without bound, so it can only ever be
897
+ // an upper bound, and using it makes a session look far larger than it
898
+ // is - which pushes it off providers whose window it would actually
899
+ // fit in. The transcript read below gives the real number.
900
+ if (session.contextTokens === 0) {
901
+ const real = await this.readCurrentContext(sessionID)
902
+ if (real !== undefined) {
903
+ this.ledger.patchSession(sessionID, {
904
+ contextTokens: real.contextTokens,
905
+ cacheReadTokens: real.cacheReadTokens,
906
+ })
907
+ } else {
908
+ // The read failed. Assume a modest transcript rather than
909
+ // inventing a number that would wrongly exclude a model.
910
+ const created = Number(info?.time?.created) || 0
911
+ const updated = Number(info?.time?.updated) || 0
912
+ let estimate = 0
913
+ if (info?.fork) {
914
+ // A fork inherits a transcript but may carry no cumulative usage.
915
+ estimate = 350_000
916
+ } else if (created > 0 && updated - created > 60_000) {
917
+ estimate = 150_000
918
+ }
919
+ if (estimate > 0) {
920
+ this.ledger.patchSession(sessionID, { contextTokens: estimate, cacheReadTokens: 0 })
921
+ }
922
+ }
923
+ }
924
+ } catch {
925
+ // The prompt can still be routed from ledger state if the read fails.
926
+ }
927
+ }
928
+
929
+ private indicatorFor(sessionID: string, session: SessionState | undefined): Indicator {
930
+ const settings = this.settings
931
+ const state = session?.indicatorTitle?.startsWith("BLOCKED ·")
932
+ ? "BLOCKED"
933
+ : indicatorState({
934
+ enabled: settings.enabled,
935
+ runtimePaused: this.runtime.paused === true,
936
+ manual: session?.manualOverride === true,
937
+ dryRun: settings.dryRun,
938
+ })
939
+ const ref = session?.pinnedModel ?? session?.manualModel ?? session?.lastModel
940
+ const model = ref ? this.registry.get(ref.providerID, ref.id) : undefined
941
+ return {
942
+ state,
943
+ ...(ref ? { provider: providerShort(ref.providerID), model: ref.id } : {}),
944
+ ...(model ? { modelLabel: model.name } : ref ? { modelLabel: ref.id } : {}),
945
+
946
+ }
947
+ }
948
+
949
+ private queueTitle(sessionID: string) {
950
+ const previous = this.titleQueue.get(sessionID) ?? Promise.resolve()
951
+ const next = previous
952
+ .catch(() => undefined)
953
+ .then(() => this.applyTitleNow(sessionID))
954
+ .catch(() => undefined)
955
+ .finally(() => {
956
+ if (this.titleQueue.get(sessionID) === next) this.titleQueue.delete(sessionID)
957
+ })
958
+ this.titleQueue.set(sessionID, next)
959
+ return next
960
+ }
961
+
962
+ /**
963
+ * The router never renames a session.
964
+ *
965
+ * A chat title is the owner's name for the work, and a plugin that rewrites it
966
+ * to advertise which model it picked trades the owner's memory for the
967
+ * plugin's status line. Status lives in the panel, the terminal footer and the
968
+ * decision record instead.
969
+ *
970
+ * One exception remains, and it only runs against titles this plugin wrote in
971
+ * an older version: a single pass that strips the prefix when the session is
972
+ * next touched, so upgrades heal instead of leaving `AUTO · OD · model`
973
+ * forever.
974
+ */
975
+ private async applyTitleNow(sessionID: string) {
976
+ const session = this.ledger.getSession(sessionID)
977
+ if (!session) return
978
+ let info: any
979
+ try {
980
+ info = await this.ctx.session.get({ sessionID })
981
+ } catch {
982
+ info = undefined
983
+ }
984
+ if (!this.projectMatches(info?.projectID, info?.location?.directory)) return
985
+ const currentTitle = typeof info?.title === "string" ? info.title : undefined
986
+
987
+ {
988
+ /**
989
+ * The indicator is off. If a previous version of this plugin left its
990
+ * prefix on the title, put the name back once, the first time the session
991
+ * is touched. Without this, upgrading would leave every old chat reading
992
+ * "AUTO · OD · Model — name" forever, because the prefix lives in
993
+ * OpenCode's database and the router no longer rewrites it.
994
+ */
995
+ const stripped = stripTitlePrefix(currentTitle)
996
+ if (currentTitle && stripped && stripped !== currentTitle.trim()) {
997
+ try {
998
+ await this.ctx.session.update({ sessionID, title: stripped })
999
+ this.ledger.patchSession(sessionID, { title: stripped, indicatorTitle: undefined })
1000
+ } catch {
1001
+ // Leaving the prefix is better than failing the prompt over a title.
1002
+ }
1003
+ }
1004
+ return
1005
+ }
1006
+
1007
+ }
1008
+
1009
+ /** Strips prefixes this plugin wrote. Returns how many titles changed. */
1010
+ private async restoreTitles(): Promise<number> {
1011
+ let restored = 0
1012
+ for (const session of Object.values(this.ledger.data.sessions)) {
1013
+ if (!this.ownsSession(session)) continue
1014
+ const visible = session.title ?? session.indicatorTitle
1015
+ const base = session.baseTitle || stripTitlePrefix(visible)
1016
+ // A title that is only a prefix has no name left to restore. Rewriting it
1017
+ // to the prefix again would keep the damage; leave it for the owner.
1018
+ if (!base || base === visible) continue
1019
+ try {
1020
+ await this.ctx.session.update({ sessionID: session.sessionID, title: base })
1021
+ this.ledger.patchSession(session.sessionID, { title: base, indicatorTitle: undefined })
1022
+ restored += 1
1023
+ } catch (error) {
1024
+ this.settingsWarnings.push(`Could not restore session title: ${safeError(error)}`)
1025
+ }
1026
+ }
1027
+ return restored
1028
+ }
1029
+
1030
+ private async notifyChange(key: string, message: string) {
1031
+ if (!this.settings.ui.notifications) return
1032
+ if (!this.notifier.shouldNotify(key)) return
1033
+ await this.notifier.notify(message)
1034
+ }
1035
+
1036
+ private modelNotice(sessionID: string, state: string, ref: ModelRef | undefined, reason?: string) {
1037
+ if (!ref) return
1038
+ const model = this.registry.get(ref.providerID, ref.id)
1039
+ const label = `${providerShort(ref.providerID)} · ${model?.name ?? ref.id}`
1040
+ const detail = reason ? ` — ${reason}` : ""
1041
+ void this.notifyChange(
1042
+ `${sessionID}:${state}:${ref.providerID}:${ref.id}:${reason ?? ""}`,
1043
+ `${state} · ${label}${detail}`,
1044
+ )
1045
+ }
1046
+
1047
+ /**
1048
+ * Measured generation speed per model, for the routing engine.
1049
+ *
1050
+ * Only models with enough samples are passed; the engine applies its own
1051
+ * minimum, so a single fast request cannot promote a model on noise.
1052
+ */
1053
+ private speedSnapshot(): Record<string, { tokensPerSecond: number; samples: number }> {
1054
+ const out: Record<string, { tokensPerSecond: number; samples: number }> = {}
1055
+ for (const [key, metrics] of Object.entries(this.ledger.snapshotMetrics().byModel)) {
1056
+ if (metrics.outputTokensPerSecond <= 0 || metrics.latencySamples <= 0) continue
1057
+ out[key] = {
1058
+ tokensPerSecond: metrics.outputTokensPerSecond,
1059
+ samples: metrics.latencySamples,
1060
+ }
1061
+ }
1062
+ return out
1063
+ }
1064
+
1065
+ private premiumState(session?: SessionState): PremiumState {
1066
+ // Reading budget state must never be able to fail a prompt or a status
1067
+ // request. Every value here degrades to a conservative default, and a
1068
+ // ledger that cannot answer is reported as unknown rather than thrown.
1069
+ const read = <T>(fn: () => T, fallback: T): T => {
1070
+ try {
1071
+ const value = fn()
1072
+ return value === undefined || value === null ? fallback : value
1073
+ } catch {
1074
+ return fallback
1075
+ }
1076
+ }
1077
+ const spent = read(() => this.ledger.premiumSpent(), 0)
1078
+ const budget = this.settings.premium.monthlyBudgetUsd
1079
+ // The plan-model pool is the escalation budget of record. Its cap comes
1080
+ // from the resolved plan catalog, not from premium.monthlyBudgetUsd, so
1081
+ // the gate tracks the allowance the account actually has.
1082
+ const planPoolMonthlyUsd = this.planPoolMonthlyUsd()
1083
+ const planPoolSpent = read(
1084
+ () => (typeof this.ledger.planPoolSpent === "function" ? this.ledger.planPoolSpent() : undefined),
1085
+ undefined as number | undefined,
1086
+ )
1087
+ return {
1088
+ mode: this.settings.premium.mode,
1089
+ monthlyBudgetUsd: budget,
1090
+ spentUsd: spent,
1091
+ remainingUsd: Math.max(0, budget - spent),
1092
+ ...(planPoolMonthlyUsd !== undefined && planPoolSpent !== undefined
1093
+ ? { planPoolRemainingUsd: Math.max(0, planPoolMonthlyUsd - planPoolSpent) }
1094
+ : {}),
1095
+ oneShotGranted: session?.premiumOneShot === true,
1096
+ ...(session?.premiumOneShotAt ? { oneShotGrantedAt: session.premiumOneShotAt } : {}),
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * The monthly cap of the OpenDesign plan-model pool for the configured plan,
1102
+ * when the model pool is the one escalation draws from. Undefined for any
1103
+ * other configuration, which leaves the gate on the legacy premium budget.
1104
+ */
1105
+ private planPoolMonthlyUsd(): number | undefined {
1106
+ const provider = this.settings.providers.opendesign
1107
+ if (!provider?.enabled) return undefined
1108
+ const plan = resolvePlan("opendesign", provider.plan)
1109
+ if (!plan) return undefined
1110
+ return plan.pools.find((pool) => pool.id === OPENDESIGN_PLAN_MODEL_POOL)?.monthlyUsd
1111
+ }
1112
+
1113
+ private quotaSnapshots() {
1114
+ return this.quota
1115
+ ? this.quota.read().map((snapshot) => {
1116
+ const providerSettings = this.settings.providers[snapshot.providerID]
1117
+ // A provider-scoped allowance is one shared pool. Per-model quota
1118
+ // snapshots would count the same pool once per model.
1119
+ if (!providerSettings || providerSettings.providerScopedQuota) return snapshot
1120
+ return this.ledger.attachModelQuotas(snapshot, this.registry.all(), providerSettings)
1121
+ })
1122
+ : []
1123
+ }
1124
+
1125
+ private async ensureContextTokens(sessionID: string) {
1126
+ if (this.contextLoaded.has(sessionID)) return
1127
+ const existing = this.contextInflight.get(sessionID)
1128
+ if (existing) return existing
1129
+ const task = (async () => {
1130
+ try {
1131
+ const real = await this.readCurrentContext(sessionID)
1132
+ if (real) {
1133
+ this.sessionState(sessionID)
1134
+ this.ledger.patchSession(sessionID, {
1135
+ contextTokens: real.contextTokens,
1136
+ cacheReadTokens: real.cacheReadTokens,
1137
+ })
1138
+ }
1139
+ } catch {
1140
+ // A context read failure must not block prompt admission.
1141
+ } finally {
1142
+ this.remember(this.contextLoaded, sessionID, 2_000)
1143
+ this.contextInflight.delete(sessionID)
1144
+ }
1145
+ })()
1146
+ this.contextInflight.set(sessionID, task)
1147
+ return task
1148
+ }
1149
+
1150
+ /**
1151
+ * Read a session's real context size from the host's transcript.
1152
+ *
1153
+ * The authoritative value is the last assistant step: its cache-read
1154
+ * count is the size of the transcript the next request will carry. A
1155
+ * compaction message's own counts describe the transcript it replaced, so
1156
+ * those are not used.
1157
+ *
1158
+ * Returns undefined when the transcript cannot be read.
1159
+ */
1160
+ private async readCurrentContext(
1161
+ sessionID: string,
1162
+ ): Promise<{ contextTokens: number; cacheReadTokens: number } | undefined> {
1163
+ try {
1164
+ const messages = unwrap<any>(
1165
+ await Promise.race([
1166
+ this.ctx.session.context({ sessionID }),
1167
+ new Promise((resolve) => setTimeout(() => resolve({ data: [] }), 5_000)),
1168
+ ]),
1169
+ )
1170
+ const assistant = [...messages]
1171
+ .reverse()
1172
+ .find((message: any) => message?.type === "assistant" && message?.tokens)
1173
+ if (!assistant?.tokens) return undefined
1174
+ const tokens = tokensFrom(assistant.tokens)
1175
+ const contextTokens = tokens.input + tokens.cache.read + tokens.cache.write
1176
+ if (contextTokens <= 0) return undefined
1177
+ return { contextTokens, cacheReadTokens: tokens.cache.read }
1178
+ } catch {
1179
+ return undefined
1180
+ }
1181
+ }
1182
+
1183
+ /**
1184
+ * Re-read a session's real context size right after a compaction.
1185
+ *
1186
+ * A compaction rebuilds the transcript, so the size the router was
1187
+ * carrying is stale. Learning the new size now - rather than at the next
1188
+ * prompt - means the cheap provider is eligible again on the very next
1189
+ * turn instead of being excluded for a size the session no longer has.
1190
+ */
1191
+ private async refreshContextAfterCompaction(sessionID: string) {
1192
+ const real = await this.readCurrentContext(sessionID)
1193
+ if (!real) return
1194
+ this.sessionState(sessionID)
1195
+ this.ledger.patchSession(sessionID, {
1196
+ contextTokens: real.contextTokens,
1197
+ cacheReadTokens: real.cacheReadTokens,
1198
+ })
1199
+ this.persistLedgerSoon()
1200
+ }
1201
+
1202
+ private classifierFiles(files: unknown): ClassifierFile[] {
1203
+ if (!Array.isArray(files)) return []
1204
+ return files
1205
+ .filter(isRecord)
1206
+ .map((file) => ({
1207
+ uri: typeof file.uri === "string" ? file.uri : "",
1208
+ ...(typeof file.name === "string" ? { name: file.name } : {}),
1209
+ ...(typeof file.description === "string" ? { description: file.description } : {}),
1210
+ }))
1211
+ .filter((file) => file.uri.length > 0)
1212
+ }
1213
+
1214
+ private statusFor(sessionID?: string): RouterStatus {
1215
+ if (this.settingsWarnings.length > 50) this.settingsWarnings = this.settingsWarnings.slice(-50)
1216
+ const session = sessionID ? this.ledger.getSession(sessionID) : undefined
1217
+ const current = session?.lastModel
1218
+ const runtime = this.runtimeFor(session)
1219
+ const currentModel = this.registry.get(current?.providerID ?? "", current?.id ?? "")
1220
+ const decisions = sessionID
1221
+ ? this.ledger.decisions(20).filter((decision) => decision.sessionID === sessionID)
1222
+ : this.ledger.decisions(20)
1223
+ const quota = this.quotaSnapshots()
1224
+ const warnings = [
1225
+ ...this.settingsWarnings,
1226
+ ...quota.filter((snapshot) => snapshot.error).map((snapshot) => `${snapshot.label}: ${snapshot.error}`),
1227
+ ]
1228
+ return {
1229
+ enabled: this.settings.enabled,
1230
+ mode: this.settings.mode,
1231
+ runtime,
1232
+ dryRun: this.settings.dryRun,
1233
+ ...(sessionID ? { sessionID } : {}),
1234
+ ...(current ? { current } : {}),
1235
+ ...(currentModel ? { currentTier: currentModel.tier } : {}),
1236
+ manualOverride: session?.manualOverride ?? false,
1237
+ ...(session?.pinnedModel ? { pinnedModel: session.pinnedModel } : {}),
1238
+ premium: this.premiumState(session),
1239
+ quota,
1240
+ context: computeContextHealth(session, this.settings.contextHealth),
1241
+ indicator: this.indicatorFor(sessionID ?? "", session),
1242
+ ...(decisions[0] ? { lastDecision: decisions[0] } : {}),
1243
+ lastDecisions: decisions,
1244
+ metrics: this.ledger.snapshotMetrics(),
1245
+ approvedModels: this.registry.approved().map((model) => ({
1246
+ key: model.key,
1247
+ tier: model.tier,
1248
+ name: model.name,
1249
+ intelligence: model.intelligence,
1250
+ design: model.design,
1251
+ vision: model.inputModalities.includes("image"),
1252
+ premium: model.premium,
1253
+ })),
1254
+ models: this.registry.all().map((model) => ({
1255
+ key: model.key,
1256
+ providerID: model.providerID,
1257
+ name: model.name,
1258
+ tier: model.tier,
1259
+ intelligence: model.intelligence,
1260
+ design: model.design,
1261
+ vision: model.inputModalities.includes("image"),
1262
+ premium: model.premium,
1263
+ context: model.context,
1264
+ costInput: model.cost[0]?.input ?? 0,
1265
+ costOutput: model.cost[0]?.output ?? 0,
1266
+ approved: model.approved,
1267
+ excluded: this.settings.models.excluded.includes(model.key),
1268
+ ...(model.notes ? { approvalNote: model.notes } : {}),
1269
+ })),
1270
+ warnings,
1271
+ }
1272
+ }
1273
+
1274
+ private async markBlocked(sessionID: string, reason: string) {
1275
+ const session = this.ledger.ensureSession(sessionID)
1276
+ const ref = session.lastModel
1277
+ const model = ref ? this.registry.get(ref.providerID, ref.id) : undefined
1278
+ const label = model?.name ?? ref?.id ?? "current model"
1279
+ // Blocking a prompt must not rename the owner's chat. The refusal lives in
1280
+ // the decision record, the panel and the notice.
1281
+ this.settingsWarnings.push(`Blocked a prompt: ${reason}`)
1282
+ void this.notifyChange(`${sessionID}:blocked:${reason}`, `BLOCKED · ${label} — ${reason}`)
1283
+ }
1284
+
1285
+ private recordFailedDecision(sessionID: string, text: string, files: unknown, reason: string) {
1286
+ const session = this.ledger.ensureSession(sessionID)
1287
+ const task = classifyTask({
1288
+ text,
1289
+ files: this.classifierFiles(files),
1290
+ contextTokens: session.contextTokens,
1291
+ recentFailures: session.failures.length,
1292
+ })
1293
+ const runtime = this.runtimeFor(session)
1294
+ const decision: RoutingDecision = {
1295
+ id: nowId("dec"),
1296
+ at: Date.now(),
1297
+ sessionID,
1298
+ mode: this.settings.mode,
1299
+ dryRun: this.settings.dryRun,
1300
+ runtime,
1301
+ task,
1302
+ ...(session.lastModel ? { current: session.lastModel } : {}),
1303
+ switched: false,
1304
+ switchReason: `Routing failed; the current model was kept: ${reason}`,
1305
+ reasons: [`Routing failed: ${reason}`],
1306
+ warnings: [`Routing failed: ${reason}`],
1307
+ candidates: [],
1308
+ premium: this.premiumState(session),
1309
+ quota: this.quotaSnapshots(),
1310
+ contextTokens: session.contextTokens,
1311
+ cachedTokens: session.cacheReadTokens,
1312
+ estimatedUsd: 0,
1313
+ needsPremiumApproval: false,
1314
+ manualOverride: session.manualOverride === true,
1315
+ error: reason,
1316
+ }
1317
+ this.ledger.recordDecision(decision)
1318
+ this.persistLedgerSoon()
1319
+ void this.emit("decision", { decision })
1320
+ return decision
1321
+ }
1322
+
1323
+ /**
1324
+ * A routing exception must never fall through as a silent automatic route.
1325
+ * Record the failure, and when failClosed is on, block admission.
1326
+ */
1327
+ private async handleRoutingFailure(sessionID: string, text: string, files: unknown, error: unknown) {
1328
+ const reason = safeError(error)
1329
+ this.settingsWarnings.push(`Prompt routing failed: ${reason}`)
1330
+ try {
1331
+ this.recordFailedDecision(sessionID, text, files, reason)
1332
+ } catch (recordError) {
1333
+ this.settingsWarnings.push(`Could not record the failed routing decision: ${safeError(recordError)}`)
1334
+ }
1335
+ if (!this.settings.routing.failClosed) return
1336
+ await this.markBlocked(sessionID, reason)
1337
+ throw new RouterBlockedError(`Smart Router blocked this prompt: ${reason}`)
1338
+ }
1339
+
1340
+ private async routePrompt(sessionID: string, text: string, files: unknown): Promise<RoutingDecision | undefined> {
1341
+ const settings = this.settings
1342
+ await this.loadSessionInfo(sessionID)
1343
+ const session = this.sessionState(sessionID)
1344
+ // A new prompt supersedes a previous blocked admission. If this prompt
1345
+ // blocks again, markBlocked sets the title again.
1346
+ if (session.indicatorTitle?.startsWith("BLOCKED ·")) {
1347
+ this.ledger.patchSession(sessionID, { indicatorTitle: undefined })
1348
+ }
1349
+ const runtime = this.runtimeFor(session)
1350
+ void this.ensureContextTokens(sessionID)
1351
+
1352
+ if (runtime !== "automatic") {
1353
+ const models = await this.routingModels()
1354
+ const task = classifyTask({
1355
+ text,
1356
+ files: this.classifierFiles(files),
1357
+ contextTokens: session.contextTokens,
1358
+ recentFailures: session.failures.length,
1359
+ })
1360
+ const result = routeTask({
1361
+ task,
1362
+ settings,
1363
+ mode: settings.mode,
1364
+ runtime,
1365
+ models: models,
1366
+ intelligenceFloor: this.routingFloor(models).floor,
1367
+ current: session.lastModel,
1368
+ session,
1369
+ quota: this.quotaSnapshots(),
1370
+ premium: this.premiumState(session),
1371
+ contextTokens: session.contextTokens,
1372
+ cachedTokens: session.cacheReadTokens,
1373
+ dryRun: settings.dryRun,
1374
+ })
1375
+ this.ledger.recordDecision(result.decision)
1376
+ this.persistLedgerSoon()
1377
+ void this.emit("decision", { decision: result.decision })
1378
+ return result.decision
1379
+ }
1380
+
1381
+ const models = await this.routingModels()
1382
+ const task = classifyTask({
1383
+ text,
1384
+ files: this.classifierFiles(files),
1385
+ contextTokens: session.contextTokens,
1386
+ recentFailures: session.failures.length,
1387
+ })
1388
+ const quota = this.quotaSnapshots()
1389
+ const effectiveMode = session.parentID && settings.mode === "premium" ? "balanced" : settings.mode
1390
+ const premium = session.parentID
1391
+ ? { ...this.premiumState(session), oneShotGranted: false }
1392
+ : this.premiumState(session)
1393
+ const result = routeTask({
1394
+ task,
1395
+ settings,
1396
+ mode: effectiveMode,
1397
+ runtime,
1398
+ models: models,
1399
+ intelligenceFloor: this.routingFloor(models).floor,
1400
+ current: session.lastModel,
1401
+ session,
1402
+ quota,
1403
+ premium,
1404
+ contextTokens: session.contextTokens,
1405
+ cachedTokens: session.cacheReadTokens,
1406
+ dryRun: settings.dryRun,
1407
+ modelHistory: this.ledger.snapshotMetrics().byModel,
1408
+ cooldowns: this.ledger.cooldowns(),
1409
+ providerCooldowns: this.ledger.providerCooldowns(),
1410
+ speed: this.speedSnapshot(),
1411
+ })
1412
+ if (session.lastModel) this.rememberModel(sessionID, session.lastModel.providerID, session.lastModel.id)
1413
+
1414
+ let switchError: string | undefined
1415
+ if (result.chosenModel && result.decision.switched) {
1416
+ const target = result.chosenModel
1417
+ const key = modelKey(target.providerID, target.modelID)
1418
+ if (this.expectedSwitch.size > 500) {
1419
+ const cutoff = Date.now() - 60_000
1420
+ for (const [sid, entry] of this.expectedSwitch) if (entry.at < cutoff) this.expectedSwitch.delete(sid)
1421
+ if (this.expectedSwitch.size > 500) {
1422
+ const oldest = this.expectedSwitch.keys().next().value
1423
+ if (oldest) this.expectedSwitch.delete(oldest)
1424
+ }
1425
+ }
1426
+ // NEVER SWITCH IN THE MIDDLE OF A RUNNING TURN.
1427
+ //
1428
+ // `switchModel` changes the model for the NEXT step. If a turn is already
1429
+ // in flight, the step that is queued behind the switch resolves against a
1430
+ // provider whose registration may not be settled yet, and dies with
1431
+ // "Model unavailable: <provider>/<model>" -- which reads like a dead
1432
+ // provider and is not one. Observed live on 2026-11-03: four of six such
1433
+ // failures landed within ten seconds of the plugin being reloaded, three
1434
+ // of them within one second.
1435
+ //
1436
+ // The prompt hook normally fires before a turn starts, so this is a guard
1437
+ // for the cases where it does not: a retry, a second queued prompt, or an
1438
+ // event arriving while the session is still busy. Deferring is always safe
1439
+ // because the next prompt routes again anyway.
1440
+ const busy = this.sessionBusy(sessionID)
1441
+ if (busy) {
1442
+ this.expectedSwitch.delete(sessionID)
1443
+ this.settingsWarnings.push(
1444
+ `Deferred a switch to ${target.providerID}/${target.modelID}: the session was mid-turn. Routing again on the next prompt.`,
1445
+ )
1446
+ return
1447
+ }
1448
+
1449
+ // NEVER SWITCH TO A MODEL OPENCODE DOES NOT HAVE.
1450
+ //
1451
+ // The router's candidate list is built from the GATEWAY and the seed catalog.
1452
+ // OpenCode's own model list is a third thing, and they can disagree: a model
1453
+ // the gateway serves, and the router has approved, may not be present in
1454
+ // OpenCode's provider config -- or, as on 2026-11-03, the provider's model
1455
+ // list can be empty mid-session because the plugin directory was replaced
1456
+ // underneath a running server.
1457
+ //
1458
+ // Switching to a model OpenCode cannot resolve fails the NEXT step with
1459
+ // "Model unavailable: <provider>/<model>", which looks exactly like a dead
1460
+ // gateway and is not one. Checking first costs one call and turns a broken
1461
+ // session into a skipped switch.
1462
+ const resolvable = await this.modelResolvable(target.providerID, target.modelID)
1463
+ if (!resolvable) {
1464
+ this.expectedSwitch.delete(sessionID)
1465
+ this.settingsWarnings.push(
1466
+ `Skipped a switch to ${target.providerID}/${target.modelID}: OpenCode does not currently expose that model.`,
1467
+ )
1468
+ return
1469
+ }
1470
+ this.expectedSwitch.set(sessionID, { key, at: Date.now() })
1471
+ try {
1472
+ await this.ctx.session.switchModel({
1473
+ sessionID,
1474
+ model: { providerID: target.providerID, id: target.modelID },
1475
+ })
1476
+ this.ledger.patchSession(sessionID, {
1477
+ lastModel: { providerID: target.providerID, id: target.modelID },
1478
+ lastRoutedModel: { providerID: target.providerID, id: target.modelID },
1479
+ })
1480
+ this.rememberModel(sessionID, target.providerID, target.modelID)
1481
+ if (result.decision.premium.oneShotGranted && target.premium) {
1482
+ this.ledger.consumePremiumOneShot(sessionID)
1483
+ }
1484
+ } catch (error) {
1485
+ switchError = safeError(error)
1486
+ this.expectedSwitch.delete(sessionID)
1487
+ this.settingsWarnings.push(`Model switch failed: ${switchError}`)
1488
+ }
1489
+ }
1490
+
1491
+ const finalDecision: RoutingDecision = switchError
1492
+ ? {
1493
+ ...result.decision,
1494
+ error: switchError,
1495
+ switched: false,
1496
+ switchReason: `Switch failed; kept the current model. ${switchError}`,
1497
+ warnings: [...result.decision.warnings, `Switch failed: ${switchError}`],
1498
+ }
1499
+ : result.decision
1500
+
1501
+ // Fail closed when the request would otherwise run on an unqualified model.
1502
+ if (runtime === "automatic" && settings.routing.failClosed && (!finalDecision.chosen || switchError)) {
1503
+ const currentRecord = session.lastModel
1504
+ ? this.registry.get(session.lastModel.providerID, session.lastModel.id)
1505
+ : undefined
1506
+ const premiumDecision = decidePremium({
1507
+ settings,
1508
+ mode: effectiveMode,
1509
+ task,
1510
+ session,
1511
+ premium,
1512
+ })
1513
+ const currentEligibility = currentRecord
1514
+ ? evaluateEligibility({
1515
+ model: currentRecord,
1516
+ task,
1517
+ settings,
1518
+ mode: effectiveMode,
1519
+ contextTokens: session.contextTokens,
1520
+ providerQuota: quota.find((entry) => entry.providerID === currentRecord.providerID),
1521
+ modelQuota: quota
1522
+ .find((entry) => entry.providerID === currentRecord.providerID)
1523
+ ?.models[`${currentRecord.providerID}/${currentRecord.modelID}`],
1524
+ premium: premiumDecision,
1525
+ currentModelKey: currentRecord.key,
1526
+ })
1527
+ : { eligible: false, reason: "No current model is recorded for this session", warnings: [] }
1528
+ if (!currentEligibility.eligible) {
1529
+ const reason = currentEligibility.reason
1530
+ await this.markBlocked(sessionID, reason)
1531
+ const blockedDecision: RoutingDecision = {
1532
+ ...finalDecision,
1533
+ error: reason,
1534
+ switched: false,
1535
+ switchReason: `Prompt blocked: ${reason}`,
1536
+ warnings: [...finalDecision.warnings, `Prompt blocked: ${reason}`],
1537
+ }
1538
+ this.ledger.recordDecision(blockedDecision)
1539
+ this.persistLedgerSoon()
1540
+ void this.emit("decision", { decision: blockedDecision })
1541
+ throw new RouterBlockedError(`Smart Router blocked this prompt: ${reason}`)
1542
+ }
1543
+ }
1544
+
1545
+ this.ledger.recordDecision(finalDecision)
1546
+ this.persistLedgerSoon()
1547
+ void this.emit("decision", { decision: finalDecision })
1548
+ if (finalDecision.needsPremiumApproval) {
1549
+ void this.emit("status", { summary: { needsPremiumApproval: true, sessionID } })
1550
+ void this.notifyChange(
1551
+ `${sessionID}:premium-needed`,
1552
+ "Premium approval required. Run /router-approve for this session.",
1553
+ )
1554
+ }
1555
+ void this.queueTitle(sessionID)
1556
+ const indicator = this.indicatorFor(sessionID, this.ledger.getSession(sessionID))
1557
+ if (finalDecision.switched && finalDecision.chosen) {
1558
+ const tier = finalDecision.task.tier === 1 ? "economy" : finalDecision.task.tier === 3 ? "premium" : "standard"
1559
+ this.modelNotice(sessionID, indicator.state, finalDecision.chosen, `${tier} task · quota-aware route`)
1560
+ }
1561
+ return finalDecision
1562
+ }
1563
+
1564
+ /**
1565
+ * How long routing may hold a prompt before the prompt is served without it.
1566
+ *
1567
+ * The number is a ceiling on waiting, not a performance target. Routing that
1568
+ * has not finished in this long is not going to improve the request in front
1569
+ * of the owner, and every extra second is a second the editor appears frozen.
1570
+ */
1571
+ private routeBudgetMs(): number {
1572
+ const configured = Number((this.settings as any)?.routing?.routeBudgetMs)
1573
+ if (Number.isFinite(configured) && configured > 0) return Math.min(configured, 60_000)
1574
+ return 10_000
1575
+ }
1576
+
1577
+ private async registerHooks() {
1578
+ this.registrations.push(
1579
+ await this.ctx.session.hook("prompt", containHook(
1580
+ async (event: any) => {
1581
+ const sessionID = event.sessionID
1582
+ if (!sessionID) return
1583
+ try {
1584
+ const probe: any = await this.ctx.session.get({ sessionID })
1585
+ if (!this.projectMatches(probe?.projectID, probe?.location?.directory)) return
1586
+ } catch {
1587
+ // If the location probe fails, the location-scoped hook can still run.
1588
+ }
1589
+ sharedCore.claimSession(sessionID, this.bridgeID)
1590
+ this.remember(this.promptSeen, sessionID)
1591
+ this.pendingHeals.delete(sessionID)
1592
+ if (this.inflight.has(sessionID)) return
1593
+ this.inflight.add(sessionID)
1594
+ try {
1595
+ // Routing runs against a deadline. If it cannot finish, the prompt
1596
+ // proceeds unrouted: an optimization that cannot finish is worth
1597
+ // nothing unless it gets out of the way.
1598
+ const decision = await withDeadline({
1599
+ work: () => this.routePrompt(sessionID, event.prompt?.text ?? "", event.prompt?.files),
1600
+ ms: this.routeBudgetMs(),
1601
+ onTimeout: () => {
1602
+ this.settingsWarnings.push(
1603
+ `Routing did not finish within ${this.routeBudgetMs()}ms; the prompt was served without a route.`,
1604
+ )
1605
+ },
1606
+ })
1607
+ if (decision) {
1608
+ // Available to the retry hook, which has no prompt linkage of its
1609
+ // own: a deterministic rejection re-routes this same task.
1610
+ this.promptRoutes.set(sessionID, {
1611
+ messageID: typeof event.messageID === "string" ? event.messageID : undefined,
1612
+ task: decision.task,
1613
+ decision,
1614
+ at: Date.now(),
1615
+ })
1616
+ }
1617
+ } catch (error) {
1618
+ if (error instanceof RouterBlockedError || (error as any)?.name === "RouterBlockedError") throw error
1619
+ await this.handleRoutingFailure(sessionID, event.prompt?.text ?? "", event.prompt?.files, error)
1620
+ } finally {
1621
+ this.inflight.delete(sessionID)
1622
+ }
1623
+ },
1624
+ {
1625
+ label: "prompt routing",
1626
+ allowBlocked: true,
1627
+ onContained: (message) => {
1628
+ // A defect inside the router is reported, never escalated to a
1629
+ // refused prompt. The host keeps working; the owner can see why
1630
+ // routing was skipped.
1631
+ this.settingsWarnings.push(`Routing skipped: ${message}`)
1632
+ },
1633
+ },
1634
+ )),
1635
+ )
1636
+
1637
+ this.registrations.push(
1638
+ await this.ctx.session.hook("model.request", containHook((event: any) => {
1639
+ if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
1640
+ // Latch the request kind so the context hook (whose event carries no
1641
+ // kind) can tell the agent loop apart from title and compaction runs.
1642
+ // One-shot consumption keeps a stale latch from leaking to a later
1643
+ // request of another kind.
1644
+ if (typeof event.kind === "string" && event.kind) {
1645
+ if (this.requestKinds.size > 500) {
1646
+ const cutoff = Date.now() - 60_000
1647
+ for (const [sid, entry] of this.requestKinds) if (entry.at < cutoff) this.requestKinds.delete(sid)
1648
+ if (this.requestKinds.size > 500) {
1649
+ const oldest = this.requestKinds.keys().next().value
1650
+ if (oldest) this.requestKinds.delete(oldest)
1651
+ }
1652
+ }
1653
+ this.requestKinds.set(event.sessionID, { kind: event.kind, at: Date.now() })
1654
+ }
1655
+ if (event.model?.providerID === "opencode-go") {
1656
+ event.headers["x-opencode-session"] = event.headers["x-opencode-session"] ?? event.sessionID
1657
+ }
1658
+ if (this.settings.privacy.requireZdr && event.model?.providerID === "commandcode") {
1659
+ event.headers["x-cmd-zdr"] = "1"
1660
+ }
1661
+ }, {
1662
+ label: "request bookkeeping",
1663
+ onContained: (message) => this.settingsWarnings.push(`Request bookkeeping skipped: ${message}`),
1664
+ })),
1665
+ )
1666
+
1667
+ // The host emits `model.request` for every model call with the request's
1668
+ // kind and `context` with the built messages, in that order (the bundled
1669
+ // Claude integration relies on the same pairing), so a missing latch reads
1670
+ // as the agent loop: the common case, and the one that needs the repair.
1671
+ this.registrations.push(
1672
+ await this.ctx.session.hook("context", async (event: any) => {
1673
+ try {
1674
+ if (!(await this.ensureOwnership(event.sessionID))) return
1675
+ const latched = this.requestKinds.get(event.sessionID)
1676
+ this.requestKinds.delete(event.sessionID)
1677
+ if (latched && latched.kind !== "primary") return
1678
+ const session = this.ledger.getSession(event.sessionID)
1679
+ const model = event.model ?? session?.lastModel
1680
+ const record = model ? this.registry.get(model.providerID, model.id) : undefined
1681
+ const protocol = providerProtocol({
1682
+ ...(record ?? {}),
1683
+ providerID: model?.providerID ?? "",
1684
+ modelID: model?.id ?? "",
1685
+ })
1686
+ if (protocol !== "anthropic") return
1687
+ const result = repairMessages(event.messages, { protocol })
1688
+ if (result.folded > 0) event.messages = result.messages
1689
+ } catch (error) {
1690
+ // A repair failure must never escape into the model request.
1691
+ this.settingsWarnings.push(`Prompt repair failed: ${safeError(error)}`)
1692
+ }
1693
+ }),
1694
+ )
1695
+
1696
+ this.registrations.push(
1697
+ await this.ctx.session.hook("retry", containHook(async (event: any) => {
1698
+ if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
1699
+ const session = this.ledger.getSession(event.sessionID)
1700
+ const assessment = classifyFailure(
1701
+ { type: event.error?.type, message: event.error?.message ?? "", status: event.error?.status },
1702
+ session,
1703
+ )
1704
+ // The retry hook and the step/execution/idle events can all report
1705
+ // the same fault. Record it once, and carry the cooldown through so
1706
+ // the model is actually held back when it keeps failing.
1707
+ // The host identifies a scheduled retry by its assistant message id,
1708
+ // which is the same identity the step/execution events carry.
1709
+ const attemptKey =
1710
+ typeof event.assistantMessageID === "string"
1711
+ ? event.assistantMessageID
1712
+ : typeof event.attemptID === "string"
1713
+ ? event.attemptID
1714
+ : undefined
1715
+ if (!this.alreadyRecorded(event.sessionID, assessment.class, event.error?.message ?? "", attemptKey)) {
1716
+ this.ledger.recordFailure(event.sessionID, event.model, assessment.class, event.error?.message ?? "", {
1717
+ countAgainstModel: assessment.countAgainstModel,
1718
+ cooldownMs: assessment.cooldownMs,
1719
+ providerScoped: assessment.providerScoped,
1720
+ saturation: assessment.saturation === true,
1721
+ })
1722
+ }
1723
+ this.ledger.recordRetry(event.sessionID)
1724
+ this.persistLedgerSoon()
1725
+ if (assessment.class === "auth" || assessment.class === "context_overflow" || assessment.class === "image_rejected") {
1726
+ event.decision = { retry: false }
1727
+ return
1728
+ }
1729
+
1730
+ /**
1731
+ * The provider has no allowance left.
1732
+ *
1733
+ * This is the compensating control for everything the router cannot
1734
+ * read in advance. For OpenDesign it cannot read usage at all, so its
1735
+ * figure is a lower bound and a spent allowance is only discovered when
1736
+ * the gateway refuses. One refusal is enough: stop retrying this turn,
1737
+ * and hold the provider back until its window resets so the next prompt
1738
+ * routes somewhere that can actually serve it.
1739
+ *
1740
+ * Holding back for the reset, not for a few seconds, is the point. A
1741
+ * short backoff would re-probe an empty account on every prompt and burn
1742
+ * a turn each time to learn something already known.
1743
+ */
1744
+ if (assessment.class === "quota_exhausted") {
1745
+ const providerID = event.model?.providerID
1746
+ if (providerID) {
1747
+ this.ledger.exhaustProvider(providerID, this.providerResetAt(providerID))
1748
+ }
1749
+ this.settingsWarnings.push(
1750
+ `${providerID ?? "The provider"} reported no allowance left. Routing will avoid it until its window resets.`,
1751
+ )
1752
+ event.decision = { retry: false }
1753
+ this.persistLedgerSoon()
1754
+ return
1755
+ }
1756
+ if (assessment.class === "capability") {
1757
+ event.decision = { retry: false }
1758
+ return
1759
+ }
1760
+ if (assessment.class === "prompt_rejected") {
1761
+ // A deterministic rejection: the provider refused the request shape
1762
+ // before generating anything. Retrying reproduces the same 400, so
1763
+ // the turn stops immediately and the router repairs (next request)
1764
+ // or fails over to the next best choice.
1765
+ event.decision = { retry: false }
1766
+ try {
1767
+ await this.attemptSelfHeal(event.sessionID, event)
1768
+ } catch (error) {
1769
+ this.settingsWarnings.push(`Prompt-rejection failover failed: ${safeError(error)}`)
1770
+ }
1771
+ this.persistLedgerSoon()
1772
+ return
1773
+ }
1774
+ if (assessment.class === "rate_limit") {
1775
+ event.decision = { retry: true, delay: Math.max(5_000, event.error?.retryAfterMs ?? 15_000) }
1776
+ return
1777
+ }
1778
+ if (assessment.providerScoped) {
1779
+ // The provider's own pre-request path failed before a model ran, or
1780
+ // its gateway is refusing work outright, so the attempt cost nothing.
1781
+ // These failures come in runs: the gateway is slow or saturated for a
1782
+ // stretch and every immediate retry lands inside the same stretch and
1783
+ // dies with it, which is what turns a recoverable blip into a turn
1784
+ // that stops and waits for the owner to say continue. Back off long
1785
+ // enough for the stretch to pass.
1786
+ //
1787
+ // Backing off is the only thing available here: the retry hook can
1788
+ // retry with a delay or give up, but it cannot change model, so there
1789
+ // is no cheaper escape from a saturated gateway within the turn.
1790
+ if (event.attempt >= PROVIDER_SCOPED_RETRY_ATTEMPTS) {
1791
+ event.decision = { retry: false }
1792
+ } else {
1793
+ event.decision = { retry: true, delay: Math.min(40_000, 5_000 * 2 ** (event.attempt - 1)) }
1794
+ }
1795
+ } else if (assessment.class === "provider_outage" || assessment.class === "network") {
1796
+ if (event.attempt >= 3) event.decision = { retry: false }
1797
+ }
1798
+ }, {
1799
+ label: "retry handling",
1800
+ // A contained retry failure leaves the host's own policy in place,
1801
+ // which is a working default rather than a blocked turn.
1802
+ onContained: (message) => this.settingsWarnings.push(`Retry handling skipped: ${message}`),
1803
+ })),
1804
+ )
1805
+ }
1806
+
1807
+
1808
+
1809
+ private async ensureOwnership(sessionID: string): Promise<boolean> {
1810
+ const owner = sharedCore.ownerOf(sessionID)
1811
+ if (owner) return owner === this.bridgeID
1812
+ try {
1813
+ const info: any = await this.ctx.session.get({ sessionID })
1814
+ if (!this.projectMatches(info?.projectID, info?.location?.directory)) return false
1815
+ sharedCore.claimSession(sessionID, this.bridgeID)
1816
+ return true
1817
+ } catch {
1818
+ return false
1819
+ }
1820
+ }
1821
+
1822
+ /**
1823
+ * Record a provider fault against the model that was actually serving the
1824
+ * session.
1825
+ *
1826
+ * The router's own session state is the primary source, but it can lag
1827
+ * behind a manual model change. The host's session record is the
1828
+ * authority, so it is consulted when the router has no model on record.
1829
+ */
1830
+ private async recordProviderFault(sessionID: string, outcome: string, error: any, attemptKey?: string) {
1831
+ const session = this.ledger.getSession(sessionID)
1832
+ let model = session?.lastModel
1833
+ if (!model) {
1834
+ try {
1835
+ const info: any = await this.ctx.session.get({ sessionID })
1836
+ const ref = modelRefFrom(info?.model)
1837
+ if (ref) {
1838
+ model = ref
1839
+ this.ledger.patchSession(sessionID, { lastModel: ref })
1840
+ this.rememberModel(sessionID, ref.providerID, ref.id)
1841
+ }
1842
+ } catch {
1843
+ // The session may already be gone; the fault is still recorded.
1844
+ }
1845
+ }
1846
+ const message =
1847
+ typeof error?.message === "string" && error.message.trim().length > 0
1848
+ ? error.message
1849
+ : outcome === "interrupted"
1850
+ ? "Step interrupted"
1851
+ : "The provider ended the response without completing it"
1852
+ const assessment = classifyFailure(
1853
+ {
1854
+ type: typeof error?.type === "string" ? error.type : outcome,
1855
+ message,
1856
+ ...(typeof error?.status === "number" ? { status: error.status } : {}),
1857
+ },
1858
+ session,
1859
+ )
1860
+ // The same fault can arrive through the retry hook and through the
1861
+ // step, execution and idle events. Record it once.
1862
+ if (this.alreadyRecorded(sessionID, assessment.class, message, attemptKey)) return
1863
+ this.ledger.recordFailure(sessionID, model, assessment.class, message, {
1864
+ countAgainstModel: assessment.countAgainstModel,
1865
+ cooldownMs: assessment.cooldownMs,
1866
+ providerScoped: assessment.providerScoped,
1867
+ saturation: assessment.saturation === true,
1868
+ })
1869
+ }
1870
+
1871
+ /** Hold-back window applied to a model that deterministically rejected a prompt. */
1872
+ private static readonly PROMPT_REJECTED_HOLD_MS = 5 * 60_000
1873
+
1874
+ /**
1875
+ * Recover a turn whose provider rejected the request shape.
1876
+ *
1877
+ * The model is healthy and the request cost nothing, so the router re-routes
1878
+ * the prompt's stored task with the rejecting model held back, moves the
1879
+ * session onto the next best choice, and continues the turn with one
1880
+ * router-marked synthetic notice. Everything that can be missing is missing
1881
+ * in a defined way: no banked routing, no other eligible model, or a host
1882
+ * without the needed API all degrade to leaving the failed turn as it is,
1883
+ * with the rejecting model held back so the next prompt routes elsewhere.
1884
+ * One failover per prompt, ever.
1885
+ *
1886
+ * A session that is still running its failed step must not be switched
1887
+ * mid-flight (a queued step would resolve against an unsettled model
1888
+ * registration), so the heal parks and is flushed from the turn's terminal
1889
+ * event, when the host considers the session idle.
1890
+ */
1891
+ private async attemptSelfHeal(sessionID: string, event: any): Promise<void> {
1892
+ const route = this.promptRoutes.get(sessionID)
1893
+ const rejected = modelRefFrom(event.model) ?? this.ledger.getSession(sessionID)?.lastModel
1894
+ if (!rejected) return
1895
+ const rejectedKey = modelKey(rejected.providerID, rejected.id)
1896
+ const hold = (model: ModelRef) => this.ledger.holdModel(model, RouterController.PROMPT_REJECTED_HOLD_MS)
1897
+
1898
+ // One failover per prompt: the cap is set before any host call, so a
1899
+ // failure partway through cannot let a later rejection heal again.
1900
+ if (!route || route.healedAt) {
1901
+ hold(rejected)
1902
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1903
+ return
1904
+ }
1905
+ // Automatic routing only: a manual model is the owner's pick, and a paused
1906
+ // session answers for itself on the next prompt.
1907
+ if (this.runtimeFor(this.sessionState(sessionID)) !== "automatic") {
1908
+ hold(rejected)
1909
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1910
+ return
1911
+ }
1912
+
1913
+ const session = this.sessionState(sessionID)
1914
+ const models = await this.routingModels()
1915
+ const result = routeTask({
1916
+ task: route.task,
1917
+ settings: this.settings,
1918
+ mode: session.parentID && this.settings.mode === "premium" ? "balanced" : this.settings.mode,
1919
+ runtime: "automatic",
1920
+ models: models,
1921
+ intelligenceFloor: this.routingFloor(models).floor,
1922
+ current: rejected,
1923
+ session,
1924
+ quota: this.quotaSnapshots(),
1925
+ // A child session has no one-shot of its own: the owner approves spend,
1926
+ // not the subagent.
1927
+ premium: session.parentID ? { ...this.premiumState(session), oneShotGranted: false } : this.premiumState(session),
1928
+ contextTokens: session.contextTokens,
1929
+ cachedTokens: session.cacheReadTokens,
1930
+ dryRun: this.settings.dryRun,
1931
+ modelHistory: this.ledger.snapshotMetrics().byModel,
1932
+ cooldowns: { ...this.ledger.cooldowns(), [rejectedKey]: Date.now() + RouterController.PROMPT_REJECTED_HOLD_MS },
1933
+ providerCooldowns: this.ledger.providerCooldowns(),
1934
+ speed: this.speedSnapshot(),
1935
+ })
1936
+ const chosen = result.chosenModel
1937
+ // `decision.switched` is false under dry run: the plan explains what would
1938
+ // happen instead of doing it, and so does the heal.
1939
+ if (!chosen || !result.decision.switched || modelKey(chosen.providerID, chosen.modelID) === rejectedKey) {
1940
+ route.healedAt = Date.now()
1941
+ hold(rejected)
1942
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1943
+ return
1944
+ }
1945
+ if (
1946
+ typeof this.ctx.session.switchModel !== "function" ||
1947
+ typeof this.ctx.session.synthetic !== "function" ||
1948
+ !(await this.modelResolvable(chosen.providerID, chosen.modelID))
1949
+ ) {
1950
+ route.healedAt = Date.now()
1951
+ hold(rejected)
1952
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1953
+ return
1954
+ }
1955
+
1956
+ route.healedAt = Date.now()
1957
+ hold(rejected)
1958
+ this.pendingHeals.set(sessionID, {
1959
+ chosen,
1960
+ base: result.decision,
1961
+ rejectedLabel: `${rejected.providerID}/${rejected.id}`,
1962
+ })
1963
+ if (!this.sessionBusy(sessionID)) await this.flushPendingHeal(sessionID)
1964
+ }
1965
+
1966
+ /**
1967
+ * Apply a parked failover: the failed turn is over, so switching is safe.
1968
+ * Switches the session, mirrors the routing state (expected switch, last
1969
+ * model, one-shot consumption) exactly as the prompt path does, and
1970
+ * continues the turn with one router-marked notice.
1971
+ */
1972
+ private async flushPendingHeal(sessionID: string): Promise<void> {
1973
+ const heal = this.pendingHeals.get(sessionID)
1974
+ if (!heal) return
1975
+ this.pendingHeals.delete(sessionID)
1976
+ if (sharedCore.ownerOf(sessionID) !== this.bridgeID) return
1977
+ const chosen = heal.chosen
1978
+ this.expectedSwitch.set(sessionID, { key: modelKey(chosen.providerID, chosen.modelID), at: Date.now() })
1979
+ try {
1980
+ await this.ctx.session.switchModel({ sessionID, model: { providerID: chosen.providerID, id: chosen.modelID } })
1981
+ this.ledger.patchSession(sessionID, {
1982
+ lastModel: { providerID: chosen.providerID, id: chosen.modelID },
1983
+ lastRoutedModel: { providerID: chosen.providerID, id: chosen.modelID },
1984
+ })
1985
+ this.rememberModel(sessionID, chosen.providerID, chosen.modelID)
1986
+ if (heal.base.premium.oneShotGranted && chosen.premium) {
1987
+ this.ledger.consumePremiumOneShot(sessionID)
1988
+ }
1989
+ await this.ctx.session.synthetic({
1990
+ sessionID,
1991
+ text: `[router] The request was rejected on ${heal.rejectedLabel}, so the router moved it to ${chosen.providerID}/${chosen.modelID}.`,
1992
+ description: "Smart Router",
1993
+ // A fresh run, not a display notice: the failed turn was admitted under
1994
+ // the rejecting model and this is its continuation.
1995
+ resume: true,
1996
+ })
1997
+ const annotated: RoutingDecision = {
1998
+ ...heal.base,
1999
+ id: `${heal.base.id}+heal`,
2000
+ switched: true,
2001
+ switchReason: "selfheal",
2002
+ selfheal: {
2003
+ rejected: heal.rejectedLabel,
2004
+ action: `continued on ${chosen.providerID}/${chosen.modelID}`,
2005
+ },
2006
+ }
2007
+ this.ledger.recordDecision(annotated)
2008
+ void this.emit("decision", { decision: annotated })
2009
+ this.persistLedgerSoon()
2010
+ } catch (error) {
2011
+ this.expectedSwitch.delete(sessionID)
2012
+ this.settingsWarnings.push(`Self-heal switch failed: ${safeError(error)}`)
2013
+ this.persistLedgerSoon()
2014
+ }
2015
+ }
2016
+
2017
+ /**
2018
+ * Record the failover's outcome without re-counting the routed prompt's own
2019
+ * metrics: fresh id, applied-switch fields neutralized. The panel line comes
2020
+ * from the annotation.
2021
+ */
2022
+ private noteSelfHeal(
2023
+ sessionID: string,
2024
+ route: { decision: RoutingDecision } | undefined,
2025
+ rejected: ModelRef,
2026
+ action: string,
2027
+ ): void {
2028
+ if (!route) return
2029
+ this.ledger.recordDecision({
2030
+ ...route.decision,
2031
+ id: `${route.decision.id}+heal-note`,
2032
+ switched: false,
2033
+ dryRun: false,
2034
+ needsPremiumApproval: false,
2035
+ chosen: route.decision.current,
2036
+ selfheal: { rejected: `${rejected.providerID}/${rejected.id}`, action },
2037
+ })
2038
+ this.persistLedgerSoon()
2039
+ }
2040
+
2041
+ /** Record that a session has been on a model, so a later change back to
2042
+ * it is read as the client catching up rather than a fresh pick. */
2043
+ private rememberModel(sessionID: string, providerID: string, id: string) {
2044
+ let seen = this.modelsSeen.get(sessionID)
2045
+ if (!seen) {
2046
+ seen = new Set()
2047
+ this.modelsSeen.set(sessionID, seen)
2048
+ }
2049
+ seen.add(modelKey(providerID, id))
2050
+ }
2051
+
2052
+ /**
2053
+ * The context window of the cheapest variant of a model, across providers.
2054
+ *
2055
+ * The same model id is often served by more than one provider at different
2056
+ * prices. The variant the router wants to keep a session on is the cheapest
2057
+ * one, so that variant's window is the size the session needs to fit.
2058
+ * Ties prefer the larger window.
2059
+ */
2060
+ /**
2061
+ * Whether OpenCode exposes a model RIGHT NOW.
2062
+ *
2063
+ * The router knows a model because the gateway serves it and the owner approved it.
2064
+ * OpenCode knows it only if it is in the provider's configured model list. When the
2065
+ * two disagree, switching produces a session that fails on its NEXT step with
2066
+ * "Model unavailable: provider/model", which reads like a dead provider and is not
2067
+ * one. Observed 2026-11-03: the plugin directory was replaced underneath a running
2068
+ * server, the provider's model list came back empty, and four live sessions died
2069
+ * that way.
2070
+ *
2071
+ * So ask the host instead of assuming. If the host cannot answer at all, allow the
2072
+ * switch: the previous behaviour was to try it and surface the real error, and a
2073
+ * false negative here would silently stop all routing.
2074
+ */
2075
+ /**
2076
+ * Whether a turn is currently running for this session.
2077
+ *
2078
+ * Two independent signals, because neither is complete on its own:
2079
+ *
2080
+ * - `stepStarted` is set in-process and cleared on the step's terminal event.
2081
+ * It is the precise one, but a plugin reload empties it, so a session that
2082
+ * was already mid-turn looks idle.
2083
+ * - `status` is persisted in the ledger and survives a reload, so it covers
2084
+ * exactly that gap. It is also written by auto-compaction.
2085
+ *
2086
+ * Either one saying "busy" is enough. A false positive only defers a switch
2087
+ * to the next prompt, which costs nothing; a false negative breaks the turn.
2088
+ */
2089
+ private sessionBusy(sessionID: string): boolean {
2090
+ if (this.stepStarted.has(sessionID)) return true
2091
+ return this.ledger.getSession(sessionID)?.status === "busy"
2092
+ }
2093
+
2094
+ private async modelResolvable(providerID: string, modelID: string): Promise<boolean> {
2095
+ // The host is the authority on which models it can serve, and there are two
2096
+ // ways to ask it. Use both, cheapest and most authoritative first.
2097
+ //
2098
+ // 1. The model list this plugin already fetched. It is the closest thing to
2099
+ // "what this machine can actually serve", but it is not exhaustive: a
2100
+ // provider read from a gateway (OpenDesign) is merged in separately, and
2101
+ // a provider that is not configured yet is absent rather than empty. So
2102
+ // the refusal is scoped to the case that is genuinely knowable: the host
2103
+ // lists this provider, and does not list this model. That is the state
2104
+ // that produced "Model unavailable: opencode-go/claude-haiku-5-5" on a
2105
+ // fresh install (scripts/isolated-e2e.sh, 2026-11-06).
2106
+ const known = unwrap<any>(this.modelList ?? [])
2107
+ if (Array.isArray(known) && known.length > 0) {
2108
+ const providerIsKnown = known.some((entry: any) => entry?.providerID === providerID)
2109
+ if (providerIsKnown) {
2110
+ const modelIsKnown = known.some(
2111
+ (entry: any) => entry?.providerID === providerID && entry?.id === modelID,
2112
+ )
2113
+ if (!modelIsKnown) return false
2114
+ }
2115
+ }
2116
+ // 2. Ask the host directly. A MISSING API is not a "no": it means this host
2117
+ // cannot answer the question, so fall back to attempting the switch and
2118
+ // surfacing whatever happens. Only an API that answers and returns
2119
+ // nothing is treated as a real refusal.
2120
+ const get = this.ctx?.model?.get
2121
+ if (typeof get !== "function") return true
2122
+ try {
2123
+ const model = await get.call(this.ctx.model, providerID, modelID)
2124
+ return Boolean(model)
2125
+ } catch {
2126
+ return true
2127
+ }
2128
+ }
2129
+
2130
+ /**
2131
+ * The candidate list a routing decision may choose from.
2132
+ *
2133
+ * Offline mode replaces the approved-online list with the local models. The
2134
+ * engine applies its own allow-list on top, so this is a convenience rather
2135
+ * than the security boundary; the boundary is in `routeTask` and stays there
2136
+ * even if a future caller passes the wrong list.
2137
+ *
2138
+ * MODELS THE HOST CANNOT SERVE ARE NOT CANDIDATES.
2139
+ *
2140
+ * The seed catalog describes providers in general; it does not know which of
2141
+ * them are configured on this machine. Offering the whole catalog meant the
2142
+ * router could choose a model OpenCode had never heard of and switch the
2143
+ * session onto it, after which the next step died with "Model unavailable".
2144
+ * Verified on a fresh install in scripts/isolated-e2e.sh (2026-11-06): the
2145
+ * host exposed only its own default model, and routing reached for
2146
+ * `opencode-go/claude-haiku-5-5`.
2147
+ *
2148
+ * The live list is authoritative once it has been read: it is OpenCode's own
2149
+ * catalog plus the OpenDesign gateway's entitled models. Until it has been
2150
+ * read it is empty, and an empty list must not filter anything out, or the
2151
+ * router would have no candidates during startup.
2152
+ */
2153
+ private async routingModels(): Promise<ModelRecord[]> {
2154
+ if (this.settings.offline?.enabled) return this.registry.modelsForOffline()
2155
+ const approved = this.registry.approved()
2156
+ // WAIT FOR THE HOST'S MODEL LIST ON THE FIRST ROUTING DECISION.
2157
+ //
2158
+ // Discovery starts in the background during initialize(), so the first
2159
+ // prompt after startup used to route against an empty list -- the exact
2160
+ // window in which the router could pick a seed model this machine cannot
2161
+ // serve. Awaiting it is bounded by the routing deadline, and a discovery
2162
+ // that fails leaves the list empty, which falls back to the old behaviour.
2163
+ await this.ensureModelsLoaded()
2164
+ const live = unwrap<any>(this.modelList ?? [])
2165
+ if (!Array.isArray(live) || live.length === 0) return approved
2166
+ const servable = new Set(live.map((entry: any) => modelKey(entry?.providerID ?? "", entry?.id ?? "")))
2167
+ // No fallback to the unfiltered list. Returning the full catalog when the
2168
+ // intersection is empty is how an unservable model gets chosen anyway: on a
2169
+ // machine whose providers do not match the seed catalog, filtering produced
2170
+ // an empty list, the fallback reinstated every seed model, and the router
2171
+ // switched onto `opencode-go/claude-haiku-5-5` -- a model the host had never
2172
+ // heard of. Verified in scripts/isolated-e2e.sh on 2026-11-06.
2173
+ //
2174
+ // An empty list is the correct answer there. It means "this plugin has
2175
+ // nothing it may usefully route to on this machine", and the router then
2176
+ // leaves the session alone, which is what the host would have done had the
2177
+ // plugin not been installed.
2178
+ return approved.filter((model) => servable.has(model.key))
2179
+ }
2180
+
2181
+ /** One in-flight model discovery shared by every caller. */
2182
+ private async ensureModelsLoaded(): Promise<void> {
2183
+ if (unwrap<any>(this.modelList ?? []).length > 0) return
2184
+ if (!this.modelLoadPromise) {
2185
+ this.modelLoadPromise = this.loadModels().finally(() => {
2186
+ this.modelLoadPromise = undefined
2187
+ })
2188
+ }
2189
+ await this.modelLoadPromise
2190
+ }
2191
+
2192
+ /**
2193
+ * The intelligence floor to route with.
2194
+ *
2195
+ * With calibration enabled this replaces the hand-picked number with one
2196
+ * derived from what the owner actually spent over the recorded window. It is
2197
+ * memoized briefly because it is otherwise recomputed for every decision,
2198
+ * while the underlying ledger changes per request, not per candidate.
2199
+ *
2200
+ * Offline mode bypasses it: that path has its own allow-list and its own
2201
+ * relationship with the floor, and online cost calibration must not reach
2202
+ * into it.
2203
+ */
2204
+ /**
2205
+ * Everything needed to judge whether the floor in force is trustworthy.
2206
+ *
2207
+ * Reads the ledger directly rather than the memoized routing value, so the
2208
+ * report answers for the current moment even when nothing has routed yet.
2209
+ */
2210
+ private floorReport() {
2211
+ const models = this.registry.approved()
2212
+ const configured = this.settings.routing.intelligenceFloor
2213
+ const enabled = this.settings.routing.calibrateFloor === true
2214
+ const offline = this.settings.offline?.enabled === true
2215
+ const now = Date.now()
2216
+ const usage = this.ledger.usageByModel({ since: now - CALIBRATION_WINDOW_MS })
2217
+ const calibration = resolveIntelligenceFloor(this.settings, { models, usage, now })
2218
+ const inForce = enabled && !offline ? calibration.floor : configured
2219
+
2220
+ const perModel = usage
2221
+ .filter((row) => row.requests > 0)
2222
+ .map((row) => {
2223
+ const record = this.registry.get(row.providerID, row.modelID)
2224
+ return {
2225
+ model: `${row.providerID}/${row.modelID}`,
2226
+ requests: row.requests,
2227
+ usd: Number(row.usd.toFixed(6)),
2228
+ usdPerRequest: Number((row.usd / row.requests).toFixed(6)),
2229
+ intelligence: record?.intelligence ?? null,
2230
+ }
2231
+ })
2232
+ .sort((a, b) => a.usdPerRequest - b.usdPerRequest)
2233
+
2234
+ return {
2235
+ calibrateFloor: enabled,
2236
+ offlineBypass: offline,
2237
+ configuredFloor: configured,
2238
+ floorInForce: inForce,
2239
+ calibrated: calibration.calibrated,
2240
+ source: calibration.calibrated ? "measured" : enabled ? "configured (not calibrated)" : "configured",
2241
+ reason: calibration.reason,
2242
+ baselineUsdPerRequest: calibration.baselineUsdPerRequest,
2243
+ admittedUsdPerRequest: calibration.admittedUsdPerRequest,
2244
+ windowDays: Math.round(CALIBRATION_WINDOW_MS / 86_400_000),
2245
+ minRequests: MIN_REQUESTS_FOR_COST_EVIDENCE,
2246
+ // What the floor actually admits, so the number has a meaning attached.
2247
+ admitted: models
2248
+ .filter((model) => !model.escalationOnly && !model.premium)
2249
+ .filter((model) => model.intelligence >= inForce)
2250
+ .map((model) => ({
2251
+ model: `${model.providerID}/${model.modelID}`,
2252
+ intelligence: model.intelligence,
2253
+ listInputUsdPerMtok: model.cost?.[0]?.input ?? null,
2254
+ }))
2255
+ .sort((a, b) => (a.listInputUsdPerMtok ?? 0) - (b.listInputUsdPerMtok ?? 0)),
2256
+ measuredTraffic: perModel,
2257
+ totalRequests: perModel.reduce((sum, row) => sum + row.requests, 0),
2258
+ }
2259
+ }
2260
+
2261
+ private routingFloor(models: ReadonlyArray<ModelRecord>): { floor: number; calibration?: CalibrationResult } {
2262
+ if (this.settings.routing.calibrateFloor !== true || this.settings.offline?.enabled === true) {
2263
+ return { floor: this.settings.routing.intelligenceFloor }
2264
+ }
2265
+ const now = Date.now()
2266
+ if (this.floorCache && now - this.floorCache.at < FLOOR_CACHE_MS) return this.floorCache.value
2267
+ const calibration = resolveIntelligenceFloor(this.settings, {
2268
+ models,
2269
+ usage: this.ledger.usageByModel({ since: now - CALIBRATION_WINDOW_MS }),
2270
+ now,
2271
+ })
2272
+ const value = { floor: calibration.floor, calibration }
2273
+ this.floorCache = { at: now, value }
2274
+ return value
2275
+ }
2276
+
2277
+ private cheapestVariantContext(modelID: string): number {
2278
+ let best: { multiplier: number; context: number } | undefined
2279
+ for (const model of this.registry.approved()) {
2280
+ if (model.modelID !== modelID) continue
2281
+ const multiplier = model.creditMultiplier && model.creditMultiplier > 0 ? model.creditMultiplier : 1
2282
+ if (
2283
+ !best ||
2284
+ multiplier < best.multiplier ||
2285
+ (multiplier === best.multiplier && model.context > best.context)
2286
+ ) {
2287
+ best = { multiplier, context: model.context }
2288
+ }
2289
+ }
2290
+ return best?.context ?? 0
2291
+ }
2292
+
2293
+ /**
2294
+ * Router-driven conversation compaction.
2295
+ *
2296
+ * Compaction is destructive: it replaces the transcript with a summary and
2297
+ * the detail is gone. It was introduced when OpenDesign Go's $10 allowance
2298
+ * made a long session on the cheapest provider unaffordable, and it is now
2299
+ * governed by `contextManagement.mode`:
2300
+ *
2301
+ * - `native` (default) never compacts except to avoid a verified overflow.
2302
+ * - `conservative` compacts only to prevent a real overflow.
2303
+ * - `budget-optimized` preserves the old early-compaction behavior for
2304
+ * small plans and small-window models.
2305
+ *
2306
+ * In every mode the effective window is the one the provider actually
2307
+ * accepts, never a theoretical maximum.
2308
+ */
2309
+ private maybeCompactForCost(sessionID: string) {
2310
+ const settings = this.settings
2311
+ if (!settings.enabled || this.runtime.paused === true) return
2312
+ if (this.ledger.getSession(sessionID)?.paused === true) return
2313
+ if (!settings.contextManagement.enabled) return
2314
+ const session = this.ledger.getSession(sessionID)
2315
+ const model = session?.lastModel
2316
+ if (!model) return
2317
+ if (session?.status !== "idle") return
2318
+ const record = this.registry.get(model.providerID, model.id)
2319
+ if (!record || record.context <= 0) return
2320
+ const contextTokens = session?.contextTokens ?? 0
2321
+ if (contextTokens <= 0) return
2322
+ const now = Date.now()
2323
+ const cooldownMs = settings.contextManagement.cooldownMinutes * 60_000
2324
+ const last = this.routerCompactAt.get(sessionID)
2325
+ if (typeof last === "number" && now - last < cooldownMs) return
2326
+
2327
+ // The window that matters is the one the *current* model accepts. The
2328
+ // cheapest variant's window is only relevant to the budget-optimized
2329
+ // strategy, which trades context for staying on the cheapest route.
2330
+ const effectiveWindow = record.context
2331
+ const cheapestWindow =
2332
+ settings.contextManagement.mode === "budget-optimized"
2333
+ ? this.cheapestVariantContext(record.modelID) || record.context
2334
+ : effectiveWindow
2335
+
2336
+ const decision = decideCompaction({
2337
+ mode: settings.contextManagement.mode,
2338
+ settings: settings.contextManagement,
2339
+ contextTokens,
2340
+ effectiveWindow,
2341
+ cheapestWindow,
2342
+ })
2343
+ this.lastCompactionDecision.set(sessionID, decision)
2344
+ if (!decision.compact) return
2345
+
2346
+ if (typeof this.ctx.session.compact !== "function") return
2347
+ this.routerCompactAt.set(sessionID, now)
2348
+ this.ledger.patchSession(sessionID, { status: "busy" })
2349
+ this.persistLedgerSoon()
2350
+ void Promise.resolve(
2351
+ this.ctx.session.compact({ sessionID }),
2352
+ )
2353
+ .then(() => {
2354
+ this.ledger.patchSession(sessionID, { status: "idle" })
2355
+ void this.emit("status", {
2356
+ sessionID,
2357
+ summary: {
2358
+ note: `${decision.hardLimit ? "Protected context limit" : "Compacted"}: ${decision.reason} (${(contextTokens / 1000).toFixed(0)}k to fit ${((decision.targetTokens ?? effectiveWindow) / 1000).toFixed(0)}k)`,
2359
+ contextManagement: settings.contextManagement.mode,
2360
+ },
2361
+ })
2362
+ })
2363
+ .catch((error: unknown) => {
2364
+ this.ledger.patchSession(sessionID, { status: "idle" })
2365
+ this.settingsWarnings.push(`Auto-compact failed: ${safeError(error)}`)
2366
+ })
2367
+ }
2368
+
2369
+ /**
2370
+ * True when this exact failure was already recorded for the session.
2371
+ *
2372
+ * `attemptKey` identifies the failed attempt. When the host provides one
2373
+ * (the assistant message id), it is authoritative: the same attempt is
2374
+ * reported once, however many channels describe it. When it does not,
2375
+ * the error text plus a short window stands in, which still collapses the
2376
+ * channels of one attempt without merging a later, separate fault.
2377
+ */
2378
+ private alreadyRecorded(sessionID: string, failureClass: string, message: string, attemptKey?: string): boolean {
2379
+ const now = Date.now()
2380
+ const key = attemptKey
2381
+ ? `${sessionID} ${failureClass} attempt:${attemptKey}`
2382
+ : `${sessionID} ${failureClass} text:${message.slice(0, 200)}`
2383
+ const previous = this.recordedFaults.get(key)
2384
+ if (attemptKey) {
2385
+ // An attempt identity is exact, so it never expires into a new fault.
2386
+ if (previous !== undefined) return true
2387
+ } else if (typeof previous === "number" && now - previous < FAULT_DEDUPE_WINDOW_MS) {
2388
+ return true
2389
+ }
2390
+ this.recordedFaults.set(key, now)
2391
+ // Keep the map bounded; entries older than the window are dead weight.
2392
+ if (this.recordedFaults.size > 500) {
2393
+ for (const [candidate, at] of this.recordedFaults) {
2394
+ if (now - at >= FAULT_DEDUPE_WINDOW_MS) this.recordedFaults.delete(candidate)
2395
+ }
2396
+ }
2397
+ return false
2398
+ }
2399
+
2400
+ async handleEvent(event: any) {
2401
+ const type = event?.type
2402
+ // Env-gated event trace. Used to learn which bus events carry a provider
2403
+ // fault; inert unless SMART_ROUTER_EVENT_LOG is set.
2404
+ const eventLog = process.env.SMART_ROUTER_EVENT_LOG
2405
+ if (eventLog) {
2406
+ try {
2407
+ appendFileSync(eventLog, `${JSON.stringify({ at: Date.now(), type, sessionID: event?.data?.sessionID, keys: Object.keys(event?.data ?? {}) })}\n`)
2408
+ } catch {
2409
+ // Diagnostics only.
2410
+ }
2411
+ }
2412
+ const data = event?.data ?? {}
2413
+ const eventSessionID = typeof data.sessionID === "string" ? data.sessionID : undefined
2414
+ if (eventSessionID && type !== "session.created") {
2415
+ if (!(await this.ensureOwnership(eventSessionID))) {
2416
+ sharedCore.noteEvent(false)
2417
+ return
2418
+ }
2419
+ }
2420
+ sharedCore.noteEvent(true)
2421
+ if (type === "session.created") {
2422
+ const sessionID = data.sessionID
2423
+ if (typeof sessionID !== "string") return
2424
+ if (
2425
+ !this.projectMatches(
2426
+ typeof data.projectID === "string" ? data.projectID : undefined,
2427
+ typeof data.location?.directory === "string" ? data.location.directory : undefined,
2428
+ )
2429
+ ) {
2430
+ sharedCore.noteEvent(false)
2431
+ return
2432
+ }
2433
+ if (!this.created.has(sessionID) && this.created.size >= 500) {
2434
+ const oldest = this.created.keys().next().value
2435
+ if (oldest) this.created.delete(oldest)
2436
+ }
2437
+ this.created.set(sessionID, Date.now())
2438
+ const session = this.ledger.ensureSession(sessionID, data.parentID, data.title)
2439
+ if (typeof data.projectID === "string") this.ledger.patchSession(sessionID, { projectID: data.projectID })
2440
+ if (typeof data.location?.directory === "string") {
2441
+ this.ledger.patchSession(sessionID, { directory: data.location.directory })
2442
+ }
2443
+ const base = stripTitlePrefix(typeof data.title === "string" ? data.title : undefined)
2444
+ if (base && !session.baseTitle) this.ledger.patchSession(sessionID, { baseTitle: base })
2445
+ this.persistLedgerSoon()
2446
+ return
2447
+ }
2448
+ if (type === "session.renamed") {
2449
+ const sessionID = data.sessionID
2450
+ if (typeof sessionID !== "string" || typeof data.title !== "string") return
2451
+ const session = this.ledger.getSession(sessionID)
2452
+ if (!session) return
2453
+ if (data.title === session.indicatorTitle) return
2454
+ const base = stripTitlePrefix(data.title)
2455
+ if (base) this.ledger.patchSession(sessionID, { baseTitle: base })
2456
+ void this.queueTitle(sessionID)
2457
+ return
2458
+ }
2459
+ if (type === "session.deleted") {
2460
+ if (eventSessionID) {
2461
+ sharedCore.releaseSession(eventSessionID)
2462
+ this.stepStarted.delete(eventSessionID)
2463
+ this.contextLoaded.delete(eventSessionID)
2464
+ this.expectedSwitch.delete(eventSessionID)
2465
+ this.promptSeen.delete(eventSessionID)
2466
+ this.created.delete(eventSessionID)
2467
+ }
2468
+ return
2469
+ }
2470
+ if (type === "session.model.selected") {
2471
+ const sessionID = data.sessionID
2472
+ if (typeof sessionID !== "string") return
2473
+ const model = modelRefFrom(data.model)
2474
+ if (!model) return
2475
+ const expected = this.expectedSwitch.get(sessionID)
2476
+ const key = modelKey(model.providerID, model.id)
2477
+ const isOurs = expected?.key === key && Date.now() - expected.at < 15_000
2478
+ if (isOurs) {
2479
+ this.expectedSwitch.delete(sessionID)
2480
+ return
2481
+ }
2482
+ const created = this.created.get(sessionID) ?? 0
2483
+ const initialSelection = !this.promptSeen.has(sessionID) && Date.now() - created < 5_000 && !data.previous
2484
+ if (initialSelection) return
2485
+ // A change back to a model this session has already been on is the
2486
+ // client catching up with its own saved selection, not the owner
2487
+ // picking a model. Only a model new to the session suspends routing.
2488
+ const changedModelKey = modelKey(model.providerID, model.id)
2489
+ const seen = this.modelsSeen.get(sessionID)
2490
+ const isCatchUp = seen?.has(changedModelKey) === true
2491
+ this.rememberModel(sessionID, model.providerID, model.id)
2492
+ if (isCatchUp) {
2493
+ this.ledger.patchSession(sessionID, { lastModel: model })
2494
+ this.persistLedgerSoon()
2495
+ return
2496
+ }
2497
+ this.ledger.setManual(sessionID, model)
2498
+ this.persistLedgerSoon()
2499
+ void this.emit("status", { sessionID, summary: { manualOverride: true, model } })
2500
+ void this.queueTitle(sessionID)
2501
+ this.modelNotice(sessionID, "MANUAL", model, "automatic routing suspended · /router-auto to resume")
2502
+ return
2503
+ }
2504
+ if (type === "session.execution.started") {
2505
+ const sessionID = data.sessionID
2506
+ if (typeof sessionID === "string") {
2507
+ if (!this.stepStarted.has(sessionID) && this.stepStarted.size >= 500) {
2508
+ const oldest = this.stepStarted.keys().next().value
2509
+ if (oldest) this.stepStarted.delete(oldest)
2510
+ }
2511
+ this.stepStarted.set(sessionID, Date.now())
2512
+ this.ledger.patchSession(sessionID, { status: "busy" })
2513
+ this.persistLedgerSoon()
2514
+ }
2515
+ return
2516
+ }
2517
+ if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted" || type === "session.idle") {
2518
+ const sessionID = data.sessionID
2519
+ if (typeof sessionID === "string") {
2520
+ this.ledger.patchSession(sessionID, { status: "idle" })
2521
+ this.stepStarted.delete(sessionID)
2522
+ // A failover parked because the turn was busy switches here: the turn
2523
+ // is over, so the queued-step hazard is gone.
2524
+ if (type !== "session.execution.succeeded") {
2525
+ try {
2526
+ if (!this.sessionBusy(sessionID)) await this.flushPendingHeal(sessionID)
2527
+ } catch (healError) {
2528
+ this.settingsWarnings.push(`Prompt-rejection failover failed: ${safeError(healError)}`)
2529
+ }
2530
+ }
2531
+ // `session.execution.failed` and `.interrupted` carry no outcome
2532
+ // field; the event type itself is the signal. `session.idle` does
2533
+ // carry an outcome. Recording these is what lets the router see a
2534
+ // model that keeps dropping streams.
2535
+ const outcome = typeof data.outcome === "string" ? data.outcome : undefined
2536
+ const attemptKey = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
2537
+ if (type === "session.execution.failed" || outcome === "failed") {
2538
+ await this.recordProviderFault(sessionID, "failed", data.error, attemptKey)
2539
+ } else if (type === "session.execution.interrupted" || outcome === "interrupted") {
2540
+ await this.recordProviderFault(sessionID, "interrupted", data.error, attemptKey)
2541
+ }
2542
+ // Idle is the only safe moment to compact: a running turn cannot be
2543
+ // interrupted. Compacting here keeps the session inside the cheapest
2544
+ // provider's window, so the next prompt does not get pushed onto a
2545
+ // costlier provider purely because the conversation grew.
2546
+ //
2547
+ // Note the host's `session.idle` bus event carries no outcome field
2548
+ // (only the persisted message does), so success cannot be read here.
2549
+ // `session.execution.succeeded` is the signal that a turn finished
2550
+ // cleanly; the idle event covers the rest. The compaction cooldown is
2551
+ // what stops the two from both firing.
2552
+ if (type === "session.execution.succeeded" || type === "session.idle") {
2553
+ this.maybeCompactForCost(sessionID)
2554
+ }
2555
+ this.persistLedgerSoon()
2556
+ }
2557
+ return
2558
+ }
2559
+ if (type === "session.compaction.ended" || type === "session.compaction.failed") {
2560
+ const sessionID = data.sessionID
2561
+ if (typeof sessionID !== "string") return
2562
+ // A compaction rebuilds the transcript, so the context size the router
2563
+ // has been carrying is stale: it still reflects the pre-compaction
2564
+ // session. That stale number is what the context gate uses to decide
2565
+ // whether a model can serve the session, so a session that was just
2566
+ // compacted can be wrongly excluded from the cheapest provider - it
2567
+ // looks too large for OpenDesign's window when it is not.
2568
+ //
2569
+ // Drop the cached value so the next prompt re-reads the real size.
2570
+ this.contextLoaded.delete(sessionID)
2571
+ const session = this.ledger.getSession(sessionID)
2572
+ if (session) {
2573
+ this.ledger.patchSession(sessionID, { contextTokens: 0, cacheReadTokens: 0 })
2574
+ }
2575
+ // Re-read the real size now rather than at the next prompt. The
2576
+ // compaction message's own token counts describe the transcript it
2577
+ // replaced, not the rebuilt one, so the authoritative value is the
2578
+ // last assistant step after the compaction.
2579
+ void this.refreshContextAfterCompaction(sessionID)
2580
+ this.persistLedgerSoon()
2581
+ return
2582
+ }
2583
+ if (type === "session.step.ended") {
2584
+ const sessionID = data.sessionID
2585
+ if (typeof sessionID !== "string") return
2586
+ const session = this.ledger.getSession(sessionID)
2587
+ const model = session?.lastModel
2588
+ const tokens = tokensFrom(data.tokens)
2589
+ const latency = this.stepStarted.get(sessionID) ? Date.now() - (this.stepStarted.get(sessionID) ?? Date.now()) : undefined
2590
+ if (model) {
2591
+ // Plan-model-pool identity is resolved from the model id, so every
2592
+ // recorded step attributes its spend to the right allowance: plan
2593
+ // models draw the $120 escalation pool; Design Plan models and every
2594
+ // other provider's models draw their own.
2595
+ const modelRecord = this.registry.get(model.providerID, model.id)
2596
+ const isOpendesignPlanPool =
2597
+ model.providerID === "opendesign" && modelRecord !== undefined && !OPENDESIGN_DESIGN_PLAN_MODELS.has(model.id)
2598
+ this.ledger.recordUsage({
2599
+ sessionID,
2600
+ model,
2601
+ cost: typeof data.cost === "number" ? data.cost : 0,
2602
+ tokens,
2603
+ at: typeof event.created === "number" ? event.created : Date.now(),
2604
+ latencyMs: latency,
2605
+ premium: modelRecord?.premium ?? false,
2606
+ planPool: isOpendesignPlanPool,
2607
+ })
2608
+ // A completed step is proof the provider's pre-request path worked,
2609
+ // so any run of provider-scoped failures starts over.
2610
+ this.ledger.recordProviderSuccess(model.providerID)
2611
+ }
2612
+ this.persistLedgerSoon()
2613
+ return
2614
+ }
2615
+ if (type === "session.step.failed") {
2616
+ const sessionID = data.sessionID
2617
+ if (typeof sessionID !== "string") return
2618
+ // The assistant message id identifies the attempt, so the same
2619
+ // attempt reported through several channels is counted once.
2620
+ const attemptKey = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
2621
+ await this.recordProviderFault(sessionID, "failed", data.error, attemptKey)
2622
+ this.persistLedgerSoon()
2623
+ return
2624
+ }
2625
+ if (type === "session.retry.scheduled") {
2626
+ const sessionID = data.sessionID
2627
+ if (typeof sessionID === "string") {
2628
+ // This event only schedules a retry; the failure itself is recorded
2629
+ // by the retry hook or the step/execution events, so it must not
2630
+ // add a strike of its own.
2631
+ this.ledger.recordRetry(sessionID)
2632
+ this.persistLedgerSoon()
2633
+ }
2634
+ }
2635
+ }
2636
+
2637
+ private async registerRpc() {
2638
+ const implementation: any = {
2639
+ status: async (input: any) => this.statusFor(input?.sessionID),
2640
+ onboarding: async () => this.onboardingState(),
2641
+ completeOnboarding: async () => {
2642
+ this.onboardingComplete = true
2643
+ return { ok: true }
2644
+ },
2645
+ recordPlanPage: async (input: any) => this.recordPlanPage(input),
2646
+ preview: async (input: any) => {
2647
+ const sessionID = input?.sessionID ?? "preview"
2648
+ const session = this.ledger.getSession(sessionID)
2649
+ const task = classifyTask({
2650
+ text: input?.text ?? "",
2651
+ files: this.classifierFiles(input?.files),
2652
+ contextTokens: session?.contextTokens ?? 0,
2653
+ recentFailures: session?.failures.length ?? 0,
2654
+ })
2655
+ const child = Boolean(session?.parentID)
2656
+ const previewMode = child && this.settings.mode === "premium" ? "balanced" : this.settings.mode
2657
+ const previewPremium = child ? { ...this.premiumState(session), oneShotGranted: false } : this.premiumState(session)
2658
+ const result = routeTask({
2659
+ task,
2660
+ settings: this.settings,
2661
+ mode: previewMode,
2662
+ runtime: session?.manualOverride ? "manual" : this.settings.enabled ? "automatic" : "paused",
2663
+ models: this.registry.approved(),
2664
+ current: session?.lastModel,
2665
+ session,
2666
+ quota: this.quotaSnapshots(),
2667
+ premium: previewPremium,
2668
+ contextTokens: session?.contextTokens ?? 0,
2669
+ cachedTokens: session?.cacheReadTokens ?? 0,
2670
+ dryRun: true,
2671
+ modelHistory: this.ledger.snapshotMetrics().byModel,
2672
+ cooldowns: this.ledger.cooldowns(),
2673
+ })
2674
+ return result.decision
2675
+ },
2676
+ setMode: async (input: any) => {
2677
+ this.runtime.mode = input.mode
2678
+ this.persistRuntime()
2679
+ const summary = this.statusFor()
2680
+ void this.emit("status", { summary })
2681
+ return summary
2682
+ },
2683
+ setPaused: async (input: any) => {
2684
+ this.runtime.paused = Boolean(input.paused)
2685
+ this.persistRuntime()
2686
+ void this.notifyChange(
2687
+ `global:paused:${Boolean(input.paused)}`,
2688
+ input.paused ? "PAUSED · automatic routing is paused" : "AUTO · automatic routing resumed",
2689
+ )
2690
+ const summary = this.statusFor()
2691
+ void this.emit("status", { summary })
2692
+ return summary
2693
+ },
2694
+ setSessionAuto: async (input: any) => {
2695
+ const sessionID = input.sessionID
2696
+ await this.loadSessionInfo(sessionID)
2697
+ const auto = Boolean(input.auto)
2698
+ this.ledger.patchSession(sessionID, { paused: !auto })
2699
+ if (auto) {
2700
+ // Handing control back means letting the router pick again; leaving a
2701
+ // manual override in place would make the switch look broken.
2702
+ this.ledger.clearManual(sessionID)
2703
+ this.ledger.patchSession(sessionID, { pinnedModel: undefined })
2704
+ }
2705
+ this.persistLedgerSoon()
2706
+ await this.queueTitle(sessionID)
2707
+ this.modelNotice(
2708
+ sessionID,
2709
+ auto ? "AUTO" : "MANUAL",
2710
+ this.ledger.getSession(sessionID)?.lastModel,
2711
+ auto ? "automatic routing resumed" : "automatic routing off for this session",
2712
+ )
2713
+ const summary = this.statusFor(sessionID)
2714
+ void this.emit("status", { sessionID, summary })
2715
+ return summary
2716
+ },
2717
+ setDryRun: async (input: any) => {
2718
+ this.runtime.dryRun = Boolean(input.dryRun)
2719
+ this.persistRuntime()
2720
+ void this.notifyChange(
2721
+ `global:dry-run:${Boolean(input.dryRun)}`,
2722
+ input.dryRun ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
2723
+ )
2724
+ const summary = this.statusFor()
2725
+ void this.emit("status", { summary })
2726
+ return summary
2727
+ },
2728
+ resumeAuto: async (input: any) => {
2729
+ const sessionID = input.sessionID
2730
+ await this.loadSessionInfo(sessionID)
2731
+ this.ledger.clearManual(sessionID)
2732
+ this.ledger.patchSession(sessionID, { pinnedModel: undefined, paused: false, premiumOneShot: false })
2733
+ this.persistLedgerSoon()
2734
+ await this.queueTitle(sessionID)
2735
+ this.modelNotice(sessionID, "AUTO", this.ledger.getSession(sessionID)?.lastModel, "automatic routing resumed")
2736
+ const summary = this.statusFor(sessionID)
2737
+ void this.emit("status", { sessionID, summary })
2738
+ return summary
2739
+ },
2740
+ pin: async (input: any) => {
2741
+ const model: ModelRef = {
2742
+ providerID: input.providerID,
2743
+ id: input.modelID,
2744
+ ...(typeof input.variant === "string" && input.variant.length > 0 ? { variant: input.variant } : {}),
2745
+ }
2746
+ this.ledger.setManual(input.sessionID, model)
2747
+ this.ledger.patchSession(input.sessionID, { pinnedModel: model, lastModel: model })
2748
+ this.rememberModel(input.sessionID, model.providerID, model.id)
2749
+ void this.queueTitle(input.sessionID)
2750
+ this.modelNotice(input.sessionID, "MANUAL", model, "pinned · /router-auto to resume")
2751
+ this.expectedSwitch.set(input.sessionID, { key: modelKey(model.providerID, model.id), at: Date.now() })
2752
+ try {
2753
+ await this.ctx.session.switchModel({ sessionID: input.sessionID, model })
2754
+ } catch (error) {
2755
+ this.expectedSwitch.delete(input.sessionID)
2756
+ this.settingsWarnings.push(`Pin switch failed: ${safeError(error)}`)
2757
+ }
2758
+ this.persistLedgerSoon()
2759
+ const summary = this.statusFor(input.sessionID)
2760
+ void this.emit("status", { sessionID: input.sessionID, summary })
2761
+ return summary
2762
+ },
2763
+ approvePremium: async (input: any) => {
2764
+ this.ledger.grantPremiumOneShot(input.sessionID)
2765
+ this.persistLedgerSoon()
2766
+ void this.notifyChange(`${input.sessionID}:premium-granted`, "Premium approval granted for this session.")
2767
+ const summary = this.statusFor(input.sessionID)
2768
+ void this.emit("status", { sessionID: input.sessionID, summary })
2769
+ return summary
2770
+ },
2771
+ denyPremium: async (input: any) => {
2772
+ this.ledger.consumePremiumOneShot(input.sessionID)
2773
+ this.persistLedgerSoon()
2774
+ void this.notifyChange(`${input.sessionID}:premium-denied`, "Premium approval cleared for this session.")
2775
+ const summary = this.statusFor(input.sessionID)
2776
+ void this.emit("status", { sessionID: input.sessionID, summary })
2777
+ return summary
2778
+ },
2779
+ excludeModel: async (input: any) => {
2780
+ const key = modelKey(input.providerID, input.modelID)
2781
+ const excluded = new Set(this.runtime.excluded ?? [])
2782
+ if (input.excluded) excluded.add(key)
2783
+ else excluded.delete(key)
2784
+ this.runtime.excluded = [...excluded]
2785
+ this.persistRuntime()
2786
+ this.rebuildRegistry()
2787
+ const summary = this.statusFor()
2788
+ void this.emit("status", { summary })
2789
+ return summary
2790
+ },
2791
+ setPremiumBudget: async (input: any) => {
2792
+ this.runtime.premiumBudgetUsd = input.usd
2793
+ this.persistRuntime()
2794
+ const summary = this.statusFor()
2795
+ void this.emit("status", { summary })
2796
+ return summary
2797
+ },
2798
+ setReserve: async (input: any) => {
2799
+ const reserves = { ...(this.runtime.reserves ?? {}) }
2800
+ reserves[input.providerID] = {
2801
+ ...(reserves[input.providerID] ?? {}),
2802
+ ...(typeof input.monthlyPct === "number" ? { monthlyPct: input.monthlyPct } : {}),
2803
+ ...(typeof input.weeklyPct === "number" ? { weeklyPct: input.weeklyPct } : {}),
2804
+ ...(typeof input.fiveHourPct === "number" ? { fiveHourPct: input.fiveHourPct } : {}),
2805
+ }
2806
+ this.runtime.reserves = reserves
2807
+ this.persistRuntime()
2808
+ const summary = this.statusFor()
2809
+ void this.emit("status", { summary })
2810
+ return summary
2811
+ },
2812
+ refreshQuota: async () => {
2813
+ await Promise.allSettled([this.quota.refresh("opencode-go", true), this.quota.refresh("commandcode", true)])
2814
+ const summary = this.statusFor()
2815
+ void this.emit("status", { summary })
2816
+ return summary
2817
+ },
2818
+ report: async () => this.report(),
2819
+ usage: async () => this.usageReport(),
2820
+ /**
2821
+ * Report the intelligence floor in force and where it came from.
2822
+ *
2823
+ * Deliberately reports the *disabled* case too. The useful answer to
2824
+ * "what is my floor" is not one number: it is whether that number was
2825
+ * measured, hand-picked, or not yet calibratable because there is no
2826
+ * traffic to calibrate against.
2827
+ */
2828
+ floor: async () => this.floorReport(),
2829
+ setCalibrateFloor: async (input: any) => {
2830
+ const enabled = Boolean(input?.enabled)
2831
+ // Routing settings are file-backed. Runtime overrides exist for mode,
2832
+ // pause and dry-run; changing a routing policy value at runtime would
2833
+ // diverge from the file and silently reset on reload, so this reports
2834
+ // exactly what to write instead of pretending it was applied.
2835
+ return {
2836
+ ...this.floorReport(),
2837
+ setting: "routing.calibrateFloor",
2838
+ requested: enabled,
2839
+ applied: false,
2840
+ instruction: enabled
2841
+ ? "Set routing.calibrateFloor to true in smart-router.json, then restart the router."
2842
+ : "Set routing.calibrateFloor to false in smart-router.json, then restart the router.",
2843
+ }
2844
+ },
2845
+ getConfig: async () => ({
2846
+ settings: (() => {
2847
+ const copy = this.safe(this.settings)
2848
+ for (const provider of Object.values(copy.providers ?? {}) as any[]) {
2849
+ if (provider && typeof provider === "object") delete provider.credentialPath
2850
+ }
2851
+ return copy
2852
+ })(),
2853
+ sources: this.settingsSources,
2854
+ warnings: this.settingsWarnings,
2855
+ runtime: this.runtime,
2856
+ ledgerUpdatedAt: this.ledger.data.updatedAt,
2857
+ models: this.registry.approved().map((model) => ({
2858
+ key: model.key,
2859
+ tier: model.tier,
2860
+ intelligence: model.intelligence,
2861
+ design: model.design,
2862
+ premium: model.premium,
2863
+ training: model.training,
2864
+ monthlyAllowanceUsd: model.monthlyAllowanceUsd,
2865
+ })),
2866
+ }),
2867
+ resetOverrides: async () => {
2868
+ for (const key of Object.keys(this.runtime) as Array<keyof RuntimeOverrides>) delete this.runtime[key]
2869
+ sharedCore.runtime = this.runtime
2870
+ this.persistRuntime()
2871
+ this.rebuildRegistry()
2872
+ const summary = this.statusFor()
2873
+ void this.emit("status", { summary })
2874
+ return summary
2875
+ },
2876
+ resetMetrics: async () => {
2877
+ await sharedCore.resetLedger()
2878
+ this.ledger = sharedCore.ledger
2879
+ this.contextLoaded.clear()
2880
+ this.contextInflight.clear()
2881
+ const summary = this.statusFor()
2882
+ void this.emit("status", { summary })
2883
+ return summary
2884
+ },
2885
+ refreshIndicator: async (input: any) => {
2886
+ await this.loadSessionInfo(input.sessionID)
2887
+ await this.queueTitle(input.sessionID)
2888
+ const summary = this.statusFor(input.sessionID)
2889
+ void this.emit("status", { sessionID: input.sessionID, summary })
2890
+ return summary
2891
+ },
2892
+ setTitleIndicator: async (input: any) => {
2893
+ // The router no longer renames sessions. `enabled: true` is accepted for
2894
+ // old callers and ignored; `enabled: false` runs the one-time cleanup of
2895
+ // titles written by earlier versions.
2896
+ if (input?.enabled !== true) await this.restoreTitles()
2897
+ const summary = this.statusFor()
2898
+ void this.emit("status", { summary })
2899
+ return summary
2900
+ },
2901
+ diagnostics: async () => sharedCore.diagnostics(),
2902
+ }
2903
+ const safeImplementation = Object.fromEntries(
2904
+ Object.entries(implementation).map(([name, handler]) => [
2905
+ name,
2906
+ async (input: any) => this.safe(await (handler as any)(input)),
2907
+ ]),
2908
+ )
2909
+ this.rpcRegistration = await this.ctx.rpc.register(SmartRouterRpc, safeImplementation)
2910
+ }
2911
+
2912
+ private async registerCommands() {
2913
+ const notice = async (sessionID: string, text: string) => {
2914
+ try {
2915
+ await this.ctx.session.synthetic({ sessionID, text, description: "Smart Router", resume: false })
2916
+ } catch (error) {
2917
+ this.settingsWarnings.push(`Could not display router command output: ${safeError(error)}`)
2918
+ }
2919
+ }
2920
+
2921
+ const command = (name: string, description: string, execute: (input: any) => Promise<void>) =>
2922
+ this.ctx.command.transform((editor: any) => {
2923
+ editor.add({ name, description, execute })
2924
+ })
2925
+
2926
+ this.registrations.push(
2927
+ await command("router-status", "Show the current routing decision, tier and quota state", async ({ sessionID }: any) => {
2928
+ await this.loadSessionInfo(sessionID)
2929
+ await this.queueTitle(sessionID)
2930
+ const status = this.statusFor(sessionID)
2931
+ const decision = status.lastDecision
2932
+ const lines = [
2933
+ `Smart Router: ${status.indicator.state} · ${status.mode}${status.dryRun ? " · dry run" : ""}`,
2934
+ status.current ? `Current: ${status.current.providerID}/${status.current.id}${status.currentTier ? ` (tier ${status.currentTier})` : ""}` : "Current: unknown",
2935
+ decision ? `Task: tier ${decision.task.tier} ${decision.task.kind}; chosen: ${decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : "none"}` : "No decision yet",
2936
+ decision ? `Why: ${decision.switchReason}` : "",
2937
+ ...status.quota.flatMap((snapshot) => formatQuotaLines(snapshot)),
2938
+ status.premium.mode !== "off"
2939
+ ? `Premium: ${status.premium.mode}, ${usd(status.premium.remainingUsd)} remaining${status.premium.oneShotGranted ? ", one-shot approved" : ""}`
2940
+ : "Premium: off",
2941
+ ...status.warnings.slice(0, 4).map((warning) => `Warning: ${warning}`),
2942
+ ].filter(Boolean)
2943
+ await notice(sessionID, lines.join("\n"))
2944
+ }),
2945
+ )
2946
+
2947
+ this.registrations.push(
2948
+ await command("router-auto", "Resume automatic routing for this session", async ({ sessionID }: any) => {
2949
+ this.ledger.clearManual(sessionID)
2950
+ this.ledger.patchSession(sessionID, { pinnedModel: undefined, paused: false })
2951
+ this.persistLedgerSoon()
2952
+ void this.queueTitle(sessionID)
2953
+ this.modelNotice(sessionID, "AUTO", this.ledger.getSession(sessionID)?.lastModel, "automatic routing resumed")
2954
+ await notice(sessionID, "AUTO · automatic routing resumed for this session.")
2955
+ }),
2956
+ )
2957
+
2958
+ this.registrations.push(
2959
+ await command("router-pin", "Pin a model for this session: /router-pin provider/model", async ({ sessionID, prompt }: any) => {
2960
+ const requested = String(prompt?.text ?? "").trim().split(/\s+/).pop() ?? ""
2961
+ const [modelKeyPart, variant] = requested.split("#", 2)
2962
+ const parsed = parseModelKey(modelKeyPart ?? "")
2963
+ if (!parsed.modelID) {
2964
+ await notice(sessionID, "Usage: /router-pin provider/model[#variant]")
2965
+ return
2966
+ }
2967
+ const model = {
2968
+ providerID: parsed.providerID,
2969
+ id: parsed.modelID,
2970
+ ...(variant ? { variant } : {}),
2971
+ }
2972
+ this.ledger.setManual(sessionID, model)
2973
+ this.ledger.patchSession(sessionID, { pinnedModel: model, lastModel: model })
2974
+ this.rememberModel(sessionID, model.providerID, model.id)
2975
+ this.expectedSwitch.set(sessionID, { key: requested, at: Date.now() })
2976
+ try {
2977
+ await this.ctx.session.switchModel({ sessionID, model })
2978
+ void this.queueTitle(sessionID)
2979
+ this.modelNotice(sessionID, "MANUAL", model, "pinned · /router-auto to resume")
2980
+ await notice(sessionID, `MANUAL · pinned ${requested}. Automatic routing is suspended for this session. Run /router-auto to resume.`)
2981
+ } catch (error) {
2982
+ this.expectedSwitch.delete(sessionID)
2983
+ await notice(sessionID, `Could not pin ${requested}: ${safeError(error)}`)
2984
+ }
2985
+ this.persistLedgerSoon()
2986
+ }),
2987
+ )
2988
+
2989
+ this.registrations.push(
2990
+ await command("router-mode", "Set routing mode: efficiency, balanced, or premium", async ({ sessionID, prompt }: any) => {
2991
+ const mode = String(prompt?.text ?? "").trim().split(/\s+/).pop()
2992
+ if (mode !== "efficiency" && mode !== "balanced" && mode !== "premium") {
2993
+ await notice(sessionID, "Usage: /router-mode efficiency|balanced|premium")
2994
+ return
2995
+ }
2996
+ this.runtime.mode = mode
2997
+ this.persistRuntime()
2998
+ await notice(sessionID, `Routing mode set to ${mode}.`)
2999
+ }),
3000
+ )
3001
+
3002
+ this.registrations.push(
3003
+ await command("router-approve", "Approve one premium request for this session", async ({ sessionID }: any) => {
3004
+ this.ledger.grantPremiumOneShot(sessionID)
3005
+ this.persistLedgerSoon()
3006
+ await notice(sessionID, "One-shot premium approval granted for this session.")
3007
+ }),
3008
+ )
3009
+
3010
+ this.registrations.push(
3011
+ await command("router-pause", "Pause automatic routing globally", async ({ sessionID }: any) => {
3012
+ this.runtime.paused = true
3013
+ this.persistRuntime()
3014
+ void this.notifyChange("global:paused:true", "PAUSED · automatic routing is paused")
3015
+ await notice(sessionID, "PAUSED · automatic routing is paused.")
3016
+ }),
3017
+ )
3018
+
3019
+ this.registrations.push(
3020
+ await command("router-resume", "Resume automatic routing globally", async ({ sessionID }: any) => {
3021
+ this.runtime.paused = false
3022
+ this.persistRuntime()
3023
+ void this.notifyChange("global:paused:false", "AUTO · automatic routing resumed")
3024
+ await notice(sessionID, "AUTO · automatic routing resumed.")
3025
+ }),
3026
+ )
3027
+
3028
+ this.registrations.push(
3029
+ await command("router-report", "Show local usage and routing metrics", async ({ sessionID }: any) => {
3030
+ await notice(sessionID, this.formatReport())
3031
+ }),
3032
+ )
3033
+
3034
+ this.registrations.push(
3035
+ await command("router-floor", "Show the intelligence floor in force and where it came from", async ({ sessionID }: any) => {
3036
+ await notice(sessionID, this.formatFloor())
3037
+ }),
3038
+ )
3039
+
3040
+ this.registrations.push(
3041
+ await command("router-dry-run", "Enable or disable dry-run routing: /router-dry-run on|off", async ({ sessionID, prompt }: any) => {
3042
+ const value = String(prompt?.text ?? "").trim().split(/\s+/).pop()
3043
+ if (value !== "on" && value !== "off") {
3044
+ await notice(sessionID, "Usage: /router-dry-run on|off")
3045
+ return
3046
+ }
3047
+ this.runtime.dryRun = value === "on"
3048
+ this.persistRuntime()
3049
+ void this.notifyChange(
3050
+ `global:dry-run:${value === "on"}`,
3051
+ value === "on" ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
3052
+ )
3053
+ await notice(sessionID, value === "on" ? "DRY RUN · decisions will be explained, not applied." : "AUTO · routing will apply decisions.")
3054
+ }),
3055
+ )
3056
+
3057
+ this.registrations.push(
3058
+ await command("router-title", "Strip router-written prefixes from existing session titles: /router-title off", async ({ sessionID, prompt }: any) => {
3059
+ const value = String(prompt?.text ?? "").trim().split(/\s+/).pop()
3060
+ if (value === "on") {
3061
+ await notice(sessionID, "The router no longer renames sessions. Titles stay yours; status lives in the panel and the header.")
3062
+ return
3063
+ }
3064
+ if (value !== "off") {
3065
+ await notice(sessionID, "Usage: /router-title off (strips prefixes this plugin wrote in older versions)")
3066
+ return
3067
+ }
3068
+ const restored = await this.restoreTitles()
3069
+ await notice(sessionID, `Cleaned ${restored} session title${restored === 1 ? "" : "s"}. Titles written before the separator was lost cannot be recovered.`)
3070
+ }),
3071
+ )
3072
+
3073
+ this.registrations.push(
3074
+ await command("router-exclude", "Exclude a model from automatic routing: /router-exclude provider/model", async ({ sessionID, prompt }: any) => {
3075
+ const requested = String(prompt?.text ?? "").trim().split(/\s+/).pop() ?? ""
3076
+ const parsed = parseModelKey(requested)
3077
+ if (!parsed.modelID) {
3078
+ await notice(sessionID, "Usage: /router-exclude provider/model")
3079
+ return
3080
+ }
3081
+ const excluded = new Set(this.runtime.excluded ?? [])
3082
+ excluded.add(requested)
3083
+ this.runtime.excluded = [...excluded]
3084
+ this.persistRuntime()
3085
+ this.rebuildRegistry()
3086
+ await notice(sessionID, `Excluded ${requested} from automatic routing.`)
3087
+ }),
3088
+ )
3089
+
3090
+ this.registrations.push(
3091
+ await command("router-premium-budget", "Set the monthly premium budget in USD: /router-premium-budget 10", async ({ sessionID, prompt }: any) => {
3092
+ const value = Number(String(prompt?.text ?? "").trim().split(/\s+/).pop())
3093
+ if (!Number.isFinite(value) || value < 0) {
3094
+ await notice(sessionID, "Usage: /router-premium-budget <usd>")
3095
+ return
3096
+ }
3097
+ this.runtime.premiumBudgetUsd = Math.max(0, Math.min(100_000, value))
3098
+ this.persistRuntime()
3099
+ await notice(sessionID, `Premium monthly budget set to ${usd(value)}.`)
3100
+ }),
3101
+ )
3102
+
3103
+ this.registrations.push(
3104
+ await command("router-reserve", "Set provider reserves: /router-reserve provider monthlyPct weeklyPct fiveHourPct", async ({ sessionID, prompt }: any) => {
3105
+ const tokens = String(prompt?.text ?? "").trim().split(/\s+/).filter(Boolean)
3106
+ const tail = tokens.slice(-4)
3107
+ const providerID = tail[0]
3108
+ const monthlyPct = Number(tail[1])
3109
+ const weeklyPct = Number(tail[2])
3110
+ const fiveHourPct = Number(tail[3])
3111
+ if (!providerID || ![monthlyPct, weeklyPct, fiveHourPct].every((value) => Number.isFinite(value))) {
3112
+ await notice(sessionID, "Usage: /router-reserve provider monthlyPct weeklyPct fiveHourPct")
3113
+ return
3114
+ }
3115
+ const clampReserve = (value: number) => Math.max(0, Math.min(90, value))
3116
+ this.runtime.reserves = {
3117
+ ...(this.runtime.reserves ?? {}),
3118
+ [providerID]: {
3119
+ monthlyPct: clampReserve(monthlyPct),
3120
+ weeklyPct: clampReserve(weeklyPct),
3121
+ fiveHourPct: clampReserve(fiveHourPct),
3122
+ },
3123
+ }
3124
+ this.persistRuntime()
3125
+ await notice(sessionID, `Reserves for ${providerID}: monthly ${monthlyPct}%, weekly ${weeklyPct}%, 5h ${fiveHourPct}%.`)
3126
+ }),
3127
+ )
3128
+
3129
+ this.registrations.push(
3130
+ await command("router-reset-metrics", "Reset local router metrics and session routing state: /router-reset-metrics --confirm", async ({ sessionID, prompt }: any) => {
3131
+ if (!String(prompt?.text ?? "").includes("--confirm")) {
3132
+ await notice(sessionID, "This resets local router metrics, not conversations. Run /router-reset-metrics --confirm to proceed.")
3133
+ return
3134
+ }
3135
+ await sharedCore.resetLedger()
3136
+ this.ledger = sharedCore.ledger
3137
+ this.contextLoaded.clear()
3138
+ this.contextInflight.clear()
3139
+ await notice(sessionID, "Local router metrics and routing state were reset; conversations were not changed.")
3140
+ }),
3141
+ )
3142
+
3143
+ this.registrations.push(
3144
+ await command("router-diagnostics", "Show shared-core lifecycle and registration diagnostics", async ({ sessionID }: any) => {
3145
+ await notice(sessionID, JSON.stringify(sharedCore.diagnostics(), null, 2))
3146
+ }),
3147
+ )
3148
+
3149
+ this.registrations.push(
3150
+ await command("router-explain", "Explain the last routing decision", async ({ sessionID }: any) => {
3151
+ const status = this.statusFor(sessionID)
3152
+ const decision = status.lastDecision
3153
+ if (!decision) {
3154
+ await notice(sessionID, "No routing decision has been recorded yet.")
3155
+ return
3156
+ }
3157
+ const lines = [
3158
+ `Decision ${decision.id} at ${new Date(decision.at).toISOString()}`,
3159
+ `Task: tier ${decision.task.tier} ${decision.task.kind} (${decision.task.confidence} confidence)`,
3160
+ `Signals: ${decision.task.signals.join(", ") || "none"}`,
3161
+ `Reasons: ${decision.reasons.join(" ") || "none"}`,
3162
+ `Chosen: ${decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : "none"}`,
3163
+ `Switch: ${decision.switchReason}`,
3164
+ `Warnings: ${decision.warnings.join(" ") || "none"}`,
3165
+ ...decision.candidates.slice(0, 5).map(
3166
+ (candidate) =>
3167
+ ` ${candidate.model.providerID}/${candidate.model.modelID}: ${candidate.eligible ? "eligible" : candidate.reason} · ${usd(candidate.effectiveCost)} expected${candidate.creditMultiplier && candidate.creditMultiplier !== 1 ? ` · ${candidate.creditMultiplier}x credits` : ""}`,
3168
+ ),
3169
+ ...decision.quota.map(
3170
+ (snapshot) => ` quota ${snapshot.label}: ${quotaStateLabel(snapshot)}`,
3171
+ ),
3172
+ ]
3173
+ await notice(sessionID, lines.join("\n"))
3174
+ }),
3175
+ )
3176
+
3177
+ this.registrations.push(
3178
+ await command("router-opendesign", "Show OpenDesign connection, entitlement and credit diagnostics", async ({ sessionID }: any) => {
3179
+ await notice(sessionID, await this.formatOpenDesignReport())
3180
+ }),
3181
+ )
3182
+ }
3183
+
3184
+ /**
3185
+ * Report the OpenDesign connection state.
3186
+ *
3187
+ * This command makes only read-only metadata requests. It never performs
3188
+ * inference and never spends plan credits.
3189
+ */
3190
+ private async formatOpenDesignReport(): Promise<string> {
3191
+ const providerSettings = this.settings.providers.opendesign
3192
+ if (!providerSettings) return "OpenDesign is not configured."
3193
+ const lines: string[] = []
3194
+ lines.push(`OpenDesign provider: ${providerSettings.enabled ? "enabled" : "disabled"} · plan ${providerSettings.plan}`)
3195
+ lines.push(`Base URL: ${normalizeOpenDesignBaseURL(providerSettings.baseURL)}`)
3196
+ lines.push(
3197
+ `Allowance: monthly ${usd(providerSettings.monthlyCreditsUsd ?? 0)} · 5h ${usd(providerSettings.fiveHourCapUsd ?? 0)} · weekly ${usd(providerSettings.weeklyCapUsd ?? 0)} (5h and weekly are nested inside the monthly maximum)`,
3198
+ )
3199
+ lines.push(`Subscription only: ${providerSettings.subscriptionOnly !== false ? "yes" : "no"} · purchased credits allowed: ${providerSettings.allowExtraUsage ? "yes" : "no"}`)
3200
+ if (providerSettings.creditsPerUsd) lines.push(`Credits per USD: ${providerSettings.creditsPerUsd}`)
3201
+
3202
+ let apiKey: string | undefined
3203
+ let controlKey: string | undefined
3204
+ try {
3205
+ const resolved = await this.resolveOpenDesignCredentials()
3206
+ apiKey = resolved.openDesignApiKey
3207
+ controlKey = resolved.openDesignControlKey
3208
+ } catch {
3209
+ // Fall through to the unknown state.
3210
+ }
3211
+ lines.push(`Inference key: ${apiKey ? "found" : "not found"}`)
3212
+ lines.push(`Control key: ${controlKey ? "found (verified quota available)" : "not configured (quota is an estimate)"}`)
3213
+
3214
+ if (apiKey) {
3215
+ const result = await discoverOpenDesignModels({
3216
+ apiKey,
3217
+ ...(providerSettings.baseURL ? { baseURL: providerSettings.baseURL } : {}),
3218
+ })
3219
+ if (!result.ok) {
3220
+ lines.push(`Gateway discovery failed: ${result.error ?? "unknown error"}`)
3221
+ } else {
3222
+ lines.push(`Entitled models on the gateway: ${result.models.length}`)
3223
+ for (const model of result.models.slice(0, 20)) {
3224
+ const spec = OPEN_DESIGN_MODEL_BY_ID.get(model.id)
3225
+ const multiplier = creditMultiplierFor(model.id)
3226
+ const context = model.contextBudget ?? spec?.context ?? 0
3227
+ const vision = model.inputModalities.includes("image") ? "image" : "text only"
3228
+ lines.push(
3229
+ ` ${model.id}: ${context.toLocaleString()} usable input · ${(model.outputBudget ?? spec?.maxOutput ?? 0).toLocaleString()} output · ${vision} · ${multiplier.multiplier}x credits${multiplier.evidence === "unverified" ? " (multiplier not published)" : ""}`,
3230
+ )
3231
+ }
3232
+ }
3233
+ }
3234
+
3235
+ const snapshot = this.quotaSnapshots().find((entry) => entry.providerID === "opendesign")
3236
+ if (snapshot) {
3237
+ lines.push(`Quota state: ${quotaStateLabel(snapshot)}`)
3238
+ for (const note of snapshot.notes.slice(0, 4)) lines.push(` ${note}`)
3239
+ if (snapshot.error) lines.push(` ${snapshot.error}`)
3240
+ }
3241
+ lines.push("No inference request was made by this command.")
3242
+ return lines.join("\n")
3243
+ }
3244
+
3245
+ /**
3246
+ * One screen describing the floor, its provenance, and what it admits.
3247
+ *
3248
+ * Printed rather than summarised because the point of the command is to make
3249
+ * the number checkable. The reason line names the model and both cost figures
3250
+ * the calibration compared, so the reader can disagree with it.
3251
+ */
3252
+ private formatFloor(): string {
3253
+ const report = this.floorReport()
3254
+ const lines: string[] = []
3255
+ lines.push(`Floor in force: ${report.floorInForce.toFixed(1)} (${report.source})`)
3256
+ lines.push(` configured: ${report.configuredFloor.toFixed(1)}`)
3257
+
3258
+ if (report.offlineBypass) {
3259
+ lines.push(" calibration is inactive while offline mode is enabled")
3260
+ } else if (!report.calibrateFloor) {
3261
+ lines.push(" calibration is off; set routing.calibrateFloor to true to derive this from spend")
3262
+ } else if (!report.calibrated) {
3263
+ lines.push(` not calibrated: ${report.reason}`)
3264
+ } else {
3265
+ lines.push(` calibrated: ${report.reason}`)
3266
+ if (report.baselineUsdPerRequest !== null) {
3267
+ lines.push(` measured baseline: $${report.baselineUsdPerRequest.toFixed(6)} per request over ${report.windowDays} days`)
3268
+ }
3269
+ }
3270
+
3271
+ lines.push(` needs ${report.minRequests} recorded requests; you have ${report.totalRequests}`)
3272
+ lines.push(`Admitted at this floor (${report.admitted.length}):`)
3273
+ for (const entry of report.admitted.slice(0, 10)) {
3274
+ const price = entry.listInputUsdPerMtok === null ? "?" : `$${entry.listInputUsdPerMtok}`
3275
+ lines.push(` ${entry.model} intel ${entry.intelligence.toFixed(1)} ${price}/Mtok in`)
3276
+ }
3277
+ if (report.admitted.length > 10) lines.push(` ... ${report.admitted.length - 10} more`)
3278
+
3279
+ if (report.measuredTraffic.length > 0) {
3280
+ lines.push("Measured traffic:")
3281
+ for (const row of report.measuredTraffic.slice(0, 6)) {
3282
+ lines.push(` ${row.model} ${row.requests} req $${row.usdPerRequest.toFixed(6)}/req`)
3283
+ }
3284
+ }
3285
+ return lines.join("\n")
3286
+ }
3287
+
3288
+ private formatReport() {
3289
+ const metrics = this.ledger.snapshotMetrics()
3290
+ const modelLines = Object.entries(metrics.byModel)
3291
+ .sort((a, b) => b[1].usd - a[1].usd)
3292
+ .slice(0, 8)
3293
+ .map(([key, value]) => {
3294
+ const reliability =
3295
+ value.failures > 0
3296
+ ? ` · ${value.failures} provider fault${value.failures === 1 ? "" : "s"}`
3297
+ : ""
3298
+ const cost = key.startsWith("opendesign/")
3299
+ ? `${usd(value.usd)} est. credits`
3300
+ : usd(value.usd)
3301
+ return `${key}: ${value.requests} req · ${value.input.toLocaleString()} in · ${value.cacheRead.toLocaleString()} cache · ${value.output.toLocaleString()} out · ${cost}${reliability}`
3302
+ })
3303
+ const latency = metrics.latencySamples > 0 ? `${Math.round(metrics.latencyMsTotal / metrics.latencySamples)} ms avg` : "no samples"
3304
+ const cooldowns = Object.entries(this.ledger.cooldowns()).filter(([, until]) => until > Date.now())
3305
+ return [
3306
+ `Requests: ${metrics.requests.toLocaleString()}`,
3307
+ `Tokens: ${metrics.inputTokens.toLocaleString()} fresh input, ${metrics.cacheReadTokens.toLocaleString()} cache read, ${metrics.outputTokens.toLocaleString()} output`,
3308
+ `Estimated allowance value: ${usd(metrics.estimatedUsd)} (provider-reported where available; otherwise local estimate)`,
3309
+ `Switches: ${metrics.switches} · escalations: ${metrics.escalations} · prevented premium: ${metrics.preventedPremium} · retries: ${metrics.retries}`,
3310
+ `Premium spend: ${usd(metrics.premiumUsd)} across ${metrics.premiumRequests} requests`,
3311
+ `Latency: ${latency}`,
3312
+ ...cooldowns.map(
3313
+ ([key, until]) => `Cooling down: ${key} for another ${Math.max(0, Math.round((until - Date.now()) / 1000))}s after a provider fault`,
3314
+ ),
3315
+ ...modelLines,
3316
+ ].join("\n")
3317
+ }
3318
+
3319
+ private report() {
3320
+ return {
3321
+ text: this.formatReport(),
3322
+ metrics: this.ledger.snapshotMetrics(),
3323
+ quota: this.quotaSnapshots(),
3324
+ decisions: this.ledger.decisions(20),
3325
+ status: this.statusFor(),
3326
+ }
3327
+ }
3328
+
3329
+ /**
3330
+ * Durable usage report: per-provider, per-model, per-window, with forecasts.
3331
+ *
3332
+ * A provider that meters a five-hour window reports it; one that does not
3333
+ * reports an empty row, so a dashboard can distinguish "unused" from "not
3334
+ * applicable" instead of showing a limit that cannot be hit.
3335
+ */
3336
+ private usageReport() {
3337
+ const now = Date.now()
3338
+ const quota = this.quotaSnapshots()
3339
+ const providers = Object.entries(this.settings.providers)
3340
+ .filter(([, settings]) => settings.enabled)
3341
+ .map(([providerID, settings]) => {
3342
+ const snapshot = quota.find((entry) => entry.providerID === providerID)
3343
+ const resolvedPlan = resolvePlanWithReason(providerID, settings.plan)
3344
+ const plan = resolvedPlan.plan
3345
+ const designPlan = plan?.pools.find((pool) => pool.id === "design-plan")
3346
+ const hasFiveHourWindow = Boolean(
3347
+ !settings.ignoreFiveHourWindow && designPlan?.fiveHourUsd !== undefined,
3348
+ )
3349
+ const hasWeeklyWindow = Boolean(
3350
+ !settings.ignoreWeeklyWindow &&
3351
+ (designPlan?.weeklyUsd !== undefined ||
3352
+ plan?.perModelWindowRule !== undefined),
3353
+ )
3354
+ return buildProviderUsageReport({
3355
+ providerID,
3356
+ label: snapshot?.label ?? plan?.label ?? providerID,
3357
+ // A configured-but-unrecognized plan id is reported as unresolved
3358
+ // rather than echoed. Printing the raw string next to dollar figures
3359
+ // implies it is a tier this build understands.
3360
+ plan: describePlan(plan?.planID, settings.plan),
3361
+ source: snapshot?.source ?? "unknown",
3362
+ ...(snapshot?.quotaState ? { quotaState: snapshot.quotaState } : {}),
3363
+ now,
3364
+ source_: {
3365
+ aggregate: (window) => this.ledger.aggregate(window),
3366
+ usageByModel: (window) => this.ledger.usageByModel(window),
3367
+ usageEvents: this.ledger.usageEvents(),
3368
+ },
3369
+ hasFiveHourWindow,
3370
+ hasWeeklyWindow,
3371
+ ...(snapshot?.monthlyRemainingUsd !== undefined
3372
+ ? { remainingMonthlyUsd: snapshot.monthlyRemainingUsd }
3373
+ : {}),
3374
+ notes: snapshot?.notes ?? [],
3375
+ })
3376
+ })
3377
+ return {
3378
+ generatedAt: now,
3379
+ providers,
3380
+ speed: this.speedSnapshot(),
3381
+ contextManagement: {
3382
+ mode: this.settings.contextManagement.mode,
3383
+ description: describeMode(this.settings.contextManagement.mode),
3384
+ enabled: this.settings.contextManagement.enabled,
3385
+ },
3386
+ retention: {
3387
+ rawEventHorizonHours: 48,
3388
+ note: "Events older than the horizon are folded into hourly aggregates, so nothing is discarded.",
3389
+ },
3390
+ }
3391
+ }
3392
+
3393
+ async dispose() {
3394
+ if (this.cleanupDone) return
3395
+ this.cleanupDone = true
3396
+ this.promptRoutes.clearAll()
3397
+ this.pendingHeals.clear()
3398
+ this.requestKinds.clear()
3399
+ if (this.bridgeID) sharedCore.removeBridge(this.bridgeID)
3400
+ await Promise.allSettled(this.registrations.map((registration) => registration?.dispose?.()))
3401
+ // Start the write immediately, but do not block a reload on storage. The
3402
+ // grace timer performs the final flush when the last bridge leaves.
3403
+ void sharedCore.persistNow()
3404
+ }
3405
+ }