Back to home@jwilson411

dsh-spend-receipt

A cache-aware JSONL cost receipt plugin for DeepSeek Harness.

Stars
0
Language
JavaScript
Created
Aug 29, 2026
Updated
Aug 29, 2026
GitHub repo

Introduction

dsh-spend-receipt

A DeepSeek Harness function plugin that turns reported usage into a citable, append-only JSONL cost receipt, and a spend_receipt tool that reads it back: the last N lines, the totals, and the cache-hit rate.

The point is that the number is checkable. Every line records the token counts a provider actually reported, the dated rate table it was priced under, which side of the off-peak window it fell on, and where it was derived from — so a reader can re-derive the cost without trusting this package. Nothing is estimated: no text is tokenised, no missing count is filled in with a zero, and a call that cannot be priced is listed with its tokens and usd: null rather than a guess.

Install

dsh plugin --profile web add github:jwilson411/dsh-spend-receipt

dsh plugin forwards to pnpm inside $DSH_HOME/profiles/web, then reconciles the profile: because this package's manifest declares dsh.bundle.patch, it is appended to the profile manifest's ordered dsh.profile.bundles list and its cordis.patch.yml becomes a layer. Remove it the same way, with remove in place of add.

Point it at your paths from the profile's own cordis.patch.yml — note that an id-targeted patch replaces the row's whole config, so restate every field you mean to keep:

- id: spend-receipt
  config:
    receiptPath: ~/.dsh/spend-receipt.jsonl
    sessionLog: ~/.dsh/sessions/current.jsonl
config keyenvironment fallbackdefault
receiptPathDSH_SPEND_RECEIPT_PATH./spend-receipt.jsonl
sessionLogDSH_SPEND_RECEIPT_SESSION_LOGunset — read-only mode
sessionIdDSH_SPEND_RECEIPT_SESSION_IDunset — lines record null
limit20

Input assumptions

Receipt lines come from one of two places, never from a guess.

Usage events. A host that already has a usage hook calls recordUsage(event, { receiptPath }); the CLI's record command does the same for JSONL on stdin. An event is anything with a model and token counts, at the top level or nested under usage, usage.details, or response.usage. Both snake and camel spellings are read (prompt_tokens / input_tokens / promptTokens, prompt_cache_hit_tokens / cache_hit_tokens / cached_tokens under prompt_tokens_details, completion_tokens / output_tokens). If an event reports the cache hit and miss halves but no prompt total, the total is their sum — the one derivation allowed, because it restates reported counts rather than measuring anything new.

An append-only session log. syncFromSessionLog({ logPath, receiptPath }) reads the log as JSONL: lines carrying usage become receipt lines, a line carrying only a session id sets the session for the lines below it, and everything else is ignored. The log must be genuinely append-only — resumption has no separate cursor file, it is the highest log line the receipt already cites, so a rewritten log needs a fresh receipt. Re-running a sync over a growing log appends only what is new.

The receipt is JSONL and is only ever appended to; a batch is serialised and written in one append so a crash cannot interleave a half-written line. One corrupt line is counted and reported, not thrown — it cannot make the rest of the history unreadable.

One line, formatted:

{
  "ts": "2026-08-29T09:00:12.000Z",
  "session_id": "sess-7c2",
  "model": "deepseek-v4-pro",
  "input_tokens": 12000,
  "cache_hit_tokens": 9000,
  "output_tokens": 800,
  "usd": 0.007524,
  "currency": "USD",
  "nano_usd": 7524000,
  "rate_table": "deepseek-v4@2026-08-29",
  "rate_tier": "peak",
  "ts_source": "event",
  "source": { "kind": "session-log", "path": "…/session.jsonl", "line": 3 }
}

input_tokens is the total billed prompt, of which cache_hit_tokens were served from cache. nano_usd is the exact integer cost in 1e-9 USD that usd rounds; totals are summed from it, so a thousand-line receipt adds up without floating-point drift. ts_source is event when the timestamp came from the usage event and recorded when the event carried none and this is when the line was written.

The rate table

Pinned and dated 2026-08-29, read from DeepSeek's published price list: https://api-docs.deepseek.com/quick_start/pricing. Bump RATE_TABLE_DATE and the id in src/rates.js when the rates change, so old lines stay attributable to the numbers that actually produced them.

USD per 1M tokens:

modeltiercache hitcache missoutput
deepseek-v4-propeak$0.044$1.32$3.96
deepseek-v4-prooff-peak$0.022$0.66$1.98
deepseek-v4-flashpeak$0.014$0.44$1.32
deepseek-v4-flashoff-peak$0.007$0.22$0.66

Peak is 01:00–04:00 and 06:00–10:00 UTC, Monday through Friday. Every other hour is off-peak, at half the peak rate — the gap between the two windows, every evening and night, and the whole of Saturday and Sunday. Peak is the narrow case, so a call is off-peak unless it lands inside one of those two weekday windows.

Windows are half-open, [start, end): 04:00:00 is already off-peak. The tier is read from the call's own timestamp, in UTC, whatever zone the offset was written in — 2026-08-29T03:00:00+02:00 is 01:00 UTC on a Saturday, so it is off-peak despite reading like a peak hour locally.

