@loommii/dsh-provider-usage 0.6.1 → 0.8.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.
@@ -8,7 +8,9 @@ import { promises as fs } from 'node:fs'
8
8
  import { init as zstdInit, decompress as zstdWasmDec } from '@bokuweb/zstd-wasm'
9
9
  import { decompress as zstdFzDec } from 'fzstd'
10
10
 
11
- export const DAILY_VERSION = 1
11
+ // v2(v0.7.0):byModel 升级为按提供方嵌套 { 提供方: { 模型: 计数 } };v1 为扁平 { 模型: 计数 }。
12
+ // .backfilled 哨兵按版本判定:v1 哨兵会触发一次性全量重折(从会话文件恢复提供方归属)。
13
+ export const DAILY_VERSION = 2
12
14
 
13
15
  /** 本地时区日期键:YYYY-MM-DD。 */
14
16
  export function dayKey(ms) {
@@ -152,7 +154,8 @@ export function parseEvents(text) {
152
154
  /** 把事件流中落在 [dayStart, dayEnd) 的 assistant/message 记账折叠成按天 totals。
153
155
  * 归属:message.source.provider/model,缺失回退 request/header;targetProvider 为空 = 全部 provider;
154
156
  * 事件 time 缺失按当天计入(宽松)。返回 { byProvider, byModel },键值含
155
- * { requests, inputTokens, outputTokens, cacheReadTokens }。 */
157
+ * { requests, inputTokens, outputTokens, cacheReadTokens }
158
+ * byModel 为按提供方嵌套:{ <provider>: { <model>: e } }(v0.7.0 起,支持按提供方过滤模型汇总)。 */
156
159
  /** 内部:把一条记账累加到 byProvider/byModel 桶。 */
157
160
  function bump(map, key, usage) {
158
161
  let e = map[key]
@@ -203,13 +206,16 @@ export function foldEventsByDay(events, dayStart, dayEnd, targetProvider) {
203
206
  const guess = stepGuess(data, headerProvider, headerModel)
204
207
  if (target !== null && guess.stepProvider !== target) continue
205
208
  bump(byProvider, guess.stepProvider || 'unknown', usage)
206
- bump(byModel, guess.stepModel || 'unknown', usage)
209
+ const provKey = guess.stepProvider || 'unknown'
210
+ if (!byModel[provKey]) byModel[provKey] = {}
211
+ bump(byModel[provKey], guess.stepModel || 'unknown', usage)
207
212
  }
208
213
  return { byProvider, byModel }
209
214
  }
210
215
 
211
216
  /** 把事件流按天分组折叠(首次全量回填用):返回 { <YYYY-MM-DD>: { byProvider, byModel },
212
- * unknown: {...} }——time 缺失的事件归 unknown 桶。targetProvider 为空 = 全部 provider。 */
217
+ * unknown: {...} }——time 缺失的事件归 unknown 桶。targetProvider 为空 = 全部 provider。
218
+ * byModel 按提供方嵌套(v0.7.0 起,见 foldEventsByDay)。 */
213
219
  export function foldEventsByDays(events, targetProvider) {
214
220
  const target = (typeof targetProvider === 'string' && targetProvider.trim() !== '') ? targetProvider.trim() : null
215
221
  const days = {}
@@ -236,14 +242,29 @@ export function foldEventsByDays(events, targetProvider) {
236
242
  let bucket = days[key]
237
243
  if (!bucket) { bucket = { byProvider: {}, byModel: {} }; days[key] = bucket }
238
244
  bump(bucket.byProvider, guess.stepProvider || 'unknown', usage)
239
- bump(bucket.byModel, guess.stepModel || 'unknown', usage)
245
+ const provKey = guess.stepProvider || 'unknown'
246
+ if (!bucket.byModel[provKey]) bucket.byModel[provKey] = {}
247
+ bump(bucket.byModel[provKey], guess.stepModel || 'unknown', usage)
240
248
  }
241
249
  return days
242
250
  }
243
251
 
244
- /** src byProvider/byModel 累加进 dst(同键相加)。 */
252
+ /** 判断 byModel 是否为 v1 扁平形态(顶层直接是计数条目,无提供方层)。 */
253
+ export function isFlatByModel(map) {
254
+ for (const k of Object.keys(map || {})) {
255
+ const v = map[k]
256
+ if (v && typeof v === 'object' && typeof v.requests === 'number') return true
257
+ }
258
+ return false
259
+ }
260
+
261
+ /** 把 src 的 byProvider/byModel 累加进 dst(同键相加)。
262
+ * byModel 兼容 v0.6.1 扁平形态({ <model>: e },双向):src 顶层条目并入 unknown 提供方桶,
263
+ * dst 顶层条目迁移到 unknown 桶后再合并(旧天文件被今天的重算复写时保持形态一致)。 */
245
264
  export function mergeTotals(dst, src) {
246
265
  if (!src) return
266
+ if (!dst.byProvider || typeof dst.byProvider !== 'object') dst.byProvider = {}
267
+ if (!dst.byModel || typeof dst.byModel !== 'object') dst.byModel = {}
247
268
  function mergeInto(map, srcMap) {
248
269
  for (const k of Object.keys(srcMap || {})) {
249
270
  const s = srcMap[k], t = map[k] || (map[k] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 })
@@ -254,7 +275,21 @@ export function mergeTotals(dst, src) {
254
275
  }
255
276
  }
256
277
  mergeInto(dst.byProvider, src.byProvider)
257
- mergeInto(dst.byModel, src.byModel)
278
+ if (isFlatByModel(dst.byModel)) {
279
+ const legacy = dst.byModel
280
+ dst.byModel = {}
281
+ mergeInto(dst.byModel.unknown || (dst.byModel.unknown = {}), legacy)
282
+ }
283
+ for (const prov of Object.keys(src.byModel || {})) {
284
+ const bucket = src.byModel[prov]
285
+ if (!bucket || typeof bucket !== 'object') continue
286
+ if (typeof bucket.requests === 'number') { mergeInto(dst.byModel.unknown || (dst.byModel.unknown = {}), { [prov]: bucket }); continue }
287
+ if (!dst.byModel[prov] || typeof dst.byModel[prov] !== 'object' || typeof dst.byModel[prov].requests === 'number') {
288
+ const legacy = dst.byModel[prov] && typeof dst.byModel[prov] === 'object' ? dst.byModel[prov] : null
289
+ dst.byModel[prov] = legacy ? { unknown: legacy } : {}
290
+ }
291
+ mergeInto(dst.byModel[prov], bucket)
292
+ }
258
293
  }
259
294
 
260
295
  /** 读某天文件;损坏/缺失返回 null。 */
@@ -289,15 +324,38 @@ export async function saveCursors(dir, cursors) {
289
324
  await fs.rename(tmp, join(dir, 'cursors.json'))
290
325
  }
291
326
 
292
- /** 回填哨兵:标记"全量历史回填已完成"(防止无历史数据时每次请求都重扫)。 */
327
+ /** 回填哨兵:标记"全量历史回填已完成且天文件为当前格式"(防止每次请求都重扫)。
328
+ * v0.7.0 起(DAILY_VERSION=2)按哨兵内容版本判定:旧版写的 version 1 哨兵视为未完成,
329
+ * 触发一次性全量重折,把 v1 扁平 byModel 天文件重算成嵌套形态(会话文件仍是事实源)。 */
293
330
  export async function hasBackfilled(dir) {
294
- try { await fs.access(join(dir, '.backfilled')); return true } catch { return false }
331
+ try {
332
+ const raw = await fs.readFile(join(dir, '.backfilled'), 'utf8')
333
+ const j = JSON.parse(raw)
334
+ return !!(j && typeof j === 'object' && Number(j.version) >= DAILY_VERSION)
335
+ } catch { return false }
295
336
  }
296
337
  export async function writeBackfilled(dir) {
297
338
  await fs.mkdir(dir, { recursive: true, mode: 0o700 })
298
339
  await fs.writeFile(join(dir, '.backfilled'), JSON.stringify({ version: DAILY_VERSION, at: Date.now() }) + '\n', { mode: 0o600 })
299
340
  }
300
341
 
342
+ /** 重置本地统计:删除全部派生数据(天文件/游标/回填哨兵/格式标记),不触碰会话原文。
343
+ * 下一次 local-usage 查询会自动从会话文件重新全量回填。返回删除的天文件数。 */
344
+ export async function resetDailyStats(dir) {
345
+ let removed = 0
346
+ let names
347
+ try { names = await fs.readdir(dir) } catch { return 0 }
348
+ for (const n of names) {
349
+ if (n === 'cursors.json' || n === '.backfilled' || n === '.format' || /^\.\d{4}-\d{2}-\d{2}\.tmp-/.test(n)) {
350
+ try { await fs.rm(join(dir, n)); removed++; continue } catch { /* ignore */ }
351
+ }
352
+ if (/^\d{4}-\d{2}-\d{2}\.json$/.test(n)) {
353
+ try { await fs.rm(join(dir, n)); removed++ } catch { /* ignore */ }
354
+ }
355
+ }
356
+ return removed
357
+ }
358
+
301
359
  /** 校验 deps(path -> mtime):返回变化的与缺失的路径。 */
302
360
  export async function validateDeps(deps) {
303
361
  const changed = []
package/lib/index.js CHANGED
@@ -4,14 +4,15 @@
4
4
  // balance-json(余额型) :DeepSeek {{baseUrl}}/user/balance → balance_infos[]
5
5
  // 凭证链 per provider:DSH 设置 provider.apiKeyEnv → 凭证服务 → 环境变量。
6
6
  // 路由:/api/provider-usage/opencode-go(兼容旧版)、/api/provider-usage/query?provider=<id>、
7
- // /api/provider-usage/templates / credentials / dsh-providers / credential-refs(仅回环)。
7
+ // /api/provider-usage/templates / credentials / dsh-providers / credential-refs / reset-local-stats(仅回环)、
8
+ // /api/provider-usage/providers(提供方实例清单持久化,v0.8.0 —— host 端 providers.json 为单一事实源)。
8
9
  // 本地 Token 统计(v0.5.0):/api/provider-usage/local-usage(累计+按模型汇总,按天物化存储),
9
10
  // 数据源 = 自读 $DSH_HOME/sessions 会话文件(fzstd 解压,assistant/message 事件 data.usage),只读、不联网;
10
11
  // 存储 = $DSH_HOME/provider-usage/daily-stats/YYYY-MM-DD.json:历史天封存(deps mtime 校验),只算今天。
11
12
  // 缓存:每 provider 独立 30s 新鲜窗口 + 并发去重 + 3 快照环;stale-while-error。
12
13
 
13
14
  import { join, dirname } from 'node:path'
14
- import { stat as fsStat } from 'node:fs/promises'
15
+ import { stat as fsStat, readFile, writeFile, rename, chmod, mkdir } from 'node:fs/promises'
15
16
  import { readFileSync } from 'node:fs'
16
17
  import { createSecureStore, resolveProviderUsageDir } from './secure-store.js'
17
18
  import * as daily from './daily-stats.js'
@@ -20,8 +21,8 @@ export const name = 'provider-usage'
20
21
 
21
22
  /** 插件版本单一来源:package.json(UA 标识用;bundle 内嵌等读不到时回退字面量)。 */
22
23
  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' }
24
+ try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.7.0' }
25
+ catch { return '0.7.0' }
25
26
  })()
