@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.
- package/CHANGELOG.md +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +114 -0
- package/install +75 -0
- package/package.json +94 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1047 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +654 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1304 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- 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 }
|