dsh-usage-ledger
August 20, 2026 · View on GitHub
Cross-session LLM token-usage accounting for DeepSeek Harness, as a dual-face plugin package: a host half (Node) that keeps the ledger, and a browser half that adds a 数据与统计 (Data & Usage) section to the web client's Settings panel. It fills the gap the base harness deliberately leaves open: how many tokens did I use — across every session, model, and provider? No pricing, no currency — just an honest token ledger.
What it does
- Records every model call in the process through the
llm/streamwaterfall — agent turns, subagents, session titles, compaction summaries. One ledger entry per call that reports provider usage (inputTokens,cacheReadTokens,cacheWriteTokens,outputTokens,reasoningTokens), stamped with provider, model, session id, and purpose. - Reported first, estimates flagged: provider-reported usage is recorded
as-is. With
estimateFallbackenabled (off by default), usage-less calls are priced with the token-meter's fixed heuristic and stampedestimated— reported and estimated figures are always shown separately. - Durable across restarts and sessions: entries persist in the plugin's
own SQLite file (
node:sqlite, WAL mode) at$DSH_HOME/storages/usage-ledger.sqlite. No storage-hub dependency, so the ledger works identically in web, headless, and TUI profiles. If the store cannot open, the ledger degrades to an in-process record. usage_statsagent tool — the model can answer "how many tokens did we use this month?" from the same durable ledger (monospace text report).- 数据与统计 settings panel — a dashboard section in the web client's
Settings (App GUI): a time-range toggle (last 7d / last 30d) with a manual
refresh button, six summary cards (tokens used, sessions, calls, active
days, current streak, top model with its share), a GitHub-style activity
heatmap (last 53 weeks, cells shaded by daily tokens), a daily token
trend chart with stacked per-model bars, and a model-usage share donut
(per-model tokens + percentage, long tail folded into Other). Zero harness
changes: the panel
registers into the open
settings.sectionslot and pulls aggregates over the plugin's own loopback RPC channel.
Package layout
cordis.patch.yml bundle patch: two rows (usage-ledger, usage-ledger-tool)
lib/ host half (plain ESM, no build) + the prebuilt client bundle
index.js UsageLedgerService: capture, store, RPC channel
rpc.js pure payload handling for the /usage-ledger channel
tool.js the usage_stats agent tool (host)
client.js(.map) browser half, built from src/client (see Build)
src/client/ browser-half sources (TS/TSX + CSS Modules)
tsdown.config.ts client-bundle build (mirrors the harness preset)
test/smoke.mjs standalone smoke test for the pure modules
How the two halves load
- The bundle patch inserts two rows.
usage-ledger(bare package name) carries the host half — and is exactly the loader entry the harness client-module system scans: becausepackage.jsondeclaresdsh.client: { platform: 'web', inject: [...] }and exports"./client" → ./lib/client.js, the prebuilt browser bundle joinswindow.__DSH_BOOT__automatically and is served at/plugins/dsh-usage-ledger/client.js. usage-ledger-tool(subpath row) mounts theusage_statstool; delete that row to hide the tool from agents — the ledger and the settings panel are unaffected.- The browser half's
apply(ctx)registers itszh/endictionaries and one entry in thesettings.sectionlist slot (idusage, label 数据与统计), carrying the section's own line-chart nav glyph through theiconregistration option. The settings shell renders a registrant-suppliediconahead of its id→glyph map, so the icon ships with the plugin instead of living in the shell. - Data path: the host half (when a
connectionservice exists — web profiles) registers a private RPC channel withctx.connection.rpc.handle('/usage-ledger', …, { authority: 'loopback' }); the panel calls it withctx.connection.rpc.call. The sole endpointdashboardtakes{ period }and returns dashboard aggregates only (the raw entry list never leaves the host). Headless/TUI profiles never register the channel and are otherwise unaffected.
Build
The host half ships as plain source (no build step). The browser half must be prebuilt before packing:
npm install # dev deps: tsdown, lightningcss, react (types/external)
npm run build # tsdown → lib/client.js + lib/client.js.map
npm test # standalone smoke test (pure modules, no harness needed)
npm pack # → dsh-usage-ledger-<version>.tgz
The tsdown config mirrors the harness preset
(packages/client/tsdown.client.ts): a closure-factory CJS artifact that
hands itself to window.__ModuleLoader__.load({ id, factory }), externals
limited to the frozen platform-module table (react and friends), and CSS
Modules compiled by lightningcss into injected <style data-plugin> tags.
Install
dsh plugin --profile web add ./dsh-usage-ledger-<version>.tgz
# optional: also for one-shot runs
dsh plugin --profile headless add ./dsh-usage-ledger-<version>.tgz
The bundle declares dsh.bundle.patch, so dsh appends it to the profile's
bundle list automatically. Verify with dsh --profile web --dump-config,
then restart the running app — host plugins and client modules load at
boot.
The settings nav icon (为什么别人装上也应显示折线图)
The 「数据与统计」nav glyph is registered by the plugin itself: the
settings.section entry carries icon (a line-chart element, see
src/client/index.ts). Whether that glyph actually renders depends on the
harness shell:
-
Harness that supports the
iconoption (upstream feature, seedocs/settings-section-icon-upstream.md) — the shell renders the registrant's icon ahead of its id map, so every install shows the line-chart glyph automatically. This is the durable fix: submit that small change upstream so a DSH release ships it; then nothing else is needed on any machine. -
Older prebuilt harness (e.g. 0.1.0-rc.6) — the shell maps icons by
idand drops unknown registration options, so the gear shows. For those installs the stopgap is a one-time shell patch:node node_modules/dsh-usage-ledger/scripts/patch-settings-icon.mjsIt inserts the
usage→ line-chart branch into the installed shell'snavIcon()(idempotent; a marker makes re-runs a no-op). It is a stopgap: it edits the installed harness's own files, depends on the harness's bundle shape, and is lost on harness updates — prefer shipping the upstream feature over relying on it.
Usage
In the App client: Settings → 数据与统计 — a usage dashboard with a time-range toggle (last 7d / last 30d), summary cards (tokens, sessions, calls, active days, current streak, top model), an activity heatmap, a daily token trend stacked by model, and a model-usage share donut (per-model tokens + percentage).
From a conversation, the usage_stats agent tool answers questions directly:
"这个月用了多少 token?" → this-month totals + by-model report
"最近 7 天按天看看用量" → last 7 days, daily rows
"按提供方统计一下" → by-provider breakdown
Tool parameters: period (this-month | today | 7d | 30d | Nd | YYYY-MM |
YYYY-MM..YYYY-MM | all) and by (model | provider | day | session).
Configuration
Edit the usage-ledger row's config in a patch layer — for example the
profile's cordis.patch.yml at $DSH_HOME/profiles/web/cordis.patch.yml:
- id: usage-ledger
config:
estimateFallback: false # heuristic tokens for calls without provider usage
retentionDays: 0 # 0 = keep forever; N = drop entries older than N days
flushIntervalMs: 5000 # durability latency for buffered entries
flushEveryEntries: 32 # flush early once this many entries are buffered
maxMemoryEntries: 200000 # in-memory cap when the store cannot open
How it works
- The
usage-ledgerrow loads aService(ctx.usageLedger) that registers on thellm/streamwaterfall. Every model call in the process crosses it; the wrapper passes chunks through untouched and records an entry when the stream reports ausagechunk with at least one nonzero token count. Failed calls (no usage chunk, or an all-zero usage from the error path) never inflate the ledger. The finish chunk'sreplayStateis deliberately ignored: it is provenance metadata present on every pi-ai completion, not a replay signal. Only the innermost call of a delegation chain is recorded. A delegating wrapper provider (modlens'(modlens vision)facade, ...) forwards to a real upstream by re-enteringctx.llm.stream(...), so the same physical call crosses this waterfall twice; the ledger tracks nesting on the async stack and records the inner (real) call once, skipping the facade — seelib/nesting.js. - At load the service opens (or creates) its SQLite database at
$DSH_HOME/storages/usage-ledger.sqliteand loads stored entries. Entries buffer in memory and flush on a timer, on a batch threshold, and on shutdown. A failed flush keeps the batch buffered and retries — no silent loss. - The
usage_statstool aggregates the durable + pending entries for the requested period. The settings panel does the same over the/usage-ledgerRPC channel.
清理历史重复记录(一次性)
修复前经 modlens (modlens vision) 包装的调用会在账本里记两笔(门面 + 真实上游)。
脚本会删掉门面记录、只保留真实上游那笔,并在删除前自动备份:
node scripts/dedupe-modlens.mjs --dry-run # 先预览,不改动
node scripts/dedupe-modlens.mjs # 实际清理(建议先退出 DSH)
Requirements
- Node ≥ 22.5 at runtime (
node:sqlite; every harness profile already carries annode:sqliteconsumer, so the runtime is guaranteed). - A harness whose base layer exposes the
llm/streamwaterfall and thetokenMeterservice (0.1.0-rc.5-era releases). - The settings panel needs a web profile (the App client); other profiles simply skip the browser half and the RPC channel.
- The harness packages the plugin imports (
@deepseek-ai/cordis,@deepseek-ai/schemastery,@deepseek-ai/dsh-tools) resolve through the profile's node_modules fallback links, which the harness heals at boot.
Limitations
- Calls made in worker threads or separate processes (e.g. workflow
worker threads, other
dshinstances) are outside this process'sllm/streamwaterfall. One host process = one ledger file; multiple harness instances should use separate$DSH_HOMEs or share the ledger via a network store. - All-zero usage calls (error-path completions) are not recorded, and query-time filtering drops any legacy zero rows, so call counts and token totals always agree on what is billable.
- Estimates are heuristics (chars/4 density), never provider numbers; they
stay marked
estimatedin every surface. - The Settings nav entry registers its own line-chart glyph through the
iconoption on thesettings.sectionregistration, so the icon is owned by the plugin (it ships in the tarball). The harness shell must render a registrant-suppliediconahead of its id map — the small shell featuresettings.sectiongains once theiconslot option and nav rendering are merged upstream; shells without it fall back to the gear glyph. - This is the first third-party
dsh.clientpackage: the scan-over-all- entries mechanism is verified against the harness sources, but expect to be off the beaten path.