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/LICENSE +21 -0
- package/README.md +122 -2
- package/client.js +1640 -0
- package/cordis.patch.yml +93 -0
- package/icon.svg +8 -0
- package/index.js +33 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/router.js +1027 -0
- package/test.mjs +370 -0
- package/usage.js +749 -0
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
|
+
}
|