@kenz1117/dsh-ui-usage-billing 1.7.2 → 1.7.3

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
@@ -9,7 +9,7 @@
9
9
  <a href="https://www.npmjs.com/package/@kenz1117/dsh-ui-usage-billing"><img alt="npm version" src="https://img.shields.io/npm/v/@kenz1117/dsh-ui-usage-billing?logo=npm"></a>
10
10
  <a href="https://www.npmjs.com/package/@kenz1117/dsh-ui-usage-billing"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@kenz1117/dsh-ui-usage-billing?logo=npm"></a>
11
11
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/kenz1117/dsh-ui-usage-billing/actions/workflows/ci.yml/badge.svg"></a>
12
- <a href="https://github.com/kenz1117/dsh-ui-usage-billing/blob/main/COMPATIBILITY.md"><img alt="DSH compat" src="https://img.shields.io/badge/DSH-0.1.2_%7E_0.2.1--rc.2_%7C_0.1.0--rc.8%2B-blue"></a>
12
+ <a href="https://github.com/kenz1117/dsh-ui-usage-billing/blob/main/COMPATIBILITY.md"><img alt="DSH compat" src="https://img.shields.io/badge/DSH-0.1.2_%7E_0.2.1--rc.3_%7C_0.1.0--rc.8%2B-blue"></a>
13
13
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing/blob/main/LICENSE"><img alt="License MIT" src="https://img.shields.io/github/license/kenz1117/dsh-ui-usage-billing"></a>
14
14
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing/pulls"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen"></a>
15
15
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing"><img alt="GitHub last commit" src="https://img.shields.io/github/last-commit/kenz1117/dsh-ui-usage-billing?logo=github"></a>
@@ -25,11 +25,13 @@
25
25
  ---
26
26
 
27
27
  <div align="center">
28
- <img src="screenshots/demo.png" alt="dsh-ui-usage-billing — billing dashboard overview" width="80%">
28
+ <img src="screenshots/overview.png" alt="dsh-ui-usage-billing — billing dashboard overview" width="80%">
29
29
  </div>
30
30
 
31
31
  ### Demo GIF
32
32
 
33
+ The eight dashboard tabs in turn (Overview / Billing / Usage / Trends / Context / Agents / Rates / Settings):
34
+
33
35
  ![Demo](screenshots/demo.gif)
34
36
 
35
37
  ## ✨ Why this plugin
@@ -63,24 +65,31 @@ The billing channel is detected from the current session's model: DeepSeek meter
63
65
  ## 📊 Dashboard
64
66
 
65
67
  - **Sidebar trigger card**: persistent above the Settings button — a single headline figure (today / this week / this month, selectable in settings, optional day-over-day badge) with a 7-day sparkline mini-trend and a today-vs-average gauge needle; collapses to an icon button; hover reveals a quick-look card (updated-at + metric grid + top spenders / quota alerts).
