dsh-cost-meter 1.6.1 → 1.6.2

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.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  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; from Aug 23, 2026 weekends are billed at off-peak prices all day, shown as “Weekend — all off-peak”) · 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) 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)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.6.1-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.6.2-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -231,22 +231,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
231
231
  dsh plugin --profile web add dsh-cost-meter
232
232
  ```
233
233
 
234
- **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.6.1`** — review the script before running):
234
+ **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.6.2`** — review the script before running):
235
235
 
236
236
  ```powershell
237
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.1/install.ps1 | iex
237
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.2/install.ps1 | iex
238
238
  ```
239
239
 
240
240
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
241
241
 
242
242
  ```sh
243
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.1
243
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.2
244
244
  ```
245
245
 
246
246
  Without git, use the GitHub tag archive:
247
247
 
248
248
  ```sh
249
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.1.tar.gz
249
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.2.tar.gz
250
250
  ```
251
251
 
252
252
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
@@ -284,7 +284,7 @@ dsh plugin --profile web add link:./dsh-cost-meter # symlink; edit lib/client.j
284
284
  - Billing sources are the `usage` block of every model call (including sub-agents, compression, title generation and other auxiliary calls), matching the billable view;
285
285
  - Budget and over-budget warnings **only warn — they never block calls**.
286
286
  - **Plan/API dual-track billing** (issue #64): calls on subscription-style channels (MiniMax, manually-marked Codex, etc.) are recorded as catalog-price equivalents only; budget/today-cost/overview cards count the pay-as-you-go (API) channel exclusively.
287
- - **Full-repo security audit fixes (v1.6.1)**: resolved 3 high-risk findings — ledger writes lost on exit (close/flush ordering), command injection in the release script, panel null-pointer crash on null Go monthly window — plus 30+ medium/low issues including missing hour-bucket entries for routed calls, dead balance-reconciliation warnings, failed custom-balance extraction silently showing $0, and leaked upstream streams on consumer abort; ledger corruption auto-backup, write-failure retry, config-patch atomicity and prototype-chain hardening included. Itemized list: [docs/security-audit-v1.6.1.md](docs/security-audit-v1.6.1.md).
287
+ - **Full-repo security audit fixes (v1.6.2)**: resolved 3 high-risk findings — ledger writes lost on exit (close/flush ordering), command injection in the release script, panel null-pointer crash on null Go monthly window — plus 30+ medium/low issues including missing hour-bucket entries for routed calls, dead balance-reconciliation warnings, failed custom-balance extraction silently showing $0, and leaked upstream streams on consumer abort; ledger corruption auto-backup, write-failure retry, config-patch atomicity and prototype-chain hardening included. Itemized list: [docs/security-audit-v1.6.2.md](docs/security-audit-v1.6.2.md).
288
288
  - **"Include plan totals" toggle** under the overview cards switches every amount between real-money (API channels only, default) and total equivalent cost (plan subscriptions included); routed calls with a missing provider whose models match third-party catalogs are auto-classified into the matching plan, and legacy per-day residuals without model details count toward the API scope; The Token Plan stats panel in Settings → Usage provides per-1% and full-window token/equivalent-cost estimates plus daily/weekly/monthly usage curves; classification is configurable per vendor and per provider:model.
289
289
 
290
290
  ## Data storage
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;2026-08-23 起周末全天按谷价,显示「周末时段——全谷价」) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 九家,含 Volcano Ark AK/SK 签名) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.6.1-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.6.2-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -233,22 +233,22 @@
233
233
  dsh plugin --profile web add dsh-cost-meter
234
234
  ```
235
235
 
236
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.6.1`**,建议先下载审阅再运行):
236
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.6.2`**,建议先下载审阅再运行):
237
237
 
238
238
  ```powershell
239
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.1/install.ps1 | iex
239
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.2/install.ps1 | iex
240
240
  ```
241
241
 
242
242
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
243
243
 
244
244
  ```sh
245
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.1
245
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.2
246
246
  ```
247
247
 
248
248
  没有 git 时可用 GitHub tag 打包直链:
249
249
 
250
250
  ```sh
251
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.1.tar.gz
251
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.2.tar.gz
252
252
  ```
253
253
 
254
254
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
@@ -287,7 +287,7 @@ dsh plugin --profile web add link:./dsh-cost-meter # 符号链接,改 lib/clien
287
287
  - 预算与超支提示**仅提醒,不阻止调用**。
