← Back to home@dsh-plugin-db

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 the connection and credentials services, 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_KEY credential 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
  1. 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:

  1. The signed-in DeepSeek account through ctx.remote.account.getBalance, for routes whose provider starts with deepseek.
  2. 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 through ctx.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

HalfFileRole
Hostindex.jsOne sessionProjections unit (costMeter) that prices provider-reported usage, plus one balance route on Connection's /api channel
Clientclient.jsOne 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 only schema.parse, so the schemas here are small hand-written normalizers with no schema-library dependency.
  • The SessionEvent envelope and payloads: request/header (route), assistant/message / assistant/attempt (usage, from data.usage or the last usage chunk of the compact stream), llm/retry-started (retry replacement slot) and compaction/summary (its own route and usage). Unrecognized events return the same state reference, which the registry's Object.is gate turns into zero downstream work.
  • ctx.slots.inject(key, () => ctx.slots.register(options, Component)) and the slot's standard useProjection prop.
  • ctx.locale.register(ns, dicts) with locale: ns on the slot registration.
  • The row Config is a Standard Schema object (~standard.validate), which is exactly what Cordis resolveConfig reads, so no schemastery import is needed. (The Config inspector reports such a schema as unsupported, 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 /api channel (/api/dsh-cost-meter.balance) for an API-key route. The route registers on a separate optional child fiber that injects connection and credentials, and the Client reaches it with the page's plain fetch — the same pattern shipped UI plugins use (e.g. /api/present.open in dsh-client-ui-deliverables, /api/session.export in dsh-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.handle is not used: it mounts its route on the caller's context (owner.webServer.register(route)), so it dies unless that fiber also declares webServer, whereas connection.fetch.register only writes into Connection's own route table. verify.mjs pins the dependency list and the route shape.

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.