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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,155 @@
1
+ import { OPENDESIGN_MODELS } from "../providers/opendesign-models.js"
2
+ import { creditMultiplierFor } from "../providers/opendesign-credits.js"
3
+ import type { UsageReading } from "../plan-page.js"
4
+
5
+ /**
6
+ * Check the router's cost model against what the account was actually charged.
7
+ *
8
+ * This is the whole reason a usage paste beats an allowance paste. A percentage
9
+ * tells you how much allowance is left; a usage table tells you whether the
10
+ * arithmetic that predicted it was right in the first place.
11
+ *
12
+ * For one model, charged credits divided by the published base price gives the
13
+ * multiplier that was actually applied. Compare that to the multiplier the
14
+ * catalog believes, and the disagreement — if any — is measured rather than
15
+ * assumed. Ten wrong prices were found exactly this way, by comparing numbers
16
+ * instead of trusting comments.
17
+ *
18
+ * Nothing here writes anything. A disagreement is reported for a human to
19
+ * decide on, because a usage page is one window of one account and a catalog is
20
+ * meant to describe everyone.
21
+ */
22
+
23
+ export interface ReconcileRow {
24
+ readonly modelName: string
25
+ readonly modelID?: string
26
+ /** Credits charged, as USD-equivalent. */
27
+ readonly chargedUsd: number
28
+ /** What the router's prices say that usage should have cost, before multiplier. */
29
+ readonly baseUsd?: number
30
+ /** chargedUsd / baseUsd — the multiplier the account actually applied. */
31
+ readonly impliedMultiplier?: number
32
+ /** The multiplier the catalog believes. */
33
+ readonly catalogMultiplier?: number
34
+ readonly verdict: "agrees" | "differs" | "unmatched" | "no-price"
35
+ /** For a differing row, how far apart in relative terms. */
36
+ readonly driftPct?: number
37
+ }
38
+
39
+ export interface ReconcileResult {
40
+ readonly rows: ReadonlyArray<ReconcileRow>
41
+ readonly agree: number
42
+ readonly differ: number
43
+ readonly unmatched: number
44
+ /** One sentence for the panel. */
45
+ readonly summary: string
46
+ }
47
+
48
+ /** Normalise a display name for matching: "DeepSeek V4.1 Flash" -> "deepseekv41flash". */
49
+ const normaliseName = (value: string): string =>
50
+ value.toLowerCase().replace(/\(.*?\)/g, "").replace(/[^a-z0-9]/g, "")
51
+
52
+ const byNormalisedName = new Map(OPENDESIGN_MODELS.map((model) => [normaliseName(model.name), model]))
53
+
54
+ /** Names the console may use that differ from our label. */
55
+ const ALIASES: Readonly<Record<string, string>> = {
56
+ deepseekv41flash: "deepseek-v4.1-flash",
57
+ deepseekv4flash: "deepseek-v4-flash",
58
+ deepseekv4pro: "deepseek-v4-pro",
59
+ glm53flash: "glm-5.3-flash",
60
+ glm53flashx: "glm-5.3-flashx",
61
+ claudehaiku55: "claude-haiku-5.5",
62
+ gpt6luna: "gpt-6-luna",
63
+ mimo26flash: "mimo-v2.6-flash",
64
+ mimo26pro: "mimo-v2.6-pro",
65
+ kimik27code: "kimi-k2.7-code",
66
+ }
67
+
68
+ const resolveModel = (name: string) => {
69
+ const key = normaliseName(name)
70
+ const direct = byNormalisedName.get(key)
71
+ if (direct) return direct
72
+ const aliasID = ALIASES[key as keyof typeof ALIASES]
73
+ if (aliasID) return OPENDESIGN_MODELS.find((model) => model.modelID === aliasID)
74
+ return undefined
75
+ }
76
+
77
+ /** Within this much, a rounding difference is not a finding. */
78
+ const TOLERANCE_PCT = 5
79
+
80
+ export const reconcileUsage = (reading: UsageReading): ReconcileResult => {
81
+ const rows: ReconcileRow[] = []
82
+
83
+ for (const row of reading.rows) {
84
+ if (row.chargedUsd === undefined || !row.modelName) continue
85
+ const model = resolveModel(row.modelName)
86
+ if (!model) {
87
+ rows.push({
88
+ modelName: row.modelName,
89
+ chargedUsd: row.chargedUsd,
90
+ verdict: "unmatched",
91
+ })
92
+ continue
93
+ }
94
+
95
+ const tier = model.cost[0]
96
+ if (!tier) {
97
+ rows.push({
98
+ modelName: row.modelName,
99
+ modelID: model.modelID,
100
+ chargedUsd: row.chargedUsd,
101
+ verdict: "no-price",
102
+ })
103
+ continue
104
+ }
105
+
106
+ const input = row.inputTokens ?? 0
107
+ const output = row.outputTokens ?? 0
108
+ const cacheRead = row.cacheReadTokens ?? 0
109
+ // Tokens absent means the base cost cannot be reconstructed; the row still
110
+ // proves a charge happened, which is why it is kept rather than dropped.
111
+ const baseUsd =
112
+ input + output + cacheRead === 0
113
+ ? undefined
114
+ : (tier.input * input + tier.output * output + (tier.cache?.read ?? 0) * cacheRead) / 1_000_000
115
+
116
+ if (baseUsd === undefined || baseUsd <= 0) {
117
+ rows.push({
118
+ modelName: row.modelName,
119
+ modelID: model.modelID,
120
+ chargedUsd: row.chargedUsd,
121
+ verdict: "no-price",
122
+ })
123
+ continue
124
+ }
125
+
126
+ const impliedMultiplier = row.chargedUsd / baseUsd
127
+ const catalogMultiplier = creditMultiplierFor(model.modelID).multiplier
128
+ const driftPct = catalogMultiplier === 0 ? undefined : ((impliedMultiplier - catalogMultiplier) / catalogMultiplier) * 100
129
+ const agrees = driftPct !== undefined && Math.abs(driftPct) <= TOLERANCE_PCT
130
+
131
+ rows.push({
132
+ modelName: row.modelName,
133
+ modelID: model.modelID,
134
+ chargedUsd: row.chargedUsd,
135
+ baseUsd,
136
+ impliedMultiplier,
137
+ catalogMultiplier,
138
+ verdict: agrees ? "agrees" : "differs",
139
+ ...(driftPct !== undefined ? { driftPct } : {}),
140
+ })
141
+ }
142
+
143
+ const agree = rows.filter((row) => row.verdict === "agrees").length
144
+ const differ = rows.filter((row) => row.verdict === "differs").length
145
+ const unmatched = rows.filter((row) => row.verdict !== "agrees" && row.verdict !== "differs").length
146
+
147
+ const summary =
148
+ rows.length === 0
149
+ ? "No charged amounts on that page, so the cost model cannot be checked from it."
150
+ : differ === 0
151
+ ? `The cost model matches what was charged for ${agree} of ${rows.length} model${rows.length === 1 ? "" : "s"}.`
152
+ : `The cost model disagrees with what was charged for ${differ} of ${rows.length} model${rows.length === 1 ? "" : "s"}. That is a catalog problem, not a routing one.`
153
+
154
+ return { rows, agree, differ, unmatched, summary }
155
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Usage aggregation and forecasting.
3
+ *
4
+ * The earlier ledger kept a flat, capped array of events, so a heavy afternoon
5
+ * erased the start of the week and no weekly or monthly projection was
6
+ * possible. The ledger now folds aged events into hourly buckets; this module
7
+ * turns those into the windows a plan actually meters, and projects
8
+ * consumption against the window that will reset first.
9
+ *
10
+ * Everything here is an estimate unless a provider snapshot supplied the
11
+ * numbers, and each figure says which it is.
12
+ */
13
+
14
+ import type { UsageEvent } from "../types.js"
15
+
16
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
17
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000
18
+ const DAY_MS = 24 * 60 * 60 * 1000
19
+
20
+ export interface WindowTotals {
21
+ readonly since: number
22
+ readonly until: number
23
+ readonly requests: number
24
+ readonly input: number
25
+ readonly output: number
26
+ readonly cacheRead: number
27
+ readonly cacheWrite: number
28
+ readonly usd: number
29
+ readonly failures: number
30
+ readonly latencyMsTotal: number
31
+ readonly latencySamples: number
32
+ }
33
+
34
+ export interface Forecast {
35
+ /** Credits or dollars projected over the full window at the current rate. */
36
+ readonly projectedUsd: number
37
+ /** Hours of representative data the projection is based on. */
38
+ readonly basisHours: number
39
+ /** When the allowance would run out at the current rate, if it would. */
40
+ readonly exhaustsAt?: number
41
+ /** True when the projection exceeds the remaining allowance. */
42
+ readonly willExhaust: boolean
43
+ /** Short, plain-language explanation for a dashboard. */
44
+ readonly explanation: string
45
+ }
46
+
47
+ export interface UsageSource {
48
+ aggregate(input: { providerID?: string; modelID?: string; since: number; until?: number }): WindowTotals
49
+ usageByModel(input: { since: number; until?: number }): Array<{
50
+ providerID: string
51
+ modelID: string
52
+ requests: number
53
+ input: number
54
+ output: number
55
+ cacheRead: number
56
+ cacheWrite: number
57
+ usd: number
58
+ }>
59
+ readonly usageEvents: ReadonlyArray<UsageEvent>
60
+ }
61
+
62
+ /**
63
+ * Project consumption from a representative recent window.
64
+ *
65
+ * The basis is capped so a long quiet stretch cannot dilute a burst, and
66
+ * floored so a burst of a few minutes is not extrapolated into a month. Both
67
+ * bounds are stated in the explanation rather than hidden.
68
+ */
69
+ export const forecast = (input: {
70
+ windowMs: number
71
+ now: number
72
+ spendInWindowUsd: number
73
+ /** Earliest instant with representative activity, if known. */
74
+ activityStart?: number
75
+ remainingUsd?: number
76
+ label: string
77
+ }): Forecast => {
78
+ const windowStart = input.now - input.windowMs
79
+ const earliest = Math.max(windowStart, input.activityStart ?? windowStart)
80
+ // A representative basis: at least one hour, at most the full window.
81
+ const basisMs = Math.min(
82
+ input.windowMs,
83
+ Math.max(60 * 60 * 1000, input.now - earliest),
84
+ )
85
+ const basisHours = basisMs / (60 * 60 * 1000)
86
+ const windowHours = input.windowMs / (60 * 60 * 1000)
87
+ const perHour = input.spendInWindowUsd / Math.max(0.001, basisHours)
88
+ const projectedUsd = perHour * windowHours
89
+ const remaining = input.remainingUsd
90
+ const willExhaust = remaining !== undefined && projectedUsd > remaining
91
+ const exhaustsAt =
92
+ willExhaust && perHour > 0
93
+ ? input.now + (Math.max(0, remaining ?? 0) / perHour) * 60 * 60 * 1000
94
+ : undefined
95
+ const explanation = willExhaust
96
+ ? `At the current rate the ${input.label} allowance runs out about ${formatRelative(exhaustsAt ?? input.now, input.now)}.`
97
+ : `At the current rate the ${input.label} allowance covers the full window.`
98
+ return {
99
+ projectedUsd,
100
+ basisHours,
101
+ ...(exhaustsAt !== undefined ? { exhaustsAt } : {}),
102
+ willExhaust,
103
+ explanation,
104
+ }
105
+ }
106
+
107
+ export const formatRelative = (at: number, now: number): string => {
108
+ const delta = at - now
109
+ if (delta <= 0) return "now"
110
+ const hours = delta / (60 * 60 * 1000)
111
+ if (hours < 1) return `${Math.round(delta / 60_000)} minutes from now`
112
+ if (hours < 48) return `${hours.toFixed(1)} hours from now`
113
+ return `${(hours / 24).toFixed(1)} days from now`
114
+ }
115
+
116
+ export interface ProviderUsageReport {
117
+ readonly providerID: string
118
+ readonly label: string
119
+ readonly plan: string
120
+ readonly source: "provider" | "estimate" | "unknown"
121
+ readonly quotaState?: string
122
+ readonly fiveHour: WindowTotals
123
+ readonly weekly: WindowTotals
124
+ readonly monthly: WindowTotals
125
+ readonly cacheSharePct: number
126
+ readonly byModel: Array<{
127
+ providerID: string
128
+ modelID: string
129
+ requests: number
130
+ input: number
131
+ output: number
132
+ cacheRead: number
133
+ cacheWrite: number
134
+ usd: number
135
+ }>
136
+ readonly forecasts: ReadonlyArray<{ window: string; forecast: Forecast }>
137
+ readonly notes: ReadonlyArray<string>
138
+ }
139
+
140
+ /**
141
+ * Build a per-provider report from durable aggregates.
142
+ *
143
+ * A plan with no five-hour window reports `fiveHour` with zero requests rather
144
+ * than pretending a limit exists, so a dashboard can tell "unused" apart from
145
+ * "not applicable".
146
+ */
147
+ export const buildProviderUsageReport = (input: {
148
+ providerID: string
149
+ label: string
150
+ plan: string
151
+ source: "provider" | "estimate" | "unknown"
152
+ quotaState?: string
153
+ now: number
154
+ source_: UsageSource
155
+ hasFiveHourWindow: boolean
156
+ hasWeeklyWindow: boolean
157
+ remainingMonthlyUsd?: number
158
+ notes: ReadonlyArray<string>
159
+ }): ProviderUsageReport => {
160
+ const { now, source_ } = input
161
+ const monthStart = now - 30 * DAY_MS
162
+ const fiveHour = input.hasFiveHourWindow
163
+ ? source_.aggregate({ providerID: input.providerID, since: now - FIVE_HOURS_MS, until: now })
164
+ : emptyTotals(now - FIVE_HOURS_MS, now)
165
+ const weekly = input.hasWeeklyWindow
166
+ ? source_.aggregate({ providerID: input.providerID, since: now - WEEK_MS, until: now })
167
+ : emptyTotals(now - WEEK_MS, now)
168
+ const monthly = source_.aggregate({ providerID: input.providerID, since: monthStart, until: now })
169
+ const byModel = source_.usageByModel({ since: monthStart, until: now })
170
+ .filter((entry) => entry.providerID === input.providerID)
171
+
172
+ const totalInput = monthly.input + monthly.cacheRead + monthly.cacheWrite
173
+ const cacheSharePct = totalInput > 0 ? (monthly.cacheRead / totalInput) * 100 : 0
174
+
175
+ const activityStart = earliestActivity(source_.usageEvents, input.providerID)
176
+ const forecasts = [
177
+ ...(input.hasFiveHourWindow
178
+ ? [{
179
+ window: "5-hour",
180
+ forecast: forecast({
181
+ windowMs: FIVE_HOURS_MS,
182
+ now,
183
+ spendInWindowUsd: fiveHour.usd,
184
+ ...(activityStart !== undefined ? { activityStart } : {}),
185
+ label: `${input.label} five-hour`,
186
+ }),
187
+ }]
188
+ : []),
189
+ ...(input.hasWeeklyWindow
190
+ ? [{
191
+ window: "7-day",
192
+ forecast: forecast({
193
+ windowMs: WEEK_MS,
194
+ now,
195
+ spendInWindowUsd: weekly.usd,
196
+ ...(activityStart !== undefined ? { activityStart } : {}),
197
+ label: `${input.label} seven-day`,
198
+ }),
199
+ }]
200
+ : []),
201
+ {
202
+ window: "monthly",
203
+ forecast: forecast({
204
+ windowMs: 30 * DAY_MS,
205
+ now,
206
+ spendInWindowUsd: monthly.usd,
207
+ ...(activityStart !== undefined ? { activityStart } : {}),
208
+ ...(input.remainingMonthlyUsd !== undefined
209
+ ? { remainingUsd: input.remainingMonthlyUsd }
210
+ : {}),
211
+ label: `${input.label} monthly`,
212
+ }),
213
+ },
214
+ ]
215
+
216
+ return {
217
+ providerID: input.providerID,
218
+ label: input.label,
219
+ plan: input.plan,
220
+ source: input.source,
221
+ ...(input.quotaState ? { quotaState: input.quotaState } : {}),
222
+ fiveHour,
223
+ weekly,
224
+ monthly,
225
+ cacheSharePct,
226
+ byModel,
227
+ forecasts,
228
+ notes: [
229
+ ...input.notes,
230
+ input.hasFiveHourWindow
231
+ ? "This plan meters a five-hour window."
232
+ : "This plan has no five-hour window; that row is not a limit being ignored.",
233
+ ],
234
+ }
235
+ }
236
+
237
+ const emptyTotals = (since: number, until: number): WindowTotals => ({
238
+ since,
239
+ until,
240
+ requests: 0,
241
+ input: 0,
242
+ output: 0,
243
+ cacheRead: 0,
244
+ cacheWrite: 0,
245
+ usd: 0,
246
+ failures: 0,
247
+ latencyMsTotal: 0,
248
+ latencySamples: 0,
249
+ })
250
+
251
+ const earliestActivity = (
252
+ events: ReadonlyArray<UsageEvent>,
253
+ providerID: string,
254
+ ): number | undefined => {
255
+ let earliest: number | undefined
256
+ for (const event of events) {
257
+ if (event.providerID !== providerID) continue
258
+ if (earliest === undefined || event.at < earliest) earliest = event.at
259
+ }
260
+ return earliest
261
+ }
@@ -0,0 +1,273 @@
1
+ import type { ProviderQuotaSnapshot, ProviderSettings, QuotaWindow, UsageEvent } from "../types.js"
2
+ import { probeCommandCodeQuota, type FetchLike } from "./commandcode.js"
3
+ import { probeOpenCodeGoQuota } from "./opencode-go.js"
4
+ import { probeOpenDesignQuota, OPENDESIGN_MONTH_MS } from "./opendesign.js"
5
+
6
+ export interface QuotaCredentials {
7
+ readonly goAccessToken?: string
8
+ readonly commandCodeIntegrationKey?: string
9
+ readonly commandCodeProviderKey?: string
10
+ /** OpenDesign inference key. It cannot read quota. */
11
+ readonly openDesignApiKey?: string
12
+ /** OpenDesign account-control key. Only used when explicitly configured. */
13
+ readonly openDesignControlKey?: string
14
+ }
15
+
16
+ export interface QuotaServiceOptions {
17
+ readonly getSettings: () => Readonly<Record<string, ProviderSettings>>
18
+ readonly getCredentials: () => Promise<QuotaCredentials>
19
+ readonly probeIntervalMinutes: number
20
+ readonly fetchImpl?: FetchLike
21
+ readonly now?: () => number
22
+ readonly onSnapshot?: (snapshot: ProviderQuotaSnapshot) => void
23
+ readonly initial?: ReadonlyArray<ProviderQuotaSnapshot>
24
+ /**
25
+ * Local usage events, used by providers whose quota must be modelled.
26
+ *
27
+ * The argument is the window the probe needs, not a hint. A provider that
28
+ * models a 30-day allowance cannot be handed fewer than 30 days of history:
29
+ * history that has aged out of the raw event array has been folded into
30
+ * hourly buckets, and a caller that ignores the window returns the raw array
31
+ * alone and under-reports spend exactly when the account is busiest.
32
+ */
33
+ readonly getUsageEvents?: (window?: { since: number; until?: number }) => ReadonlyArray<UsageEvent>
34
+ }
35
+
36
+ /** Providers the router can probe. Order does not imply preference. */
37
+ export const QUOTA_PROVIDER_IDS = ["opencode-go", "commandcode", "opendesign"] as const
38
+
39
+ export type QuotaProviderID = (typeof QUOTA_PROVIDER_IDS)[number]
40
+
41
+ interface ProviderState {
42
+ snapshot?: ProviderQuotaSnapshot
43
+ lastAttempt: number
44
+ nextAllowed: number
45
+ failures: number
46
+ inFlight?: Promise<ProviderQuotaSnapshot>
47
+ }
48
+
49
+ const unknownWindow: QuotaWindow = { usedPct: 0, remainingPct: 100, source: "unknown" }
50
+
51
+ const unknownSnapshot = (
52
+ providerID: string,
53
+ label: string,
54
+ plan: string,
55
+ now: number,
56
+ error: string,
57
+ ): ProviderQuotaSnapshot => ({
58
+ providerID,
59
+ label,
60
+ plan,
61
+ source: "unknown",
62
+ fetchedAt: now,
63
+ stale: false,
64
+ rolling: unknownWindow,
65
+ weekly: unknownWindow,
66
+ monthly: unknownWindow,
67
+ models: {},
68
+ notes: [],
69
+ error,
70
+ quotaState: "unknown",
71
+ })
72
+
73
+ const withStale = (snapshot: ProviderQuotaSnapshot, now: number, intervalMs: number): ProviderQuotaSnapshot => ({
74
+ ...snapshot,
75
+ stale: now - snapshot.fetchedAt > Math.max(intervalMs * 2, 60_000),
76
+ ...(snapshot.quotaState && snapshot.quotaState !== "unknown" && snapshot.quotaState !== "unavailable"
77
+ ? { quotaState: now - snapshot.fetchedAt > Math.max(intervalMs * 2, 60_000) ? ("stale" as const) : snapshot.quotaState }
78
+ : {}),
79
+ })
80
+
81
+ const backoffFor = (failures: number) => {
82
+ const steps = [30_000, 2 * 60_000, 5 * 60_000, 15 * 60_000]
83
+ return steps[Math.min(Math.max(failures, 1) - 1, steps.length - 1)] ?? 15 * 60_000
84
+ }
85
+
86
+ /**
87
+ * Caches provider-reported quota, probes on a conservative interval, and backs
88
+ * off independently per provider. Snapshots are read-only and contain no
89
+ * credentials.
90
+ */
91
+ export class QuotaService {
92
+ private readonly options: QuotaServiceOptions
93
+ private readonly state: Record<string, ProviderState> = {
94
+ "opencode-go": { lastAttempt: 0, nextAllowed: 0, failures: 0 },
95
+ commandcode: { lastAttempt: 0, nextAllowed: 0, failures: 0 },
96
+ opendesign: { lastAttempt: 0, nextAllowed: 0, failures: 0 },
97
+ }
98
+
99
+ constructor(options: QuotaServiceOptions) {
100
+ this.options = options
101
+ const now = options.now?.() ?? Date.now()
102
+ for (const snapshot of options.initial ?? []) {
103
+ const state = this.state[snapshot.providerID]
104
+ if (!state) continue
105
+ state.snapshot = { ...snapshot, stale: true }
106
+ state.lastAttempt = snapshot.fetchedAt
107
+ }
108
+ void now
109
+ }
110
+
111
+ private intervalMs() {
112
+ return Math.max(1, this.options.probeIntervalMinutes) * 60_000
113
+ }
114
+
115
+ private now() {
116
+ return this.options.now?.() ?? Date.now()
117
+ }
118
+
119
+ getCached(): ProviderQuotaSnapshot[] {
120
+ const now = this.now()
121
+ const interval = this.intervalMs()
122
+ return Object.values(this.state)
123
+ .map((state) => state.snapshot)
124
+ .filter((snapshot): snapshot is ProviderQuotaSnapshot => Boolean(snapshot))
125
+ .map((snapshot) => withStale(snapshot, now, interval))
126
+ }
127
+
128
+ /**
129
+ * Return the freshest available snapshots without making a prompt wait on a
130
+ * slow provider. Stale data is refreshed in the background.
131
+ */
132
+ read(): ProviderQuotaSnapshot[] {
133
+ const now = this.now()
134
+ const interval = this.intervalMs()
135
+ const settings = this.options.getSettings()
136
+ for (const providerID of QUOTA_PROVIDER_IDS) {
137
+ const providerSettings = settings[providerID]
138
+ if (!providerSettings?.enabled || !providerSettings.probeUsage) continue
139
+ const state = this.state[providerID]
140
+ if (!state) continue
141
+ if ((!state.snapshot || now - state.lastAttempt > interval) && now >= state.nextAllowed && !state.inFlight) {
142
+ void this.refresh(providerID).catch(() => undefined)
143
+ }
144
+ }
145
+ return this.getCached()
146
+ }
147
+
148
+ /**
149
+ * Drop every cached snapshot.
150
+ *
151
+ * Called when settings change underneath the service — a pasted baseline is the
152
+ * case that matters. The cache holds snapshots computed from the *previous*
153
+ * settings, and a stale one is served with `stale: true` while a fresh probe is
154
+ * still pending. Without this the panel can show a percentage that predates the
155
+ * reading it was given, which reads as "my paste did nothing" even though it was
156
+ * applied.
157
+ */
158
+ invalidate(): void {
159
+ for (const state of Object.values(this.state)) {
160
+ state.snapshot = undefined
161
+ state.lastAttempt = 0
162
+ state.nextAllowed = 0
163
+ }
164
+ }
165
+
166
+ async refresh(providerID: QuotaProviderID | string, force = false): Promise<ProviderQuotaSnapshot> {
167
+ const settings = this.options.getSettings()[providerID]
168
+ const state = this.state[providerID]
169
+ if (!settings || !state) {
170
+ return unknownSnapshot(providerID, providerID, settings?.plan ?? "unknown", this.now(), "Unknown provider")
171
+ }
172
+ if (!settings.enabled || !settings.probeUsage) {
173
+ return (
174
+ state.snapshot ??
175
+ unknownSnapshot(providerID, providerID, settings.plan, this.now(), "Quota probing is disabled for this provider")
176
+ )
177
+ }
178
+ if (state.inFlight) return state.inFlight
179
+ const now = this.now()
180
+ if (!force && now < state.nextAllowed) {
181
+ return state.snapshot ?? unknownSnapshot(providerID, providerID, settings.plan, now, "Quota probe is cooling down")
182
+ }
183
+
184
+ state.lastAttempt = now
185
+ const task = this.probe(providerID, settings)
186
+ .then((snapshot) => {
187
+ state.snapshot = snapshot
188
+ state.failures = snapshot.error ? state.failures + 1 : 0
189
+ const retryAfter = snapshot.retryAfterMs === undefined
190
+ ? undefined
191
+ : Math.min(Math.max(0, snapshot.retryAfterMs), 15 * 60_000)
192
+ state.nextAllowed = snapshot.error
193
+ ? this.now() + (retryAfter ?? backoffFor(state.failures))
194
+ : 0
195
+ this.options.onSnapshot?.(snapshot)
196
+ return snapshot
197
+ })
198
+ .catch((error) => {
199
+ state.failures += 1
200
+ state.nextAllowed = this.now() + backoffFor(state.failures)
201
+ const snapshot = unknownSnapshot(
202
+ providerID,
203
+ providerID,
204
+ settings.plan,
205
+ this.now(),
206
+ error instanceof Error ? error.message : String(error),
207
+ )
208
+ state.snapshot = snapshot
209
+ this.options.onSnapshot?.(snapshot)
210
+ return snapshot
211
+ })
212
+ .finally(() => {
213
+ state.inFlight = undefined
214
+ })
215
+
216
+ state.inFlight = task
217
+ return task
218
+ }
219
+
220
+ private async probe(providerID: string, settings: ProviderSettings) {
221
+ const credentials = await this.options.getCredentials()
222
+ if (providerID === "opencode-go") {
223
+ const result = await probeOpenCodeGoQuota({
224
+ accessToken: credentials.goAccessToken ?? "",
225
+ settings,
226
+ fetchImpl: this.options.fetchImpl,
227
+ now: this.options.now,
228
+ })
229
+ return result.snapshot
230
+ }
231
+ if (providerID === "opendesign") {
232
+ // Ask for the widest window the model uses. Without it the estimator
233
+ // receives only the raw event array, and every hour of history that has
234
+ // been folded into a bucket stops counting.
235
+ const now = this.options.now?.() ?? Date.now()
236
+ const result = await probeOpenDesignQuota({
237
+ settings,
238
+ apiKey: credentials.openDesignApiKey,
239
+ controlKey: credentials.openDesignControlKey,
240
+ usage: this.options.getUsageEvents?.({ since: now - OPENDESIGN_MONTH_MS, until: now }) ?? [],
241
+ fetchImpl: this.options.fetchImpl,
242
+ now: this.options.now,
243
+ ...(settings.controlBaseURL ? { controlBaseURL: settings.controlBaseURL } : {}),
244
+ ...(settings.creditsPerUsd !== undefined ? { creditsPerUsd: settings.creditsPerUsd } : {}),
245
+ ...(settings.uncertaintyMarginPct !== undefined ? { uncertaintyMarginPct: settings.uncertaintyMarginPct } : {}),
246
+ ...(settings.multiplierOverrides ? { multiplierOverrides: settings.multiplierOverrides } : {}),
247
+ })
248
+ return { ...result.snapshot, quotaState: result.state }
249
+ }
250
+ const result = await probeCommandCodeQuota({
251
+ settings,
252
+ openCodeIntegrationKey: credentials.commandCodeIntegrationKey,
253
+ providerSettingsKey: credentials.commandCodeProviderKey,
254
+ fetchImpl: this.options.fetchImpl,
255
+ now: this.options.now,
256
+ })
257
+ return result.snapshot
258
+ }
259
+
260
+ async initialize(): Promise<ProviderQuotaSnapshot[]> {
261
+ const settings = this.options.getSettings()
262
+ const tasks: Array<Promise<ProviderQuotaSnapshot>> = []
263
+ for (const providerID of QUOTA_PROVIDER_IDS) {
264
+ const providerSettings = settings[providerID]
265
+ if (providerSettings?.enabled && providerSettings.probeUsage) tasks.push(this.refresh(providerID, true))
266
+ }
267
+ if (tasks.length === 0) return this.getCached()
268
+ await Promise.allSettled(tasks)
269
+ return this.getCached()
270
+ }
271
+ }
272
+
273
+ export const __quotaInternals = { backoffFor, withStale }