dsh-plugin-db/dsh-cost-meter

dsh-cost-meter

Session cost and remaining provider balance below the DeepSeek Harness Web composer.

미분류cordiscost-trackingdeepseek-harness

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 of every subagent session it started, plus the remaining balance of the provider route in use.

[ Composer card                                 ]
      Cost $0.1306 · Balance $12.50

Hovering a pill shows its breakdown: token buckets per model route, how much of the total came from subagent sessions, per-request fees, and topped-up versus granted credit for the balance.

What the number includes

  • This session, priced from the usage the provider reported in its own log.
  • Every subagent session it delegated to, transitively. A subagent runs as a separate Session with its own log, so the parent's log names its children (subagent/catalog) but never their usage; the plugin reads each child's own projected cost instead — from the live Session while the app still holds it, from the child's durable checkpoint once it has settled, and by re-folding that child's own log where no checkpoint can serve it (see below).
  • Per-request fees for calls that are billed outside token usage, currently DeepSeek Search/Fetch requests at $0.75 per 1,000 successful requests. A request event records the request, not its usage, so this is a stated estimate.

If a child session cannot be read at all — no live Session and no checkpoint whose lifecycle matches — the plugin does not pretend it cost nothing. It marks the total as a lower bound (at least $0.1306) and says how many sessions were unreadable, rather than showing a number that looks exact.

Why a total can still differ from https://platform.deepseek.com's usage log: that page bills an account, so it also covers sessions this machine never ran and any other client using the same key. Auxiliary model calls whose usage is never recorded (session titles, and the DeepSeek-side search calls a search request can fan out to, up to maxUses per request) are billed by DeepSeek but are invisible to any log-based reader. The plugin therefore estimates from what the logs record, and labels that estimate as one.

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
    fees:                    # optional; Session events that are billed per request
      - event: web/deepseek-search-llm-request
        label: Search
        per1kRequests: 0.75  # DeepSeek Search/Fetch list price, USD
        currency: USD

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.

A fees row counts the Session events of that exact type and bills them per 1,000 requests, outside token pricing, which is how DeepSeek bills its Search and Fetch calls. The default row covers web/deepseek-search-llm-request, the event the Web app's DeepSeek search feature records for each search request. Set fees: [] to price tokens only.

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, and the Search/Fetch per-request fee. 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.jsTwo sessionProjections units — costMeter (prices provider-reported usage and per-request fees) and costMeterFamily (this session plus every session it delegated to) — plus one balance route on Connection's /api channel
Clientclient.jsOne conversation.composer.dock entry that renders the family wire value, falling back to the session value, asking the Host for a family read while the projection is still incomplete, and reading the balance

The Host folds the durable Session log and publishes two client-visible projections; the Client renders them 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), compaction/summary (its own route and usage), subagent/catalog (which child Sessions this one started, with the creation time a checkpoint identity needs) and web/*-llm-request (per-request fees). 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.)
  • Two exact Fetch routes live on Connection's /api channel: /api/dsh-cost-meter.balance and /api/dsh-cost-meter.family. The second one exists because a projection view is synchronous and a settled subagent Session can only be re-folded asynchronously; the Client asks for it while the projection still reports unresolved children, so an exact total appears within a second of the page loading rather than at that Session's next event. The read fills the cache the projection view reads, so the pushed value is complete from then on.
  • 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.

Reading a subagent's spend

A subagent is a separate Session, so the family unit has to read another session's projection. Three constraints shape how it does that, and each one is load-bearing:

  • A projection wire.view is invoked on every read, including the durable checkpoint path (viewCheckpoint) where no live Session exists, and the registry has no reentrancy guard. The family view therefore computes its own spend from its own state (the same fold, run twice) and never reads its own session's cells — only its children's.
  • A view must be synchronous, which rules out sessionQuery.observeSession — the only read that re-folds a cold Session's complete log. So a child is read live first, then from the durable checkpoint the cache wrote when it went cold (the same live-then-cached shape dsh-session-reference uses), and only then from an asynchronously filled cache. cachedSnapshot takes a header as its identity witness and matches version, createdAt, cwd and isSeeded; the parent's own header supplies all but createdAt, which subagent/catalog records as childCreatedAt.
  • The version gate is why the third source is unavoidable. A checkpoint written before a unit existed carries no row for it at all, and the registry drops rows whose stateVersion is not the registered one, so a settled Session that finished under an older build answers no checkpoint read — for any key. The plugin therefore folds such a child through sessionQuery.observeSession (asynchronous, so it cannot run inside the view), caches the result, and asks for that fold when a view misses. A settled Session's cost cannot change, so one fold per child is enough; a child that runs again is read live again.
  • The Client would otherwise wait for that Session's next event to see the filled value, because the Host only pushes projections when a Session commits — there is no heartbeat and no polling in the shipped carrier. That is what the family route is for: the Client asks for a read while its own value is incomplete, and prefers whichever of the two can see more.
  • Every read is contained and cycle-guarded. A child that is absent, unreadable, or fails its lifecycle check stays unresolved: it is counted in unresolved, never as zero, and the pill renders at least …. A child's own costMeterFamily value already contains its descendants, which keeps this walk one level deep and counts a grandchild exactly once.

Verification

node verify.mjs runs 50 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, per-request fee counting, the family fold and its live/durable/cached child resolution (including an unreadable child and a throwing lookup), the family route's two-phase warm-up over settled children and grandchildren, the projection's self-healing miss, 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, shows the family total with its subagent and fee breakdown, degrades to the session value when no family value exists, marks a subagent no source can read as a lower bound, prefers the route only while it sees more than the projection, 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; generation 3 adds per-request fee accumulators. The family unit is registered beside the spend unit at the same version, so a checkpoint written by an older build has no costMeterFamily row and the family view simply falls back to the live read or reports the child as unresolved until that child runs again.