dsh-cost-meter 1.5.36 → 1.5.38

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) · 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) · 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.5.36-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.38-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)
@@ -45,6 +45,7 @@ English | [中文](README.md)
45
45
  | Peak/off-peak switch popup alert | Global overlay | A full-width bracketed popup appears when the next tier switch is within the configured lead time (default 2 minutes, 1–30), with an alert-colored badge distinguishing entering peak vs off-peak; position selectable (**bottom-right / screen center**), alert type selectable (entering peak / entering off-peak / both), one alert per switch point; optionally **sends a browser (system) notification** (so you still get alerted when the page is backgrounded; requires granting notification permission); configured in the peak pricing panel in Settings, with a **one-click popup preview** (rendered by the real component — copy, position and notifications exactly as they will fire) |
46
46
  | Official price sync | Settings page | Fetches and parses the official pricing page, applies with one click |
47
47
  | UI language | Settings → Display settings | Simplified Chinese / English / Follow browser (auto); switches instantly and auto-saves |
48
+ | Hide amounts (privacy mode) | Settings → Display settings | One toggle masks every amount (costs / balances / budget / history) as “$ ***” while token stats stay visible — safe for screen sharing and screenshots |
48
49
  | AI price sync | [prompt](docs/AI-PRICE-SYNC-PROMPT.en.md) | DeepSeek official sync; other providers use the verified official price catalog and manual configuration |
49
50
  | Model & Plan adaptation guide | [adaptation doc](docs/model-and-plan-adaptation.en.md) | Adaptation matrix for per-model billing and the 8 Coding Plan vendors, the auto-matching mechanism and price sources ([中文](docs/model-and-plan-adaptation.md)) |
50
51
  | Peak/off-peak alert guide | [alert doc](docs/peak-alert.en.md) | Fully illustrated guide to the pre-switch popup and system notification: effect screenshots (EN/中文), settings reference and usage tips ([中文](docs/peak-alert.md)) |
@@ -228,22 +229,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
228
229
  dsh plugin --profile web add dsh-cost-meter
229
230
  ```
230
231
 
231
- **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.5.36`** — review the script before running):
232
+ **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.5.38`** — review the script before running):
232
233
 
233
234
  ```powershell
234
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.36/install.ps1 | iex
235
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.38/install.ps1 | iex
235
236
  ```
236
237
 
237
238
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
238
239
 
239
240
  ```sh
240
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.36
241
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.38
241
242
  ```
242
243
 
243
244
  Without git, use the GitHub tag archive:
244
245
 
245
246
  ```sh
246
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.36.tar.gz
247
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.38.tar.gz
247
248
  ```
248
249
 
249
250
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
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 为峰时段) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet 八家) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.5.36-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.38-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)
@@ -46,6 +46,7 @@
46
46
  | 峰/谷切换弹窗提醒 | 全局浮层 | 距进入峰/谷时段不足设定提前量(默认 2 分钟,1-30 可配)时全屏色条徽标弹窗(提醒色区分进入峰/谷);弹窗位置可选**右下角 / 屏幕中心**,提醒类型可选(进入峰 / 进入谷 / 峰和谷),同一切换点只提醒一次;可选**同步发送浏览器(系统)通知**(页面最小化也能收到,需授权通知权限);设置页峰谷计价面板内配置,并可**一键预览弹窗效果**(真实组件渲染,文案/位置/通知与实际触发完全一致) |
47
47
  | 官方价格同步 | 设置页 | 抓取解析官方定价页,一键应用 |
48
48
  | 界面语言 | 设置页 → 显示设置 | 简体中文 / English / 跟随浏览器(自动);切换即时生效并自动保存 |
49
+ | 隐藏金额(隐私模式) | 设置页 → 显示设置 | 一键把全部金额(费用/余额/预算/历史等)遮罩为「$ ***」,token 统计不受影响,共享屏幕/截图防泄露 |
49
50
  | AI 价格同步 | [提示词](docs/AI-PRICE-SYNC-PROMPT.md) | DeepSeek 官方同步;其他 provider 使用已核对的官方价格目录与手动配置 |
50
51
  | 模型与 Plan 适配说明 | [适配文档](docs/model-and-plan-adaptation.md) | 各厂商模型计费与 8 家 Coding Plan 的适配矩阵、自动匹配机制与价格来源([English](docs/model-and-plan-adaptation.en.md)) |
51
52
  | 峰/谷切换提醒图解 | [提醒文档](docs/peak-alert.md) | 峰谷切换前弹窗与系统通知的完整图解:效果截图(中/英)、设置项说明与使用建议([English](docs/peak-alert.en.md)) |
@@ -230,22 +231,22 @@
230
231
  dsh plugin --profile web add dsh-cost-meter
231
232
  ```
