dsh-cost-meter
Session spend for the DSH Web client: a stats-line pill that prices the durable tokenUsage projection with user-configured per-bucket unit prices.
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 10, 2026
- Updated
- Sep 10, 2026
Introduction
description: "Shows the current session's spend to the right of the token-usage pill in the DSH Web composer stats line, priced from user-configured per-bucket unit prices." kind: "package-reference"
dsh-cost-meter
English | 中文
Shows this session's spend to the right of the token-usage pill in the bottom stats line, with a per-bucket breakdown on click and unit prices you configure yourself.
It is a pure consumer of @deepseek-ai/dsh-token-meter: it does not meter anything, never touches a model
request, and never writes the session log. It only multiplies the existing tokenUsage projection by the unit
prices you enter.
Once installed, that line reads:
⬤ 4 turns 68 steps · 191 tok/s ⬤ 25.7M tok · Cache hit 99% ⬤ ¥13.974
All three pills open a detail panel (the first two are identical to ui-chat's originals; the third is the per-bucket bill).
Table of Contents
- Why the host token accounting needs no change
- Install
- Configure prices
- Uninstall
- How it works
- Compatibility and versioning
- Changelog
- Known limitations
- Development
- Verification status
- License
Why the host token accounting needs no change
The DSH stats line (4 turns 68 steps · 191 tok/s · 5.9M tok · cache hit 98%) is rendered by
StatsPills in @deepseek-ai/dsh-client-ui-chat, which registers into the list slot
conversation.composer.dock and does exactly one thing: read two host projections.
| On screen | Source |
|---|---|
4 turns 68 steps, 191 tok/s | the sessionStats projection (whole-log fold, dsh-session-stats) |
5.9M tok, cache hit 98% | the tokenUsage projection (dsh-token-meter) |
The tokenUsage wire value is exactly four disjoint billing buckets:
{ uncachedInputTokens, cacheReadTokens, cacheWriteTokens, outputTokens }
where uncachedInputTokens = usage.inputTokens (cache-miss prompt input) and cache read/write stay separate.
So spend is one multiply-add:
cost = (uncached·P_in + cacheRead·P_read + cacheWrite·P_write + output·P_out) / price denominator
dsh-llm's model metadata (LlmModelInfo) carries no price field at all, and neither llm-pi-ai nor a
router catalog supplies prices — which is why the unit prices can only be user-configured, and why that is the
only thing this plugin adds.
Install
Pick one of the two paths, do not mix them: dsh plugin add goes through pnpm, and any later
pnpm install prunes a manually copied directory as an extraneous package.
A. Official path (pnpm / GitHub)
dsh plugin --profile web add github:Nanako660/dsh-cost-meter
That forwards to pnpm inside the profile directory, so $DSH_HOME\profiles\web\package.json gains a
dependency and the package (plus its @deepseek-ai/schemastery dependency) lands under that profile's
node_modules, where the loader resolves it from.
Installing the package does not compose it — pnpm never edits the composition. Append this block to
$DSH_HOME\profiles\web\cordis.patch.yml:
# >>> dsh-cost-meter
- insert:
- id: cost-meter
name: dsh-cost-meter
config:
currency: ¥
unit: per-million
input: 0
cacheRead: 0
cacheWrite: 0
output: 0
decimals: 3
showSavings: true
# <<< dsh-cost-meter
The whole config: block may be deleted — it is identical to the schema defaults and is written out only to
document the knobs.
B. Offline / scripted path
pwsh -File install.ps1
For a machine with no npm network, or to install straight from a checkout. The script does two things, both idempotent:
- copies the package to
$DSH_HOME\profiles\web\node_modules\dsh-cost-meter(the profile directory is the anchor the loader resolves bare specifiers from, and the profile's ownnode_modulesis the first stop); - inserts the
- insert:block above into$DSH_HOME\profiles\web\cordis.patch.yml, backing the file up tocordis.patch.yml.bak-<timestamp>first.
Common to both paths
The web profile's patchReload: live makes the host hot-apply that row, so no restart is needed; the
browser needs one page refresh to fetch the new client bundle.
Verify
# 1. Is the row in the composed tree? (no warning, and "- id: cost-meter" present, means yes)
dsh --profile web --dump-config | Select-String cost-meter
# 2. Can the profile resolve both halves, import the host half, and validate the row config?
node test\verify-install.mjs
Note that http://127.0.0.1:3080/plugins/... cannot be used to verify: requests without the credential
from the launch URL always get a 404, including for built-in plugin bundles.
Configure prices
Settings → Plugins → Plugin configuration, card "花费金额(估算)" (estimated spend):
| Field | Meaning |
|---|---|
| 未命中输入 (cache-miss input) | unit price of uncachedInputTokens |
| 缓存读取 (cache read) | unit price of cacheReadTokens (the hit price) |
| 缓存写入 (cache write) | unit price of cacheWriteTokens; always 0 on DeepSeek, leave it at 0 |
| 输出 (output) | unit price of outputTokens |
| 货币符号 (currency) | display prefix, default ¥ |
| 计价单位 (unit) | per million / per thousand tokens |
| 金额小数位 (decimals) | 0–6, default 3 |
| 同时显示缓存省下的金额 | adds a counterfactual saving row to the spend panel (cacheRead × (P_in − P_read)) |
Prices persist in the cost-meter: section of ~/.dsh/settings.yaml and "重置为默认" (reset to defaults)
clears the user layer whenever you want.
When all four prices are 0 the pill reads "花费未配置" (price not configured) — with a hover hint pointing at the settings page — rather than ¥0.000. Not inventing an amount is deliberate.
Uninstall
# A. official path
dsh plugin --profile web remove dsh-cost-meter
# B. scripted path (also removes the marked block and the package directory)
pwsh -File install.ps1 -Uninstall
Either way, delete the marked block from the patch file by hand, then refresh the page. Removing that block immediately restores ui-chat's own stats line.
How it works
| Path | Role |
|---|---|
lib/index.js | the only host-side responsibility: registers the cost-meter settings namespace through settings.installSection (optional attach, falling back to the composition entry when no settings provider is mounted) |
lib/client.js | the hand-written lazy-CJS client bundle: takes over the stats cell (three pills) plus the price card |
test/smoke.mjs | drives the real window.__ModuleLoader__ envelope as a smoke test (React is shimmed, because the deployment only installs a prebuilt frontend) |
test/verify-install.mjs | reproduces the loader's resolution after install: both halves resolve, the host half imports, the row config passes the schema |
Why the client bundle is hand-written: @deepseek-ai/dsh-client-modules serves every package that declares
dsh.client as its lib/client.js verbatim and evaluates it in the page, while the build preset that normally
emits that envelope (packages/client/tsdown.client.ts) is not a published package — so a plugin living outside
the DSH checkout writes the envelope itself. The factory may require the shell's platform seed table:
react, react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis,
@deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
@deepseek-ai/dsh-client-ui-primitives, and @deepseek-ai/dsh-client-ui-dockkit. All of those are baseline
requests, so dsh.client.external declares nothing.
The two registration points (both checked against the live slot protocol):
conversation.composer.dock list, session → needs an id; this plugin uses id:"stats" + priority:-1 to shadow that cell
settings.plugin.item keyed, root → needs a key (= the "cost-meter" settings namespace)
Prices are read through the client settings scope ctx.settingsScope.bind({ namespace: 'cost-meter' }) and
subscribed with uSES; tokenUsage and sessionStats arrive through the session-scoped slot's standard
useProjection prop. Both re-render immediately, so saving prices needs no refresh.
Why the cell is taken over instead of adding another pill
The stats line is not the conversation.composer.dock container — it is StatsPills' own root node
(dsh-client-ui-chat). The dock's parent is InputBar.root, styled
flex-direction:column; align-items:center, so one more dock entry would simply stack below the stats line
and could never appear to the right of the token pill.
The only supported mechanism for "right of the token pill" is a list slot's shadow registration:
register({ id: "stats", priority: -1 }), where the lowest priority renders (the slot core's own validation text
reads "register at a different priority to shadow it (lowest renders)"). That cell is therefore rendered by this
plugin, and the price of that is reproducing the two original pills and their panels:
TimePill(turns/steps + tok/s) and its "会话统计" panel;UsagePill(total tok + cache-hit share) and its "Token 用量" panel;- every formatter they use:
formatTokens/formatExactTokens/formatTokensPerSecond/formatCacheHitPercent(including the binary-search rounding that refuses to round a partial hit up to 100%).
Panel placement and dismissal reuse the platform seed's useAnchoredPosition / useDismissOnOutsidePointer
(with a local fallback when that export is missing), and the geometry constants and CSS are copied verbatim from
ui-chat's stat-dialog. The two pills' copy comes from the deployment's own dictionary through
ctx.locale.bind("chat"), so it follows the active language; a bundled Chinese copy is the fallback when no
locale service is mounted.
Because this plugin now owns that cell, the row is wrapped in a RowErrorBoundary: if the styled row throws, it
degrades to a plain-text stats line that re-reads the projections itself, so the stats line can never vanish
entirely.
What was reproduced, and its license, is documented in THIRD-PARTY-NOTICES.md.
Compatibility and versioning
This plugin's version is independent of DSH's, but it depends on DSH slot protocols, projection keys, and service names that are not stable APIs.
| dsh-cost-meter | DSH verified against | Notes |
|---|---|---|
0.1.0 | 0.1.5-rc.1 | first release; slot protocol, the tokenUsage/sessionStats projection keys, and the settingsScope contract were all checked against that version |
peerDependencies carries that range (@deepseek-ai/dsh-settings@^0.1.5-rc.1, @deepseek-ai/cordis@^4.0.2), so a
mismatch is visible at install time.
How the version moves:
- PATCH — documentation, comments, internal tidying; no behaviour change.
- MINOR — a new configuration field or new displayed content; existing configuration keeps working.
- MAJOR — anything that requires a different DSH version: a changed slot protocol, a renamed projection key, a renamed service. Those do not make the plugin error out; they make the pill silently disappear, which is exactly why they must be signalled as MAJOR.
0.1.0 is not a stable release: it takes over a cell of ui-chat, so an upstream change can invalidate this port
at any time. After upgrading DSH, re-run both commands under Verify. Per-release notes are in
CHANGELOG.md.
Known limitations
- The session-level aggregate has no route dimension: a session that switched provider/model mid-way bills
every token at one set of prices. A single-route session is unaffected; exact pricing would need a per-turn fold
(
deriveTurnTokenUsage, which carriesroutes). - Only provider-reported usage counts: a step that was interrupted or reported no usage is not billed here, so this is a ledger estimate rather than a reconciliation against an invoice.
cacheWriteTokensmeans different things per provider: always 0 on DeepSeek, while Anthropic-style cache writes are a separate premium tier that needs its own price.- No durable settings channel when the page is not loopback, which the card says explicitly; prices then only follow the composition entry.
- Upgrading DSH leaves this package alone: it lives in the profile, not in the DSH install directory — but if
the
0.1.xslot/service APIs move,lib/client.jsmust be updated with them. - This plugin renders the stats cell (shadow takeover). The upside is that the spend pill sits right of the
token pill and all three share one panel style and one exclusive open slot; the cost is that ui-chat's
StatsPillsis hidden, so upstream changes to those two pills (a new bucket, new copy, a new panel row) will not appear here automatically and must be mirrored intolib/client.js. To drop the takeover entirely, change the registration to{ name: "conversation.composer.dock", id: "cost", order: 10 }(droppingpriority): the spend pill moves to its own line below the stats line and nothing else changes. - The host reports live packages: DSH sends
{name, version}of every live loader package with each official API request for diagnostics, soname/versioninpackage.jsonmust be real and non-empty (malformed package metadata fails request preparation outright).
Development
npm run check # node --check both halves
npm test # client bundle: envelope, takeover registration, three pills, both ported panels, spend panel, every fallback
npm run test:install # after install: both halves resolve, host half imports, row config passes the schema
smoke.mjs covers: the bundle envelope and export surface, the takeover registration's id/priority/order, the
three pills and their order, the contents of both ported panels and the spend panel, four-bucket cost arithmetic,
an empty session rendering nothing, a missing projection seat rendering nothing, the unconfigured-price state, the
error-boundary fallback, and degraded rendering with react-dom/primitives absent.
verify-install.mjs reproduces the loader's resolution (createRequire anchored at the profile manifest, matching
cordis-plugin-loader, which uses the profile directory as parentURL) and confirms that dsh-cost-meter,
dsh-cost-meter/client, and @deepseek-ai/schemastery all resolve from the profile.
Three easy traps (all handled in code)
- A new row in
cordis.patch.ymlmust be wrapped in- insert:. A bare- id: xis a patch targeting an existing row of that id; when none exists it only printspatch: entry "x" not foundand skips — silently ineffective. The semantics come fromapplyEntryPatchesindsh-app-boot. - Emptying the patch file leaves
null. A file with only comments parses asnull, not as an empty list, so-Uninstallrestores the[]sentinel when it finds the document has no entries left. - Same id + same priority in a list slot throws rather than silently replacing:
already has an entry with id "stats" at priority 0 — register at a different priority to shadow it. A takeover must state a differentpriorityexplicitly.
Verification status
Verified locally:
- the row reaches the composed tree:
dsh --profile web --dump-configreports no warning andcost-meterappears under the user patch layer; - the profile resolves both halves and imports the host half (
apply/Config/ the namespace constant are all present), and the row config passes the schema; - the smoke test is green (takeover registration, the order of the three pills, the contents of both ported panels plus the spend panel, every fallback state);
- the installed
lib/client.jsandlib/index.jsare byte-identical to the versions in this repository that passed the smoke test (SHA256 comparison).
Not verified (needs a browser): that the running page really draws the spend pill to the right of the token pill, that all three panels open, and that the settings card saves. If that line looks wrong after a refresh, roll back by deleting the marked block as described under Uninstall — ui-chat's own stats line returns immediately.
License
MIT, Copyright (c) 2026 Nanako660. See LICENSE.
This package reproduces presentation code from @deepseek-ai/dsh-client-ui-chat (also MIT); the provenance and
notice are in THIRD-PARTY-NOTICES.md.