Rates are stored as integer nano-USD per token rather than floats, so pricing and totalling are integer arithmetic and the conversion to USD happens once, at the edge.

deepseek/deepseek-v4-pro, deepseek/deepseek-v4-flash, and the two -latest pointers resolve to the rows above. Only spellings of the same model are aliased — mapping a near neighbour onto a priced row would invent a price.

When a call cannot be priced

The tokens are still recorded, usd and nano_usd are null, and unpriced_reason says which of three things was missing:

  • model-not-in-rate-table — an unknown or newer model. Its tokens appear in the token totals and it is counted in unpriced_lines, but it contributes nothing to usd, so a total understates rather than invents.
  • cache-split-unknown — a prompt total with no reported cache split. The two halves bill at rates ~30× apart, so the call cannot be priced from the total alone. Such lines are also excluded from both halves of the cache-hit rate, rather than being counted as misses.
  • timestamp-unknown — no usable instant, so the peak/off-peak tier cannot be determined. An unknown tier does not silently become the peak one.

The spend_receipt tool

Cordis plugin idspend-receipt (the row id in cordis.patch.yml)
Injectstools — a hard dependency; the plugin waits rather than degrading
Toolspend_receipt
Argumentslimit (integer, optional), sync (boolean, optional)

limit is how many of the most recent receipt lines to return, defaulting to the configured limit. sync defaults to true: the tool first sweeps the configured sessionLog for new usage, then reports. Pass sync: false to report only what has already been recorded. A sweep that cannot run — a mistyped or not-yet-created log — reports its failure in synced.error rather than making the receipt unreadable.

It returns receipt_path, the rate_table it priced under, the lines themselves, totals and cache_hit_rate over the whole receipt, window_totals and window_cache_hit_rate over just the returned lines, malformed_lines, and synced. The cache-hit rate is cache_hit_tokens / input_tokens over lines with a known split, with a basis saying how much of the receipt it was computed over.

The tool reads a file and reaches no network, so it needs no API key.

The CLI

The same functions from a shell. It imports nothing outside node: and this package, so it runs against a checkout with no dependencies installed — useful when the receipt is the thing you need and the harness is the thing that is broken.

dsh-spend-receipt show   [--receipt <path>] [-n <count>] [--json]
dsh-spend-receipt sync   --log <path> [--receipt <path>] [--session <id>] [--json]
dsh-spend-receipt record [--receipt <path>] [--session <id>] [--json]   # events on stdin
dsh-spend-receipt rates  [--json]
$ dsh-spend-receipt sync --log ~/.dsh/sessions/current.jsonl
appended 6 line(s) to /home/you/spend-receipt.jsonl
from /home/you/.dsh/sessions/current.jsonl, 12 new log line(s) scanned, cursor now 12
  skipped log line 6: unparseable-json
  skipped log line 9: invalid-count

$ dsh-spend-receipt show -n 2
receipt: /home/you/spend-receipt.jsonl

2026-08-29T20:15:00.000Z  deepseek/deepseek-v4-pro  off-peak  in 2000 (cached 0)  out 100  $0.001518
2026-08-29T12:00:00.000Z  deepseek-v4-flash  peak  in 100 (cached 50)  out 10  $0.000036

showing 2 of 6 line(s)
totals (whole receipt): $0.009491 USD over 19600 input (13050 cached) and 1210 output tokens
cache-hit rate: 68.0% (over 5 line(s) with a known split)
unpriced: 2 line(s) contributed tokens but no cost

rates prints the pinned table, which is the fastest way to check what a receipt was priced against.

Layout

package.json        manifest + `dsh.bundle.patch` — what makes this a bundle
cordis.patch.yml    the bundle's patch layer: one insert, one plugin row
src/rates.js        the pinned, dated rate table and exact nano-USD pricing
src/usage.js        reading reported counts without inventing any
src/receipt.js      building, appending, reading, and syncing receipt lines
src/summary.js      the last N lines, totals, and cache-hit rate
src/index.js        the plugin: `name`, `inject`, `apply(ctx, config)`
bin/                the CLI
test/               offline tests over checked-in fixtures
package-lock.json   the pinned dependency tree `npm ci` installs in CI

Tests

npm install
npm test

Offline by construction, and fixture-only. The suite reads the checked-in JSONL fixtures in test/fixtures/ and writes scratch receipts to a temporary directory: no profile boots, no socket opens, no key is read, and no clock is consulted — the recording time is passed in, so a line whose event carried no timestamp is still deterministic.

Only test/plugin.test.js needs a dependency: it registers the plugin against a stub context and validates the tool's result with the real @deepseek-ai/dsh-tools, pinned to 0.1.1-rc.2 in devDependencies and in package-lock.json so the contract is tested against one known API. Everything else — the library, the CLI, and the other four test files — imports nothing outside node: and this package.

CI (.github/workflows/ci.yml) runs npm ci and npm test on Node 22 and 24 from the committed lockfile, against the public registry only. It needs no credentials and the suite reaches no network.

License

MIT — see LICENSE.