dsh-cost-ledger
Cross-session persistent cost ledger for DeepSeek Harness. Auto-logs every LLM token-usage event to SQLite and exposes
record_cost/query_cost/set_budgetagent tools. Built-in DeepSeek pricing, overridable via plugin config. Install-and-go, no extra wiring.
Status
Phase 1 (host side) — complete & live-verified in DSH 0.1.0-rc.6 (last verified 2026-08-13). 28 data-layer checks pass (pnpm selftest); the plugin loads and runs in a real dsh web profile.
The token-usage source and tool registration API are confirmed from DSH 0.1.0-rc.6 source and wired for real:
| Integration | API | Status |
|---|---|---|
| Token usage capture | ctx.on('llm/stream', (options, next) => AsyncIterable<StreamChunk>) — Cordis waterfall around every model call |
✅ wired |
| Tool registration | ctx.tools.register(ToolDefinition) — real { name, description, parameters, output:{schema,render}, execute(args, exec) } |
✅ wired |
| Persistent store | SQLite via better-sqlite3 (prebuilt win32 binary, no compile) |
✅ |
| Pricing | DeepSeek official (CNY/1M tok), cache-aware; config-overridable | ✅ |
Live load in dsh web |
installed via dsh plugin --profile web add ., host loads the bundle, apply() runs, SQLite opens |
✅ verified |
| End-to-end token capture | dsh --profile headless "..." → real LLM call → llm/stream listener fires → usage chunk harvested → SQLite row written |
✅ verified |
Live-verified: a one-shot dsh --profile headless "reply with exactly: hi" produced real rows in ledger.db ({model:"glm-5-2", inputTokens, outputTokens, cacheReadTokens}) — the full llm/stream → usage-chunk → SQLite pipeline works against the real host.
Module loading uses .ts extension specifiers (./store.ts) — the Cordis loader rewrites these and loads TypeScript directly, so no build step is needed; the plugin runs from source after install.
Two issues found & fixed during live integration:
{ global: true }onctx.on('llm/stream')— without it the listener only sees calls from its own fiber; the agent loop dispatches from a different scope. (Mirrors the host's owninvariant.jsusage.)- No uninjected service access —
ctx.session/ctx.workspacethrow "cannot get property without inject" unless declared ininject. Session id now comes from thellm/streamevent'soptions.sessionIdinstead.
Phase 2 (WebUI dashboard panel) — researched, not implemented. See Phase 2.
What it does
- Auto-logs token usage: subscribes to the
llm/streamwaterfall and writes{timestamp, session, project, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cost}per real provider call (including retries). - Computes cost: built-in DeepSeek pricing (
deepseek-chat/deepseek-reasoner, CNY per 1M tokens).inputTokensis uncached input; billed input =input + cacheRead + cacheWrite, each priced at its own rate. Override or add models via config. - Three agent tools:
record_cost— manually record a usage entry (backfill/test).query_cost— total spend + per-model breakdown within a range (today/week/month/all/ISO); reports current daily-budget status.set_budget— create/update/remove a spending budget (daily/weekly/monthly/neverwindow, label-scoped:daily,model:<name>,project:<name>).
Install (community / standard)
dsh plugin --profile web add <path-or-package>
The host discovers the plugin from package.json (dsh.bundle.patch) — no absolute path, no manual wiring. The prepare script builds lib/ from source, so a git checkout installs self-contained. Restart dsh web after install.
Git install note: pnpm ≥10 blocks a git dependency's
preparescript until allowed. If the firstaddfails, copy the exact package key pnpm printed into the profile'spnpm-workspace.yaml(allowBuilds: dsh-cost-ledger: true) and re-run — exactly as the official docs describe.
Local development
pnpm install
pnpm selftest # verify the data layer (28 checks)
pnpm typecheck
pnpm build # build host (lib/*.js) + client (lib/client.js)
# load into a running dsh web via the patch (absolute path, this machine):
dsh web --patch ./cordis.patch.yml
Pricing — built-in official rates
Built-in model pricing (CNY per 1M tokens, from DeepSeek's official pricing page, snapshot 2026-08-14):
| Model | input (cache miss) | cacheRead (cache hit) | output | cacheWrite |
|---|---|---|---|---|
deepseek-v4-flash |
1 | 0.02 | 2 | 1 |
deepseek-v4-pro |
3 | 0.025 | 6 | 3 |
deepseek-chat (V3) |
2 | 0.5 | 8 | 2 |
deepseek-reasoner (R1) |
4 | 1 | 16 | 4 |
glm-5-2 |
0 | 0 | 0 | 0 |
Key billing facts (DeepSeek V4 model):
cacheRead(cache hit) is far cheaper thaninput(cache miss) — typically 30–50× cheaper. The plugin reflects this; earlier versions hadcacheReadhigher thaninput, which produced wildly inflated costs. If you have ledger data recorded under the old table, totals are recomputed at query time from the current pricing, so simply updating prices fixes displayed totals retroactively.- DeepSeek V4 publishes only cache-hit / cache-miss / output. There is no separate cache-write line; the plugin bills cache writes at the input (cache-miss) rate, matching DeepSeek's effective model.
glm-5-2(Zhipu) defaults to 0 — override in the dashboard ⚙️设置 tab or config.
Unknown models record tokens only (cost = 0) until you set a price. Override anytime via the ⚙️设置 tab in the dashboard or the WebUI settings card — changes apply to new calls and retroactively to queried totals immediately (costs are recomputed from token sums at query time).
Config
Edited in the dashboard ⚙️设置 tab (runtime, not persisted across restart) or under the WebUI plugin config card (durable) / cordis.patch.yml:
config:
dbPath: 'dsh-cost-ledger/ledger.db' # relative to DSH cwd
defaultDailyBudget: 0 # CNY; 0 = no limit
pricing:
deepseek-chat: # override or add any model
input: 2
output: 8
cacheRead: 0.5
cacheWrite: 8
Architecture
src/
pricing.ts built-in DeepSeek price table + computeCost()
store.ts SQLite ledger: insert + summaryByModel + spentSince + budgets
tools.ts three ToolDefs (pure handlers over store + config)
config.ts Schema (schemastery) config
index.ts apply(ctx): llm/stream listener + registerTools + cleanup
client/ Phase 2 WebUI entry (stub)
cordis.patch.yml local-dev profile patch (absolute path)
scripts/selftest.ts standalone data-layer verification
Token capture (confirmed API)
llm/stream is a Cordis waterfall wrapping every streaming model call (retries included). The listener transparently forwards the underlying stream while harvesting the usage chunk:
ctx.on('llm/stream', (options, next) => (async function* () {
let usage: TokenUsage | undefined
for await (const chunk of next()) {
if (chunk.type === 'usage') usage = chunk.usage
yield chunk
}
if (usage) record(options, usage) // persist one row
})())
Key billing facts (from official TokenUsage):
inputTokens= uncached input only. Billed input =input + cacheRead + cacheWrite.reasoningTokensis already included inoutputTokens— never double-count.usageis not guaranteed (early abort/error) — absence is logged at debug.
The TokenUsage / StreamChunk / GenerateOptions types live in @deepseek-ai/dsh-llm (an unpublished host-injected package). This plugin declares minimal local types to typecheck standalone; swap for a real import type once the host package is resolvable.
Phase 2 (WebUI panel)
Researched against the dsh-web-ui reference. Key finding: DSH exposes no general-purpose sidebar-panel slot for external plugins. sidebar.workspaces / sidebar.settings are single-occupant and taken by the shell. Options:
- DOM-level bypass (the
dsh-task-board/dsh-sshapproach):MutationObserver+createRootinto[data-pane="sidebar"]/[data-pane="conversation"]. Most flexible. - Settings card via
web-ui.plugin.item(lightest — a summary card under Settings → Plugins). - No chart-library precedent in the ecosystem. Recommended: inline SVG, or vendor
recharts(strict CSP, no CDN).
Compatibility
- DSH version: verified against
0.1.0-rc.6(the API surface —ctx.on('llm/stream'),ctx.tools.register,ctx.inject,agentDefaultModel— is confirmed from DSH 0.1.0-rc.6 source). - Last verified: 2026-08-13.
- Profiles: loads under both
dsh web(full UI + HTTP API + dashboard) anddsh --profile headless(token capture + tools only; HTTP API and dashboard are web-profile-only and silently skipped). - OS: the SQLite backend ships a prebuilt win32 binary via
better-sqlite3; other platforms build from source at install time (requires a C++ toolchain). Verified on Windows 11 / Node 22. - Pre-release caveat: DSH is in developer preview with expected compatibility-breaking changes. The token-usage source and tool registration API are confirmed against rc.6; a future mainline may rename them, which would require a plugin update.
Uninstall
# Remove the bundle from the profile, then restart dsh web
dsh plugin --profile web remove dsh-cost-ledger
This removes the bundle from the profile's dsh.profile.bundles list and unlinks the package. The ledger database (dsh-cost-ledger/ledger.db) and any config overrides in cordis.patch.yml are not removed automatically — delete them manually if you want a clean sweep:
Remove-Item -Recurse -Force dsh-cost-ledger # the data dir created under the DSH cwd
To disable temporarily without removing: comment out dsh-cost-ledger in the profile's cordis.yml bundles list and restart.
Quick start
# 1. Install into your web profile (see Install above)
dsh plugin --profile web add .
# 2. Restart dsh web, then just use the agent normally
dsh web
Every model call is now auto-logged. After a few prompts, ask the agent to query spend:
> 查一下今天花了多少钱(query_cost today)
Or open the dashboard panel in the Web UI (the cost-ledger tab) to see a live summary and per-model breakdown — the panel registers the HTTP API backing it; the in-WebUI panel UI itself is Phase 2 (currently a stub entry).
Permissions & data
- Files written: one SQLite database at
dsh-cost-ledger/ledger.db(path configurable viadbPath, relative to the DSH working directory) plus its WAL/SHM sidecar files. Nothing else touches the filesystem. - Network: none. The plugin makes no outbound network calls. (The
parse-pricesendpoint calls the host's ownctx.llm.stream()— it reuses the model call path you already configured, not a new connection.) - Credentials: none read, stored, or transmitted. The plugin never touches your API keys; it only reads token counts and model names that the host already emits on the
llm/streamevent. - Data logged per model call:
{timestamp, sessionId, project, model, provider, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cost}. No prompt content or completions are ever stored — only aggregate token counts and metadata.sessionIdis taken from thellm/streamevent when the host supplies it;projectis a best-effort label derived from the workspace cwd basename (null when unavailable). - HTTP API: when running under
dsh web, the plugin registers JSON endpoints under/api/cost-ledger/*. They bind to the host's web server and are reachable from the same origin as the Web UI. Thecleanup,config, andparse-pricesendpoints are POST write endpoints intended for the dashboard UI; they reject cross-origin writes (require aContent-Type: application/jsonrequest and same-originOrigin/Refererwhen present) so a hostile webpage cannot wipe the ledger or flip pricing via a simple form POST.
Troubleshooting
Cannot find module './store.ts'/ load error after a git pull: the Cordis loader resolves.tsspecifiers directly, but a stalelib/can interfere. Runpnpm build(orpnpm installto triggerprepare) and restartdsh web.better-sqlite3install fails on macOS/Linux: the prebuilt binary is win32-only; other platforms compile from source. Install a C++ toolchain (build-essential/ Xcode CLT) and re-runpnpm install.- No cost rows appear: confirm the model call actually emitted a
usagechunk (early aborts/errors may not). Token capture is best-effort — absent usage is logged at debug level. Also confirmdbPathis writable. parse-pricesreturns 503 / "default model not configured": theagentDefaultModelservice hadn't resolved yet, or no default model is set in Agent settings. Set a default model and retry.- pnpm blocks the
preparescript on git install: addallowBuilds: dsh-cost-ledger: trueto the profile'spnpm-workspace.yamland re-rundsh plugin add. - Logs: plugin diagnostics go to the host log (
dsh webconsole /dsh-run.log); the SQLite ledger itself is the system of record for cost data. - Rollback:
dsh plugin --profile web remove dsh-cost-ledger+ restart; optionally delete thedsh-cost-ledger/data dir (see Uninstall).
Development
pnpm install
pnpm selftest # verify the data layer (28 checks)
pnpm typecheck
pnpm build # build host (lib/*.js) + client (lib/client.js)
# load into a running dsh web via the patch (absolute path, this machine):
dsh web --patch ./cordis.patch.yml
react / react-dom and the @deepseek-ai/dsh-client-* runtime packages are external to the client bundle (resolved by the host's module loader at runtime), so they are correctly declared as devDependencies / peerDependencies, not dependencies. The only runtime dependency is better-sqlite3.
License & security
MIT — see LICENSE.
Security reporting: this plugin has no network surface and stores no credentials, but if you find a vulnerability (e.g. unsafe SQL handling, path traversal via dbPath), please do not open a public issue. Report it privately via GitHub Security Advisories (Security → Report a vulnerability on the repo). All SQL uses parameterized statements (@named bind params) and all filesystem access is confined to the configured dbPath.
No comments yet. Be the first to write one.