26
27
  const USER_AGENT = 'dsh-provider-usage/' + PLUGIN_VERSION
27
28
 
@@ -298,6 +299,15 @@ function commandcodeMonthlyUsedPct(totalQuota, monthlyCredits) {
298
299
  return Math.max(0, Math.min(100, ((totalQuota - remaining) / totalQuota) * 100))
299
300
  }
300
301
 
302
+ /** 月剩余百分比:钳制 [0,100] 后保留 3 位小数向下取整(如 89.918878… → 89.918);无法推算返回 null。 */
303
+ function commandcodeMonthlyRemainingPct(totalQuota, monthlyCredits) {
304
+ if (totalQuota === null || totalQuota === undefined || !(totalQuota > 0)) return null
305
+ const remaining = Number(monthlyCredits)
306
+ if (!Number.isFinite(remaining)) return null
307
+ const pct = Math.max(0, Math.min(100, (remaining / totalQuota) * 100))
308
+ return Math.floor(pct * 1000) / 1000
309
+ }
310
+
301
311
  /** 把 credits + subscriptions 两个端点的响应换算成混合模型(订阅使用量 + 剩余额度)。 */
302
312
  function parseCommandCodeCredits(creditsBody, subBody) {
303
313
  let credits = creditsBody
@@ -334,6 +344,7 @@ function parseCommandCodeCredits(creditsBody, subBody) {
334
344
  ? subData.currentPeriodEnd : null
335
345
 
336
346
  const monthlyUsedPct = commandcodeMonthlyUsedPct(totalQuota, monthlyCredits)
347
+ const monthlyRemainingPct = commandcodeMonthlyRemainingPct(totalQuota, monthlyCredits)
337
348
  const monthlyUsed = (monthlyUsedPct === null || totalQuota === null) ? null
338
349
  : Math.max(0, totalQuota - monthlyCredits)
339
350
 
@@ -367,7 +378,7 @@ function parseCommandCodeCredits(creditsBody, subBody) {
367
378
  fiveHour: windowOf(fiveHourUsed, fiveHourCap, fiveHourResetAt),
368
379
  weekly: windowOf(weeklyUsed, weeklyCap, weeklyResetAt),
369
380
  },
370
- monthly: { usedPct: monthlyUsedPct, used: monthlyUsed, totalQuota, remaining: monthlyCredits, resetsAt: nextResetAt },
381
+ monthly: { usedPct: monthlyUsedPct, remainingPct: monthlyRemainingPct, used: monthlyUsed, totalQuota, remaining: monthlyCredits, resetsAt: nextResetAt },
371
382
  cards: null,
372
383
  isValid: true,
373
384
  invalidMessage: null,
@@ -396,12 +407,67 @@ export function apply(ctx, rawConfig) {
396
407
  ? rawConfig.sessionsDir
397
408
  : join(dirname(resolveProviderUsageDir()), 'sessions'),
398
409
  dailyDir: join(resolveProviderUsageDir(), 'daily-stats'),
410
+ // 提供方实例清单(v0.8.0):host 端单一事实源(localStorage 只是缓存),防重启后清单被静默重建
411
+ providersFile: join(resolveProviderUsageDir(), 'providers.json'),
399
412
  }
400
413
 
401
414
  // cordis 配置的 baseUrl 仅覆盖 opencode-go(历史语义),其余 preset 用内置 URL(插件自维护)
402
415
  const effectiveBaseUrl = (cfg) =>
403
416
  cfg.id === 'usage-percent' && config.baseUrlOverride ? config.baseUrlOverride : cfg.baseUrl
404
417
 
418
+ // ── 提供方实例清单持久化(v0.8.0)─
419
+ // 修复「重启后提供方清单被静默重建」:实例清单此前只存浏览器 localStorage,
420
+ // 存储 key 丢失(换 origin/清站点数据/浏览器驱逐)后 loadProviders() 走 DEFAULT_PROVIDERS
421
+ // 兜底 → 已删除的默认实例复活、自定义实例(如 Command Code)永久消失。
422
+ // 现在 host 端 providers.json 为单一事实源;客户端启动时从 host 恢复,写操作同步落盘。
423
+ const PROVIDER_ADAPTER_IDS = new Set(ADAPTERS.map((a) => a.id))
424
+ const PROVIDERS_MAX = 64
425
+
426
+ /** 规范并校验一个提供方实例(不合法字段一律剔除,而非整单拒绝——避免脏数据把恢复流程卡死)。 */
427
+ function normalizeProviderEntry(raw) {
428
+ if (!raw || typeof raw !== 'object') return null
429
+ if (typeof raw.adapter !== 'string' || !PROVIDER_ADAPTER_IDS.has(raw.adapter)) return null
430
+ const id = typeof raw.id === 'string' && raw.id.trim() !== '' ? raw.id.trim().slice(0, 128) : null
431
+ if (!id) return null
432
+ const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim().slice(0, 64) : ''
433
+ const ref = typeof raw.ref === 'string' ? raw.ref.slice(0, 128) : ''
434
+ const source = raw.source === 'vault' ? 'vault' : 'dsh'
435
+ const type = raw.type === 'manual' || raw.type === 'import' ? raw.type : (source === 'vault' ? 'manual' : 'import')
436
+ return { id, name, adapter: raw.adapter, ref, source, type, paused: raw.paused === true }
437
+ }
438
+
439
+ /** 读清单:providers.json 缺失/损坏 → null(调用方决定兜底,损坏文件同时改名留证)。 */
440
+ async function readProvidersDoc() {
441
+ let raw
442
+ try {
443
+ raw = await readFile(config.providersFile, 'utf8')
444
+ } catch (e) {
445
+ if (e && e.code === 'ENOENT') return null
446
+ return null // 其它读错误按缺失处理
447
+ }
448
+ try {
449
+ const doc = JSON.parse(raw)
450
+ if (!doc || doc.version !== 1 || !Array.isArray(doc.providers)) throw new Error('bad doc')
451
+ const providers = doc.providers.map(normalizeProviderEntry).filter(Boolean)
452
+ return { version: 1, providers }
453
+ } catch (e) {
454
+ // 损坏文件改名留证(.corrupt-<ts>),下次写盘重建;绝不静默吞掉用户清单
455
+ try { await rename(config.providersFile, config.providersFile + '.corrupt-' + Date.now()) } catch { /* ignore */ }
456
+ return null
457
+ }
458
+ }
459
+
460
+ /** 写清单:原子写(tmp → rename)+ 0600(与 credentials.json 同款边界);目录缺失时先建(0700)。 */
461
+ async function writeProvidersDoc(providers) {
462
+ const doc = { version: 1, providers, savedAt: Date.now() }
463
+ await mkdir(dirname(config.providersFile), { recursive: true, mode: 0o700 }).catch(() => {})
464
+ const tmp = config.providersFile + '.tmp'
465
+ await writeFile(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 })
466
+ await chmod(tmp, 0o600)
467
+ try { await rename(tmp, config.providersFile) } catch (e) { await writeFile(config.providersFile, JSON.stringify(doc, null, 2), { mode: 0o600 }) }
468
+ return doc
469
+ }
470
+
405
471
  // 私有加密凭证库(方案 B,懒初始化:首次使用时生成密钥)
406
472
  let secure = null
407
473
  async function secureStore() {
@@ -696,7 +762,7 @@ export function apply(ctx, rawConfig) {
696
762
  if (state.lastGood) {
697
763
  return { ...state.lastGood.payload, cached: true, stale: true, ok: false, error: { type, message, httpStatus: null } }
698
764
  }
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 }
765
+ return { ok: false, error: { type, message, httpStatus: null }, provider: null, detected: true, days: 0, scanned: 0, totals: null, byModel: { unknown: {} }, providers: [], fetchedAt: null, cached: false, stale: false, snapshots: (state.snapshots || []).slice(), parseVersion: LOCAL_PARSE_VERSION }
700
766
  }
701
767
 
702
768
  /** 校验待查天的 deps:unique 路径并行 stat,按天判定 mtime 变化/缺失,必要时重算该天并刷新缓存。 */
@@ -723,7 +789,15 @@ export function apply(ctx, rawConfig) {
723
789
  if (c.missing) missing.push(p)
724
790
  else if (Math.abs(c.currentMtime - Number(data.deps[p])) > 1) changed.push(p)
725
791
  }
726
- if (changed.length > 0 || missing.length > 0) {
792
+ // v1 扁平 byModel(v0.6.1 落盘):deps 仍全部指向存在的会话文件 重折恢复提供方归属(一次性,重写为 v2 嵌套)
793
+ let flatLegacy = daily.isFlatByModel(data.byModel || {}) && Object.keys(data.deps || {}).length > 0
794
+ if (flatLegacy) {
795
+ for (const p of Object.keys(data.deps)) {
796
+ const c = current.get(p)
797
+ if (!c || c.missing) { flatLegacy = false; break }
798
+ }
799
+ }
800
+ if (changed.length > 0 || missing.length > 0 || flatLegacy) {
727
801
  const repaired = await recomputeDay(day, data.deps || {})
728
802
  data.byProvider = repaired.byProvider
729
803
  data.byModel = repaired.byModel
@@ -799,19 +873,22 @@ export function apply(ctx, rawConfig) {
799
873
  const files = await daily.scanSessionFiles(config.sessionsDir, config.maxSessions, todayStart - 3600000)
800
874
  const cursors = await daily.loadCursors(config.dailyDir)
801
875
  // 今天 totals = 上次累积的天文件 + 本轮新增帧(文件 mtime 未变则完全跳过)
876
+ // 例外:天文件还是 v0.6.1 的扁平 byModel(无提供方维度)→ 不种子、当天全量重折(v2 嵌套),
877
+ // 游标一并重置,避免旧扁平数据进 unknown 桶 / 增量与重折双计
802
878
  const prevDay = await cachedDay(toDay)
803
- const out = prevDay ? {
879
+ const prevFlat = !!(prevDay && daily.isFlatByModel(prevDay.byModel || {}))
880
+ const out = prevDay && !prevFlat ? {
804
881
  byProvider: JSON.parse(JSON.stringify(prevDay.byProvider || {})),
805
882
  byModel: JSON.parse(JSON.stringify(prevDay.byModel || {})),
806
883
  } : { byProvider: {}, byModel: {} }
807
- const deps = prevDay && prevDay.deps ? { ...prevDay.deps } : {}
808
- let anyWork = false
884
+ const deps = prevDay && prevDay.deps && !prevFlat ? { ...prevDay.deps } : {}
885
+ let anyWork = prevFlat
809
886
  for (const f of files) {
810
887
  deps[f.path] = f.mtimeMs
811
888
  const cur = cursors[f.path]
812
- if (cur && cur.mtimeMs === f.mtimeMs) continue // 文件无新写入:本轮零成本跳过
889
+ if (!prevFlat && cur && cur.mtimeMs === f.mtimeMs) continue // 文件无新写入:本轮零成本跳过
813
890
  anyWork = true
814
- if (cur && typeof cur.offset === 'number') {
891
+ if (!prevFlat && cur && typeof cur.offset === 'number') {
815
892
  const inc = await daily.readSessionFileFrom(f.path, cur.offset)
816
893
  if (inc.changed && inc.text) {
817
894
  daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(inc.text), todayStart, todayEnd, null))
@@ -840,6 +917,18 @@ export function apply(ctx, rawConfig) {
840
917
  return { toDay, out, scanned: files.length }
841
918
  }
842
919
 
920
+ /** 按提供方过滤嵌套 byModel(v0.6.1 扁平旧数据已由 mergeTotals 迁到 unknown 桶)。
921
+ * pick 为空 = 全部提供方(原样返回嵌套形态);选中提供方无数据 = 空对象。
922
+ * 恒返回嵌套形态(选中时只含该提供方键),客户端据此统一判别新旧服务端。 */
923
+ function filterModelByProvider(byModel, pick) {
924
+ const src = byModel && typeof byModel === 'object' ? byModel : {}
925
+ if (!pick) return src
926
+ const out = {}
927
+ const inner = src[pick]
928
+ if (inner && typeof inner === 'object' && typeof inner.requests !== 'number') out[pick] = inner
929
+ return out
930
+ }
931
+
843
932
  async function doLocalUsage(target, since, key, state) {
844
933
  let st
845
934
  try { st = await fsStat(config.sessionsDir) } catch { return localFail(state, 'no-sessions', '未找到 DSH 会话目录:' + config.sessionsDir) }
@@ -858,12 +947,15 @@ export function apply(ctx, rawConfig) {
858
947
  await backfillAll(nowMs)
859
948
  await daily.writeBackfilled(config.dailyDir)
860
949
  }
950
+ // agg = 全量聚合(providers/totals 用);modelAgg = 模型维度聚合(按提供方嵌套,v0.7.0)
861
951
  const agg = { byProvider: {}, byModel: {} }
952
+ const modelAgg = { byModel: {} }
862
953
  const dayCount = new Set()
863
954
  dayCount.add(toDay)
864
955
  // 今天:只算今天变过的文件
865
956
  const today = await computeToday(nowMs)
866
957
  daily.mergeTotals(agg, today.out)
958
+ daily.mergeTotals(modelAgg, { byModel: today.out.byModel || {} })
867
959
  // 历史天:读天文件(内存缓存;5 分钟周期内只校验一次 deps;mtime 变化才重算该天)
868
960
  const existing = await daily.listDayFiles(config.dailyDir)
869
961
  const pending = []
@@ -883,14 +975,16 @@ export function apply(ctx, rawConfig) {
883
975
  for (const day of Object.keys(dayData)) {
884
976
  const data = dayData[day]
885
977
  daily.mergeTotals(agg, { byProvider: data.byProvider || {}, byModel: data.byModel || {} })
978
+ daily.mergeTotals(modelAgg, { byModel: data.byModel || {} })
886
979
  dayCount.add(day)
887
980
  }
888
981
  // unknown 桶(time 缺失事件):并入聚合(不占天数)
889
982
  const unknownData = await cachedDay('unknown')
890
983
  if (unknownData) {
891
984
  daily.mergeTotals(agg, { byProvider: unknownData.byProvider || {}, byModel: unknownData.byModel || {} })
985
+ daily.mergeTotals(modelAgg, { byModel: unknownData.byModel || {} })
892
986
  }
893
- // 组装响应(chips 只过滤 totals;byModel 恒为全量)
987
+ // 组装响应(chips/totals/byModel 均按选中提供方过滤;v0.7.0 byModel 按提供方嵌套可过滤)
894
988
  const providers = Object.keys(agg.byProvider).sort()
895
989
  const pick = target ? (agg.byProvider[target] || null) : null
896
990
  const t = pick || { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }
@@ -917,7 +1011,7 @@ export function apply(ctx, rawConfig) {
917
1011
  ? t.cacheReadTokens / (t.inputTokens + t.cacheReadTokens)
918
1012
  : 0,
919
1013
  },
920
- byModel: agg.byModel || null,
1014
+ byModel: filterModelByProvider(modelAgg.byModel, target || null),
921
1015
  providers,
922
1016
  fetchedAt: Date.now(), cached: false, stale: false,
923
1017
  snapshots: (state.snapshots || []).slice(),
@@ -928,6 +1022,18 @@ export function apply(ctx, rawConfig) {
928
1022
  return payload
929
1023
  }
930
1024
 
1025
+ /** 重置本地统计:删派生天文件/游标/哨兵(会话原文只读不动),清空 30s 缓存与去重态。 */
1026
+ async function resetLocalUsage() {
1027
+ const st = await fsStat(config.sessionsDir).then(() => true, () => false)
1028
+ if (!st) return { ok: false, error: { type: 'no-sessions', message: '未找到 DSH 会话目录:' + config.sessionsDir, httpStatus: null } }
1029
+ const removed = await daily.resetDailyStats(config.dailyDir)
1030
+ dayCache.clear() // 天文件内存缓存一并清除:否则重查会用旧天数据做种子,重置对损坏天文件无效
1031
+ for (const [k, s] of states) {
1032
+ if (k.startsWith('local|')) { s.lastGood = null; s.fetching = null; s.validatedDays = new Set(); s.depsCheckAt = 0 }
1033
+ }
1034
+ return { ok: true, removed }
1035
+ }
1036
+
931
1037
  async function localUsage(params) {
932
1038
  const target = (params && typeof params.provider === 'string' && params.provider.trim() !== '') ? params.provider.trim() : null
933
1039
  const since = (params && typeof params.since === 'string' && Number(params.since) > 0) ? Number(params.since) : 0
@@ -1018,6 +1124,12 @@ export function apply(ctx, rawConfig) {
1018
1124
  path: '/api/provider-usage/local-usage',
1019
1125
  handler: jsonRoute((req) => localUsage(queryParamsFromUrl(req))),
1020
1126
  }),
1127
+ // 重置本地统计:删除按天物化的派生数据(天文件/游标/哨兵),下次查询自动全量重算;会话原文只读不动
1128
+ ctx.webServer.register({
1129
+ kind: 'exact',
1130
+ path: '/api/provider-usage/reset-local-stats',
1131
+ handler: jsonRoute(() => resetLocalUsage(), true),
1132
+ }),
1021
1133
 
1022
1134
  // 适配器清单(不含任何 secret;客户端据此渲染"添加供应商"选项)
1023
1135
  ctx.webServer.register({
@@ -1098,6 +1210,28 @@ export function apply(ctx, rawConfig) {
1098
1210
  return { ok: true, refs }
1099
1211
  }),
1100
1212
  }),
1213
+ // 提供方实例清单持久化(v0.8.0)─:host 端 providers.json 为单一事实源。
1214
+ // GET 读清单(缺失返回 ok:true + items:null,由客户端走一次性迁移);
1215
+ // POST 整单覆盖保存(幂等)。仅回环,条目逐个规范化,不合法条目剔除。
1216
+ ctx.webServer.register({
1217
+ kind: 'exact',
1218
+ path: '/api/provider-usage/providers',
1219
+ handler: jsonRoute(async (req, body) => {
1220
+ if (req.method === 'POST') {
1221
+ if (!body || !Array.isArray(body.providers)) {
1222
+ return { ok: false, error: { type: 'bad-providers', message: 'providers 必须是数组', httpStatus: 400 } }
1223
+ }
1224
+ if (body.providers.length > PROVIDERS_MAX) {
1225
+ return { ok: false, error: { type: 'too-many-providers', message: '提供方实例最多 ' + PROVIDERS_MAX + ' 个', httpStatus: 400 } }
1226
+ }
1227
+ const providers = body.providers.map(normalizeProviderEntry).filter(Boolean)
1228
+ await writeProvidersDoc(providers)
1229
+ return { ok: true, providers }
1230
+ }
1231
+ const doc = await readProvidersDoc()
1232
+ return { ok: true, providers: doc ? doc.providers : null, exists: doc !== null }
1233
+ }, true),
1234
+ }),
1101
1235
  ]
1102
1236
  return () => { for (const dispose of disposers) { if (typeof dispose === 'function') dispose() } }
1103
1237
  }, 'provider-usage: routes')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loommii/dsh-provider-usage",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "DSH 插件(用量中心):常驻用量卡片 · 多提供方用量/余额查询(OpenCode Go 订阅 + DeepSeek 余额 + Command Code 订阅/余额,对齐 cc-switch 查询语义)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -48,15 +48,12 @@
48
48
  "patch": "./cordis.patch.yml"
49
49
  },
50
50
  "client": {
51
- "platform": "web",
52
- "inject": [
53
- "@deepseek-ai/dsh-client-runtime"
54
- ]
51
+ "platform": "web"
55
52
  }
56
53
  },
57
54
  "peerDependencies": {
58
55
  "@deepseek-ai/cordis": "^4.0.1",
59
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6",
60
57
  "react": "^18.2.0",
61
58
  "react-dom": "^18.2.0"
62
59
  },