dsh-opencode-routing 0.0.0-stage → 2.1.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.
package/usage.js ADDED
@@ -0,0 +1,749 @@
1
+ /**
2
+ * OpenCode Go usage dashboard.
3
+ *
4
+ * OpenCode exposes only one rounded percentage for the whole Go plan, while the
5
+ * plan actually meters every model against its own monthly dollar limit
6
+ * (5-hour = 20% of it, weekly = 50%, monthly = 100%). This plugin rebuilds the
7
+ * per-model picture from the only source that carries it: the token usage the
8
+ * harness records on every assistant message.
9
+ *
10
+ * Data path: scan this month's Session logs once at startup, then fold every
11
+ * live `assistant/message` through the same reducer. Each record keeps the
12
+ * model, the timestamp, and the token counts, so rolling 5-hour, weekly, and
13
+ * calendar-month windows are plain sums.
14
+ *
15
+ * Surfaces: `GET <path>` renders an HTML table that refreshes itself, and
16
+ * `GET <path>.json` returns the same view as JSON. Both are loopback routes on
17
+ * the harness web listener; the harness adds no authentication to plugin routes,
18
+ * so anything that can reach the port can read them.
19
+ */
20
+
21
+ import fs from 'node:fs'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+ import zlib from 'node:zlib'
25
+
26
+ export const name = 'opencode-usage'
27
+
28
+ /** The dashboard needs the web listener; without it the plugin stays dormant. */
29
+ export const inject = ['webServer']
30
+
31
+ const MIB = 1024 * 1024
32
+ const HOUR = 60 * 60 * 1000
33
+ const DAY = 24 * HOUR
34
+ const FREE_MODELS = new Set(['beam-free', 'iceman-contributor-free'])
35
+
36
+ /** Dollars per million tokens, at the plan's off-peak (standard) price. */
37
+ const PRICES = {
38
+ 'deepseek-v4.1-flash': { input: 0.15, output: 0.6, cacheRead: 0.003 },
39
+ 'deepseek-v4-pro': { input: 0.66, output: 1.98, cacheRead: 0.022 },
40
+ 'glm-5.3-flash': { input: 0.15, output: 0.5, cacheRead: 0.03 },
41
+ 'glm-5.3': { input: 1.4, output: 4.4, cacheRead: 0.26 },
42
+ 'glm-5.2': { input: 1.4, output: 4.4, cacheRead: 0.26 },
43
+ 'kimi-k3.1-pro': { input: 2, output: 10, cacheRead: 0.2 },
44
+ 'kimi-k3': { input: 3, output: 15, cacheRead: 0.3 },
45
+ 'kimi-k2.7-code': { input: 0.95, output: 4, cacheRead: 0.19 },
46
+ 'longcat-2.0': { input: 0.3, output: 1.2, cacheRead: 0.006 },
47
+ 'step-5-preview': { input: 1, output: 2.7, cacheRead: 0.1 },
48
+ 'mimo-v2.6-flash': { input: 0.14, output: 0.28, cacheRead: 0.0028 },
49
+ 'mimo-v2.6-pro': { input: 0.435, output: 0.87, cacheRead: 0.003625 },
50
+ 'minimax-m3.1-flash': { input: 0.15, output: 0.6, cacheRead: 0.015 },
51
+ 'minimax-m3': { input: 0.3, output: 1.2, cacheRead: 0.06 },
52
+ 'qwen3.8-max': { input: 2, output: 6, cacheRead: 0.25 },
53
+ 'qwen3.8-flash': { input: 0.15, output: 0.47, cacheRead: 0.016 },
54
+ 'qwen3.7-plus': { input: 0.4, output: 1.6, cacheRead: 0.04 },
55
+ 'hy4-preview': { input: 0.834, output: 2.501, cacheRead: 0.042 },
56
+ hy3: { input: 0.14, output: 0.58, cacheRead: 0.035 },
57
+ 'grok-4.7': { input: 2, output: 6, cacheRead: 0.5 },
58
+ 'gpt-6-luna': { input: 0.1, output: 0.5, cacheRead: 0.01 },
59
+ 'gpt-5.6-luna': { input: 0.2, output: 1.2, cacheRead: 0.02 },
60
+ 'claude-haiku-5-5': { input: 0.1, output: 0.5, cacheRead: 0.01 },
61
+ 'gemini-4-argon': { input: 2, output: 10, cacheRead: 0.2 },
62
+ 'muse-spark-1.2-contributor': { input: 0.1, output: 0.2, cacheRead: 0.002 },
63
+ 'muse-spark-1.3-contributor': { input: 0.1, output: 0.2, cacheRead: 0.002 },
64
+ 'beam-free': { input: 0, output: 0, cacheRead: 0 },
65
+ 'iceman-contributor-free': { input: 0, output: 0, cacheRead: 0 },
66
+ }
67
+
68
+ /** DeepSeek peak hours: both the price AND the allowance burn double. Shared
69
+ * verbatim with bili's `billing.priceSource` document (see priceDocument), so
70
+ * the proxy and this dashboard never disagree about which hour is expensive. */
71
+ const PEAK = { factor: 2, utcWeekdayHours: [[1, 4], [6, 10]], modelPrefixes: ['deepseek'] }
72
+
73
+ /**
74
+ * China's statutory holidays, 2026–2031, as month-day pairs (Beijing time).
75
+ *
76
+ * DeepSeek's own API bills these whole days at the off-peak rate even when they
77
+ * fall on a weekday, so `peakMultiplier` honours them for official DeepSeek
78
+ * billing. OpenCode Go does NOT: its maintainers answered the National Day
79
+ * complaint with "Go pricing follows our own schedule, not DeepSeek's holiday
80
+ * calendar" (anomalyco/opencode#53071), and Go meters on a UTC weekday grid. The
81
+ * calendar is therefore opt-in via `holidayAware`, and the default keeps Go's
82
+ * published schedule — the one that actually matches the bill.
83
+ *
84
+ * `2025` reform: 元旦 1 day, 春节 4 (除夕 + 初一..初三), 清明 1, 劳动节 2 (5/1–5/2),
85
+ * 端午 1, 中秋 1, 国庆 3 (10/1–10/3). Weekend makeup days need no entry: Go and
86
+ * DeepSeek both charge weekends at the off-peak rate already. Lunar dates come
87
+ * from `solarlunar`; Qingming from the solar-term table (Apr 4–5).
88
+ */
89
+ const HOLIDAYS = {
90
+ 2026: ['01-01', '02-16', '02-17', '02-18', '02-19', '04-05', '05-01', '05-02', '06-19', '09-25', '10-01', '10-02', '10-03'],
91
+ 2027: ['01-01', '02-05', '02-06', '02-07', '02-08', '04-05', '05-01', '05-02', '06-09', '09-15', '10-01', '10-02', '10-03'],
92
+ 2028: ['01-01', '01-25', '01-26', '01-27', '01-28', '04-04', '05-01', '05-02', '05-28', '10-01', '10-02', '10-03'],
93
+ 2029: ['01-01', '02-12', '02-13', '02-14', '02-15', '04-04', '05-01', '05-02', '06-16', '09-22', '10-01', '10-02', '10-03'],
94
+ 2030: ['01-01', '02-02', '02-03', '02-04', '02-05', '04-05', '05-01', '05-02', '06-05', '09-12', '10-01', '10-02', '10-03'],
95
+ 2031: ['01-01', '01-22', '01-23', '01-24', '01-25', '04-05', '05-01', '05-02', '06-24', '10-01', '10-02', '10-03'],
96
+ }
97
+
98
+ /** DeepSeek's own API list price, ¥ per million tokens at the OFF-PEAK rate
99
+ * (cache hit / input / output). Public pricing page, `deepseek-flash` and
100
+ * `deepseek-v4-pro`; the peak rate is exactly 2x. Compared against the Go
101
+ * prices above so the page can point at whichever route is cheaper right now. */
102
+ const OFFICIAL = {
103
+ defaultCnyPerUsd: 7.1,
104
+ prices: {
105
+ 'deepseek-v4.1-flash': { input: 1, output: 4, cacheRead: 0.02 },
106
+ 'deepseek-v4-pro': { input: 4.5, output: 13.5, cacheRead: 0.15 },
107
+ },
108
+ }
109
+
110
+ /** Monthly dollar allowance per model, per plan (USD). */
111
+ const LIMITS = {
112
+ go: {
113
+ 'deepseek-v4.1-flash': 100,
114
+ 'deepseek-v4-pro': 15,
115
+ 'glm-5.3-flash': 60,
116
+ 'glm-5.3': 15,
117
+ 'glm-5.2': 60,
118
+ 'kimi-k3.1-pro': 15,
119
+ 'kimi-k3': 15,
120
+ 'kimi-k2.7-code': 60,
121
+ 'longcat-2.0': 60,
122
+ 'step-5-preview': 15,
123
+ 'mimo-v2.6-flash': 60,
124
+ 'mimo-v2.6-pro': 15,
125
+ 'minimax-m3.1-flash': 20,
126
+ 'minimax-m3': 60,
127
+ 'qwen3.8-max': 15,
128
+ 'qwen3.8-flash': 30,
129
+ 'qwen3.7-plus': 60,
130
+ 'hy4-preview': 30,
131
+ hy3: 60,
132
+ 'grok-4.7': 15,
133
+ 'gpt-6-luna': 15,
134
+ 'gpt-5.6-luna': 15,
135
+ 'claude-haiku-5-5': 15,
136
+ 'gemini-4-argon': 30,
137
+ 'muse-spark-1.2-contributor': 60,
138
+ 'muse-spark-1.3-contributor': 60,
139
+ 'beam-free': Infinity,
140
+ 'iceman-contributor-free': Infinity,
141
+ },
142
+ 'go-plus': {
143
+ 'deepseek-v4.1-flash': 200,
144
+ 'deepseek-v4-pro': 60,
145
+ 'glm-5.3-flash': 180,
146
+ 'glm-5.3': 120,
147
+ 'glm-5.2': 180,
148
+ 'kimi-k3.1-pro': 60,
149
+ 'kimi-k3': 60,
150
+ 'kimi-k2.7-code': 180,
151
+ 'longcat-2.0': 240,
152
+ 'step-5-preview': 60,
153
+ 'mimo-v2.6-flash': 120,
154
+ 'mimo-v2.6-pro': 60,
155
+ 'minimax-m3.1-flash': 80,
156
+ 'minimax-m3': 180,
157
+ 'qwen3.8-max': 60,
158
+ 'qwen3.8-flash': 90,
159
+ 'qwen3.7-plus': 180,
160
+ 'hy4-preview': 120,
161
+ hy3: 240,
162
+ 'grok-4.7': 60,
163
+ 'gpt-6-luna': 60,
164
+ 'gpt-5.6-luna': 60,
165
+ 'claude-haiku-5-5': 60,
166
+ 'gemini-4-argon': 120,
167
+ 'muse-spark-1.2-contributor': 120,
168
+ 'muse-spark-1.3-contributor': 120,
169
+ 'beam-free': Infinity,
170
+ 'iceman-contributor-free': Infinity,
171
+ },
172
+ 'go-teams': {
173
+ 'deepseek-v4.1-flash': 105,
174
+ 'deepseek-v4-pro': 20,
175
+ 'glm-5.3-flash': 65,
176
+ 'glm-5.3': 20,
177
+ 'glm-5.2': 65,
178
+ 'kimi-k3.1-pro': 20,
179
+ 'kimi-k3': 20,
180
+ 'kimi-k2.7-code': 65,
181
+ 'longcat-2.0': 65,
182
+ 'step-5-preview': 20,
183
+ 'mimo-v2.6-flash': 65,
184
+ 'mimo-v2.6-pro': 20,
185
+ 'minimax-m3.1-flash': 25,
186
+ 'minimax-m3': 65,
187
+ 'qwen3.8-max': 20,
188
+ 'qwen3.8-flash': 35,
189
+ 'qwen3.7-plus': 65,
190
+ 'hy4-preview': 35,
191
+ hy3: 65,
192
+ 'grok-4.7': 20,
193
+ 'gpt-6-luna': 20,
194
+ 'gpt-5.6-luna': 20,
195
+ 'claude-haiku-5-5': 20,
196
+ 'gemini-4-argon': 35,
197
+ 'muse-spark-1.2-contributor': 65,
198
+ 'muse-spark-1.3-contributor': 65,
199
+ 'beam-free': Infinity,
200
+ 'iceman-contributor-free': Infinity,
201
+ },
202
+ }
203
+
204
+ function dshHome() {
205
+ const configured = process.env.DSH_HOME
206
+ return configured !== undefined && configured.length > 0 ? configured : path.join(os.homedir(), '.dsh')
207
+ }
208
+
209
+ function normalize(config = {}) {
210
+ const plan = typeof config.plan === 'string' && LIMITS[config.plan] !== undefined ? config.plan : 'go'
211
+ return {
212
+ plan,
213
+ path: typeof config.path === 'string' && config.path.startsWith('/') ? config.path : '/opencode-usage',
214
+ refreshSeconds: Number.isFinite(config.refreshSeconds) && config.refreshSeconds >= 5 ? config.refreshSeconds : 60,
215
+ usageUrl: typeof config.usageUrl === 'string' ? config.usageUrl : 'https://opencode.ai/inference/go/v1/usage',
216
+ modelsUrl: typeof config.modelsUrl === 'string' ? config.modelsUrl : 'https://opencode.ai/inference/go/v1/models',
217
+ apiKeyRef: typeof config.apiKeyRef === 'string' ? config.apiKeyRef : 'OPENCODE_API_KEY',
218
+ sessionsRoot: typeof config.sessionsRoot === 'string' ? config.sessionsRoot : path.join(dshHome(), 'sessions'),
219
+ // The account pool lives in dsh-opencode-quota-router's state file: one
220
+ // owner writes it, this dashboard only reads it, so both plugins always
221
+ // describe the same set of accounts in the same priority order.
222
+ accountsStatePath: typeof config.accountsStatePath === 'string'
223
+ ? config.accountsStatePath
224
+ : path.join(dshHome(), 'opencode-quota-router.json'),
225
+ // Published in the price document's `currency` block so bili's panel can
226
+ // show ¥ next to $; bili's own `billing.cnyPerUsd` still wins if set.
227
+ cnyPerUsd: Number.isFinite(config.cnyPerUsd) && config.cnyPerUsd > 0 ? config.cnyPerUsd : undefined,
228
+ // Off-peak on China's statutory holidays is DeepSeek's own API rule. OpenCode
229
+ // Go charges peak on those days (anomalyco/opencode#53071: "Go pricing follows
230
+ // our own schedule, not DeepSeek's holiday calendar"), so this stays off by
231
+ // default — the dashboard is about the Go bill.
232
+ holidayAware: config.holidayAware === true,
233
+ limits: LIMITS[plan],
234
+ }
235
+ }
236
+
237
+ /** The visible part of a stored secret: enough to tell two keys apart, never enough to use one. */
238
+ function maskKey(key) {
239
+ if (typeof key !== 'string' || key.length === 0) return null
240
+ if (key.length <= 12) return `${key.slice(0, 3)}…`
241
+ return `${key.slice(0, 7)}…${key.slice(-4)}`
242
+ }
243
+
244
+ /**
245
+ * The account pool managed by dsh-opencode-quota-router, in priority order.
246
+ * A missing or unreadable state file degrades to the single reference this
247
+ * plugin was configured with, so the dashboard keeps working on its own.
248
+ */
249
+ function readPool(cfg) {
250
+ try {
251
+ const parsed = JSON.parse(fs.readFileSync(cfg.accountsStatePath, 'utf8'))
252
+ const entries = Array.isArray(parsed?.accounts) ? parsed.accounts.filter((entry) => typeof entry?.id === 'string') : []
253
+ if (entries.length > 0) {
254
+ return {
255
+ active: typeof parsed.active === 'string' ? parsed.active : entries[0].id,
256
+ accounts: entries.map((entry) => ({
257
+ id: entry.id,
258
+ label: typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.id,
259
+ keyRef: typeof entry.keyRef === 'string' && entry.keyRef.length > 0 ? entry.keyRef : cfg.apiKeyRef,
260
+ enabled: entry.enabled !== false,
261
+ inlineKey: typeof entry.key === 'string' && entry.key.length > 0 ? entry.key : undefined,
262
+ })),
263
+ }
264
+ }
265
+ } catch {
266
+ /* no pool yet: fall through to the single configured reference */
267
+ }
268
+ return { active: 'default', accounts: [{ id: 'default', label: '默认账户', keyRef: cfg.apiKeyRef, enabled: true }] }
269
+ }
270
+
271
+ /** Is `at` a Chinese statutory holiday (Beijing date)? */
272
+ export function isChinaHoliday(at) {
273
+ const beijing = new Date(new Date(at).getTime() + 8 * HOUR)
274
+ const year = beijing.getUTCFullYear()
275
+ const monthDay = `${String(beijing.getUTCMonth() + 1).padStart(2, '0')}-${String(beijing.getUTCDate()).padStart(2, '0')}`
276
+ return HOLIDAYS[year]?.includes(monthDay) === true
277
+ }
278
+
279
+ /** DeepSeek peak hours double both price and allowance burn. */
280
+ export function peakMultiplier(model, at, holidayAware = false) {
281
+ if (!PEAK.modelPrefixes.some((prefix) => model.startsWith(prefix))) return 1
282
+ const date = new Date(at)
283
+ const day = date.getUTCDay()
284
+ if (day === 0 || day === 6) return 1
285
+ if (holidayAware && isChinaHoliday(at)) return 1
286
+ const hour = date.getUTCHours()
287
+ return PEAK.utcWeekdayHours.some(([from, to]) => hour >= from && hour < to) ? PEAK.factor : 1
288
+ }
289
+
290
+ /** What each plan costs per month, in USD. The plan's allowance is metered at
291
+ * DeepSeek's list price, so the subscription buys its allowance at a fixed
292
+ * discount — Go pays $10 for $100 of list-priced usage, i.e. 10x. */
293
+ const PLAN_PRICES = { go: 10, 'go-plus': 40, 'go-teams': 20 }
294
+
295
+ /** Which route is cheaper at this instant, expressed in the money you actually
296
+ * hand over: Go charges the list price against a 10x-discounted allowance, while
297
+ * DeepSeek's API bills the list price directly. */
298
+ export function peakSnapshot(cnyPerUsd, at = Date.now(), plan = 'go') {
299
+ const fx = Number.isFinite(cnyPerUsd) && cnyPerUsd > 0 ? cnyPerUsd : OFFICIAL.defaultCnyPerUsd
300
+ const priceUsd = PLAN_PRICES[plan] ?? PLAN_PRICES.go
301
+ const allowanceUsd = LIMITS[plan]?.['deepseek-v4.1-flash'] ?? LIMITS.go['deepseek-v4.1-flash']
302
+ const leverage = allowanceUsd / priceUsd
303
+ const routes = []
304
+ for (const [model, official] of Object.entries(OFFICIAL.prices)) {
305
+ const goFactor = peakMultiplier(model, at, false)
306
+ const officialFactor = peakMultiplier(model, at, true)
307
+ // One million output tokens is the yardstick: output dominates a coding bill.
308
+ const list = official.output
309
+ const burn = list * goFactor // what the request subtracts from the allowance
310
+ const goPay = burn / leverage // what it actually costs you, given the discount
311
+ const officialPay = list * officialFactor
312
+ routes.push({
313
+ model,
314
+ list,
315
+ go: { factor: goFactor, burn, pay: goPay },
316
+ official: { factor: officialFactor, pay: officialPay },
317
+ cheaper: goPay <= officialPay ? 'go' : 'official',
318
+ ratio: goPay / officialPay,
319
+ })
320
+ }
321
+ return {
322
+ at,
323
+ cnyPerUsd: fx,
324
+ holiday: isChinaHoliday(at),
325
+ plan: { id: plan, priceUsd, priceCny: priceUsd * fx, allowanceUsd, allowanceCny: allowanceUsd * fx, leverage },
326
+ go: { peak: peakMultiplier('deepseek-v4.1-flash', at, false) === PEAK.factor },
327
+ official: { peak: peakMultiplier('deepseek-v4.1-flash', at, true) === PEAK.factor },
328
+ factor: PEAK.factor,
329
+ models: routes,
330
+ }
331
+ }
332
+
333
+ function costOf(record, price, holidayAware = false) {
334
+ if (price === undefined) return undefined
335
+ const factor = peakMultiplier(record.model, record.t, holidayAware)
336
+ return ((record.input / MIB) * price.input + (record.output / MIB) * price.output + (record.cacheRead / MIB) * price.cacheRead) * factor
337
+ }
338
+
339
+ /** Decode every zstd frame in a concatenated `.jsonl.zstd` file. */
340
+ function decodeFrames(buffer) {
341
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
342
+ const offsets = []
343
+ let at = 0
344
+ while (true) {
345
+ const found = buffer.indexOf(magic, at)
346
+ if (found < 0) break
347
+ offsets.push(found)
348
+ at = found + 4
349
+ }
350
+ let text = ''
351
+ for (let index = 0; index < offsets.length; index += 1) {
352
+ const end = index + 1 < offsets.length ? offsets[index + 1] : buffer.length
353
+ try {
354
+ text += zlib.zstdDecompressSync(buffer.subarray(offsets[index], end)).toString('utf8')
355
+ } catch {
356
+ /* a partial trailing frame is expected while a Session is writing */
357
+ }
358
+ }
359
+ return text
360
+ }
361
+
362
+ export function apply(ctx, config = {}) {
363
+ const cfg = normalize(config)
364
+ const records = []
365
+ const seen = new Set()
366
+ const startedAt = Date.now()
367
+ let usageSnapshot = null
368
+ let usageError = null
369
+ let accounts = []
370
+ let catalog = []
371
+ let catalogError = null
372
+ let detectedPlan = undefined
373
+ let impliedMonthly = undefined
374
+
375
+ function record(sessionId, seq, message, usage, time) {
376
+ const source = message?.source
377
+ if (source === undefined || !String(source.provider ?? '').startsWith('opencode')) return
378
+ const key = `${sessionId}:${seq}`
379
+ if (seen.has(key)) return
380
+ seen.add(key)
381
+ records.push({
382
+ t: Number.isFinite(time) ? time : Date.now(),
383
+ model: String(source.model ?? 'unknown'),
384
+ input: Number(usage.inputTokens ?? 0),
385
+ output: Number(usage.outputTokens ?? 0),
386
+ cacheRead: Number(usage.cacheReadTokens ?? 0),
387
+ })
388
+ if (records.length > 20000) records.splice(0, records.length - 20000)
389
+ }
390
+
391
+ // Backfill: this month's Session logs, plus the last eight days for the
392
+ // rolling and weekly windows.
393
+ function backfill() {
394
+ const since = Date.now() - 40 * DAY
395
+ let files = []
396
+ try {
397
+ const walk = (dir) => {
398
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
399
+ const full = path.join(dir, entry.name)
400
+ if (entry.isDirectory()) walk(full)
401
+ else if (entry.name.endsWith('.jsonl.zstd')) files.push(full)
402
+ }
403
+ }
404
+ walk(cfg.sessionsRoot)
405
+ } catch {
406
+ return
407
+ }
408
+ for (const file of files) {
409
+ try {
410
+ if (fs.statSync(file).mtimeMs < since) continue
411
+ const text = decodeFrames(fs.readFileSync(file))
412
+ const sessionId = path.basename(path.dirname(file))
413
+ for (const line of text.split('\n')) {
414
+ if (line.length === 0 || !line.includes('assistant/message')) continue
415
+ let event
416
+ try {
417
+ event = JSON.parse(line)
418
+ } catch {
419
+ continue
420
+ }
421
+ if (event.type !== 'assistant/message') continue
422
+ record(sessionId, event.seq, event.data?.message, event.data?.usage, event.time)
423
+ }
424
+ } catch {
425
+ /* unreadable log: ignore */
426
+ }
427
+ }
428
+ }
429
+
430
+ backfill()
431
+
432
+ const disposeEvent = ctx.on('session/event', (session, event) => {
433
+ if (event?.type !== 'assistant/message') return
434
+ record(session.id, event.seq, event.data?.message, event.data?.usage, event.time)
435
+ })
436
+
437
+ async function refreshCatalog(key) {
438
+ try {
439
+ const response = await fetch(cfg.modelsUrl, { headers: { authorization: `Bearer ${key}`, accept: 'application/json' }, signal: AbortSignal.timeout(15000) })
440
+ if (!response.ok) {
441
+ catalogError = `model endpoint answered ${response.status}`
442
+ return
443
+ }
444
+ const body = await response.json()
445
+ catalog = Array.isArray(body?.data)
446
+ ? body.data
447
+ .filter((entry) => typeof entry?.id === 'string')
448
+ .map((entry) => {
449
+ // The endpoint advertises what each model accepts (modalities) and
450
+ // how it can be driven (tools / reasoning), so the table can say
451
+ // "image + video" instead of guessing from the model name.
452
+ const kinds = new Set()
453
+ for (const kind of Array.isArray(entry?.modalities?.input) ? entry.modalities.input : []) {
454
+ const text = String(kind)
455
+ if (text === 'text') kinds.add('text')
456
+ else if (text.startsWith('image')) kinds.add('image')
457
+ else if (text.startsWith('video')) kinds.add('video')
458
+ else if (text.startsWith('audio')) kinds.add('audio')
459
+ else if (text.includes('pdf')) kinds.add('pdf')
460
+ }
461
+ const caps = entry?.capabilities ?? {}
462
+ const price = entry?.cost?.text
463
+ return {
464
+ id: entry.id,
465
+ name: typeof entry.name === 'string' ? entry.name : entry.id,
466
+ inputs: [...kinds],
467
+ tools: caps?.tools?.supported === true,
468
+ reasoning: caps?.reasoning?.supported === true,
469
+ efforts: Array.isArray(caps?.reasoning?.effort) ? caps.reasoning.effort : [],
470
+ context: typeof entry?.limit?.context === 'number' ? entry.limit.context : undefined,
471
+ output: typeof entry?.limit?.output === 'number' ? entry.limit.output : undefined,
472
+ free: price?.input?.price === 0 && price?.output?.price === 0,
473
+ }
474
+ })
475
+ : []
476
+ catalogError = null
477
+ } catch (error) {
478
+ catalogError = String(error?.message ?? error)
479
+ }
480
+ }
481
+
482
+ async function keyFor(account) {
483
+ const credentials = ctx.get('credentials')
484
+ if (credentials !== undefined && typeof credentials.resolve === 'function') {
485
+ try {
486
+ const value = (await credentials.resolve(account.keyRef))?.value
487
+ if (typeof value === 'string' && value.length > 0) return value
488
+ } catch {
489
+ /* a broken store must not hide the other accounts */
490
+ }
491
+ }
492
+ if (account.inlineKey !== undefined) return account.inlineKey
493
+ const ambient = process.env[account.keyRef]
494
+ return typeof ambient === 'string' && ambient.length > 0 ? ambient : undefined
495
+ }
496
+
497
+ async function fetchUsage(key) {
498
+ try {
499
+ const response = await fetch(cfg.usageUrl, {
500
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
501
+ signal: AbortSignal.timeout(10000),
502
+ })
503
+ if (!response.ok) return { usage: null, error: `usage endpoint answered ${response.status}` }
504
+ return { usage: (await response.json())?.usage ?? null, error: null }
505
+ } catch (error) {
506
+ return { usage: null, error: String(error?.message ?? error) }
507
+ }
508
+ }
509
+
510
+ async function refreshSnapshot() {
511
+ const pool = readPool(cfg)
512
+ const viewed = []
513
+ let catalogKey
514
+ for (const account of pool.accounts) {
515
+ const key = await keyFor(account)
516
+ if (key === undefined) {
517
+ viewed.push({
518
+ id: account.id,
519
+ label: account.label,
520
+ keyRef: account.keyRef,
521
+ enabled: account.enabled,
522
+ active: account.id === pool.active,
523
+ configured: false,
524
+ hint: null,
525
+ usage: null,
526
+ usageError: `no credential for ${account.keyRef}`,
527
+ checkedAt: Date.now(),
528
+ })
529
+ continue
530
+ }
531
+ catalogKey ??= key
532
+ const { usage, error } = await fetchUsage(key)
533
+ viewed.push({
534
+ id: account.id,
535
+ label: account.label,
536
+ keyRef: account.keyRef,
537
+ enabled: account.enabled,
538
+ active: account.id === pool.active,
539
+ configured: true,
540
+ hint: maskKey(key),
541
+ usage,
542
+ usageError: error,
543
+ checkedAt: Date.now(),
544
+ })
545
+ }
546
+ if (catalogKey !== undefined) await refreshCatalog(catalogKey)
547
+ accounts = viewed
548
+ const active = viewed.find((entry) => entry.active) ?? viewed[0]
549
+ usageSnapshot = active?.usage ?? null
550
+ usageError = active?.usageError ?? null
551
+ }
552
+ void refreshSnapshot()
553
+
554
+ function windowStart(name) {
555
+ const now = Date.now()
556
+ const reset = Date.parse(usageSnapshot?.[name]?.resetsAt ?? '')
557
+ if (name === 'monthly') {
558
+ const current = new Date(now)
559
+ return Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), 1)
560
+ }
561
+ if (Number.isFinite(reset)) {
562
+ const span = name === 'rolling' ? 5 * HOUR : 7 * DAY
563
+ return Math.min(now, reset - span)
564
+ }
565
+ if (name === 'rolling') return now - 5 * HOUR
566
+ return now - 7 * DAY
567
+ }
568
+
569
+ function view() {
570
+ const starts = { rolling: windowStart('rolling'), weekly: windowStart('weekly'), monthly: windowStart('monthly') }
571
+ const models = new Map()
572
+ for (const record of records) {
573
+ const entry = models.get(record.model) ?? {
574
+ model: record.model,
575
+ requests: 0,
576
+ input: 0,
577
+ output: 0,
578
+ cacheRead: 0,
579
+ rolling: 0,
580
+ weekly: 0,
581
+ monthly: 0,
582
+ cost: 0,
583
+ priced: PRICES[record.model] !== undefined,
584
+ }
585
+ entry.requests += 1
586
+ entry.input += record.input
587
+ entry.output += record.output
588
+ entry.cacheRead += record.cacheRead
589
+ const cost = costOf(record, PRICES[record.model], cfg.holidayAware)
590
+ if (cost !== undefined) entry.cost += cost
591
+ if (record.t >= starts.rolling) entry.rolling += cost ?? 0
592
+ if (record.t >= starts.weekly) entry.weekly += cost ?? 0
593
+ if (record.t >= starts.monthly) entry.monthly += cost ?? 0
594
+ models.set(record.model, entry)
595
+ }
596
+ for (const entry of catalog) {
597
+ if (models.has(entry.id)) continue
598
+ models.set(entry.id, {
599
+ model: entry.id,
600
+ requests: 0,
601
+ input: 0,
602
+ output: 0,
603
+ cacheRead: 0,
604
+ rolling: 0,
605
+ weekly: 0,
606
+ monthly: 0,
607
+ cost: 0,
608
+ priced: PRICES[entry.id] !== undefined,
609
+ })
610
+ }
611
+ const rollingTotal = [...models.values()].reduce((sum, entry) => sum + entry.rolling, 0)
612
+ const top = [...models.values()].sort((left, right) => right.rolling - left.rolling)[0]
613
+ const officialPercent = usageSnapshot?.rolling?.percent
614
+ if (top !== undefined && top.rolling > 0 && Number.isFinite(officialPercent) && officialPercent >= 3 && top.rolling >= rollingTotal * 0.8) {
615
+ impliedMonthly = top.rolling / (officialPercent / 100) / 0.2
616
+ // Only a clear tier change is detectable: Go ($100) and Go Teams ($105)
617
+ // sit 5% apart, well inside this estimate's noise, so the configured plan
618
+ // stays authoritative unless the implied allowance is at least 1.5x it.
619
+ const configuredLimit = LIMITS[cfg.plan][top.model]
620
+ if (typeof configuredLimit === 'number') {
621
+ const upgrade = ['go', 'go-plus'].find((plan) => {
622
+ const limit = LIMITS[plan][top.model]
623
+ return typeof limit === 'number' && limit >= configuredLimit * 1.5 && Math.abs(limit - impliedMonthly) / limit <= 0.25
624
+ })
625
+ if (upgrade !== undefined) detectedPlan = upgrade
626
+ }
627
+ }
628
+ const effectiveLimits = LIMITS[detectedPlan ?? cfg.plan]
629
+ const catalogInfo = new Map(catalog.map((entry) => [entry.id, entry]))
630
+ const rows = [...models.values()]
631
+ .map((entry) => {
632
+ const limit = effectiveLimits[entry.model]
633
+ const info = catalogInfo.get(entry.model)
634
+ const free = limit === Infinity || info?.free === true
635
+ const remaining = limit === undefined || limit === Infinity ? undefined : Math.max(0, limit - entry.monthly)
636
+ return {
637
+ ...entry,
638
+ limit,
639
+ remaining,
640
+ free,
641
+ name: info?.name,
642
+ inputs: info?.inputs,
643
+ tools: info?.tools,
644
+ reasoning: info?.reasoning,
645
+ context: info?.context,
646
+ maxOutput: info?.output,
647
+ rollingLimit: limit === undefined || limit === Infinity ? undefined : limit * 0.2,
648
+ weeklyLimit: limit === undefined || limit === Infinity ? undefined : limit * 0.5,
649
+ }
650
+ })
651
+ .sort((left, right) => right.monthly - left.monthly)
652
+ return {
653
+ plan: detectedPlan ?? cfg.plan,
654
+ configuredPlan: cfg.plan,
655
+ detectedPlan,
656
+ impliedMonthly,
657
+ catalogError,
658
+ checkedAt: Date.now(),
659
+ startedAt,
660
+ windowStarts: starts,
661
+ holidayAware: cfg.holidayAware,
662
+ peak: peakSnapshot(cfg.cnyPerUsd, Date.now(), detectedPlan ?? cfg.plan),
663
+ usage: usageSnapshot,
664
+ usageError,
665
+ accounts,
666
+ activeAccount: accounts.find((entry) => entry.active)?.id ?? accounts[0]?.id ?? null,
667
+ models: rows,
668
+ totals: {
669
+ requests: rows.reduce((sum, row) => sum + row.requests, 0),
670
+ cost: rows.reduce((sum, row) => sum + row.cost, 0),
671
+ monthly: rows.reduce((sum, row) => sum + row.monthly, 0),
672
+ },
673
+ }
674
+ }
675
+
676
+ async function handler(request, response) {
677
+ let refreshNow = false
678
+ try {
679
+ refreshNow = new URL(request.url ?? '/', 'http://localhost').searchParams.get('refresh') === '1'
680
+ } catch {
681
+ refreshNow = false
682
+ }
683
+ if (refreshNow) await refreshSnapshot()
684
+ const data = view()
685
+ if (request.url?.startsWith(`${cfg.path}.json`)) {
686
+ const body = JSON.stringify(data, null, 2)
687
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
688
+ response.end(body)
689
+ return
690
+ }
691
+ }
692
+
693
+ /** The billing document consumed by bili's `billing.priceSource` — ONE price
694
+ * table, two surfaces (this dashboard and the proxy's cost ledger). Shape is
695
+ * documented in billion-context/src/price-source.ts; prices are $/Mtok at
696
+ * the off-peak rate, `peak` carries the doubling rule, and `currency` is the
697
+ * optional ¥ rate. */
698
+ function priceDocument() {
699
+ const models = {}
700
+ for (const [id, price] of Object.entries(PRICES)) {
701
+ // Free models are omitted on purpose: bili requires a positive input
702
+ // anchor to price anything (a zero-price row would be rejected and take
703
+ // the whole document with it), and a free model needs no price — the
704
+ // proxy shows its tokens and bills nothing, which is the truth.
705
+ if (!(price.input > 0)) continue
706
+ models[id] = {
707
+ input: price.input,
708
+ output: price.output,
709
+ cacheRead: price.cacheRead,
710
+ ...(PEAK.modelPrefixes.some((prefix) => id.startsWith(prefix))
711
+ ? {
712
+ peak: {
713
+ factor: PEAK.factor,
714
+ utcWeekdayHours: PEAK.utcWeekdayHours,
715
+ ...(cfg.holidayAware ? { chinaHolidays: HOLIDAYS } : {}),
716
+ },
717
+ }
718
+ : {}),
719
+ }
720
+ }
721
+ return {
722
+ version: 1,
723
+ source: 'opencode-usage',
724
+ plan: cfg.plan,
725
+ ...(cfg.cnyPerUsd === undefined ? {} : { currency: { cnyPerUsd: cfg.cnyPerUsd } }),
726
+ models,
727
+ }
728
+ }
729
+
730
+ function priceHandler(_request, response) {
731
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
732
+ response.end(JSON.stringify(priceDocument(), null, 2))
733
+ }
734
+
735
+ const disposeJson = ctx.webServer.register({ kind: 'exact', path: `${cfg.path}.json`, handler })
736
+ const disposePrices = ctx.webServer.register({ kind: 'exact', path: `${cfg.path}.prices.json`, handler: priceHandler })
737
+
738
+ ctx.effect(() => () => {
739
+ disposeJson()
740
+ disposePrices()
741
+ disposeEvent()
742
+ }, 'opencode-usage: unregister data route')
743
+
744
+ const timer = setInterval(() => void refreshSnapshot(), Math.max(60, Math.floor(cfg.refreshSeconds)) * 1000)
745
+ timer.unref?.()
746
+ ctx.effect(() => () => clearInterval(timer), 'opencode-usage: stop usage polling')
747
+
748
+ ctx.logger.info(`opencode-usage: ${records.length} records from ${cfg.plan} plan; dashboard at ${cfg.path}`)
749
+ }