288
288
  - **Plan/API 双轨计费**(issue #64):订阅制渠道(MiniMax / Codex 手动标记等)的调用金额只记「等值」,预算/今日费用/概览卡片等金额展示仅统计按量计费(API)部分;
289
289
  - **「含 Plan 总额」开关**:概览页汇总卡片下的快捷开关切换全部金额展示口径——关闭时仅计真金白银(API 渠道),开启后显示含 Plan 等值的总金额;网关路由(provider 缺失)调用的第三方目录模型自动归入对应订阅归类,无模型明细的历史残差计入 API 口径;设置 → 用量 的 Token Plan 统计面板提供每 1% 额度与满窗的 token/等值金额估算与日/周/月曲线;分类可在配置中按厂商或 provider:model 级覆盖。
290
- - **全仓安全审计修复(v1.6.1)**:账本退出丢写(close/flush 次序)、发版脚本命令注入、面板空指针崩溃(Go 月窗空值)3 项高危,及路由调用小时桶漏记、官方余额对账告警失效、自定义余额提取失败误显 $0、计费流中断泄漏等 30 余项中低危问题全量修复;账本损坏自动备份、写失败重试、配置补丁原子性、原型链与凭据处理加固。逐项清单见 [docs/security-audit-v1.6.1.md](docs/security-audit-v1.6.1.md)。
290
+ - **全仓安全审计修复(v1.6.2)**:账本退出丢写(close/flush 次序)、发版脚本命令注入、面板空指针崩溃(Go 月窗空值)3 项高危,及路由调用小时桶漏记、官方余额对账告警失效、自定义余额提取失败误显 $0、计费流中断泄漏等 30 余项中低危问题全量修复;账本损坏自动备份、写失败重试、配置补丁原子性、原型链与凭据处理加固。逐项清单见 [docs/security-audit-v1.6.2.md](docs/security-audit-v1.6.2.md)。
291
291
 
292
292
  ## 数据存储
293
293
 
package/lib/backfill.js CHANGED
@@ -249,14 +249,16 @@ export function replaySessionRecords(records, config, wantDates = null) {
249
249
  let usage = null
250
250
  let turn = 0
251
251
  let step = 0
252
- if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage !== undefined) {
252
+ // 判空与去重键缺省值与 index.js 投影完全同构(usage null 不入账;turn/step
253
+ // 缺省 0,保证实时投影与回放对同一事件产生同一 (turn,step) 去重键)。
254
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage != null) {
253
255
  usage = event.data.chunk.usage
254
- turn = event.data.turn
255
- step = event.data.step
256
- } else if (event.type === 'assistant/message' && event.data?.usage !== undefined) {
256
+ turn = event.data.turn ?? 0
257
+ step = event.data.step ?? 0
258
+ } else if (event.type === 'assistant/message' && event.data?.usage != null) {
257
259
  usage = event.data.usage
258
- turn = event.data.turn
259
- step = event.data.step
260
+ turn = event.data.turn ?? 0
261
+ step = event.data.step ?? 0
260
262
  } else {
261
263
  continue
262
264
  }
@@ -408,9 +410,12 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
408
410
  // 回放完整覆盖当日全部调用与 token 时,按回放结果重算当日总额(issue #18):
409
411
  // 旧版本曾把订阅制模型模糊匹配到同家族付费价实时误计费,回放按事件时刻
410
412
  // 正确计价,重算可修正历史虚高;仅部分覆盖时保留原始记录,差额入 legacy 行。
413
+ // reasoning 必须一并相等:旧样本缺失 reasoningTokens 时其余四桶仍可能恰好
414
+ // 相等,漏判会以不含 reasoning 成本的回放值覆盖账本且无痕。
411
415
  if (replayed.calls === (day.calls ?? 0)
412
416
  && replayed.input === (day.input ?? 0) && replayed.output === (day.output ?? 0)
413
- && replayed.cacheRead === (day.cacheRead ?? 0) && replayed.cacheWrite === (day.cacheWrite ?? 0)) {
417
+ && replayed.cacheRead === (day.cacheRead ?? 0) && replayed.cacheWrite === (day.cacheWrite ?? 0)
418
+ && replayed.reasoning === (day.reasoning ?? 0)) {
414
419
  day.cost = replayed.cost
415
420
  result.recosted = (result.recosted ?? 0) + 1
416
421
  }
@@ -446,14 +451,16 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
446
451
  acc.output += b.output ?? 0
447
452
  acc.cacheRead += b.cacheRead ?? 0
448
453
  acc.cacheWrite += b.cacheWrite ?? 0
454
+ acc.reasoning += b.reasoning ?? 0
449
455
  acc.calls += b.calls ?? 0
450
456
  acc.cost += b.cost ?? 0
451
457
  return acc
452
- }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0, cost: 0 })
458
+ }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 })
453
459
  if (sTotals.calls === (session.calls ?? 0)
454
460
  && dayIsEmpty
455
461
  && sTotals.input === (session.input ?? 0) && sTotals.output === (session.output ?? 0)
456
- && sTotals.cacheRead === (session.cacheRead ?? 0) && sTotals.cacheWrite === (session.cacheWrite ?? 0)) {
462
+ && sTotals.cacheRead === (session.cacheRead ?? 0) && sTotals.cacheWrite === (session.cacheWrite ?? 0)
463
+ && sTotals.reasoning === (session.reasoning ?? 0)) {
457
464
  session.cost = sTotals.cost
458
465
  }
459
466
  }
@@ -53,7 +53,7 @@ export function createLlmStreamBilling({ account }) {
53
53
  const result = await llmStreamDepth.run(1, () => iterator.next())
54
54
  if (result.done) break
55
55
  const chunk = result.value
56
- if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage !== undefined) {
56
+ if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage != null) {
57
57
  usage = chunk.usage
58
58
  }
59
59
  yield chunk
package/lib/client.js CHANGED
@@ -1353,6 +1353,10 @@ window.__ModuleLoader__.load({
1353
1353
  models,
1354
1354
  default: parsePrice(v.prices?.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }, path + '.prices.default'),
1355
1355
  providers: v.prices?.providers && typeof v.prices.providers === 'object' ? v.prices.providers : {},
1356
+ // 官方价格币种(issue #47):usageSplit 回退计价据此决定 CNY→USD 折算。
1357
+ // 此前白名单丢失该键,客户端恒按 USD 处理(不除汇率),展示时又乘回
1358
+ // 汇率,CNY 价目下徽章/明细金额被放大汇率倍。
1359
+ currency: typeof v.prices?.currency === 'string' && v.prices.currency.length > 0 ? v.prices.currency : undefined,
1356
1360
  },
1357
1361
  historyDays: needNum(v.historyDays, path + '.historyDays'),
1358
1362
  fetchedAt: v.fetchedAt === null || v.fetchedAt === undefined ? null : needStr(v.fetchedAt, path + '.fetchedAt'),
