dsh-cost-meter 1.7.37 → 1.7.39

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.
@@ -148,14 +148,15 @@ const warnedHosts = new Set()
148
148
  * @param {import('cordis').Context} ctx
149
149
  * @param {Record<string, unknown>} config
150
150
  */
151
- export async function queryCustomBalance(ctx, config) {
151
+ export async function queryCustomBalance(ctx, config, { signal } = {}) {
152
+ signal?.throwIfAborted()
152
153
  const custom = config?.customBalance
153
154
  if (custom?.enabled !== true) {
154
155
  const error = new Error('custom balance disabled')
155
156
  error.soft = true
156
157
  throw error
157
158
  }
158
- if (custom.adapter === 'aliyun') return queryAliyunBalance(ctx, config)
159
+ if (custom.adapter === 'aliyun') return queryAliyunBalance(ctx, config, { signal })
159
160
  const request = custom.request
160
161
  if (request === null || typeof request !== 'object' || typeof request.url !== 'string' || request.url.length === 0) {
161
162
  throw new Error('customBalance.request.url is required')
@@ -203,7 +204,7 @@ export async function queryCustomBalance(ctx, config) {
203
204
  }
204
205
  const method = typeof request.method === 'string' ? request.method.toUpperCase() : 'GET'
205
206
  const headers = await resolveHeaders(rawHeaders, ctx)
206
- const init = { method, headers, redirect: 'manual' }
207
+ const init = { method, headers, redirect: 'manual', signal }
207
208
  if (method !== 'GET' && method !== 'HEAD' && request.body !== undefined) {
208
209
  init.body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body)
209
210
  if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-type')) {
@@ -215,6 +216,7 @@ export async function queryCustomBalance(ctx, config) {
215
216
  // 目标主机,一次跨源重定向就足以把密钥发去别处;遇到重定向直接报错。
216
217
  const response = await fetchWithRetry(request.url, init, { timeoutMs: 15000 })
217
218
  if (response.status >= 300 && response.status < 400) {
219
+ try { await response.body?.cancel?.() } catch {}
218
220
  throw new Error(`custom balance endpoint redirected (HTTP ${String(response.status)}); redirects are refused to avoid forwarding credentials to another host`)
219
221
  }
220
222
  if (!response.ok) {
@@ -0,0 +1,111 @@
1
+ import { constants } from 'node:fs'
2
+ import { open } from 'node:fs/promises'
3
+ import { localDayKey, zeroDay } from './store.js'
4
+ import { costOf, providerPriceEntryFor, usdFromCost } from './pricing.js'
5
+
6
+ export const EXTERNAL_USAGE_MAX_BYTES = 512 * 1024
7
+ const MAX_AGE = 30 * 86400000
8
+ const FRESH_AGE = 86400000
9
+ const fields = ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost', 'apiCost']
10
+ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v)
11
+ const count = v => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 1e12
12
+ const money = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e9
13
+ const dateKey = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + 'T00:00:00Z')) && new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v
14
+ const timestamp = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(v) && dateKey(v.slice(0, 10)) ? Date.parse(v) : NaN
15
+
16
+ function add(into, day) {
17
+ for (const key of fields) into[key] += day[key]
18
+ }
19
+
20
+ function summary(days, dayKey) {
21
+ const today = zeroDay(dayKey)
22
+ const month = zeroDay(dayKey.slice(0, 7))
23
+ const total = zeroDay('total')
24
+ const history = Object.values(days).sort((a, b) => b.date.localeCompare(a.date)).slice(0, 90)
25
+ for (const day of Object.values(days)) {
26
+ add(total, day)
27
+ if (day.date.startsWith(dayKey.slice(0, 7))) add(month, day)
28
+ if (day.date === dayKey) add(today, day)
29
+ }
30
+ return { today, month, total, history }
31
+ }
32
+
33
+ /** A snapshot replaces its predecessor; it never mutates the DSH ledger or balance reconciliation. */
34
+ export function parseExternalUsageSnapshot(value, config, nowMs = Date.now()) {
35
+ if (!object(value)) return null
36
+ if (Object.hasOwn(value, 'sources') && !Array.isArray(value.sources)) return null
37
+ const fetchedAt = timestamp(value.fetchedAt)
38
+ if (!Number.isFinite(fetchedAt) || fetchedAt > nowMs + 300000 || nowMs - fetchedAt > MAX_AGE) return null
39
+ const entries = Array.isArray(value.sources) ? value.sources : [value]
40
+ if (entries.length < 1 || entries.length > 8) return null
41
+ const seen = new Set()
42
+ const sources = []
43
+ const combinedDays = {}
44
+ let recordCount = 0
45
+ for (const entry of entries) {
46
+ if (!object(entry) || typeof entry.source !== 'string' || entry.source.length < 1 || entry.source.length > 64 || seen.has(entry.source)) return null
47
+ seen.add(entry.source)
48
+ if (object(entry.days) === Array.isArray(entry.records)) return null // exactly one input mode
49
+ const days = {}
50
+ if (object(entry.days)) {
51
+ if (Object.keys(entry.days).length > 3660) return null
52
+ for (const [date, raw] of Object.entries(entry.days)) {
53
+ if (!dateKey(date) || date > localDayKey(nowMs) || !object(raw) || !count(raw.input) || !count(raw.output) || !count(raw.cached) || !count(raw.calls) || !money(raw.costUsd)) return null
54
+ if (raw.cacheWrite !== undefined && !count(raw.cacheWrite)) return null
55
+ if (raw.reasoning !== undefined && !count(raw.reasoning)) return null
56
+ days[date] = { ...zeroDay(date), input: raw.input, output: raw.output, cacheRead: raw.cached, cacheWrite: raw.cacheWrite ?? 0, reasoning: raw.reasoning ?? 0, calls: raw.calls, cost: raw.costUsd, apiCost: raw.costUsd }
57
+ }
58
+ } else {
59
+ recordCount += entry.records.length
60
+ if (recordCount > 5000) return null
61
+ const ids = new Set()
62
+ for (const raw of entry.records) {
63
+ if (!object(raw) || typeof raw.id !== 'string' || raw.id.length < 1 || raw.id.length > 128 || ids.has(raw.id)) return null
64
+ ids.add(raw.id)
65
+ const at = timestamp(raw.at)
66
+ if (!Number.isFinite(at) || at > nowMs + 300000 || !count(raw.input) || !count(raw.output) || !count(raw.cached)) return null
67
+ if (raw.cacheWrite !== undefined && !count(raw.cacheWrite)) return null
68
+ if (raw.reasoning !== undefined && !count(raw.reasoning)) return null
69
+ if (typeof raw.provider !== 'string' || !raw.provider || typeof raw.model !== 'string' || !raw.model) return null
70
+ const resolved = providerPriceEntryFor(raw.provider, raw.model, config?.prices, {
71
+ mode: config?.priceMatch === 'exact' ? 'exact' : 'auto', overrides: config?.priceOverrides,
72
+ })
73
+ if (!resolved.priced) return null
74
+ const peak = { enabled: resolved.billingMode === 'deepseek-peak' && config?.peakEnabled === true,
75
+ effectiveAtMs: Date.parse(config?.peakEffectiveAt ?? ''), windows: config?.peakWindows, holidays: config?.peakHolidays }
76
+ const tokens = { input: raw.input, output: raw.output, cacheRead: raw.cached, cacheWrite: raw.cacheWrite ?? 0, reasoning: raw.reasoning ?? 0 }
77
+ const priced = costOf(tokens, resolved.entry, at, peak)
78
+ const usd = usdFromCost(priced, resolved.billingMode === 'deepseek-peak' && config?.prices?.currency === 'CNY' ? 'CNY' : 'USD', config?.exchangeRate)
79
+ const date = localDayKey(at)
80
+ const day = days[date] ?? (days[date] = zeroDay(date))
81
+ for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning']) day[key] += tokens[key]
82
+ day.calls++
83
+ day.cost += usd
84
+ day.apiCost += usd
85
+ }
86
+ }
87
+ for (const [date, day] of Object.entries(days)) add(combinedDays[date] ?? (combinedDays[date] = zeroDay(date)), day)
88
+ sources.push({ source: entry.source, ...summary(days, localDayKey(nowMs)) })
89
+ }
90
+ return { fetchedAt, stale: nowMs - fetchedAt > FRESH_AGE, sources,
91
+ combined: summary(combinedDays, localDayKey(nowMs)) }
92
+ }
93
+
94
+ /** Bounded, fail-closed read of a regular file. Producers should write a temp file and rename it. */
95
+ export async function readExternalUsageSnapshot(path, config, nowMs = Date.now()) {
96
+ let file
97
+ try {
98
+ file = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
99
+ const stat = await file.stat()
100
+ if (!stat.isFile() || stat.size > EXTERNAL_USAGE_MAX_BYTES) return null
101
+ const buffer = Buffer.alloc(EXTERNAL_USAGE_MAX_BYTES + 1)
102
+ let length = 0
103
+ while (length < buffer.length) {
104
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length, null)
105
+ if (!bytesRead) break
106
+ length += bytesRead
107
+ }
108
+ if (length > EXTERNAL_USAGE_MAX_BYTES) return null
109
+ return parseExternalUsageSnapshot(JSON.parse(buffer.subarray(0, length).toString('utf8')), config, nowMs)
110
+ } catch { return null } finally { await file?.close().catch(() => {}) }
111
+ }
package/lib/index.js CHANGED
@@ -33,6 +33,7 @@ import { queryGatewayQuota, emptyGatewayQuota, managementKeyVarOf, gatewaySource
33
33
  import { stateSchema, TYPERT } from './typert.host.js'
34
34
  import { customBalanceCredentialVars } from './custom-balance.js'
35
35
  import { readScnetSnapshot } from './scnet-snapshot.js'
36
+ import { readExternalUsageSnapshot } from './external-usage.js'
36
37
  import { queryQwenCli } from './qwen-cli.js'
37
38
  import { queryBailianCli } from './bailian-cli.js'
38
39
  import { getSessionCost as readSessionCost } from './session-tree.js'
@@ -776,7 +777,7 @@ function normalizeGoWindow(raw) {
776
777
  * @param config - 插件配置(goQuota.apiKey / 消息语言)。
777
778
  * @param locale - 消息语言(zh/en)。
778
779
  */
779
- async function queryGoQuota(ctx, config, locale) {
780
+ async function queryGoQuota(ctx, config, locale, signal) {
780
781
  const key = await resolveGoKey(ctx, config)
781
782
  if (key === null) {
782
783
  const error = new Error(tmsg(locale, 'goQuotaKeyMissing'))
@@ -786,6 +787,7 @@ async function queryGoQuota(ctx, config, locale) {
786
787
  // Cloudflare 前置会间歇性重置连接(ECONNRESET → fetch failed):瞬时网络错误
787
788
  // 自动重试,401/403 等业务状态仍走下方原语义(issue #28)。
788
789
  const response = await fetchWithRetry(GO_QUOTA_URL, {
790
+ signal,
789
791
  headers: {
790
792
  authorization: `Bearer ${key}`,
791
793
  // 浏览器 UA:避免被 opencode.ai 前置 Cloudflare 以 error 1010 拦截。
@@ -943,7 +945,7 @@ function balanceEndpoint(baseURL) {
943
945
  * @param locale - 消息语言(zh/en)。
944
946
  * @returns { currency, totalBalance, grantedBalance, toppedUpBalance }。
945
947
  */
946
- async function queryBalance(ctx, locale) {
948
+ async function queryBalance(ctx, locale, signal) {
947
949
  const settings = ctx.get('settings')
948
950
  const section = typeof settings?.get === 'function' ? settings.get('llm-deepseek') : undefined
949
951
  const baseURL = section?.baseURL
@@ -975,6 +977,7 @@ async function queryBalance(ctx, locale) {
975
977
  }
976
978
  // 瞬时网络错误自动重试(issue #28 同一封装);非 2xx 状态仍按业务错误处理。
977
979
  const response = await fetchWithRetry(endpoint, {
980
+ signal,
978
981
  headers: { authorization: `Bearer ${apiKey}` },
979
982
  }, { timeoutMs: 15000 })
980
983
  if (!response.ok) throw new Error(tmsg(locale, 'balanceHttp', { code: String(response.status) }))
@@ -1165,6 +1168,9 @@ async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuo
1165
1168
  codingPlans,
1166
1169
  planStats,
1167
1170
  history: ledger.history(90),
1171
+ // 独立来源账本只读快照;today/month/history 继续仅指 DSH,官方余额对账不混入外部费用。
1172
+ ...(await readExternalUsageSnapshot(join(dirname(ledger.path), 'external_usage.json'), ledger.config, now)
1173
+ .then(externalUsage => externalUsage ? { externalUsage } : {})),
1168
1174
  // 密钥脱敏(v1.6.8):下发给前端的 config 不含任何明文密钥,只留空占位字符串。
1169
1175
  config: clientConfig,
1170
1176
  priceCatalog: ledger.config.prices?.currency === 'CNY' ? PRICE_CATALOG_CNY : PRICE_CATALOG,
@@ -1313,17 +1319,32 @@ async function describeGoKey(ctx, config) {
1313
1319
  }
1314
1320
 
1315
1321
  function createService(ctx, ledger, openRouterPrices) {
1322
+ let active = true
1316
1323
  // 余额进程内缓存:display=off 时不清缓存但不下发;按 refreshMinutes 过期。
1317
1324
  let balanceCache = { fetchedAt: 0, value: emptyBalance() }
1318
1325
  // OpenCode Go 订阅额度进程内缓存(同上策略)。
1319
1326
  let goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
1320
1327
  // Coding plan 额度进程内缓存(每家一个条目,同上策略)。
1321
1328
  let codingPlanCaches = {}
1329
+ const resetQueryCache = target => {
1330
+ if (target === 'balance') {
1331
+ balanceCache.controller?.abort()
1332
+ balanceCache = { fetchedAt: 0, value: emptyBalance() }
1333
+ } else if (target === 'goQuota') {
1334
+ goQuotaCache.controller?.abort()
1335
+ goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
1336
+ } else if (target.startsWith('codingPlans.')) {
1337
+ const id = target.split('.')[1]
1338
+ codingPlanCaches[id]?.controller?.abort()
1339
+ delete codingPlanCaches[id]
1340
+ }
1341
+ }
1322
1342
  ctx.effect(() => () => {
1323
- codingPlanCaches.qwen?.controller?.abort()
1324
- delete codingPlanCaches.qwen
1325
- codingPlanCaches.minimax?.controller?.abort()
1326
- delete codingPlanCaches.minimax
1343
+ active = false
1344
+ gatewayQuotaCaches = {}
1345
+ resetQueryCache('balance')
1346
+ resetQueryCache('goQuota')
1347
+ for (const id of Object.keys(codingPlanCaches)) resetQueryCache('codingPlans.' + id)
1327
1348
  }, 'cost-meter: quota query cancellation')
1328
1349
  // 余额差对账提示(drift 时 ok=false 携带文案,其余静默)。
1329
1350
  let reconcileNotice = { ok: true, message: '' }
@@ -1400,8 +1421,10 @@ function createService(ctx, ledger, openRouterPrices) {
1400
1421
 
1401
1422
  /** 按需刷新余额(过期或 force);失败落 error 状态,不影响其余状态字段。 */
1402
1423
  const ensureBalance = async (force = false) => {
1424
+ if (!active) return
1403
1425
  const config = balanceConfig()
1404
1426
  if (config.display === 'off') {
1427
+ balanceCache.controller?.abort()
1405
1428
  balanceCache = { fetchedAt: Date.now(), value: emptyBalance() }
1406
1429
  return
1407
1430
  }
@@ -1413,9 +1436,12 @@ function createService(ctx, ledger, openRouterPrices) {
1413
1436
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1414
1437
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1415
1438
  await prev.catch(() => {})
1439
+ if (!active || balanceConfig().display === 'off') return
1416
1440
  if (balanceCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1417
1441
  }
1418
- const task = queryBalance(ctx, localeOf(ledger.config)).then(result => {
1442
+ const controller = new AbortController()
1443
+ const task = queryBalance(ctx, localeOf(ledger.config), controller.signal).then(result => {
1444
+ if (!active || balanceCache.inFlight !== task) return
1419
1445
  balanceCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
1420
1446
  // 余额差交叉校验(issue #18):官方余额当日变动 vs 本地账本今日官方渠道费用,偏差超阈提示。
1421
1447
  // issue #36:Coding Plan / 自定义 Provider 的费用不动官方余额,对账只统计 deepseek 渠道,否则订阅用户会恒报 drift。
@@ -1452,6 +1478,7 @@ function createService(ctx, ledger, openRouterPrices) {
1452
1478
  : { ok: true, message: '' }
1453
1479
  }
1454
1480
  }, error => {
1481
+ if (!active || balanceCache.inFlight !== task) return
1455
1482
  // 软失败(未配置 Key / 非官方端点等守卫错误,不会自愈)标记 fetchedAt 避免无意义重试;
1456
1483
  // 硬失败(网络超时等临时性问题)写入 error 状态但保留外层 fetchedAt——UI 仍显示失败原因,
1457
1484
  // 且下次轮询自动重试,不再被缓存有效期钉死(PR #40 补全)。
@@ -1466,13 +1493,16 @@ function createService(ctx, ledger, openRouterPrices) {
1466
1493
  if (balanceCache.inFlight === task) delete balanceCache.inFlight
1467
1494
  })
1468
1495
  balanceCache.inFlight = task
1496
+ balanceCache.controller = controller
1469
1497
  await task
1470
1498
  }
1471
1499
 
1472
1500
  /** 按需刷新 OpenCode Go 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
1473
1501
  const ensureGoQuota = async (force = false) => {
1502
+ if (!active) return
1474
1503
  const config = goQuotaConfig()
1475
1504
  if (config.enabled === false || config.display === 'off') {
1505
+ goQuotaCache.controller?.abort()
1476
1506
  goQuotaCache = { fetchedAt: Date.now(), value: emptyGoQuota() }
1477
1507
  return
1478
1508
  }
@@ -1484,12 +1514,16 @@ function createService(ctx, ledger, openRouterPrices) {
1484
1514
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1485
1515
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1486
1516
  await prev.catch(() => {})
1517
+ if (!active || goQuotaConfig().enabled === false || goQuotaConfig().display === 'off') return
1487
1518
  if (goQuotaCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1488
1519
  }
1489
- const task = queryGoQuota(ctx, ledger.config, localeOf(ledger.config)).then(result => {
1520
+ const controller = new AbortController()
1521
+ const task = queryGoQuota(ctx, ledger.config, localeOf(ledger.config), controller.signal).then(result => {
1522
+ if (!active || goQuotaCache.inFlight !== task) return
1490
1523
  goQuotaCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
1491
1524
  recordQuotaSamples('go', { rolling: result.rolling, weekly: result.weekly, monthly: result.monthly })
1492
1525
  }, error => {
1526
+ if (!active || goQuotaCache.inFlight !== task) return
1493
1527
  // 失败不更新外层 fetchedAt:让下次轮询重试,而非在缓存有效期内一直跳过(PR #40)。
1494
1528
  // 软失败(未登录/无订阅,不会自愈)完整缓存避免无意义重试;硬失败(网络超时等)写入
1495
1529
  // error 状态但保留旧 fetchedAt——UI 仍显示失败原因,轮询到点自动重试。
@@ -1504,6 +1538,7 @@ function createService(ctx, ledger, openRouterPrices) {
1504
1538
  if (goQuotaCache.inFlight === task) delete goQuotaCache.inFlight
1505
1539
  })
1506
1540
  goQuotaCache.inFlight = task
1541
+ goQuotaCache.controller = controller
1507
1542
  await task
1508
1543
  }
1509
1544
 
@@ -1516,6 +1551,18 @@ function createService(ctx, ledger, openRouterPrices) {
1516
1551
  const customBalanceConfigs = () => Array.isArray(ledger.config?.customBalances)
1517
1552
  ? ledger.config.customBalances : []
1518
1553
  const customBalanceCaches = {}
1554
+ const customBalanceFingerprint = index => {
1555
+ // Currency conversion is presentation-only; it must preserve the API snapshot.
1556
+ const { convertToDisplayCurrency, labelEn, ...query } = customBalanceConfigs()[index] ?? {}
1557
+ return JSON.stringify([query, localeOf(ledger.config)])
1558
+ }
1559
+ const discardCustomBalanceCache = index => {
1560
+ customBalanceCaches[index]?.controller?.abort()
1561
+ delete customBalanceCaches[index]
1562
+ }
1563
+ ctx.effect(() => () => {
1564
+ for (const index of Object.keys(customBalanceCaches)) discardCustomBalanceCache(index)
1565
+ }, 'cost-meter: custom balance cancellation')
1519
1566
  /**
1520
1567
  * 作废引用了指定变量的全部自定义余额快照缓存(v1.7.6,issue #86):
1521
1568
  * setCredential / clearCredential 写过 customVar 后调用,下一次状态组装
@@ -1526,17 +1573,24 @@ function createService(ctx, ledger, openRouterPrices) {
1526
1573
  for (const key of Object.keys(customBalanceCaches)) {
1527
1574
  const index = Number(key)
1528
1575
  const uses = customBalanceCredentialVars(entries[index]).includes(varName)
1529
- if (uses) customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
1576
+ if (uses) discardCustomBalanceCache(index)
1530
1577
  }
1531
1578
  }
1532
1579
  const customBalanceCacheAt = index => {
1580
+ const fingerprint = customBalanceFingerprint(index)
1581
+ if (customBalanceCaches[index]?.fingerprint !== fingerprint) discardCustomBalanceCache(index)
1533
1582
  if (customBalanceCaches[index] === undefined) {
1534
- customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
1583
+ customBalanceCaches[index] = { fingerprint, fetchedAt: 0, value: emptyCustomBalance() }
1535
1584
  }
1536
1585
  return customBalanceCaches[index]
1537
1586
  }
1538
1587
  const ensureCustomBalance = async (force = false, index = null) => {
1588
+ if (!active) return
1539
1589
  const entries = customBalanceConfigs()
1590
+ for (const key of Object.keys(customBalanceCaches)) {
1591
+ if (!entries[key]) discardCustomBalanceCache(key)
1592
+ else customBalanceCacheAt(key)
1593
+ }
1540
1594
  const targets = []
1541
1595
  if (index === null) {
1542
1596
  // ambient:全部启用且非 off 的条目;一条都没有时清理出空缓存(与旧单条 off 行为一致)。
@@ -1545,7 +1599,7 @@ function createService(ctx, ledger, openRouterPrices) {
1545
1599
  })
1546
1600
  if (targets.length === 0) {
1547
1601
  for (const key of Object.keys(customBalanceCaches)) {
1548
- customBalanceCaches[key] = { fetchedAt: Date.now(), value: emptyCustomBalance() }
1602
+ discardCustomBalanceCache(key)
1549
1603
  }
1550
1604
  return
1551
1605
  }
@@ -1558,6 +1612,7 @@ function createService(ctx, ledger, openRouterPrices) {
1558
1612
  const ensureCustomBalanceEntry = async (force, index) => {
1559
1613
  const config = customBalanceConfigs()[index]
1560
1614
  const cache = customBalanceCacheAt(index)
1615
+ const current = () => active && customBalanceCaches[index] === cache && cache.fingerprint === customBalanceFingerprint(index)
1561
1616
  const interval = Math.max(1, Number(config?.refreshMinutes) || 15) * 60_000
1562
1617
  if (!force && Date.now() - cache.fetchedAt < interval) return
1563
1618
  while (cache.inFlight !== undefined) {
@@ -1566,50 +1621,50 @@ function createService(ctx, ledger, openRouterPrices) {
1566
1621
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1567
1622
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1568
1623
  await prev.catch(() => {})
1624
+ if (!current()) return
1569
1625
  if (cache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1570
1626
  }
1571
1627
  // queryCustomBalance 的单条入参重载:customBalances[i] 视图(兼容旧键,设置页
1572
1628
  // 面向 entries 编辑)。
1573
1629
  const configForEntry = { ...ledger.config, customBalance: config, customBalances: undefined }
1574
- const task = queryCustomBalance(ctx, configForEntry).then(result => {
1575
- customBalanceCaches[index] = {
1576
- fetchedAt: Date.now(),
1577
- value: { status: 'ok', message: '', fetchedAt: Date.now(), index, ...result },
1578
- }
1630
+ cache.controller = new AbortController()
1631
+ const task = queryCustomBalance(ctx, configForEntry, { signal: cache.controller.signal }).then(result => {
1632
+ if (!current()) return
1633
+ cache.fetchedAt = Date.now()
1634
+ cache.value = { status: 'ok', message: '', fetchedAt: cache.fetchedAt, index, ...result }
1579
1635
  }, error => {
1636
+ if (!current()) return
1580
1637
  // 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
1581
1638
  // 软失败完整缓存;硬失败写 error 状态但保留旧 fetchedAt(UI 可见 + 自动重试)。
1582
1639
  const now = Date.now()
1583
1640
  const message = error instanceof Error ? error.message : String(error)
1584
1641
  if (error && error.soft === true) {
1585
- customBalanceCaches[index] = {
1642
+ cache.fetchedAt = now
1643
+ cache.value = {
1644
+ ...emptyCustomBalance(),
1645
+ label: typeof config?.label === 'string' ? config.label : '',
1646
+ status: 'off',
1647
+ message,
1586
1648
  fetchedAt: now,
1587
- value: {
1588
- ...emptyCustomBalance(),
1589
- label: typeof config?.label === 'string' ? config.label : '',
1590
- status: 'off',
1591
- message,
1592
- fetchedAt: now,
1593
- index,
1594
- },
1649
+ index,
1595
1650
  }
1596
1651
  } else {
1597
- customBalanceCaches[index] = {
1598
- ...cache,
1599
- value: {
1600
- ...emptyCustomBalance(),
1601
- label: typeof config?.label === 'string' ? config.label : '',
1602
- status: 'error',
1603
- message,
1604
- fetchedAt: now,
1605
- index,
1606
- },
1652
+ cache.value = {
1653
+ ...emptyCustomBalance(),
1654
+ label: typeof config?.label === 'string' ? config.label : '',
1655
+ status: 'error',
1656
+ message,
1657
+ fetchedAt: now,
1658
+ index,
1607
1659
  }
1608
1660
  }
1609
1661
  }).finally(() => {
1610
- if (customBalanceCaches[index]?.inFlight === task) delete customBalanceCaches[index].inFlight
1662
+ if (cache.inFlight === task) {
1663
+ delete cache.inFlight
1664
+ delete cache.controller
1665
+ }
1611
1666
  })
1612
- customBalanceCaches[index] = { ...cache, inFlight: task }
1667
+ cache.inFlight = task
1613
1668
  await task
1614
1669
  }
1615
1670
  /** 多条快照(仅含有缓存值的条目;旧单条镜像取第一条可见条目)。 */
@@ -1617,7 +1672,7 @@ function createService(ctx, ledger, openRouterPrices) {
1617
1672
  const entries = customBalanceConfigs()
1618
1673
  const out = []
1619
1674
  entries.forEach((entry, index) => {
1620
- const cached = customBalanceCaches[index]
1675
+ const cached = active ? customBalanceCacheAt(index) : undefined
1621
1676
  if (cached === undefined) return
1622
1677
  out.push({ ...emptyCustomBalance(), ...cached.value, index })
1623
1678
  })
@@ -1734,10 +1789,12 @@ function createService(ctx, ledger, openRouterPrices) {
1734
1789
 
1735
1790
  /** 按需刷新单家 coding plan 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
1736
1791
  const ensureCodingPlan = async (id, force = false) => {
1792
+ if (!active) return
1737
1793
  if (id === 'qwen') return ensureQwenPlan(force)
1738
1794
  if (id === 'minimax') return ensureMiniMaxPlan(force)
1739
1795
  const config = codingPlanConfigOf(id)
1740
1796
  if (config.enabled !== true || config.display === 'off') {
1797
+ codingPlanCaches[id]?.controller?.abort()
1741
1798
  codingPlanCaches[id] = { fetchedAt: Date.now(), value: emptyCodingPlan() }
1742
1799
  return
1743
1800
  }
@@ -1776,22 +1833,27 @@ function createService(ctx, ledger, openRouterPrices) {
1776
1833
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1777
1834
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1778
1835
  await prev.catch(() => {})
1836
+ if (!active || codingPlanConfigOf(id).enabled !== true || codingPlanConfigOf(id).display === 'off') return
1779
1837
  // 任务完成时代码会整体替换 codingPlanCaches[id] 条目:必须重读活引用后再
1780
1838
  // 回到 while 条件判定,否则旧对象上的 inFlight 恒等于 prev,并发 force 刷新
1781
1839
  // 会各自重复发起任务(双重上游请求)。
1782
1840
  cache = codingPlanCaches[id]
1783
1841
  }
1784
1842
  const locale = localeOf(ledger.config)
1843
+ const controller = new AbortController()
1785
1844
  const task = (async () => {
1786
1845
  const key = await resolveCodingPlanKey(ctx, id, ledger.config)
1787
1846
  return queryCodingPlan(id, key, locale, tmsg, {
1847
+ signal: controller.signal,
1788
1848
  onAttempt: attempt => ctx.logger?.debug?.('[cost-meter] quota candidate', attempt),
1789
1849
  })
1790
1850
  })().then(result => {
1851
+ if (!active || codingPlanCaches[id]?.inFlight !== task) return
1791
1852
  codingPlanCaches[id] = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), windows: result.windows } }
1792
1853
  // SCNet 百分比来自本地自估(自我引用),不参与采样估算。
1793
1854
  if (id !== 'scnet') recordQuotaSamples(id, result.windows)
1794
1855
  }, error => {
1856
+ if (!active || codingPlanCaches[id]?.inFlight !== task) return
1795
1857
  // 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
1796
1858
  // 软失败(未配置凭据/无订阅)完整缓存;硬失败写 error 状态但保留旧 fetchedAt。
1797
1859
  const now = Date.now()
@@ -1807,7 +1869,7 @@ function createService(ctx, ledger, openRouterPrices) {
1807
1869
  }).finally(() => {
1808
1870
  if (codingPlanCaches[id]?.inFlight === task) delete codingPlanCaches[id].inFlight
1809
1871
  })
1810
- codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task }
1872
+ codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task, controller }
1811
1873
  await task
1812
1874
  }
1813
1875
 
@@ -1822,11 +1884,15 @@ function createService(ctx, ledger, openRouterPrices) {
1822
1884
  const configured = await resolveGatewayKeyConfigured(ctx, source)
1823
1885
  const fingerprint = gatewaySourceFingerprint(source, configured)
1824
1886
  const configFingerprint = gatewaySourceConfigFingerprintOf(source)
1887
+ const currentSource = () => active && gatewaySourceConfigs().some(entry => entry.id === id && entry.enabled !== false
1888
+ && gatewaySourceConfigFingerprintOf(entry) === configFingerprint)
1889
+ if (!currentSource()) return emptyGatewayForSource(source)
1825
1890
  let cache = gatewayQuotaCaches[id]
1826
1891
  if (cache === undefined || cache.configFingerprint !== configFingerprint || cache.keyConfigured !== configured) {
1827
1892
  // Preserve only a valid LKG when config/key identity is unchanged. A changed
1828
1893
  // source starts empty; an old in-flight task is intentionally detached.
1829
- cache = { fetchedAt: 0, value: emptyGatewayForSource(source), lastGood: null, sourceFingerprint: fingerprint }
1894
+ cache = { fetchedAt: 0, value: emptyGatewayForSource(source), lastGood: null, sourceFingerprint: fingerprint,
1895
+ configFingerprint, keyConfigured: configured }
1830
1896
  gatewayQuotaCaches[id] = cache
1831
1897
  }
1832
1898
  const interval = Math.max(1, Number(source.refreshMinutes) || 15) * 60_000
@@ -1839,8 +1905,9 @@ function createService(ctx, ledger, openRouterPrices) {
1839
1905
  }
1840
1906
  // Force intentionally waits for the current request, then starts a new one.
1841
1907
  await current.catch(() => {})
1908
+ if (!currentSource()) return emptyGatewayForSource(source)
1842
1909
  cache = gatewayQuotaCaches[id]
1843
- if (cache?.inFlight !== undefined) return ensureGatewayQuota(source, true)
1910
+ if (cache === undefined || cache.inFlight !== undefined) return ensureGatewayQuota(source, true)
1844
1911
  }
1845
1912
  const task = queryGatewayQuota(ctx, source).then(value => {
1846
1913
  const active = gatewayQuotaCaches[id]
@@ -1852,14 +1919,14 @@ function createService(ctx, ledger, openRouterPrices) {
1852
1919
  message: typeof value?.message === 'string' ? value.message : '', attemptedAt: value?.attemptedAt ?? Date.now(),
1853
1920
  keyConfigured: value?.keyConfigured === true, keySource: typeof value?.keySource === 'string' ? value.keySource : 'none',
1854
1921
  }
1855
- gatewayQuotaCaches[id] = { fetchedAt: ok ? Date.now() : (active.fetchedAt ?? 0), value: next, lastGood, sourceFingerprint: fingerprint }
1922
+ gatewayQuotaCaches[id] = { ...active, fetchedAt: ok ? Date.now() : (active.fetchedAt ?? 0), value: next, lastGood }
1856
1923
  return next
1857
1924
  }).catch(error => {
1858
1925
  const active = gatewayQuotaCaches[id]
1859
1926
  if (active?.inFlight !== task || active.sourceFingerprint !== fingerprint) return active?.value ?? emptyGatewayForSource(source)
1860
1927
  const lastGood = active.lastGood
1861
1928
  const next = { ...(lastGood ?? emptyGatewayForSource(source)), status: lastGood ? 'stale' : 'error', message: 'gateway query failed', attemptedAt: Date.now() }
1862
- gatewayQuotaCaches[id] = { fetchedAt: active.fetchedAt ?? 0, value: next, lastGood, sourceFingerprint: fingerprint }
1929
+ gatewayQuotaCaches[id] = { ...active, fetchedAt: active.fetchedAt ?? 0, value: next, lastGood }
1863
1930
  return next
1864
1931
  }).finally(() => {
1865
1932
  if (gatewayQuotaCaches[id]?.inFlight === task) {
@@ -1897,7 +1964,9 @@ function createService(ctx, ledger, openRouterPrices) {
1897
1964
  }
1898
1965
  kick(balanceCache.fetchedAt > 0, () => ensureBalance(forceBalance))
1899
1966
  kick(goQuotaCache.fetchedAt > 0, () => ensureGoQuota(false))
1900
- kick(Object.values(customBalanceCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCustomBalance(false))
1967
+ const customWarm = customBalanceConfigs().every((entry, index) => entry?.enabled !== true || entry.display === 'off'
1968
+ || customBalanceCacheAt(index).fetchedAt > 0)
1969
+ kick(customWarm, () => ensureCustomBalance(false))
1901
1970
  kick(Object.values(codingPlanCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCodingPlans(false))
1902
1971
  pending.push(ensureCodingPlan('scnet', false))
1903
1972
  const enabledGatewaySources = gatewaySourceConfigs().filter(source => source?.enabled !== false)
@@ -1914,6 +1983,7 @@ function createService(ctx, ledger, openRouterPrices) {
1914
1983
  },
1915
1984
 
1916
1985
  async updateConfig(patch) {
1986
+ const before = ledger.config
1917
1987
  const gatewayBefore = ledger.config?.gatewayQuotas
1918
1988
  const gatewayBeforeSources = gatewayBefore?.sources
1919
1989
  const currencyBefore = ledger.config.prices?.currency === 'CNY' ? 'CNY' : 'USD'
@@ -1923,6 +1993,12 @@ function createService(ctx, ledger, openRouterPrices) {
1923
1993
  throw new Error(tmsg(locale, 'configRejected', { errors: errors.join(locale === 'zh' ? ';' : '; ') }))
1924
1994
  }
1925
1995
  ledger.config = config
1996
+ // Cache values belong to their query settings and credential generation.
1997
+ // Reset before build() so disabled or changed queries cannot publish late results.
1998
+ for (const target of ['balance', 'goQuota', ...CODING_PLAN_PROVIDER_IDS.map(id => 'codingPlans.' + id)]) {
1999
+ const value = cfg => target.startsWith('codingPlans.') ? cfg.codingPlans?.[target.split('.')[1]] : cfg[target]
2000
+ if (before.locale !== config.locale || JSON.stringify(value(before)) !== JSON.stringify(value(config))) resetQueryCache(target)
2001
+ }
1926
2002
  if (patch?.gatewayQuotas !== undefined || gatewayBefore !== config.gatewayQuotas) {
1927
2003
  syncGatewayQuotaCaches(gatewayBeforeSources, config.gatewayQuotas?.sources)
1928
2004
  }
@@ -2100,6 +2176,7 @@ function createService(ctx, ledger, openRouterPrices) {
2100
2176
  invalidateCustomBalanceCachesForVar(customVar)
2101
2177
  for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
2102
2178
  }
2179
+ else resetQueryCache(key)
2103
2180
  return {
2104
2181
  ok: true,
2105
2182
  message: tmsg(locale, 'credentialSaved'),
@@ -2140,6 +2217,7 @@ function createService(ctx, ledger, openRouterPrices) {
2140
2217
  invalidateCustomBalanceCachesForVar(customVar)
2141
2218
  for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
2142
2219
  }
2220
+ else resetQueryCache(key)
2143
2221
  return {
2144
2222
  ok: true,
2145
2223
  message: tmsg(locale, 'credentialCleared'),
package/lib/store.js CHANGED
@@ -1972,6 +1972,9 @@ export class Ledger {
1972
1972
  let historyReset = null
1973
1973
  try {
1974
1974
  const parsed = JSON.parse(readFileSync(path, 'utf8'))
1975
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1976
+ throw new Error('Unsupported ledger format')
1977
+ }
1975
1978
  if (parsed !== null && typeof parsed === 'object') {
1976
1979
  if (parsed.version !== LEDGER_VERSION) {
1977
1980
  // 先把旧文件改名留存,再按空账本启动:防止下一次 flush 直接覆盖销毁历史。
@@ -38,6 +38,14 @@ const daySchema = z.object({
38
38
  sessions: z.array(sessionSchema),
39
39
  })
40
40
 
41
+ const externalSummarySchema = z.object({ today: daySchema, month: daySchema, total: daySchema, history: z.array(daySchema) })
42
+ const externalUsageSchema = z.object({
43
+ fetchedAt: num,
44
+ stale: z.boolean(),
45
+ sources: z.array(externalSummarySchema.extend({ source: z.string() })),
46
+ combined: externalSummarySchema,
47
+ })
48
+
41
49
  // 带所属日期的会话条目(issue #22 按会话排行:不分日期视角)。
42
50
  const datedSessionSchema = sessionSchema.extend({ date: z.string() })
43
51
 
@@ -470,6 +478,7 @@ export const stateSchema = z.object({
470
478
  // Token Plan 统计(issue #64);缺席 = 旧快照/降级路径。
471
479
  planStats: planStatsSchema.optional(),
472
480
  history: z.array(daySchema),
481
+ externalUsage: externalUsageSchema.optional(),
473
482
  config: configSchema,
474
483
  priceCatalog: z.record(z.string(), z.record(z.string(), z.record(z.string(), catalogEntrySchema))).optional(),
475
484
  // 存量密钥迁移提示(v1.6.8):仅在有密钥未能自动导入凭据库时出现,值为环境变量名。