232
233
 
233
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.36`**,建议先下载审阅再运行):
234
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.38`**,建议先下载审阅再运行):
234
235
 
235
236
  ```powershell
236
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.36/install.ps1 | iex
237
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.38/install.ps1 | iex
237
238
  ```
238
239
 
239
240
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
240
241
 
241
242
  ```sh
242
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.36
243
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.38
243
244
  ```
244
245
 
245
246
  没有 git 时可用 GitHub tag 打包直链:
246
247
 
247
248
  ```sh
248
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.36.tar.gz
249
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.38.tar.gz
249
250
  ```
250
251
 
251
252
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
package/lib/backfill.js CHANGED
@@ -682,3 +682,66 @@ export function repairForkSeed(ledger, sessionsRoot) {
682
682
  }
683
683
  return result
684
684
  }
685
+
686
+ /**
687
+ * 包装路由重复计费一次性清洗(issue #48):modlens / vision-router 这类包装
688
+ * 插件在自身 stream() 体内再发起 ctx.llm.stream(),旧版计费监听器在瀑布
689
+ * 每层都记账,同一次请求在 byProviderModel 里留下 official / modlens /
690
+ * modlens-vision 等多份 token 逐位相同的条目(报告者 08-22 账本:三行
691
+ * 40 次调用 token 逐位相同)。本函数对每个日期与其下每个会话的
692
+ * byProviderModel 按指纹分组——键的模型后缀(首个冒号之后,嵌套包装链
693
+ * 每层透传同一 model)与六值桶(input/output/cacheRead/cacheWrite/
694
+ * reasoning/calls)完全一致才算同组;每组保留字母序第一个,其余条目从
695
+ * 所在容器(day 或 session)的顶层合计(含 cost、calls)中扣除后删除。
696
+ *
697
+ * 幂等由调用方用账本 migrations 标记(provider-dedup-v1)保证只跑一次;
698
+ * 扣除逐字段 clamp ≥ 0,指纹不全同的条目(不同 token 量的真实调用,如
699
+ * 报告者 deepseek-vision 的 24 次独立记录)不碰。
700
+ * @param ledger - 已打开的账本。
701
+ * @returns { groups, removedCost } 合并的重复组数与扣除的金额合计(USD)。
702
+ */
703
+ export function repairProviderDupes(ledger) {
704
+ const result = { groups: 0, removedCost: 0 }
705
+ // 清洗单个容器(day 或 session):返回是否发生扣除。
706
+ const repairContainer = (container) => {
707
+ const pm = container?.byProviderModel
708
+ if (pm === null || typeof pm !== 'object') return false
709
+ // 指纹 → 键列表。键形如 `${provider}:${model}`,provider 名不含冒号,
710
+ // 首个冒号之后即模型 id;跨 provider 指纹相同且模型相同才是包装链重复。
711
+ const groups = new Map()
712
+ for (const [key, bucket] of Object.entries(pm)) {
713
+ if (bucket === null || typeof bucket !== 'object') continue
714
+ const model = key.slice(key.indexOf(':') + 1)
715
+ const fingerprint = `${model}|${bucket.input ?? 0}|${bucket.output ?? 0}|${bucket.cacheRead ?? 0}|${bucket.cacheWrite ?? 0}|${bucket.reasoning ?? 0}|${bucket.calls ?? 0}`
716
+ const list = groups.get(fingerprint)
717
+ if (list === undefined) groups.set(fingerprint, [key])
718
+ else list.push(key)
719
+ }
720
+ let touched = false
721
+ for (const keys of groups.values()) {
722
+ if (keys.length < 2) continue
723
+ keys.sort()
724
+ for (const key of keys.slice(1)) {
725
+ const bucket = pm[key]
726
+ subtractBucketInto(container, bucket)
727
+ delete pm[key]
728
+ result.removedCost += bucket.cost ?? 0
729
+ touched = true
730
+ }
731
+ result.groups += 1
732
+ }
733
+ return touched
734
+ }
735
+ let scheduled = false
736
+ for (const day of Object.values(ledger.days ?? {})) {
737
+ if (day === null || typeof day !== 'object') continue
738
+ if (repairContainer(day)) scheduled = true
739
+ if (Array.isArray(day.sessions)) {
740
+ for (const session of day.sessions) {
741
+ if (session !== null && typeof session === 'object' && repairContainer(session)) scheduled = true
742
+ }
743
+ }
744
+ }
745
+ if (scheduled) ledger.scheduleWrite()
746
+ return result
747
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * llm/stream 计费包裹(issue #48 包装路由重复计费修复)。
3
+ *
4
+ * 宿主的 `llm/stream` 是一条监听器瀑布:每次 ctx.llm.stream() 都会把全部
5
+ * 监听器按注册序串起来。modlens / vision-router 这类包装路由插件的适配器
6
+ * 在自己 stream() 体内再次调用 ctx.llm.stream({ ..., provider: upstream })
7
+ * ——于是同一次请求沿 `wrapper-vision → wrapper → official` 每层都完整走
8
+ * 一遍瀑布,链尾的计费监听器每层都把 usage 记进账本(同 token 逐位相同 ×3)。
9
+ *
10
+ * 修复:用 AsyncLocalStorage 标记「正在计费消费的流」。外层计费包装在标记
11
+ * 内拉取下游 chunk;下游适配器体内再发起的 ctx.llm.stream() 在同一异步上
12
+ * 下文中同步分发瀑布,内层计费监听器读到标记即判定为嵌套调用,直接透传
13
+ * next() 不再包一层(也就不再记账)。usage 只由最外层记一次。
14
+ *
15
+ * ALS 按异步上下文隔离:两个并发请求各自从无标记的根上下文进入,互不误伤。
16
+ */
17
+
18
+ import { AsyncLocalStorage } from 'node:async_hooks'
19
+
20
+ /** 嵌套深度标记:值恒为 1(存在即嵌套);按异步上下文隔离,并发流互不串扰。 */
21
+ const llmStreamDepth = new AsyncLocalStorage()
22
+
23
+ /**
24
+ * 创建 llm/stream 计费监听器。
25
+ * @param {object} deps
26
+ * @param {(usage: object, model: string, sessionId: string, atMs: number, provider: string) => void} deps.account
27
+ * 计费回调:流结束时以捕获的 usage 块调用(五参签名与 ledger.account 对齐,
28
+ * 由 index.js 负责把 usage 五桶映射过去)。
29
+ * @returns {(options: object, next: () => AsyncIterable) => AsyncIterable} 宿主监听器。
30
+ */
31
+ export function createLlmStreamBilling({ account }) {
32
+ return (options, next) => {
33
+ const downstream = next()
34
+ // 嵌套内层(包装路由在上层计费流的消费上下文中再发起的 llm/stream):
35
+ // 外层已记账,透传下游流,不再包裹。
36
+ if (llmStreamDepth.getStore() !== undefined) return downstream
37
+ return (async function* costMeterStream() {
38
+ let usage = null
39
+ const iterator = downstream[Symbol.asyncIterator]()
40
+ try {
41
+ for (;;) {
42
+ // 在深度标记内拉取下游:下游适配器(modlens 等)体内再发起的
43
+ // ctx.llm.stream() 同步瀑布分发继承 depth=1,其监听器判定为嵌套。
44
+ const result = await llmStreamDepth.run(1, () => iterator.next())
45
+ if (result.done) break
46
+ const chunk = result.value
47
+ if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage !== undefined) {
48
+ usage = chunk.usage
49
+ }
50
+ yield chunk
51
+ }
52
+ } finally {
53
+ if (usage !== null) {
54
+ try {
55
+ account(usage, options?.model, options?.sessionId, Date.now(), options?.provider)
56
+ } catch (error) {
57
+ console.warn(`[dsh-cost-meter] 计费失败: ${String(error)}`)
58
+ }
59
+ }
60
+ }
61
+ })()
62
+ }
63
+ }
package/lib/client.js CHANGED
@@ -574,6 +574,7 @@ window.__ModuleLoader__.load({
574
574
  sessionRankError: '会话排行加载失败',
575
575
  sessionRankLimit: '显示条数',
576
576
  showSessionIdLabel: '会话列表附显会话 ID',
577
+ hideAmountsLabel: '隐藏金额(隐私模式:余额与费用金额显示为 ***)',
577
578
  sessionRankSort: '排序',
578
579
  sessionSortCostDesc: '费用 高→低',
579
580
  sessionSortCostAsc: '费用 低→高',
@@ -923,6 +924,7 @@ window.__ModuleLoader__.load({
923
924
  sessionRankError: 'Failed to load session ranking',
924
925
  sessionRankLimit: 'Rows',
925
926
  showSessionIdLabel: 'Show session IDs in session lists',
927
+ hideAmountsLabel: 'Hide amounts (privacy mode: balances and costs render as ***)',
926
928
  sessionRankSort: 'Sort',
927
929
  sessionSortCostDesc: 'Cost high→low',
928
930
  sessionSortCostAsc: 'Cost low→high',
@@ -1156,6 +1158,10 @@ window.__ModuleLoader__.load({
1156
1158
  locale: v.locale === 'zh' || v.locale === 'en' || v.locale === 'auto' ? v.locale : 'auto',
1157
1159
  position: v.position === 'header' || v.position === 'off' ? v.position : 'dock',
1158
1160
  sidebar: v.sidebar !== false,
1161
+ // showSessionId 曾遗漏于本白名单:checkbox 能保存但读侧恒 undefined,
1162
+ // 会话列表附显 ID 自上线以来实际从未生效(随 hideAmounts 一并修复)。
1163
+ showSessionId: v.showSessionId === true,
1164
+ hideAmounts: v.hideAmounts === true,
1159
1165
  currency: typeof v.currency === 'string' ? v.currency : 'CNY',
1160
1166
  symbol: typeof v.symbol === 'string' ? v.symbol : '¥',
1161
1167
  decimals: needNum(v.decimals, path + '.decimals'),
@@ -1498,6 +1504,9 @@ window.__ModuleLoader__.load({
1498
1504
  /** 已换算币种金额 → 显示字符串(符号 + 可调小数位)。 */
1499
1505
  function formatMoneyValue(value, config) {
1500
1506
  const symbol = typeof config?.symbol === 'string' && config.symbol.length > 0 ? config.symbol : '$'
1507
+ // 隐私模式(issues #45/#46):全部金额统一遮罩为「符号 ***」——保留币种符号与布局,
1508
+ // 数字不泄露;会话费用/今日费用/历史/余额/预算等所有调用方自动生效。
1509
+ if (config?.hideAmounts === true) return symbol + '***'
1501
1510
  const decimals = Math.max(0, Math.min(10, Math.floor(Number(config?.decimals) || 2)))
1502
1511
  let effective = decimals
1503
1512
  if (value > 0 && value < Math.pow(10, -decimals)) effective = decimals + 2
@@ -1762,8 +1771,8 @@ window.__ModuleLoader__.load({
1762
1771
  // ── 侧边栏:余额行 + 预算图框/今日徽章(纵向堆叠,位于设置按钮上方) ──────
1763
1772
 
1764
1773
  function formatBalanceMoney(value, config) {
1765
- // 余额是官方接口返回的记账币种金额(如 CNY),不经过汇率换算。
1766
- return formatMoneyValue(value, { symbol: config.symbol, decimals: Math.max(2, Math.min(10, Math.floor(Number(config.decimals) || 2))) })
1774
+ // 余额是官方接口返回的记账币种金额(如 CNY),不经过汇率换算;隐私模式透传给 formatMoneyValue。
1775
+ return formatMoneyValue(value, { symbol: config.symbol, decimals: Math.max(2, Math.min(10, Math.floor(Number(config.decimals) || 2))), hideAmounts: config.hideAmounts })
1767
1776
  }
1768
1777
 
1769
1778
  function resolveBalanceCap(config, custom) {
@@ -1966,6 +1975,8 @@ window.__ModuleLoader__.load({
1966
1975
  const unit = customBalanceUnitOf(config, custom)
1967
1976
  const decimals = Math.max(2, Math.min(6, Math.floor(Number(config?.decimals) || 4)))
1968
1977
  const symbol = unit === 'CNY' ? '¥' : unit === 'EUR' ? '€' : '$'
1978
+ // 隐私模式(issues #45/#46):自定义 Provider 余额同口径遮罩。
1979
+ if (config?.hideAmounts === true) return symbol + '***'
1969
1980
  const value = Number(amount)
1970
1981
  if (!Number.isFinite(value)) return '—'
1971
1982
  let fixed = value.toFixed(decimals)
@@ -4661,6 +4672,14 @@ window.__ModuleLoader__.load({
4661
4672
  el('div', { className: 'cm-field' },
4662
4673
  el('label', null, t('decimalsLabel')),
4663
4674
  numInput({ value: draft?.decimals ?? 2 }, v => setField('decimals', Math.min(10, Math.floor(v))))),
4675
+ // 隐私模式(issues #45/#46):全部余额/费用金额遮罩为 ***,共享屏幕/截图防泄露。
4676
+ el('label', { className: 'cm-check' },
4677
+ el('input', {
4678
+ type: 'checkbox',
4679
+ checked: draft?.hideAmounts === true,
4680
+ onChange: event => setField('hideAmounts', event.target.checked),
4681
+ }),
4682
+ el('span', null, t('hideAmountsLabel'))),
4664
4683
  el('div', { className: 'cm-grid-group' }, t('groupSidebar')),
4665
4684
  el('div', { className: 'cm-field' },
4666
4685
  el('label', null, t('balanceDisplayLabel')),
@@ -126,10 +126,12 @@ export function parseAnthropicUsage(data) {
126
126
  /**
127
127
  * 解析 Z.ai / 智谱 GLM Coding Plan 用量响应。
128
128
  * 兼容三种已见形态(按优先级):
129
- * - { data: { limits: [{ type: TOKENS_LIMIT|TIME_LIMIT, unit, number, percentage,
130
- * nextResetTime, usage, currentValue, ... }], level } }(2026-08 起的监控端点
131
- * /api/monitor/usage/quota/limit,issue #42;TOKENS_LIMIT 为 token 窗口,TIME_LIMIT
132
- * 为 MCP/工具调用月度额度、量纲不同不纳入;unit 3=小时档,6=周档,缺失时按
129
+ * - { data: { limits: [{ type, unit, number, percentage, nextResetTime, usage,
130
+ * currentValue, ... }], level } }(2026-08 起的监控端点
131
+ * /api/monitor/usage/quota/limit,issue #42;TOKENS_LIMIT 为 token 窗口(Pro/Max),
132
+ * CREDIT_LIMIT Credit 计费窗口(Lite,percentage/currentValue 语义与
133
+ * TOKENS_LIMIT 一致,issue #44),两者均按 unit 3=小时档、6=周档映射;
134
+ * TIME_LIMIT 为 MCP/工具调用月度额度、量纲不同不纳入;unit 缺失时按
133
135
  * nextResetTime 排序兜底——0% 用量的滚动窗口不返回重置时间,排最前)
134
136
  * - { plans: [{ status, total_units, used_units, available_units, period_end, capabilities }] }
135
137
  * (旧计费端点;period_end 语义按数值大小推断:重置跨度 >1 天视为周档,否则为 5 小时档)
@@ -147,7 +149,9 @@ export function parseZaiUsage(data) {
147
149
  const monWindows = {}
148
150
  const rest = []
149
151
  for (const limit of limits) {
150
- if (limit === null || typeof limit !== 'object' || limit.type !== 'TOKENS_LIMIT') continue
152
+ // TOKENS_LIMIT(Pro/Max token 窗口)与 CREDIT_LIMIT(Lite Credit 窗口,issue #44)
153
+ // 的 percentage/currentValue/usage 与 unit 语义完全一致,一并接受。
154
+ if (limit === null || typeof limit !== 'object' || (limit.type !== 'TOKENS_LIMIT' && limit.type !== 'CREDIT_LIMIT')) continue
151
155
  // percentage 已是 0-100 百分数;缺失时用 currentValue/usage 反推。
152
156
  const pct = limit.percentage !== undefined ? clampPct(Number(limit.percentage))
153
157
  : Number.isFinite(Number(limit.usage)) && Number(limit.usage) > 0 && Number.isFinite(Number(limit.currentValue))
@@ -610,6 +614,10 @@ export async function queryCodingPlan(provider, key, locale, t) {
610
614
  const urls = CODING_PLAN_ENDPOINTS[provider]
611
615
  const parse = CODING_PLAN_PARSERS[provider]
612
616
  let lastError = null
617
+ // 200 但解析失败的「结构化错误」(业务信封 / 结构已变):比后续端点的 404 等传输层
618
+ // 错误更有诊断价值,单独保留且最终优先抛出——否则最后端点的 404 会盖住 monitor
619
+ // 端点解析失败的真实原因(issue #44 的误导性报错即由此而来)。
620
+ let parseError = null
613
621
  for (const url of urls) {
614
622
  let response
615
623
  try {
@@ -643,14 +651,16 @@ export async function queryCodingPlan(provider, key, locale, t) {
643
651
  // 200 但业务失败(如 Z.ai 的错误信封 {code:1001,msg:...}):透出服务端 msg,避免误报「接口结构已变」。
644
652
  const envelope = data !== null && typeof data === 'object' && typeof data.code === 'number' && data.code !== 0
645
653
  && typeof (data.msg ?? data.message) === 'string' ? (data.msg ?? data.message) : null
646
- lastError = envelope !== null
654
+ const error = envelope !== null
647
655
  ? new Error(`${meta.label}: ${envelope}`)
648
656
  : new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
657
+ parseError ??= error
658
+ lastError = error
649
659
  continue
650
660
  }
651
661
  return { windows, endpoint: url }
652
662
  }
653
- throw lastError ?? new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
663
+ throw parseError ?? lastError ?? new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
654
664
  }
655
665
 
656
666
  export { CUSTOM_BALANCE_ADAPTER_ID, emptyCustomBalance, extractByRule, queryCustomBalance } from './custom-balance.js'
package/lib/index.js CHANGED
@@ -19,7 +19,8 @@ import { join } from 'node:path'
19
19
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
20
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
21
21
  import { Ledger, applyConfigPatch, localDayKey, pickBalanceInfo, reconcileBalanceDelta, zeroDay } from './store.js'
22
- import { backfillLegacyLedger, importLegacyHistory, repairForkSeed } from './backfill.js'
22
+ import { backfillLegacyLedger, importLegacyHistory, repairForkSeed, repairProviderDupes } from './backfill.js'
23
+ import { createLlmStreamBilling } from './billing-stream.js'
23
24
  import { OFFICIAL_PRICING_URL, normalizePrice, parsePricingHtml, costOf, priceEntryFor, providerPriceEntryFor, buildPriceCatalog } from './pricing.js'
24
25
  import { CODING_PLAN_PROVIDERS, CODING_PLAN_PROVIDER_IDS, queryCodingPlan, scnetTokenPlanWindows, emptyCustomBalance, queryCustomBalance } from './coding-plans.js'
25
26
  import { fetchWithRetry } from './net.js'
@@ -698,7 +699,8 @@ function createService(ctx, ledger) {
698
699
  // issue #36:Coding Plan / 自定义 Provider 的费用不动官方余额,对账只统计 deepseek 渠道,否则订阅用户会恒报 drift。
699
700
  if ((ledger.config?.balance?.reconcile ?? true) === true && balanceCache.value.status === 'ok') {
700
701
  const nowMs = Date.now()
701
- const usd = v => '$' + Number(v).toFixed(4)
702
+ // 隐私模式(issues #45/#46):对账提示中的金额同步遮罩,避免悬浮提示泄露。
703
+ const usd = v => ledger.config?.hideAmounts === true ? '$***' : '$' + Number(v).toFixed(4)
702
704
  const { ref, event } = reconcileBalanceDelta(ledger.balanceRef, balanceCache.value, ledger.todayOfficialCost(), localDayKey(nowMs), nowMs)
703
705
  if (ref !== ledger.balanceRef) {
704
706
  ledger.balanceRef = ref
@@ -1155,6 +1157,18 @@ export async function runStartupImports(ledger, sessionsRoot) {
1155
1157
  console.log(`[dsh-cost-meter] fork 会话重复计费清洗完成:扣除 ${repaired.sessions} 个会话 / ${repaired.days} 天的种子污染(扫描 ${repaired.scanned} 份会话日志)`)
1156
1158
  }
1157
1159
  }
1160
+ // 包装路由重复计费一次性清洗(issue #48):旧版在 llm/stream 瀑布每层都
1161
+ // 记账,modlens/vision-router 嵌套链把同一次请求记成 official/modlens/
1162
+ // modlens-vision 三份(byProviderModel 六值指纹逐位相同)。嵌套去重
1163
+ // (billing-stream ALS 标记)已堵住新污染,此处扣除历史存量重复条目。
1164
+ if (!ledger.migrations.includes('provider-dedup-v1')) {
1165
+ const repaired = repairProviderDupes(ledger)
1166
+ ledger.migrations.push('provider-dedup-v1')
1167
+ ledger.scheduleWrite()
1168
+ if (repaired.groups > 0) {
1169
+ console.log(`[dsh-cost-meter] 包装路由重复计费清洗完成:合并 ${repaired.groups} 组重复条目,扣除 ${repaired.removedCost.toFixed(4)} USD`)
1170
+ }
1171
+ }
1158
1172
  if (!(Number(ledger.config?.legacyAutoImportedAt) > 0)) {
1159
1173
  const stats = await importLegacyHistory(ledger, sessionsRoot)
1160
1174
  if (stats.days > 0 || stats.sessions > 0) {
@@ -1188,34 +1202,21 @@ export function apply(ctx) {
1188
1202
 
1189
1203
  // 包裹 llm/stream:捕获 usage 块(位于 finish 之前),按官方价格计入账本。
1190
1204
  // 本插件是链尾监听者,next() 即适配器流;仅透传数据块,不改变流协议。
1191
- ctx.on('llm/stream', (options, next) => {
1192
- const downstream = next()
1193
- return (async function* costMeterStream() {
1194
- let usage = null
1195
- try {
1196
- for await (const chunk of downstream) {
1197
- if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage !== undefined) {
1198
- usage = chunk.usage
1199
- }
1200
- yield chunk
1201
- }
1202
- } finally {
1203
- if (usage !== null) {
1204
- try {
1205
- ledger.account({
1206
- input: usage.inputTokens ?? 0,
1207
- output: usage.outputTokens ?? 0,
1208
- cacheRead: usage.cacheReadTokens ?? 0,
1209
- cacheWrite: usage.cacheWriteTokens ?? 0,
1210
- reasoning: usage.reasoningTokens ?? 0,
1211
- }, options?.model, options?.sessionId, Date.now(), options?.provider)
1212
- } catch (error) {
1213
- ctx.logger?.warn?.(`[dsh-cost-meter] 计费失败: ${String(error)}`)
1214
- }
1215
- }
1216
- }
1217
- })()
1218
- })
1205
+ // 嵌套去重(issue #48):modlens/vision-router 等包装路由在自身 stream()
1206
+ // 体内再发起 ctx.llm.stream(),旧实现在瀑布每层都记账(同请求 ×2~3);
1207
+ // createLlmStreamBilling AsyncLocalStorage 深度标记识别嵌套调用,
1208
+ // 只由最外层记一次。历史污染由启动期 provider-dedup-v1 清洗兜底。
1209
+ ctx.on('llm/stream', createLlmStreamBilling({
1210
+ account: (usage, model, sessionId, atMs, provider) => {
1211
+ ledger.account({
1212
+ input: usage.inputTokens ?? 0,
1213
+ output: usage.outputTokens ?? 0,
1214
+ cacheRead: usage.cacheReadTokens ?? 0,
1215
+ cacheWrite: usage.cacheWriteTokens ?? 0,
1216
+ reasoning: usage.reasoningTokens ?? 0,
1217
+ }, model, sessionId, atMs, provider)
1218
+ },
1219
+ }))
1219
1220
 
1220
1221
  // costUsage 投影:向会话历史页/推送帧提供 token 桶(客户端计价)。
1221
1222
  ctx.inject(['sessionProjections'], (projectionCtx) => {
package/lib/store.js CHANGED
@@ -44,6 +44,7 @@ export function defaultConfig() {
44
44
  peakAlertPosition: 'corner', // 弹窗位置:corner(右下角) | center(屏幕中心)
45
45
  peakAlertWebNotify: false, // 弹窗时额外发浏览器(系统)通知,需用户在地址栏授权
46
46
  showSessionId: false, // 会话列表中附显会话 ID(默认只显示标题,需要时开启)
47
+ hideAmounts: false, // 隐藏金额(隐私模式,issues #45/#46):全部余额/费用金额显示为 ***,共享屏幕或截图不泄露
47
48
  legacyAutoImportedAt: 0, // 安装前历史自动导入标记(issue #27):完成时刻 ms;0 = 尚未跑过
48
49
  peakStyle: 'compact', // 峰谷时段条样式:compact(简洁单行/竖向同构) | classic(经典分段/胶囊芯片)
49
50
  priceMatch: 'auto', // 未知模型名自动匹配价格表:auto(去后缀/前缀/家族相似) | exact(仅精确)
@@ -266,6 +267,7 @@ const VALIDATION_MESSAGES = {
266
267
  peakAlertPosition: 'peakAlertPosition 必须是 corner / center',
267
268
  peakAlertWebNotify: 'peakAlertWebNotify 必须是布尔值',
268
269
  showSessionId: 'showSessionId 必须是布尔值',
270
+ hideAmounts: 'hideAmounts 必须是布尔值',
269
271
  peakStyle: 'peakStyle 必须是 compact / classic',
270
272
  priceMatch: 'priceMatch 必须是 auto / exact',
271
273
  priceOverrides: 'priceOverrides 必须是字符串→字符串映射',
@@ -335,6 +337,7 @@ const VALIDATION_MESSAGES = {
335
337
  peakAlertPosition: 'peakAlertPosition must be corner / center',
336
338
  peakAlertWebNotify: 'peakAlertWebNotify must be a boolean',
337
339
  showSessionId: 'showSessionId must be a boolean',
340
+ hideAmounts: 'hideAmounts must be a boolean',
338
341
  peakStyle: 'peakStyle must be compact / classic',
339
342
  priceMatch: 'priceMatch must be auto / exact',
340
343
  priceOverrides: 'priceOverrides must be a string→string map',
@@ -468,6 +471,8 @@ export function applyConfigPatch(current, patch) {
468
471
  if (!['corner', 'center'].includes(candidate.peakAlertPosition)) errors.push(vmsg(locale, 'peakAlertPosition'))
469
472
  if (typeof candidate.peakAlertWebNotify !== 'boolean') errors.push(vmsg(locale, 'peakAlertWebNotify'))
470
473
  if (candidate.showSessionId !== undefined && typeof candidate.showSessionId !== 'boolean') errors.push(vmsg(locale, 'showSessionId'))
474
+ // 隐藏金额(隐私模式,issues #45/#46):布尔开关,可缺省(默认关)。
475
+ if (candidate.hideAmounts !== undefined && typeof candidate.hideAmounts !== 'boolean') errors.push(vmsg(locale, 'hideAmounts'))
471
476
  if (candidate.peakStyle !== 'compact' && candidate.peakStyle !== 'classic') errors.push(vmsg(locale, 'peakStyle'))
472
477
  if (candidate.priceMatch !== 'auto' && candidate.priceMatch !== 'exact') errors.push(vmsg(locale, 'priceMatch'))
473
478
  const overrides = candidate.priceOverrides
@@ -661,6 +666,7 @@ export function sanitizeConfig(raw) {
661
666
  out.decimals = Math.max(0, Math.min(10, Math.floor(Number(out.decimals) || 0)))
662
667
  out.historyDays = Math.max(7, Math.min(3650, Math.floor(Number(out.historyDays) || 180)))
663
668
  out.showSessionId = out.showSessionId === true
669
+ out.hideAmounts = out.hideAmounts === true
664
670
  // 峰/谷切换弹窗提醒:非法值定向收敛(开关默认开、提前量回 2、类型回 both)。
665
671
  out.peakAlertEnabled = out.peakAlertEnabled !== false
666
672
  const alertAhead = Number(out.peakAlertAhead)
@@ -110,6 +110,8 @@ const configSchema = z.object({
110
110
  peakAlertPosition: z.enum(['corner', 'center']).optional(),
111
111
  peakAlertWebNotify: z.boolean().optional(),
112
112
  showSessionId: z.boolean().optional(),
113
+ // 隐藏金额(隐私模式,issues #45/#46):全部余额/费用金额遮罩为 ***。
114
+ hideAmounts: z.boolean().optional(),
113
115
  // 安装前历史自动导入完成时刻(issue #27,内部标记;0/缺席 = 尚未跑过)。
114
116
  legacyAutoImportedAt: num.optional(),
115
117
  peakStyle: z.enum(['compact', 'classic']).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.5.36",
3
+ "version": "1.5.38",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(7 家,含 SCNet Token Plan 本地 Credits 计量)、自定义 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 7 vendors (incl. local credits metering for the SCNet Token Plan), 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",