@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,1304 @@
1
+ import type {
2
+ FailureClass,
3
+ Ledger,
4
+ Metrics,
5
+ ModelMetrics,
6
+ ModelQuotaSnapshot,
7
+ ModelRecord,
8
+ ModelRef,
9
+ ProviderQuotaSnapshot,
10
+ ProviderSettings,
11
+ RoutingDecision,
12
+ SessionState,
13
+ UsageBucket,
14
+ UsageEvent,
15
+ UsageRollup,
16
+ } from "../types.js"
17
+ import { clamp, keyOfRef, modelKey, monthKey, nowId, pct, safeError } from "../util.js"
18
+
19
+ export const LEDGER_VERSION = 1 as const
20
+
21
+ /**
22
+ * How many provider-scoped failures in a row, with no successful request in
23
+ * between, before the whole provider is treated as not serving.
24
+ *
25
+ * A transient blip is ridden out by the host's own retry and by the owner
26
+ * saying "continue", because a couple of failures in a row is normal during a
27
+ * gateway hiccup. Only a sustained failure, where every attempt fails and
28
+ * nothing succeeds in between, is an outage worth stepping back from.
29
+ */
30
+ export const PROVIDER_DOWN_STREAK = 5
31
+
32
+ /** How long the provider is held back once it is judged to be down. */
33
+ export const PROVIDER_BACKOFF_MS = 3 * 60_000
34
+ const MAX_USAGE_EVENTS = 2_000
35
+ const MAX_DECISIONS = 120
36
+ const MAX_SESSIONS = 250
37
+ /**
38
+ * How long raw per-request events are kept before they are folded into hourly
39
+ * buckets. Raw events are what the per-request detail is built from; beyond
40
+ * this horizon the aggregates are all that any consumer needs, and they cost
41
+ * three orders of magnitude less space.
42
+ */
43
+ const RAW_EVENT_HORIZON_MS = 48 * 60 * 60 * 1000
44
+ /** Retention for the hourly aggregates. 400 days covers a yearly comparison. */
45
+ const ROLLUP_RETENTION_MS = 400 * 24 * 60 * 60 * 1000
46
+ /** Upper bound on hourly buckets kept in the ledger. */
47
+ const MAX_BUCKETS = 400 * 24 * 4
48
+ const RETENTION_MS = 40 * 24 * 60 * 60 * 1000
49
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
50
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000
51
+ const MONTH_MS = 30 * 24 * 60 * 60 * 1000
52
+ const HOUR_MS = 60 * 60 * 1000
53
+
54
+ const emptyFailureCounts = (): Record<FailureClass, number> => ({
55
+ context_overflow: 0,
56
+ rate_limit: 0,
57
+ network: 0,
58
+ auth: 0,
59
+ provider_outage: 0,
60
+ invalid_request: 0,
61
+ image_rejected: 0,
62
+ tool: 0,
63
+ capability: 0,
64
+ interrupted: 0,
65
+ quota_exhausted: 0,
66
+ prompt_rejected: 0,
67
+ unknown: 0,
68
+ })
69
+
70
+ export const emptyMetrics = (now = Date.now()): Metrics => ({
71
+ startedAt: now,
72
+ requests: 0,
73
+ inputTokens: 0,
74
+ cacheReadTokens: 0,
75
+ cacheWriteTokens: 0,
76
+ outputTokens: 0,
77
+ estimatedUsd: 0,
78
+ switches: 0,
79
+ escalations: 0,
80
+ retries: 0,
81
+ preventedPremium: 0,
82
+ manualOverrides: 0,
83
+ dryRunDecisions: 0,
84
+ premiumRequests: 0,
85
+ premiumUsd: 0,
86
+ planPoolUsd: 0,
87
+ latencyMsTotal: 0,
88
+ latencySamples: 0,
89
+ failures: emptyFailureCounts(),
90
+ byModel: {},
91
+ timeouts: 0,
92
+ })
93
+
94
+ const emptyModelMetrics = (): ModelMetrics => ({
95
+ requests: 0,
96
+ input: 0,
97
+ output: 0,
98
+ cacheRead: 0,
99
+ cacheWrite: 0,
100
+ usd: 0,
101
+ failures: 0,
102
+ outputTokensPerSecond: 0,
103
+ latencyMsTotal: 0,
104
+ latencySamples: 0,
105
+ timeoutFailures: 0,
106
+ })
107
+
108
+ export const emptyLedger = (now = Date.now()): Ledger => ({
109
+ version: LEDGER_VERSION,
110
+ usage: [],
111
+ premiumSpend: [],
112
+ planPoolSpend: [],
113
+ sessions: {},
114
+ decisions: [],
115
+ metrics: emptyMetrics(now),
116
+ cooldowns: {},
117
+ providerCooldowns: {},
118
+ updatedAt: now,
119
+ })
120
+
121
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
122
+ typeof value === "object" && value !== null && !Array.isArray(value)
123
+
124
+ const finite = (value: unknown, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback)
125
+
126
+ const asModelRef = (value: unknown): ModelRef | undefined => {
127
+ if (!isRecord(value)) return undefined
128
+ if (typeof value.providerID !== "string" || typeof value.id !== "string") return undefined
129
+ return {
130
+ providerID: value.providerID,
131
+ id: value.id,
132
+ ...(typeof value.variant === "string" ? { variant: value.variant } : {}),
133
+ }
134
+ }
135
+
136
+ const asUsageEvent = (value: unknown): UsageEvent | undefined => {
137
+ if (!isRecord(value)) return undefined
138
+ if (typeof value.providerID !== "string" || typeof value.modelID !== "string") return undefined
139
+ return {
140
+ id: typeof value.id === "string" ? value.id : nowId("evt"),
141
+ at: finite(value.at, Date.now()),
142
+ providerID: value.providerID,
143
+ modelID: value.modelID,
144
+ ...(typeof value.sessionID === "string" ? { sessionID: value.sessionID } : {}),
145
+ usd: Math.max(0, finite(value.usd)),
146
+ input: Math.max(0, finite(value.input)),
147
+ output: Math.max(0, finite(value.output)),
148
+ cacheRead: Math.max(0, finite(value.cacheRead)),
149
+ cacheWrite: Math.max(0, finite(value.cacheWrite)),
150
+ source: value.source === "bootstrap" || value.source === "manual" ? value.source : "step",
151
+ }
152
+ }
153
+
154
+ const asUsageBucket = (value: unknown): UsageBucket | undefined => {
155
+ if (!isRecord(value)) return undefined
156
+ if (typeof value.providerID !== "string" || typeof value.modelID !== "string") return undefined
157
+ const hour = finite(value.hour)
158
+ if (hour <= 0) return undefined
159
+ return {
160
+ hour: Math.floor(hour / HOUR_MS) * HOUR_MS,
161
+ providerID: value.providerID,
162
+ modelID: value.modelID,
163
+ periodID: typeof value.periodID === "string" ? value.periodID : "",
164
+ requests: Math.max(0, finite(value.requests)),
165
+ input: Math.max(0, finite(value.input)),
166
+ output: Math.max(0, finite(value.output)),
167
+ cacheRead: Math.max(0, finite(value.cacheRead)),
168
+ cacheWrite: Math.max(0, finite(value.cacheWrite)),
169
+ usd: Math.max(0, finite(value.usd)),
170
+ failures: Math.max(0, finite(value.failures)),
171
+ latencyMsTotal: Math.max(0, finite(value.latencyMsTotal)),
172
+ latencySamples: Math.max(0, finite(value.latencySamples)),
173
+ }
174
+ }
175
+
176
+ const bucketKeyOf = (bucket: { hour: number; providerID: string; modelID: string; periodID: string }) =>
177
+ `${bucket.hour}:${bucket.providerID}:${bucket.modelID}:${bucket.periodID}`
178
+
179
+ /**
180
+ * Fold an event into an hourly bucket. `periodID` separates consecutive
181
+ * billing periods, so usage from a previous plan period is never re-read as
182
+ * if it had been spent against the current one's limits.
183
+ */
184
+ const foldEventInto = (
185
+ buckets: Map<string, UsageBucket>,
186
+ event: UsageEvent,
187
+ periodID: string,
188
+ ): void => {
189
+ const hour = Math.floor(event.at / HOUR_MS) * HOUR_MS
190
+ const key = bucketKeyOf({ hour, providerID: event.providerID, modelID: event.modelID, periodID })
191
+ const existing = buckets.get(key)
192
+ if (existing) {
193
+ existing.requests += 1
194
+ existing.input += event.input
195
+ existing.output += event.output
196
+ existing.cacheRead += event.cacheRead
197
+ existing.cacheWrite += event.cacheWrite
198
+ existing.usd += event.usd
199
+ return
200
+ }
201
+ buckets.set(key, {
202
+ hour,
203
+ providerID: event.providerID,
204
+ modelID: event.modelID,
205
+ periodID,
206
+ requests: 1,
207
+ input: event.input,
208
+ output: event.output,
209
+ cacheRead: event.cacheRead,
210
+ cacheWrite: event.cacheWrite,
211
+ usd: event.usd,
212
+ failures: 0,
213
+ latencyMsTotal: 0,
214
+ latencySamples: 0,
215
+ })
216
+ }
217
+
218
+ const asSessionState = (value: unknown): SessionState | undefined => { if (!isRecord(value) || typeof value.sessionID !== "string") return undefined
219
+ return {
220
+ sessionID: value.sessionID,
221
+ ...(typeof value.parentID === "string" ? { parentID: value.parentID } : {}),
222
+ ...(typeof value.projectID === "string" ? { projectID: value.projectID } : {}),
223
+ ...(typeof value.directory === "string" ? { directory: value.directory } : {}),
224
+ manualOverride: value.manualOverride === true,
225
+ manualModel: asModelRef(value.manualModel),
226
+ pinnedModel: asModelRef(value.pinnedModel),
227
+ premiumOneShot: value.premiumOneShot === true,
228
+ premiumOneShotAt: typeof value.premiumOneShotAt === "number" ? value.premiumOneShotAt : undefined,
229
+ paused: value.paused === true,
230
+ lastModel: asModelRef(value.lastModel),
231
+ lastRoutedModel: asModelRef(value.lastRoutedModel),
232
+ lastDecisionID: typeof value.lastDecisionID === "string" ? value.lastDecisionID : undefined,
233
+ contextTokens: Math.max(0, finite(value.contextTokens)),
234
+ cacheReadTokens: Math.max(0, finite(value.cacheReadTokens)),
235
+ stepCount: Math.max(0, finite(value.stepCount)),
236
+ status: value.status === "busy" || value.status === "idle" ? value.status : "unknown",
237
+ failures: Array.isArray(value.failures)
238
+ ? value.failures
239
+ .filter(isRecord)
240
+ .slice(-12)
241
+ .map((failure) => ({
242
+ class: (typeof failure.class === "string" ? failure.class : "unknown") as FailureClass,
243
+ at: finite(failure.at),
244
+ message: typeof failure.message === "string" ? failure.message.slice(0, 300) : "",
245
+ }))
246
+ : [],
247
+ retries: Math.max(0, finite(value.retries)),
248
+ createdAt: finite(value.createdAt, Date.now()),
249
+ updatedAt: finite(value.updatedAt, Date.now()),
250
+ ...(typeof value.title === "string" ? { title: value.title.slice(0, 200) } : {}),
251
+ }
252
+ }
253
+
254
+ export const hydrateLedger = (value: unknown): { ledger: Ledger; warnings: string[] } => {
255
+ const warnings: string[] = []
256
+ const now = Date.now()
257
+ if (!isRecord(value)) return { ledger: emptyLedger(now), warnings: ["No saved ledger found"] }
258
+ if (value.version !== LEDGER_VERSION) warnings.push("Saved ledger version differs; mixing retained data")
259
+
260
+ const metrics = isRecord(value.metrics) ? value.metrics : {}
261
+ const byModel: Metrics["byModel"] = {}
262
+ if (isRecord(metrics.byModel)) {
263
+ for (const [key, entry] of Object.entries(metrics.byModel)) {
264
+ if (!isRecord(entry)) continue
265
+ byModel[key] = {
266
+ requests: Math.max(0, finite(entry.requests)),
267
+ input: Math.max(0, finite(entry.input)),
268
+ output: Math.max(0, finite(entry.output)),
269
+ cacheRead: Math.max(0, finite(entry.cacheRead)),
270
+ cacheWrite: Math.max(0, finite(entry.cacheWrite)),
271
+ usd: Math.max(0, finite(entry.usd)),
272
+ failures: Math.max(0, finite(entry.failures)),
273
+ outputTokensPerSecond: Math.max(0, finite(entry.outputTokensPerSecond)),
274
+ latencyMsTotal: Math.max(0, finite(entry.latencyMsTotal)),
275
+ latencySamples: Math.max(0, finite(entry.latencySamples)),
276
+ timeoutFailures: Math.max(0, finite(entry.timeoutFailures)),
277
+ }
278
+ }
279
+ }
280
+
281
+ const cooldowns: Record<string, number> = {}
282
+ if (isRecord(value.cooldowns)) {
283
+ for (const [key, until] of Object.entries(value.cooldowns)) {
284
+ const parsed = finite(until)
285
+ if (parsed > 0) cooldowns[key] = parsed
286
+ }
287
+ }
288
+ const providerCooldowns: Record<string, number> = {}
289
+ if (isRecord(value.providerCooldowns)) {
290
+ for (const [key, until] of Object.entries(value.providerCooldowns)) {
291
+ const parsed = finite(until)
292
+ if (parsed > 0) providerCooldowns[key] = parsed
293
+ }
294
+ }
295
+
296
+ const sessions: Record<string, SessionState> = {}
297
+ if (isRecord(value.sessions)) { for (const entry of Object.values(value.sessions)) {
298
+ const session = asSessionState(entry)
299
+ if (session) sessions[session.sessionID] = session
300
+ }
301
+ }
302
+
303
+ const decisions: RoutingDecision[] = []
304
+ if (Array.isArray(value.decisions)) {
305
+ for (const entry of value.decisions) {
306
+ if (isRecord(entry) && typeof entry.id === "string" && typeof entry.at === "number") {
307
+ decisions.push(entry as unknown as RoutingDecision)
308
+ }
309
+ }
310
+ }
311
+
312
+ const usage: UsageEvent[] = []
313
+ if (Array.isArray(value.usage)) {
314
+ for (const entry of value.usage) {
315
+ const event = asUsageEvent(entry)
316
+ if (event) usage.push(event)
317
+ }
318
+ }
319
+
320
+ const buckets: UsageBucket[] = []
321
+ const rollupInput = isRecord(value.usageRollup) ? value.usageRollup : undefined
322
+ if (rollupInput && Array.isArray(rollupInput.buckets)) {
323
+ for (const entry of rollupInput.buckets) {
324
+ const bucket = asUsageBucket(entry)
325
+ if (bucket) buckets.push(bucket)
326
+ }
327
+ }
328
+
329
+ /**
330
+ * A ledger written before aggregates existed has more raw events than the
331
+ * cap, and truncating it on load would discard exactly the history the
332
+ * aggregates were introduced to keep. Fold the overflow into buckets here so
333
+ * the first load after an upgrade preserves every token.
334
+ */
335
+ let loadTruncated: UsageEvent[] | undefined
336
+ if (usage.length > MAX_USAGE_EVENTS && buckets.length === 0) {
337
+ const byKey = new Map<string, UsageBucket>()
338
+ const overflow = usage.length - MAX_USAGE_EVENTS
339
+ for (const event of usage.slice(0, overflow)) {
340
+ foldEventInto(byKey, event, monthKey(event.at))
341
+ }
342
+ buckets.push(...byKey.values())
343
+ loadTruncated = usage.slice(overflow)
344
+ warnings.push(
345
+ `Folded ${overflow} older usage events into hourly aggregates on load instead of discarding them`,
346
+ )
347
+ }
348
+
349
+ const failures = emptyFailureCounts()
350
+ if (isRecord(metrics.failures)) {
351
+ for (const key of Object.keys(failures) as FailureClass[]) {
352
+ failures[key] = Math.max(0, finite(metrics.failures[key]))
353
+ }
354
+ }
355
+
356
+ const ledger: Ledger = {
357
+ version: LEDGER_VERSION,
358
+ bootstrappedAt: typeof value.bootstrappedAt === "number" ? value.bootstrappedAt : undefined,
359
+ bootstrapSource: typeof value.bootstrapSource === "string" ? value.bootstrapSource : undefined,
360
+ cooldowns,
361
+ providerCooldowns,
362
+ usage: loadTruncated ? loadTruncated.slice(-MAX_USAGE_EVENTS) : usage.slice(-MAX_USAGE_EVENTS),
363
+ ...(buckets.length > 0
364
+ ? {
365
+ usageRollup: {
366
+ version: 1 as const,
367
+ buckets,
368
+ recent: usage.slice(-MAX_USAGE_EVENTS),
369
+ updatedAt: finite(rollupInput?.updatedAt, now),
370
+ },
371
+ }
372
+ : {}),
373
+ premiumSpend: Array.isArray(value.premiumSpend)
374
+ ? value.premiumSpend
375
+ .filter(isRecord)
376
+ .map((entry) => ({ month: String(entry.month ?? ""), usd: Math.max(0, finite(entry.usd)) }))
377
+ .filter((entry) => entry.month)
378
+ : [],
379
+ planPoolSpend: Array.isArray(value.planPoolSpend)
380
+ ? value.planPoolSpend
381
+ .filter(isRecord)
382
+ .map((entry) => ({ month: String(entry.month ?? ""), usd: Math.max(0, finite(entry.usd)) }))
383
+ .filter((entry) => entry.month)
384
+ : [],
385
+ sessions,
386
+ decisions: decisions.slice(0, MAX_DECISIONS),
387
+ metrics: {
388
+ startedAt: finite(metrics.startedAt, now),
389
+ requests: Math.max(0, finite(metrics.requests)),
390
+ inputTokens: Math.max(0, finite(metrics.inputTokens)),
391
+ cacheReadTokens: Math.max(0, finite(metrics.cacheReadTokens)),
392
+ cacheWriteTokens: Math.max(0, finite(metrics.cacheWriteTokens)),
393
+ outputTokens: Math.max(0, finite(metrics.outputTokens)),
394
+ estimatedUsd: Math.max(0, finite(metrics.estimatedUsd)),
395
+ switches: Math.max(0, finite(metrics.switches)),
396
+ escalations: Math.max(0, finite(metrics.escalations)),
397
+ retries: Math.max(0, finite(metrics.retries)),
398
+ preventedPremium: Math.max(0, finite(metrics.preventedPremium)),
399
+ manualOverrides: Math.max(0, finite(metrics.manualOverrides)),
400
+ dryRunDecisions: Math.max(0, finite(metrics.dryRunDecisions)),
401
+ premiumRequests: Math.max(0, finite(metrics.premiumRequests)),
402
+ premiumUsd: Math.max(0, finite(metrics.premiumUsd)),
403
+ planPoolUsd: Math.max(0, finite(metrics.planPoolUsd)),
404
+ latencyMsTotal: Math.max(0, finite(metrics.latencyMsTotal)),
405
+ latencySamples: Math.max(0, finite(metrics.latencySamples)),
406
+ lastLatencyMs: typeof metrics.lastLatencyMs === "number" ? metrics.lastLatencyMs : undefined,
407
+ failures,
408
+ byModel,
409
+ timeouts: Math.max(0, finite(metrics.timeouts)),
410
+ },
411
+ updatedAt: now,
412
+ }
413
+ return { ledger, warnings }
414
+ }
415
+
416
+ export interface StepUsage {
417
+ readonly sessionID: string
418
+ readonly model: ModelRef
419
+ readonly cost: number
420
+ readonly tokens: {
421
+ readonly input: number
422
+ readonly output: number
423
+ readonly reasoning?: number
424
+ readonly cache: { readonly read: number; readonly write: number }
425
+ }
426
+ readonly at?: number
427
+ readonly latencyMs?: number
428
+ readonly premium?: boolean
429
+ /** True when this step drew from the plan-model pool (escalation allowance). */
430
+ readonly planPool?: boolean
431
+ }
432
+
433
+ export interface BootstrapSession {
434
+ readonly id: string
435
+ readonly title?: string
436
+ readonly model?: ModelRef
437
+ readonly cost: number
438
+ readonly tokens?: {
439
+ readonly input: number
440
+ readonly output: number
441
+ readonly cache: { readonly read: number; readonly write: number }
442
+ }
443
+ readonly updatedAt: number
444
+ }
445
+
446
+ export interface ModelUsageTotals {
447
+ readonly monthlyUsd: number
448
+ readonly fiveHourUsd: number
449
+ readonly weeklyUsd: number
450
+ readonly input: number
451
+ readonly output: number
452
+ readonly cacheRead: number
453
+ readonly requests: number
454
+ }
455
+
456
+ /**
457
+ * Local ledger for observed usage. Provider-reported totals, when available,
458
+ * are kept in the provider snapshot; this ledger supplies per-model estimates
459
+ * and the fallback when a provider has no readable billing endpoint.
460
+ */
461
+ export class LedgerStore {
462
+ private ledger: Ledger
463
+ private dirty = false
464
+ /**
465
+ * Consecutive provider-scoped failures with no successful request in
466
+ * between, per provider. A short streak is a transient blip that a retry
467
+ * rides over; a long one means the provider is genuinely not serving.
468
+ * Ephemeral on purpose: it is only meaningful across back-to-back attempts.
469
+ */
470
+ private readonly providerFaultStreak = new Map<string, number>()
471
+
472
+ constructor(initial?: Ledger) {
473
+ this.ledger = initial ?? emptyLedger()
474
+ }
475
+
476
+ static hydrate(value: unknown) {
477
+ return hydrateLedger(value)
478
+ }
479
+
480
+ get data(): Readonly<Ledger> {
481
+ return this.ledger
482
+ }
483
+
484
+ reset() {
485
+ this.ledger = emptyLedger()
486
+ this.dirty = true
487
+ }
488
+
489
+ get dirtyFlag() {
490
+ return this.dirty
491
+ }
492
+
493
+ markClean() {
494
+ this.dirty = false
495
+ }
496
+
497
+ private touch() {
498
+ this.ledger.updatedAt = Date.now()
499
+ this.dirty = true
500
+ }
501
+
502
+ ensureSession(sessionID: string, parentID?: string, title?: string): SessionState {
503
+ const existing = this.ledger.sessions[sessionID]
504
+ if (existing) {
505
+ if (parentID && !existing.parentID) (existing as { parentID?: string }).parentID = parentID
506
+ if (title && !existing.title) existing.title = title
507
+ return existing
508
+ }
509
+ const now = Date.now()
510
+ const session: SessionState = {
511
+ sessionID,
512
+ ...(parentID ? { parentID } : {}),
513
+ manualOverride: false,
514
+ premiumOneShot: false,
515
+ paused: false,
516
+ contextTokens: 0,
517
+ cacheReadTokens: 0,
518
+ stepCount: 0,
519
+ status: "unknown",
520
+ failures: [],
521
+ retries: 0,
522
+ createdAt: now,
523
+ updatedAt: now,
524
+ ...(title ? { title } : {}),
525
+ }
526
+ this.ledger.sessions[sessionID] = session
527
+ this.touch()
528
+ return session
529
+ }
530
+
531
+ getSession(sessionID: string): SessionState | undefined {
532
+ return this.ledger.sessions[sessionID]
533
+ }
534
+
535
+ patchSession(sessionID: string, patch: Partial<SessionState>): SessionState {
536
+ const session = this.ensureSession(sessionID)
537
+ Object.assign(session, patch, { updatedAt: Date.now() })
538
+ this.touch()
539
+ return session
540
+ }
541
+
542
+ setManual(sessionID: string, model: ModelRef) {
543
+ const session = this.ensureSession(sessionID)
544
+ session.manualOverride = true
545
+ session.manualModel = model
546
+ session.updatedAt = Date.now()
547
+ this.ledger.metrics.manualOverrides += 1
548
+ this.touch()
549
+ }
550
+
551
+ clearManual(sessionID: string) {
552
+ const session = this.ensureSession(sessionID)
553
+ session.manualOverride = false
554
+ session.manualModel = undefined
555
+ session.updatedAt = Date.now()
556
+ this.touch()
557
+ }
558
+
559
+ grantPremiumOneShot(sessionID: string) {
560
+ const session = this.ensureSession(sessionID)
561
+ session.premiumOneShot = true
562
+ session.premiumOneShotAt = Date.now()
563
+ session.updatedAt = Date.now()
564
+ this.touch()
565
+ }
566
+
567
+ consumePremiumOneShot(sessionID: string) {
568
+ const session = this.ensureSession(sessionID)
569
+ session.premiumOneShot = false
570
+ session.premiumOneShotAt = undefined
571
+ session.updatedAt = Date.now()
572
+ this.touch()
573
+ }
574
+
575
+ premiumSpent(month = monthKey()) {
576
+ return this.ledger.premiumSpend.find((entry) => entry.month === month)?.usd ?? 0
577
+ }
578
+
579
+ /**
580
+ * Spend against the plan-model pool (the escalation allowance), tracked in
581
+ * its own monthly buckets so the $120 pool gate reads one number that
582
+ * survives restarts. Premium spend that is not plan-pool spend - Command
583
+ * Code GOAT flagships on their own allowances - never lands here.
584
+ */
585
+ planPoolSpent(month = monthKey()) {
586
+ return this.ledger.planPoolSpend.find((entry) => entry.month === month)?.usd ?? 0
587
+ }
588
+
589
+ private addPlanPoolSpend(usd: number, at: number) {
590
+ if (usd <= 0) return
591
+ const month = monthKey(at)
592
+ const existing = this.ledger.planPoolSpend.find((entry) => entry.month === month)
593
+ if (existing) existing.usd += usd
594
+ else this.ledger.planPoolSpend.push({ month, usd })
595
+ this.ledger.metrics.planPoolUsd += usd
596
+ }
597
+
598
+ private addPremiumSpend(usd: number) {
599
+ if (usd <= 0) return
600
+ const month = monthKey()
601
+ const existing = this.ledger.premiumSpend.find((entry) => entry.month === month)
602
+ if (existing) existing.usd += usd
603
+ else this.ledger.premiumSpend.push({ month, usd })
604
+ this.ledger.metrics.premiumUsd += usd
605
+ }
606
+
607
+ recordUsage(input: StepUsage) {
608
+ const at = input.at ?? Date.now()
609
+ const event: UsageEvent = {
610
+ id: nowId("use"),
611
+ at,
612
+ providerID: input.model.providerID,
613
+ modelID: input.model.id,
614
+ sessionID: input.sessionID,
615
+ usd: Math.max(0, input.cost),
616
+ input: Math.max(0, input.tokens.input),
617
+ output: Math.max(0, input.tokens.output),
618
+ cacheRead: Math.max(0, input.tokens.cache.read),
619
+ cacheWrite: Math.max(0, input.tokens.cache.write),
620
+ source: "step",
621
+ }
622
+ this.ledger.usage.push(event)
623
+ this.ledger.metrics.requests += 1
624
+ this.ledger.metrics.inputTokens += event.input
625
+ this.ledger.metrics.outputTokens += event.output
626
+ this.ledger.metrics.cacheReadTokens += event.cacheRead
627
+ this.ledger.metrics.cacheWriteTokens += event.cacheWrite
628
+ this.ledger.metrics.estimatedUsd += event.usd
629
+ if (input.premium) this.ledger.metrics.premiumRequests += 1
630
+ if (input.latencyMs !== undefined && Number.isFinite(input.latencyMs)) {
631
+ this.ledger.metrics.latencySamples += 1
632
+ this.ledger.metrics.latencyMsTotal += Math.max(0, input.latencyMs)
633
+ this.ledger.metrics.lastLatencyMs = Math.max(0, input.latencyMs)
634
+ }
635
+ const key = modelKey(input.model.providerID, input.model.id)
636
+ const modelMetrics = (this.ledger.metrics.byModel[key] ??= emptyModelMetrics())
637
+ modelMetrics.requests += 1
638
+ modelMetrics.input += event.input
639
+ modelMetrics.output += event.output
640
+ modelMetrics.cacheRead += event.cacheRead
641
+ modelMetrics.cacheWrite += event.cacheWrite
642
+ modelMetrics.usd += event.usd
643
+ if (input.latencyMs !== undefined && Number.isFinite(input.latencyMs) && input.latencyMs > 0) {
644
+ const latency = Math.max(0, input.latencyMs)
645
+ modelMetrics.latencySamples += 1
646
+ modelMetrics.latencyMsTotal += latency
647
+ // Generation speed is the only figure that separates a slow provider
648
+ // from a model that simply reasons for longer: dividing by the tokens
649
+ // actually produced normalizes for reasoning depth.
650
+ if (event.output > 0) {
651
+ const seconds = latency / 1000
652
+ const instant = event.output / Math.max(0.001, seconds)
653
+ // A cumulative mean keeps the metric stable without storing history.
654
+ const previous = modelMetrics.outputTokensPerSecond
655
+ modelMetrics.outputTokensPerSecond = previous > 0 ? previous * 0.7 + instant * 0.3 : instant
656
+ }
657
+ }
658
+ if (input.premium) this.addPremiumSpend(event.usd)
659
+ if (input.planPool) this.addPlanPoolSpend(event.usd, at)
660
+
661
+ const session = this.ensureSession(input.sessionID)
662
+ session.lastModel = input.model
663
+ session.contextTokens = event.input + event.cacheRead + event.cacheWrite
664
+ session.cacheReadTokens = event.cacheRead
665
+ session.stepCount += 1
666
+ session.updatedAt = at
667
+ this.prune(at)
668
+ this.touch()
669
+ }
670
+
671
+ recordDecision(decision: RoutingDecision) {
672
+ this.ledger.decisions.unshift(decision)
673
+ this.ledger.decisions = this.ledger.decisions.slice(0, MAX_DECISIONS)
674
+ if (decision.switched) this.ledger.metrics.switches += 1
675
+ if (decision.dryRun) this.ledger.metrics.dryRunDecisions += 1
676
+ if (decision.needsPremiumApproval) this.ledger.metrics.preventedPremium += 1
677
+ if (decision.chosen?.providerID && decision.chosen) {
678
+ const chosen = decision.candidates.find(
679
+ (candidate) =>
680
+ candidate.model.providerID === decision.chosen?.providerID && candidate.model.modelID === decision.chosen?.id,
681
+ )
682
+ const current = decision.candidates.find(
683
+ (candidate) =>
684
+ candidate.model.providerID === decision.current?.providerID && candidate.model.modelID === decision.current?.id,
685
+ )
686
+ if (chosen && current && chosen.tier > current.tier) this.ledger.metrics.escalations += 1
687
+ }
688
+ const session = this.ensureSession(decision.sessionID)
689
+ session.lastDecisionID = decision.id
690
+ if (decision.chosen) session.lastRoutedModel = decision.chosen
691
+ session.updatedAt = decision.at
692
+ this.touch()
693
+ }
694
+
695
+ recordFailure(
696
+ sessionID: string,
697
+ model: ModelRef | undefined,
698
+ failureClass: FailureClass,
699
+ message: string,
700
+ options: {
701
+ readonly countAgainstModel?: boolean
702
+ readonly cooldownMs?: number
703
+ readonly providerScoped?: boolean
704
+ /** The gateway refused for capacity reasons; holds back the provider and this endpoint. */
705
+ readonly saturation?: boolean
706
+ } = {},
707
+ ) {
708
+ this.ledger.metrics.failures[failureClass] = (this.ledger.metrics.failures[failureClass] ?? 0) + 1
709
+
710
+ // Provider-scoped faults feed the provider streak. A billing-scope fault
711
+ // and a saturated gateway both mean the provider, not the model, is the
712
+ // thing failing, so both count here.
713
+ if ((options.providerScoped || options.saturation) && model) {
714
+ const streak = (this.providerFaultStreak.get(model.providerID) ?? 0) + 1
715
+ this.providerFaultStreak.set(model.providerID, streak)
716
+ if (streak >= PROVIDER_DOWN_STREAK) {
717
+ const until = Date.now() + PROVIDER_BACKOFF_MS
718
+ this.ledger.providerCooldowns = {
719
+ ...(this.ledger.providerCooldowns ?? {}),
720
+ [model.providerID]: Math.max(this.ledger.providerCooldowns?.[model.providerID] ?? 0, until),
721
+ }
722
+ }
723
+ }
724
+
725
+ // A billing-scope fault happens in the provider's billing path before any
726
+ // model is selected, so cooling the model that happened to be serving
727
+ // would be arbitrary blame. Stop here and let the streak handle it.
728
+ if (options.providerScoped && !options.saturation && model) {
729
+ const session = this.ensureSession(sessionID)
730
+ session.failures = [...session.failures, { class: failureClass, at: Date.now(), message: safeError(message) }].slice(-12)
731
+ session.updatedAt = Date.now()
732
+ this.touch()
733
+ return
734
+ }
735
+
736
+ // A saturated gateway is a different case again: the endpoint really did
737
+ // refuse this request, so it must be held back at once rather than after
738
+ // the provider streak reaches five. But the refusal says nothing about how
739
+ // capable the model is, so it is kept out of the reliability record that
740
+ // drives the success estimate. Holding back and blaming are separate.
741
+ if (options.saturation && model) {
742
+ this.armModelCooldown(model, options.cooldownMs ?? 0, 1)
743
+ const session = this.ensureSession(sessionID)
744
+ session.failures = [...session.failures, { class: failureClass, at: Date.now(), message: safeError(message) }].slice(-12)
745
+ session.updatedAt = Date.now()
746
+ this.touch()
747
+ return
748
+ }
749
+
750
+ // Only a provider fault counts against a model's reliability. A user
751
+ // interrupt must never make the router distrust a healthy model.
752
+ if (model && options.countAgainstModel !== false) {
753
+ const key = modelKey(model.providerID, model.id)
754
+ const metrics = (this.ledger.metrics.byModel[key] ??= emptyModelMetrics())
755
+ metrics.failures += 1
756
+ if (failureClass === "context_overflow" || failureClass === "rate_limit") {
757
+ metrics.timeoutFailures += 1
758
+ this.ledger.metrics.timeouts += 1
759
+ }
760
+ if ((options.cooldownMs ?? 0) > 0) {
761
+ // Repeated faults extend the cooldown, so a model that keeps dropping
762
+ // streams is avoided for longer each time.
763
+ this.armModelCooldown(model, options.cooldownMs ?? 0, Math.min(4, (metrics.failures % 4) + 1))
764
+ }
765
+ }
766
+ const session = this.ensureSession(sessionID)
767
+ session.failures = [...session.failures, { class: failureClass, at: Date.now(), message: safeError(message) }].slice(-12)
768
+ session.updatedAt = Date.now()
769
+ this.touch()
770
+ }
771
+
772
+ /** Active failure cooldowns, keyed by model key. */
773
+ cooldowns(): Readonly<Record<string, number>> {
774
+ return this.ledger.cooldowns ?? {}
775
+ }
776
+
777
+ /**
778
+ * Hold one model back from routing without touching its reliability record.
779
+ *
780
+ * These are separate concerns and were previously welded together: a model
781
+ * could only be cooled by also being judged less reliable. A gateway
782
+ * refusing for capacity is proof that this endpoint is not serving, and no
783
+ * evidence at all about how well the model reasons.
784
+ */
785
+ private armModelCooldown(model: ModelRef, cooldownMs: number, strikes: number) {
786
+ if (cooldownMs <= 0) return
787
+ const key = modelKey(model.providerID, model.id)
788
+ const previous = this.ledger.cooldowns?.[key] ?? 0
789
+ const until = Date.now() + cooldownMs * Math.max(1, strikes)
790
+ this.ledger.cooldowns = { ...(this.ledger.cooldowns ?? {}), [key]: Math.max(previous, until) }
791
+ }
792
+
793
+ /**
794
+ * Hold one model back from routing for the given window, without touching
795
+ * its reliability record or its failure history.
796
+ *
797
+ * The prompt-rejection failover uses this: the model that rejected a
798
+ * request is healthy, the request shape was not, and the next prompt should
799
+ * route elsewhere while the shape stays what it is.
800
+ */
801
+ holdModel(model: ModelRef, cooldownMs: number): void {
802
+ this.armModelCooldown(model, cooldownMs, 1)
803
+ }
804
+
805
+ /** Active provider outage backoffs, keyed by provider id. */
806
+ providerCooldowns(): Readonly<Record<string, number>> {
807
+ return this.ledger.providerCooldowns ?? {}
808
+ }
809
+
810
+ /**
811
+ * Hold a provider back until a given time because its allowance is spent.
812
+ *
813
+ * Separate from the streak-based backoff in `recordFailure`: a spent
814
+ * allowance is not a fault that might clear in a minute, it is a fact that
815
+ * holds until the window resets. Never shortens an existing cooldown, so a
816
+ * shorter guess cannot undo a longer one.
817
+ */
818
+ exhaustProvider(providerID: string, until: number): void {
819
+ const current = this.ledger.providerCooldowns?.[providerID] ?? 0
820
+ if (until <= current) return
821
+ this.ledger.providerCooldowns = { ...(this.ledger.providerCooldowns ?? {}), [providerID]: until }
822
+ }
823
+
824
+ isProviderCoolingDown(providerID: string, now = Date.now()): boolean {
825
+ const until = this.ledger.providerCooldowns?.[providerID]
826
+ return typeof until === "number" && until > now
827
+ }
828
+
829
+ /**
830
+ * A successful request proves the provider is serving again, so the
831
+ * consecutive-failure streak starts over.
832
+ */
833
+ recordProviderSuccess(providerID: string) {
834
+ if (this.providerFaultStreak.size > 0) this.providerFaultStreak.delete(providerID)
835
+ }
836
+
837
+ isCoolingDown(modelKeyValue: string, now = Date.now()): boolean {
838
+ const until = this.ledger.cooldowns?.[modelKeyValue]
839
+ return typeof until === "number" && until > now
840
+ }
841
+
842
+ recordRetry(sessionID: string) {
843
+ this.ledger.metrics.retries += 1
844
+ const session = this.ensureSession(sessionID)
845
+ session.retries += 1
846
+ session.updatedAt = Date.now()
847
+ this.touch()
848
+ }
849
+
850
+ bootstrap(sessions: ReadonlyArray<BootstrapSession>, input: { days: number; limit: number; now?: number }) {
851
+ if (this.ledger.bootstrappedAt) return { added: 0, skipped: true }
852
+ const now = input.now ?? Date.now()
853
+ const cutoff = now - input.days * 24 * 60 * 60 * 1000
854
+ const eligible = sessions
855
+ .filter((session) => session.cost > 0 && session.updatedAt >= cutoff && session.model)
856
+ .sort((a, b) => b.updatedAt - a.updatedAt)
857
+ .slice(0, Math.max(0, input.limit))
858
+
859
+ for (const session of eligible) {
860
+ const model = session.model
861
+ if (!model) continue
862
+ this.ledger.usage.push({
863
+ id: nowId("boot"),
864
+ at: Math.min(now, session.updatedAt),
865
+ providerID: model.providerID,
866
+ modelID: model.id,
867
+ sessionID: session.id,
868
+ usd: Math.max(0, session.cost),
869
+ input: Math.max(0, session.tokens?.input ?? 0),
870
+ output: Math.max(0, session.tokens?.output ?? 0),
871
+ cacheRead: Math.max(0, session.tokens?.cache.read ?? 0),
872
+ cacheWrite: Math.max(0, session.tokens?.cache.write ?? 0),
873
+ source: "bootstrap",
874
+ })
875
+ }
876
+
877
+ this.ledger.bootstrappedAt = now
878
+ this.ledger.bootstrapSource = `Recent sessions (${eligible.length}) attributed to each session's selected model; approximate when a session switched models.`
879
+ this.prune(now)
880
+ this.touch()
881
+ return { added: eligible.length, skipped: false }
882
+ }
883
+
884
+ /**
885
+ * Seed the ledger from OpenCode's own message history.
886
+ *
887
+ * Better than `bootstrap` in three ways, all of which matter for a fixed
888
+ * seven-day window:
889
+ *
890
+ * - it is per MESSAGE, so each event carries its real timestamp and lands in
891
+ * the correct window instead of being smeared onto the session's last
892
+ * update time
893
+ * - it records the model that actually served each request, not the session's
894
+ * currently selected model
895
+ * - it can be re-read, because the database keeps growing and a one-shot
896
+ * import stops being true the moment a new session runs
897
+ *
898
+ * Rows already present are skipped by id, so re-reading is idempotent.
899
+ */
900
+ importHistory(
901
+ entries: ReadonlyArray<{
902
+ readonly id: string
903
+ readonly at: number
904
+ readonly providerID: string
905
+ readonly modelID: string
906
+ readonly cost: number
907
+ readonly input: number
908
+ readonly output: number
909
+ readonly cacheRead: number
910
+ readonly cacheWrite: number
911
+ }>,
912
+ input: { readonly now?: number; readonly source?: string } = {},
913
+ ): { added: number; skipped: number } {
914
+ const now = input.now ?? Date.now()
915
+ const existing = new Set(this.ledger.usage.map((event) => event.id))
916
+ let added = 0
917
+ let skipped = 0
918
+ for (const entry of entries) {
919
+ const id = `hist:${entry.id}`
920
+ if (existing.has(id)) {
921
+ skipped += 1
922
+ continue
923
+ }
924
+ this.ledger.usage.push({
925
+ id,
926
+ at: Math.min(now, entry.at),
927
+ providerID: entry.providerID,
928
+ modelID: entry.modelID,
929
+ usd: Math.max(0, entry.cost),
930
+ input: Math.max(0, entry.input),
931
+ output: Math.max(0, entry.output),
932
+ cacheRead: Math.max(0, entry.cacheRead),
933
+ cacheWrite: Math.max(0, entry.cacheWrite),
934
+ source: "bootstrap",
935
+ })
936
+ existing.add(id)
937
+ added += 1
938
+ }
939
+ if (added > 0) {
940
+ this.ledger.bootstrappedAt = now
941
+ this.ledger.bootstrapSource =
942
+ input.source ??
943
+ `Imported ${added} messages from OpenCode history, each attributed to the model that actually served it.`
944
+ this.prune(now)
945
+ this.touch()
946
+ }
947
+ return { added, skipped }
948
+ }
949
+
950
+ totalsForModel(providerID: string, modelID: string, input: { now?: number; periodStart?: number } = {}): ModelUsageTotals {
951
+ const now = input.now ?? Date.now()
952
+ const periodStart = input.periodStart ?? now - MONTH_MS
953
+ const fiveHourCutoff = now - FIVE_HOURS_MS
954
+ const weekCutoff = now - WEEK_MS
955
+ let monthlyUsd = 0
956
+ let fiveHourUsd = 0
957
+ let weeklyUsd = 0
958
+ let inputTokens = 0
959
+ let output = 0
960
+ let cacheRead = 0
961
+ let requests = 0
962
+ for (const event of this.ledger.usage) {
963
+ if (event.providerID !== providerID || event.modelID !== modelID) continue
964
+ if (event.at >= periodStart) {
965
+ monthlyUsd += event.usd
966
+ inputTokens += event.input
967
+ output += event.output
968
+ cacheRead += event.cacheRead
969
+ requests += 1
970
+ }
971
+ if (event.at >= fiveHourCutoff) fiveHourUsd += event.usd
972
+ if (event.at >= weekCutoff) weeklyUsd += event.usd
973
+ }
974
+ return { monthlyUsd, fiveHourUsd, weeklyUsd, input: inputTokens, output, cacheRead, requests }
975
+ }
976
+
977
+ modelQuotaSnapshots(
978
+ providerID: string,
979
+ models: ReadonlyArray<ModelRecord>,
980
+ provider: ProviderQuotaSnapshot | undefined,
981
+ settings: ProviderSettings,
982
+ now = Date.now(),
983
+ ): Record<string, ModelQuotaSnapshot> {
984
+ const periodStart = provider?.periodStart ?? (provider?.monthly.resetsAt ? provider.monthly.resetsAt - MONTH_MS : now - MONTH_MS)
985
+ const result: Record<string, ModelQuotaSnapshot> = {}
986
+ for (const model of models) {
987
+ if (model.providerID !== providerID) continue
988
+ const totals = this.totalsForModel(providerID, model.modelID, { now, periodStart })
989
+ const monthlyCap = model.monthlyAllowanceUsd > 0 ? model.monthlyAllowanceUsd : 0
990
+ const fiveHourCap = monthlyCap > 0 ? monthlyCap * 0.2 : 0
991
+ const weeklyCap = monthlyCap > 0 ? monthlyCap * 0.5 : 0
992
+ const monthlyUsedPct = monthlyCap > 0 ? pct(totals.monthlyUsd, monthlyCap) : 0
993
+ const fiveHourUsedPct = fiveHourCap > 0 ? pct(totals.fiveHourUsd, fiveHourCap) : 0
994
+ const weeklyUsedPct = weeklyCap > 0 ? pct(totals.weeklyUsd, weeklyCap) : 0
995
+ let blocked = false
996
+ let blockReason: string | undefined
997
+ if (monthlyCap > 0 && monthlyUsedPct >= 100) {
998
+ blocked = true
999
+ blockReason = "Estimated monthly model allowance exhausted"
1000
+ } else if (fiveHourCap > 0 && fiveHourUsedPct >= 100) {
1001
+ blocked = true
1002
+ blockReason = "Estimated five-hour model allowance exhausted"
1003
+ } else if (weeklyCap > 0 && weeklyUsedPct >= 100) {
1004
+ blocked = true
1005
+ blockReason = "Estimated weekly model allowance exhausted"
1006
+ }
1007
+ const key = modelKey(providerID, model.modelID)
1008
+ result[key] = {
1009
+ model: { providerID, modelID: model.modelID },
1010
+ monthlyAllowanceUsd: monthlyCap,
1011
+ monthlyUsedUsd: totals.monthlyUsd,
1012
+ monthlyRemainingUsd: Math.max(0, monthlyCap - totals.monthlyUsd),
1013
+ monthlyUsedPct,
1014
+ fiveHourUsedUsd: totals.fiveHourUsd,
1015
+ fiveHourCapUsd: fiveHourCap,
1016
+ fiveHourUsedPct,
1017
+ weeklyUsedUsd: totals.weeklyUsd,
1018
+ weeklyCapUsd: weeklyCap,
1019
+ weeklyUsedPct,
1020
+ blocked,
1021
+ ...(blockReason ? { blockReason } : {}),
1022
+ source: "estimate",
1023
+ }
1024
+ }
1025
+ return result
1026
+ }
1027
+
1028
+ attachModelQuotas(
1029
+ provider: ProviderQuotaSnapshot,
1030
+ models: ReadonlyArray<ModelRecord>,
1031
+ settings: ProviderSettings,
1032
+ now = Date.now(),
1033
+ ): ProviderQuotaSnapshot {
1034
+ return {
1035
+ ...provider,
1036
+ models: this.modelQuotaSnapshots(provider.providerID, models, provider, settings, now),
1037
+ }
1038
+ }
1039
+
1040
+ snapshotMetrics(): Metrics {
1041
+ return structuredClone(this.ledger.metrics)
1042
+ }
1043
+
1044
+ /**
1045
+ * Raw usage events, used by providers whose allowance must be modelled from
1046
+ * local activity rather than read from a provider endpoint.
1047
+ */
1048
+ /**
1049
+ * Usage events over a window, including history that has been folded.
1050
+ *
1051
+ * This returns the raw event array when no window is given, which is the
1052
+ * cheapest thing to do and what callers that only need recent detail want.
1053
+ *
1054
+ * A windowed call must not return the raw array alone. Once usage overflows
1055
+ * the raw cap the oldest events are folded into hourly buckets and leave that
1056
+ * array, so a raw-only answer shrinks as history ages. Anything estimating a
1057
+ * weekly or monthly allowance from usage would then under-report spend on a
1058
+ * busy account, which is the opposite of failing safe: the busier the week,
1059
+ * the more the estimate says is left.
1060
+ *
1061
+ * Buckets are expanded back into synthetic events carrying the hour they
1062
+ * belong to. Attribution is per model, so pool assignment still works, but
1063
+ * the individual request detail inside a folded hour is gone by design.
1064
+ */
1065
+ usageEvents(window?: { since: number; until?: number }): ReadonlyArray<UsageEvent> {
1066
+ if (!window) return this.ledger.usage
1067
+ const { since } = window
1068
+ const until = window.until ?? Date.now()
1069
+ const raw = this.ledger.usage.filter((event) => event.at >= since && event.at <= until)
1070
+ const buckets = this.ledger.usageRollup?.buckets
1071
+ if (!buckets || buckets.length === 0) return raw
1072
+
1073
+ const folded: UsageEvent[] = []
1074
+ for (const bucket of buckets) {
1075
+ if (bucket.hour < since || bucket.hour > until) continue
1076
+ folded.push({
1077
+ id: `folded_${bucket.hour}_${bucket.providerID}_${bucket.modelID}`,
1078
+ at: bucket.hour,
1079
+ providerID: bucket.providerID,
1080
+ modelID: bucket.modelID,
1081
+ usd: bucket.usd,
1082
+ input: bucket.input,
1083
+ output: bucket.output,
1084
+ cacheRead: bucket.cacheRead,
1085
+ cacheWrite: bucket.cacheWrite,
1086
+ source: "step",
1087
+ })
1088
+ }
1089
+ // A bucket and a raw event never cover the same request: folding removes
1090
+ // the event from the raw array as it creates the bucket.
1091
+ return [...folded, ...raw].sort((a, b) => a.at - b.at)
1092
+ }
1093
+
1094
+ /**
1095
+ * Fold aged usage events into hourly buckets instead of discarding them.
1096
+ *
1097
+ * The previous implementation truncated the event array, so a heavy
1098
+ * afternoon silently erased the start of the week and no weekly or monthly
1099
+ * forecast was possible. Folding keeps every token accounted for at a tiny
1100
+ * fraction of the storage cost.
1101
+ */
1102
+ private foldUsage(now: number) {
1103
+ const rollup: UsageRollup = (this.ledger.usageRollup ??= {
1104
+ version: 1,
1105
+ buckets: [],
1106
+ recent: [],
1107
+ updatedAt: now,
1108
+ })
1109
+ const byKey = new Map<string, UsageBucket>()
1110
+ for (const bucket of rollup.buckets) byKey.set(bucketKeyOf(bucket), bucket)
1111
+
1112
+ const rawCutoff = now - RAW_EVENT_HORIZON_MS
1113
+ const kept: UsageEvent[] = []
1114
+ // When the raw array is above the cap, the oldest events are folded even
1115
+ // if they are still inside the horizon. That keeps the in-memory array
1116
+ // bounded without losing a single token from the totals.
1117
+ const overflow = Math.max(0, this.ledger.usage.length - MAX_USAGE_EVENTS * 0.8)
1118
+ let folded = 0
1119
+ for (const event of this.ledger.usage) {
1120
+ const shouldFold = event.at < rawCutoff || folded < overflow
1121
+ if (!shouldFold) {
1122
+ kept.push(event)
1123
+ continue
1124
+ }
1125
+ folded += 1
1126
+ foldEventInto(byKey, event, this.periodIDFor(event.at))
1127
+ }
1128
+ this.ledger.usage = kept
1129
+
1130
+ const bucketCutoff = now - ROLLUP_RETENTION_MS
1131
+ const buckets = [...byKey.values()].filter((bucket) => bucket.hour >= bucketCutoff)
1132
+ buckets.sort((a, b) => a.hour - b.hour)
1133
+ rollup.buckets =
1134
+ buckets.length > MAX_BUCKETS ? buckets.slice(buckets.length - MAX_BUCKETS) : buckets
1135
+ rollup.recent = kept
1136
+ rollup.updatedAt = now
1137
+ }
1138
+
1139
+ /**
1140
+ * Billing period key for an instant.
1141
+ *
1142
+ * This is a calendar month boundary, which is exact for providers whose
1143
+ * allowance resets per billing cycle and a deliberate approximation for
1144
+ * providers whose period starts on a subscription anniversary. It exists to
1145
+ * keep two *different* billing periods from being summed as one, which is
1146
+ * the failure that matters here.
1147
+ */
1148
+ periodIDFor(at: number): string {
1149
+ return monthKey(at)
1150
+ }
1151
+
1152
+ /** Totals over an arbitrary window, combining folded buckets and raw events. */
1153
+ aggregate(input: {
1154
+ providerID?: string
1155
+ modelID?: string
1156
+ since: number
1157
+ until?: number
1158
+ }): {
1159
+ since: number
1160
+ until: number
1161
+ requests: number
1162
+ input: number
1163
+ output: number
1164
+ cacheRead: number
1165
+ cacheWrite: number
1166
+ usd: number
1167
+ failures: number
1168
+ latencyMsTotal: number
1169
+ latencySamples: number
1170
+ } {
1171
+ const until = input.until ?? Date.now()
1172
+ const totals = {
1173
+ requests: 0,
1174
+ input: 0,
1175
+ output: 0,
1176
+ cacheRead: 0,
1177
+ cacheWrite: 0,
1178
+ usd: 0,
1179
+ failures: 0,
1180
+ latencyMsTotal: 0,
1181
+ latencySamples: 0,
1182
+ }
1183
+ const matches = (providerID: string, modelID: string) =>
1184
+ (!input.providerID || providerID === input.providerID) && (!input.modelID || modelID === input.modelID)
1185
+
1186
+ for (const bucket of this.ledger.usageRollup?.buckets ?? []) {
1187
+ if (bucket.hour < input.since || bucket.hour > until) continue
1188
+ if (!matches(bucket.providerID, bucket.modelID)) continue
1189
+ totals.requests += bucket.requests
1190
+ totals.input += bucket.input
1191
+ totals.output += bucket.output
1192
+ totals.cacheRead += bucket.cacheRead
1193
+ totals.cacheWrite += bucket.cacheWrite
1194
+ totals.usd += bucket.usd
1195
+ totals.failures += bucket.failures
1196
+ totals.latencyMsTotal += bucket.latencyMsTotal
1197
+ totals.latencySamples += bucket.latencySamples
1198
+ }
1199
+ for (const event of this.ledger.usage) {
1200
+ if (event.at < input.since || event.at > until) continue
1201
+ if (!matches(event.providerID, event.modelID)) continue
1202
+ totals.requests += 1
1203
+ totals.input += event.input
1204
+ totals.output += event.output
1205
+ totals.cacheRead += event.cacheRead
1206
+ totals.cacheWrite += event.cacheWrite
1207
+ totals.usd += event.usd
1208
+ }
1209
+ return { ...totals, since: input.since, until }
1210
+ }
1211
+
1212
+ /** Usage grouped by provider and model over a window. */
1213
+ usageByModel(input: { since: number; until?: number }): Array<{
1214
+ providerID: string
1215
+ modelID: string
1216
+ requests: number
1217
+ input: number
1218
+ output: number
1219
+ cacheRead: number
1220
+ cacheWrite: number
1221
+ usd: number
1222
+ }> {
1223
+ const grouped = new Map<string, {
1224
+ providerID: string
1225
+ modelID: string
1226
+ requests: number
1227
+ input: number
1228
+ output: number
1229
+ cacheRead: number
1230
+ cacheWrite: number
1231
+ usd: number
1232
+ }>()
1233
+ const until = input.until ?? Date.now()
1234
+ const add = (
1235
+ providerID: string,
1236
+ modelID: string,
1237
+ row: { requests: number; input: number; output: number; cacheRead: number; cacheWrite: number; usd: number },
1238
+ ) => {
1239
+ const key = modelKey(providerID, modelID)
1240
+ let entry = grouped.get(key)
1241
+ if (!entry) {
1242
+ entry = {
1243
+ providerID,
1244
+ modelID,
1245
+ requests: 0,
1246
+ input: 0,
1247
+ output: 0,
1248
+ cacheRead: 0,
1249
+ cacheWrite: 0,
1250
+ usd: 0,
1251
+ }
1252
+ grouped.set(key, entry)
1253
+ }
1254
+ entry.requests += row.requests
1255
+ entry.input += row.input
1256
+ entry.output += row.output
1257
+ entry.cacheRead += row.cacheRead
1258
+ entry.cacheWrite += row.cacheWrite
1259
+ entry.usd += row.usd
1260
+ }
1261
+ for (const bucket of this.ledger.usageRollup?.buckets ?? []) {
1262
+ if (bucket.hour < input.since || bucket.hour > until) continue
1263
+ add(bucket.providerID, bucket.modelID, bucket)
1264
+ }
1265
+ for (const event of this.ledger.usage) {
1266
+ if (event.at < input.since || event.at > until) continue
1267
+ add(event.providerID, event.modelID, { ...event, requests: 1 })
1268
+ }
1269
+ return [...grouped.values()].sort((a, b) => b.usd - a.usd)
1270
+ }
1271
+
1272
+ prune(now = Date.now()) {
1273
+ this.foldUsage(now)
1274
+ if (this.ledger.decisions.length > MAX_DECISIONS) this.ledger.decisions = this.ledger.decisions.slice(0, MAX_DECISIONS)
1275
+ const sessions = Object.values(this.ledger.sessions)
1276
+ if (sessions.length > MAX_SESSIONS) {
1277
+ sessions
1278
+ .sort((a, b) => a.updatedAt - b.updatedAt)
1279
+ .slice(0, sessions.length - MAX_SESSIONS)
1280
+ .forEach((session) => delete this.ledger.sessions[session.sessionID])
1281
+ }
1282
+ }
1283
+
1284
+ export(): Ledger {
1285
+ this.prune()
1286
+ return structuredClone(this.ledger)
1287
+ }
1288
+
1289
+ get lastDecision(): RoutingDecision | undefined {
1290
+ return this.ledger.decisions[0]
1291
+ }
1292
+
1293
+ decisions(limit = 10): RoutingDecision[] {
1294
+ return this.ledger.decisions.slice(0, clamp(limit, 1, MAX_DECISIONS))
1295
+ }
1296
+
1297
+ modelMetrics(key: string) {
1298
+ return this.ledger.metrics.byModel[key]
1299
+ }
1300
+
1301
+ sessionModelKey(sessionID: string) {
1302
+ return keyOfRef(this.ledger.sessions[sessionID]?.lastModel)
1303
+ }
1304
+ }