@@ -1780,7 +1784,14 @@ window.__ModuleLoader__.load({
1780
1784
  for (const c of list) {
1781
1785
  const cc = canonModelIdLocal(c)
1782
1786
  if (cc.length < 4 || cc === canon) continue
1783
- if (canon.includes(cc) && cc.length > containLen) { containHit = c; containLen = cc.length }
1787
+ if (canon.includes(cc) && cc.length > containLen) {
1788
+ // 数字分叉守卫(issue #18 同源,与 pricing.js 同步):候选是请求 canon
1789
+ // 的真前缀且剩余段为 1-2 位纯数字(glm-5 vs glm-5.3)时视为版本分叉
1790
+ // 拒绝;日期快照(≥3 位)与 '-128k' 容量后缀余量不受影响。
1791
+ const idx = canon.indexOf(cc)
1792
+ if (/^\d{1,2}$/.test(canon.slice(idx + cc.length))) continue
1793
+ containHit = c; containLen = cc.length
1794
+ }
1784
1795
  }
1785
1796
  if (containHit !== null) return containHit
1786
1797
  const stripped = strip(modelId)
@@ -1790,7 +1801,9 @@ window.__ModuleLoader__.load({
1790
1801
  for (const c of list) {
1791
1802
  const cs = strip(c)
1792
1803
  if (cs.length === 0 || cs === stripped) continue
1793
- if (stripped.startsWith(cs) && /^[-_./:]/.test(stripped.slice(cs.length))) {
1804
+ const rest = stripped.slice(cs.length)
1805
+ if (stripped.startsWith(cs) && /^[-_./:]/.test(rest)) {
1806
+ if (/^\d{1,2}$/.test(rest.replace(/^[-_./:]+/, ''))) continue // 版本分叉(gpt-5.9 的 '.9'),与 pricing.js 同步
1794
1807
  if (prefixHit === null || strip(prefixHit).length < cs.length) prefixHit = c
1795
1808
  }
1796
1809
  }
@@ -1806,6 +1819,11 @@ window.__ModuleLoader__.load({
1806
1819
  while (n < mt.length && n < ct.length && mt[n] === ct[n]) n += 1
1807
1820
  // 防跨版本误配(issue #18,与 pricing.js 同步):分歧位置两侧都是数字/版本号 token 时拒绝匹配。
1808
1821
  if (n < mt.length && n < ct.length && /^\d+$/.test(mt[n]) && /^\d+$/.test(ct[n])) continue
1822
+ // 候选耗尽而请求多出全为 1-2 位纯数字 token(glm-5 vs glm-5.3)同为版本分叉,拒绝。
1823
+ if (n >= 2 && n === ct.length && n < mt.length && mt.slice(n).every(t => /^\d{1,2}$/.test(t))) continue
1824
+ // 分歧位一侧为 1-2 位版本号、另一侧为变体名(gpt-5.9 vs gpt-5-nano),与 pricing.js 同步拒绝。
1825
+ if (n >= 2 && n < mt.length && n < ct.length
1826
+ && ((/^\d{1,2}$/.test(mt[n]) && /^[a-z]/.test(ct[n])) || (/^\d{1,2}$/.test(ct[n]) && /^[a-z]/.test(mt[n])))) continue
1809
1827
  if (n >= 2 && (n > bestLen || (n === bestLen && best !== null && c.length < best.length))) { best = c; bestLen = n }
1810
1828
  }
1811
1829
  return best
@@ -1848,15 +1866,21 @@ window.__ModuleLoader__.load({
1848
1866
  if (mode === 'auto') {
1849
1867
  let bestEntry = null
1850
1868
  let bestLen = -1
1869
+ let bestMode = 'flat'
1851
1870
  for (const [prov, table] of Object.entries(prices.providers ?? {})) {
1852
1871
  const modelsCat = table?.models ?? {}
1853
1872
  const h = matchModelIdLocal(targetModel, Object.keys(modelsCat))
1854
1873
  if (h === null || modelsCat[h]?.unpriced === true) continue
1855
1874
  const isExact = h === targetModel || canonModelIdLocal(h) === canonModelIdLocal(targetModel)
1856
1875
  const score = (isExact ? 1000 : 0) + canonModelIdLocal(h).length
1857
- if (score > bestLen) { bestEntry = modelsCat[h]; bestLen = score }
1876
+ if (score > bestLen) {
1877
+ bestEntry = modelsCat[h]; bestLen = score
1878
+ // 与 pricing.js 同口径:命中条目自带峰谷模式时保留,否则客户端
1879
+ // 会按 flat 计与服务端入账不一致。
1880
+ bestMode = modelsCat[h]?.billingMode === 'deepseek-peak' ? 'deepseek-peak' : 'flat'
1881
+ }
1858
1882
  }
1859
- if (bestEntry !== null) return { entry: bestEntry, priced: true, billingMode: 'flat', matched: true }
1883
+ if (bestEntry !== null) return { entry: bestEntry, priced: true, billingMode: bestMode, matched: true }
1860
1884
  }
1861
1885
  return { entry: prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }, priced: true, billingMode: 'deepseek-peak', matched: false }
1862
1886
  }
@@ -1871,6 +1895,7 @@ window.__ModuleLoader__.load({
1871
1895
  if (dsHit !== null) return { entry: dsModels[dsHit], priced: true, billingMode: 'deepseek-peak', matched: true }
1872
1896
  let bestEntry = null
1873
1897
  let bestLen = -1
1898
+ let bestMode = 'flat'
1874
1899
  for (const [prov, table] of Object.entries(prices.providers ?? {})) {
1875
1900
  if (prov === targetProvider) continue
1876
1901
  const models = table?.models ?? {}
@@ -1878,9 +1903,12 @@ window.__ModuleLoader__.load({
1878
1903
  if (h === null || models[h]?.unpriced === true) continue
1879
1904
  const isExact = h === targetModel || canonModelIdLocal(h) === canonModelIdLocal(targetModel)
1880
1905
  const score = (isExact ? 1000 : 0) + canonModelIdLocal(h).length
1881
- if (score > bestLen) { bestEntry = models[h]; bestLen = score }
1906
+ if (score > bestLen) {
1907
+ bestEntry = models[h]; bestLen = score
1908
+ bestMode = models[h]?.billingMode === 'deepseek-peak' ? 'deepseek-peak' : 'flat' // 与 pricing.js 同口径
1909
+ }
1882
1910
  }
1883
- if (bestEntry !== null) return { entry: bestEntry, priced: true, billingMode: 'flat', matched: true }
1911
+ if (bestEntry !== null) return { entry: bestEntry, priced: true, billingMode: bestMode, matched: true }
1884
1912
  }
1885
1913
  // issue #56 镜像:v1.5.42 及之前设置页下拉框把 DeepSeek 目标存成裸名,被按
1886
1914
  // 「同渠道换名」解析后查无此价。此处对「裸值覆盖 + 非 DeepSeek 渠道解析失败」
@@ -2155,9 +2183,18 @@ window.__ModuleLoader__.load({
2155
2183
 
2156
2184
  // ── 侧边栏:余额行 + 预算图框/今日徽章(纵向堆叠,位于设置按钮上方) ──────
2157
2185
 
2158
- function formatBalanceMoney(value, config) {
2159
- // 余额是官方接口返回的记账币种金额(如 CNY),不经过汇率换算。
2160
- return formatMoneyValue(value, { symbol: config.symbol, decimals: Math.max(2, Math.min(10, Math.floor(Number(config.decimals) || 2))) })
2186
+ /** 记账币种代码 → 符号(官方/自定义余额金额不经汇率换算,符号必须与数值同币种) */
2187
+ const BALANCE_CURRENCY_SYMBOLS = { USD: '$', CNY: '¥', EUR: '€' }
2188
+
2189
+ function formatBalanceMoney(value, config, currency) {
2190
+ // 余额是官方接口返回的记账币种金额(如 CNY),不经过汇率换算;
2191
+ // 此前恒用显示币种符号(¥53 渲染成 $53),数值未换算而符号错位近汇率倍。
2192
+ const code = typeof currency === 'string' ? currency.toUpperCase() : ''
2193
+ const fallbackSymbol = typeof config?.symbol === 'string' && config.symbol.length > 0 ? config.symbol : '$'
2194
+ return formatMoneyValue(value, {
2195
+ symbol: BALANCE_CURRENCY_SYMBOLS[code] ?? fallbackSymbol,
2196
+ decimals: Math.max(2, Math.min(10, Math.floor(Number(config?.decimals) || 2))),
2197
+ })
2161
2198
  }
2162
2199
 
2163
2200
  function resolveBalanceCap(config, custom) {
@@ -2315,10 +2352,10 @@ window.__ModuleLoader__.load({
2315
2352
  }
2316
2353
  const detail = [
2317
2354
  t('balanceTitle'),
2318
- t('totalBalance', { amount: formatBalanceMoney(balance.totalBalance, state.config) }),
2355
+ t('totalBalance', { amount: formatBalanceMoney(balance.totalBalance, state.config, balance.currency) }),
2319
2356
  t('grantedToppedUp', {
2320
- granted: formatBalanceMoney(balance.grantedBalance, state.config),
2321
- toppedUp: formatBalanceMoney(balance.toppedUpBalance, state.config),
2357
+ granted: formatBalanceMoney(balance.grantedBalance, state.config, balance.currency),
2358
+ toppedUp: formatBalanceMoney(balance.toppedUpBalance, state.config, balance.currency),
2322
2359
  }),
2323
2360
  t('updatedAt', { time: balance.fetchedAt > 0 ? new Date(balance.fetchedAt).toLocaleTimeString() : '—' }),
2324
2361
  ...(state.reconcile?.ok === false ? ['⚠ ' + state.reconcile.message] : []),
@@ -2326,7 +2363,7 @@ window.__ModuleLoader__.load({
2326
2363
  ].join('; ')
2327
2364
  return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
2328
2365
  el('div', { className: 'cm-foot clickable' + (wide ? '' : ' cm-foot-rail') + (refresh.busy ? ' busy' : ''), ...clickableRefreshProps(refresh.busy, refresh.run) },
2329
- wide ? el(Fragment, null, t('balance'), ' ', el('span', { className: 'cm-num' }, formatBalanceMoney(balance.totalBalance, state.config)), ' ', rechargeLinkEl(t), state.reconcile?.ok === false ? ' ⚠' : '') : el(WalletIcon, { size: 16 })))
2366
+ wide ? el(Fragment, null, t('balance'), ' ', el('span', { className: 'cm-num' }, formatBalanceMoney(balance.totalBalance, state.config, balance.currency)), ' ', rechargeLinkEl(t), state.reconcile?.ok === false ? ' ⚠' : '') : el(WalletIcon, { size: 16 })))
2330
2367
  }
2331
2368
 
2332
2369
  function BalanceBox(props) {
@@ -2337,14 +2374,14 @@ window.__ModuleLoader__.load({
2337
2374
  const refresh = useClickRefresh(api ? () => api.refreshBalance() : null)
2338
2375
  if (!balance || balance.status !== 'ok') return null
2339
2376
  const segments = segmentsForOfficialBalance(state, config)
2340
- const remaining = formatBalanceMoney(balance.totalBalance, config)
2341
- const formatAmt = v => formatBalanceMoney(v, config)
2377
+ const remaining = formatBalanceMoney(balance.totalBalance, config, balance.currency)
2378
+ const formatAmt = v => formatBalanceMoney(v, config, balance.currency)
2342
2379
  const detail = [
2343
2380
  t('balanceTitle'),
2344
2381
  balanceBarTooltipLines(t, formatAmt, segments, remaining, segments.cap),
2345
2382
  t('grantedToppedUp', {
2346
- granted: formatBalanceMoney(balance.grantedBalance, config),
2347
- toppedUp: formatBalanceMoney(balance.toppedUpBalance, config),
2383
+ granted: formatBalanceMoney(balance.grantedBalance, config, balance.currency),
2384
+ toppedUp: formatBalanceMoney(balance.toppedUpBalance, config, balance.currency),
2348
2385
  }),
2349
2386
  t('updatedAt', { time: balance.fetchedAt > 0 ? new Date(balance.fetchedAt).toLocaleTimeString() : '—' }),
2350
2387
  ...(state.reconcile?.ok === false ? ['⚠ ' + state.reconcile.message] : []),
@@ -4020,7 +4057,16 @@ window.__ModuleLoader__.load({
4020
4057
  value: typeof value === 'number' ? String(value) : '',
4021
4058
  onChange: event => {
4022
4059
  const text = event.target.value
4023
- if (text === '') { onChange(0); return }
4060
+ // 清空语义分三档(emptyMode):
4061
+ // 'clear' → 回调 null,由调用方删除该字段/档位(价格 offPeak/peak 行);
4062
+ // 'ignore' → 不提交,保留原值(必填结构如基础价、汇率——写 0 会静默
4063
+ // 改变计费或被服务端拒绝);
4064
+ // 默认 → 提交 0(预算等 0 为合法值的场景)。
4065
+ if (text === '') {
4066
+ if (props.emptyMode === 'clear') { onChange(null); return }
4067
+ if (props.emptyMode === 'ignore') return
4068
+ onChange(0); return
4069
+ }
4024
4070
  const parsed = Number(text)
4025
4071
  // 负数直接忽略,避免把负值写进价格/预算配置(min 属性挡不住手动输入)。
4026
4072
  if (Number.isFinite(parsed) && parsed >= 0) onChange(parsed)
@@ -4031,6 +4077,33 @@ window.__ModuleLoader__.load({
4031
4077
  function PriceCard(props) {
4032
4078
  const { modelId, entry, isDefault, draft, setDraft, t } = props
4033
4079
  const setTier = (tierKey, field, value) => {
4080
+ // 清空输入(emptyMode:'clear' 回传 null):
4081
+ // - offPeak/peak 档位字段 → 从档位对象中删除;档位删空则整键移除,
4082
+ // 恢复「无该档、按基础价计」状态。此前空串被提交成显式 0 价,
4083
+ // normalizePrice 会保留该档,之后所有调用按 0 计费(静默变免费)。
4084
+ // - 基础价三桶为必填结构 → 忽略清空(numInput 'ignore' 不回调)。
4085
+ if (value === null) {
4086
+ if (isDefault) {
4087
+ const def = draft.prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
4088
+ const tier = { ...(def[tierKey] ?? {}) }
4089
+ delete tier[field]
4090
+ const next = { ...def }
4091
+ if (Object.keys(tier).length > 0) next[tierKey] = tier
4092
+ else delete next[tierKey]
4093
+ setDraft({ ...draft, prices: { ...draft.prices, default: next } })
4094
+ return
4095
+ }
4096
+ const models = { ...draft.prices.models }
4097
+ const current = models[modelId] ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
4098
+ const tier = { ...(current[tierKey] ?? {}) }
4099
+ delete tier[field]
4100
+ const next = { ...current }
4101
+ if (Object.keys(tier).length > 0) next[tierKey] = tier
4102
+ else delete next[tierKey]
4103
+ models[modelId] = next
4104
+ setDraft({ ...draft, prices: { ...draft.prices, models } })
4105
+ return
4106
+ }
4034
4107
  const nextField = Math.max(0, value)
4035
4108
  if (isDefault) {
4036
4109
  const def = draft.prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
@@ -4061,11 +4134,12 @@ window.__ModuleLoader__.load({
4061
4134
  }
4062
4135
  const tierRow = (label, tierKey) => {
4063
4136
  const tier = tierKey === 'base' ? entry : entry[tierKey] ?? null
4137
+ const emptyMode = tierKey === 'base' ? 'ignore' : 'clear'
4064
4138
  return el('div', { className: 'cm-price-row', key: tierKey },
4065
4139
  el('span', null, label),
4066
- numInput({ value: tier?.cacheHit ?? null }, v => setTier(tierKey, 'cacheHit', v)),
4067
- numInput({ value: tier?.cacheMiss ?? null }, v => setTier(tierKey, 'cacheMiss', v)),
4068
- numInput({ value: tier?.output ?? null }, v => setTier(tierKey, 'output', v)))
4140
+ numInput({ value: tier?.cacheHit ?? null, emptyMode }, v => setTier(tierKey, 'cacheHit', v)),
4141
+ numInput({ value: tier?.cacheMiss ?? null, emptyMode }, v => setTier(tierKey, 'cacheMiss', v)),
4142
+ numInput({ value: tier?.output ?? null, emptyMode }, v => setTier(tierKey, 'output', v)))
4069
4143
  }
4070
4144
  return el('div', { className: 'cm-price-card' },
4071
4145
  el('div', { className: 'cm-price-head' },
@@ -4562,9 +4636,10 @@ window.__ModuleLoader__.load({
4562
4636
  el('span', null, t('flatInput')), el('span', null, t('flatCached')), el('span', null, t('flatOutput'))),
4563
4637
  el('div', { className: 'cm-price-row' },
4564
4638
  el('span', null, 'USD'),
4565
- numInput({ value: entry?.input ?? null }, v => setNum('input', v)),
4566
- numInput({ value: entry?.cachedInput ?? null }, v => setNum('cachedInput', v)),
4567
- numInput({ value: entry?.output ?? null }, v => setNum('output', v)))))
4639
+ // 第三方三桶为必填计价结构:清空不提交(保留原值),避免误清成 0 价免费。
4640
+ numInput({ value: entry?.input ?? null, emptyMode: 'ignore' }, v => setNum('input', v)),
4641
+ numInput({ value: entry?.cachedInput ?? null, emptyMode: 'ignore' }, v => setNum('cachedInput', v)),
4642
+ numInput({ value: entry?.output ?? null, emptyMode: 'ignore' }, v => setNum('output', v)))))
4568
4643
  }
4569
4644
 
4570
4645
  // ── 余额面板(设置页,按 balance.display 配置挂载) ────────────────────────
@@ -4595,7 +4670,7 @@ window.__ModuleLoader__.load({
4595
4670
  setBusy(false)
4596
4671
  }
4597
4672
  }
4598
- const money = value => formatBalanceMoney(value, config)
4673
+ const money = value => formatBalanceMoney(value, config, balance?.currency)
4599
4674
  const body = balance.status === 'ok'
4600
4675
  ? el('div', { className: 'cm-bal-line' },
4601
4676
  el('span', null, t('balanceLine', {
@@ -5539,7 +5614,8 @@ window.__ModuleLoader__.load({
5539
5614
  })),
5540
5615
  el('div', { className: 'cm-field' },
5541
5616
  el('label', null, t('rateLabel')),
5542
- numInput({ value: draft?.exchangeRate ?? 1 }, v => setField('exchangeRate', v))),
5617
+ // 清空不提交:汇率 0 会被服务端校验拒绝并整包报错,保留原值更友好。
5618
+ numInput({ value: draft?.exchangeRate ?? 1, emptyMode: 'ignore' }, v => setField('exchangeRate', v))),
5543
5619
  el('div', { className: 'cm-field' },
5544
5620
  el('label', null, t('decimalsLabel')),
5545
5621
  numInput({ value: draft?.decimals ?? 2 }, v => setField('decimals', Math.min(10, Math.floor(v))))),
@@ -138,6 +138,11 @@ export function parseAnthropicUsage(data) {
138
138
  const windows = {}
139
139
  for (const [name, raw] of Object.entries(data)) {
140
140
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
141
+ // 子配额窗口(seven_day_sonnet / five_hour_opus 等)只描述单一模型系列的
142
+ // 限额:canonicalWindowKey 会把它与同名主窗口归到同一键,后解析的子配额
143
+ // 覆盖主窗采样列与面板百分比(seven_day: 12% 显示成 sonnet 的 3%)。
144
+ // 只保留主窗口,子配额整体丢弃。
145
+ if (/^(five[_ -]?hour|seven[_ -]?day)[_ -]/i.test(name)) continue
141
146
  const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
142
147
  if (win !== null) windows[name] = win
143
148
  }
@@ -1072,9 +1077,10 @@ export async function queryCodingPlan(provider, key, locale, t) {
1072
1077
  error.soft = true // Key 无效/无订阅属预期场景,面板中性提示
1073
1078
  // zai 国内(bigmodel.cn)/国际(z.ai)域名 Key 不互通:单域 401 只说明 Key
1074
1079
  // 不属于该域,继续尝试下一域;全部 401 才认定为凭据无效(issue #42)。
1080
+ // minimax 国内(minimaxi.com)/国际(minimax.io)同为双域不互通,同待遇(issue #42 同型)。
1075
1081
  // kimi 订阅端点 401 = 该 Key 不是订阅 Key(sk-kimi-*)或无订阅:继续走
1076
1082
  // PAYG 余额兜底,不直接报错(issue #53)。
1077
- if (provider === 'zai' || isKimiCoding) { lastError = error; continue }
1083
+ if (provider === 'zai' || provider === 'minimax' || isKimiCoding) { lastError = error; continue }
1078
1084
  throw error
1079
1085
  }
1080
1086
  if (!response.ok) {
@@ -18,6 +18,23 @@ function getPath(root, path) {
18
18
  return current
19
19
  }
20
20
 
21
+ /**
22
+ * 严格取数(v1.6.1 B-3 残留变体):Number(null)/Number('')/Number(false) 都
23
+ * 是 0,会蒙混过 isFinite 守卫把「提取失败」伪造成 remaining:0。这里只接受
24
+ * 数字与非空数值字面量字符串;其余一律 NaN(fail-loud)。
25
+ * @param {unknown} value
26
+ * @returns {number} 有限数值,或 NaN 表示不可信。
27
+ */
28
+ function toStrictNumber(value) {
29
+ if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
30
+ if (typeof value === 'string') {
31
+ const text = value.trim()
32
+ if (text.length > 0 && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
33
+ return NaN
34
+ }
35
+ return NaN
36
+ }
37
+
21
38
  /**
22
39
  * @param {unknown} data
23
40
  * @param {unknown} rule
@@ -27,25 +44,28 @@ export function extractByRule(data, rule) {
27
44
  if (typeof rule === 'number' && Number.isFinite(rule)) return rule
28
45
  if (typeof rule === 'string') {
29
46
  const value = getPath(data, rule)
30
- const num = Number(value)
31
- return Number.isFinite(num) ? num : (typeof value === 'string' ? value : null)
47
+ const num = toStrictNumber(value)
48
+ // 非数值字符串原样交回外层 fail-loud(千分位/带货币符号等由 queryCustomBalance 抛错),
49
+ // 而 null/布尔/对象等一律 null,不再被 Number() 强转成 0。
50
+ if (Number.isFinite(num)) return num
51
+ return typeof value === 'string' ? value : null
32
52
  }
33
53
  if (typeof rule === 'object' && !Array.isArray(rule)) {
34
54
  const op = rule.op
35
55
  if (op === 'subtract' && Array.isArray(rule.paths)) {
36
56
  if (rule.paths.length === 0) return null // 空 paths 防 Reduce of empty array 报错(与 add 的空数组返 0 区分:减法无中性初值)
37
- const values = rule.paths.map(path => Number(getPath(data, path)))
57
+ const values = rule.paths.map(path => toStrictNumber(getPath(data, path)))
38
58
  if (!values.every(Number.isFinite)) return null
39
59
  return values.reduce((acc, value) => acc - value)
40
60
  }
41
61
  if (op === 'add' && Array.isArray(rule.paths)) {
42
- const values = rule.paths.map(path => Number(getPath(data, path)))
62
+ const values = rule.paths.map(path => toStrictNumber(getPath(data, path)))
43
63
  if (!values.every(Number.isFinite)) return null
44
64
  return values.reduce((acc, value) => acc + value, 0)
45
65
  }
46
66
  if (op === 'divide' && typeof rule.path === 'string') {
47
- const value = Number(getPath(data, rule.path))
48
- const by = Number(rule.by)
67
+ const value = toStrictNumber(getPath(data, rule.path))
68
+ const by = toStrictNumber(rule.by)
49
69
  if (!Number.isFinite(value) || !Number.isFinite(by) || by === 0) return null
50
70
  return value / by
51
71
  }
package/lib/index.js CHANGED
@@ -400,14 +400,17 @@ function makeCostUsageProjection(ledger) {
400
400
  let usage = null
401
401
  let turn = 0
402
402
  let step = 0
403
- if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage !== undefined) {
403
+ // 判空用 != null:usage === null !== undefined 会放行,随后读
404
+ // usage.inputTokens 直接抛 TypeError 打断投影;billing-stream 侧同处
405
+ // 还会让 null 覆盖先前捕获的有效 usage 快照导致整次调用漏计。
406
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage != null) {
404
407
  usage = event.data.chunk.usage
405
- turn = event.data.turn
406
- step = event.data.step
407
- } else if (event.type === 'assistant/message' && event.data?.usage !== undefined) {
408
+ turn = event.data.turn ?? 0
409
+ step = event.data.step ?? 0
410
+ } else if (event.type === 'assistant/message' && event.data?.usage != null) {
408
411
  usage = event.data.usage
409
- turn = event.data.turn
410
- step = event.data.step
412
+ turn = event.data.turn ?? 0
413
+ step = event.data.step ?? 0
411
414
  } else {
412
415
  return state
413
416
  }
package/lib/net.js CHANGED
@@ -50,7 +50,16 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
50
50
  export async function fetchWithRetry(url, init = {}, { attempts = 4, backoffMs = 300, timeoutMs = 0 } = {}) {
51
51
  for (let attempt = 1; ; attempt++) {
52
52
  if (attempt > 1) await sleep(Math.min(1500, backoffMs * 2 ** (attempt - 2)))
53
- const perAttempt = timeoutMs > 0 ? { ...init, signal: AbortSignal.timeout(timeoutMs) } : init
53
+ let perAttempt = init
54
+ if (timeoutMs > 0) {
55
+ // 超时信号不得吞掉调用方的取消信号:两者并存时用 AbortSignal.any 组合
56
+ // (任一触发即中止);运行时不支持 any 时退回旧行为(仅超时信号)。
57
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
58
+ const signal = init.signal !== undefined && init.signal !== null && typeof AbortSignal.any === 'function'
59
+ ? AbortSignal.any([init.signal, timeoutSignal])
60
+ : timeoutSignal
61
+ perAttempt = { ...init, signal }
62
+ }
54
63
  try {
55
64
  return await fetch(url, perAttempt)
56
65
  } catch (error) {
@@ -156,6 +156,19 @@ export function canonicalWindowKey(name) {
156
156
  if (/week|seven_?day|7\s*d/.test(n)) return 'weekly'
157
157
  if (/month/.test(n)) return 'monthly'
158
158
  if (/daily|^day$/.test(n)) return 'daily'
159
+ // 滚动窗命名(duration+timeUnit,Kimi limits[] 的 '5h'/'1w'/'2d'/'1mo'):
160
+ // 按时间量级归入最近标准周期,避免落进 periodStartOf 的 48h 兜底——满窗
161
+ // 估算用「兜底窗本地用量 ÷ 服务端整窗已用%」时单位错配严重失真。
162
+ // 分钟级窗口过短,任何标准档都更不准,维持原样走兜底。
163
+ const dm = /^(\d+(?:\.\d+)?)\s*(h|d|w|mo|m)$/.exec(n)
164
+ if (dm !== null) {
165
+ if (dm[2] === 'm') return n
166
+ const qty = Number(dm[1]) * { h: 1, d: 24, w: 168, mo: 720 }[dm[2]]
167
+ if (qty <= 6) return 'fiveHour'
168
+ if (qty <= 36) return 'daily'
169
+ if (qty <= 336) return 'weekly'
170
+ return 'monthly'
171
+ }
159
172
  return n
160
173
  }
161
174
 
package/lib/pricing.js CHANGED
@@ -198,7 +198,7 @@ export const DEFAULT_PROVIDER_PRICE_TABLE = {
198
198
  },
199
199
  tencent: {
200
200
  models: {
201
- 'hunyuan-a13b': { input: 0.5, output: 2, billingMode: 'flat', notes: 'Official price is CNY; catalog value requires currency conversion before use.' },
201
+ 'hunyuan-a13b': { input: 0.0694, output: 0.2778, billingMode: 'flat', notes: '官方价为 CNY ¥0.5/¥2 每百万 token,按默认展示汇率 7.2 折算为 USD 入账' },
202
202
  'hy3': { input: 0.14, cachedInput: 0.035, output: 0.58, billingMode: 'flat', sourceUrl: 'https://opencode.ai/docs/go', checkedAt: '2026-08-17', notes: 'OpenCode Go 目录价;厂商归属未官宣' },
203
203
  },
204
204
  },
@@ -526,23 +526,34 @@ export function matchModelId(modelId, candidates) {
526
526
  const byCanon = list.find(c => canonModelId(c) === canon)
527
527
  if (byCanon !== undefined) return byCanon
528
528
  // 宽泛包含:'gpt5.6 luna(go)' 归一化后包含 'gpt56luna' 即命中;取最长候选,过短候选(≤3)防误配。
529
+ // 数字分叉守卫(issue #18 同源):候选是请求 canon 的真前缀且剩余段为 1-2 位
530
+ // 纯数字(glm-5 vs glm-5.3 → 'glm53' 含 'glm5' 余 '3')时视为版本分叉拒绝;
531
+ // '-20260821' 等日期快照(≥3 位)与 '-128k' 容量后缀不受影响。
529
532
  let containHit = null
530
533
  let containLen = 0
531
534
  for (const c of list) {
532
535
  const cc = canonModelId(c)
533
536
  if (cc.length < 4 || cc === canon) continue
534
- if (canon.includes(cc) && cc.length > containLen) { containHit = c; containLen = cc.length }
537
+ if (canon.includes(cc) && cc.length > containLen) {
538
+ const idx = canon.indexOf(cc)
539
+ if (/^\d{1,2}$/.test(canon.slice(idx + cc.length))) continue
540
+ containHit = c; containLen = cc.length
541
+ }
535
542
  }
536
543
  if (containHit !== null) return containHit
537
544
  const stripped = stripIdDecor(modelId)
538
545
  const byStripped = list.find(c => stripIdDecor(c) === stripped)
539
546
  if (byStripped !== undefined) return byStripped
540
547
  // 前缀匹配:modelId(去饰后)以候选(去饰后)开头且紧接分隔符,取最长候选。
548
+ // 分隔符后的整段若为 1-2 位纯数字(gpt-5.9 的 '.9'、kimi-k2.6 的 '.6')同样
549
+ // 视为版本分叉拒绝;'-128k'/''-v3.1'/日期后缀等不受影响。
541
550
  let prefixHit = null
542
551
  for (const c of list) {
543
552
  const cs = stripIdDecor(c)
544
553
  if (cs.length === 0 || cs === stripped) continue
545
- if (stripped.startsWith(cs) && /^[\-_./:]/.test(stripped.slice(cs.length))) {
554
+ const rest = stripped.slice(cs.length)
555
+ if (stripped.startsWith(cs) && /^[\-_./:]/.test(rest)) {
556
+ if (/^\d{1,2}$/.test(rest.replace(/^[\-_./:]+/, ''))) continue
546
557
  if (prefixHit === null || stripIdDecor(prefixHit).length < cs.length) prefixHit = c
547
558
  }
548
559
  }
@@ -559,6 +570,13 @@ export function matchModelId(modelId, candidates) {
559
570
  // 防跨版本误配(issue #18):分歧位置两侧都有数字/版本号 token(如 glm-5.3 vs glm-5.2)
560
571
  // 视为不同模型拒绝匹配——订阅制/新版本模型不应落到同家族其它版本的付费单价。
561
572
  if (n < mt.length && n < ct.length && /^\d+$/.test(mt[n]) && /^\d+$/.test(ct[n])) continue
573
+ // 候选 token 耗尽而请求多出的部分全是 1-2 位纯数字 token(glm-5 vs glm-5.3)
574
+ // 同为版本分叉,拒绝;多出日期(≥3 位)或带字母的容量/变体 token 时放行。
575
+ if (n >= 2 && n === ct.length && n < mt.length && mt.slice(n).every(t => /^\d{1,2}$/.test(t))) continue
576
+ // 分歧位一侧为 1-2 位版本号、另一侧为变体名(gpt-5.9 的 '9' vs gpt-5-nano
577
+ // 的 'nano'):containment/prefix 守卫生效后不再掉进变体互配,一并拒绝。
578
+ if (n >= 2 && n < mt.length && n < ct.length
579
+ && ((/^\d{1,2}$/.test(mt[n]) && /^[a-z]/.test(ct[n])) || (/^\d{1,2}$/.test(ct[n]) && /^[a-z]/.test(mt[n])))) continue
562
580
  if (n >= 2 && (n > bestLen || (n === bestLen && best !== null && c.length < best.length))) {
563
581
  best = c
564
582
  bestLen = n
@@ -1059,7 +1077,9 @@ export function parsePricingHtml(html) {
1059
1077
  const winEn = /Peak hours are\s+(.+?)\s+UTC/.exec(plain)
1060
1078
  // 中文页窗口为北京时间,提取后 -8h 折算为 UTC;捕获限长且止于全/半角括号,
1061
1079
  // 防止页面无终止括号时惰性匹配吞掉全文导致时间对解析失败。
1062
- const winZh = winEn === null ? /高峰时段为北京时间\s+([^((]{0,80})/.exec(plain) : null
1080
+ // 冒号与空白均可省略:官方页改版(如「北京时间9:00」紧凑排版)不再静默
1081
+ // 解析失败——失败会导致 peakWindows 缺失并沿用上一次同步的旧窗口。
1082
+ const winZh = winEn === null ? /高峰时段为北京时间[::]?\s*([^((]{0,80})/.exec(plain) : null
1063
1083
  const win = winEn ?? winZh
1064
1084
  if (win !== null) {
1065
1085
  const pairs = win[1].match(/\d{1,2}:\d{2}/g) ?? []
package/lib/store.js CHANGED
@@ -224,9 +224,12 @@ export function splitLedgerApiCost(ledger) {
224
224
  sumApi += nextApi
225
225
  if ((entry.apiCost ?? -1) !== nextApi) { entry.apiCost = nextApi; dayChanged = true }
226
226
  }
227
- // 残差归 API:无模型明细的历史差额计入真金白银。
228
- const residual = Math.max(0, (Number(day.cost) || 0) - sumCost)
229
- const dayNext = sumApi + residual
227
+ // 残差归 API:无模型明细的历史差额计入真金白银。桶级合计异常超过容器总额
228
+ // (repairForkSeed 扣减 clamp 等副产物)以容器 cost 封顶,防止在下次冷加载
229
+ // sanitizeBuckets 钳制前下发 apiCost > cost 的倒挂数字。
230
+ const dayTotal = Number(day.cost) || 0
231
+ const residual = Math.max(0, dayTotal - sumCost)
232
+ const dayNext = Math.min(dayTotal, sumApi + residual)
230
233
  if ((day.apiCost ?? -1) !== dayNext) { day.apiCost = dayNext; dayChanged = true }
231
234
  if (dayChanged) touched += 1
232
235
  if (!Array.isArray(day.sessions)) continue
@@ -243,9 +246,10 @@ export function splitLedgerApiCost(ledger) {
243
246
  sessSumApi += nextApi
244
247
  if ((entry.apiCost ?? -1) !== nextApi) { entry.apiCost = nextApi; sessChanged = true }
245
248
  }
246
- // 会话级残差同样归 API
247
- const sessResidual = Math.max(0, (Number(session.cost) || 0) - sessSumCost)
248
- const sessNext = sessSumApi + sessResidual
249
+ // 会话级残差同样归 API;容器总额封顶理由同日级。
250
+ const sessTotal = Number(session.cost) || 0
251
+ const sessResidual = Math.max(0, sessTotal - sessSumCost)
252
+ const sessNext = Math.min(sessTotal, sessSumApi + sessResidual)
249
253
  if ((session.apiCost ?? -1) !== sessNext) { session.apiCost = sessNext; sessChanged = true }
250
254
  if (sessChanged) touched += 1
251
255
  }
@@ -626,9 +630,22 @@ export function applyConfigPatch(current, patch) {
626
630
  const candidate = mergeDeep(structuredClone(current), patch)
627
631
  // prices.models 是可编辑列表:客户端提交完整列表时必须按替换语义处理,
628
632
  // 否则 mergeDeep 会把已删除的旧模型重新合并回来。
629
- if (patch.prices !== null && typeof patch.prices === 'object' && !Array.isArray(patch.prices)
630
- && patch.prices.models !== null && typeof patch.prices.models === 'object' && !Array.isArray(patch.prices.models)) {
631
- candidate.prices.models = patch.prices.models
633
+ if (patch.prices !== null && typeof patch.prices === 'object' && !Array.isArray(patch.prices)) {
634
+ if (patch.prices.models !== null && typeof patch.prices.models === 'object' && !Array.isArray(patch.prices.models)) {
635
+ candidate.prices.models = patch.prices.models
636
+ }
637
+ // 第三方渠道 models 同为可编辑列表(ProviderPriceCard 取消挂载走同一条
638
+ // diff 补丁路径):补丁内出现该 provider 的 models 对象即整体替换,
639
+ // 逐键深合并会把被删除的模型复活,「取消挂载」永不生效。
640
+ const patchProviders = patch.prices.providers
641
+ if (patchProviders !== null && typeof patchProviders === 'object' && !Array.isArray(patchProviders)) {
642
+ for (const [prov, table] of Object.entries(patchProviders)) {
643
+ if (table === null || typeof table !== 'object' || Array.isArray(table)) continue
644
+ if (table.models === null || typeof table.models !== 'object' || Array.isArray(table.models)) continue
645
+ const target = candidate.prices?.providers?.[prov]
646
+ if (target !== null && typeof target === 'object' && !Array.isArray(target)) target.models = table.models
647
+ }
648
+ }
632
649
  }
633
650
  // 逐项校验。
634
651
  if (!['auto', 'zh', 'en'].includes(candidate.locale)) errors.push(vmsg(locale, 'locale'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(9 家,含 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 9 vendors (incl. 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",