dsh-model-usage 0.8.2 → 0.8.3

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/lib/index.js CHANGED
@@ -1,1083 +1,1083 @@
1
- // Node half of dsh-model-usage: hosts the usage query for the model the CURRENT
2
- // CONVERSATION uses and exposes it to the browser half over loopback-only Web
3
- // routes. The model route is resolved from the Session's `modelSelection`
4
- // projection (the same fact the model picker shows), the provider's own
5
- // credential reference is read from the live `llm-pi-ai` / `llm-deepseek`
6
- // configuration, and the matching usage API is called with that very key.
7
- // Secrets never leave the host process — the browser only ever receives the
8
- // normalized payload plus the credential REFERENCE NAME (never a value).
9
- //
10
- // Query kinds, chosen from the resolved provider route and its configured
11
- // `baseURL`:
12
- // - commandcode: the Command Code private usage API (default origin
13
- // https://api.commandcode.ai): `/alpha/whoami?limits=1`,
14
- // `/alpha/billing/credits`, `/alpha/billing/subscriptions`,
15
- // `/alpha/usage/summary?since=`. This is exactly what `cmd /usage` renders.
16
- // - deepseek: the official `GET https://api.deepseek.com/user/balance`.
17
- // - deepseek-account: the signed-in DeepSeek platform wallet, read through
18
- // `ctx.deepseekAccount` (no API key involved).
19
- // - opencode-go: the OpenCode Go / Go Plus subscription allowances at
20
- // `GET {origin}/zen/go/v1/usage` (undocumented; normalized defensively).
21
- // - openrouter: `GET {origin}/api/v1/key`, plus `/api/v1/credits` when the key
22
- // carries no limit of its own.
23
- // - moonshot: `GET {origin}/v1/users/me/balance`.
24
- // - siliconflow: `GET {origin}/v1/user/info`.
25
- // - newapi: any other configured OpenAI-compatible route — best effort against
26
- // the cc-switch New API convention `GET {origin}/api/user/self`.
27
- //
28
- // The routes follow the webServer contract (like the official webhook
29
- // plugins): `ctx.effect(() => ctx.webServer.register({ kind: 'exact', ... }))`.
30
- // node:http routes carry no global authentication, so every one of ours asks
31
- // the composition's connection service for a rejection first and answers its
32
- // 401/403 itself — the same fence the shipped browser-facing routes apply.
33
- // No Config schema is declared: the plugin loads through a profile junction or
34
- // an npm install, so everything is node builtins and validation happens at
35
- // query time (fail loud in the panel).
36
- // Only `webServer` is a hard dependency; `connection`, `credentials`, `llm`,
37
- // `settings`, `sessions`, `sessionProjections`, `sessionController`,
38
- // `agentDefaultModel` and `deepseekAccount` are read through `ctx.get()` on
39
- // every use: the plugin still mounts — and says what is missing — in a
40
- // composition that provides fewer services, and a service that activates later
41
- // is picked up by the next query.
42
- import { readFileSync } from 'node:fs'
43
- import { homedir } from 'node:os'
44
-
45
- export const inject = ['webServer']
46
-
47
- const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance'
48
- /** Provider routes that authenticate with the official DeepSeek API key. */
49
- const DEEPSEEK_PROVIDERS = Object.freeze(['deepseek-official', 'deepseek'])
50
- const NEW_API_USER_AGENT = 'cc-switch/1.0'
51
- const NEW_API_QUOTA_SCALE = 500000
52
- /** Deadline for one usage API call; a wedged provider must not block the next refresh. */
53
- const QUERY_TIMEOUT_MS = 30000
54
-
55
- const COMMAND_CODE_PROVIDER = 'commandcode'
56
- const COMMAND_CODE_BASE = 'https://api.commandcode.ai'
57
- /** Web host the human-facing usage page lives on (the API host is `api.`). */
58
- const COMMAND_CODE_WEB_BASE = 'https://commandcode.ai'
59
- /** Env var older builds of this plugin (and the cc tooling) used. */
60
- const COMMAND_CODE_LEGACY_ENV_KEY = 'COMMAND_CODE_API_KEY'
61
- const COMMAND_CODE_AUTH_FILE = '.commandcode/auth.json'
62
-
63
- /** Plan id → display name (subset relevant to goat and neighbors). */
64
- const COMMAND_CODE_PLANS = Object.freeze({
65
- 'individual-go': 'Go',
66
- 'individual-goat': 'GOAT',
67
- 'individual-pro': 'Pro',
68
- 'individual-pro-v1': 'Pro',
69
- 'individual-provider': 'Provider',
70
- 'individual-max': 'Max',
71
- 'individual-ultra': 'Ultra',
72
- 'teams-pro': 'Teams Pro',
73
- })
74
-
75
- /** Human labels for the panel's 说明 list, keyed by query kind. */
76
- const KIND_LABELS = Object.freeze({
77
- commandcode: 'Command Code 用量(窗口 / 信用)',
78
- deepseek: 'DeepSeek 官方余额',
79
- 'deepseek-account': 'DeepSeek 账户钱包余额',
80
- 'opencode-go': 'OpenCode Go 订阅用量',
81
- openrouter: 'OpenRouter 额度',
82
- moonshot: 'Moonshot / Kimi 余额',
83
- siliconflow: 'SiliconFlow 余额',
84
- newapi: 'New API 兼容额度',
85
- })
86
-
87
- /** Reference names to fall back on when a setup declares no `apiKeyEnv`. */
88
- const PROVIDER_KEY_REFS = Object.freeze({
89
- opencode: 'OPENCODE_API_KEY',
90
- 'opencode-go': 'OPENCODE_API_KEY',
91
- moonshotai: 'MOONSHOT_API_KEY',
92
- 'moonshotai-cn': 'MOONSHOT_API_KEY',
93
- 'kimi-coding': 'KIMI_API_KEY',
94
- })
95
-
96
- /** Normalize a configured base URL: trim and strip trailing slashes. */
97
- function baseUrlOf(raw) {
98
- return typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
99
- }
100
-
101
- /**
102
- * Path part of a configured base URL, lowercased — the same host can serve
103
- * several products (`opencode.ai/zen` vs `opencode.ai/zen/go`), so the path is
104
- * what separates a queryable subscription from one with no usage API at all.
105
- * @param raw - configured `baseURL`.
106
- * @returns the pathname (starting with `/`), or `""`.
107
- */
108
- function pathOf(raw) {
109
- const base = baseUrlOf(raw)
110
- if (base === '') return ''
111
- try {
112
- return new URL(base).pathname.toLowerCase()
113
- } catch {
114
- return ''
115
- }
116
- }
117
-
118
- /**
119
- * Reduce a configured base URL to its origin (`https://host[:port]`), which is
120
- * what the billing endpoints hang off — a provider base URL carries the
121
- * protocol path (`https://api.commandcode.ai/provider/v1`) that usage APIs do
122
- * not serve.
123
- * @param raw - configured `baseURL`.
124
- * @returns the origin, or null when the value is not an absolute http(s) URL.
125
- */
126
- function originOf(raw) {
127
- const base = baseUrlOf(raw)
128
- if (base === '') return null
129
- try {
130
- const url = new URL(base)
131
- if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
132
- return url.origin
133
- } catch {
134
- return null
135
- }
136
- }
137
-
138
- /** Lowercased hostname of an origin, or `""`. */
139
- function hostOf(origin) {
140
- if (origin === null) return ''
141
- try {
142
- return new URL(origin).hostname.toLowerCase()
143
- } catch {
144
- return ''
145
- }
146
- }
147
-
148
- /**
149
- * Derive the conventional credential reference for a provider route — the same
150
- * name the Models settings page stores a typed key under when the profile does
151
- * not declare `apiKeyEnv` (`commandcode` → `COMMANDCODE_API_KEY`).
152
- * @param provider - provider route id.
153
- * @returns the derived reference name.
154
- */
155
- function deriveKeyRef(provider) {
156
- return `${String(provider).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
157
- }
158
-
159
- /**
160
- * Read a nested value by path, tolerating every intermediate being absent.
161
- * @param root - container value (a settings namespace `value`, usually).
162
- * @param path - key path such as `['providers', 'commandcode']`.
163
- * @returns the value at the path, or undefined.
164
- */
165
- function atPath(root, path) {
166
- let node = root
167
- for (const key of Array.isArray(path) ? path : []) {
168
- if (node === null || typeof node !== 'object') return undefined
169
- node = node[key]
170
- }
171
- return node
172
- }
173
-
174
- /**
175
- * Apply the cc-switch New API extractor to a `/api/user/self` response body.
176
- * @param body - parsed JSON response.
177
- * @returns the normalized balance, or a readable failure.
178
- */
179
- function extractNewApi(body) {
180
- if (body === null || typeof body !== 'object' || body.success !== true || body.data == null) {
181
- return { ok: false, message: typeof body?.message === 'string' && body.message !== '' ? body.message : '查询失败' }
182
- }
183
- const quota = Number(body.data.quota)
184
- const used = Number(body.data.used_quota)
185
- if (!Number.isFinite(quota) || !Number.isFinite(used)) {
186
- return { ok: false, message: '返回数据缺少 quota / used_quota 字段' }
187
- }
188
- return {
189
- ok: true,
190
- value: {
191
- planName: typeof body.data.group === 'string' && body.data.group !== '' ? body.data.group : '默认套餐',
192
- remaining: quota / NEW_API_QUOTA_SCALE,
193
- used: used / NEW_API_QUOTA_SCALE,
194
- total: (quota + used) / NEW_API_QUOTA_SCALE,
195
- unit: 'USD',
196
- },
197
- }
198
- }
199
-
200
- /**
201
- * Read a Command Code login file (never logged) — the fallback source for the
202
- * key when the provider's credential reference is not configured.
203
- * @returns the parsed file, or undefined.
204
- */
205
- function readCommandCodeAuthFile() {
206
- // auth.json lives in the OS user home (`command-code login` writes it there),
207
- // never under DSH_HOME.
208
- const home = homedir()
209
- if (home === undefined || home === '') return undefined
210
- try {
211
- const raw = readFileSync(`${home.replace(/[\\/]+$/, '')}/${COMMAND_CODE_AUTH_FILE}`, 'utf8')
212
- return JSON.parse(raw)
213
- } catch {
214
- return undefined
215
- }
216
- }
217
-
218
- /**
219
- * Resolve one credential reference to its value plus a readable source name.
220
- * Resolution is per call, exactly like the LLM adapters: a key stored from the
221
- * Models page reaches the next query without a restart.
222
- * @param ctx - host context, with the optional `credentials` service.
223
- * @param ref - reference name (`COMMANDCODE_API_KEY`).
224
- * @param provider - provider route, used for the provider-specific fallbacks.
225
- * @returns `{ value, source }`, or undefined while unconfigured.
226
- */
227
- async function resolveKey(ctx, ref, provider) {
228
- try {
229
- const hit = await ctx.get('credentials')?.resolve?.(ref)
230
- if (hit !== undefined && hit.value !== '') {
231
- return { value: hit.value, source: `凭据 ${ref}(${hit.source})` }
232
- }
233
- } catch {
234
- // An unusable credential store falls through to the environment probes.
235
- }
236
- const fromEnv = process.env[ref]
237
- if (fromEnv !== undefined && fromEnv.trim() !== '') {
238
- return { value: fromEnv.trim(), source: `环境变量 ${ref}` }
239
- }
240
- if (provider === COMMAND_CODE_PROVIDER) {
241
- const legacy = process.env[COMMAND_CODE_LEGACY_ENV_KEY]
242
- if (legacy !== undefined && legacy.trim() !== '') {
243
- return { value: legacy.trim(), source: `环境变量 ${COMMAND_CODE_LEGACY_ENV_KEY}` }
244
- }
245
- const parsed = readCommandCodeAuthFile()
246
- if (typeof parsed?.apiKey === 'string' && parsed.apiKey !== '') {
247
- return {
248
- value: parsed.apiKey,
249
- source: `~/.commandcode/auth.json${typeof parsed.userName === 'string' ? `(${parsed.userName})` : ''}`,
250
- }
251
- }
252
- }
253
- return undefined
254
- }
255
-
256
- /**
257
- * Build a per-request provider-profile lookup: the credential reference,
258
- * endpoint, and display name the LLM adapter itself uses. The directory
259
- * supplies the settings address, so a hand-declared route in the profile
260
- * (`llm-pi-ai.providers.commandcode`) and a shipped one
261
- * (`llm-deepseek.apiKeyEnv`) both resolve the same way.
262
- *
263
- * `settings.describe()` projects every active plugin's configuration and is not
264
- * cheap, so it is read once per lookup and shared by every provider the request
265
- * touches.
266
- * @param ctx - host context carrying optional `llm` and `settings`.
267
- * @returns `(provider) => { displayName, apiKeyEnv, baseURL, api, headers }`,
268
- * with fields absent from the configuration left undefined.
269
- */
270
- function createProfileLookup(ctx) {
271
- let entries
272
- let descriptors
273
- let loaded = false
274
- const load = () => {
275
- if (loaded) return
276
- loaded = true
277
- try {
278
- const rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
279
- entries = Array.isArray(rows) ? rows : []
280
- } catch {
281
- entries = []
282
- }
283
- try {
284
- const rows = ctx.get('settings')?.describe?.() ?? []
285
- descriptors = Array.isArray(rows) ? rows : []
286
- } catch {
287
- descriptors = []
288
- }
289
- }
290
- return (provider) => {
291
- const profile = { displayName: undefined, apiKeyEnv: undefined, baseURL: undefined, api: undefined, headers: undefined }
292
- load()
293
- const entry = entries.find((row) => row?.provider === provider)
294
- if (entry === undefined) return profile
295
- profile.displayName = typeof entry.displayName === 'string' && entry.displayName !== '' ? entry.displayName : undefined
296
- const namespace = descriptors.find((row) => row?.ns === entry.settingsNs)
297
- const configured = atPath(namespace?.value, entry.settingsPath ?? [])
298
- if (configured === null || typeof configured !== 'object') return profile
299
- if (typeof configured.displayName === 'string' && configured.displayName !== '') profile.displayName = configured.displayName
300
- if (typeof configured.apiKeyEnv === 'string' && configured.apiKeyEnv !== '') profile.apiKeyEnv = configured.apiKeyEnv
301
- if (typeof configured.baseURL === 'string' && configured.baseURL !== '') profile.baseURL = configured.baseURL
302
- if (typeof configured.api === 'string' && configured.api !== '') profile.api = configured.api
303
- if (configured.headers !== null && typeof configured.headers === 'object') profile.headers = configured.headers
304
- return profile
305
- }
306
- }
307
-
308
- /**
309
- * The credential reference a route resolves its key from: what the profile
310
- * declares, else the conventional name — with the official DeepSeek route
311
- * pinned to its own long-standing variable.
312
- * @param provider - provider route id.
313
- * @param profile - resolved provider profile.
314
- * @returns the reference name, or undefined when the route needs no API key.
315
- */
316
- function keyRefOf(provider, profile) {
317
- if (profile.apiKeyEnv !== undefined) return profile.apiKeyEnv
318
- if (provider === 'deepseek-account') return undefined
319
- if (DEEPSEEK_PROVIDERS.includes(provider)) return 'DEEPSEEK_API_KEY'
320
- if (PROVIDER_KEY_REFS[provider] !== undefined) return PROVIDER_KEY_REFS[provider]
321
- return deriveKeyRef(provider)
322
- }
323
-
324
- /**
325
- * Choose the usage API for one route. The configured endpoint wins over the
326
- * route name, so a renamed deployment still reaches the right service.
327
- *
328
- * `opencode.ai` serves two products on one host: Zen (pay-as-you-go, no usage
329
- * API at all) and Go/Go Plus (a subscription whose `/zen/go/v1/usage` endpoint
330
- * exists and validates the API key), so the path decides.
331
- * @param provider - provider route id.
332
- * @param profile - resolved provider profile.
333
- * @returns the query kind, or null when no usage API is known for the route.
334
- */
335
- function kindOf(provider, profile) {
336
- if (provider === 'deepseek-account') return 'deepseek-account'
337
- const origin = originOf(profile.baseURL)
338
- const host = hostOf(origin)
339
- const path = pathOf(profile.baseURL)
340
- if (host !== '' && (host === 'commandcode.ai' || host.endsWith('.commandcode.ai'))) return 'commandcode'
341
- if (host !== '' && (host === 'opencode.ai' || host.endsWith('.opencode.ai'))) {
342
- return path.includes('/zen/go') ? 'opencode-go' : null
343
- }
344
- if (host !== '' && (host === 'openrouter.ai' || host.endsWith('.openrouter.ai'))) return 'openrouter'
345
- if (host !== '' && (host.endsWith('moonshot.cn') || host.endsWith('moonshot.ai'))) return 'moonshot'
346
- if (host !== '' && (host.endsWith('siliconflow.cn') || host.endsWith('siliconflow.com'))) return 'siliconflow'
347
- if (host !== '' && (host === 'deepseek.com' || host.endsWith('.deepseek.com'))) return 'deepseek'
348
- if (provider === COMMAND_CODE_PROVIDER) return 'commandcode'
349
- if (provider === 'opencode-go') return 'opencode-go'
350
- if (provider === 'openrouter') return 'openrouter'
351
- if (provider === 'moonshotai' || provider === 'moonshotai-cn' || provider === 'kimi-coding') return 'moonshot'
352
- if (provider === 'siliconflow') return 'siliconflow'
353
- if (DEEPSEEK_PROVIDERS.includes(provider)) return 'deepseek'
354
- return origin !== null ? 'newapi' : null
355
- }
356
-
357
- /** Fetch one Command Code API endpoint with the shared bearer header. */
358
- async function commandCodeFetch(base, endpoint, key, signal) {
359
- const res = await fetch(`${base}${endpoint}`, {
360
- headers: {
361
- 'content-type': 'application/json',
362
- authorization: `Bearer ${key}`,
363
- },
364
- signal,
365
- })
366
- if (!res.ok) {
367
- throw new Error(`Command Code 接口返回 HTTP ${res.status}`)
368
- }
369
- return res.json()
370
- }
371
-
372
- /**
373
- * Build a query string skipping null / undefined / empty values — the API
374
- * rejects an empty `orgId` (personal accounts have no org), and the CLI's
375
- * own `buildUsageEndpoint` skips them the same way.
376
- * @param params - key → value map; falsy scalars are omitted.
377
- * @returns `?a=1&b=2` or `""`.
378
- */
379
- function queryOf(params) {
380
- const parts = []
381
- for (const [key, value] of Object.entries(params)) {
382
- if (value === null || value === undefined || value === '') continue
383
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
384
- }
385
- return parts.length > 0 ? `?${parts.join('&')}` : ''
386
- }
387
-
388
- /**
389
- * Query the Command Code private usage API — the same four calls the CLI's
390
- * `/usage` command makes. Returns the normalized usage view.
391
- * @param base - API origin.
392
- * @param key - the provider key the conversation itself is using.
393
- * @param signal - abort signal.
394
- */
395
- async function queryCommandCode(base, key, signal) {
396
- let whoami
397
- try {
398
- whoami = await commandCodeFetch(base, '/alpha/whoami?limits=1', key, signal)
399
- } catch (err) {
400
- return { ok: false, message: err instanceof Error ? err.message : String(err) }
401
- }
402
- const orgId = whoami?.org?.id ?? null
403
- let credits = null
404
- let subscription = null
405
- let summary = null
406
- try {
407
- ;[credits, subscription] = await Promise.all([
408
- commandCodeFetch(base, `/alpha/billing/credits${queryOf({ orgId })}`, key, signal),
409
- commandCodeFetch(base, `/alpha/billing/subscriptions${queryOf({ orgId })}`, key, signal),
410
- ])
411
- } catch (err) {
412
- return { ok: false, message: err instanceof Error ? err.message : String(err) }
413
- }
414
- const sub = subscription?.data ?? null
415
- const periodStart = typeof sub?.currentPeriodStart === 'string' ? sub.currentPeriodStart : null
416
- const periodEnd = typeof sub?.currentPeriodEnd === 'string' ? Date.parse(sub.currentPeriodEnd) : NaN
417
- if (periodStart !== null) {
418
- try {
419
- summary = await commandCodeFetch(base, `/alpha/usage/summary${queryOf({ orgId, since: periodStart })}`, key, signal)
420
- } catch {
421
- // Summary is best-effort; the windows and credits still render.
422
- summary = null
423
- }
424
- }
425
- const creditInfo = credits?.credits ?? null
426
- const monthly = Number(creditInfo?.monthlyCredits ?? 0)
427
- const purchased = Number(creditInfo?.purchasedCredits ?? 0)
428
- const free = Number(creditInfo?.freeCredits ?? 0)
429
- const totalCost = Number(summary?.totalCost ?? 0)
430
- const totalCount = Number(summary?.totalCount ?? 0)
431
- const planId = typeof sub?.planId === 'string' ? sub.planId : null
432
- // windowLimits sits at the credits response top level, beside `credits`.
433
- const windows = credits?.windowLimits ?? null
434
- const account = typeof whoami?.user?.userName === 'string' && whoami.user.userName !== ''
435
- ? whoami.user.userName
436
- : typeof whoami?.user?.name === 'string' ? whoami.user.name : null
437
- return {
438
- ok: true,
439
- value: {
440
- planName: planId !== null && COMMAND_CODE_PLANS[planId] !== undefined ? COMMAND_CODE_PLANS[planId] : (planId ?? '未知套餐'),
441
- account,
442
- status: typeof sub?.status === 'string' ? sub.status : null,
443
- cycleEnd: Number.isFinite(periodEnd) ? periodEnd : null,
444
- credits: {
445
- monthlyRemaining: monthly,
446
- purchasedRemaining: purchased,
447
- freeRemaining: free,
448
- totalRemaining: monthly + purchased + free,
449
- totalSpent: totalCost,
450
- totalCount,
451
- },
452
- windows: windows === null ? null : {
453
- limited: windows.limited === true,
454
- fiveHour: windows.fiveHour != null ? {
455
- used: Number(windows.fiveHour.used ?? 0),
456
- cap: Number(windows.fiveHour.cap ?? 0),
457
- resetAt: Number(windows.fiveHour.resetAt ?? 0),
458
- } : null,
459
- weekly: windows.weekly != null ? {
460
- used: Number(windows.weekly.used ?? 0),
461
- cap: Number(windows.weekly.cap ?? 0),
462
- resetAt: Number(windows.weekly.resetAt ?? 0),
463
- } : null,
464
- },
465
- now: Date.now(),
466
- usageUrl: `${COMMAND_CODE_WEB_BASE}/usage`,
467
- },
468
- }
469
- }
470
-
471
- /**
472
- * Query the official DeepSeek balance endpoint with the route's own key.
473
- * @param url - balance endpoint (the configured origin when it is DeepSeek's).
474
- * @param key - the key the conversation is using.
475
- * @param signal - abort signal.
476
- */
477
- async function queryDeepSeek(url, key, signal) {
478
- const res = await fetch(url, {
479
- headers: { Authorization: `Bearer ${key}` },
480
- signal,
481
- })
482
- if (!res.ok) {
483
- return { ok: false, message: `DeepSeek 余额接口返回 HTTP ${res.status}` }
484
- }
485
- return { ok: true, value: await res.json() }
486
- }
487
-
488
- /**
489
- * Query a New API deployment's `/api/user/self` (best effort for any other
490
- * OpenAI-compatible route), carrying the route's configured headers.
491
- * @param origin - the route's origin.
492
- * @param key - the key the conversation is using.
493
- * @param headers - extra headers the profile declares.
494
- * @param signal - abort signal.
495
- */
496
- async function queryNewApi(origin, key, headers, signal) {
497
- const res = await fetch(`${origin}/api/user/self`, {
498
- method: 'GET',
499
- headers: {
500
- 'Content-Type': 'application/json',
501
- Authorization: `Bearer ${key}`,
502
- 'User-Agent': NEW_API_USER_AGENT,
503
- ...(headers ?? {}),
504
- },
505
- signal,
506
- })
507
- if (!res.ok) {
508
- return { ok: false, message: `New API 余额接口返回 HTTP ${res.status}` }
509
- }
510
- const extracted = extractNewApi(await res.json())
511
- return extracted.ok ? { ok: true, value: extracted.value } : { ok: false, message: extracted.message }
512
- }
513
-
514
- // ---------------------------------------------------------------------------
515
- // Per-provider usage adapters. Each one answers the same question in the
516
- // provider's own vocabulary and normalizes to the panel's two building blocks:
517
- // `windows` (used / limit / resetsAt meters) and `rows` (label → value lines).
518
- // Whatever the endpoint actually returned rides along as `raw`, so an endpoint
519
- // whose shape drifts still shows something readable — and reportable.
520
-
521
- /** Parse a numeric field that may arrive as a number or a numeric string. */
522
- function numberOf(value) {
523
- if (typeof value === 'number') return Number.isFinite(value) ? value : null
524
- if (typeof value === 'string' && value.trim() !== '') {
525
- const parsed = Number(value)
526
- return Number.isFinite(parsed) ? parsed : null
527
- }
528
- return null
529
- }
530
-
531
- /** Parse a reset instant that may arrive as epoch milliseconds or an ISO string. */
532
- function dateOf(value) {
533
- const numeric = numberOf(value)
534
- if (numeric !== null) return numeric < 1e11 ? Math.round(numeric * 1000) : numeric
535
- if (typeof value === 'string') {
536
- const parsed = Date.parse(value)
537
- return Number.isFinite(parsed) ? parsed : 0
538
- }
539
- return 0
540
- }
541
-
542
- /** Round a money value for display without inventing precision. */
543
- function moneyOf(value) {
544
- const numeric = numberOf(value)
545
- return numeric === null ? null : numeric
546
- }
547
-
548
- /** Take the first present value among several candidate field names. */
549
- function firstOf(node, keys) {
550
- for (const key of keys) {
551
- if (node[key] !== undefined && node[key] !== null) return node[key]
552
- }
553
- return undefined
554
- }
555
-
556
- /** Fetch one JSON endpoint with the provider's own key. */
557
- async function fetchUsageJson(url, key, headers, signal, label) {
558
- const res = await fetch(url, {
559
- headers: {
560
- accept: 'application/json',
561
- authorization: `Bearer ${key}`,
562
- ...(headers ?? {}),
563
- },
564
- signal,
565
- })
566
- if (!res.ok) {
567
- return { ok: false, message: `${label} 接口返回 HTTP ${res.status}` }
568
- }
569
- try {
570
- return { ok: true, value: await res.json() }
571
- } catch {
572
- return { ok: false, message: `${label} 返回的不是 JSON` }
573
- }
574
- }
575
-
576
- /** One window meter from a node that names its own used / limit / reset. */
577
- function windowOf(label, node) {
578
- if (node === null || typeof node !== 'object') return null
579
- const used = moneyOf(firstOf(node, ['used', 'usage', 'spent', 'cost', 'usedUsd', 'used_usd']))
580
- const limit = moneyOf(firstOf(node, ['limit', 'cap', 'quota', 'max', 'total', 'monthlyLimit', 'monthly_limit']))
581
- if (used === null && limit === null) return null
582
- return {
583
- label,
584
- used: used ?? 0,
585
- limit: limit ?? 0,
586
- resetsAt: dateOf(firstOf(node, ['resetsAt', 'resetAt', 'reset', 'resets_at', 'reset_at', 'renewsAt', 'periodEnd'])),
587
- }
588
- }
589
-
590
- /**
591
- * Normalize the OpenCode Go usage payload. Its exact shape is not documented
592
- * (verified only as far as `401` without a key), so the candidate field names
593
- * cover the plausible spellings and anything unrecognized stays visible in
594
- * `raw`.
595
- */
596
- function normalizeOpenCodeGo(body) {
597
- const root = body !== null && typeof body === 'object' ? (body.data ?? body) : {}
598
- const windows = []
599
- const windowsNode = root.windows ?? root.limits ?? root.usageWindows ?? root.usage_windows ?? root
600
- for (const [keys, label] of [
601
- [['fiveHour', 'five_hour', 'fivehour', '5h', 'five_hours'], '5 小时窗口'],
602
- [['weekly', 'week', '7d', 'sevenDay', 'seven_day'], '7 天窗口'],
603
- [['monthly', 'month', 'monthlyUsage', 'monthly_usage', '30d'], '月额度'],
604
- ]) {
605
- const node = firstOf(windowsNode, keys)
606
- const window = windowOf(label, node)
607
- if (window !== null) windows.push(window)
608
- }
609
- const rows = []
610
- const plan = firstOf(root, ['plan', 'planName', 'plan_name', 'tier', 'subscription'])
611
- if (typeof plan === 'string' && plan !== '') rows.push({ label: '套餐', value: plan })
612
- const status = firstOf(root, ['status', 'state'])
613
- if (typeof status === 'string' && status !== '') rows.push({ label: '状态', value: status })
614
- const used = moneyOf(firstOf(root, ['used', 'usage', 'spent', 'cost', 'totalUsed', 'total_used']))
615
- if (used !== null) rows.push({ label: '已用', value: `$${used.toFixed(2)}` })
616
- const limit = moneyOf(firstOf(root, ['limit', 'cap', 'quota', 'totalLimit', 'total_limit']))
617
- if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
618
- const balance = moneyOf(firstOf(root, ['balance', 'credit', 'credits', 'remaining']))
619
- if (balance !== null) rows.push({ label: '余额', value: `$${balance.toFixed(2)}` })
620
- // A per-model breakdown, if this endpoint carries one.
621
- const modelsNode = firstOf(root, ['models', 'byModel', 'by_model', 'perModel', 'per_model'])
622
- const models = []
623
- if (Array.isArray(modelsNode)) {
624
- for (const entry of modelsNode) {
625
- if (entry === null || typeof entry !== 'object') continue
626
- const id = firstOf(entry, ['model', 'id', 'name', 'modelId', 'model_id'])
627
- const entryUsed = moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost']))
628
- const entryLimit = moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max']))
629
- if (id === undefined && entryUsed === null) continue
630
- models.push({ model: String(id ?? '未知模型'), used: entryUsed, limit: entryLimit })
631
- }
632
- } else if (modelsNode !== null && typeof modelsNode === 'object') {
633
- for (const [id, entry] of Object.entries(modelsNode)) {
634
- if (entry === null || typeof entry !== 'object') continue
635
- models.push({
636
- model: id,
637
- used: moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost'])),
638
- limit: moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max'])),
639
- })
640
- }
641
- }
642
- return { windows, models, rows, raw: body }
643
- }
644
-
645
- /** OpenCode Go / Go Plus subscription usage (`/zen/go/v1/usage`). */
646
- async function queryOpenCodeGo(origin, key, headers, signal) {
647
- const res = await fetchUsageJson(`${origin}/zen/go/v1/usage`, key, headers, signal, 'OpenCode Go')
648
- if (!res.ok) return res
649
- return { ok: true, value: normalizeOpenCodeGo(res.value) }
650
- }
651
-
652
- /** OpenRouter credits: `/api/v1/key` carries the limit, `/api/v1/credits` the pool. */
653
- async function queryOpenRouter(origin, key, headers, signal) {
654
- const res = await fetchUsageJson(`${origin}/api/v1/key`, key, headers, signal, 'OpenRouter')
655
- if (!res.ok) return res
656
- const data = res.value?.data ?? {}
657
- const usage = moneyOf(data.usage) ?? 0
658
- const limit = moneyOf(data.limit)
659
- const remaining = moneyOf(data.limit_remaining)
660
- const rows = [{ label: '已用', value: `$${usage.toFixed(2)}` }]
661
- if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
662
- rows.push({ label: '剩余', value: `$${(remaining ?? (limit === null ? 0 : Math.max(0, limit - usage))).toFixed(2)}` })
663
- if (typeof data.label === 'string' && data.label !== '') rows.push({ label: 'Key', value: data.label })
664
- if (data.is_free_tier === true) rows.push({ label: '套餐', value: '免费层' })
665
- const raw = { key: res.value }
666
- if (limit === null) {
667
- // A key without its own limit spends from the account credit pool.
668
- const credits = await fetchUsageJson(`${origin}/api/v1/credits`, key, headers, signal, 'OpenRouter')
669
- if (credits.ok) {
670
- const total = moneyOf(credits.value?.data?.total_credits)
671
- const spent = moneyOf(credits.value?.data?.total_usage)
672
- if (total !== null) rows.push({ label: '账户总额度', value: `$${total.toFixed(2)}` })
673
- if (spent !== null) rows.push({ label: '账户已用', value: `$${spent.toFixed(2)}` })
674
- if (total !== null && spent !== null) rows.push({ label: '账户剩余', value: `$${Math.max(0, total - spent).toFixed(2)}` })
675
- raw.credits = credits.value
676
- }
677
- }
678
- return { ok: true, value: { rows, raw } }
679
- }
680
-
681
- /** Moonshot / Kimi balance: `GET /v1/users/me/balance`. */
682
- async function queryMoonshot(origin, key, headers, signal) {
683
- const res = await fetchUsageJson(`${origin}/v1/users/me/balance`, key, headers, signal, 'Moonshot')
684
- if (!res.ok) return res
685
- const data = res.value?.data ?? {}
686
- const rows = []
687
- const available = moneyOf(data.available_balance ?? data.availableBalance)
688
- const voucher = moneyOf(data.voucher_balance ?? data.voucherBalance)
689
- const cash = moneyOf(data.cash_balance ?? data.cashBalance)
690
- if (available !== null) rows.push({ label: '可用余额', value: `¥${available.toFixed(2)}` })
691
- if (cash !== null) rows.push({ label: '现金余额', value: `¥${cash.toFixed(2)}` })
692
- if (voucher !== null) rows.push({ label: '代金券', value: `¥${voucher.toFixed(2)}` })
693
- if (rows.length === 0) return { ok: false, message: 'Moonshot 接口未返回可识别的余额字段' }
694
- return { ok: true, value: { rows, raw: res.value } }
695
- }
696
-
697
- /** SiliconFlow balance: `GET /v1/user/info`. */
698
- async function querySiliconFlow(origin, key, headers, signal) {
699
- const res = await fetchUsageJson(`${origin}/v1/user/info`, key, headers, signal, 'SiliconFlow')
700
- if (!res.ok) return res
701
- const data = res.value?.data ?? {}
702
- const rows = []
703
- const total = moneyOf(data.totalBalance ?? data.total_balance)
704
- const charge = moneyOf(data.chargeBalance ?? data.charge_balance)
705
- const gift = moneyOf(data.balance)
706
- if (total !== null) rows.push({ label: '总余额', value: `¥${total.toFixed(2)}` })
707
- if (charge !== null) rows.push({ label: '充值余额', value: `¥${charge.toFixed(2)}` })
708
- if (gift !== null) rows.push({ label: '赠送余额', value: `¥${gift.toFixed(2)}` })
709
- if (typeof data.name === 'string' && data.name !== '') rows.push({ label: '账户', value: data.name })
710
- if (rows.length === 0) return { ok: false, message: 'SiliconFlow 接口未返回可识别的余额字段' }
711
- return { ok: true, value: { rows, raw: res.value } }
712
- }
713
-
714
- /**
715
- * Read the signed-in DeepSeek platform wallet through the account service —
716
- * the `deepseek-account` route holds no API key at all.
717
- * @param ctx - host context carrying optional `deepseekAccount`.
718
- * @returns the normalized wallet view, or a readable failure.
719
- */
720
- async function queryDeepSeekAccount(ctx) {
721
- const account = ctx.get('deepseekAccount')
722
- if (account === undefined) {
723
- return { ok: false, message: '当前部署没有挂载 deepseek-account 服务,无法读取账户钱包' }
724
- }
725
- const client = {
726
- version: '1.0.0',
727
- locale: 'zh-CN',
728
- timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
729
- }
730
- const balance = await account.getBalance(client)
731
- if (balance === null || balance === undefined) {
732
- return { ok: false, message: '尚未登录 DeepSeek 账户(设置 → 账户里登录后再试)' }
733
- }
734
- if (balance.status !== 'ready') {
735
- return { ok: false, message: 'DeepSeek 账户余额查询失败' }
736
- }
737
- const wallets = [...(balance.value ?? []), ...(balance.bonusWallets ?? [])]
738
- .map((wallet) => ({ currency: wallet.currency, balance: wallet.balance }))
739
- return { ok: true, value: { wallets } }
740
- }
741
-
742
- /**
743
- * The model selection the current conversation runs on: the browser's copy of
744
- * the Session's own projection first, then this Host's live projection, then
745
- * the deployment default.
746
- * @param ctx - host context carrying optional session services.
747
- * @param payload - browser payload; `provider` + `model` win when both exist.
748
- * @returns `{ provider, model, reasoningEffort? }`, or null while unknown.
749
- */
750
- function resolveSelection(ctx, payload) {
751
- if (typeof payload?.provider === 'string' && payload.provider !== ''
752
- && typeof payload?.model === 'string' && payload.model !== '') {
753
- return {
754
- provider: payload.provider,
755
- model: payload.model,
756
- ...(typeof payload?.reasoningEffort === 'string' ? { reasoningEffort: payload.reasoningEffort } : {}),
757
- }
758
- }
759
- try {
760
- const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
761
- const session = sessionId === '' ? undefined : ctx.get('sessions')?.get?.(sessionId)
762
- const state = session === undefined ? undefined : ctx.get('sessionProjections')?.stateOf?.(session, 'modelSelection')
763
- const selected = state?.pending ?? state?.lastUsed
764
- if (selected !== null && selected !== undefined && typeof selected.provider === 'string') return selected
765
- } catch {
766
- // A missing or cold Session simply falls through to the deployment default.
767
- }
768
- const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
769
- return fallback !== null && fallback !== undefined && typeof fallback.provider === 'string' ? fallback : null
770
- }
771
-
772
- /**
773
- * Run the usage query that belongs to one conversation model.
774
- * @param ctx - host context.
775
- * @param signal - abort signal for every outbound call.
776
- * @param payload - browser payload (model selection and/or session id).
777
- * @returns the normalized `{ ok, value | error }` envelope.
778
- */
779
- async function queryUsage(ctx, signal, payload) {
780
- const selection = resolveSelection(ctx, payload)
781
- if (selection === null) {
782
- return {
783
- ok: false,
784
- error: {
785
- code: 'internal',
786
- message: '无法确定当前对话使用的模型:会话还没有过模型选择,请先在对话里选一次模型',
787
- details: {},
788
- },
789
- }
790
- }
791
- const { provider } = selection
792
- const profileOf = createProfileLookup(ctx)
793
- const profile = profileOf(provider)
794
- const kind = kindOf(provider, profile)
795
- const providerName = profile.displayName ?? provider
796
- const shared = { provider, providerName, model: selection.model, reasoningEffort: selection.reasoningEffort }
797
- if (kind === null) {
798
- const configured = originOf(profile.baseURL) !== null
799
- return {
800
- ok: false,
801
- error: {
802
- code: 'internal',
803
- message: configured
804
- ? `供应商「${providerName}」没有已适配的用量接口:${profile.baseURL} 不对应任何已知的用量 API`
805
- : `供应商「${providerName}」既没有配置 baseURL,也不是已知的用量接口,无法查询用量`,
806
- details: {},
807
- },
808
- }
809
- }
810
- const ref = keyRefOf(provider, profile)
811
- let key
812
- if (kind !== 'deepseek-account') {
813
- if (ref === undefined) {
814
- return {
815
- ok: false,
816
- error: { code: 'internal', message: `供应商「${providerName}」没有可用的凭据引用`, details: {} },
817
- }
818
- }
819
- const resolved = await resolveKey(ctx, ref, provider)
820
- if (resolved === undefined) {
821
- return {
822
- ok: false,
823
- error: {
824
- code: 'internal',
825
- message: `未找到供应商「${providerName}」的 API Key:当前对话用的是 ${ref},请在 设置 → 模型 里填写`,
826
- details: {},
827
- },
828
- }
829
- }
830
- key = resolved
831
- }
832
- const credential = ref === undefined
833
- ? null
834
- : { ref, source: key?.source ?? null }
835
- let result
836
- const deadline = withTimeout(signal, QUERY_TIMEOUT_MS)
837
- try {
838
- if (kind === 'commandcode') {
839
- result = await queryCommandCode(originOf(profile.baseURL) ?? COMMAND_CODE_BASE, key.value, deadline)
840
- } else if (kind === 'deepseek') {
841
- const origin = originOf(profile.baseURL)
842
- const host = hostOf(origin)
843
- const url = host === 'deepseek.com' || host.endsWith('.deepseek.com')
844
- ? `${origin}/user/balance`
845
- : DEEPSEEK_BALANCE_URL
846
- result = await queryDeepSeek(url, key.value, deadline)
847
- } else if (kind === 'deepseek-account') {
848
- result = await queryDeepSeekAccount(ctx)
849
- } else if (kind === 'opencode-go') {
850
- result = await queryOpenCodeGo(originOf(profile.baseURL), key.value, profile.headers, deadline)
851
- } else if (kind === 'openrouter') {
852
- result = await queryOpenRouter(originOf(profile.baseURL), key.value, profile.headers, deadline)
853
- } else if (kind === 'moonshot') {
854
- result = await queryMoonshot(originOf(profile.baseURL), key.value, profile.headers, deadline)
855
- } else if (kind === 'siliconflow') {
856
- result = await querySiliconFlow(originOf(profile.baseURL), key.value, profile.headers, deadline)
857
- } else {
858
- result = await queryNewApi(originOf(profile.baseURL), key.value, profile.headers, deadline)
859
- }
860
- } catch (err) {
861
- return {
862
- ok: false,
863
- error: { code: 'internal', message: queryFailureMessage(err), details: {} },
864
- }
865
- }
866
- if (!result.ok) {
867
- return { ok: false, error: { code: 'internal', message: result.message, details: {} } }
868
- }
869
- return { ok: true, value: { mode: kind, ...shared, credential, data: result.value } }
870
- }
871
-
872
- /**
873
- * The read-only provider directory for the panel's 说明 dialog: which routes
874
- * exist, which credential reference each resolves, and whether that reference
875
- * is configured. No value ever crosses this boundary.
876
- * @param ctx - host context.
877
- * @returns one row per declared provider route.
878
- */
879
- async function providerRows(ctx) {
880
- const profileOf = createProfileLookup(ctx)
881
- let rows = []
882
- try {
883
- rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
884
- } catch {
885
- rows = []
886
- }
887
- if (!Array.isArray(rows)) return []
888
- const out = []
889
- for (const row of rows) {
890
- if (row === null || typeof row !== 'object' || typeof row.provider !== 'string') continue
891
- const profile = profileOf(row.provider)
892
- const ref = keyRefOf(row.provider, profile)
893
- const kind = kindOf(row.provider, profile)
894
- let configured = false
895
- let source = null
896
- if (ref !== undefined) {
897
- try {
898
- const info = await ctx.get('credentials')?.describe?.(ref)
899
- configured = info?.configured === true
900
- source = typeof info?.source === 'string' ? info.source : null
901
- } catch {
902
- configured = false
903
- }
904
- } else {
905
- configured = true
906
- source = '账户登录'
907
- }
908
- out.push({
909
- provider: row.provider,
910
- displayName: profile.displayName ?? row.displayName ?? row.provider,
911
- kind,
912
- kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
913
- baseUrl: profile.baseURL ?? null,
914
- credentialRef: ref ?? null,
915
- configured,
916
- source,
917
- })
918
- }
919
- return out
920
- }
921
-
922
- /**
923
- * Bound one outbound call. The panel now refreshes on a timer, so a provider
924
- * that never answers must not hold the one in-flight slot forever — the caller
925
- * aborts on close, and this adds a deadline of its own.
926
- * @param signal - caller cancellation, when there is one.
927
- * @param ms - deadline in milliseconds.
928
- * @returns a signal that aborts on either cause.
929
- */
930
- function withTimeout(signal, ms) {
931
- if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return signal
932
- const deadline = AbortSignal.timeout(ms)
933
- if (signal === undefined || typeof AbortSignal.any !== 'function') return deadline
934
- return AbortSignal.any([signal, deadline])
935
- }
936
-
937
- /** Turn one adapter failure into a readable message; a deadline reads as a timeout. */
938
- function queryFailureMessage(err) {
939
- if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
940
- return `用量接口超时(${Math.round(QUERY_TIMEOUT_MS / 1000)} 秒未返回)`
941
- }
942
- return err instanceof Error ? err.message : String(err)
943
- }
944
-
945
- /**
946
- * The routable model catalog, annotated with the usage API each provider route
947
- * resolves — the data behind the panel's manual model picker. Providers come
948
- * from the same catalog the model switch uses, so a manual choice is always a
949
- * route the deployment can actually serve.
950
- * @param ctx - host context carrying optional `sessionController`.
951
- * @returns `{ groups, default }`, each group `{ provider, name, kind, kindLabel, models }`.
952
- */
953
- async function modelCatalogRows(ctx) {
954
- let catalog
955
- try {
956
- catalog = await ctx.get('sessionController')?.modelCatalog?.()
957
- } catch {
958
- catalog = undefined
959
- }
960
- const profileOf = createProfileLookup(ctx)
961
- const groups = []
962
- for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
963
- if (group === null || typeof group !== 'object' || typeof group.id !== 'string' || group.id === '') continue
964
- const kind = kindOf(group.id, profileOf(group.id))
965
- groups.push({
966
- provider: group.id,
967
- name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
968
- kind,
969
- kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
970
- models: (Array.isArray(group.models) ? group.models : []).flatMap((model) => (
971
- model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== ''
972
- ? [{ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id }]
973
- : []
974
- )),
975
- })
976
- }
977
- const selected = catalog?.default
978
- return {
979
- groups,
980
- default: selected !== null && typeof selected === 'object' && typeof selected.provider === 'string'
981
- ? { provider: selected.provider, model: typeof selected.model === 'string' ? selected.model : '' }
982
- : null,
983
- }
984
- }
985
-
986
- /** Read the entirety of a node:http request body as a UTF-8 string. */
987
- async function readBody(req) {
988
- const chunks = []
989
- for await (const chunk of req) chunks.push(chunk)
990
- return Buffer.concat(chunks).toString('utf8')
991
- }
992
-
993
- /** Write a JSON response with a normalized `{ ok, value | error }` envelope. */
994
- function writeJson(res, statusCode, value) {
995
- res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' })
996
- res.end(JSON.stringify(value))
997
- }
998
-
999
- /**
1000
- * Apply the composition's own browser-trust fence to one request. `node:http`
1001
- * routes are unauthenticated by construction (the web server has no global
1002
- * policy — every route owner decides), so each of ours asks the connection
1003
- * service first and answers its 401/403 itself when the request is not from an
1004
- * accepted authority. A deployment without the service keeps the plugin usable.
1005
- * @param ctx - host context carrying the optional `connection` service.
1006
- * @param req - the incoming request.
1007
- * @param res - the response to answer on when the request is rejected.
1008
- * @returns true when the request was already rejected and answered.
1009
- */
1010
- function rejected(ctx, req, res) {
1011
- let rejection
1012
- try {
1013
- rejection = ctx.get('connection')?.requestRejection?.(req)
1014
- } catch {
1015
- rejection = undefined
1016
- }
1017
- if (rejection === undefined) return false
1018
- res.statusCode = rejection
1019
- res.end()
1020
- return true
1021
- }
1022
-
1023
- /**
1024
- * Register the /model-usage/* exact Web routes: the usage query for one
1025
- * conversation model, the read-only provider directory, and the routable model
1026
- * catalog behind the manual picker.
1027
- * @param ctx - host context with webServer, credentials, and the session/LLM
1028
- * services read through `ctx.get()`.
1029
- */
1030
- export function apply(ctx) {
1031
- ctx.effect(() => ctx.webServer.register({
1032
- kind: 'exact',
1033
- path: '/model-usage/balance',
1034
- handler: async (req, res) => {
1035
- if (rejected(ctx, req, res)) return
1036
- const controller = new AbortController()
1037
- res.once('close', () => controller.abort())
1038
- try {
1039
- const payload = req.method === 'POST'
1040
- ? JSON.parse((await readBody(req)) || '{}')
1041
- : {}
1042
- writeJson(res, 200, await queryUsage(ctx, controller.signal, payload))
1043
- } catch (err) {
1044
- writeJson(res, 200, {
1045
- ok: false,
1046
- error: { code: 'internal', message: `查询失败:${err instanceof Error ? err.message : String(err)}`, details: {} },
1047
- })
1048
- }
1049
- },
1050
- }), 'model-usage: /model-usage/balance')
1051
- // Read-only provider/key directory (never a key value), for the 说明 dialog.
1052
- ctx.effect(() => ctx.webServer.register({
1053
- kind: 'exact',
1054
- path: '/model-usage/providers',
1055
- handler: async (req, res) => {
1056
- if (rejected(ctx, req, res)) return
1057
- try {
1058
- writeJson(res, 200, { ok: true, value: await providerRows(ctx) })
1059
- } catch (err) {
1060
- writeJson(res, 200, {
1061
- ok: false,
1062
- error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
1063
- })
1064
- }
1065
- },
1066
- }), 'model-usage: /model-usage/providers')
1067
- // Routable models (with the usage API each route resolves) for the manual picker.
1068
- ctx.effect(() => ctx.webServer.register({
1069
- kind: 'exact',
1070
- path: '/model-usage/models',
1071
- handler: async (req, res) => {
1072
- if (rejected(ctx, req, res)) return
1073
- try {
1074
- writeJson(res, 200, { ok: true, value: await modelCatalogRows(ctx) })
1075
- } catch (err) {
1076
- writeJson(res, 200, {
1077
- ok: false,
1078
- error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
1079
- })
1080
- }
1081
- },
1082
- }), 'model-usage: /model-usage/models')
1083
- }
1
+ // Node half of dsh-model-usage: hosts the usage query for the model the CURRENT
2
+ // CONVERSATION uses and exposes it to the browser half over loopback-only Web
3
+ // routes. The model route is resolved from the Session's `modelSelection`
4
+ // projection (the same fact the model picker shows), the provider's own
5
+ // credential reference is read from the live `llm-pi-ai` / `llm-deepseek`
6
+ // configuration, and the matching usage API is called with that very key.
7
+ // Secrets never leave the host process — the browser only ever receives the
8
+ // normalized payload plus the credential REFERENCE NAME (never a value).
9
+ //
10
+ // Query kinds, chosen from the resolved provider route and its configured
11
+ // `baseURL`:
12
+ // - commandcode: the Command Code private usage API (default origin
13
+ // https://api.commandcode.ai): `/alpha/whoami?limits=1`,
14
+ // `/alpha/billing/credits`, `/alpha/billing/subscriptions`,
15
+ // `/alpha/usage/summary?since=`. This is exactly what `cmd /usage` renders.
16
+ // - deepseek: the official `GET https://api.deepseek.com/user/balance`.
17
+ // - deepseek-account: the signed-in DeepSeek platform wallet, read through
18
+ // `ctx.deepseekAccount` (no API key involved).
19
+ // - opencode-go: the OpenCode Go / Go Plus subscription allowances at
20
+ // `GET {origin}/zen/go/v1/usage` (undocumented; normalized defensively).
21
+ // - openrouter: `GET {origin}/api/v1/key`, plus `/api/v1/credits` when the key
22
+ // carries no limit of its own.
23
+ // - moonshot: `GET {origin}/v1/users/me/balance`.
24
+ // - siliconflow: `GET {origin}/v1/user/info`.
25
+ // - newapi: any other configured OpenAI-compatible route — best effort against
26
+ // the cc-switch New API convention `GET {origin}/api/user/self`.
27
+ //
28
+ // The routes follow the webServer contract (like the official webhook
29
+ // plugins): `ctx.effect(() => ctx.webServer.register({ kind: 'exact', ... }))`.
30
+ // node:http routes carry no global authentication, so every one of ours asks
31
+ // the composition's connection service for a rejection first and answers its
32
+ // 401/403 itself — the same fence the shipped browser-facing routes apply.
33
+ // No Config schema is declared: the plugin loads through a profile junction or
34
+ // an npm install, so everything is node builtins and validation happens at
35
+ // query time (fail loud in the panel).
36
+ // Only `webServer` is a hard dependency; `connection`, `credentials`, `llm`,
37
+ // `settings`, `sessions`, `sessionProjections`, `sessionController`,
38
+ // `agentDefaultModel` and `deepseekAccount` are read through `ctx.get()` on
39
+ // every use: the plugin still mounts — and says what is missing — in a
40
+ // composition that provides fewer services, and a service that activates later
41
+ // is picked up by the next query.
42
+ import { readFileSync } from 'node:fs'
43
+ import { homedir } from 'node:os'
44
+
45
+ export const inject = ['webServer']
46
+
47
+ const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance'
48
+ /** Provider routes that authenticate with the official DeepSeek API key. */
49
+ const DEEPSEEK_PROVIDERS = Object.freeze(['deepseek-official', 'deepseek'])
50
+ const NEW_API_USER_AGENT = 'cc-switch/1.0'
51
+ const NEW_API_QUOTA_SCALE = 500000
52
+ /** Deadline for one usage API call; a wedged provider must not block the next refresh. */
53
+ const QUERY_TIMEOUT_MS = 30000
54
+
55
+ const COMMAND_CODE_PROVIDER = 'commandcode'
56
+ const COMMAND_CODE_BASE = 'https://api.commandcode.ai'
57
+ /** Web host the human-facing usage page lives on (the API host is `api.`). */
58
+ const COMMAND_CODE_WEB_BASE = 'https://commandcode.ai'
59
+ /** Env var older builds of this plugin (and the cc tooling) used. */
60
+ const COMMAND_CODE_LEGACY_ENV_KEY = 'COMMAND_CODE_API_KEY'
61
+ const COMMAND_CODE_AUTH_FILE = '.commandcode/auth.json'
62
+
63
+ /** Plan id → display name (subset relevant to goat and neighbors). */
64
+ const COMMAND_CODE_PLANS = Object.freeze({
65
+ 'individual-go': 'Go',
66
+ 'individual-goat': 'GOAT',
67
+ 'individual-pro': 'Pro',
68
+ 'individual-pro-v1': 'Pro',
69
+ 'individual-provider': 'Provider',
70
+ 'individual-max': 'Max',
71
+ 'individual-ultra': 'Ultra',
72
+ 'teams-pro': 'Teams Pro',
73
+ })
74
+
75
+ /** Human labels for the panel's 说明 list, keyed by query kind. */
76
+ const KIND_LABELS = Object.freeze({
77
+ commandcode: 'Command Code 用量(窗口 / 信用)',
78
+ deepseek: 'DeepSeek 官方余额',
79
+ 'deepseek-account': 'DeepSeek 账户钱包余额',
80
+ 'opencode-go': 'OpenCode Go 订阅用量',
81
+ openrouter: 'OpenRouter 额度',
82
+ moonshot: 'Moonshot / Kimi 余额',
83
+ siliconflow: 'SiliconFlow 余额',
84
+ newapi: 'New API 兼容额度',
85
+ })
86
+
87
+ /** Reference names to fall back on when a setup declares no `apiKeyEnv`. */
88
+ const PROVIDER_KEY_REFS = Object.freeze({
89
+ opencode: 'OPENCODE_API_KEY',
90
+ 'opencode-go': 'OPENCODE_API_KEY',
91
+ moonshotai: 'MOONSHOT_API_KEY',
92
+ 'moonshotai-cn': 'MOONSHOT_API_KEY',
93
+ 'kimi-coding': 'KIMI_API_KEY',
94
+ })
95
+
96
+ /** Normalize a configured base URL: trim and strip trailing slashes. */
97
+ function baseUrlOf(raw) {
98
+ return typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
99
+ }
100
+
101
+ /**
102
+ * Path part of a configured base URL, lowercased — the same host can serve
103
+ * several products (`opencode.ai/zen` vs `opencode.ai/zen/go`), so the path is
104
+ * what separates a queryable subscription from one with no usage API at all.
105
+ * @param raw - configured `baseURL`.
106
+ * @returns the pathname (starting with `/`), or `""`.
107
+ */
108
+ function pathOf(raw) {
109
+ const base = baseUrlOf(raw)
110
+ if (base === '') return ''
111
+ try {
112
+ return new URL(base).pathname.toLowerCase()
113
+ } catch {
114
+ return ''
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Reduce a configured base URL to its origin (`https://host[:port]`), which is
120
+ * what the billing endpoints hang off — a provider base URL carries the
121
+ * protocol path (`https://api.commandcode.ai/provider/v1`) that usage APIs do
122
+ * not serve.
123
+ * @param raw - configured `baseURL`.
124
+ * @returns the origin, or null when the value is not an absolute http(s) URL.
125
+ */
126
+ function originOf(raw) {
127
+ const base = baseUrlOf(raw)
128
+ if (base === '') return null
129
+ try {
130
+ const url = new URL(base)
131
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
132
+ return url.origin
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ /** Lowercased hostname of an origin, or `""`. */
139
+ function hostOf(origin) {
140
+ if (origin === null) return ''
141
+ try {
142
+ return new URL(origin).hostname.toLowerCase()
143
+ } catch {
144
+ return ''
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Derive the conventional credential reference for a provider route — the same
150
+ * name the Models settings page stores a typed key under when the profile does
151
+ * not declare `apiKeyEnv` (`commandcode` → `COMMANDCODE_API_KEY`).
152
+ * @param provider - provider route id.
153
+ * @returns the derived reference name.
154
+ */
155
+ function deriveKeyRef(provider) {
156
+ return `${String(provider).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
157
+ }
158
+
159
+ /**
160
+ * Read a nested value by path, tolerating every intermediate being absent.
161
+ * @param root - container value (a settings namespace `value`, usually).
162
+ * @param path - key path such as `['providers', 'commandcode']`.
163
+ * @returns the value at the path, or undefined.
164
+ */
165
+ function atPath(root, path) {
166
+ let node = root
167
+ for (const key of Array.isArray(path) ? path : []) {
168
+ if (node === null || typeof node !== 'object') return undefined
169
+ node = node[key]
170
+ }
171
+ return node
172
+ }
173
+
174
+ /**
175
+ * Apply the cc-switch New API extractor to a `/api/user/self` response body.
176
+ * @param body - parsed JSON response.
177
+ * @returns the normalized balance, or a readable failure.
178
+ */
179
+ function extractNewApi(body) {
180
+ if (body === null || typeof body !== 'object' || body.success !== true || body.data == null) {
181
+ return { ok: false, message: typeof body?.message === 'string' && body.message !== '' ? body.message : '查询失败' }
182
+ }
183
+ const quota = Number(body.data.quota)
184
+ const used = Number(body.data.used_quota)
185
+ if (!Number.isFinite(quota) || !Number.isFinite(used)) {
186
+ return { ok: false, message: '返回数据缺少 quota / used_quota 字段' }
187
+ }
188
+ return {
189
+ ok: true,
190
+ value: {
191
+ planName: typeof body.data.group === 'string' && body.data.group !== '' ? body.data.group : '默认套餐',
192
+ remaining: quota / NEW_API_QUOTA_SCALE,
193
+ used: used / NEW_API_QUOTA_SCALE,
194
+ total: (quota + used) / NEW_API_QUOTA_SCALE,
195
+ unit: 'USD',
196
+ },
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Read a Command Code login file (never logged) — the fallback source for the
202
+ * key when the provider's credential reference is not configured.
203
+ * @returns the parsed file, or undefined.
204
+ */
205
+ function readCommandCodeAuthFile() {
206
+ // auth.json lives in the OS user home (`command-code login` writes it there),
207
+ // never under DSH_HOME.
208
+ const home = homedir()
209
+ if (home === undefined || home === '') return undefined
210
+ try {
211
+ const raw = readFileSync(`${home.replace(/[\\/]+$/, '')}/${COMMAND_CODE_AUTH_FILE}`, 'utf8')
212
+ return JSON.parse(raw)
213
+ } catch {
214
+ return undefined
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Resolve one credential reference to its value plus a readable source name.
220
+ * Resolution is per call, exactly like the LLM adapters: a key stored from the
221
+ * Models page reaches the next query without a restart.
222
+ * @param ctx - host context, with the optional `credentials` service.
223
+ * @param ref - reference name (`COMMANDCODE_API_KEY`).
224
+ * @param provider - provider route, used for the provider-specific fallbacks.
225
+ * @returns `{ value, source }`, or undefined while unconfigured.
226
+ */
227
+ async function resolveKey(ctx, ref, provider) {
228
+ try {
229
+ const hit = await ctx.get('credentials')?.resolve?.(ref)
230
+ if (hit !== undefined && hit.value !== '') {
231
+ return { value: hit.value, source: `凭据 ${ref}(${hit.source})` }
232
+ }
233
+ } catch {
234
+ // An unusable credential store falls through to the environment probes.
235
+ }
236
+ const fromEnv = process.env[ref]
237
+ if (fromEnv !== undefined && fromEnv.trim() !== '') {
238
+ return { value: fromEnv.trim(), source: `环境变量 ${ref}` }
239
+ }
240
+ if (provider === COMMAND_CODE_PROVIDER) {
241
+ const legacy = process.env[COMMAND_CODE_LEGACY_ENV_KEY]
242
+ if (legacy !== undefined && legacy.trim() !== '') {
243
+ return { value: legacy.trim(), source: `环境变量 ${COMMAND_CODE_LEGACY_ENV_KEY}` }
244
+ }
245
+ const parsed = readCommandCodeAuthFile()
246
+ if (typeof parsed?.apiKey === 'string' && parsed.apiKey !== '') {
247
+ return {
248
+ value: parsed.apiKey,
249
+ source: `~/.commandcode/auth.json${typeof parsed.userName === 'string' ? `(${parsed.userName})` : ''}`,
250
+ }
251
+ }
252
+ }
253
+ return undefined
254
+ }
255
+
256
+ /**
257
+ * Build a per-request provider-profile lookup: the credential reference,
258
+ * endpoint, and display name the LLM adapter itself uses. The directory
259
+ * supplies the settings address, so a hand-declared route in the profile
260
+ * (`llm-pi-ai.providers.commandcode`) and a shipped one
261
+ * (`llm-deepseek.apiKeyEnv`) both resolve the same way.
262
+ *
263
+ * `settings.describe()` projects every active plugin's configuration and is not
264
+ * cheap, so it is read once per lookup and shared by every provider the request
265
+ * touches.
266
+ * @param ctx - host context carrying optional `llm` and `settings`.
267
+ * @returns `(provider) => { displayName, apiKeyEnv, baseURL, api, headers }`,
268
+ * with fields absent from the configuration left undefined.
269
+ */
270
+ function createProfileLookup(ctx) {
271
+ let entries
272
+ let descriptors
273
+ let loaded = false
274
+ const load = () => {
275
+ if (loaded) return
276
+ loaded = true
277
+ try {
278
+ const rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
279
+ entries = Array.isArray(rows) ? rows : []
280
+ } catch {
281
+ entries = []
282
+ }
283
+ try {
284
+ const rows = ctx.get('settings')?.describe?.() ?? []
285
+ descriptors = Array.isArray(rows) ? rows : []
286
+ } catch {
287
+ descriptors = []
288
+ }
289
+ }
290
+ return (provider) => {
291
+ const profile = { displayName: undefined, apiKeyEnv: undefined, baseURL: undefined, api: undefined, headers: undefined }
292
+ load()
293
+ const entry = entries.find((row) => row?.provider === provider)
294
+ if (entry === undefined) return profile
295
+ profile.displayName = typeof entry.displayName === 'string' && entry.displayName !== '' ? entry.displayName : undefined
296
+ const namespace = descriptors.find((row) => row?.ns === entry.settingsNs)
297
+ const configured = atPath(namespace?.value, entry.settingsPath ?? [])
298
+ if (configured === null || typeof configured !== 'object') return profile
299
+ if (typeof configured.displayName === 'string' && configured.displayName !== '') profile.displayName = configured.displayName
300
+ if (typeof configured.apiKeyEnv === 'string' && configured.apiKeyEnv !== '') profile.apiKeyEnv = configured.apiKeyEnv
301
+ if (typeof configured.baseURL === 'string' && configured.baseURL !== '') profile.baseURL = configured.baseURL
302
+ if (typeof configured.api === 'string' && configured.api !== '') profile.api = configured.api
303
+ if (configured.headers !== null && typeof configured.headers === 'object') profile.headers = configured.headers
304
+ return profile
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The credential reference a route resolves its key from: what the profile
310
+ * declares, else the conventional name — with the official DeepSeek route
311
+ * pinned to its own long-standing variable.
312
+ * @param provider - provider route id.
313
+ * @param profile - resolved provider profile.
314
+ * @returns the reference name, or undefined when the route needs no API key.
315
+ */
316
+ function keyRefOf(provider, profile) {
317
+ if (profile.apiKeyEnv !== undefined) return profile.apiKeyEnv
318
+ if (provider === 'deepseek-account') return undefined
319
+ if (DEEPSEEK_PROVIDERS.includes(provider)) return 'DEEPSEEK_API_KEY'
320
+ if (PROVIDER_KEY_REFS[provider] !== undefined) return PROVIDER_KEY_REFS[provider]
321
+ return deriveKeyRef(provider)
322
+ }
323
+
324
+ /**
325
+ * Choose the usage API for one route. The configured endpoint wins over the
326
+ * route name, so a renamed deployment still reaches the right service.
327
+ *
328
+ * `opencode.ai` serves two products on one host: Zen (pay-as-you-go, no usage
329
+ * API at all) and Go/Go Plus (a subscription whose `/zen/go/v1/usage` endpoint
330
+ * exists and validates the API key), so the path decides.
331
+ * @param provider - provider route id.
332
+ * @param profile - resolved provider profile.
333
+ * @returns the query kind, or null when no usage API is known for the route.
334
+ */
335
+ function kindOf(provider, profile) {
336
+ if (provider === 'deepseek-account') return 'deepseek-account'
337
+ const origin = originOf(profile.baseURL)
338
+ const host = hostOf(origin)
339
+ const path = pathOf(profile.baseURL)
340
+ if (host !== '' && (host === 'commandcode.ai' || host.endsWith('.commandcode.ai'))) return 'commandcode'
341
+ if (host !== '' && (host === 'opencode.ai' || host.endsWith('.opencode.ai'))) {
342
+ return path.includes('/zen/go') ? 'opencode-go' : null
343
+ }
344
+ if (host !== '' && (host === 'openrouter.ai' || host.endsWith('.openrouter.ai'))) return 'openrouter'
345
+ if (host !== '' && (host.endsWith('moonshot.cn') || host.endsWith('moonshot.ai'))) return 'moonshot'
346
+ if (host !== '' && (host.endsWith('siliconflow.cn') || host.endsWith('siliconflow.com'))) return 'siliconflow'
347
+ if (host !== '' && (host === 'deepseek.com' || host.endsWith('.deepseek.com'))) return 'deepseek'
348
+ if (provider === COMMAND_CODE_PROVIDER) return 'commandcode'
349
+ if (provider === 'opencode-go') return 'opencode-go'
350
+ if (provider === 'openrouter') return 'openrouter'
351
+ if (provider === 'moonshotai' || provider === 'moonshotai-cn' || provider === 'kimi-coding') return 'moonshot'
352
+ if (provider === 'siliconflow') return 'siliconflow'
353
+ if (DEEPSEEK_PROVIDERS.includes(provider)) return 'deepseek'
354
+ return origin !== null ? 'newapi' : null
355
+ }
356
+
357
+ /** Fetch one Command Code API endpoint with the shared bearer header. */
358
+ async function commandCodeFetch(base, endpoint, key, signal) {
359
+ const res = await fetch(`${base}${endpoint}`, {
360
+ headers: {
361
+ 'content-type': 'application/json',
362
+ authorization: `Bearer ${key}`,
363
+ },
364
+ signal,
365
+ })
366
+ if (!res.ok) {
367
+ throw new Error(`Command Code 接口返回 HTTP ${res.status}`)
368
+ }
369
+ return res.json()
370
+ }
371
+
372
+ /**
373
+ * Build a query string skipping null / undefined / empty values — the API
374
+ * rejects an empty `orgId` (personal accounts have no org), and the CLI's
375
+ * own `buildUsageEndpoint` skips them the same way.
376
+ * @param params - key → value map; falsy scalars are omitted.
377
+ * @returns `?a=1&b=2` or `""`.
378
+ */
379
+ function queryOf(params) {
380
+ const parts = []
381
+ for (const [key, value] of Object.entries(params)) {
382
+ if (value === null || value === undefined || value === '') continue
383
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
384
+ }
385
+ return parts.length > 0 ? `?${parts.join('&')}` : ''
386
+ }
387
+
388
+ /**
389
+ * Query the Command Code private usage API — the same four calls the CLI's
390
+ * `/usage` command makes. Returns the normalized usage view.
391
+ * @param base - API origin.
392
+ * @param key - the provider key the conversation itself is using.
393
+ * @param signal - abort signal.
394
+ */
395
+ async function queryCommandCode(base, key, signal) {
396
+ let whoami
397
+ try {
398
+ whoami = await commandCodeFetch(base, '/alpha/whoami?limits=1', key, signal)
399
+ } catch (err) {
400
+ return { ok: false, message: err instanceof Error ? err.message : String(err) }
401
+ }
402
+ const orgId = whoami?.org?.id ?? null
403
+ let credits = null
404
+ let subscription = null
405
+ let summary = null
406
+ try {
407
+ ;[credits, subscription] = await Promise.all([
408
+ commandCodeFetch(base, `/alpha/billing/credits${queryOf({ orgId })}`, key, signal),
409
+ commandCodeFetch(base, `/alpha/billing/subscriptions${queryOf({ orgId })}`, key, signal),
410
+ ])
411
+ } catch (err) {
412
+ return { ok: false, message: err instanceof Error ? err.message : String(err) }
413
+ }
414
+ const sub = subscription?.data ?? null
415
+ const periodStart = typeof sub?.currentPeriodStart === 'string' ? sub.currentPeriodStart : null
416
+ const periodEnd = typeof sub?.currentPeriodEnd === 'string' ? Date.parse(sub.currentPeriodEnd) : NaN
417
+ if (periodStart !== null) {
418
+ try {
419
+ summary = await commandCodeFetch(base, `/alpha/usage/summary${queryOf({ orgId, since: periodStart })}`, key, signal)
420
+ } catch {
421
+ // Summary is best-effort; the windows and credits still render.
422
+ summary = null
423
+ }
424
+ }
425
+ const creditInfo = credits?.credits ?? null
426
+ const monthly = Number(creditInfo?.monthlyCredits ?? 0)
427
+ const purchased = Number(creditInfo?.purchasedCredits ?? 0)
428
+ const free = Number(creditInfo?.freeCredits ?? 0)
429
+ const totalCost = Number(summary?.totalCost ?? 0)
430
+ const totalCount = Number(summary?.totalCount ?? 0)
431
+ const planId = typeof sub?.planId === 'string' ? sub.planId : null
432
+ // windowLimits sits at the credits response top level, beside `credits`.
433
+ const windows = credits?.windowLimits ?? null
434
+ const account = typeof whoami?.user?.userName === 'string' && whoami.user.userName !== ''
435
+ ? whoami.user.userName
436
+ : typeof whoami?.user?.name === 'string' ? whoami.user.name : null
437
+ return {
438
+ ok: true,
439
+ value: {
440
+ planName: planId !== null && COMMAND_CODE_PLANS[planId] !== undefined ? COMMAND_CODE_PLANS[planId] : (planId ?? '未知套餐'),
441
+ account,
442
+ status: typeof sub?.status === 'string' ? sub.status : null,
443
+ cycleEnd: Number.isFinite(periodEnd) ? periodEnd : null,
444
+ credits: {
445
+ monthlyRemaining: monthly,
446
+ purchasedRemaining: purchased,
447
+ freeRemaining: free,
448
+ totalRemaining: monthly + purchased + free,
449
+ totalSpent: totalCost,
450
+ totalCount,
451
+ },
452
+ windows: windows === null ? null : {
453
+ limited: windows.limited === true,
454
+ fiveHour: windows.fiveHour != null ? {
455
+ used: Number(windows.fiveHour.used ?? 0),
456
+ cap: Number(windows.fiveHour.cap ?? 0),
457
+ resetAt: Number(windows.fiveHour.resetAt ?? 0),
458
+ } : null,
459
+ weekly: windows.weekly != null ? {
460
+ used: Number(windows.weekly.used ?? 0),
461
+ cap: Number(windows.weekly.cap ?? 0),
462
+ resetAt: Number(windows.weekly.resetAt ?? 0),
463
+ } : null,
464
+ },
465
+ now: Date.now(),
466
+ usageUrl: `${COMMAND_CODE_WEB_BASE}/usage`,
467
+ },
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Query the official DeepSeek balance endpoint with the route's own key.
473
+ * @param url - balance endpoint (the configured origin when it is DeepSeek's).
474
+ * @param key - the key the conversation is using.
475
+ * @param signal - abort signal.
476
+ */
477
+ async function queryDeepSeek(url, key, signal) {
478
+ const res = await fetch(url, {
479
+ headers: { Authorization: `Bearer ${key}` },
480
+ signal,
481
+ })
482
+ if (!res.ok) {
483
+ return { ok: false, message: `DeepSeek 余额接口返回 HTTP ${res.status}` }
484
+ }
485
+ return { ok: true, value: await res.json() }
486
+ }
487
+
488
+ /**
489
+ * Query a New API deployment's `/api/user/self` (best effort for any other
490
+ * OpenAI-compatible route), carrying the route's configured headers.
491
+ * @param origin - the route's origin.
492
+ * @param key - the key the conversation is using.
493
+ * @param headers - extra headers the profile declares.
494
+ * @param signal - abort signal.
495
+ */
496
+ async function queryNewApi(origin, key, headers, signal) {
497
+ const res = await fetch(`${origin}/api/user/self`, {
498
+ method: 'GET',
499
+ headers: {
500
+ 'Content-Type': 'application/json',
501
+ Authorization: `Bearer ${key}`,
502
+ 'User-Agent': NEW_API_USER_AGENT,
503
+ ...(headers ?? {}),
504
+ },
505
+ signal,
506
+ })
507
+ if (!res.ok) {
508
+ return { ok: false, message: `New API 余额接口返回 HTTP ${res.status}` }
509
+ }
510
+ const extracted = extractNewApi(await res.json())
511
+ return extracted.ok ? { ok: true, value: extracted.value } : { ok: false, message: extracted.message }
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Per-provider usage adapters. Each one answers the same question in the
516
+ // provider's own vocabulary and normalizes to the panel's two building blocks:
517
+ // `windows` (used / limit / resetsAt meters) and `rows` (label → value lines).
518
+ // Whatever the endpoint actually returned rides along as `raw`, so an endpoint
519
+ // whose shape drifts still shows something readable — and reportable.
520
+
521
+ /** Parse a numeric field that may arrive as a number or a numeric string. */
522
+ function numberOf(value) {
523
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
524
+ if (typeof value === 'string' && value.trim() !== '') {
525
+ const parsed = Number(value)
526
+ return Number.isFinite(parsed) ? parsed : null
527
+ }
528
+ return null
529
+ }
530
+
531
+ /** Parse a reset instant that may arrive as epoch milliseconds or an ISO string. */
532
+ function dateOf(value) {
533
+ const numeric = numberOf(value)
534
+ if (numeric !== null) return numeric < 1e11 ? Math.round(numeric * 1000) : numeric
535
+ if (typeof value === 'string') {
536
+ const parsed = Date.parse(value)
537
+ return Number.isFinite(parsed) ? parsed : 0
538
+ }
539
+ return 0
540
+ }
541
+
542
+ /** Round a money value for display without inventing precision. */
543
+ function moneyOf(value) {
544
+ const numeric = numberOf(value)
545
+ return numeric === null ? null : numeric
546
+ }
547
+
548
+ /** Take the first present value among several candidate field names. */
549
+ function firstOf(node, keys) {
550
+ for (const key of keys) {
551
+ if (node[key] !== undefined && node[key] !== null) return node[key]
552
+ }
553
+ return undefined
554
+ }
555
+
556
+ /** Fetch one JSON endpoint with the provider's own key. */
557
+ async function fetchUsageJson(url, key, headers, signal, label) {
558
+ const res = await fetch(url, {
559
+ headers: {
560
+ accept: 'application/json',
561
+ authorization: `Bearer ${key}`,
562
+ ...(headers ?? {}),
563
+ },
564
+ signal,
565
+ })
566
+ if (!res.ok) {
567
+ return { ok: false, message: `${label} 接口返回 HTTP ${res.status}` }
568
+ }
569
+ try {
570
+ return { ok: true, value: await res.json() }
571
+ } catch {
572
+ return { ok: false, message: `${label} 返回的不是 JSON` }
573
+ }
574
+ }
575
+
576
+ /** One window meter from a node that names its own used / limit / reset. */
577
+ function windowOf(label, node) {
578
+ if (node === null || typeof node !== 'object') return null
579
+ const used = moneyOf(firstOf(node, ['used', 'usage', 'spent', 'cost', 'usedUsd', 'used_usd']))
580
+ const limit = moneyOf(firstOf(node, ['limit', 'cap', 'quota', 'max', 'total', 'monthlyLimit', 'monthly_limit']))
581
+ if (used === null && limit === null) return null
582
+ return {
583
+ label,
584
+ used: used ?? 0,
585
+ limit: limit ?? 0,
586
+ resetsAt: dateOf(firstOf(node, ['resetsAt', 'resetAt', 'reset', 'resets_at', 'reset_at', 'renewsAt', 'periodEnd'])),
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Normalize the OpenCode Go usage payload. Its exact shape is not documented
592
+ * (verified only as far as `401` without a key), so the candidate field names
593
+ * cover the plausible spellings and anything unrecognized stays visible in
594
+ * `raw`.
595
+ */
596
+ function normalizeOpenCodeGo(body) {
597
+ const root = body !== null && typeof body === 'object' ? (body.data ?? body) : {}
598
+ const windows = []
599
+ const windowsNode = root.windows ?? root.limits ?? root.usageWindows ?? root.usage_windows ?? root
600
+ for (const [keys, label] of [
601
+ [['fiveHour', 'five_hour', 'fivehour', '5h', 'five_hours'], '5 小时窗口'],
602
+ [['weekly', 'week', '7d', 'sevenDay', 'seven_day'], '7 天窗口'],
603
+ [['monthly', 'month', 'monthlyUsage', 'monthly_usage', '30d'], '月额度'],
604
+ ]) {
605
+ const node = firstOf(windowsNode, keys)
606
+ const window = windowOf(label, node)
607
+ if (window !== null) windows.push(window)
608
+ }
609
+ const rows = []
610
+ const plan = firstOf(root, ['plan', 'planName', 'plan_name', 'tier', 'subscription'])
611
+ if (typeof plan === 'string' && plan !== '') rows.push({ label: '套餐', value: plan })
612
+ const status = firstOf(root, ['status', 'state'])
613
+ if (typeof status === 'string' && status !== '') rows.push({ label: '状态', value: status })
614
+ const used = moneyOf(firstOf(root, ['used', 'usage', 'spent', 'cost', 'totalUsed', 'total_used']))
615
+ if (used !== null) rows.push({ label: '已用', value: `$${used.toFixed(2)}` })
616
+ const limit = moneyOf(firstOf(root, ['limit', 'cap', 'quota', 'totalLimit', 'total_limit']))
617
+ if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
618
+ const balance = moneyOf(firstOf(root, ['balance', 'credit', 'credits', 'remaining']))
619
+ if (balance !== null) rows.push({ label: '余额', value: `$${balance.toFixed(2)}` })
620
+ // A per-model breakdown, if this endpoint carries one.
621
+ const modelsNode = firstOf(root, ['models', 'byModel', 'by_model', 'perModel', 'per_model'])
622
+ const models = []
623
+ if (Array.isArray(modelsNode)) {
624
+ for (const entry of modelsNode) {
625
+ if (entry === null || typeof entry !== 'object') continue
626
+ const id = firstOf(entry, ['model', 'id', 'name', 'modelId', 'model_id'])
627
+ const entryUsed = moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost']))
628
+ const entryLimit = moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max']))
629
+ if (id === undefined && entryUsed === null) continue
630
+ models.push({ model: String(id ?? '未知模型'), used: entryUsed, limit: entryLimit })
631
+ }
632
+ } else if (modelsNode !== null && typeof modelsNode === 'object') {
633
+ for (const [id, entry] of Object.entries(modelsNode)) {
634
+ if (entry === null || typeof entry !== 'object') continue
635
+ models.push({
636
+ model: id,
637
+ used: moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost'])),
638
+ limit: moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max'])),
639
+ })
640
+ }
641
+ }
642
+ return { windows, models, rows, raw: body }
643
+ }
644
+
645
+ /** OpenCode Go / Go Plus subscription usage (`/zen/go/v1/usage`). */
646
+ async function queryOpenCodeGo(origin, key, headers, signal) {
647
+ const res = await fetchUsageJson(`${origin}/zen/go/v1/usage`, key, headers, signal, 'OpenCode Go')
648
+ if (!res.ok) return res
649
+ return { ok: true, value: normalizeOpenCodeGo(res.value) }
650
+ }
651
+
652
+ /** OpenRouter credits: `/api/v1/key` carries the limit, `/api/v1/credits` the pool. */
653
+ async function queryOpenRouter(origin, key, headers, signal) {
654
+ const res = await fetchUsageJson(`${origin}/api/v1/key`, key, headers, signal, 'OpenRouter')
655
+ if (!res.ok) return res
656
+ const data = res.value?.data ?? {}
657
+ const usage = moneyOf(data.usage) ?? 0
658
+ const limit = moneyOf(data.limit)
659
+ const remaining = moneyOf(data.limit_remaining)
660
+ const rows = [{ label: '已用', value: `$${usage.toFixed(2)}` }]
661
+ if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
662
+ rows.push({ label: '剩余', value: `$${(remaining ?? (limit === null ? 0 : Math.max(0, limit - usage))).toFixed(2)}` })
663
+ if (typeof data.label === 'string' && data.label !== '') rows.push({ label: 'Key', value: data.label })
664
+ if (data.is_free_tier === true) rows.push({ label: '套餐', value: '免费层' })
665
+ const raw = { key: res.value }
666
+ if (limit === null) {
667
+ // A key without its own limit spends from the account credit pool.
668
+ const credits = await fetchUsageJson(`${origin}/api/v1/credits`, key, headers, signal, 'OpenRouter')
669
+ if (credits.ok) {
670
+ const total = moneyOf(credits.value?.data?.total_credits)
671
+ const spent = moneyOf(credits.value?.data?.total_usage)
672
+ if (total !== null) rows.push({ label: '账户总额度', value: `$${total.toFixed(2)}` })
673
+ if (spent !== null) rows.push({ label: '账户已用', value: `$${spent.toFixed(2)}` })
674
+ if (total !== null && spent !== null) rows.push({ label: '账户剩余', value: `$${Math.max(0, total - spent).toFixed(2)}` })
675
+ raw.credits = credits.value
676
+ }
677
+ }
678
+ return { ok: true, value: { rows, raw } }
679
+ }
680
+
681
+ /** Moonshot / Kimi balance: `GET /v1/users/me/balance`. */
682
+ async function queryMoonshot(origin, key, headers, signal) {
683
+ const res = await fetchUsageJson(`${origin}/v1/users/me/balance`, key, headers, signal, 'Moonshot')
684
+ if (!res.ok) return res
685
+ const data = res.value?.data ?? {}
686
+ const rows = []
687
+ const available = moneyOf(data.available_balance ?? data.availableBalance)
688
+ const voucher = moneyOf(data.voucher_balance ?? data.voucherBalance)
689
+ const cash = moneyOf(data.cash_balance ?? data.cashBalance)
690
+ if (available !== null) rows.push({ label: '可用余额', value: `¥${available.toFixed(2)}` })
691
+ if (cash !== null) rows.push({ label: '现金余额', value: `¥${cash.toFixed(2)}` })
692
+ if (voucher !== null) rows.push({ label: '代金券', value: `¥${voucher.toFixed(2)}` })
693
+ if (rows.length === 0) return { ok: false, message: 'Moonshot 接口未返回可识别的余额字段' }
694
+ return { ok: true, value: { rows, raw: res.value } }
695
+ }
696
+
697
+ /** SiliconFlow balance: `GET /v1/user/info`. */
698
+ async function querySiliconFlow(origin, key, headers, signal) {
699
+ const res = await fetchUsageJson(`${origin}/v1/user/info`, key, headers, signal, 'SiliconFlow')
700
+ if (!res.ok) return res
701
+ const data = res.value?.data ?? {}
702
+ const rows = []
703
+ const total = moneyOf(data.totalBalance ?? data.total_balance)
704
+ const charge = moneyOf(data.chargeBalance ?? data.charge_balance)
705
+ const gift = moneyOf(data.balance)
706
+ if (total !== null) rows.push({ label: '总余额', value: `¥${total.toFixed(2)}` })
707
+ if (charge !== null) rows.push({ label: '充值余额', value: `¥${charge.toFixed(2)}` })
708
+ if (gift !== null) rows.push({ label: '赠送余额', value: `¥${gift.toFixed(2)}` })
709
+ if (typeof data.name === 'string' && data.name !== '') rows.push({ label: '账户', value: data.name })
710
+ if (rows.length === 0) return { ok: false, message: 'SiliconFlow 接口未返回可识别的余额字段' }
711
+ return { ok: true, value: { rows, raw: res.value } }
712
+ }
713
+
714
+ /**
715
+ * Read the signed-in DeepSeek platform wallet through the account service —
716
+ * the `deepseek-account` route holds no API key at all.
717
+ * @param ctx - host context carrying optional `deepseekAccount`.
718
+ * @returns the normalized wallet view, or a readable failure.
719
+ */
720
+ async function queryDeepSeekAccount(ctx) {
721
+ const account = ctx.get('deepseekAccount')
722
+ if (account === undefined) {
723
+ return { ok: false, message: '当前部署没有挂载 deepseek-account 服务,无法读取账户钱包' }
724
+ }
725
+ const client = {
726
+ version: '1.0.0',
727
+ locale: 'zh-CN',
728
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
729
+ }
730
+ const balance = await account.getBalance(client)
731
+ if (balance === null || balance === undefined) {
732
+ return { ok: false, message: '尚未登录 DeepSeek 账户(设置 → 账户里登录后再试)' }
733
+ }
734
+ if (balance.status !== 'ready') {
735
+ return { ok: false, message: 'DeepSeek 账户余额查询失败' }
736
+ }
737
+ const wallets = [...(balance.value ?? []), ...(balance.bonusWallets ?? [])]
738
+ .map((wallet) => ({ currency: wallet.currency, balance: wallet.balance }))
739
+ return { ok: true, value: { wallets } }
740
+ }
741
+
742
+ /**
743
+ * The model selection the current conversation runs on: the browser's copy of
744
+ * the Session's own projection first, then this Host's live projection, then
745
+ * the deployment default.
746
+ * @param ctx - host context carrying optional session services.
747
+ * @param payload - browser payload; `provider` + `model` win when both exist.
748
+ * @returns `{ provider, model, reasoningEffort? }`, or null while unknown.
749
+ */
750
+ function resolveSelection(ctx, payload) {
751
+ if (typeof payload?.provider === 'string' && payload.provider !== ''
752
+ && typeof payload?.model === 'string' && payload.model !== '') {
753
+ return {
754
+ provider: payload.provider,
755
+ model: payload.model,
756
+ ...(typeof payload?.reasoningEffort === 'string' ? { reasoningEffort: payload.reasoningEffort } : {}),
757
+ }
758
+ }
759
+ try {
760
+ const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
761
+ const session = sessionId === '' ? undefined : ctx.get('sessions')?.get?.(sessionId)
762
+ const state = session === undefined ? undefined : ctx.get('sessionProjections')?.stateOf?.(session, 'modelSelection')
763
+ const selected = state?.pending ?? state?.lastUsed
764
+ if (selected !== null && selected !== undefined && typeof selected.provider === 'string') return selected
765
+ } catch {
766
+ // A missing or cold Session simply falls through to the deployment default.
767
+ }
768
+ const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
769
+ return fallback !== null && fallback !== undefined && typeof fallback.provider === 'string' ? fallback : null
770
+ }
771
+
772
+ /**
773
+ * Run the usage query that belongs to one conversation model.
774
+ * @param ctx - host context.
775
+ * @param signal - abort signal for every outbound call.
776
+ * @param payload - browser payload (model selection and/or session id).
777
+ * @returns the normalized `{ ok, value | error }` envelope.
778
+ */
779
+ async function queryUsage(ctx, signal, payload) {
780
+ const selection = resolveSelection(ctx, payload)
781
+ if (selection === null) {
782
+ return {
783
+ ok: false,
784
+ error: {
785
+ code: 'internal',
786
+ message: '无法确定当前对话使用的模型:会话还没有过模型选择,请先在对话里选一次模型',
787
+ details: {},
788
+ },
789
+ }
790
+ }
791
+ const { provider } = selection
792
+ const profileOf = createProfileLookup(ctx)
793
+ const profile = profileOf(provider)
794
+ const kind = kindOf(provider, profile)
795
+ const providerName = profile.displayName ?? provider
796
+ const shared = { provider, providerName, model: selection.model, reasoningEffort: selection.reasoningEffort }
797
+ if (kind === null) {
798
+ const configured = originOf(profile.baseURL) !== null
799
+ return {
800
+ ok: false,
801
+ error: {
802
+ code: 'internal',
803
+ message: configured
804
+ ? `供应商「${providerName}」没有已适配的用量接口:${profile.baseURL} 不对应任何已知的用量 API`
805
+ : `供应商「${providerName}」既没有配置 baseURL,也不是已知的用量接口,无法查询用量`,
806
+ details: {},
807
+ },
808
+ }
809
+ }
810
+ const ref = keyRefOf(provider, profile)
811
+ let key
812
+ if (kind !== 'deepseek-account') {
813
+ if (ref === undefined) {
814
+ return {
815
+ ok: false,
816
+ error: { code: 'internal', message: `供应商「${providerName}」没有可用的凭据引用`, details: {} },
817
+ }
818
+ }
819
+ const resolved = await resolveKey(ctx, ref, provider)
820
+ if (resolved === undefined) {
821
+ return {
822
+ ok: false,
823
+ error: {
824
+ code: 'internal',
825
+ message: `未找到供应商「${providerName}」的 API Key:当前对话用的是 ${ref},请在 设置 → 模型 里填写`,
826
+ details: {},
827
+ },
828
+ }
829
+ }
830
+ key = resolved
831
+ }
832
+ const credential = ref === undefined
833
+ ? null
834
+ : { ref, source: key?.source ?? null }
835
+ let result
836
+ const deadline = withTimeout(signal, QUERY_TIMEOUT_MS)
837
+ try {
838
+ if (kind === 'commandcode') {
839
+ result = await queryCommandCode(originOf(profile.baseURL) ?? COMMAND_CODE_BASE, key.value, deadline)
840
+ } else if (kind === 'deepseek') {
841
+ const origin = originOf(profile.baseURL)
842
+ const host = hostOf(origin)
843
+ const url = host === 'deepseek.com' || host.endsWith('.deepseek.com')
844
+ ? `${origin}/user/balance`
845
+ : DEEPSEEK_BALANCE_URL
846
+ result = await queryDeepSeek(url, key.value, deadline)
847
+ } else if (kind === 'deepseek-account') {
848
+ result = await queryDeepSeekAccount(ctx)
849
+ } else if (kind === 'opencode-go') {
850
+ result = await queryOpenCodeGo(originOf(profile.baseURL), key.value, profile.headers, deadline)
851
+ } else if (kind === 'openrouter') {
852
+ result = await queryOpenRouter(originOf(profile.baseURL), key.value, profile.headers, deadline)
853
+ } else if (kind === 'moonshot') {
854
+ result = await queryMoonshot(originOf(profile.baseURL), key.value, profile.headers, deadline)
855
+ } else if (kind === 'siliconflow') {
856
+ result = await querySiliconFlow(originOf(profile.baseURL), key.value, profile.headers, deadline)
857
+ } else {
858
+ result = await queryNewApi(originOf(profile.baseURL), key.value, profile.headers, deadline)
859
+ }
860
+ } catch (err) {
861
+ return {
862
+ ok: false,
863
+ error: { code: 'internal', message: queryFailureMessage(err), details: {} },
864
+ }
865
+ }
866
+ if (!result.ok) {
867
+ return { ok: false, error: { code: 'internal', message: result.message, details: {} } }
868
+ }
869
+ return { ok: true, value: { mode: kind, ...shared, credential, data: result.value } }
870
+ }
871
+
872
+ /**
873
+ * The read-only provider directory for the panel's 说明 dialog: which routes
874
+ * exist, which credential reference each resolves, and whether that reference
875
+ * is configured. No value ever crosses this boundary.
876
+ * @param ctx - host context.
877
+ * @returns one row per declared provider route.
878
+ */
879
+ async function providerRows(ctx) {
880
+ const profileOf = createProfileLookup(ctx)
881
+ let rows = []
882
+ try {
883
+ rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
884
+ } catch {
885
+ rows = []
886
+ }
887
+ if (!Array.isArray(rows)) return []
888
+ const out = []
889
+ for (const row of rows) {
890
+ if (row === null || typeof row !== 'object' || typeof row.provider !== 'string') continue
891
+ const profile = profileOf(row.provider)
892
+ const ref = keyRefOf(row.provider, profile)
893
+ const kind = kindOf(row.provider, profile)
894
+ let configured = false
895
+ let source = null
896
+ if (ref !== undefined) {
897
+ try {
898
+ const info = await ctx.get('credentials')?.describe?.(ref)
899
+ configured = info?.configured === true
900
+ source = typeof info?.source === 'string' ? info.source : null
901
+ } catch {
902
+ configured = false
903
+ }
904
+ } else {
905
+ configured = true
906
+ source = '账户登录'
907
+ }
908
+ out.push({
909
+ provider: row.provider,
910
+ displayName: profile.displayName ?? row.displayName ?? row.provider,
911
+ kind,
912
+ kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
913
+ baseUrl: profile.baseURL ?? null,
914
+ credentialRef: ref ?? null,
915
+ configured,
916
+ source,
917
+ })
918
+ }
919
+ return out
920
+ }
921
+
922
+ /**
923
+ * Bound one outbound call. The panel now refreshes on a timer, so a provider
924
+ * that never answers must not hold the one in-flight slot forever — the caller
925
+ * aborts on close, and this adds a deadline of its own.
926
+ * @param signal - caller cancellation, when there is one.
927
+ * @param ms - deadline in milliseconds.
928
+ * @returns a signal that aborts on either cause.
929
+ */
930
+ function withTimeout(signal, ms) {
931
+ if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return signal
932
+ const deadline = AbortSignal.timeout(ms)
933
+ if (signal === undefined || typeof AbortSignal.any !== 'function') return deadline
934
+ return AbortSignal.any([signal, deadline])
935
+ }
936
+
937
+ /** Turn one adapter failure into a readable message; a deadline reads as a timeout. */
938
+ function queryFailureMessage(err) {
939
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
940
+ return `用量接口超时(${Math.round(QUERY_TIMEOUT_MS / 1000)} 秒未返回)`
941
+ }
942
+ return err instanceof Error ? err.message : String(err)
943
+ }
944
+
945
+ /**
946
+ * The routable model catalog, annotated with the usage API each provider route
947
+ * resolves — the data behind the panel's manual model picker. Providers come
948
+ * from the same catalog the model switch uses, so a manual choice is always a
949
+ * route the deployment can actually serve.
950
+ * @param ctx - host context carrying optional `sessionController`.
951
+ * @returns `{ groups, default }`, each group `{ provider, name, kind, kindLabel, models }`.
952
+ */
953
+ async function modelCatalogRows(ctx) {
954
+ let catalog
955
+ try {
956
+ catalog = await ctx.get('sessionController')?.modelCatalog?.()
957
+ } catch {
958
+ catalog = undefined
959
+ }
960
+ const profileOf = createProfileLookup(ctx)
961
+ const groups = []
962
+ for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
963
+ if (group === null || typeof group !== 'object' || typeof group.id !== 'string' || group.id === '') continue
964
+ const kind = kindOf(group.id, profileOf(group.id))
965
+ groups.push({
966
+ provider: group.id,
967
+ name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
968
+ kind,
969
+ kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
970
+ models: (Array.isArray(group.models) ? group.models : []).flatMap((model) => (
971
+ model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== ''
972
+ ? [{ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id }]
973
+ : []
974
+ )),
975
+ })
976
+ }
977
+ const selected = catalog?.default
978
+ return {
979
+ groups,
980
+ default: selected !== null && typeof selected === 'object' && typeof selected.provider === 'string'
981
+ ? { provider: selected.provider, model: typeof selected.model === 'string' ? selected.model : '' }
982
+ : null,
983
+ }
984
+ }
985
+
986
+ /** Read the entirety of a node:http request body as a UTF-8 string. */
987
+ async function readBody(req) {
988
+ const chunks = []
989
+ for await (const chunk of req) chunks.push(chunk)
990
+ return Buffer.concat(chunks).toString('utf8')
991
+ }
992
+
993
+ /** Write a JSON response with a normalized `{ ok, value | error }` envelope. */
994
+ function writeJson(res, statusCode, value) {
995
+ res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' })
996
+ res.end(JSON.stringify(value))
997
+ }
998
+
999
+ /**
1000
+ * Apply the composition's own browser-trust fence to one request. `node:http`
1001
+ * routes are unauthenticated by construction (the web server has no global
1002
+ * policy — every route owner decides), so each of ours asks the connection
1003
+ * service first and answers its 401/403 itself when the request is not from an
1004
+ * accepted authority. A deployment without the service keeps the plugin usable.
1005
+ * @param ctx - host context carrying the optional `connection` service.
1006
+ * @param req - the incoming request.
1007
+ * @param res - the response to answer on when the request is rejected.
1008
+ * @returns true when the request was already rejected and answered.
1009
+ */
1010
+ function rejected(ctx, req, res) {
1011
+ let rejection
1012
+ try {
1013
+ rejection = ctx.get('connection')?.requestRejection?.(req)
1014
+ } catch {
1015
+ rejection = undefined
1016
+ }
1017
+ if (rejection === undefined) return false
1018
+ res.statusCode = rejection
1019
+ res.end()
1020
+ return true
1021
+ }
1022
+
1023
+ /**
1024
+ * Register the /model-usage/* exact Web routes: the usage query for one
1025
+ * conversation model, the read-only provider directory, and the routable model
1026
+ * catalog behind the manual picker.
1027
+ * @param ctx - host context with webServer, credentials, and the session/LLM
1028
+ * services read through `ctx.get()`.
1029
+ */
1030
+ export function apply(ctx) {
1031
+ ctx.effect(() => ctx.webServer.register({
1032
+ kind: 'exact',
1033
+ path: '/model-usage/balance',
1034
+ handler: async (req, res) => {
1035
+ if (rejected(ctx, req, res)) return
1036
+ const controller = new AbortController()
1037
+ res.once('close', () => controller.abort())
1038
+ try {
1039
+ const payload = req.method === 'POST'
1040
+ ? JSON.parse((await readBody(req)) || '{}')
1041
+ : {}
1042
+ writeJson(res, 200, await queryUsage(ctx, controller.signal, payload))
1043
+ } catch (err) {
1044
+ writeJson(res, 200, {
1045
+ ok: false,
1046
+ error: { code: 'internal', message: `查询失败:${err instanceof Error ? err.message : String(err)}`, details: {} },
1047
+ })
1048
+ }
1049
+ },
1050
+ }), 'model-usage: /model-usage/balance')
1051
+ // Read-only provider/key directory (never a key value), for the 说明 dialog.
1052
+ ctx.effect(() => ctx.webServer.register({
1053
+ kind: 'exact',
1054
+ path: '/model-usage/providers',
1055
+ handler: async (req, res) => {
1056
+ if (rejected(ctx, req, res)) return
1057
+ try {
1058
+ writeJson(res, 200, { ok: true, value: await providerRows(ctx) })
1059
+ } catch (err) {
1060
+ writeJson(res, 200, {
1061
+ ok: false,
1062
+ error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
1063
+ })
1064
+ }
1065
+ },
1066
+ }), 'model-usage: /model-usage/providers')
1067
+ // Routable models (with the usage API each route resolves) for the manual picker.
1068
+ ctx.effect(() => ctx.webServer.register({
1069
+ kind: 'exact',
1070
+ path: '/model-usage/models',
1071
+ handler: async (req, res) => {
1072
+ if (rejected(ctx, req, res)) return
1073
+ try {
1074
+ writeJson(res, 200, { ok: true, value: await modelCatalogRows(ctx) })
1075
+ } catch (err) {
1076
+ writeJson(res, 200, {
1077
+ ok: false,
1078
+ error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
1079
+ })
1080
+ }
1081
+ },
1082
+ }), 'model-usage: /model-usage/models')
1083
+ }