@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.
- package/README.md +15 -3
- package/cordis.patch.yml +4 -1
- package/docs/CHANGELOG.md +114 -0
- package/lib/client.js +282 -70
- package/lib/daily-stats.js +67 -9
- package/lib/index.js +148 -14
- package/package.json +3 -6
package/lib/daily-stats.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
24
|
-
catch { return '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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
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.
|
|
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": "
|
|
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
|
},
|