68
+
69
+ ![Sidebar trigger card: today's spend, delta badge and mini-trend](screenshots/sidebar.png)
70
+
66
71
  - **Eight-tab dashboard**: Overview / Billing / Usage / Trends / Context / Agents / Rates / Settings — hero figures + comparisons + month projection + KPIs (hover for the metric definition) + usage heatmap, trends switchable between cost and tokens over 7/30 days, model price table, budgets (global + per model) and peak/off-peak alerts all live here; the settings page supports full-text search (zh/en keywords, empty state when nothing matches); tabs carry names only — no status badges.
67
72
  - **Context section**: an occupancy bar plus next-request / last-request occupancy, window capacity and the composition split (system / tools / messages). The figures come from the host's official context projections; a session without one hides the whole section rather than showing a fabricated 0.
68
73
  - **Agents section**: the agent network rebuilt from delegation relations — a parent conversation → sub-agent tree where each row carries its call count, own cost and **chain total** (itself + all descendants), which directly answers "which delegation was the most expensive".
69
74
 
70
- ![Overview: month cost hero, budget progress, KPIs and usage heatmap](screenshots/1.png)
75
+ ![Overview: month cost hero, year/today/month projection, KPIs and usage heatmap](screenshots/overview.png)
76
+ ![Context: occupancy, window capacity and the system / tools / messages split](screenshots/context.png)
77
+ ![Agents: parent conversation → sub-agent delegation tree with chain totals](screenshots/agents.png)
71
78
  - **Live cost bar**: persistent "this turn / session" cost with three positions (composer toolbar row / above / below, toolbar by default); the peak/off-peak tier & countdown section appears only when the current model involves peak/off-peak pricing; subscription low-quota chips appear at ≤20% remaining, red at ≤10%; hideable via the settings-tab toggle (display-only preference persisted locally; stats and alerts unaffected).
72
79
  - **Peak/off-peak switch alert**: a popover plus an optional system notification before a switch; lead time / position / mode / preview configurable; copy differs by billing channel (DeepSeek price halves, Zhipu credits at 50%).
73
80
  - **Session completion notification**: an optional system notification when a session transitions running → completed (off by default, enable it in the panel); multi-window / multi-instance dedup through the claim endpoint, so each session notifies once.
74
81
  - **Plugin info card**: a persistent "About" card in the Settings tab — version read server-side from the package's `package.json` (single source of truth, correct on publish), author / repo / npm / license one click away.
75
82
 
83
+ ![Settings: display preferences, monthly budget, peak alerts, usage-query injection, custom prices and the plugin info card](screenshots/settings.png)
84
+
76
85
  ## 💰 Billing engine
77
86
 
78
87
  - **Provider-first grouping**: usage is grouped by the llm entry the calls actually went through (channel) — Tencent Cloud TokenHub / Token Plan / DeepSeek official / direct:<route> / unknown routes; the model brand stays as a row logo + sub-line. Official judgement follows the channel origin (`api.deepseek.com`) instead of the route name, so gateway routes named `deepseek-*` no longer count as official. `routeAliases` relocates renamed/deleted historical routes; `modelKeyAliases` binds uncatalogued model ids to catalog keys (date suffixes, org prefixes and the TokenHub short id `hy3` are recognized out of the box).
79
88
  - **Live rate table**: models.dev fetched pricing + live-model alignment — all configured models included; peak/off-peak split (weekdays 9-12 / 14-18 peak ×2, weekends off-peak all day; history priced per official change boundaries, see "Billing details") + a live USD→CNY rate, auto-refreshed every 6 hours; the rate strip shows the last sync time with a **one-click "Sync now"** button (no host restart needed).
80
89
 
81
- ![Rates: model rate table (peak/off-peak split and live rate)](screenshots/5.png)
90
+ ![Rates: model rate table (peak/off-peak split and live rate)](screenshots/pricing.png)
82
91
  - **Custom unit prices**: set real paid prices (miss / cache-hit / output, optional USD and off-peak columns) for uncatalogued or repriced models; bindable per relay origin; an out-of-catalog model is priced as soon as you fill it in.
83
- - **Official vs third-party buckets**: the detail cost column splits official direct / third-party relay ("official x / third y" when mixed); the Stats tab has a summary card; web-search assist calls count as official.
92
+ - **Official vs third-party buckets**: the model cost column in the Billing tab splits official direct / third-party relay ("official x / third y" when mixed); web-search assist calls count as official.
84
93
  - **Monthly budget + tier alerts**: budget bar ≥80% amber, over-budget red pulse; one alert per 50/80/100% crossing; a balance below the CNY threshold alerts once a day. **Per-model budgets**: set a CNY cap per top-spending model of the month (independent of the global budget), with one alert per model per 80% / 100% crossing.
85
94
  - **Cost-spike attribution**: last-40-turn cost bars, amount at bar top, peak/off-peak background bands, >2× spikes flagged with attribution.
86
95
 
@@ -92,20 +101,23 @@ The billing channel is detected from the current session's model: DeepSeek meter
92
101
  - **Declared endpoints + balance reconcile**: `declaredEndpoints` self-declares balance/quota interfaces for vendors absent from the built-in table — dot-paths only ("where the number is"), no expressions; safety bounds (single-slash absolute path, GET only, no cross-origin redirects, response-size/timeout caps, credentials from the matched provider only) are enforced by `src/declarative.ts`; a wrong path is marked `declared` with a reason. **Balance reconcile** (`reconcilePath`) cross-checks the official balance change against the local ledger, flagging drift above the threshold (0.3 CNY and >15%); top-ups / grants / currency changes reset the baseline instead of alerting, and a flat balance (subscription spend) stays silent.
93
102
  - **Relay-site attribution & quota**: usage is grouped by `baseURL` origin — multiple keys on one relay merge into a row named by its domain; New API (`/api/status`) and Sub2API (`/v1/usage`) are auto-detected for balance and rolling quota windows, labeled "no quota" when unreadable, <20% remaining in red; recognition caches for 5 minutes (per-key fuse-breaking), and the `relay-quotas` endpoint attaches `diagnostics` for "why is my relay not showing"; project attribution prefers the workspace title.
94
103
 
95
- ![Providers: provider billing & subscriptions (balance, plan quota, model usage)](screenshots/3.png)
104
+ ![Billing: provider billing & subscriptions (balance, plan quota, model usage) and session detail](screenshots/billing.png)
96
105
 
97
106
  > For the full adapter matrix per channel (detection / endpoints / credentials / troubleshooting), see [docs/adapters.md](docs/adapters.md).
98
107
 
99
108
  ## 📈 Usage visualizations
100
109
 
101
- - **Session detail + heatmap**: sessions sorted by cost (title / project / calls / cost / last active); "view all N sessions" opens a full-screen viewer (substring search by title / project / id, capped at 100 rows); month / half-year / year calendar heatmap (5-color scale, hover detail; the year view is ~52 weeks, GitHub-style, and the **half-year view uses 26 weeks of large cells** — half a year of intensity in one screenshot-ready chart) with a **cost / tokens metric switch**; total, active-day and streak counts on top.
102
- - **Performance metrics**: per-model TTFT mean / P50 / P90, generation speed (tokens/s), total-latency mean; per-hour × per-model comparison curves — metric tabs, clickable model chips (top-5 by samples lit by default), hover snapping to the nearest hour with a crosshair and per-model values, broken lines for missing-sample hours (never fabricated); view preferences persist locally.
103
- - **Token insights**: the daily token chart switches between two views — "Structure" (cache-miss / cache-hit / output, including reasoning) and "By model" (the toggle hides itself when snapshots lack per-day-per-model detail); hover shows the day's exact breakdown (thousand-separated); clicking a legend swatch or a model-table row focuses that model (click again to release); structural KPIs (cache-hit rate / reasoning share / input-output ratio / peak day); per-day CSV and JSON export (JSON includes per-day-per-model detail).
104
-
105
- ![Trends: daily cost trend, per-turn costs and peak/off-peak share](screenshots/2.png)
106
- - **Export + drill-down**: daily / per-session / per-site CSV and full JSON from the Stats tab; cost breakdown / workspaces / session detail drillable (click a project row to expand its sessions); no chart library, no external CDN, pure design tokens.
110
+ - **Session detail (Billing tab)**: sessions sorted by cost (title / project / calls / actual cost / last active); "view all N sessions" opens a full-screen viewer (substring search by title / project / id, capped at 100 rows); **clicking a session title opens that conversation in the host** (hosts without a session-selection channel show a readable notice instead of silently doing nothing).
111
+ - **Usage heatmap (Overview tab)**: month / half-year / year calendar heatmap (5-color scale, hover detail; the year view is ~52 weeks, GitHub-style, and the **half-year view uses 26 weeks of large cells** — half a year of intensity in one screenshot-ready chart) with a **cost / tokens metric switch**; total, active-day and streak counts on top.
112
+ - **Token insights (Usage tab)**: the daily token chart switches between two views — "Structure" (cache-miss / cache-hit / output, including reasoning) and "By model" (the toggle hides itself when snapshots lack per-day-per-model detail); hover shows the day's exact breakdown (thousand-separated); clicking a legend swatch or a model-table row focuses that model (click again to release); the model table breaks down input / output / reasoning / cache-hit rate / share / calls.
113
+ - **Tool-call ranking (Usage tab)**: call counts per tool (tokens cannot be attributed per tool, so only counts and shares), showing which tools dominate an agent loop.
114
+ - **Cost composition (Usage tab)**: output cost is measured, input cost is apportioned by user / tool message text length (the log has no per-role token measurement, so the UI labels it as an estimate).
115
+ - **Performance metrics (Usage tab)**: per-model TTFT mean / P50 / P90, generation speed (tokens/s), total-latency mean; per-hour × per-model comparison curves — metric tabs, clickable model chips (top-5 by samples lit by default), hover snapping to the nearest hour with a crosshair and per-model values, broken lines for missing-sample hours (never fabricated); view preferences persist locally.
116
+ - **Trends (Trends tab)**: daily cost & call trend (7 / 30 days, cost / tokens metric switch), last-40-turn cost bars (amount at bar top, >2× spikes flagged with attribution), peak/off-peak time share and the amount saved in off-peak hours.
107
117
 
108
- ![Stats: export, cost breakdown, workspaces and session detail](screenshots/4.png)
118
+ ![Trends: daily cost trend, per-turn costs and peak/off-peak share](screenshots/trends.png)
119
+ ![Usage: daily tokens, per-model tokens, tool ranking and cost composition](screenshots/usage.png)
120
+ - **Export + drill-down**: daily / per-session / per-site CSV and full JSON from the Billing tab, daily-token CSV from the Usage tab; session detail is drillable (click a project row to expand its sessions); no chart library, no external CDN, pure design tokens.
109
121
 
110
122
  ## 🛡️ Robustness & privacy
111
123
 
@@ -121,7 +133,7 @@ The billing channel is detected from the current session's model: DeepSeek meter
121
133
 
122
134
  Check your host generation first (`dsh --version`), then pick the matching install command — **a mismatched line is rejected by the DSH Store via the `engines.dsh` declaration** (declared since v1.0.41). Note that `dsh plugin add` requires an explicit `--profile` (otherwise it fails with `required option '--profile <name>' not specified`), and prefer **pinning an exact version** over `@latest` (pnpm's `minimumReleaseAge` cooldown skips freshly published versions and may fall back to the other line):
123
135
 
124
- - **DSH 0.1.2 ~ 0.2.0 era** (0.1.2-alpha.1 and later; npm `latest` currently at 0.2.0-rc.2; `npm ls -g @deepseek-ai/dsh` shows 0.1.2-* ~ 0.2.0-*):
136
+ - **DSH 0.1.2 ~ 0.2.1 era** (0.1.2-alpha.1 and later; the host's npm `latest` is currently 0.2.1-rc.2 and the preview line has reached 0.2.1-rc.3; `npm ls -g @deepseek-ai/dsh` shows 0.1.2-* ~ 0.2.1-*):
125
137
 
126
138
  ```sh
127
139
  dsh plugin --profile web add npm:@kenz1117/dsh-ui-usage-billing@latest
@@ -274,7 +286,7 @@ The host discovers the browser side automatically via the `dsh.client` declarati
274
286
 
275
287
  - **Permission level: high**: reads durable session logs (files), calls official multi-vendor / subscription / balance / pricing APIs (network), reads `apiKeyEnv` via the credentials seam (credentials), writes the ledger and stats snapshot under the harness home (`DSH_HOME` / `~/.dsh`) (persistent state); **no** command execution / shell.
276
288
  - **Update channel: `user-reviewed`**: with file / network / credential capabilities, DSH STORE requires local manual confirmation on every install; review the repo, pinned commit, lifecycle scripts, and impact scope before installing.
277
- - **Compatibility**: the preview line (npm `latest`/`alpha`, 1.6.x — kept above the stable line to kill the version inversion) targets DSH `0.1.2-alpha.1` ~ `0.2.1-rc.2` (host `latest` currently at 0.2.0-rc.2, preview at 0.2.1-rc.2; the full 0.2.1 series is declared per version); the stable line (npm `stable`, 1.1.x, **frozen**, final v1.1.17) targets legacy hosts `0.1.0-rc.8` ~ `0.1.1-rc.2`. Per-version declarations live in `package.json` under `dsh.compatibility`; the two-line mapping and monitoring mechanism are documented in [COMPATIBILITY.md](COMPATIBILITY.md). Node.js `^22.19.0 || >=24.0.0`.
289
+ - **Compatibility**: the preview line (npm `latest`/`alpha`, 1.7.x — kept above the stable line to kill the version inversion) targets DSH `0.1.2-alpha.1` ~ `0.2.1-rc.3` (the host's `latest` currently at 0.2.1-rc.2, preview at 0.2.1-rc.3; the full 0.2.1 series is declared per version); the stable line (npm `stable`, 1.1.x, **frozen**, final v1.1.17) targets legacy hosts `0.1.0-rc.8` ~ `0.1.1-rc.2`. Per-version declarations live in `package.json` under `dsh.compatibility`; the two-line mapping and monitoring mechanism are documented in [COMPATIBILITY.md](COMPATIBILITY.md). Node.js `^22.19.0 || >=24.0.0`.
278
290
  - **Lifecycle**: no `preinstall` / `install` / `postinstall` / `prepare` (ready on install).
279
291
  - **No impersonation**: adds only its own entry id `ui-usage-billing`; `@deepseek-ai/dsh-*` packages are `peerDependencies` only (no reinstall / replace / shadowing of official components); the package uses the third-party namespace `@kenz1117/*`.
280
292
  - **Build artifacts**: runtime files `lib/*` and `cordis.patch.yml` are committed at the pinned commit and declared in `files`.
@@ -297,7 +309,7 @@ Boundaries: the tool reads only this plugin's own aggregates — never session b
297
309
  - **Balance queries cover DeepSeek / Moonshot (Kimi) / StepFun / SiliconFlow / xAI / Zhipu GLM (Z.ai CN region) / Tencent Cloud TokenHub (token-plan quota)**: the first six use a standard Bearer API key; Tencent Cloud uses the cloud-API key pair (`<SecretId>:<SecretKey>`, TC3-signed management API). Other providers expose no public balance API or need non-Bearer auth (Xiaomi MiMo via console Cookie, SenseTime via AccessKey signing, MiniMax/Doubao via quota or AK/SK), so they currently show "not configured"; the extension point is `src/balance.ts` (add a querier per provider balance API).
298
310
  - **Relay quota depends on upstream private schemas**: New API / Sub2API interface fields are not public, so an unreadable station is labeled "no quota" rather than fabricating an amount; if a station's response fields differ, extend the parsers in `src/relay.ts`. An "unknown route" means that route exists neither in the current provider config nor in the host llm registry (deleted config, logs older than the install, …); historical call data is not lost — re-adding the same-named route restores attribution automatically. Cost in the unknown-route bucket is always 0 (issue #82): the spending party cannot be verified — same posture as uncatalogued models and subscription channels — while tokens are still counted, and the panel shows "—" for the group cost with an explanatory note. Channels registered by another plugin through `ctx.llm.registerAdapter` (e.g. CommandCode) are **not** unknown routes: the plugin discovers them from the host llm registry and bills them in the `direct:<provider>` bucket at the model's official catalog price (issue #91); the registry is read through `ctx.get('llm')` (no `inject` requirement), and an unreadable or empty registry warns once (panel numbers and the unknown policy are unchanged) so this class of failure is never silent again.
299
311
  - **Overspend notifications rely on the browser Notification API**: when permission is denied or the platform lacks support, only the in-UI red-pulse fallback remains — no host-level notification channel; notifications are capped at once per day.
300
- - **Session rows are not navigable**: clicking a session row does not open that session (cross-plugin navigation needs a host session-selection channel); the panel shows the top 20 by default and the "view all" full-screen viewer is capped at 100 rows.
312
+ - **Session-row navigation depends on a host channel**: clicking a session title in the panel opens that conversation in the host (both generations of the host's session-selection channel are probed); on very old hosts, or when the session was deleted / is not in the current session list, the UI shows a readable notice instead of silently doing nothing. The panel shows the top 20 by default and the "view all" full-screen viewer is capped at 100 rows.
301
313
  - **Cost is a catalog estimate**: models without published per-token pricing (iFlytek, SenseTime, Xiaomi) use estimates (feature-list footnote ¹); official billing is authoritative.
302
314
  - **The ledger starts at its first successful aggregation**: sessions permanently deleted before the upgrade and absent from the old snapshot cannot be recovered. Manually deleting `.dsh-usage-ledger.json` and its `.bak` clears independently retained history. Only calls successfully observed by this plugin are retained.
303
315
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/kenz1117/dsh-ui-usage-billing?logo=github"></a>
10
10
  <a href="https://www.npmjs.com/package/@kenz1117/dsh-ui-usage-billing"><img alt="npm version" src="https://img.shields.io/npm/v/@kenz1117/dsh-ui-usage-billing?logo=npm"></a>
11
11
  <a href="https://www.npmjs.com/package/@kenz1117/dsh-ui-usage-billing"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@kenz1117/dsh-ui-usage-billing?logo=npm"></a>
12
- <a href="https://github.com/kenz1117/dsh-ui-usage-billing/blob/main/COMPATIBILITY.md"><img alt="DSH 兼容" src="https://img.shields.io/badge/DSH-0.1.2_%7E_0.2.1--rc.2_%7C_0.1.0--rc.8%2B-blue"></a>
12
+ <a href="https://github.com/kenz1117/dsh-ui-usage-billing/blob/main/COMPATIBILITY.md"><img alt="DSH 兼容" src="https://img.shields.io/badge/DSH-0.1.2_%7E_0.2.1--rc.3_%7C_0.1.0--rc.8%2B-blue"></a>
13
13
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing/blob/main/LICENSE"><img alt="License MIT" src="https://img.shields.io/github/license/kenz1117/dsh-ui-usage-billing"></a>
14
14
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing/pulls"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen"></a>
15
15
  <a href="https://github.com/kenz1117/dsh-ui-usage-billing"><img alt="GitHub last commit" src="https://img.shields.io/github/last-commit/kenz1117/dsh-ui-usage-billing?logo=github"></a>
@@ -25,11 +25,13 @@
25
25
  ---
26
26
 
27
27
  <div align="center">
28
- <img src="screenshots/demo.png" alt="dsh-ui-usage-billing — 计费仪表盘总览" width="80%">
28
+ <img src="screenshots/overview.png" alt="dsh-ui-usage-billing — 计费仪表盘总览" width="80%">
29
29
  </div>
30
30
 
31
31
  ### 演示动图
32
32
 
33
+ 新版仪表盘八个分区逐个切换(概览 / 账单 / 用量 / 趋势 / 上下文 / 智能体 / 费率 / 设置):
34
+
33
35
  ![演示](screenshots/demo.gif)
34
36
 
35
37
  ## ✨ 为什么选它
@@ -64,24 +66,31 @@ DeepSeek / Kimi / 智谱 GLM / 腾讯云 TokenHub 等 7 家官方余额、Coding
64
66
  ## 📊 仪表盘
65
67
 
66
68
  - **侧边栏触发卡**:设置按钮上方常驻单值主数字(今日 / 本周 / 本月统计范围在设置里选,可选涨跌徽章),配近 7 天 sparkline 迷你趋势与当日水位仪表指针;折叠栏自动切为图标钮,悬停浮现速览卡(更新时间 + 指标网格 + 主力消耗 / 额度提醒)。
69
+
70
+ ![侧边栏触发卡:今日消耗金额、涨跌徽章与迷你趋势](screenshots/sidebar.png)
71
+
67
72
  - **八区仪表盘**:概览 / 账单 / 用量 / 趋势 / 上下文 / 智能体 / 费率 / 设置——Hero 大数字 + 环比 + 本月预计 + KPI(悬停显示口径说明)+ 用量热力图,趋势 7/30 天可切费用 / Token,模型单价表、预算(全局 + 按模型)与峰谷提醒都在;设置页支持全文搜索(中英关键词过滤分区,全不命中显示空态);克制冷调、深浅主题自适应。Tab 只保留名称,不再挂状态徽标(信息都在各自分区里)。
68
73
  - **上下文分区**:占用率进度条 + 下一次 / 最近一次请求占用 + 窗口上限 + 构成拆解(系统 / 工具 / 消息)。数据取自宿主官方 context 投影;**没有投影的会话整块不显示**,不拿 0 冒充。
69
74
  - **智能体分区**:按委派关系还原的智能体网络——父对话 → 子智能体的层级树,每行给调用数、自身费用与**链合计**(自身 + 全部后代),直接回答「哪次委派最贵」。
70
75
 
71
- ![概览:本月费用 Hero、预算进度、KPI 与用量热力图](screenshots/1.png)
76
+ ![概览:本月费用 Hero、本年 / 今日 / 本月预计、KPI 与用量热力图](screenshots/overview.png)
77
+ ![上下文:占用率、窗口上限与系统 / 工具 / 消息构成拆解](screenshots/context.png)
78
+ ![智能体:父对话 → 子智能体的委派树与链合计](screenshots/agents.png)
72
79
  - **即时代费用条**:常驻「本轮 / 会话」费用,位置三档可选(输入框内部工具行 / 上方 / 下方,默认工具行);峰谷档位与切换倒计时只在当前模型涉及峰谷时出现;订阅额度预警 chips 剩余 ≤20% 浮现、≤10% 标红;可在设置 Tab 整条隐藏(偏好本地持久化,统计与提醒不受影响)。
73
80
  - **峰 / 谷切换提醒**:切档前弹窗 + 可选系统通知,提前量 / 位置 / 模式 / 预览均可配;文案按计费通道区分(DeepSeek 价格减半、智谱积分 5 折)。
74
81
  - **会话完成提醒**:会话 running → completed 时弹系统通知(默认关闭,面板设置开启);多窗口 / 多实例按认领端点去重,同一会话只提醒一次。
75
82
  - **插件信息卡**:设置 Tab 常驻「关于」卡——版本号服务端读自包 `package.json`(单一来源,发布自动正确),作者 / 仓库 / npm / 许可证一键可达。
76
83
 
84
+ ![设置:显示偏好、本月预算、峰谷提醒、注入用量查询、自定义单价与插件信息卡](screenshots/settings.png)
85
+
77
86
  ## 💰 计费引擎
78
87
 
79
88
  - **提供商优先分组**:费用按调用实际发生的 llm 入口(通道)分组——腾讯云 TokenHub / Token Plan / DeepSeek 官方 / 直连·路由名 / 未知路由,模型品牌只是行内徽标 + 副标;官方判定按通道 origin(`api.deepseek.com`)而非路由名,`deepseek-*` 网关路由不再被误算官方。`routeAliases` 归位改名 / 删除的历史路由,`modelKeyAliases` 把目录外模型 id 绑定到计费键(日期后缀、组织前缀、TokenHub 短 id `hy3` 已内置识别)。
80
89
  - **实时费率表**:models.dev 抓价 + 探活模型对标,系统实际配置的模型全纳入;峰谷分时(工作日 9-12 / 14-18 高峰 ×2、周末全天低谷,历史费用按官方变更节点分段,见下方「计费细节」)+ 实时汇率(USD→CNY),每 6 小时自动刷新;费率条显示「上次同步」时间并可**一键立即同步**(无需重启宿主)。
81
90
  - **自定义单价**:设置面板为未收录或变价模型填实付价(未命中 / 缓存命中 / 输出,可选 USD 与低谷价),总览与日趋势按用户价重估;支持按中转站来源绑定同模型不同价,目录外模型填价即生效。
82
91
 
83
- ![费率:模型单价表(峰谷分时与实时汇率)](screenshots/5.png)
84
- - **官方 vs 三方分桶**:明细费用列按官方直连 / 第三方中转分解(混合时「官 x / 三 y」),统计 Tab 有汇总卡;联网搜索辅助请求计入官方。
92
+ ![费率:模型单价表(峰谷分时与实时汇率)](screenshots/pricing.png)
93
+ - **官方 vs 三方分桶**:账单 Tab 的模型明细费用列按官方直连 / 第三方中转分解(混合时「官 x / 三 y」);联网搜索辅助请求计入官方。
85
94
  - **月度预算 + 分档提醒**:预算条 ≥80% 琥珀、超支红脉;跨 50 / 80 / 100% 各提醒一次;余额折算 CNY 低于阈值每天提醒一次。**按模型预算**:为本月消耗 Top 模型逐个设人民币上限(与全局预算相互独立),单模型跨 80% / 100% 各提醒一次。
86
95
  - **成本突增归因**:最近 40 轮费用柱状图,金额贴柱顶、峰谷背景分带、超 2 倍红标归因。
87
96
 
@@ -93,20 +102,23 @@ DeepSeek / Kimi / 智谱 GLM / 腾讯云 TokenHub 等 7 家官方余额、Coding
93
102
  - **声明端点 + 余额对账**:内置表没有的供应商用 `declaredEndpoints` 自声明余额接口——只写「数字在哪里」的点路径、无表达式;安全边界(单斜杠绝对路径、仅 GET、拒跨源重定向、响应体 / 超时上限、凭据只取本 provider)由 `src/declarative.ts` 强制执行,取错路径在界面标 `declared` 与 reason。**余额差对账**(`reconcilePath`)用官方余额当日变动与本地账本交叉校验,偏差超阈值(0.3 元且 >15%)提示核对;充值 / 授信 / 币种变化重置基准而非告警,余额未减少(走订阅扣费)静默。
94
103
  - **中转站归组与额度**:按 `baseURL` 归一化 origin 归组,同站多把 key 合并一行、站名即域名;自动识别 New API 系(`/api/status`)与 Sub2API(`/v1/usage`)的余额与滚动额度窗口,读不出标「未读出额度」、剩余 <20% 标红;识别结果 5 分钟指纹缓存(同站多 key 独立熔断),`relay-quotas` 端点附 `diagnostics` 供「为什么不显示」自查;项目归属优先用工作区标题命名。
95
104
 
96
- ![明细:厂商计费与订阅(余额、套餐额度、模型用量)](screenshots/3.png)
105
+ ![账单:提供商计费与订阅(余额、套餐额度、模型用量)与会话明细](screenshots/billing.png)
97
106
 
98
107
  > 全部渠道的适配矩阵(识别方式 / 端点 / 凭据要求 / 排查顺序)见 [docs/adapters.md](docs/adapters.md)。
99
108
 
100
109
  ## 📈 用量可视化
101
110
 
102
- - **会话明细 + 热力图**:按会话费用倒序(标题 / 项目 / 调用 / 费用 / 最后活跃);「查看全部 N 个会话」打开全屏浮层(按标题 / 项目 / id 子串搜索,封顶 100 行);月 / 半年 / 年三档日历热力图(5 档色阶、悬停明细,年视图近 52 周 GitHub 风格,**半年视图 26 周大格**——一张图看完近半年强度,截图即用),**费用 / Token 双口径**切换,头部显示区间合计、活跃天数 / 连续使用天数。
103
- - **性能指标**:每模型 TTFT 均值 / P50 / P90、生成速度(tokens/s)、总延迟均值;按小时 × 模型对比曲线——指标 tab 切换、模型 chip 点击开关曲线(默认点亮样本数前 5)、悬停吸附最近小时显示十字线与逐模型数值,缺失样本小时断线不造假;视图偏好本地持久化。
104
- - **Token 统计洞察**:每日 token 堆叠双视角——「按结构」(输入未命中 / 命中 / 输出三桶,含 reasoning)与「按模型」(旧快照缺明细时自动隐藏切换);悬停显示当日精确明细(千分位不缩写),点击图例色块或 Token 表行聚焦单模型(再点解除);结构 KPI(缓存命中率 / 思考占比 / 输入输出比 / 峰值日);按日 CSV 与 JSON 导出(JSON 含按日 × 模型明细)。
105
-
106
- ![趋势:每日费用趋势、每轮费用与峰谷时段占比](screenshots/2.png)
107
- - **数据导出 + 下钻**:统计 Tab 导出按日 / 按会话 / 按站点 CSV 与全量 JSON;费用构成 / 工作区 / 会话明细可下钻(点项目行展开该项目的会话);无图表库、无外部 CDN、纯设计令牌。
111
+ - **会话明细(账单 Tab)**:按会话费用倒序(标题 / 项目 / 调用 / 实际费用 / 最后活跃),「查看全部 N 个会话」打开全屏浮层(按标题 / 项目 / id 子串搜索,封顶 100 行);**点会话标题直接打开宿主对话**(宿主不支持时给出可读提示,不静默无反应)。
112
+ - **用量热力图(概览 Tab)**:月 / 半年 / 年三档日历热力图(5 档色阶、悬停明细,年视图近 52 周 GitHub 风格,**半年视图 26 周大格**——一张图看完近半年强度,截图即用),**费用 / Token 双口径**切换,头部显示区间合计、活跃天数 / 连续使用天数。
113
+ - **Token 统计洞察(用量 Tab)**:每日 token 堆叠双视角——「按结构」(输入未命中 / 命中 / 输出三桶,含 reasoning)与「按模型」(旧快照缺明细时自动隐藏切换);悬停显示当日精确明细(千分位不缩写),点击图例色块或 Token 表行聚焦单模型(再点解除);模型 Token 表给出输入 / 输出 / 思考 / 缓存命中率 / 占比 / 调用数。
114
+ - **工具调用排行(用量 Tab)**:按工具计次的调用结构(token 无法按工具归因,只给次数与占比),看清 agent 循环里哪些工具在刷量。
115
+ - **费用构成(用量 Tab)**:输出成本按实测计价、输入成本按 user / tool 消息文本长度占比摊分(日志无角色级 token 实测,界面标注估算口径)。
116
+ - **性能指标(用量 Tab)**:每模型 TTFT 均值 / P50 / P90、生成速度(tokens/s)、总延迟均值;按小时 × 模型对比曲线——指标 tab 切换、模型 chip 点击开关曲线(默认点亮样本数前 5)、悬停吸附最近小时显示十字线与逐模型数值,缺失样本小时断线不造假;视图偏好本地持久化。
117
+ - **趋势(趋势 Tab)**:每日费用与调用趋势(7 / 30 天可切,费用 / Token 双口径)、最近 40 轮费用柱状图(金额贴柱顶、超 2 倍红标归因)、峰谷时段占比与低谷省下的金额。
108
118
 
109
- ![统计:导出、费用构成、工作区与会话明细](screenshots/4.png)
119
+ ![趋势:每日费用趋势、每轮费用与峰谷时段占比](screenshots/trends.png)
120
+ ![用量:每日 Token、模型 Token、工具排行与费用构成](screenshots/usage.png)
121
+ - **数据导出 + 下钻**:账单 Tab 导出按日 / 按会话 / 按站点 CSV 与全量 JSON,用量 Tab 导出按日 Token CSV;会话明细可下钻(点项目行展开该项目的会话);无图表库、无外部 CDN、纯设计令牌。
110
122
 
111
123
  ## 🛡️ 健壮性与隐私
112
124
 
@@ -122,7 +134,7 @@ DeepSeek / Kimi / 智谱 GLM / 腾讯云 TokenHub 等 7 家官方余额、Coding
122
134
 
123
135
  先确认宿主代际(`dsh --version`),再按代际选安装命令——**装错线会在市场侧被 `engines.dsh` 声明拦截**(v1.0.41 起声明生效)。注意 `dsh plugin add` 需要显式 `--profile`(缺省会报 `required option '--profile <name>' not specified`),且建议**钉具体版本号**而非 `@latest`(pnpm 的 `minimumReleaseAge` 冷静期会让刚发布的版本被跳过、回退到旧线):
124
136
 
125
- - **DSH 0.1.2 ~ 0.2.0 系**(0.1.2-alpha.1 起,现行 latest 为 0.2.0-rc.2;`npm ls -g @deepseek-ai/dsh` 显示 0.1.2-* ~ 0.2.0-*):
137
+ - **DSH 0.1.2 ~ 0.2.1 系**(0.1.2-alpha.1 起,宿主 `latest` 现为 0.2.1-rc.2、预览线已到 0.2.1-rc.3;`npm ls -g @deepseek-ai/dsh` 显示 0.1.2-* ~ 0.2.1-*):
126
138
 
127
139
  ```sh
128
140
  dsh plugin --profile web add npm:@kenz1117/dsh-ui-usage-billing@latest
@@ -283,7 +295,7 @@ npm dist-tag add @kenz1117/dsh-ui-usage-billing@<版本> alpha
283
295
 
284
296
  - **权限等级:high**:读取持久会话日志(文件)、访问多厂商官方 / 订阅 / 余额 / 定价 API(网络)、经凭据 seam 读取 `apiKeyEnv`(凭据)、写入 harness home(`DSH_HOME` / `~/.dsh`)下的账本与快照(持久状态);**不含**命令执行 / Shell。
285
297
  - **更新通道:`user-reviewed`**:本插件具备文件 / 网络 / 凭据能力,DSH STORE 采用每次安装需本机人工确认的通道;安装前请复核仓库、固定 Commit、生命周期脚本与影响范围。
286
- - **兼容范围**:预览线(npm `latest`/`alpha`,1.6.x,自 v1.2.0 起恒高于稳定线以消除版本号倒挂)适配 DSH `0.1.2-alpha.1` ~ `0.2.1-rc.2`(宿主 `latest` 现指向 0.2.0-rc.2、预览线最新为 0.2.1-rc.2;0.2.1 全系列逐版本声明);稳定线(npm `stable`,1.1.x,**已冻结**,终版 v1.1.17)适配旧宿主 `0.1.0-rc.8` ~ `0.1.1-rc.2`。逐版本声明见 `package.json` 的 `dsh.compatibility`,双线对照与监控机制见 [COMPATIBILITY.md](COMPATIBILITY.md)。Node.js `^22.19.0 || >=24.0.0`。
298
+ - **兼容范围**:预览线(npm `latest`/`alpha`,1.7.x,自 v1.2.0 起恒高于稳定线以消除版本号倒挂)适配 DSH `0.1.2-alpha.1` ~ `0.2.1-rc.3`(宿主 `latest` 现指向 0.2.1-rc.2、预览线已到 0.2.1-rc.3;0.2.1 全系列逐版本声明);稳定线(npm `stable`,1.1.x,**已冻结**,终版 v1.1.17)适配旧宿主 `0.1.0-rc.8` ~ `0.1.1-rc.2`。逐版本声明见 `package.json` 的 `dsh.compatibility`,双线对照与监控机制见 [COMPATIBILITY.md](COMPATIBILITY.md)。Node.js `^22.19.0 || >=24.0.0`。
287
299
  - **生命周期**:无 `preinstall` / `install` / `postinstall` / `prepare`(安装即用)。
288
300
  - **不冒用官方**:仅新增自有 entry id `ui-usage-billing`;对 `@deepseek-ai/dsh-*` 仅为 `peerDependencies` 依赖(不重复安装 / 不替换 / 不遮蔽官方组件),包名使用第三方命名空间 `@kenz1117/*`。
289
301
  - **打包产物**:运行文件 `lib/*` 与 `cordis.patch.yml` 已随固定 Commit 提交并声明在 `files`。
@@ -306,7 +318,7 @@ npm dist-tag add @kenz1117/dsh-ui-usage-billing@<版本> alpha
306
318
  - **余额查询已接入 DeepSeek / 月之暗面(Kimi)/ 阶跃星辰(StepFun)/ 硅基流动 / xAI / 智谱 GLM(Z.ai 国内域)/ 腾讯云 TokenHub(Token Plan 余量)**:前六家用标准 Bearer API key,腾讯云用云 API 密钥对(TC3 签名)。其余厂商因无公开余额接口或需非 Bearer 鉴权(小米 MiMo 走控制台 Cookie、商汤走 AccessKey 签名、MiniMax/字节豆包走额度制或 AK/SK),暂显示「未配置」;扩展点在 `src/balance.ts`(按厂商余额 API 增加查询器)。
307
319
  - **中转站额度依赖上游私有 schema**:New API / Sub2API 的接口字段未公开,读不出时标「未读出额度」而非臆造金额;若某中转站响应字段不同,需按 `src/relay.ts` 的解析器扩展。未知路由表示该路由在当前 provider 配置与宿主 llm 注册表里都不存在(配置已删、日志比安装更早等),历史调用数据未丢,重新配置同名路由即可自动归位。未知路由桶的费用一律记 0(issue #82):支出主体无法核实,与未收录模型、订阅通道同姿态——token 照常统计,面板组头费用显示「—」并附说明。由独立插件经 `ctx.llm.registerAdapter` 注册的通道(如 CommandCode)不属于未知路由:插件从宿主 llm 注册表发现它们,按 `direct:<provider>` 桶以模型官方目录价计费(issue #91);注册表读取走 `ctx.get('llm')`(免 `inject` 声明),读不到/读到空集合时告警一次(面板数字不变,unknown 口径不变),避免这类失效无声无息。
308
320
  - **超支通知依赖浏览器 Notification**:权限被拒绝或平台不支持时只有界面红色脉冲兜底,没有宿主级通知通道;通知上限为每天一次。
309
- - **会话明细不可跳转**:点击会话行不会打开对应会话(跨插件导航需要宿主会话选择通道);面板默认只显示前 20 行,「查看全部」全屏浮层封顶 100 行。
321
+ - **会话行跳转依赖宿主通道**:面板里的会话标题点一下即可打开宿主对话(双代会话选择通道自动探测);宿主过旧、或会话已被删除 / 不在当前会话列表时,界面给出可读提示而不是静默无反应。面板默认只显示前 20 行,「查看全部」全屏浮层封顶 100 行。
310
322
  - **费用为目录价估算**:讯飞 / 商汤 / 小米等未公布按量单价的模型使用估算价(特性表脚注 ¹),正式定价以厂商账单为准。
311
323
  - **账本从首次成功聚合开始生效**:升级前已经永久删除且不在旧快照中的会话无法恢复;手动删除 `.dsh-usage-ledger.json` 及其 `.bak` 会清空独立保留的历史。账本只保留本插件已经成功观测过的调用。
312
324
  - **历史回放预热**:插件加载 3 秒后台自动全量折叠宿主全部历史会话日志(账本幂等,重复折叠不重复计数),首次打开面板即可看到完整历史统计,无需等一遍首次折叠。历史会话按「首次折叠时的模型单价」计价;厂商改价前的历史消耗不会按新价重算(价格目录无历史时点价,属估算口径)。
package/lib/index.js CHANGED
@@ -5188,7 +5188,8 @@ function createUsageAggregator(persistence, options = {}) {
5188
5188
  const foldFingerprintOf = (registered, routes) => configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, options.modelKeyAliases, registered, routeOriginsOf(routes));
5189
5189
  let foldFingerprint = foldFingerprintOf(/* @__PURE__ */ new Set(), {});
5190
5190
  const FOLD_CHUNK_EVENTS = 8e3;
5191
- const FOLD_YIELD_SESSIONS = 8;
5191
+ const FOLD_SLICE_MS = 8;
5192
+ const foldAllowedAt = Date.now() + Math.max(0, options.deferFoldMs ?? 0);
5192
5193
  /** 惰性失效(价目表时序):账本/缓存行若以「当时不可计价」的模型落盘,
5193
5194
  * 而价目表现在能计价了,该行必须重折——否则显示层按实时价算出的
5194
5195
  * 「目录价预估」与服务端 cost=0 并存,出现「明细比总额高」的双口径
@@ -5226,6 +5227,10 @@ function createUsageAggregator(persistence, options = {}) {
5226
5227
  const document = stored;
5227
5228
  if (runLedgerMigrations(document)) ledgerNeedsSave = true;
5228
5229
  ledgerAppliedMigrations = document.appliedMigrations;
5230
+ for (const refusal of document.refused ?? []) if (typeof refusal?.id === "string" && typeof refusal?.stamp === "string") unreadable.set(refusal.id, {
5231
+ stamp: refusal.stamp,
5232
+ format: refusal.format === true
5233
+ });
5229
5234
  }
5230
5235
  for (const entry of ledgerSessionsOf(stored)) ledger.set(entry.id, entry);
5231
5236
  } catch (error) {
@@ -5260,6 +5265,11 @@ function createUsageAggregator(persistence, options = {}) {
5260
5265
  version: 1,
5261
5266
  updatedAt: Date.now(),
5262
5267
  sessions: [...ledger.values()],
5268
+ ...unreadable.size === 0 ? {} : { refused: [...unreadable].slice(0, 500).map(([id, mark]) => ({
5269
+ id,
5270
+ stamp: mark.stamp,
5271
+ format: mark.format
5272
+ })) },
5263
5273
  ...ledgerAppliedMigrations === void 0 ? {} : { appliedMigrations: ledgerAppliedMigrations }
5264
5274
  });
5265
5275
  ledgerNeedsSave = false;
@@ -5268,20 +5278,19 @@ function createUsageAggregator(persistence, options = {}) {
5268
5278
  console.warn("[usage-billing] failed to persist durable usage ledger:", error);
5269
5279
  }
5270
5280
  };
5271
- const aggregateOnce = async () => {
5281
+ /**
5282
+ * 一轮聚合。mode='full' 走完整流程(list + 逐会话失效判定 + 折叠/复用);
5283
+ * mode='ledger-only' 只吃账本存档出轻文档(冷phase,issue #105):不 list、
5284
+ * 不读任何日志,doc 带 staleSnapshot=true 且**不进 TTL 缓存**(lastDoc 不动,
5285
+ * 开门后的下一轮即完整折叠)——轻文档的每行折叠反序列化有 WeakMap 记忆,
5286
+ * 重复构建只付 O(行数) 的汇总合并,冷phase里每个请求都可负担。
5287
+ */
5288
+ const aggregateOnce = async (mode) => {
5272
5289
  const now = Date.now();
5273
5290
  await ensureLedgerLoaded();
5274
5291
  const registeredProviders = options.resolveRegisteredProviders?.() ?? /* @__PURE__ */ new Set();
5275
5292
  const roundRoutes = routesOf(registeredProviders);
5276
5293
  foldFingerprint = foldFingerprintOf(registeredProviders, roundRoutes);
5277
- let metas;
5278
- try {
5279
- metas = await persistence.list();
5280
- } catch (error) {
5281
- if (options.ledger === void 0 || ledger.size === 0) throw error;
5282
- console.warn("[usage-billing] session list unavailable; serving durable usage ledger:", error);
5283
- metas = [];
5284
- }
5285
5294
  const seen = /* @__PURE__ */ new Set();
5286
5295
  const included = /* @__PURE__ */ new Set();
5287
5296
  const folds = [];
@@ -5291,132 +5300,164 @@ function createUsageAggregator(persistence, options = {}) {
5291
5300
  let formatUnreadable = 0;
5292
5301
  let noLedgerUnreadable = 0;
5293
5302
  let staleLedgerSessions = 0;
5294
- for (const [metaIndex, meta] of metas.entries()) {
5295
- if (metaIndex > 0 && metaIndex % FOLD_YIELD_SESSIONS === 0) await new Promise((resolve) => {
5296
- setImmediate(resolve);
5297
- });
5298
- const id = String(meta.id);
5299
- seen.add(id);
5300
- const stamp = await stampOf(meta);
5301
- const mark = unreadable.get(id);
5302
- if (stamp !== null && mark !== void 0 && mark.stamp === stamp) {
5303
- carriedUnreadable += 1;
5304
- if (mark.format) carriedFormatUnreadable += 1;
5305
- if (!ledger.has(id)) noLedgerUnreadable += 1;
5306
- continue;
5307
- }
5303
+ if (mode === "full") {
5304
+ let metas;
5308
5305
  try {
5309
- const cwd = meta.cwd;
5310
- const subOf = subParentOf(meta);
5311
- const ledgerRow = ledger.get(id);
5312
- if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 23 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
5313
- const fold = deserializedFoldOf(ledgerRow.fold);
5314
- folds.push({
5315
- id,
5316
- ...cwd === void 0 ? {} : { cwd },
5317
- ...subOf === void 0 ? {} : { subOf },
5318
- fold
5306
+ metas = await persistence.list();
5307
+ } catch (error) {
5308
+ if (options.ledger === void 0 || ledger.size === 0) throw error;
5309
+ console.warn("[usage-billing] session list unavailable; serving durable usage ledger:", error);
5310
+ metas = [];
5311
+ }
5312
+ let sliceAt = Date.now();
5313
+ for (const meta of metas) {
5314
+ if (Date.now() - sliceAt >= FOLD_SLICE_MS) {
5315
+ await new Promise((resolve) => {
5316
+ setImmediate(resolve);
5319
5317
  });
5320
- included.add(id);
5321
- continue;
5318
+ sliceAt = Date.now();
5322
5319
  }
5323
- const hit = cache.get(id);
5324
- if (hit !== void 0 && stamp !== null && hit.stamp === stamp && hit.fingerprint === foldFingerprint && ![...hit.fold.unpricedModels].some((key) => isPriced(key))) {
5325
- cache.delete(id);
5326
- cache.set(id, hit);
5327
- folds.push({
5328
- id,
5329
- ...meta.cwd === void 0 ? {} : { cwd: meta.cwd },
5330
- ...subOf === void 0 ? {} : { subOf },
5331
- fold: hit.fold
5332
- });
5333
- included.add(id);
5320
+ const id = String(meta.id);
5321
+ seen.add(id);
5322
+ const stamp = await stampOf(meta);
5323
+ const mark = unreadable.get(id);
5324
+ if (stamp !== null && mark !== void 0 && mark.stamp === stamp) {
5325
+ carriedUnreadable += 1;
5326
+ if (mark.format) carriedFormatUnreadable += 1;
5327
+ if (!ledger.has(id)) noLedgerUnreadable += 1;
5334
5328
  continue;
5335
5329
  }
5336
- const previous = cache.get(id);
5337
- if (previous?.machine !== void 0 && previous.lastSeq !== void 0 && previous.fingerprint === foldFingerprint && ![...previous.fold.unpricedModels].some((key) => isPriced(key))) {
5338
- const from = previous.lastSeq + 1;
5339
- const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(from));
5340
- const after = await stampOf(meta);
5341
- if (stamp !== null && after !== stamp) {
5330
+ try {
5331
+ const cwd = meta.cwd;
5332
+ const subOf = subParentOf(meta);
5333
+ const ledgerRow = ledger.get(id);
5334
+ if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 23 && ledgerRow.fingerprint === foldFingerprint && !foldNeedsReprice(ledgerRow.fold)) {
5335
+ const fold = deserializedFoldOf(ledgerRow.fold);
5336
+ folds.push({
5337
+ id,
5338
+ ...cwd === void 0 ? {} : { cwd },
5339
+ ...subOf === void 0 ? {} : { subOf },
5340
+ fold
5341
+ });
5342
+ included.add(id);
5343
+ continue;
5344
+ }
5345
+ const hit = cache.get(id);
5346
+ if (hit !== void 0 && stamp !== null && hit.stamp === stamp && hit.fingerprint === foldFingerprint && ![...hit.fold.unpricedModels].some((key) => isPriced(key))) {
5342
5347
  cache.delete(id);
5348
+ cache.set(id, hit);
5349
+ folds.push({
5350
+ id,
5351
+ ...meta.cwd === void 0 ? {} : { cwd: meta.cwd },
5352
+ ...subOf === void 0 ? {} : { subOf },
5353
+ fold: hit.fold
5354
+ });
5355
+ included.add(id);
5343
5356
  continue;
5344
5357
  }
5345
- const first = events[0]?.seq;
5346
- if (events.length === 0 || first !== from) {
5358
+ const previous = cache.get(id);
5359
+ if (previous?.machine !== void 0 && previous.lastSeq !== void 0 && previous.fingerprint === foldFingerprint && ![...previous.fold.unpricedModels].some((key) => isPriced(key))) {
5360
+ const from = previous.lastSeq + 1;
5361
+ const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(from));
5362
+ const after = await stampOf(meta);
5363
+ if (stamp !== null && after !== stamp) {
5364
+ cache.delete(id);
5365
+ continue;
5366
+ }
5367
+ const first = events[0]?.seq;
5368
+ if (events.length === 0 || first !== from) {
5369
+ cache.delete(id);
5370
+ continue;
5371
+ }
5372
+ foldInto(previous.fold, previous.machine, events, subscriptionMatcher, officialProviderIds, roundRoutes, searchEstimate, inheritedEventCount, routeAliases);
5373
+ refreshTurns(previous.fold, previous.machine);
5374
+ previous.lastSeq = events[events.length - 1]?.seq ?? previous.lastSeq;
5375
+ previous.stamp = stamp;
5347
5376
  cache.delete(id);
5377
+ cache.set(id, previous);
5378
+ folds.push({
5379
+ id,
5380
+ ...cwd === void 0 ? {} : { cwd },
5381
+ ...subOf === void 0 ? {} : { subOf },
5382
+ fold: previous.fold
5383
+ });
5384
+ included.add(id);
5385
+ recordLedger(id, cwd, stamp, previous.fold, subOf);
5348
5386
  continue;
5349
5387
  }
5350
- foldInto(previous.fold, previous.machine, events, subscriptionMatcher, officialProviderIds, roundRoutes, searchEstimate, inheritedEventCount, routeAliases);
5351
- refreshTurns(previous.fold, previous.machine);
5352
- previous.lastSeq = events[events.length - 1]?.seq ?? previous.lastSeq;
5353
- previous.stamp = stamp;
5354
- cache.delete(id);
5355
- cache.set(id, previous);
5388
+ const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(0));
5389
+ const after = await stampOf(meta);
5390
+ if (stamp !== null && after !== stamp) continue;
5391
+ const fold = freshFold();
5392
+ const machine = freshMachine();
5393
+ for (let start = 0; start < events.length; start += FOLD_CHUNK_EVENTS) {
5394
+ foldInto(fold, machine, events.slice(start, start + FOLD_CHUNK_EVENTS), subscriptionMatcher, officialProviderIds, roundRoutes, searchEstimate, inheritedEventCount, routeAliases);
5395
+ if (start + FOLD_CHUNK_EVENTS < events.length && Date.now() - sliceAt >= FOLD_SLICE_MS) {
5396
+ await new Promise((resolve) => {
5397
+ setImmediate(resolve);
5398
+ });
5399
+ sliceAt = Date.now();
5400
+ }
5401
+ }
5402
+ refreshTurns(fold, machine);
5403
+ const last = events[events.length - 1];
5404
+ cache.set(id, {
5405
+ stamp,
5406
+ fold,
5407
+ machine,
5408
+ ...last !== void 0 && typeof last.seq === "number" ? { lastSeq: last.seq } : {},
5409
+ fingerprint: foldFingerprint
5410
+ });
5356
5411
  folds.push({
5357
5412
  id,
5358
5413
  ...cwd === void 0 ? {} : { cwd },
5359
5414
  ...subOf === void 0 ? {} : { subOf },
5360
- fold: previous.fold
5415
+ fold
5361
5416
  });
5362
5417
  included.add(id);
5363
- recordLedger(id, cwd, stamp, previous.fold, subOf);
5364
- continue;
5365
- }
5366
- const { events, inheritedEventCount } = await persistence.readFrom(meta.id, sessionLogOffsetOf(0));
5367
- const after = await stampOf(meta);
5368
- if (stamp !== null && after !== stamp) continue;
5369
- const fold = freshFold();
5370
- const machine = freshMachine();
5371
- for (let start = 0; start < events.length; start += FOLD_CHUNK_EVENTS) {
5372
- foldInto(fold, machine, events.slice(start, start + FOLD_CHUNK_EVENTS), subscriptionMatcher, officialProviderIds, roundRoutes, searchEstimate, inheritedEventCount, routeAliases);
5373
- if (start + FOLD_CHUNK_EVENTS < events.length) await new Promise((resolve) => {
5374
- setImmediate(resolve);
5375
- });
5376
- }
5377
- refreshTurns(fold, machine);
5378
- const last = events[events.length - 1];
5379
- cache.set(id, {
5380
- stamp,
5381
- fold,
5382
- machine,
5383
- ...last !== void 0 && typeof last.seq === "number" ? { lastSeq: last.seq } : {},
5384
- fingerprint: foldFingerprint
5385
- });
5386
- folds.push({
5387
- id,
5388
- ...cwd === void 0 ? {} : { cwd },
5389
- ...subOf === void 0 ? {} : { subOf },
5390
- fold
5391
- });
5392
- included.add(id);
5393
- recordLedger(id, cwd, stamp, fold, subOf);
5394
- } catch (error) {
5395
- skipped.push(id);
5396
- const format = isFormatUnsupportedError(error);
5397
- if (format) formatUnreadable += 1;
5398
- if (!ledger.has(id)) noLedgerUnreadable += 1;
5399
- if (stamp !== null) unreadable.set(id, {
5400
- stamp,
5401
- format
5402
- });
5403
- if (options.resolveSessionTitle !== void 0) {
5404
- const hint = options.resolveSessionTitle(meta);
5405
- if (hint !== void 0) titleHints.set(id, hint);
5418
+ recordLedger(id, cwd, stamp, fold, subOf);
5419
+ } catch (error) {
5420
+ skipped.push(id);
5421
+ const format = isFormatUnsupportedError(error);
5422
+ if (format) formatUnreadable += 1;
5423
+ if (!ledger.has(id)) noLedgerUnreadable += 1;
5424
+ if (stamp !== null) {
5425
+ unreadable.set(id, {
5426
+ stamp,
5427
+ format
5428
+ });
5429
+ ledgerNeedsSave = true;
5430
+ }
5431
+ if (options.resolveSessionTitle !== void 0) {
5432
+ const hint = options.resolveSessionTitle(meta);
5433
+ if (hint !== void 0) titleHints.set(id, hint);
5434
+ }
5435
+ console.warn("[usage-billing] skip unreadable session", id, error);
5406
5436
  }
5407
- console.warn("[usage-billing] skip unreadable session", id, error);
5437
+ if (included.has(id)) titleHints.delete(id);
5408
5438
  }
5409
- if (included.has(id)) titleHints.delete(id);
5410
5439
  }
5411
- for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
5412
- for (const key of [...unreadable.keys()]) if (!seen.has(key)) unreadable.delete(key);
5413
- while (cache.size > maxCacheSessions) {
5414
- const oldest = cache.keys().next();
5415
- if (oldest.done === true) break;
5416
- cache.delete(oldest.value);
5440
+ if (mode === "full") {
5441
+ for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
5442
+ for (const key of [...unreadable.keys()]) if (!seen.has(key)) {
5443
+ unreadable.delete(key);
5444
+ ledgerNeedsSave = true;
5445
+ }
5446
+ while (cache.size > maxCacheSessions) {
5447
+ const oldest = cache.keys().next();
5448
+ if (oldest.done === true) break;
5449
+ cache.delete(oldest.value);
5450
+ }
5417
5451
  }
5418
5452
  if (options.ledger !== void 0) {
5453
+ let fallbackSliceAt = Date.now();
5419
5454
  for (const entry of ledger.values()) {
5455
+ if (Date.now() - fallbackSliceAt >= FOLD_SLICE_MS) {
5456
+ await new Promise((resolve) => {
5457
+ setImmediate(resolve);
5458
+ });
5459
+ fallbackSliceAt = Date.now();
5460
+ }
5420
5461
  if (included.has(entry.id)) continue;
5421
5462
  try {
5422
5463
  const stale = (entry.foldVersion ?? 1) < 23 || entry.fingerprint !== foldFingerprint;
@@ -5594,7 +5635,7 @@ function createUsageAggregator(persistence, options = {}) {
5594
5635
  };
5595
5636
  const unreadableTotal = skipped.length + carriedUnreadable;
5596
5637
  const formatUnreadableTotal = formatUnreadable + carriedFormatUnreadable;
5597
- lastDoc = {
5638
+ const document = {
5598
5639
  version: 4,
5599
5640
  updatedAt: now,
5600
5641
  source: "session-logs",
@@ -5628,15 +5669,21 @@ function createUsageAggregator(persistence, options = {}) {
5628
5669
  assistant: roles.outputCost,
5629
5670
  tool: roles.inputCost * (1 - userShare)
5630
5671
  };
5631
- })()
5672
+ })(),
5673
+ ...mode === "ledger-only" ? { staleSnapshot: true } : {}
5632
5674
  };
5633
- lastAt = now;
5634
- return lastDoc;
5675
+ if (mode === "full") {
5676
+ lastDoc = document;
5677
+ lastAt = now;
5678
+ }
5679
+ return document;
5635
5680
  };
5636
5681
  return {
5637
5682
  aggregate() {
5638
- if (lastDoc !== void 0 && Date.now() - lastAt < 5e3) return Promise.resolve(lastDoc);
5639
- inflight ??= aggregateOnce().finally(() => {
5683
+ const now = Date.now();
5684
+ if (lastDoc !== void 0 && now - lastAt < 5e3) return Promise.resolve(lastDoc);
5685
+ if (now < foldAllowedAt) return inflight ?? aggregateOnce("ledger-only");
5686
+ inflight ??= aggregateOnce("full").finally(() => {
5640
5687
  inflight = void 0;
5641
5688
  });
5642
5689
  return inflight;
@@ -9335,9 +9382,19 @@ const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z
9335
9382
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
9336
9383
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
9337
9384
  const PRICING_REFRESH_INTERVAL_MS = 216e5;
9338
- /** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
9339
- * 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
9340
- const WARMUP_DELAY_MS = 3e3;
9385
+ /** 启动后延迟全量折叠的默认时长(毫秒,issue #105):冷phase内 /api/billing/*
9386
+ * 只出账本轻文档(不读日志),到点后预热定时器照常完整折叠——UI 一加载的
9387
+ * 轮询不再把全量折叠拉进启动关键路径。可用 config.replayDelayMs 覆盖;0 = 旧
9388
+ * 行为(随到随折)。20 秒的取值:小账本用户只损失十几秒新鲜度,重账本用户的
9389
+ * 启动高峰(插件加载/索引/首批会话写入)已经过去;配合聚合器的墙钟分片让出
9390
+ * (FOLD_SLICE_MS),折叠真跑起来时单片阻塞也被压在 ~8ms。
9391
+ */
9392
+ const DEFAULT_REPLAY_DELAY_MS = 2e4;
9393
+ /** 手写收窄(schemastery 不能当形状校验器,S-7):非有限非负数字回落默认值。 */
9394
+ function readReplayDelayMs(value) {
9395
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
9396
+ return DEFAULT_REPLAY_DELAY_MS;
9397
+ }
9341
9398
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
9342
9399
  const SUBSCRIPTION_CACHE_MS = 3e5;
9343
9400
  const BALANCE_CACHE_MS = 3e5;
@@ -9821,6 +9878,7 @@ function apply(ctx, config = {}) {
9821
9878
  swallowed("minimax-plan-classify-failed", error);
9822
9879
  }
9823
9880
  };
9881
+ const replayDelayMs = readReplayDelayMs(config.replayDelayMs);
9824
9882
  const aggregator = createUsageAggregator(adaptSessionPersistence(ctx.sessionPersistence), {
9825
9883
  subscriptionMatcher: subscriptionRoutes,
9826
9884
  ...config.routeAliases === void 0 ? {} : { routeAliases: config.routeAliases },
@@ -9830,6 +9888,7 @@ function apply(ctx, config = {}) {
9830
9888
  ...workspaceTitleResolver === void 0 ? {} : { resolveWorkspaceTitle: workspaceTitleResolver },
9831
9889
  ...sessionTitleResolver === void 0 ? {} : { resolveSessionTitle: sessionTitleResolver },
9832
9890
  ...config.searchCallEstimateCny === void 0 ? {} : { searchCallEstimateCny: config.searchCallEstimateCny },
9891
+ deferFoldMs: replayDelayMs,
9833
9892
  ledger: ledgerStore
9834
9893
  });
9835
9894
  refreshMiniMaxPlanRoutes();
@@ -9843,7 +9902,7 @@ function apply(ctx, config = {}) {
9843
9902
  }).catch((error) => {
9844
9903
  console.warn("[usage-billing] historical replay warmup failed; will fold on first dashboard request:", error);
9845
9904
  });
9846
- }, WARMUP_DELAY_MS);
9905
+ }, replayDelayMs);
9847
9906
  ctx.effect(() => () => {
9848
9907
  clearTimeout(warmupTimer);
9849
9908
  }, "usage-billing: historical replay warmup timer");
@@ -179,6 +179,13 @@ export interface AggregateOptions {
179
179
  /** 联网搜索请求(`web/deepseek-search-llm-request`,无用量事件)的单次费用
180
180
  * 估算(人民币元);默认 {@link DEFAULT_SEARCH_CALL_ESTIMATE_CNY},设 0 关闭。 */
181
181
  searchCallEstimateCny?: number;
182
+ /**
183
+ * 启动冷phase时长(毫秒,自聚合器创建起算,issue #105):期间 aggregate()
184
+ * 只出账本轻文档——不 list 会话、不读任何日志,文档带 `staleSnapshot: true`,
185
+ * UI 一加载的轮询不再把「全量折叠」拉进启动关键路径。到点后首个请求(或预热
186
+ * 定时器)照常完整折叠。0 / 缺省 = 不设闸(旧行为:随到随折)。
187
+ */
188
+ deferFoldMs?: number;
182
189
  }
183
190
  /** 每会话折叠缓存默认上限:超过则按 LRU 淘汰(P1-6 峰值内存治理)。 */
184
191
  export declare const DEFAULT_MAX_CACHE_SESSIONS = 400;
@@ -352,6 +359,12 @@ export interface UsageStatsDocument {
352
359
  unreadableFormatSessions?: number;
353
360
  /** 未统计中无任何账本存档、完全未计入的子集(区别于有存档按最近可用点计入的);无存档缺失时省略。 */
354
361
  unreadableNoLedgerSessions?: number;
362
+ /**
363
+ * 冷phase轻文档标记(issue #105):本档全部出自账本存档、未读任何会话日志
364
+ * (启动后 replayDelayMs 内的请求)。客户端复用既有「数据滞后」横幅呈现
365
+ * (issue #103 加的 staleSnapshot 通道,此前无生产方),零新增客户端体积。
366
+ */
367
+ staleSnapshot?: boolean;
355
368
  }
356
369
  /** 按角色费用归因:user / tool 为输入成本的启发式摊分,assistant 为输出成本实测。 */
357
370
  export interface RoleCost {
@@ -637,6 +650,19 @@ export interface UsageLedgerDocument {
637
650
  sessions: UsageLedgerSession[];
638
651
  /** 已应用的一次性配置迁移 id 列表(随文档落盘;缺省 = 尚未跑过任何迁移)。 */
639
652
  appliedMigrations?: string[];
653
+ /**
654
+ * 拒读会话负缓存(issue #105):读失败(含格式拒读)会话的失败时 stamp。落盘
655
+ * 后重启不再重读——宿主对读不动的日志要解压解析到拒绝点才抛错,单条数百 ms、
656
+ * 老用户上百条时是每次启动的固定大头(负缓存此前只在内存,重启即失)。stamp
657
+ * 变化(日志被改写/追加)后照常失效重试;会话消失由 seen 清理兜底。
658
+ */
659
+ refused?: UsageLedgerRefusal[];
660
+ }
661
+ /** 账本里的一条拒读记录:会话 id + 失败时的失效键 + 是否格式拒读(计数细分用)。 */
662
+ export interface UsageLedgerRefusal {
663
+ id: string;
664
+ stamp: string;
665
+ format: boolean;
640
666
  }
641
667
  /**
642
668
  * 多实例账本合并(自审计延期项:save 路径 last-writer-wins 丢写):同一
@@ -666,6 +692,9 @@ export declare function mergeUsageLedgerDocuments(document: UsageLedgerDocument,
666
692
  * 持久账本行据此区分新旧算法:日志已删/不可读而只能沿用旧行时,UI 标注置信度提示。
667
693
  */
668
694
  export declare const FOLD_VERSION = 23;
695
+ /** 账本里拒读记录的条数上限:防异常环境(大量损坏日志)把账本撑爆;
696
+ * 超限按 Map 插入序保留前 N 条(最早发现的先留,stamp 失效的自然被清理让位)。 */
697
+ export declare const LEDGER_REFUSED_CAP = 500;
669
698
  /**
670
699
  * 一次性账本迁移:id 唯一,apply 在加载边界对原始文档执行,已应用过的跳过。
671
700
  * 未来账本/schema 字段变更(重命名、拆桶、语义调整)时,在此追加一条迁移并
@@ -96,5 +96,13 @@ export interface UsageBillingConfig {
96
96
  * 如 `{ "hy4-preview": "hunyuan" }`)。聚合折叠与客户端渲染共用同一映射。
97
97
  */
98
98
  modelKeyAliases?: Record<string, string>;
99
+ /**
100
+ * 启动后延迟全量折叠的时长(毫秒,issue #105):聚合器创建起 `replayDelayMs`
101
+ * 之内,`/api/billing/*` 只出账本轻文档(不读任何会话日志,面板以「数据滞后」
102
+ * 横幅提示,复用 issue #103 的 staleSnapshot 通道),到点后预热定时器照常完整
103
+ * 折叠。默认 20 000(20 秒);重账本用户(数千会话 / 数十 MB 日志)可调大到
104
+ * 180 000;0 = 旧行为(随到随折,首屏轮询即触发全量折叠)。
105
+ */
106
+ replayDelayMs?: number;
99
107
  }
100
108
  //# sourceMappingURL=config.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-ui-usage-billing",
3
3
  "description": "Usage billing dashboard for DeepSeek Harness: sidebar cost metrics plus a full dashboard modal, priced from a current multi-provider catalog with real usage aggregated from session logs.",
4
- "version": "1.7.2",
4
+ "version": "1.7.3",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "deepseek",