dsh-opencode-usage-dashboard 0.0.0-stage → 0.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/index.js ADDED
@@ -0,0 +1,810 @@
1
+ /**
2
+ * OpenCode 额度面板的 Host 半边。
3
+ *
4
+ * DSH 里的 `opencode-go` / `opencode` 只是两个连接预设,不能拿 provider id 判断
5
+ * 账户有没有 Go 订阅:同一个组织的任意服务账号 Key 都能读 `/inference/go/v1/usage`,
6
+ * 额度本身也是组织级共享的。这里用这个接口的 200/404 做订阅探测(404 = 没有订阅),
7
+ * provider id 只决定优先用哪把 Key、以及没有 Key 时怎么回落。
8
+ *
9
+ * 供应商列表来自 `llm-pi-ai` 设置里的 providers,凭据引用取各自的 `apiKeyEnv`,
10
+ * 缺失时按预设回落到 `OPENCODE_GO_API_KEY` / `OPENCODE_API_KEY`。
11
+ */
12
+
13
+ import { appendFile, mkdir } from 'node:fs/promises'
14
+ import { join } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+
17
+ /** @typedef {{ percent: number | null, status: 'ok' | 'limited', resetsAt: string | null }} QuotaWindow */
18
+ /** @typedef {{ id: string, kind: 'go' | 'console', ref: string, name: string }} Account 一个 provider 连接;`kind` 只是连接预设,不代表计费方式。 */
19
+
20
+ const GO_BASE = 'https://opencode.ai/inference/go/v1'
21
+ const CONSOLE_BASE = 'https://opencode.ai/console/api'
22
+ const DEFAULT_KEY_REF = 'OPENCODE_GO_API_KEY'
23
+ const CONSOLE_KEY_REF = 'OPENCODE_API_KEY'
24
+ const QUOTA_WINDOWS = ['rolling', 'weekly', 'monthly']
25
+ const CACHE_MS = 10_000
26
+ const TIMEOUT_MS = 12_000
27
+ const MAX_RESPONSE_BYTES = 256 * 1024
28
+ const MAX_BODY_BYTES = 8 * 1024
29
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
30
+ const GO_PROBE_MS = 60 * 1000
31
+
32
+ /** 需要的 Host 服务:HTTP 路由、凭据库、只读设置。 */
33
+ export const inject = ['webServer', 'credentials', 'settings']
34
+
35
+ /**
36
+ * 挂载路由。
37
+ * @param {import('@deepseek-ai/cordis').Context} ctx 插件上下文。
38
+ * @param {{ baseURL?: string }} [config] 该行可选的 `config`。
39
+ */
40
+ /** 激活失败也不能拖垮 DSH:包一层,出错只记日志。 */
41
+ export function apply(ctx, config) {
42
+ try {
43
+ applyInner(ctx, config)
44
+ } catch (error) {
45
+ console.error('[opencode-quota] 激活失败:', error)
46
+ }
47
+ }
48
+
49
+ function applyInner(ctx, config) {
50
+ const goBase = String((config && config.baseURL) || GO_BASE).replace(/\/+$/, '')
51
+ /** @type {Map<string, { at: number, view: any }>} */
52
+ const cache = new Map()
53
+
54
+ /**
55
+ * 从 `llm-pi-ai` 设置里找出 opencode 供应商;读不到时回落到 Go。
56
+ * @returns {Account[]} provider 列表(未合并)。
57
+ */
58
+ const providerEntries = () => {
59
+ /** @type {Account[]} */
60
+ const accounts = []
61
+ try {
62
+ const doc = ctx.settings.describe({ redactSecrets: true })
63
+ const descriptor = Array.isArray(doc) ? doc.find((row) => row && row.ns === 'llm-pi-ai') : null
64
+ const providers = (descriptor && descriptor.value && descriptor.value.providers) || {}
65
+ for (const [route, entry] of Object.entries(providers)) {
66
+ if (!entry || typeof entry !== 'object') continue
67
+ const id = String(route)
68
+ const baseURL = typeof entry.baseURL === 'string' ? entry.baseURL : ''
69
+ /** provider 名以 opencode 开头(opencode、opencode-go、opencode-work…)或指向 opencode.ai 都算。 */
70
+ const opencode = /^opencode(-|$)/i.test(id) || /opencode\.ai/i.test(baseURL)
71
+ if (!opencode) continue
72
+ /** kind 只是 DSH 的连接预设(决定优先用哪把 Key),订阅与否由 probeGo 探测。 */
73
+ const kind = id === 'opencode-go' || /\/inference\/go(\/|$)/.test(baseURL) ? 'go' : 'console'
74
+ const ref =
75
+ typeof entry.apiKeyEnv === 'string' && entry.apiKeyEnv.length > 0
76
+ ? entry.apiKeyEnv
77
+ : kind === 'go'
78
+ ? DEFAULT_KEY_REF
79
+ : CONSOLE_KEY_REF
80
+ const name = typeof entry.displayName === 'string' && entry.displayName ? entry.displayName : id
81
+ accounts.push({ id, kind, ref, name })
82
+ }
83
+ } catch {
84
+ /* 设置不可读时走回落 */
85
+ }
86
+ if (accounts.length === 0) accounts.push({ id: 'opencode-go', kind: 'go', ref: DEFAULT_KEY_REF, name: 'OpenCode Go' })
87
+ return accounts
88
+ }
89
+
90
+ /** 凭证所属身份,用于把同一个账户下的多个 provider 合并。 */
91
+ const identities = new Map()
92
+ /** 服务账号列表按 Key 缓存:返回组织下所有 SA 及其 Key 前缀(tokenHint)。 */
93
+ const serviceAccounts = new Map()
94
+ /** 组织成员(owner 的邮箱最适合当 org 名字),按 Key 缓存。 */
95
+ const orgOwners = new Map()
96
+ /** 账户是否有 Go 订阅;额度是组织级,所以按账户(org id)缓存 60 秒。 */
97
+ const goProbes = new Map()
98
+
99
+ /**
100
+ * 读组织成员,挑 owner;同一个 Key 只请求一次。
101
+ * @param {string} key 明文 Key。
102
+ * @returns {Promise<{ email: string | null, name: string | null } | null>} 组织主账号。
103
+ */
104
+ const readOwner = async (key) => {
105
+ if (orgOwners.has(key)) return orgOwners.get(key)
106
+ let owner = null
107
+ try {
108
+ const body = await requestJson(`${CONSOLE_BASE}/members`, key)
109
+ const items = body && Array.isArray(body.items) ? body.items : []
110
+ const hit = items.find((row) => row && row.role === 'owner') || items[0] || null
111
+ if (hit) owner = { email: hit.email || null, name: hit.displayName || null }
112
+ } catch {
113
+ /* 读不到成员就退回服务账号名 */
114
+ }
115
+ orgOwners.set(key, owner)
116
+ return owner
117
+ }
118
+
119
+ /**
120
+ * 读组织下的服务账号;同一个 Key 只请求一次。
121
+ * @param {string} key 明文 Key。
122
+ * @returns {Promise<Array<any>>} `{ account, keys }` 列表。
123
+ */
124
+ const listServiceAccounts = async (key) => {
125
+ const hit = serviceAccounts.get(key)
126
+ if (hit) return hit
127
+ const body = await requestJson(`${CONSOLE_BASE}/service-accounts`, key)
128
+ const items = body && Array.isArray(body.items) ? body.items : []
129
+ serviceAccounts.set(key, items)
130
+ return items
131
+ }
132
+
133
+ /**
134
+ * 用 Key 的前缀匹配 tokenHint,找出这把 Key 属于哪个服务账号。
135
+ * @param {Array<any>} items 服务账号列表。
136
+ * @param {string} key 明文 Key。
137
+ * @returns {{ id: string, name: string } | null} 命中的服务账号。
138
+ */
139
+ const matchServiceAccount = (items, key) => {
140
+ for (const item of items) {
141
+ const account = item && item.account
142
+ if (!account || typeof account.id !== 'string') continue
143
+ const rows = item && Array.isArray(item.keys) ? item.keys : []
144
+ for (const row of rows) {
145
+ const hint = row && typeof row.tokenHint === 'string' ? row.tokenHint.replace(/\.+$/, '') : ''
146
+ if (hint && key.startsWith(hint)) return { id: account.id, name: account.name || account.id }
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * 用 provider 的 Key 读账户身份;读不到时退回 provider 自身,保证不误合并。
154
+ * 账户 = 组织(org);服务账号只作为 Key 的归属展示。
155
+ * @param {Account} provider provider。
156
+ * @returns {Promise<{ id: string, name: string, serviceAccount?: string | null, hasKey?: boolean }>} 身份。
157
+ */
158
+ const resolveIdentity = async (provider) => {
159
+ const cached = identities.get(provider.ref)
160
+ if (cached) return cached
161
+ let identity = { id: `ref:${provider.ref}`, name: provider.name, serviceAccount: null, hasKey: false }
162
+ try {
163
+ const hit = await ctx.credentials.resolve(provider.ref)
164
+ if (hit && hit.value) {
165
+ identity.hasKey = true
166
+ /** 服务账号:用 Key 前缀匹配 tokenHint,拿不准也不影响分组。 */
167
+ let serviceAccount = null
168
+ try {
169
+ serviceAccount = matchServiceAccount(await listServiceAccounts(hit.value), hit.value)
170
+ } catch {
171
+ /* 不是服务账号 Key 时这个接口可能 403 */
172
+ }
173
+ let name = serviceAccount ? serviceAccount.name : provider.name
174
+ let user = null
175
+ if (!serviceAccount) {
176
+ try {
177
+ const body = await requestJson(`${CONSOLE_BASE}/usage/users`, hit.value)
178
+ const item = body && Array.isArray(body.items) ? body.items[0] : null
179
+ if (item) {
180
+ name = item.name || item.email || name
181
+ user = item.serviceUserId || item.userId || null
182
+ }
183
+ } catch {
184
+ /* 读不到 users 就用 provider 名 */
185
+ }
186
+ }
187
+ /** 组织 id 在 console config 的 provider 头里。 */
188
+ let org = null
189
+ try {
190
+ const config = await requestJson(`${CONSOLE_BASE}/config`, hit.value)
191
+ const providers = (config && config.config && config.config.provider) || {}
192
+ for (const entry of Object.values(providers)) {
193
+ const headers = entry && entry.options && entry.options.headers
194
+ const value = headers && headers['x-opencode-org-id']
195
+ if (typeof value === 'string' && value.length > 0) {
196
+ org = value
197
+ break
198
+ }
199
+ }
200
+ } catch {
201
+ /* 读不到 config 就退回服务账号 */
202
+ }
203
+ const id = org || (serviceAccount && serviceAccount.id) || user
204
+ /** 组织名优先用 owner 的邮箱,比服务账号名更像“账户”。 */
205
+ const owner = await readOwner(hit.value)
206
+ if (owner && owner.email) name = owner.email
207
+ if (typeof id === 'string' && id.length > 0) {
208
+ identity = {
209
+ id,
210
+ name,
211
+ email: owner ? owner.email : null,
212
+ serviceAccount: serviceAccount ? serviceAccount.name : null,
213
+ serviceAccountId: serviceAccount ? serviceAccount.id : null,
214
+ hasKey: true,
215
+ }
216
+ }
217
+ }
218
+ } catch {
219
+ /* 身份读不到就按 ref 区分 */
220
+ }
221
+ identities.set(provider.ref, identity)
222
+ return identity
223
+ }
224
+
225
+ /**
226
+ * 把 provider 按账户身份合并;同一账户的 go 与 console 合成一张卡。
227
+ * @returns {Promise<Array<{ id: string, name: string, refs: Array<{ kind: string, ref: string, name: string }> }>>} 账户。
228
+ */
229
+ const discoverAccounts = async () => {
230
+ const groups = new Map()
231
+ for (const provider of providerEntries()) {
232
+ const identity = await resolveIdentity(provider)
233
+ const group = groups.get(identity.id) || { id: identity.id, name: identity.name, refs: [], hasKey: false }
234
+ group.hasKey = group.hasKey || Boolean(identity.hasKey)
235
+ if (!group.refs.some((row) => row.ref === provider.ref)) {
236
+ group.refs.push({ kind: provider.kind, ref: provider.ref, name: provider.name })
237
+ }
238
+ groups.set(identity.id, group)
239
+ }
240
+ const list = [...groups.values()]
241
+ /** 只配了一个 provider 时,别为没配 Key 的另一个 provider 再画一张空账户。 */
242
+ const hasIdentified = list.some((group) => !group.id.startsWith('ref:'))
243
+ return hasIdentified ? list.filter((group) => !group.id.startsWith('ref:') || group.hasKey) : list
244
+ }
245
+
246
+ /**
247
+ * 用一把 Key 探测账户有没有 Go 订阅。
248
+ * @param {string} key 明文 Key。
249
+ * @returns {Promise<boolean | null>} true=有额度,false=404 没订阅,null=这次判断不了。
250
+ */
251
+ const probeGoWithKey = async (key) => {
252
+ try {
253
+ await fetchQuota(key)
254
+ return true
255
+ } catch (error) {
256
+ if (error && error.code === 'NOT_SUBSCRIBED') return false
257
+ return null
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 探测账户是否有 Go 订阅:优先 Go 预设的 Key,读不到就换下一把。
263
+ * 订阅是组织级的,所以只按账户探测一次,60 秒内复用。
264
+ * @param {Account} account 账户。
265
+ * @returns {Promise<boolean | null>} 探测结果。
266
+ */
267
+ const probeGo = async (account) => {
268
+ const cached = goProbes.get(account.id)
269
+ if (cached && Date.now() - cached.at < GO_PROBE_MS) return cached.hasGo
270
+ const candidates = []
271
+ for (const entry of account.refs) {
272
+ try {
273
+ const credential = await ctx.credentials.resolve(entry.ref)
274
+ if (credential && credential.value) candidates.push({ kind: entry.kind, key: credential.value })
275
+ } catch {
276
+ /* 单把 Key 读不到不影响其他 Key */
277
+ }
278
+ }
279
+ candidates.sort((a, b) => (a.kind === 'go' ? 0 : 1) - (b.kind === 'go' ? 0 : 1))
280
+ let hasGo = null
281
+ for (const candidate of candidates) {
282
+ const result = await probeGoWithKey(candidate.key)
283
+ if (result !== null) {
284
+ hasGo = result
285
+ break
286
+ }
287
+ }
288
+ goProbes.set(account.id, { at: Date.now(), hasGo })
289
+ return hasGo
290
+ }
291
+
292
+ /**
293
+ * 单个账户的配置与凭据状态。
294
+ * @param {Account} account 账户。
295
+ * @returns {Promise<any>} 前端用的状态。
296
+ */
297
+ const stateOf = async (account) => {
298
+ const keys = []
299
+ for (const entry of account.refs) {
300
+ const info = await ctx.credentials.describe(entry.ref)
301
+ /** 每个 provider 的 Key 属于哪个服务账号,仅作展示。 */
302
+ const identity = await resolveIdentity({ ...entry, id: entry.ref }).catch(() => null)
303
+ keys.push({
304
+ kind: entry.kind,
305
+ ref: entry.ref,
306
+ name: entry.name,
307
+ configured: Boolean(info && info.configured),
308
+ source: (info && info.source) || null,
309
+ serviceAccount: (identity && identity.serviceAccount) || null,
310
+ serviceAccountId: (identity && identity.serviceAccountId) || null,
311
+ })
312
+ }
313
+ /** 订阅与否看接口,不看 provider 名;探测不出来时才退回预设。 */
314
+ const hasGo = await probeGo(account)
315
+ const go = keys.find((row) => row.kind === 'go') || null
316
+ const configured = keys.find((row) => row.configured) || go || keys[0] || null
317
+ /** 组织里出现过的服务账号名,用于给 org 起一个能看懂的名字。 */
318
+ const members = [...new Set(keys.map((row) => row.serviceAccount).filter(Boolean))]
319
+ return {
320
+ id: account.id,
321
+ name: account.name,
322
+ members,
323
+ hasGo,
324
+ kind: hasGo === true ? 'go' : hasGo === false ? 'console' : go ? 'go' : 'console',
325
+ keys,
326
+ keyRef: configured ? configured.ref : null,
327
+ configured: keys.some((row) => row.configured),
328
+ source: configured ? configured.source : null,
329
+ writable: true,
330
+ }
331
+ }
332
+
333
+ /**
334
+ * 所有账户的状态列表。
335
+ * @returns {Promise<any>} 含 `accounts` 的状态。
336
+ */
337
+ const state = async () => {
338
+ const accounts = await discoverAccounts()
339
+ const rows = await Promise.all(accounts.map((account) => stateOf(account)))
340
+ return { accounts: rows, baseURL: goBase }
341
+ }
342
+
343
+ /**
344
+ * 找到账户;未知 id 返回 undefined。
345
+ * @param {string | null} id 账户 id。
346
+ * @returns {Account | undefined} 账户。
347
+ */
348
+ const pickAccount = async (id) => {
349
+ const accounts = await discoverAccounts()
350
+ return accounts.find((account) => account.id === id) || accounts[0]
351
+ }
352
+
353
+ /**
354
+ * 读某个账户的额度视图(带 10 秒缓存)。
355
+ * @param {Account} account 账户。
356
+ * @param {boolean} fresh 是否跳过缓存。
357
+ * @param {string} [sa] 服务账号过滤串;空串表示整个 org。
358
+ * @returns {Promise<any>} 视图。
359
+ */
360
+ const readView = async (account, fresh, sa = '') => {
361
+ const cacheKey = `${account.id}:${sa}`
362
+ const hit = cache.get(cacheKey)
363
+ if (!fresh && hit && Date.now() - hit.at < CACHE_MS) return hit.view
364
+ const keys = []
365
+ for (const entry of account.refs) {
366
+ const credential = await ctx.credentials.resolve(entry.ref)
367
+ if (credential && credential.value) keys.push({ kind: entry.kind, value: credential.value })
368
+ }
369
+ if (keys.length === 0) {
370
+ const error = new Error(`${account.name} 未配置 API Key`)
371
+ error.code = 'NO_KEY'
372
+ throw error
373
+ }
374
+ /** 额度接口两把 Key 都能读;优先 Go 预设的 Key。 */
375
+ const goKey = keys.find((row) => row.kind === 'go')
376
+ const consoleKey = keys.find((row) => row.kind === 'console')
377
+ const picked = goKey || consoleKey
378
+ const key = picked.value
379
+ /** 选了某个服务账号就按它过滤花费;选 org 时是整个组织。 */
380
+ const filter = sa ? `serviceAccountId=${encodeURIComponent(sa)}` : ''
381
+ const summary = await fetchSummary(key, null, filter).catch(() => null)
382
+ /** 一次取 7 天的小时桶:7 天图按本地日聚合,24 小时图取最后 24 格。 */
383
+ let hourly = await fetchDaily(key, '7d', 'hour', filter).catch(() => null)
384
+ let daily = null
385
+ if (!hourly || hourly.length === 0) {
386
+ daily = await fetchDaily(key, '7d', '', filter).catch(() => null)
387
+ hourly = await fetchDaily(key, '24h', 'hour', filter).catch(() => null)
388
+ }
389
+ const compare = await fetchCompare(key, filter).catch(() => null)
390
+ const models = await fetchModels(key, filter).catch(() => null)
391
+ /** 已知没有 Go 订阅时跳过额度请求;手动刷新则重新探测。 */
392
+ let quota = null
393
+ const known = goProbes.get(account.id)
394
+ if (fresh || !known || known.hasGo !== false) {
395
+ try {
396
+ quota = await fetchQuota(key)
397
+ } catch (error) {
398
+ if (!error || error.code !== 'NOT_SUBSCRIBED') throw error
399
+ }
400
+ goProbes.set(account.id, { at: Date.now(), hasGo: Boolean(quota) })
401
+ }
402
+ const period = await fetchPeriod(key, quota ? quota.monthly : null, filter).catch(() => null)
403
+ const view = { kind: quota ? 'go' : 'console', quota, windows: quota, summary, daily, hourly, compare, models, period }
404
+ cache.set(cacheKey, { at: Date.now(), view })
405
+ return view
406
+ }
407
+
408
+ /** 客户端激活/报错日志:写到 $DSH_HOME/opencode-quota/client.log,便于排查“没组件”。 */
409
+ const appendClientLog = async (text) => {
410
+ try {
411
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
412
+ const dir = join(home, 'opencode-quota')
413
+ await mkdir(dir, { recursive: true })
414
+ await appendFile(join(dir, 'client.log'), new Date().toISOString() + ' ' + text + '\n')
415
+ } catch {
416
+ /* 日志失败不影响主流程 */
417
+ }
418
+ }
419
+
420
+ ctx.effect(
421
+ () =>
422
+ ctx.webServer.register({
423
+ kind: 'exact',
424
+ path: '/api/opencode-quota/log',
425
+ handler: async (req, res) => {
426
+ if (!allowed(req)) return deny(res)
427
+ if (!method(req, res, 'POST')) return
428
+ try {
429
+ const body = await readJson(req)
430
+ await appendClientLog(String((body && body.text) || '').slice(0, 4000))
431
+ send(res, 200, { ok: true })
432
+ } catch (error) {
433
+ fail(res, error)
434
+ }
435
+ },
436
+ }),
437
+ 'opencode-quota: /log route',
438
+ )
439
+
440
+ ctx.effect(
441
+ () =>
442
+ ctx.webServer.register({
443
+ kind: 'exact',
444
+ path: '/api/opencode-quota/state',
445
+ handler: async (req, res) => {
446
+ if (!allowed(req)) return deny(res)
447
+ if (!method(req, res, 'GET')) return
448
+ try {
449
+ send(res, 200, await state())
450
+ } catch (error) {
451
+ fail(res, error)
452
+ }
453
+ },
454
+ }),
455
+ 'opencode-quota: /state route',
456
+ )
457
+
458
+ ctx.effect(
459
+ () =>
460
+ ctx.webServer.register({
461
+ kind: 'exact',
462
+ path: '/api/opencode-quota/key',
463
+ handler: async (req, res) => {
464
+ if (!allowed(req)) return deny(res)
465
+ if (!method(req, res, 'POST')) return
466
+ try {
467
+ const body = await readJson(req)
468
+ /** 可写引用来自全部 provider,不能用过滤后的账户列表,否则没配 Key 的 provider 会被漏掉。 */
469
+ const refs = providerEntries().map((row) => row.ref)
470
+ const ref = typeof body.ref === 'string' && refs.includes(body.ref) ? body.ref : refs[0]
471
+ if (!ref) throw new Error('没有可写的 Key 引用')
472
+ const value = typeof body.key === 'string' ? body.key.trim() : ''
473
+ if (value.length === 0) await ctx.credentials.unset(ref)
474
+ else await ctx.credentials.set(ref, value)
475
+ identities.delete(ref)
476
+ serviceAccounts.clear()
477
+ cache.clear()
478
+ goProbes.clear()
479
+ const accounts = await discoverAccounts()
480
+ const account = accounts.find((row) => row.refs.some((entry) => entry.ref === ref)) || accounts[0]
481
+ send(res, 200, await stateOf(account))
482
+ } catch (error) {
483
+ fail(res, error)
484
+ }
485
+ },
486
+ }),
487
+ 'opencode-quota: /key route',
488
+ )
489
+
490
+ ctx.effect(
491
+ () =>
492
+ ctx.webServer.register({
493
+ kind: 'exact',
494
+ path: '/api/opencode-quota/usage',
495
+ handler: async (req, res) => {
496
+ if (!allowed(req)) return deny(res)
497
+ if (!method(req, res, 'GET')) return
498
+ const params = new URL(req.url || '/', 'http://127.0.0.1').searchParams
499
+ const account = await pickAccount(params.get('account'))
500
+ if (!account) return send(res, 400, { error: { code: 'no-account', message: '没有可用的 opencode 账户' } })
501
+ try {
502
+ const view = await readView(account, params.get('fresh') === '1', params.get('sa') || '')
503
+ send(res, 200, { ...(await stateOf(account)), ...view, fetchedAt: Date.now() })
504
+ } catch (error) {
505
+ if (error && error.code === 'NO_KEY') return send(res, 200, { ...(await stateOf(account)), quota: null, summary: null })
506
+ fail(res, error)
507
+ }
508
+ },
509
+ }),
510
+ 'opencode-quota: /usage route',
511
+ )
512
+
513
+ /**
514
+ * 用 Go 账户的 Key 读三个额度窗口。
515
+ * @param {string} key 明文 Key。
516
+ * @returns {Promise<Record<string, QuotaWindow>>} 三个窗口。
517
+ */
518
+ async function fetchQuota(key) {
519
+ const body = await requestJson(`${goBase}/usage`, key)
520
+ return normalizeQuota(body)
521
+ }
522
+
523
+ /**
524
+ * 本周与上周的花费对比(周一为界,按本地时区)。
525
+ * @param {string} key 明文 Key。
526
+ * @param {string} saFilter 服务账号过滤串(空串表示整个组织)。
527
+ * @returns {Promise<{ current: number, previous: number }>} 两周花费。
528
+ */
529
+ async function fetchCompare(key, saFilter) {
530
+ const day = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
531
+ const now = new Date()
532
+ const mondayOffset = (now.getDay() + 6) % 7
533
+ const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - mondayOffset)
534
+ const lastMonday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - 7)
535
+ const lastSunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - 1)
536
+ const [current, previous] = await Promise.all([
537
+ fetchSummary(key, `from=${day(monday)}&to=${day(now)}`, saFilter),
538
+ fetchSummary(key, `from=${day(lastMonday)}&to=${day(lastSunday)}`, saFilter),
539
+ ])
540
+ return {
541
+ current: current.costUsd,
542
+ previous: previous.costUsd,
543
+ requests: current.requests,
544
+ tokens: current.inputTokens + current.outputTokens + current.cacheReadTokens,
545
+ }
546
+ }
547
+
548
+ /**
549
+ * 读最近 7 天每天的花费。
550
+ * @param {string} key 明文 Key。
551
+ * @returns {Promise<Array<{ date: string, costUsd: number, tokens: number, requests: number }>>} 每日序列。
552
+ */
553
+ /**
554
+ * 当前计费周期(从 monthly 重置时间的前一个月到今天)的每日花费。
555
+ * @param {string} key 明文 Key。
556
+ * @param {any} monthly monthly 窗口。
557
+ * @param {string} saFilter 服务账号过滤串。
558
+ * @returns {Promise<any>} 周期与每日花费。
559
+ */
560
+ async function fetchPeriod(key, monthly, saFilter) {
561
+ if (!monthly || typeof monthly.resetsAt !== 'string') return null
562
+ const end = new Date(monthly.resetsAt)
563
+ const start = new Date(end.getTime())
564
+ start.setMonth(start.getMonth() - 1)
565
+ const day = (date) =>
566
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
567
+ const filter = saFilter ? `&${saFilter}` : ''
568
+ const body = await requestJson(`${CONSOLE_BASE}/usage/cost-by-day?from=${day(start)}&to=${day(new Date())}${filter}`, key)
569
+ const rows = Array.isArray(body) ? body : []
570
+ return {
571
+ start: start.toISOString(),
572
+ end: end.toISOString(),
573
+ daily: rows.map((row) => ({
574
+ date: String(row.date || ''),
575
+ costUsd: Number(row.totalCostMicroCents) / 1e8,
576
+ tokens: Number(row.totalTokens) || 0,
577
+ requests: Number(row.totalRequests) || 0,
578
+ })),
579
+ }
580
+ }
581
+
582
+ async function fetchDaily(key, range, bucket, saFilter) {
583
+ const withBucket = bucket ? `&bucket=${bucket}` : ''
584
+ const filter = saFilter ? `&${saFilter}` : ''
585
+ const body = await requestJson(`${CONSOLE_BASE}/usage/cost-by-day?range=${range}${withBucket}${filter}`, key)
586
+ const rows = Array.isArray(body) ? body : []
587
+ return rows.map((row) => ({
588
+ date: String(row.date || ''),
589
+ costUsd: Number(row.totalCostMicroCents) / 1e8,
590
+ tokens: Number(row.totalTokens) || 0,
591
+ requests: Number(row.totalRequests) || 0,
592
+ }))
593
+ }
594
+
595
+ /**
596
+ * 读按模型拆分的前几名。
597
+ * @param {string} key 明文 Key。
598
+ * @returns {Promise<Array<{ model: string, costUsd: number, requests: number, tokens: number }>>} 模型用量。
599
+ */
600
+ async function fetchModels(key, saFilter) {
601
+ const filter = saFilter ? `&${saFilter}` : ''
602
+ const body = await requestJson(`${CONSOLE_BASE}/usage/models?pageSize=6${filter}`, key)
603
+ const items = body && Array.isArray(body.items) ? body.items : []
604
+ return items.slice(0, 6).map((row) => ({
605
+ model: String(row.model || ''),
606
+ costUsd: Number(row.totalCostMicroCents) / 1e8,
607
+ requests: Number(row.totalRequests) || 0,
608
+ tokens:
609
+ (Number(row.totalInputTokens) || 0) +
610
+ (Number(row.totalOutputTokens) || 0) +
611
+ (Number(row.totalCacheReadTokens) || 0) +
612
+ (Number(row.totalCacheWrite5mTokens) || 0) +
613
+ (Number(row.totalCacheWrite1hTokens) || 0),
614
+ }))
615
+ }
616
+
617
+ /**
618
+ * 用 Console 账户的 Key 读花费汇总。
619
+ * @param {string} key 明文 Key。
620
+ * @returns {Promise<any>} 汇总(美元金额已换算)。
621
+ */
622
+ async function fetchSummary(key, query, saFilter) {
623
+ /** Console 的「Today」= 本地日界,对应 `since` 参数;不带参数默认是 7 天。 */
624
+ const today = new Date()
625
+ const since = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString()
626
+ const filter = saFilter ? `&${saFilter}` : ''
627
+ const search = (query || `since=${encodeURIComponent(since)}`) + filter
628
+ const body = await requestJson(`${CONSOLE_BASE}/usage/summary?${search}`, key)
629
+ const micro = Number(body && body.totalCostMicroCents)
630
+ return {
631
+ requests: Number(body && body.totalRequests) || 0,
632
+ inputTokens: Number(body && body.totalInputTokens) || 0,
633
+ outputTokens: Number(body && body.totalOutputTokens) || 0,
634
+ cacheReadTokens: Number(body && body.totalCacheReadTokens) || 0,
635
+ costUsd: Number.isFinite(micro) ? micro / 1e8 : null,
636
+ }
637
+ }
638
+ }
639
+
640
+ /**
641
+ * 请求上游 JSON;401/403/404 转成可读原因。
642
+ * @param {string} url 地址。
643
+ * @param {string} key 明文 Key。
644
+ * @returns {Promise<any>} 解析后的 JSON。
645
+ */
646
+ async function requestJson(url, key) {
647
+ /** @type {Response} */
648
+ let response
649
+ try {
650
+ response = await fetch(url, {
651
+ method: 'GET',
652
+ redirect: 'error',
653
+ signal: AbortSignal.timeout(TIMEOUT_MS),
654
+ headers: {
655
+ authorization: `Bearer ${key}`,
656
+ accept: 'application/json',
657
+ 'cache-control': 'no-cache',
658
+ pragma: 'no-cache',
659
+ },
660
+ })
661
+ } catch (error) {
662
+ throw new Error(`连不上 ${new URL(url).host}:${errorText(error)}`)
663
+ }
664
+ let raw
665
+ try {
666
+ raw = await response.text()
667
+ } catch (error) {
668
+ throw new Error(`读取上游响应失败:${errorText(error)}`)
669
+ }
670
+ if (raw.length > MAX_RESPONSE_BYTES) throw new Error('上游响应过大')
671
+ if (response.status === 404) {
672
+ const error = new Error('这个账户没有 Go 订阅额度')
673
+ error.code = 'NOT_SUBSCRIBED'
674
+ throw error
675
+ }
676
+ if (!response.ok) {
677
+ const hint = response.status === 401 || response.status === 403 ? ',请检查 Key 是否正确' : ''
678
+ throw new Error(`上游返回 HTTP ${response.status}${hint}`)
679
+ }
680
+ try {
681
+ return JSON.parse(raw)
682
+ } catch {
683
+ throw new Error('上游返回的不是 JSON')
684
+ }
685
+ }
686
+
687
+ /**
688
+ * 把 Go 的 usage 文档归一化为三个窗口。
689
+ * @param {unknown} payload 上游 JSON。
690
+ * @returns {Record<string, QuotaWindow>} 归一化窗口。
691
+ */
692
+ function normalizeQuota(payload) {
693
+ const body = payload && typeof payload === 'object' ? payload : null
694
+ if (!body) throw new Error('上游返回格式无法识别')
695
+ const source = body.usage && typeof body.usage === 'object' ? body.usage : body
696
+ /** @type {Record<string, QuotaWindow | null>} */
697
+ const quota = {}
698
+ for (const name of QUOTA_WINDOWS) {
699
+ const row = source[name]
700
+ if (!row || typeof row !== 'object') {
701
+ quota[name] = null
702
+ continue
703
+ }
704
+ const percent = Number(row.percent)
705
+ quota[name] = {
706
+ percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : null,
707
+ status: row.status === 'rate-limited' ? 'limited' : 'ok',
708
+ resetsAt: typeof row.resetsAt === 'string' ? row.resetsAt : null,
709
+ }
710
+ }
711
+ if (QUOTA_WINDOWS.every((name) => quota[name] === null)) throw new Error('上游返回里没有额度窗口')
712
+ return quota
713
+ }
714
+
715
+ /**
716
+ * 是否放行:仅本机回环,且拒绝跨站来源。
717
+ * @param {import('node:http').IncomingMessage} req 请求。
718
+ * @returns {boolean} 放行时为 true。
719
+ */
720
+ function allowed(req) {
721
+ if (!LOOPBACK.has(String((req.socket && req.socket.remoteAddress) || ''))) return false
722
+ const site = req.headers['sec-fetch-site']
723
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return false
724
+ const origin = req.headers.origin
725
+ if (origin !== undefined) {
726
+ try {
727
+ if (new URL(origin).host !== req.headers.host) return false
728
+ } catch {
729
+ return false
730
+ }
731
+ }
732
+ return true
733
+ }
734
+
735
+ /**
736
+ * 读 JSON 请求体。
737
+ * @param {import('node:http').IncomingMessage} req 请求。
738
+ * @returns {Promise<Record<string, unknown>>} 解析后的对象。
739
+ */
740
+ async function readJson(req) {
741
+ /** @type {Buffer[]} */
742
+ const chunks = []
743
+ let size = 0
744
+ for await (const chunk of req) {
745
+ size += chunk.length
746
+ if (size > MAX_BODY_BYTES) throw new Error('请求体过大')
747
+ chunks.push(chunk)
748
+ }
749
+ if (size === 0) return {}
750
+ try {
751
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
752
+ return value && typeof value === 'object' ? value : {}
753
+ } catch {
754
+ throw new Error('请求体不是合法 JSON')
755
+ }
756
+ }
757
+
758
+ /**
759
+ * 校验请求方法。
760
+ * @param {import('node:http').IncomingMessage} req 请求。
761
+ * @param {import('node:http').ServerResponse} res 响应。
762
+ * @param {string} expected 期望的方法。
763
+ * @returns {boolean} 方法匹配时为 true。
764
+ */
765
+ function method(req, res, expected) {
766
+ if (req.method === expected) return true
767
+ send(res, 405, { error: { code: 'method-not-allowed', message: `只接受 ${expected}` } })
768
+ return false
769
+ }
770
+
771
+ /**
772
+ * 写 JSON 响应。
773
+ * @param {import('node:http').ServerResponse} res 响应。
774
+ * @param {number} status 状态码。
775
+ * @param {unknown} payload 响应体。
776
+ */
777
+ function send(res, status, payload) {
778
+ res.writeHead(status, {
779
+ 'content-type': 'application/json; charset=utf-8',
780
+ 'cache-control': 'no-store',
781
+ })
782
+ res.end(JSON.stringify(payload))
783
+ }
784
+
785
+ /**
786
+ * 拒绝非本机来源。
787
+ * @param {import('node:http').ServerResponse} res 响应。
788
+ */
789
+ function deny(res) {
790
+ send(res, 403, { error: { code: 'forbidden', message: '只允许本机页面访问' } })
791
+ }
792
+
793
+ /**
794
+ * 把异常写成 500。
795
+ * @param {import('node:http').ServerResponse} res 响应。
796
+ * @param {unknown} error 异常。
797
+ */
798
+ function fail(res, error) {
799
+ send(res, 500, { error: { code: 'upstream-failed', message: errorText(error) } })
800
+ }
801
+
802
+ /**
803
+ * 取异常可读文本。
804
+ * @param {unknown} error 异常。
805
+ * @returns {string} 可展示的原因。
806
+ */
807
+ function errorText(error) {
808
+ if (error instanceof Error) return error.message
809
+ return String(error)
810
+ }