dsh-cost-meter 1.7.38 → 1.7.40

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 CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; weekends and Chinese public holidays are off-peak all day, with separate labels) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / Volcano Ark / Qwen / Xiaomi MiMo) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
10
10
 
11
- [![version](https://img.shields.io/badge/version-1.7.38-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
11
+ [![version](https://img.shields.io/badge/version-1.7.40-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
12
12
 
13
- **v1.7.38** adds [external usage snapshots](docs/external-usage.md#english): DSH and other processes sharing an account appear separately and in combined daily/monthly totals. See the [release notes](docs/release-notes/v1.7.38.md).
13
+ **v1.7.40** adds Chinese metadata for the DSH plugin list while retaining the existing English package fallback. See the [release notes](docs/release-notes/v1.7.40.md).
14
14
 
15
15
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
16
16
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -317,22 +317,22 @@ On Node.js 20, use `npm install -g pnpm@10` instead. See [pnpm installation and
317
317
  dsh plugin --profile web add dsh-cost-meter
318
318
  ```
319
319
 
320
- **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.38`** — review the script before running):
320
+ **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.40`** — review the script before running):
321
321
 
322
322
  ```powershell
323
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.38/install.ps1 | iex
323
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.40/install.ps1 | iex
324
324
  ```
325
325
 
326
326
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
327
327
 
328
328
  ```sh
329
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.38
329
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.40
330
330
  ```
331
331
 
332
332
  Without git, use the GitHub tag archive:
333
333
 
334
334
  ```sh
335
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.38.tar.gz
335
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.40.tar.gz
336
336
  ```
337
337
 
338
338
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
package/README.zh-CN.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;周末与中国法定假日全天按谷价,分别标注) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 / 千问 / 小米 MiMo 十一家,含 Volcano Ark AK/SK 签名与 MiMo 控制台 Cookie 查询) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
10
10
 
11
- [![version](https://img.shields.io/badge/version-1.7.38-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
11
+ [![version](https://img.shields.io/badge/version-1.7.40-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
12
12
 
13
- **v1.7.38**:新增[外部用量快照](docs/external-usage.md#中文),按来源区分 DSH 与同账户其他进程的消耗,并显示日/月合计。详见[更新说明](docs/release-notes/v1.7.38.md)。
13
+ **v1.7.40**:为 DSH 插件列表增加中文名称与简介,英文界面沿用现有包信息。详见[更新说明](docs/release-notes/v1.7.40.md)。
14
14
 
15
15
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
16
16
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -317,22 +317,22 @@ Node.js 20 请改用 `npm install -g pnpm@10`。版本要求见 [pnpm 官方安
317
317
  dsh plugin --profile web add dsh-cost-meter
318
318
  ```
319
319
 
320
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.38`**,建议先下载审阅再运行):
320
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.40`**,建议先下载审阅再运行):
321
321
 
322
322
  ```powershell
323
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.38/install.ps1 | iex
323
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.40/install.ps1 | iex
324
324
  ```
325
325
 
326
326
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
327
327
 
328
328
  ```sh
329
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.38
329
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.40
330
330
  ```
331
331
 
332
332
  没有 git 时可用 GitHub tag 打包直链:
333
333
 
334
334
  ```sh
335
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.38.tar.gz
335
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.40.tar.gz
336
336
  ```
337
337
 
338
338
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
@@ -94,18 +94,22 @@ async function readPayload(response, locale) {
94
94
  } catch { throw failure(locale, 'payload') }
95
95
  }
96
96
 
97
- export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch } = {}) {
97
+ export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch, signal } = {}) {
98
+ signal?.throwIfAborted()
98
99
  const locale = config?.locale
99
100
  const [ak, sk, token] = await Promise.all(ALIYUN_BALANCE_CREDENTIAL_VARS.map(name => resolveCredential(ctx, name)))
100
101
  if (!ak || !sk) throw Object.assign(failure(locale, 'missing'), { soft: true })
101
102
  let response
102
103
  try {
103
104
  // 每次网络重试重新生成时间戳与 nonce;URL、Action、body 均固定为只读账户查询。
104
- response = await fetchWithRetry(ALIYUN_BALANCE_URL, {}, {
105
+ response = await fetchWithRetry(ALIYUN_BALANCE_URL, { signal }, {
105
106
  attempts: 2, timeoutMs: 15000,
106
107
  fetchImpl: (url, init) => fetchImpl(url, { ...signAliyunBalanceRequest(ak, sk, token), signal: init.signal }),
107
108
  })
108
- } catch { throw failure(locale, 'network') }
109
+ } catch {
110
+ signal?.throwIfAborted()
111
+ throw failure(locale, 'network')
112
+ }
109
113
  if (!response.ok) {
110
114
  await response.body?.cancel().catch(() => {})
111
115
  if (response.status >= 300 && response.status < 400) throw failure(locale, 'redirect')
@@ -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) {
@@ -11,7 +11,7 @@ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v)
11
11
  const count = v => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 1e12
12
12
  const money = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e9
13
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) ? Date.parse(v) : NaN
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
15
 
16
16
  function add(into, day) {
17
17
  for (const key of fields) into[key] += day[key]
@@ -33,6 +33,7 @@ function summary(days, dayKey) {
33
33
  /** A snapshot replaces its predecessor; it never mutates the DSH ledger or balance reconciliation. */
34
34
  export function parseExternalUsageSnapshot(value, config, nowMs = Date.now()) {
35
35
  if (!object(value)) return null
36
+ if (Object.hasOwn(value, 'sources') && !Array.isArray(value.sources)) return null
36
37
  const fetchedAt = timestamp(value.fetchedAt)
37
38
  if (!Number.isFinite(fetchedAt) || fetchedAt > nowMs + 300000 || nowMs - fetchedAt > MAX_AGE) return null
38
39
  const entries = Array.isArray(value.sources) ? value.sources : [value]
package/lib/index.js CHANGED
@@ -777,7 +777,7 @@ function normalizeGoWindow(raw) {
777
777
  * @param config - 插件配置(goQuota.apiKey / 消息语言)。
778
778
  * @param locale - 消息语言(zh/en)。
779
779
  */
780
- async function queryGoQuota(ctx, config, locale) {
780
+ async function queryGoQuota(ctx, config, locale, signal) {
781
781
  const key = await resolveGoKey(ctx, config)
782
782
  if (key === null) {
783
783
  const error = new Error(tmsg(locale, 'goQuotaKeyMissing'))
@@ -787,6 +787,7 @@ async function queryGoQuota(ctx, config, locale) {
787
787
  // Cloudflare 前置会间歇性重置连接(ECONNRESET → fetch failed):瞬时网络错误
788
788
  // 自动重试,401/403 等业务状态仍走下方原语义(issue #28)。
789
789
  const response = await fetchWithRetry(GO_QUOTA_URL, {
790
+ signal,
790
791
  headers: {
791
792
  authorization: `Bearer ${key}`,
792
793
  // 浏览器 UA:避免被 opencode.ai 前置 Cloudflare 以 error 1010 拦截。
@@ -944,7 +945,7 @@ function balanceEndpoint(baseURL) {
944
945
  * @param locale - 消息语言(zh/en)。
945
946
  * @returns { currency, totalBalance, grantedBalance, toppedUpBalance }。
946
947
  */
947
- async function queryBalance(ctx, locale) {
948
+ async function queryBalance(ctx, locale, signal) {
948
949
  const settings = ctx.get('settings')
949
950
  const section = typeof settings?.get === 'function' ? settings.get('llm-deepseek') : undefined
950
951
  const baseURL = section?.baseURL
@@ -976,6 +977,7 @@ async function queryBalance(ctx, locale) {
976
977
  }
977
978
  // 瞬时网络错误自动重试(issue #28 同一封装);非 2xx 状态仍按业务错误处理。
978
979
  const response = await fetchWithRetry(endpoint, {
980
+ signal,
979
981
  headers: { authorization: `Bearer ${apiKey}` },
980
982
  }, { timeoutMs: 15000 })
981
983
  if (!response.ok) throw new Error(tmsg(locale, 'balanceHttp', { code: String(response.status) }))
@@ -1317,17 +1319,32 @@ async function describeGoKey(ctx, config) {
1317
1319
  }
1318
1320
 
1319
1321
  function createService(ctx, ledger, openRouterPrices) {
1322
+ let active = true
1320
1323
  // 余额进程内缓存:display=off 时不清缓存但不下发;按 refreshMinutes 过期。
1321
1324
  let balanceCache = { fetchedAt: 0, value: emptyBalance() }
1322
1325
  // OpenCode Go 订阅额度进程内缓存(同上策略)。
1323
1326
  let goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
1324
1327
  // Coding plan 额度进程内缓存(每家一个条目,同上策略)。
1325
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
+ }
1326
1342
  ctx.effect(() => () => {
1327
- codingPlanCaches.qwen?.controller?.abort()
1328
- delete codingPlanCaches.qwen
1329
- codingPlanCaches.minimax?.controller?.abort()
1330
- 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)
1331
1348
  }, 'cost-meter: quota query cancellation')
1332
1349
  // 余额差对账提示(drift 时 ok=false 携带文案,其余静默)。
1333
1350
  let reconcileNotice = { ok: true, message: '' }
@@ -1404,8 +1421,10 @@ function createService(ctx, ledger, openRouterPrices) {
1404
1421
 
1405
1422
  /** 按需刷新余额(过期或 force);失败落 error 状态,不影响其余状态字段。 */
1406
1423
  const ensureBalance = async (force = false) => {
1424
+ if (!active) return
1407
1425
  const config = balanceConfig()
1408
1426
  if (config.display === 'off') {
1427
+ balanceCache.controller?.abort()
1409
1428
  balanceCache = { fetchedAt: Date.now(), value: emptyBalance() }
1410
1429
  return
1411
1430
  }
@@ -1417,9 +1436,12 @@ function createService(ctx, ledger, openRouterPrices) {
1417
1436
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1418
1437
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1419
1438
  await prev.catch(() => {})
1439
+ if (!active || balanceConfig().display === 'off') return
1420
1440
  if (balanceCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1421
1441
  }
1422
- 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
1423
1445
  balanceCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
1424
1446
  // 余额差交叉校验(issue #18):官方余额当日变动 vs 本地账本今日官方渠道费用,偏差超阈提示。
1425
1447
  // issue #36:Coding Plan / 自定义 Provider 的费用不动官方余额,对账只统计 deepseek 渠道,否则订阅用户会恒报 drift。
@@ -1456,6 +1478,7 @@ function createService(ctx, ledger, openRouterPrices) {
1456
1478
  : { ok: true, message: '' }
1457
1479
  }
1458
1480
  }, error => {
1481
+ if (!active || balanceCache.inFlight !== task) return
1459
1482
  // 软失败(未配置 Key / 非官方端点等守卫错误,不会自愈)标记 fetchedAt 避免无意义重试;
1460
1483
  // 硬失败(网络超时等临时性问题)写入 error 状态但保留外层 fetchedAt——UI 仍显示失败原因,
1461
1484
  // 且下次轮询自动重试,不再被缓存有效期钉死(PR #40 补全)。
@@ -1470,13 +1493,16 @@ function createService(ctx, ledger, openRouterPrices) {
1470
1493
  if (balanceCache.inFlight === task) delete balanceCache.inFlight
1471
1494
  })
1472
1495
  balanceCache.inFlight = task
1496
+ balanceCache.controller = controller
1473
1497
  await task
1474
1498
  }
1475
1499
 
1476
1500
  /** 按需刷新 OpenCode Go 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
1477
1501
  const ensureGoQuota = async (force = false) => {
1502
+ if (!active) return
1478
1503
  const config = goQuotaConfig()
1479
1504
  if (config.enabled === false || config.display === 'off') {
1505
+ goQuotaCache.controller?.abort()
1480
1506
  goQuotaCache = { fetchedAt: Date.now(), value: emptyGoQuota() }
1481
1507
  return
1482
1508
  }
@@ -1488,12 +1514,16 @@ function createService(ctx, ledger, openRouterPrices) {
1488
1514
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1489
1515
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1490
1516
  await prev.catch(() => {})
1517
+ if (!active || goQuotaConfig().enabled === false || goQuotaConfig().display === 'off') return
1491
1518
  if (goQuotaCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1492
1519
  }
1493
- 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
1494
1523
  goQuotaCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
1495
1524
  recordQuotaSamples('go', { rolling: result.rolling, weekly: result.weekly, monthly: result.monthly })
1496
1525
  }, error => {
1526
+ if (!active || goQuotaCache.inFlight !== task) return
1497
1527
  // 失败不更新外层 fetchedAt:让下次轮询重试,而非在缓存有效期内一直跳过(PR #40)。
1498
1528
  // 软失败(未登录/无订阅,不会自愈)完整缓存避免无意义重试;硬失败(网络超时等)写入
1499
1529
  // error 状态但保留旧 fetchedAt——UI 仍显示失败原因,轮询到点自动重试。
@@ -1508,6 +1538,7 @@ function createService(ctx, ledger, openRouterPrices) {
1508
1538
  if (goQuotaCache.inFlight === task) delete goQuotaCache.inFlight
1509
1539
  })
1510
1540
  goQuotaCache.inFlight = task
1541
+ goQuotaCache.controller = controller
1511
1542
  await task
1512
1543
  }
1513
1544
 
@@ -1520,6 +1551,18 @@ function createService(ctx, ledger, openRouterPrices) {
1520
1551
  const customBalanceConfigs = () => Array.isArray(ledger.config?.customBalances)
1521
1552
  ? ledger.config.customBalances : []
1522
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')
1523
1566
  /**
1524
1567
  * 作废引用了指定变量的全部自定义余额快照缓存(v1.7.6,issue #86):
1525
1568
  * setCredential / clearCredential 写过 customVar 后调用,下一次状态组装
@@ -1530,17 +1573,24 @@ function createService(ctx, ledger, openRouterPrices) {
1530
1573
  for (const key of Object.keys(customBalanceCaches)) {
1531
1574
  const index = Number(key)
1532
1575
  const uses = customBalanceCredentialVars(entries[index]).includes(varName)
1533
- if (uses) customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
1576
+ if (uses) discardCustomBalanceCache(index)
1534
1577
  }
1535
1578
  }
1536
1579
  const customBalanceCacheAt = index => {
1580
+ const fingerprint = customBalanceFingerprint(index)
1581
+ if (customBalanceCaches[index]?.fingerprint !== fingerprint) discardCustomBalanceCache(index)
1537
1582
  if (customBalanceCaches[index] === undefined) {
1538
- customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
1583
+ customBalanceCaches[index] = { fingerprint, fetchedAt: 0, value: emptyCustomBalance() }
1539
1584
  }
1540
1585
  return customBalanceCaches[index]
1541
1586
  }
1542
1587
  const ensureCustomBalance = async (force = false, index = null) => {
1588
+ if (!active) return
1543
1589
  const entries = customBalanceConfigs()
1590
+ for (const key of Object.keys(customBalanceCaches)) {
1591
+ if (!entries[key]) discardCustomBalanceCache(key)
1592
+ else customBalanceCacheAt(key)
1593
+ }
1544
1594
  const targets = []
1545
1595
  if (index === null) {
1546
1596
  // ambient:全部启用且非 off 的条目;一条都没有时清理出空缓存(与旧单条 off 行为一致)。
@@ -1549,7 +1599,7 @@ function createService(ctx, ledger, openRouterPrices) {
1549
1599
  })
1550
1600
  if (targets.length === 0) {
1551
1601
  for (const key of Object.keys(customBalanceCaches)) {
1552
- customBalanceCaches[key] = { fetchedAt: Date.now(), value: emptyCustomBalance() }
1602
+ discardCustomBalanceCache(key)
1553
1603
  }
1554
1604
  return
1555
1605
  }
@@ -1562,6 +1612,7 @@ function createService(ctx, ledger, openRouterPrices) {
1562
1612
  const ensureCustomBalanceEntry = async (force, index) => {
1563
1613
  const config = customBalanceConfigs()[index]
1564
1614
  const cache = customBalanceCacheAt(index)
1615
+ const current = () => active && customBalanceCaches[index] === cache && cache.fingerprint === customBalanceFingerprint(index)
1565
1616
  const interval = Math.max(1, Number(config?.refreshMinutes) || 15) * 60_000
1566
1617
  if (!force && Date.now() - cache.fetchedAt < interval) return
1567
1618
  while (cache.inFlight !== undefined) {
@@ -1570,50 +1621,50 @@ function createService(ctx, ledger, openRouterPrices) {
1570
1621
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1571
1622
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1572
1623
  await prev.catch(() => {})
1624
+ if (!current()) return
1573
1625
  if (cache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1574
1626
  }
1575
1627
  // queryCustomBalance 的单条入参重载:customBalances[i] 视图(兼容旧键,设置页
1576
1628
  // 面向 entries 编辑)。
1577
1629
  const configForEntry = { ...ledger.config, customBalance: config, customBalances: undefined }
1578
- const task = queryCustomBalance(ctx, configForEntry).then(result => {
1579
- customBalanceCaches[index] = {
1580
- fetchedAt: Date.now(),
1581
- value: { status: 'ok', message: '', fetchedAt: Date.now(), index, ...result },
1582
- }
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 }
1583
1635
  }, error => {
1636
+ if (!current()) return
1584
1637
  // 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
1585
1638
  // 软失败完整缓存;硬失败写 error 状态但保留旧 fetchedAt(UI 可见 + 自动重试)。
1586
1639
  const now = Date.now()
1587
1640
  const message = error instanceof Error ? error.message : String(error)
1588
1641
  if (error && error.soft === true) {
1589
- customBalanceCaches[index] = {
1642
+ cache.fetchedAt = now
1643
+ cache.value = {
1644
+ ...emptyCustomBalance(),
1645
+ label: typeof config?.label === 'string' ? config.label : '',
1646
+ status: 'off',
1647
+ message,
1590
1648
  fetchedAt: now,
1591
- value: {
1592
- ...emptyCustomBalance(),
1593
- label: typeof config?.label === 'string' ? config.label : '',
1594
- status: 'off',
1595
- message,
1596
- fetchedAt: now,
1597
- index,
1598
- },
1649
+ index,
1599
1650
  }
1600
1651
  } else {
1601
- customBalanceCaches[index] = {
1602
- ...cache,
1603
- value: {
1604
- ...emptyCustomBalance(),
1605
- label: typeof config?.label === 'string' ? config.label : '',
1606
- status: 'error',
1607
- message,
1608
- fetchedAt: now,
1609
- index,
1610
- },
1652
+ cache.value = {
1653
+ ...emptyCustomBalance(),
1654
+ label: typeof config?.label === 'string' ? config.label : '',
1655
+ status: 'error',
1656
+ message,
1657
+ fetchedAt: now,
1658
+ index,
1611
1659
  }
1612
1660
  }
1613
1661
  }).finally(() => {
1614
- if (customBalanceCaches[index]?.inFlight === task) delete customBalanceCaches[index].inFlight
1662
+ if (cache.inFlight === task) {
1663
+ delete cache.inFlight
1664
+ delete cache.controller
1665
+ }
1615
1666
  })
1616
- customBalanceCaches[index] = { ...cache, inFlight: task }
1667
+ cache.inFlight = task
1617
1668
  await task
1618
1669
  }
1619
1670
  /** 多条快照(仅含有缓存值的条目;旧单条镜像取第一条可见条目)。 */
@@ -1621,7 +1672,7 @@ function createService(ctx, ledger, openRouterPrices) {
1621
1672
  const entries = customBalanceConfigs()
1622
1673
  const out = []
1623
1674
  entries.forEach((entry, index) => {
1624
- const cached = customBalanceCaches[index]
1675
+ const cached = active ? customBalanceCacheAt(index) : undefined
1625
1676
  if (cached === undefined) return
1626
1677
  out.push({ ...emptyCustomBalance(), ...cached.value, index })
1627
1678
  })
@@ -1738,10 +1789,12 @@ function createService(ctx, ledger, openRouterPrices) {
1738
1789
 
1739
1790
  /** 按需刷新单家 coding plan 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
1740
1791
  const ensureCodingPlan = async (id, force = false) => {
1792
+ if (!active) return
1741
1793
  if (id === 'qwen') return ensureQwenPlan(force)
1742
1794
  if (id === 'minimax') return ensureMiniMaxPlan(force)
1743
1795
  const config = codingPlanConfigOf(id)
1744
1796
  if (config.enabled !== true || config.display === 'off') {
1797
+ codingPlanCaches[id]?.controller?.abort()
1745
1798
  codingPlanCaches[id] = { fetchedAt: Date.now(), value: emptyCodingPlan() }
1746
1799
  return
1747
1800
  }
@@ -1780,22 +1833,27 @@ function createService(ctx, ledger, openRouterPrices) {
1780
1833
  // force 链式等待:等上一个任务结束后由本调用继续走新建流程,
1781
1834
  // 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
1782
1835
  await prev.catch(() => {})
1836
+ if (!active || codingPlanConfigOf(id).enabled !== true || codingPlanConfigOf(id).display === 'off') return
1783
1837
  // 任务完成时代码会整体替换 codingPlanCaches[id] 条目:必须重读活引用后再
1784
1838
  // 回到 while 条件判定,否则旧对象上的 inFlight 恒等于 prev,并发 force 刷新
1785
1839
  // 会各自重复发起任务(双重上游请求)。
1786
1840
  cache = codingPlanCaches[id]
1787
1841
  }
1788
1842
  const locale = localeOf(ledger.config)
1843
+ const controller = new AbortController()
1789
1844
  const task = (async () => {
1790
1845
  const key = await resolveCodingPlanKey(ctx, id, ledger.config)
1791
1846
  return queryCodingPlan(id, key, locale, tmsg, {
1847
+ signal: controller.signal,
1792
1848
  onAttempt: attempt => ctx.logger?.debug?.('[cost-meter] quota candidate', attempt),
1793
1849
  })
1794
1850
  })().then(result => {
1851
+ if (!active || codingPlanCaches[id]?.inFlight !== task) return
1795
1852
  codingPlanCaches[id] = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), windows: result.windows } }
1796
1853
  // SCNet 百分比来自本地自估(自我引用),不参与采样估算。
1797
1854
  if (id !== 'scnet') recordQuotaSamples(id, result.windows)
1798
1855
  }, error => {
1856
+ if (!active || codingPlanCaches[id]?.inFlight !== task) return
1799
1857
  // 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
1800
1858
  // 软失败(未配置凭据/无订阅)完整缓存;硬失败写 error 状态但保留旧 fetchedAt。
1801
1859
  const now = Date.now()
@@ -1811,7 +1869,7 @@ function createService(ctx, ledger, openRouterPrices) {
1811
1869
  }).finally(() => {
1812
1870
  if (codingPlanCaches[id]?.inFlight === task) delete codingPlanCaches[id].inFlight
1813
1871
  })
1814
- codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task }
1872
+ codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task, controller }
1815
1873
  await task
1816
1874
  }
1817
1875
 
@@ -1826,11 +1884,15 @@ function createService(ctx, ledger, openRouterPrices) {
1826
1884
  const configured = await resolveGatewayKeyConfigured(ctx, source)
1827
1885
  const fingerprint = gatewaySourceFingerprint(source, configured)
1828
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)
1829
1890
  let cache = gatewayQuotaCaches[id]
1830
1891
  if (cache === undefined || cache.configFingerprint !== configFingerprint || cache.keyConfigured !== configured) {
1831
1892
  // Preserve only a valid LKG when config/key identity is unchanged. A changed
1832
1893
  // source starts empty; an old in-flight task is intentionally detached.
1833
- 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 }
1834
1896
  gatewayQuotaCaches[id] = cache
1835
1897
  }
1836
1898
  const interval = Math.max(1, Number(source.refreshMinutes) || 15) * 60_000
@@ -1843,8 +1905,9 @@ function createService(ctx, ledger, openRouterPrices) {
1843
1905
  }
1844
1906
  // Force intentionally waits for the current request, then starts a new one.
1845
1907
  await current.catch(() => {})
1908
+ if (!currentSource()) return emptyGatewayForSource(source)
1846
1909
  cache = gatewayQuotaCaches[id]
1847
- if (cache?.inFlight !== undefined) return ensureGatewayQuota(source, true)
1910
+ if (cache === undefined || cache.inFlight !== undefined) return ensureGatewayQuota(source, true)
1848
1911
  }
1849
1912
  const task = queryGatewayQuota(ctx, source).then(value => {
1850
1913
  const active = gatewayQuotaCaches[id]
@@ -1856,14 +1919,14 @@ function createService(ctx, ledger, openRouterPrices) {
1856
1919
  message: typeof value?.message === 'string' ? value.message : '', attemptedAt: value?.attemptedAt ?? Date.now(),
1857
1920
  keyConfigured: value?.keyConfigured === true, keySource: typeof value?.keySource === 'string' ? value.keySource : 'none',
1858
1921
  }
1859
- 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 }
1860
1923
  return next
1861
1924
  }).catch(error => {
1862
1925
  const active = gatewayQuotaCaches[id]
1863
1926
  if (active?.inFlight !== task || active.sourceFingerprint !== fingerprint) return active?.value ?? emptyGatewayForSource(source)
1864
1927
  const lastGood = active.lastGood
1865
1928
  const next = { ...(lastGood ?? emptyGatewayForSource(source)), status: lastGood ? 'stale' : 'error', message: 'gateway query failed', attemptedAt: Date.now() }
1866
- gatewayQuotaCaches[id] = { fetchedAt: active.fetchedAt ?? 0, value: next, lastGood, sourceFingerprint: fingerprint }
1929
+ gatewayQuotaCaches[id] = { ...active, fetchedAt: active.fetchedAt ?? 0, value: next, lastGood }
1867
1930
  return next
1868
1931
  }).finally(() => {
1869
1932
  if (gatewayQuotaCaches[id]?.inFlight === task) {
@@ -1901,7 +1964,9 @@ function createService(ctx, ledger, openRouterPrices) {
1901
1964
  }
1902
1965
  kick(balanceCache.fetchedAt > 0, () => ensureBalance(forceBalance))
1903
1966
  kick(goQuotaCache.fetchedAt > 0, () => ensureGoQuota(false))
1904
- 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))
1905
1970
  kick(Object.values(codingPlanCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCodingPlans(false))
1906
1971
  pending.push(ensureCodingPlan('scnet', false))
1907
1972
  const enabledGatewaySources = gatewaySourceConfigs().filter(source => source?.enabled !== false)
@@ -1918,6 +1983,7 @@ function createService(ctx, ledger, openRouterPrices) {
1918
1983
  },
1919
1984
 
1920
1985
  async updateConfig(patch) {
1986
+ const before = ledger.config
1921
1987
  const gatewayBefore = ledger.config?.gatewayQuotas
1922
1988
  const gatewayBeforeSources = gatewayBefore?.sources
1923
1989
  const currencyBefore = ledger.config.prices?.currency === 'CNY' ? 'CNY' : 'USD'
@@ -1927,6 +1993,12 @@ function createService(ctx, ledger, openRouterPrices) {
1927
1993
  throw new Error(tmsg(locale, 'configRejected', { errors: errors.join(locale === 'zh' ? ';' : '; ') }))
1928
1994
  }
1929
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
+ }
1930
2002
  if (patch?.gatewayQuotas !== undefined || gatewayBefore !== config.gatewayQuotas) {
1931
2003
  syncGatewayQuotaCaches(gatewayBeforeSources, config.gatewayQuotas?.sources)
1932
2004
  }
@@ -2104,6 +2176,7 @@ function createService(ctx, ledger, openRouterPrices) {
2104
2176
  invalidateCustomBalanceCachesForVar(customVar)
2105
2177
  for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
2106
2178
  }
2179
+ else resetQueryCache(key)
2107
2180
  return {
2108
2181
  ok: true,
2109
2182
  message: tmsg(locale, 'credentialSaved'),
@@ -2144,6 +2217,7 @@ function createService(ctx, ledger, openRouterPrices) {
2144
2217
  invalidateCustomBalanceCachesForVar(customVar)
2145
2218
  for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
2146
2219
  }
2220
+ else resetQueryCache(key)
2147
2221
  return {
2148
2222
  ok: true,
2149
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 直接覆盖销毁历史。
@@ -59,12 +59,16 @@ export function createUsageDeduper({ windowMs = USAGE_DEDUP_WINDOW_MS, now = Dat
59
59
  }
60
60
  }
61
61
 
62
- // 会话条目懒裁剪:条目在窗口内必然被访问到(有活跃指纹),把窗口外彻底
63
- // 空掉的条目删除即可让 Map 收敛到活跃会话量级。超过阈值才扫描一次,
64
- // 摊薄每次入账的开销。
65
- const pruneIfNeeded = () => {
62
+ // 不再访问的会话也要清理过期指纹。登记本次调用后再裁剪,避免把尚未
63
+ // 写入指纹的新会话当作空条目删除,使超过阈值后的转发对重复入账。
64
+ let nextPruneAt = 0
65
+ const pruneIfNeeded = t => {
66
66
  if (sessions.size <= 1024) return
67
+ if (t < nextPruneAt) return
68
+ nextPruneAt = t + windowMs
67
69
  for (const [key, entry] of sessions) {
70
+ sweep(entry.plain, t)
71
+ sweep(entry.remapped, t)
68
72
  if (entry.plain.size === 0 && entry.remapped.size === 0) sessions.delete(key)
69
73
  }
70
74
  }
@@ -94,13 +98,13 @@ export function createUsageDeduper({ windowMs = USAGE_DEDUP_WINDOW_MS, now = Dat
94
98
  }
95
99
  sweep(entry.plain, t)
96
100
  sweep(entry.remapped, t)
97
- pruneIfNeeded()
98
101
  const book = wrapped ? entry.remapped : entry.plain
99
102
  // 包装样本与普通样本、改挂样本都互斥;普通样本只与改挂样本互斥。
100
103
  if (wrapped ? (entry.plain.has(fp) || entry.remapped.has(fp)) : entry.remapped.has(fp)) {
101
104
  return null
102
105
  }
103
106
  book.set(fp, t)
107
+ pruneIfNeeded(t)
104
108
  if (book.size > maxEntries) {
105
109
  // 容量护栏:淘汰最旧条目(窗口语义只关心最近的转发对)。
106
110
  let oldestFp = null
package/locale/en.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "meta": {}
3
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "费用计量 (dsh-cost-meter)",
4
+ "description": "费用计量:按会话统计 token 与成本,含各厂商余额与峰谷计价提醒"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.7.38",
3
+ "version": "1.7.40",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
- "bin": { "dsh-cost-meter-repair-sessions": "./lib/repair-sessions-cli.js" },
7
+ "bin": {
8
+ "dsh-cost-meter-repair-sessions": "./lib/repair-sessions-cli.js"
9
+ },
8
10
  "exports": {
9
11
  ".": {
10
12
  "default": "./lib/index.js"
@@ -15,11 +17,16 @@
15
17
  "./typert": {
16
18
  "default": "./lib/typert.host.js"
17
19
  },
18
- "./package.json": "./package.json"
20
+ "./package.json": "./package.json",
21
+ "./locale/*.json": "./locale/*.json"
19
22
  },
20
23
  "dsh": {
21
- "bundle": {"patch": "./cordis.patch.yml"},
22
- "client": {"platform": "web"},
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "platform": "web"
29
+ },
23
30
  "compatibility": {
24
31
  "dsh": ">=0.1.0-rc.5",
25
32
  "dshReleases": {
@@ -82,6 +89,7 @@
82
89
  }
83
90
  },
84
91
  "files": [
92
+ "locale",
85
93
  "lib",
86
94
  "cordis.patch.yml",
87
95
  "docs/provider-pricing.json",
@@ -119,7 +127,11 @@
119
127
  "engines": {
120
128
  "node": ">=20"
121
129
  },
122
- "os": ["darwin", "linux", "win32"],
130
+ "os": [
131
+ "darwin",
132
+ "linux",
133
+ "win32"
134
+ ],
123
135
  "scripts": {
124
136
  "build": "node scripts/build.mjs",
125
137
  "test": "node test/verify.mjs"