@loommii/dsh-provider-usage 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1104 @@
1
+ // dsh-provider-usage — Host half (M2 简易版,多供应商)。
2
+ // 两类适配器(对齐 cc-switch 的 UsageData 统一模型):
3
+ // usage-percent(订阅型):OpenCode Go {{baseUrl}}/v1/usage → rolling/weekly/monthly %
4
+ // balance-json(余额型) :DeepSeek {{baseUrl}}/user/balance → balance_infos[]
5
+ // 凭证链 per provider:DSH 设置 provider.apiKeyEnv → 凭证服务 → 环境变量。
6
+ // 路由:/api/provider-usage/opencode-go(兼容旧版)、/api/provider-usage/query?provider=<id>、
7
+ // /api/provider-usage/templates / credentials / dsh-providers / credential-refs(仅回环)。
8
+ // 本地 Token 统计(v0.5.0):/api/provider-usage/local-usage(累计+按模型汇总,按天物化存储),
9
+ // 数据源 = 自读 $DSH_HOME/sessions 会话文件(fzstd 解压,assistant/message 事件 data.usage),只读、不联网;
10
+ // 存储 = $DSH_HOME/provider-usage/daily-stats/YYYY-MM-DD.json:历史天封存(deps mtime 校验),只算今天。
11
+ // 缓存:每 provider 独立 30s 新鲜窗口 + 并发去重 + 3 快照环;stale-while-error。
12
+
13
+ import { join, dirname } from 'node:path'
14
+ import { stat as fsStat } from 'node:fs/promises'
15
+ import { readFileSync } from 'node:fs'
16
+ import { createSecureStore, resolveProviderUsageDir } from './secure-store.js'
17
+ import * as daily from './daily-stats.js'
18
+
19
+ export const name = 'provider-usage'
20
+
21
+ /** 插件版本单一来源:package.json(UA 标识用;bundle 内嵌等读不到时回退字面量)。 */
22
+ const PLUGIN_VERSION = (() => {
23
+ try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.6.0' }
24
+ catch { return '0.6.0' }
25
+ })()
26
+ const USER_AGENT = 'dsh-provider-usage/' + PLUGIN_VERSION
27
+
28
+ /** 插件需要的运行时服务。本地统计不依赖 sessionQuery(自读会话文件)。 */
29
+ export const inject = ['webServer', 'settings', 'credentials']
30
+
31
+ const DEFAULT_TIMEOUT_MS = 15000
32
+ /** Command Code 次要端点(subscriptions)超时:失败只降级不致命,不拖累整体响应。 */
33
+ const SUBSCRIPTION_TIMEOUT_MS = 5000
34
+ const DEFAULT_PROVIDER_ID = 'opencode-go'
35
+ /** 缓存新鲜窗口:30s 内直接复用上次成功结果(per provider)。 */
36
+ const CACHE_FRESH_MS = 30000
37
+ /** 诊断快照环大小。 */
38
+ const SNAPSHOT_LIMIT = 3
39
+ /** 解析逻辑版本,改动加 1。 */
40
+ const PARSE_VERSION = 1
41
+
42
+ // ── 本地 Token 统计(DSH 会话日志,v0.5.0)──
43
+ const LOCAL_MAX_SESSIONS = 30
44
+ const LOCAL_PARSE_VERSION = 2
45
+ /** deps 校验周期:每 5 分钟重新校验一次历史天文件的依赖 mtime(compaction 改写兜底)。 */
46
+ const LOCAL_DEPS_CHECK_MS = 300000
47
+
48
+ /** 内置适配器注册表(URL 由插件自身维护,不读 DSH 内置目录 —— 2026-08-24 决策)。
49
+ * 用户可在客户端配置多个"供应商实例",每个实例绑定一个适配器 + 自定义名称 + key 引用。 */
50
+ const ADAPTERS = [
51
+ {
52
+ id: 'usage-percent',
53
+ displayName: 'OpenCode Go(订阅额度)',
54
+ baseUrl: 'https://opencode.ai/zen/go',
55
+ usagePath: '/v1/usage',
56
+ defaultCredentialRef: 'OPENCODE_GO_API_KEY',
57
+ settingsProviderKey: 'opencode-go',
58
+ description: 'OpenCode Go 订阅额度(5小时/7天/月度窗口百分比)',
59
+ },
60
+ {
61
+ id: 'balance-json',
62
+ displayName: 'DeepSeek',
63
+ baseUrl: 'https://api.deepseek.com',
64
+ usagePath: '/user/balance',
65
+ defaultCredentialRef: 'DEEPSEEK_API_KEY',
66
+ settingsProviderKey: 'deepseek',
67
+ description: 'DeepSeek 开放平台账户余额(CNY,balance_infos)',
68
+ },
69
+ {
70
+ // Command Code:DSH 非内置提供方(pi-ai 注册表无 commandcode),只允许「添加自定义提供方」
71
+ // (customOnly),Key 只存插件私有库(settingsProviderKey: null → 凭证链跳过 DSH 模型设置)。
72
+ id: 'commandcode-credits',
73
+ displayName: 'Command Code(订阅+余额)',
74
+ baseUrl: 'https://api.commandcode.ai',
75
+ usagePath: '/alpha/billing/credits',
76
+ subscriptionPath: '/alpha/billing/subscriptions',
77
+ defaultCredentialRef: 'COMMANDCODE_API_KEY',
78
+ settingsProviderKey: null,
79
+ customOnly: true,
80
+ description: 'Command Code 订阅使用量(5小时/周窗口)+ 月度剩余额度(USD)',
81
+ },
82
+ ]
83
+
84
+ /** 旧 provider id → 适配器 id(向后兼容 /api/provider-usage/opencode-go 与 query?provider=…)。 */
85
+ const LEGACY_PROVIDER_MAP = { 'opencode-go': 'usage-percent', 'deepseek-balance': 'balance-json' }
86
+
87
+ /** Command Code 混合卡适配器(模块级常量,避免每次查询重复 find)。 */
88
+ const COMMANDCODE_ADAPTER = ADAPTERS.find((a) => a.id === 'commandcode-credits') || null
89
+
90
+ function isLoopbackRequest(req) {
91
+ const addr = req.socket && req.socket.remoteAddress
92
+ if (addr !== '127.0.0.1' && addr !== '::1' && addr !== '::ffff:127.0.0.1') return false
93
+ const host = req.headers && req.headers.host
94
+ if (host) {
95
+ const hostname = String(host).replace(/:[0-9]+$/, '').toLowerCase()
96
+ if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '[::1]') return false
97
+ }
98
+ return true
99
+ }
100
+
101
+ /** 掩码 key:前 4 + … + 后 4。 */
102
+ function maskKey(key) {
103
+ if (typeof key !== 'string' || key.length === 0) return null
104
+ if (key.length <= 8) return '***'
105
+ return key.slice(0, 4) + '…' + key.slice(-4)
106
+ }
107
+
108
+ /** 解析指定 key 引用的 API Key:provider 配置 apiKeyEnv → DSH 凭证 → 环境变量。
109
+ * spec = { credentialRef, settingsProviderKey }。 */
110
+ /**
111
+ * 解析 Key 引用。
112
+ * - fromVault=true:手动实例显式来源=插件私有库 → 【直接】解密私有库,跳过 DSH 链(同名也不冲突)
113
+ * - fromVault=false:DSH 模型设置 → DSH 凭证 → 环境变量 → 私有库兜底(兼容旧实例)
114
+ */
115
+ async function resolveCredentialFor(ctx, spec, storeFn, fromVault) {
116
+ let ref = spec.credentialRef
117
+ if (!fromVault) {
118
+ try {
119
+ let cfg = null
120
+ try { cfg = ctx.settings.get('llm-pi-ai') } catch { /* ignore */ }
121
+ if (!cfg) {
122
+ try { cfg = ctx.settings.get('settings.llm-pi-ai') } catch { /* ignore */ }
123
+ }
124
+ const p = cfg && cfg.providers && cfg.providers[spec.settingsProviderKey]
125
+ if (p && typeof p.apiKeyEnv === 'string' && p.apiKeyEnv.trim() !== '') ref = p.apiKeyEnv.trim()
126
+ } catch { /* ignore */ }
127
+ try {
128
+ const creds = ctx.credentials || ctx.get('credentials')
129
+ if (creds && typeof creds.resolve === 'function') {
130
+ const hit = await creds.resolve(ref)
131
+ if (hit && hit.value) return { key: String(hit.value), source: 'credential:' + ref }
132
+ }
133
+ } catch { /* ignore */ }
134
+ try {
135
+ const env = typeof process !== 'undefined' ? process.env[ref] : undefined
136
+ if (env) return { key: env, source: 'env:' + ref }
137
+ } catch { /* ignore */ }
138
+ }
139
+ // 私有加密库:vault 直取,或作为旧链兜底
140
+ if (typeof storeFn === 'function') {
141
+ try {
142
+ const s = await storeFn()
143
+ const v = await s.get(ref)
144
+ if (v) return { key: v, source: 'provider-usage:' + ref }
145
+ } catch { /* ignore */ }
146
+ }
147
+ return null
148
+ }
149
+
150
+ // ── 解析(订阅型:与 cc-switch extractor 语义一致)────────────────
151
+
152
+ function getUsedPercent(usage, name) {
153
+ const item = usage && usage[name]
154
+ if (!item || item.percent === undefined || item.percent === null) return null
155
+ const value = Number(item.percent)
156
+ if (Number.isNaN(value)) return null
157
+ return Math.max(0, Math.min(100, value))
158
+ }
159
+
160
+ function formatRemainingPercent(usedPercent) {
161
+ if (usedPercent === null) return '--'
162
+ return Math.round(100 - usedPercent) + '%'
163
+ }
164
+
165
+ function formatCountdown(isoTime) {
166
+ if (!isoTime) return '--'
167
+ const resetTimestamp = Date.parse(isoTime)
168
+ if (Number.isNaN(resetTimestamp)) return '--'
169
+ const seconds = Math.max(0, Math.floor((resetTimestamp - Date.now()) / 1000))
170
+ if (seconds <= 0) return '已到期'
171
+ const days = Math.floor(seconds / 86400)
172
+ let rest = seconds % 86400
173
+ const hours = Math.floor(rest / 3600)
174
+ rest = rest % 3600
175
+ const minutes = Math.floor(rest / 60)
176
+ if (days > 0) return days + 'd' + hours + 'h'
177
+ if (hours > 0) return hours + 'h' + minutes + 'm'
178
+ if (minutes > 0) return minutes + 'm'
179
+ return Math.floor(rest) + 's'
180
+ }
181
+
182
+ /** 把官方响应换算成结构化结果(含用户脚本同款 extra 文案)。 */
183
+ function parseUsage(body) {
184
+ let data = body
185
+ if (typeof data === 'string') {
186
+ try { data = JSON.parse(data) } catch { return { error: 'bad-json' } }
187
+ }
188
+ const usage = data && data.usage
189
+ if (!usage) return { error: 'no-usage' }
190
+
191
+ const rollingUsed = getUsedPercent(usage, 'rolling')
192
+ const weeklyUsed = getUsedPercent(usage, 'weekly')
193
+ const monthlyUsed = getUsedPercent(usage, 'monthly')
194
+ const monthlyRemaining = monthlyUsed === null ? 0 : 100 - monthlyUsed
195
+
196
+ const windowOf = (name, usedPct) => {
197
+ const item = usage[name] || {}
198
+ return {
199
+ status: typeof item.status === 'string' ? item.status : 'ok',
200
+ usedPct,
201
+ remainingPct: usedPct === null ? null : Math.round((100 - usedPct) * 100) / 100,
202
+ resetsAt: typeof item.resetsAt === 'string' && item.resetsAt ? item.resetsAt : null,
203
+ }
204
+ }
205
+
206
+ return {
207
+ planName: 'OpenCode Go',
208
+ remaining: monthlyRemaining,
209
+ unit: '%',
210
+ extra:
211
+ '5小时: ' + formatRemainingPercent(rollingUsed) +
212
+ ' 7天: ' + formatRemainingPercent(weeklyUsed) +
213
+ ' ◷ ' + formatCountdown(usage.monthly && usage.monthly.resetsAt) +
214
+ ' 5小时重置 ' + formatCountdown(usage.rolling && usage.rolling.resetsAt) +
215
+ ' · 7天重置 ' + formatCountdown(usage.weekly && usage.weekly.resetsAt),
216
+ windows: {
217
+ rolling: windowOf('rolling', rollingUsed),
218
+ weekly: windowOf('weekly', weeklyUsed),
219
+ monthly: windowOf('monthly', monthlyUsed),
220
+ },
221
+ cards: null,
222
+ isValid: true,
223
+ invalidMessage: null,
224
+ }
225
+ }
226
+
227
+ // ── 解析(余额型:DeepSeek /user/balance,官方文档 + cc-switch balance.rs 语义)──
228
+
229
+ /** 把 DeepSeek balance 响应换算成结构化结果(每币种一张卡)。 */
230
+ function parseBalance(body) {
231
+ let data = body
232
+ if (typeof data === 'string') {
233
+ try { data = JSON.parse(data) } catch { return { error: 'bad-json' } }
234
+ }
235
+ const isAvailable = data && typeof data.is_available === 'boolean'
236
+ ? data.is_available
237
+ : true
238
+ const infos = data && Array.isArray(data.balance_infos) ? data.balance_infos : null
239
+ if (!infos) return { error: 'no-balance' }
240
+
241
+ const cards = infos.map((info) => {
242
+ const currency = typeof info.currency === 'string' && info.currency ? info.currency : 'CNY'
243
+ const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
244
+ return {
245
+ currency,
246
+ remaining: toNum(info.total_balance),
247
+ granted: toNum(info.granted_balance),
248
+ toppedUp: toNum(info.topped_up_balance),
249
+ isValid: isAvailable,
250
+ invalidMessage: isAvailable ? null : '余额不足(账户不可用)',
251
+ }
252
+ })
253
+
254
+ const fmt = (n) => (n === null ? '--' : n.toFixed(2))
255
+ const parts = cards.map((c) => {
256
+ let s = c.currency + ' ¥' + fmt(c.remaining)
257
+ if (c.granted !== null || c.toppedUp !== null) {
258
+ s += '(赠送 ' + fmt(c.granted) + ' + 充值 ' + fmt(c.toppedUp) + ')'
259
+ }
260
+ return s
261
+ })
262
+ const extra = parts.join(';') + (isAvailable ? '' : ' · 账户不可用(余额不足)')
263
+
264
+ return {
265
+ planName: 'DeepSeek 余额',
266
+ remaining: cards.length > 0 ? cards[0].remaining : null,
267
+ unit: cards.length > 0 ? cards[0].currency : 'CNY',
268
+ extra,
269
+ windows: null,
270
+ cards,
271
+ isValid: isAvailable,
272
+ invalidMessage: isAvailable ? null : '余额不足(账户不可用)',
273
+ }
274
+ }
275
+
276
+ // ── 解析(Command Code:订阅使用量 + 月度剩余额度混合卡)──────────────
277
+ // 官方 API(api.commandcode.ai):
278
+ // GET /alpha/billing/credits → credits.monthlyCredits(月度剩余 $)+ windowLimits.{fiveHour,weekly}.{used,cap,resetAt}
279
+ // GET /alpha/billing/subscriptions → data.{planId,currentPeriodEnd,status}
280
+ // 官方【不提供月用量窗口】,月用量 = 计划总额 − monthlyCredits(调研方案 A,Rainytoken 同款减法)。
281
+ // 计划总额依赖 planId → 映射表(官方新增计划需同步更新;未知计划安全降级为仅窗口+剩余)。
282
+
283
+ /** Command Code 订阅计划 → 月度额度(USD)与展示名(调研文档 §3,2026-08 实测)。 */
284
+ const COMMANDCODE_PLANS = {
285
+ 'individual-go': { quota: 10, name: 'Go' },
286
+ 'individual-goat': { quota: 70, name: 'GOAT' },
287
+ 'individual-pro': { quota: 80, name: 'Pro' },
288
+ 'individual-max': { quota: 150, name: 'Max' },
289
+ 'individual-ultra': { quota: 300, name: 'Ultra' },
290
+ }
291
+
292
+ const COMMANDCODE_PLAN_FALLBACK_NAME = 'Command Code'
293
+ /** 月已用百分比钳制到 [0,100];无法推算(无总额/无剩余)返回 null。 */
294
+ function commandcodeMonthlyUsedPct(totalQuota, monthlyCredits) {
295
+ if (totalQuota === null || totalQuota === undefined || !(totalQuota > 0)) return null
296
+ const remaining = Number(monthlyCredits)
297
+ if (!Number.isFinite(remaining)) return null
298
+ return Math.max(0, Math.min(100, ((totalQuota - remaining) / totalQuota) * 100))
299
+ }
300
+
301
+ /** 把 credits + subscriptions 两个端点的响应换算成混合模型(订阅使用量 + 剩余额度)。 */
302
+ function parseCommandCodeCredits(creditsBody, subBody) {
303
+ let credits = creditsBody
304
+ if (typeof credits === 'string') { try { credits = JSON.parse(credits) } catch { return { error: 'bad-json' } } }
305
+ const creditsData = credits && credits.credits
306
+ const limits = credits && credits.windowLimits
307
+ if (creditsData === undefined || creditsData === null || limits === undefined || limits === null) {
308
+ return { error: 'no-credits' }
309
+ }
310
+ const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
311
+ const monthlyCredits = toNum(creditsData.monthlyCredits)
312
+ const purchasedCredits = toNum(creditsData.purchasedCredits)
313
+ const freeCredits = toNum(creditsData.freeCredits)
314
+ const fiveHour = limits.fiveHour || {}
315
+ const weekly = limits.weekly || {}
316
+ const fiveHourCap = toNum(fiveHour.cap)
317
+ const weeklyCap = toNum(weekly.cap)
318
+ const fiveHourUsed = toNum(fiveHour.used)
319
+ const weeklyUsed = toNum(weekly.used)
320
+ const fiveHourResetAt = typeof fiveHour.resetAt === 'number' && Number.isFinite(fiveHour.resetAt)
321
+ ? new Date(fiveHour.resetAt).toISOString() : null
322
+ const weeklyResetAt = typeof weekly.resetAt === 'number' && Number.isFinite(weekly.resetAt)
323
+ ? new Date(weekly.resetAt).toISOString() : null
324
+
325
+ // 订阅端点:planId → 计划总额/名称;失败或未知计划安全降级
326
+ let sub = subBody
327
+ if (typeof sub === 'string') { try { sub = JSON.parse(sub) } catch { sub = null } }
328
+ const subData = sub && sub.success !== false && sub.data ? sub.data : null
329
+ const planId = subData && typeof subData.planId === 'string' ? subData.planId : null
330
+ const plan = planId ? COMMANDCODE_PLANS[planId] : null
331
+ const totalQuota = plan ? plan.quota : null
332
+ const planName = plan ? COMMANDCODE_PLAN_FALLBACK_NAME + '(' + plan.name + ')' : COMMANDCODE_PLAN_FALLBACK_NAME
333
+ const nextResetAt = subData && typeof subData.currentPeriodEnd === 'string' && subData.currentPeriodEnd
334
+ ? subData.currentPeriodEnd : null
335
+
336
+ const monthlyUsedPct = commandcodeMonthlyUsedPct(totalQuota, monthlyCredits)
337
+ const monthlyUsed = (monthlyUsedPct === null || totalQuota === null) ? null
338
+ : Math.max(0, totalQuota - monthlyCredits)
339
+
340
+ // resetsAt 命名对齐 parseUsage(client WindowRow 统一读 win.resetsAt)
341
+ const windowOf = (used, cap, resetAt) => {
342
+ const pct = (used !== null && cap !== null && cap > 0) ? Math.max(0, Math.min(100, (used / cap) * 100)) : null
343
+ return { usedPct: pct, used, cap, resetsAt: resetAt }
344
+ }
345
+
346
+ const extraParts = []
347
+ if (totalQuota !== null && monthlyUsed !== null) {
348
+ extraParts.push('月已用 ' + monthlyUsed.toFixed(2) + ' / ' + totalQuota.toFixed(2) + ' USD')
349
+ }
350
+ if (monthlyCredits !== null) extraParts.push('月剩余 ' + monthlyCredits.toFixed(2) + ' USD')
351
+ if (purchasedCredits !== null && purchasedCredits > 0) extraParts.push('付费包 ' + purchasedCredits.toFixed(2) + ' USD')
352
+ if (freeCredits !== null && freeCredits > 0) extraParts.push('赠送 ' + freeCredits.toFixed(2) + ' USD')
353
+ if (fiveHourResetAt) extraParts.push('5小时重置 ' + formatCountdown(fiveHourResetAt))
354
+ if (weeklyResetAt) extraParts.push('周重置 ' + formatCountdown(weeklyResetAt))
355
+
356
+ return {
357
+ planName,
358
+ remaining: monthlyCredits,
359
+ unit: 'USD',
360
+ used: monthlyUsed,
361
+ totalQuota,
362
+ // 顶层透传字段(对外协议,v0.6.0 起保留):月度周期重置时间 = currentPeriodEnd。
363
+ // 与 monthly.resetAt 同源;顶层供调试/外部脚本读取,卡片月窗口行读 monthly.resetAt。
364
+ nextResetAt,
365
+ extra: extraParts.join(';'),
366
+ windows: {
367
+ fiveHour: windowOf(fiveHourUsed, fiveHourCap, fiveHourResetAt),
368
+ weekly: windowOf(weeklyUsed, weeklyCap, weeklyResetAt),
369
+ },
370
+ monthly: { usedPct: monthlyUsedPct, used: monthlyUsed, totalQuota, remaining: monthlyCredits, resetsAt: nextResetAt },
371
+ cards: null,
372
+ isValid: true,
373
+ invalidMessage: null,
374
+ }
375
+ }
376
+
377
+ // ── 插件主体 ────────────────────────────────────────────────────────
378
+
379
+ export function apply(ctx, rawConfig) {
380
+ const config = {
381
+ baseUrlOverride: rawConfig && typeof rawConfig.baseUrl === 'string' && rawConfig.baseUrl
382
+ ? rawConfig.baseUrl.replace(/\/$/, '')
383
+ : null,
384
+ timeoutMs: rawConfig && typeof rawConfig.timeoutMs === 'number' && rawConfig.timeoutMs > 0
385
+ ? Math.min(rawConfig.timeoutMs, 30000)
386
+ : DEFAULT_TIMEOUT_MS,
387
+ // Command Code 次要端点超时(可覆盖:慢网络用户可调大;测试可调小)
388
+ subscriptionTimeoutMs: rawConfig && typeof rawConfig.subscriptionTimeoutMs === 'number' && rawConfig.subscriptionTimeoutMs > 0
389
+ ? Math.min(rawConfig.subscriptionTimeoutMs, 30000)
390
+ : SUBSCRIPTION_TIMEOUT_MS,
391
+ maxSessions: rawConfig && typeof rawConfig.maxSessions === 'number' && rawConfig.maxSessions > 0
392
+ ? Math.min(rawConfig.maxSessions, 100)
393
+ : LOCAL_MAX_SESSIONS,
394
+ // 本地 Token 统计:会话目录默认 $DSH_HOME/sessions(可覆盖);天文件存 $DSH_HOME/provider-usage/daily-stats/
395
+ sessionsDir: rawConfig && typeof rawConfig.sessionsDir === 'string' && rawConfig.sessionsDir
396
+ ? rawConfig.sessionsDir
397
+ : join(dirname(resolveProviderUsageDir()), 'sessions'),
398
+ dailyDir: join(resolveProviderUsageDir(), 'daily-stats'),
399
+ }
400
+
401
+ // cordis 配置的 baseUrl 仅覆盖 opencode-go(历史语义),其余 preset 用内置 URL(插件自维护)
402
+ const effectiveBaseUrl = (cfg) =>
403
+ cfg.id === 'usage-percent' && config.baseUrlOverride ? config.baseUrlOverride : cfg.baseUrl
404
+
405
+ // 私有加密凭证库(方案 B,懒初始化:首次使用时生成密钥)
406
+ let secure = null
407
+ async function secureStore() {
408
+ if (!secure) { secure = createSecureStore(resolveProviderUsageDir()); await secure.init() }
409
+ return secure
410
+ }
411
+
412
+ /** 某 Key 引用位于哪个库:'dsh' | 'vault' | 'both' | null(供客户端区分导入/手动实例,旧数据自愈)。 */
413
+ async function refStoreOf(name) {
414
+ let dshHit = false
415
+ const creds = ctx.credentials || ctx.get('credentials')
416
+ if (creds) {
417
+ try {
418
+ if (typeof creds.describe === 'function') {
419
+ const d = await creds.describe(name)
420
+ if (d && (d.source || d.value !== undefined || d.inherited !== undefined || d.status === 'configured')) dshHit = true
421
+ } else if (typeof creds.resolve === 'function') {
422
+ const r = await creds.resolve(name)
423
+ if (r && r.value !== undefined && r.value !== null) dshHit = true
424
+ }
425
+ } catch (e) { /* ignore */ }
426
+ }
427
+ let vaultHit = false
428
+ try { vaultHit = await (await secureStore()).has(name) } catch (e) { /* ignore */ }
429
+ if (dshHit && vaultHit) return 'both'
430
+ if (dshHit) return 'dsh'
431
+ if (vaultHit) return 'vault'
432
+ return null
433
+ }
434
+
435
+ /** 某 Key 引用是否已配置:DSH describe/resolve → 私有加密库。 */
436
+ async function refConfigured(name) {
437
+ return (await refStoreOf(name)) !== null
438
+ }
439
+
440
+ // per-adapter 状态:adapterId -> { lastGood, fetching }
441
+ const states = new Map()
442
+ function stateOf(adapterId) {
443
+ let s = states.get(adapterId)
444
+ if (!s) { s = { lastGood: null, fetching: null }; states.set(adapterId, s) }
445
+ return s
446
+ }
447
+
448
+ // 天文件内容缓存:轮询不再每次 readFile+parse 历史天(recompute/写入时刷新)
449
+ const dayCache = new Map()
450
+ async function cachedDay(day) {
451
+ let d = dayCache.get(day)
452
+ if (d === undefined) {
453
+ d = await daily.readDayFile(config.dailyDir, day)
454
+ if (d) dayCache.set(day, d)
455
+ if (dayCache.size > 2000) dayCache.clear()
456
+ }
457
+ return d || null
458
+ }
459
+ function rememberDay(day, data) {
460
+ dayCache.set(day, data)
461
+ }
462
+
463
+ function recordSnapshot(adapterId, snapshot) {
464
+ const s = stateOf(adapterId)
465
+ s.snapshots = s.snapshots || []
466
+ s.snapshots.push(snapshot)
467
+ if (s.snapshots.length > SNAPSHOT_LIMIT) s.snapshots = s.snapshots.slice(-SNAPSHOT_LIMIT)
468
+ }
469
+
470
+ const okResponse = (cfg, value, state, extraFields) => ({
471
+ ok: true,
472
+ providerId: cfg.id,
473
+ displayName: cfg.displayName,
474
+ config: { baseUrl: effectiveBaseUrl(cfg) + cfg.usagePath, timeoutMs: config.timeoutMs, provider: cfg.id },
475
+ valid: true,
476
+ planName: value.planName,
477
+ remaining: value.remaining,
478
+ unit: value.unit,
479
+ used: value.used,
480
+ totalQuota: value.totalQuota,
481
+ nextResetAt: value.nextResetAt,
482
+ monthly: value.monthly,
483
+ extra: value.extra,
484
+ windows: value.windows,
485
+ cards: value.cards,
486
+ isValid: value.isValid !== false,
487
+ invalidMessage: value.invalidMessage || null,
488
+ fetchedAt: state.lastGood ? state.lastGood.queriedAt : Date.now(),
489
+ cached: false,
490
+ stale: false,
491
+ error: null,
492
+ credential: state.lastGood ? state.lastGood.credential : null,
493
+ snapshots: (state.snapshots || []).slice(),
494
+ parseVersion: PARSE_VERSION,
495
+ ...extraFields,
496
+ })
497
+
498
+ /** 对单个 GET 端点发起请求;返回 { status, ok, body, error }(error 为 null 表示成功)。
499
+ * timeoutMs 可选:次要端点用短超时,避免拖累整体响应。 */
500
+ async function fetchEndpoint(cfg, resolved, path, timeoutMs) {
501
+ const attemptAt = Date.now()
502
+ const url = effectiveBaseUrl(cfg) + path
503
+ const controller = new AbortController()
504
+ const timer = setTimeout(() => controller.abort(), timeoutMs || config.timeoutMs)
505
+ let res
506
+ try {
507
+ res = await fetch(url, {
508
+ method: 'GET',
509
+ headers: {
510
+ authorization: 'Bearer ' + resolved.key,
511
+ accept: 'application/json',
512
+ 'user-agent': USER_AGENT,
513
+ },
514
+ signal: controller.signal,
515
+ })
516
+ } catch (e) {
517
+ clearTimeout(timer)
518
+ const aborted = e && (e.name === 'AbortError' || e.message === 'AbortError')
519
+ return { attemptAt, status: null, ok: false, body: null, error: aborted ? 'timeout' : 'network' }
520
+ }
521
+ clearTimeout(timer)
522
+ if (res.status === 401 || res.status === 403) {
523
+ return { attemptAt, status: res.status, ok: false, body: null, error: 'unauthorized' }
524
+ }
525
+ if (!res.ok) {
526
+ return { attemptAt, status: res.status, ok: false, body: null, error: 'http-' + res.status }
527
+ }
528
+ let body = null
529
+ try { body = await res.json() } catch { /* ignore */ }
530
+ if (body === null) return { attemptAt, status: res.status, ok: false, body: null, error: 'bad-json' }
531
+ return { attemptAt, status: res.status, ok: true, body, error: null }
532
+ }
533
+
534
+ async function doQuery(cfg, credentialRef, fromVault) {
535
+ const ref = (typeof credentialRef === 'string' && credentialRef.trim() !== '') ? credentialRef.trim() : cfg.defaultCredentialRef
536
+ const key = cfg.id + '|' + ref + '|' + (fromVault ? 'vault' : 'dsh')
537
+ const state = stateOf(key)
538
+ const resolved = await resolveCredentialFor(ctx, { credentialRef: ref, settingsProviderKey: cfg.settingsProviderKey }, secureStore, fromVault === true)
539
+ if (!resolved) {
540
+ recordSnapshot(key, { attemptAt: Date.now(), httpStatus: null, error: 'no-api-key' })
541
+ return fail(cfg, state, 'no-api-key', '未找到 ' + cfg.displayName + ' API Key:请在 DSH 模型设置中配置,或设置环境变量 ' + ref)
542
+ }
543
+
544
+ const credential = { source: resolved.source, keyHint: maskKey(resolved.key) }
545
+
546
+ // Command Code:并行查两个端点(credits 必查、subscriptions 可选);其余适配器单端点。
547
+ // 主端点失败 → 走统一失败路径(stale 保留旧值);订阅端点失败 → 降级展示(窗口+剩余仍在)。
548
+ if (COMMANDCODE_ADAPTER && cfg.id === COMMANDCODE_ADAPTER.id) {
549
+ const [creditsRes, subRes] = await Promise.all([
550
+ fetchEndpoint(cfg, resolved, cfg.usagePath),
551
+ // subscriptions 是次要端点:短超时,credits 成功而它卡死时整体不被拖到全局超时
552
+ fetchEndpoint(cfg, resolved, cfg.subscriptionPath, config.subscriptionTimeoutMs),
553
+ ])
554
+ recordSnapshot(key, { attemptAt: creditsRes.attemptAt, httpStatus: creditsRes.status, error: creditsRes.error })
555
+ if (!creditsRes.ok) {
556
+ const msg = creditsRes.error === 'unauthorized'
557
+ ? 'API Key 无效或已过期(401/403)'
558
+ : creditsRes.error === 'timeout'
559
+ ? '请求超时(' + config.timeoutMs + 'ms)'
560
+ : creditsRes.error === 'network'
561
+ ? '网络请求失败'
562
+ : creditsRes.error === 'bad-json'
563
+ ? '接口响应不是有效 JSON'
564
+ : '接口返回 HTTP ' + creditsRes.status
565
+ if (creditsRes.error === 'unauthorized') return fail(cfg, state, 'unauthorized', msg, creditsRes.status)
566
+ if (creditsRes.error === 'timeout' || creditsRes.error === 'network') return failTransient(cfg, state, creditsRes.error, msg)
567
+ return fail(cfg, state, creditsRes.error === 'bad-json' ? 'parse' : 'http', msg, creditsRes.status)
568
+ }
569
+ let parsed = parseCommandCodeCredits(creditsRes.body, subRes.ok ? subRes.body : null)
570
+ if (parsed.error === 'no-credits') {
571
+ recordSnapshot(key, { attemptAt: creditsRes.attemptAt, httpStatus: creditsRes.status, error: 'no-credits' })
572
+ return fail(cfg, state, 'parse', '没有找到 Command Code 额度数据(credits/windowLimits)', creditsRes.status)
573
+ }
574
+ if (parsed.error === 'bad-json') {
575
+ recordSnapshot(key, { attemptAt: creditsRes.attemptAt, httpStatus: creditsRes.status, error: 'bad-json' })
576
+ return fail(cfg, state, 'parse', '接口返回的数据不是有效 JSON', creditsRes.status)
577
+ }
578
+ const good = { queriedAt: Date.now(), credential, parsed }
579
+ state.lastGood = good
580
+ return okResponse(cfg, parsed, state, { fetchedAt: good.queriedAt, credential })
581
+ }
582
+
583
+ const attemptAt = Date.now()
584
+ const url = effectiveBaseUrl(cfg) + cfg.usagePath
585
+ const controller = new AbortController()
586
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs)
587
+ let res
588
+ try {
589
+ res = await fetch(url, {
590
+ method: 'GET',
591
+ headers: {
592
+ authorization: 'Bearer ' + resolved.key,
593
+ accept: 'application/json',
594
+ 'user-agent': USER_AGENT,
595
+ },
596
+ signal: controller.signal,
597
+ })
598
+ } catch (e) {
599
+ clearTimeout(timer)
600
+ const aborted = e && (e.name === 'AbortError' || e.message === 'AbortError')
601
+ recordSnapshot(key, { attemptAt, httpStatus: null, error: aborted ? 'timeout' : 'network' })
602
+ return aborted ? failTransient(cfg, state, 'timeout', '请求超时(' + config.timeoutMs + 'ms)') : failTransient(cfg, state, 'network', '网络请求失败:' + String((e && e.message) || e))
603
+ }
604
+ clearTimeout(timer)
605
+
606
+ if (res.status === 401 || res.status === 403) {
607
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'unauthorized' })
608
+ return fail(cfg, state, 'unauthorized', res.status === 401 ? 'API Key 无效或已过期(401)' : '无访问权限(403)', res.status)
609
+ }
610
+ if (!res.ok) {
611
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'http-' + res.status })
612
+ return fail(cfg, state, 'http', '接口返回 HTTP ' + res.status, res.status)
613
+ }
614
+
615
+ let body = null
616
+ try { body = await res.json() } catch { /* ignore */ }
617
+ if (body === null) {
618
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'bad-json' })
619
+ return fail(cfg, state, 'parse', '接口响应不是有效 JSON', res.status)
620
+ }
621
+
622
+ const parsed = cfg.id === 'balance-json' ? parseBalance(body) : parseUsage(body)
623
+ if (parsed.error === 'no-usage') {
624
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'no-usage' })
625
+ return fail(cfg, state, 'parse', '没有找到 OpenCode Go 用量数据', res.status)
626
+ }
627
+ if (parsed.error === 'no-balance') {
628
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'no-balance' })
629
+ return fail(cfg, state, 'parse', '没有找到 DeepSeek 余额数据(balance_infos)', res.status)
630
+ }
631
+ if (parsed.error === 'bad-json') {
632
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'bad-json' })
633
+ return fail(cfg, state, 'parse', '接口返回的数据不是有效 JSON', res.status)
634
+ }
635
+
636
+ recordSnapshot(key, { attemptAt, httpStatus: res.status, error: null })
637
+ const good = { queriedAt: Date.now(), credential, parsed }
638
+ state.lastGood = good
639
+ return okResponse(cfg, parsed, state, { fetchedAt: good.queriedAt, credential })
640
+ }
641
+
642
+ function fail(cfg, state, type, message, httpStatus) {
643
+ if (state.lastGood) {
644
+ const prev = state.lastGood
645
+ return { ...okResponse(cfg, prev.parsed, state, { fetchedAt: prev.queriedAt, credential: prev.credential }), cached: true, stale: true, ok: false, error: { type, message, httpStatus: httpStatus || null } }
646
+ }
647
+ return { ok: false, providerId: cfg.id, displayName: cfg.displayName, valid: null, planName: null, remaining: null, unit: '%', used: null, totalQuota: null, nextResetAt: null, monthly: null, extra: null, windows: null, cards: null, isValid: false, invalidMessage: null, fetchedAt: null, cached: false, stale: false, error: { type, message, httpStatus: httpStatus || null }, credential: null, snapshots: (state.snapshots || []).slice(), parseVersion: PARSE_VERSION, config: { baseUrl: effectiveBaseUrl(cfg) + cfg.usagePath, timeoutMs: config.timeoutMs, provider: cfg.id } }
648
+ }
649
+
650
+ function failTransient(cfg, state, type, message) {
651
+ if (state.lastGood) {
652
+ const prev = state.lastGood
653
+ return { ...okResponse(cfg, prev.parsed, state, { fetchedAt: prev.queriedAt, credential: prev.credential }), cached: true, stale: true, ok: false, error: { type, message, httpStatus: null } }
654
+ }
655
+ return fail(cfg, state, type, message, null)
656
+ }
657
+
658
+ /** 查询参数:adapter(新)/ provider(旧兼容)/ ref(key 引用,客户端实例可自定义)。 */
659
+ async function query(params) {
660
+ const provider = params && params.provider
661
+ const adapter = params && params.adapter
662
+ const mapped = provider ? LEGACY_PROVIDER_MAP[provider] : null
663
+ // 显式给了 provider/adapter 但解析不到 → 未知(404);完全没给 → 默认 usage-percent
664
+ const id = adapter || mapped || (provider || adapter ? null : 'usage-percent')
665
+ const rawLabel = adapter || provider || DEFAULT_PROVIDER_ID
666
+ const cfg = ADAPTERS.find((a) => a.id === id)
667
+ if (!cfg) {
668
+ return {
669
+ ok: false, providerId: rawLabel, displayName: null, valid: null, planName: null,
670
+ remaining: null, unit: '%', extra: null, windows: null, cards: null,
671
+ isValid: false, invalidMessage: null, fetchedAt: null, cached: false, stale: false,
672
+ error: { type: 'unknown-provider', message: '未知供应商:' + rawLabel, httpStatus: 404 },
673
+ credential: null, snapshots: [], parseVersion: PARSE_VERSION,
674
+ config: { baseUrl: null, timeoutMs: config.timeoutMs, provider: rawLabel },
675
+ }
676
+ }
677
+ const ref = (typeof (params && params.ref) === 'string' && params.ref.trim() !== '') ? params.ref.trim() : cfg.defaultCredentialRef
678
+ const fromVault = !!(params && params.source === 'vault')
679
+ const force = !!(params && (params.noCache === '1' || params.noCache === 'true'))
680
+ const key = id + '|' + ref + '|' + (fromVault ? 'vault' : 'dsh')
681
+ const state = stateOf(key)
682
+ // 30s 新鲜窗口内直接返回缓存(noCache=1 跳过:改 Key 后强制刷新)
683
+ if (!force && state.lastGood && Date.now() - state.lastGood.queriedAt < CACHE_FRESH_MS) {
684
+ const prev = state.lastGood
685
+ return { ...okResponse(cfg, prev.parsed, state, { fetchedAt: prev.queriedAt, credential: prev.credential }), cached: true }
686
+ }
687
+ if (!force && state.fetching) return state.fetching
688
+ state.fetching = doQuery(cfg, ref, fromVault)
689
+ .catch((e) => failTransient(cfg, state, 'internal', String((e && e.message) || e)))
690
+ .finally(() => { state.fetching = null })
691
+ return state.fetching
692
+ }
693
+
694
+ // ── 本地 Token 统计(DSH 会话日志,按天物化存储;历史天封存,只算今天)──
695
+ function localFail(state, type, message) {
696
+ if (state.lastGood) {
697
+ return { ...state.lastGood.payload, cached: true, stale: true, ok: false, error: { type, message, httpStatus: null } }
698
+ }
699
+ return { ok: false, error: { type, message, httpStatus: null }, provider: null, detected: true, days: 0, scanned: 0, totals: null, byModel: null, providers: [], fetchedAt: null, cached: false, stale: false, snapshots: (state.snapshots || []).slice(), parseVersion: LOCAL_PARSE_VERSION }
700
+ }
701
+
702
+ /** 校验待查天的 deps:unique 路径并行 stat,按天判定 mtime 变化/缺失,必要时重算该天并刷新缓存。 */
703
+ async function validateHistoryDeps(dayData, pending, state) {
704
+ const uniquePaths = new Set()
705
+ for (const day of pending) {
706
+ for (const p of Object.keys(dayData[day].deps || {})) uniquePaths.add(p)
707
+ }
708
+ // path -> { currentMtime } | { missing: true }(只 stat 一次,按天各自比较)
709
+ const current = new Map()
710
+ await Promise.all([...uniquePaths].map(async (path) => {
711
+ try {
712
+ const st = await fsStat(path)
713
+ current.set(path, { currentMtime: st.mtimeMs })
714
+ } catch { current.set(path, { missing: true }) }
715
+ }))
716
+ for (const day of pending) {
717
+ const data = dayData[day]
718
+ const changed = []
719
+ const missing = []
720
+ for (const p of Object.keys(data.deps || {})) {
721
+ const c = current.get(p)
722
+ if (!c) continue
723
+ if (c.missing) missing.push(p)
724
+ else if (Math.abs(c.currentMtime - Number(data.deps[p])) > 1) changed.push(p)
725
+ }
726
+ if (changed.length > 0 || missing.length > 0) {
727
+ const repaired = await recomputeDay(day, data.deps || {})
728
+ data.byProvider = repaired.byProvider
729
+ data.byModel = repaired.byModel
730
+ }
731
+ state.validatedDays.add(day)
732
+ }
733
+ }
734
+ /** 重算某一天:读 deps 中仍存在的会话文件,fold 当天窗口,重新写回天文件。 */
735
+ async function recomputeDay(day, deps) {
736
+ const start = daily.dayStartMs(day)
737
+ const end = daily.dayStartMs(daily.addDays(day, 1))
738
+ const out = { byProvider: {}, byModel: {} }
739
+ const newDeps = {}
740
+ const paths = deps && Object.keys(deps).length > 0 ? Object.keys(deps) : null
741
+ if (paths) {
742
+ for (const p of paths) {
743
+ let mtimeMs = null
744
+ try { const st = await fsStat(p); mtimeMs = st.mtimeMs } catch { continue } // 会话已删:该天剔除其贡献
745
+ const text = await daily.readSessionFile(p)
746
+ if (!text) continue
747
+ daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(text), start, end, null))
748
+ newDeps[p] = mtimeMs
749
+ }
750
+ }
751
+ const doc = { version: daily.DAILY_VERSION, date: day, deps: newDeps, byProvider: out.byProvider, byModel: out.byModel }
752
+ await daily.writeDayFile(config.dailyDir, day, doc)
753
+ rememberDay(day, doc)
754
+ return out
755
+ }
756
+
757
+ /** 首次全量回填:并行解码全部会话文件,按事件时间分天落盘(含今天与 unknown 桶),一次性。 */
758
+ async function backfillAll(nowMs) {
759
+ const toDay = daily.dayKey(nowMs)
760
+ const files = await daily.scanSessionFiles(config.sessionsDir, config.maxSessions, 0)
761
+ const days = new Map()
762
+ const cursors = await daily.loadCursors(config.dailyDir)
763
+ await Promise.all(files.map(async (f) => {
764
+ const text = await daily.readSessionFile(f.path)
765
+ cursors[f.path] = { offset: f.size || 0, mtimeMs: f.mtimeMs }
766
+ if (!text) return
767
+ const folded = daily.foldEventsByDays(daily.parseEvents(text))
768
+ for (const [day, fold] of Object.entries(folded)) {
769
+ let bucket = days.get(day)
770
+ if (!bucket) { bucket = { byProvider: {}, byModel: {}, deps: {} }; days.set(day, bucket) }
771
+ daily.mergeTotals(bucket, fold)
772
+ bucket.deps[f.path] = f.mtimeMs
773
+ }
774
+ }))
775
+ await daily.saveCursors(config.dailyDir, cursors)
776
+ for (const [day, bucket] of days.entries()) {
777
+ const doc = {
778
+ version: daily.DAILY_VERSION, date: day === 'unknown' ? 'unknown' : day,
779
+ deps: bucket.deps, byProvider: bucket.byProvider, byModel: bucket.byModel,
780
+ }
781
+ await daily.writeDayFile(config.dailyDir, day, doc)
782
+ rememberDay(day, doc)
783
+ }
784
+ if (days.has('unknown') && toDay && !days.has(toDay)) {
785
+ // unknown 兜底:若没有任何可归天的数据也要让 listDayFiles 非空(避免反复回填)
786
+ const doc = { version: daily.DAILY_VERSION, date: toDay, deps: {}, byProvider: {}, byModel: {} }
787
+ await daily.writeDayFile(config.dailyDir, toDay, doc)
788
+ rememberDay(toDay, doc)
789
+ }
790
+ return days.size
791
+ }
792
+
793
+ /** 计算今天的 totals:只扫描今天变过的会话文件(mtime >= 今天零点 - 1h 缓冲),
794
+ * 增量解码——mtime 未变的文件跳过(零读取),mtime 变化的文件只解上次偏移之后的新帧。 */
795
+ async function computeToday(nowMs) {
796
+ const toDay = daily.dayKey(nowMs)
797
+ const todayStart = daily.dayStartMs(toDay)
798
+ const todayEnd = daily.dayStartMs(daily.addDays(toDay, 1))
799
+ const files = await daily.scanSessionFiles(config.sessionsDir, config.maxSessions, todayStart - 3600000)
800
+ const cursors = await daily.loadCursors(config.dailyDir)
801
+ // 今天 totals = 上次累积的天文件 + 本轮新增帧(文件 mtime 未变则完全跳过)
802
+ const prevDay = await cachedDay(toDay)
803
+ const out = prevDay ? {
804
+ byProvider: JSON.parse(JSON.stringify(prevDay.byProvider || {})),
805
+ byModel: JSON.parse(JSON.stringify(prevDay.byModel || {})),
806
+ } : { byProvider: {}, byModel: {} }
807
+ const deps = prevDay && prevDay.deps ? { ...prevDay.deps } : {}
808
+ let anyWork = false
809
+ for (const f of files) {
810
+ deps[f.path] = f.mtimeMs
811
+ const cur = cursors[f.path]
812
+ if (cur && cur.mtimeMs === f.mtimeMs) continue // 文件无新写入:本轮零成本跳过
813
+ anyWork = true
814
+ if (cur && typeof cur.offset === 'number') {
815
+ const inc = await daily.readSessionFileFrom(f.path, cur.offset)
816
+ if (inc.changed && inc.text) {
817
+ daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(inc.text), todayStart, todayEnd, null))
818
+ cursors[f.path] = { offset: inc.total, mtimeMs: f.mtimeMs }
819
+ continue
820
+ }
821
+ if (inc.changed && !inc.text) {
822
+ cursors[f.path] = { offset: inc.total, mtimeMs: f.mtimeMs }
823
+ continue // 无新增内容
824
+ }
825
+ // 无法按偏移衔接 → 回退全量
826
+ }
827
+ const text2 = await daily.readSessionFile(f.path)
828
+ if (text2) {
829
+ daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(text2), todayStart, todayEnd, null))
830
+ }
831
+ cursors[f.path] = { offset: f.size || 0, mtimeMs: f.mtimeMs }
832
+ }
833
+ // 无任何文件变化 → 天文件与游标内容不变,跳过两次写盘
834
+ if (anyWork) {
835
+ await daily.saveCursors(config.dailyDir, cursors)
836
+ const doc = { version: daily.DAILY_VERSION, date: toDay, deps, byProvider: out.byProvider, byModel: out.byModel }
837
+ await daily.writeDayFile(config.dailyDir, toDay, doc)
838
+ rememberDay(toDay, doc)
839
+ }
840
+ return { toDay, out, scanned: files.length }
841
+ }
842
+
843
+ async function doLocalUsage(target, since, key, state) {
844
+ let st
845
+ try { st = await fsStat(config.sessionsDir) } catch { return localFail(state, 'no-sessions', '未找到 DSH 会话目录:' + config.sessionsDir) }
846
+ await daily.initDecoder() // 首次懒加载 zstd-wasm(测试注入 identity 时为 no-op)
847
+ const nowMs = Date.now()
848
+ // deps 周期校验:5 分钟内只验证一次,过期则清空已校验集合(下次请求重校验)
849
+ if (!state.depsCheckAt || nowMs - state.depsCheckAt >= LOCAL_DEPS_CHECK_MS) {
850
+ state.validatedDays = new Set()
851
+ state.depsCheckAt = nowMs
852
+ }
853
+ const toDay = daily.dayKey(nowMs)
854
+ // 回填触发:没有历史天文件(含旧版只写了今天的遗留)且未做过回填 → 全量扫历史按天落盘
855
+ const existingDays = await daily.listDayFiles(config.dailyDir)
856
+ const hasHist = existingDays.some((d) => d < toDay)
857
+ if (!hasHist && !(await daily.hasBackfilled(config.dailyDir))) {
858
+ await backfillAll(nowMs)
859
+ await daily.writeBackfilled(config.dailyDir)
860
+ }
861
+ const agg = { byProvider: {}, byModel: {} }
862
+ const dayCount = new Set()
863
+ dayCount.add(toDay)
864
+ // 今天:只算今天变过的文件
865
+ const today = await computeToday(nowMs)
866
+ daily.mergeTotals(agg, today.out)
867
+ // 历史天:读天文件(内存缓存;5 分钟周期内只校验一次 deps;mtime 变化才重算该天)
868
+ const existing = await daily.listDayFiles(config.dailyDir)
869
+ const pending = []
870
+ const dayData = {}
871
+ for (const day of existing) {
872
+ if (day >= toDay) continue
873
+ if (since > 0 && daily.dayStartMs(day) < since) continue
874
+ const data = await cachedDay(day)
875
+ if (!data) continue
876
+ dayData[day] = data
877
+ if (!state.validatedDays || !state.validatedDays.has(day)) pending.push(day)
878
+ }
879
+ if (pending.length > 0) {
880
+ state.validatedDays = state.validatedDays || new Set()
881
+ await validateHistoryDeps(dayData, pending, state)
882
+ }
883
+ for (const day of Object.keys(dayData)) {
884
+ const data = dayData[day]
885
+ daily.mergeTotals(agg, { byProvider: data.byProvider || {}, byModel: data.byModel || {} })
886
+ dayCount.add(day)
887
+ }
888
+ // unknown 桶(time 缺失事件):并入聚合(不占天数)
889
+ const unknownData = await cachedDay('unknown')
890
+ if (unknownData) {
891
+ daily.mergeTotals(agg, { byProvider: unknownData.byProvider || {}, byModel: unknownData.byModel || {} })
892
+ }
893
+ // 组装响应(chips 只过滤 totals;byModel 恒为全量)
894
+ const providers = Object.keys(agg.byProvider).sort()
895
+ const pick = target ? (agg.byProvider[target] || null) : null
896
+ const t = pick || { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }
897
+ if (!pick) {
898
+ for (const k of providers) {
899
+ t.requests += agg.byProvider[k].requests || 0
900
+ t.inputTokens += agg.byProvider[k].inputTokens || 0
901
+ t.outputTokens += agg.byProvider[k].outputTokens || 0
902
+ t.cacheReadTokens += agg.byProvider[k].cacheReadTokens || 0
903
+ }
904
+ }
905
+ const payload = {
906
+ ok: true, error: null, message: null,
907
+ provider: target || null, detected: true,
908
+ days: dayCount.size,
909
+ scanned: today.scanned,
910
+ totals: {
911
+ requests: t.requests,
912
+ inputTokens: t.inputTokens,
913
+ outputTokens: t.outputTokens,
914
+ cacheReadTokens: t.cacheReadTokens,
915
+ realTotalTokens: t.inputTokens + t.outputTokens + t.cacheReadTokens,
916
+ cacheHitRate: (t.inputTokens + t.cacheReadTokens) > 0
917
+ ? t.cacheReadTokens / (t.inputTokens + t.cacheReadTokens)
918
+ : 0,
919
+ },
920
+ byModel: agg.byModel || null,
921
+ providers,
922
+ fetchedAt: Date.now(), cached: false, stale: false,
923
+ snapshots: (state.snapshots || []).slice(),
924
+ parseVersion: LOCAL_PARSE_VERSION,
925
+ }
926
+ recordSnapshot(key, { attemptAt: Date.now(), httpStatus: null, error: null })
927
+ state.lastGood = { queriedAt: Date.now(), payload }
928
+ return payload
929
+ }
930
+
931
+ async function localUsage(params) {
932
+ const target = (params && typeof params.provider === 'string' && params.provider.trim() !== '') ? params.provider.trim() : null
933
+ const since = (params && typeof params.since === 'string' && Number(params.since) > 0) ? Number(params.since) : 0
934
+ const force = !!(params && (params.noCache === '1' || params.noCache === 'true'))
935
+ const key = 'local|' + (target || '*') + '|' + since
936
+ const state = stateOf(key)
937
+ if (!force && state.lastGood && Date.now() - state.lastGood.queriedAt < CACHE_FRESH_MS) {
938
+ return { ...state.lastGood.payload, cached: true }
939
+ }
940
+ if (!force && state.fetching) return state.fetching
941
+ state.fetching = doLocalUsage(target, since, key, state)
942
+ .catch((e) => localFail(state, 'internal', String((e && e.message) || e)))
943
+ .finally(() => { state.fetching = null })
944
+ return state.fetching
945
+ }
946
+
947
+
948
+ function readBodyJson(req) {
949
+ return new Promise((resolve) => {
950
+ let raw = ''
951
+ let done = false
952
+ const finish = (v) => { if (!done) { done = true; resolve(v) } }
953
+ try {
954
+ req.on('data', (chunk) => { raw += String(chunk); if (raw.length > 8192) finish(null) })
955
+ req.on('end', () => { try { finish(JSON.parse(raw || '{}')) } catch (e) { finish(null) } })
956
+ req.on('error', () => finish(null))
957
+ } catch (e) { finish(null) }
958
+ })
959
+ }
960
+
961
+ const jsonRoute = (handler, allowPost) => async (req, res) => {
962
+ if (!isLoopbackRequest(req)) {
963
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
964
+ res.end(JSON.stringify({ error: 'forbidden: loopback-only' }))
965
+ return
966
+ }
967
+ if (req.method !== 'GET' && req.method !== 'HEAD' && (!allowPost || req.method !== 'POST')) {
968
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
969
+ res.end(JSON.stringify({ error: 'method not allowed' }))
970
+ return
971
+ }
972
+ try {
973
+ const body = req.method === 'POST' ? await readBodyJson(req) : undefined
974
+ const result = await handler(req, body)
975
+ res.writeHead(result.error && result.error.httpStatus ? result.error.httpStatus : 200, { 'content-type': 'application/json; charset=utf-8' })
976
+ res.end(JSON.stringify(result))
977
+ } catch (e) {
978
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
979
+ res.end(JSON.stringify({ ok: false, error: { type: 'internal', message: String((e && e.message) || e) } }))
980
+ }
981
+ }
982
+
983
+ /** 从 URL 提取查询参数:adapter / provider(旧兼容)/ ref(key 引用)。 */
984
+ const queryParamsFromUrl = (req) => {
985
+ try {
986
+ if (!req.url) return {}
987
+ const u = new URL(req.url, 'http://localhost')
988
+ const out = {}
989
+ const p = u.searchParams.get('provider'); if (p && p.trim()) out.provider = p.trim()
990
+ const a = u.searchParams.get('adapter'); if (a && a.trim()) out.adapter = a.trim()
991
+ const r = u.searchParams.get('ref'); if (r && r.trim()) out.ref = r.trim()
992
+ const s = u.searchParams.get('source'); if (s && s.trim()) out.source = s.trim()
993
+ const n = u.searchParams.get('noCache'); if (n && (n === '1' || n === 'true')) out.noCache = '1'
994
+ const t = u.searchParams.get('since'); if (t && /^[0-9]+$/.test(t)) out.since = t
995
+ return out
996
+ } catch (e) {
997
+ return {}
998
+ }
999
+ }
1000
+
1001
+ ctx.effect(() => {
1002
+ const disposers = [
1003
+ // 兼容旧版:固定 opencode-go → usage-percent 适配器
1004
+ ctx.webServer.register({
1005
+ kind: 'exact',
1006
+ path: '/api/provider-usage/opencode-go',
1007
+ handler: jsonRoute(() => query({})),
1008
+ }),
1009
+ // 多实例查询:/api/provider-usage/query?adapter=<id>&ref=<credentialRef>
1010
+ ctx.webServer.register({
1011
+ kind: 'exact',
1012
+ path: '/api/provider-usage/query',
1013
+ handler: jsonRoute((req) => query(queryParamsFromUrl(req))),
1014
+ }),
1015
+ // 本地 Token 统计(DSH 会话日志,只读、不联网、与实例/Key 解耦)
1016
+ ctx.webServer.register({
1017
+ kind: 'exact',
1018
+ path: '/api/provider-usage/local-usage',
1019
+ handler: jsonRoute((req) => localUsage(queryParamsFromUrl(req))),
1020
+ }),
1021
+
1022
+ // 适配器清单(不含任何 secret;客户端据此渲染"添加供应商"选项)
1023
+ ctx.webServer.register({
1024
+ kind: 'exact',
1025
+ path: '/api/provider-usage/templates',
1026
+ handler: jsonRoute(() => ({
1027
+ ok: true,
1028
+ items: ADAPTERS.map((a) => ({ id: a.id, displayName: a.displayName, description: a.description })),
1029
+ })),
1030
+ }),
1031
+ // 手动 Key 写入/删除:值加密进 $DSH_HOME/provider-usage(方案 B,不落明文、不进 DSH 凭证)
1032
+ ctx.webServer.register({
1033
+ kind: 'exact',
1034
+ path: '/api/provider-usage/credentials',
1035
+ handler: jsonRoute(async (req, body) => {
1036
+ // 私有库键独立命名空间(非 DSH 引用名):允许字母/数字/下划线/连字符
1037
+ const keyOk = (k) => typeof k === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(k)
1038
+ if (req.method === 'POST') {
1039
+ const ref = body && typeof body.ref === 'string' ? body.ref.trim() : ''
1040
+ const value = body && typeof body.value === 'string' ? body.value : ''
1041
+ if (!keyOk(ref)) return { ok: false, error: { type: 'bad-ref', message: 'Key 标识不合法(仅限字母/数字/下划线/连字符)', httpStatus: 400 } }
1042
+ if (!value) return { ok: false, error: { type: 'bad-value', message: 'Key 值不能为空', httpStatus: 400 } }
1043
+ await (await secureStore()).set(ref, value)
1044
+ return { ok: true, ref }
1045
+ }
1046
+ const ref = typeof req.url === 'string' ? (new URL(req.url, 'http://localhost').searchParams.get('ref') || '') : ''
1047
+ if (!keyOk(ref)) return { ok: false, error: { type: 'bad-ref', message: 'Key 标识不合法', httpStatus: 400 } }
1048
+ await (await secureStore()).remove(ref)
1049
+ return { ok: true, ref }
1050
+ }, true),
1051
+ }),
1052
+ // 可从 DSH 导入的供应商(仅我们适配:OpenCode Go 订阅 / DeepSeek 余额)
1053
+ ctx.webServer.register({
1054
+ kind: 'exact',
1055
+ path: '/api/provider-usage/dsh-providers',
1056
+ handler: jsonRoute(async () => {
1057
+ let settingsCfg = null
1058
+ try { settingsCfg = ctx.settings.get('llm-pi-ai') } catch (e) { /* ignore */ }
1059
+ if (!settingsCfg) { try { settingsCfg = ctx.settings.get('settings.llm-pi-ai') } catch (e) { /* ignore */ } }
1060
+ const IMPORTABLE = [
1061
+ { route: 'opencode-go', displayName: 'OpenCode Go', adapter: 'usage-percent', ref: 'OPENCODE_GO_API_KEY', settingsKey: 'opencode-go' },
1062
+ { route: 'deepseek-official', displayName: 'DeepSeek', adapter: 'balance-json', ref: 'DEEPSEEK_API_KEY', settingsKey: 'deepseek' },
1063
+ ]
1064
+ const items = []
1065
+ for (const imp of IMPORTABLE) {
1066
+ const prov = settingsCfg && settingsCfg.providers && settingsCfg.providers[imp.settingsKey]
1067
+ const ref = prov && typeof prov.apiKeyEnv === 'string' && prov.apiKeyEnv.trim() !== '' ? prov.apiKeyEnv.trim() : imp.ref
1068
+ const displayName = prov && typeof prov.displayName === 'string' && prov.displayName ? prov.displayName : imp.displayName
1069
+ items.push({ route: imp.route, displayName, adapter: imp.adapter, ref, configured: await refConfigured(ref) })
1070
+ }
1071
+ return { ok: true, items }
1072
+ }),
1073
+ }),
1074
+ // 按需检查 Key 引用的配置状态(不含值;官方 Models 页同款的 credentials.describe)
1075
+ ctx.webServer.register({
1076
+ kind: 'exact',
1077
+ path: '/api/provider-usage/credential-refs',
1078
+ handler: jsonRoute(async (req) => {
1079
+ const list = []
1080
+ try {
1081
+ if (req.url) {
1082
+ const u = new URL(req.url, 'http://localhost')
1083
+ const raw = u.searchParams.get('refs')
1084
+ if (raw) {
1085
+ for (const part of raw.split(',')) {
1086
+ const name = part.trim()
1087
+ // 宽松校验:兼容 DSH 引用名与私有库键(实例 id 含连字符)
1088
+ if (name && /^[A-Za-z0-9_-]{1,64}$/.test(name) && list.length < 24) list.push(name)
1089
+ }
1090
+ }
1091
+ }
1092
+ } catch (e) { /* ignore */ }
1093
+ const refs = []
1094
+ for (const name of list) {
1095
+ const store = await refStoreOf(name)
1096
+ refs.push({ name, configured: store !== null, store })
1097
+ }
1098
+ return { ok: true, refs }
1099
+ }),
1100
+ }),
1101
+ ]
1102
+ return () => { for (const dispose of disposers) { if (typeof dispose === 'function') dispose() } }
1103
+ }, 'provider-usage: routes')
1104
+ }