dsh-cost-meter
Session cost and remaining provider balance below the DeepSeek Harness Web composer.
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 16, 2026
- Updated
- Oct 16, 2026
Introduction
dsh-cost-meter
A DeepSeek Harness plugin that shows one ambient line below the Web composer: the estimated model cost of the current session, and the remaining balance of the provider route in use.
[ Composer card ]
Cost $0.0142 · Balance $12.50
Hovering a pill shows its breakdown: token buckets per model route for the cost, and topped-up versus granted credit for the balance.
Requirements
- DeepSeek Harness with the Web app (
dsh web). The balance read needs theconnectionandcredentialsservices, which the shipped Web composition provides. - For the balance pill, a wallet endpoint plus a stored credential for it; the
built-in default is DeepSeek's Platform balance endpoint with a
DEEPSEEK_API_KEYcredential reference. Cost needs nothing: prices ship in the row config.
Install
# 1. install the package into a profile
dsh plugin --profile web add github:dsh-plugin-db/dsh-cost-meter
# …or from a local checkout
dsh plugin --profile web add link:/path/to/dsh-cost-meter
- Enable the bundle: Settings → Plugins → Installed → dsh-cost-meter → on.
(An agent driving the plugin manager does both steps at once with
install_bundle.)
The package ships a bundle patch, so enabling it inserts the row with built-in defaults. Cost works immediately; the balance pill appears once a wallet source answers. The credential reference is resolved on the Host, so the secret never reaches the browser.
Configuration
Every value lives in the row config, so it survives DSH upgrades in the
profile's own cordis.patch.yml (highest-priority layer):
- id: dsh-cost-meter
name: 'dsh-cost-meter'
config:
prices:
- provider: '*' # '*' matches any route; a literal provider wins over '*'
model: deepseek-flash
currency: USD
input: 0.3 # per 1M cache-miss input tokens
output: 1.2 # per 1M output tokens
cacheRead: 0.006 # per 1M cache-hit input tokens
cacheWrite: 0.3 # optional; defaults to `input`
offPeak: # optional; used outside `peakWindows`
input: 0.15
output: 0.6
cacheRead: 0.003
cacheWrite: 0.15
peakWindows: # optional; defaults to DeepSeek's published windows
- { startHourUtc: 1, endHourUtc: 4, weekdaysOnly: true }
- { startHourUtc: 6, endHourUtc: 10, weekdaysOnly: true }
balances: # optional; provider route → wallet endpoint
- provider: deepseek-official
url: https://api.deepseek.com/user/balance
credential: DEEPSEEK_API_KEY # credential reference resolved in the Host
auth: bearer # or x-api-key
timeoutMs: 10000
Lookup specificity is provider+model → provider+* → *+model → *+*. A
route with no matching row still shows its token count, and the cost pill is
prefixed with "at least" while unpriced calls exist, so an estimate never
pretends to be complete.
Shipped defaults are DeepSeek's published list prices for deepseek-flash and
deepseek-v4-pro (per 1M tokens, USD) with their peak/off-peak windows. Prices
change; edit them in the profile patch.
Balance
The Client reads the balance in two steps, and reports what it finds:
- The signed-in DeepSeek account through
ctx.remote.account.getBalance, for routes whose provider starts withdeepseek. - This plugin's Host route, asked for the exact route the session is spending
on. The Host matches that provider against
balances(an exact provider wins over*), resolves the named credential throughctx.credentials, calls the endpoint, and answers with the wallet amounts only.
A route the Host prices no wallet for (unsupported-provider) shows nothing, so
the pill never appears for a provider that simply has no balances row. Every
other outcome is visible: the amount when the wallets are read, and Balance —
with the reason in its tooltip when the credential is missing, the endpoint
fails, or the route does not answer at all. That reason carries the HTTP status
and the body, which separates the two silent failures: a browser sees
404 not found when this build's route is not mounted, and 401 unauthorized
when the request never reached it.
The key never reaches the browser, and the endpoint is ordinary configuration:
another provider whose API returns a wallet needs a new balances row plus a
parser kind in index.js. Amounts are re-read once a minute; each pill carries
its own currency, so a USD cost and a CNY balance never get conflated.
How it is built
| Half | File | Role |
|---|---|---|
| Host | index.js | One sessionProjections unit (costMeter) that prices provider-reported usage, plus one balance route on Connection's /api channel |
| Client | client.js | One conversation.composer.dock entry that renders the wire value and reads the balance |
The Host folds the durable Session log and publishes one client-visible
projection; the Client renders it through the useProjection slot prop. Both
halves were written against documented seams only:
ctx.sessionProjections.register({key, stateVersion, stateSchema, init, apply, wire})— projection units; the registry calls onlyschema.parse, so the schemas here are small hand-written normalizers with no schema-library dependency.- The
SessionEventenvelope and payloads:request/header(route),assistant/message/assistant/attempt(usage, fromdata.usageor the lastusagechunk of the compact stream),llm/retry-started(retry replacement slot) andcompaction/summary(its own route and usage). Unrecognized events return the same state reference, which the registry'sObject.isgate turns into zero downstream work. ctx.slots.inject(key, () => ctx.slots.register(options, Component))and the slot's standarduseProjectionprop.ctx.locale.register(ns, dicts)withlocale: nson the slot registration.- The row
Configis a Standard Schema object (~standard.validate), which is exactly what CordisresolveConfigreads, so noschemasteryimport is needed. (The Config inspector reports such a schema asunsupported, so the Plugins page draws no generated form; edit the values in YAML instead.) - Balances come from two documented read paths: the generated Client namespace
ctx.remote.account.getBalance(metadata)for a signed-in account, and this plugin's own exact Fetch route on Connection's/apichannel (/api/dsh-cost-meter.balance) for an API-key route. The route registers on a separate optional child fiber that injectsconnectionandcredentials, and the Client reaches it with the page's plainfetch— the same pattern shipped UI plugins use (e.g./api/present.openindsh-client-ui-deliverables,/api/session.exportindsh-session-log-export), which needs no Client service to be resolved either. Two details are pinned because getting them wrong is silent:- An exact route must be one segment directly under
/api(assertFetchRoute→endpointFromPath('/api', path)), which is why the path is dotted rather than nested. connection.rpc.handleis not used: it mounts its route on the caller's context (owner.webServer.register(route)), so it dies unless that fiber also declareswebServer, whereasconnection.fetch.registeronly writes into Connection's own route table.verify.mjspins the dependency list and the route shape.
- An exact route must be one segment directly under
Both read paths are optional at runtime (ctx.get()), so the plugin still
activates and still shows cost in a composition without them. No Harness Client
package is imported, no DOM outside the component is written, no session event
is appended, and no credential ever leaves the Host process: the balance route
answers with wallet amounts only.
Verification
node verify.mjs runs 25 dependency-free checks with no browser and no running
profile: the Config schema, the balance route's dependency list and shape, the
fold's pricing and replacement rules, every balance-route outcome, and a
fake-React render of the Client half that proves the dock entry emits the cost
pill and the balance pill together, reports Balance — with the upstream reason
when the wallet cannot be read, reports an unmounted route with its HTTP status,
and stays silent for a route with no configured wallet. It stubs the framework
rather than importing it, so it fails only when this plugin's own use of a seam
changes.
The fold was cross-checked against the shipped tokenUsage projection over a
real session (identical bucket totals) and the peak/off-peak selection is
unit-checked. The upstream wallet was called once with a stored key and answered
HTTP 200 with the expected wallet payload.
The plugin's own route cannot be exercised from a shell: it sits behind
Connection's Host/Origin fence and browser-cookie authentication, which answer
401 for a registered and an unregistered /api path alike. What a shell can
prove is that a running Host loaded this build at all — see below.
Notes for plugin authors
A Host process imports its plugin modules once, at boot, and never re-reads
them: editing index.js while the harness runs changes nothing, and a "restart"
that does not actually replace the process (a second dsh web that cannot bind
the port, or a page refresh) keeps the old build serving. The Client half is the
exception — it is served per request under an mtime-derived revision, so a page
refresh always picks up client.js.
STATE_VERSION therefore doubles as the build generation. The session
projection checkpoint ($DSH_HOME/storages/session_projcache/sessions/<id>.json)
records that version per row, so ver is the one way to tell from outside which
build a running process actually loaded:
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['record']['rows']['costMeter']['ver'])" \
"$DSH_HOME/storages/session_projcache/sessions/<session-id>.json"
Generation 2 is the first whose balance read is the exact Fetch route.