GalileoNio/dsh-cost ↗★ 0

dsh-session-cost

Per-segment Session cost in the DeepSeek Harness composer statistics strip, priced from the harness model catalog.

パッケージ
dsh-session-cost
互換性
未検証
バージョン
1.0.0
ライセンス
MIT
最終更新
2026/10/05

同名パッケージの別リポジトリ

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:GalileoNio/dsh-cost

ドキュメント

README 全文を読む ↗

dsh-session-cost

Adds one pill to the Session's bottom statistics strip — the composer dock that already shows turn/step counts, tokens per second, token totals, and cache-hit share — reporting this Session's cost, priced per segment from the model that actually served each request.

[time pill] [token pill] [$ pill] [context meter]

Clicking the pill opens the breakdown, one section per (model, rate window).

Install

This is a bundle: installing it adds a Loader entry, and the entry is what carries the plugin, so nothing in the Harness is patched by hand.

git clone git@github.com:GalileoNio/dsh-cost.git "$HOME/.dsh/profiles/plugins/dsh-session-cost"

Then either install from that path on the Plugins page, or run the same operation programmatically with plugin_manager install_bundle and the absolute path. The manager adds the package as a profile dependency, appends it to dsh.profile.bundles, and applies it live; uninstalling removes all three.

Why that clone location, and how to ignore it. A plugin is symlinked into the profile, and Node resolves an import from the symlink's real path, so a clone under ~/.dsh/profiles/ reaches the Harness's own node_modules for @deepseek-ai/schemastery and @earendil-works/pi-ai. Cloned anywhere else, run npm install once inside the clone and it becomes location-independent — every runtime dependency is declared, so its own node_modules satisfies the imports. Both flows are verified below.

Its cordis.patch.yml inserts the one row over the profile root:

- insert:
    - id: session-cost
      name: dsh-session-cost

No config is written there on purpose: the override table starts empty because presets cover the catalog, and the settings page writes any override back by entry id.

Plugin source changes need no restart. lib/client.js is re-read from disk on every page load, so a client-side change needs only a page refresh. The Host half hot-reloads too, through the Harness's own @deepseek-ai/dsh-hmr: it clears the changed module from Node's ESM and CJS caches, re-imports it, and swaps the new exports into the live fiber, rolling back if the new module throws.

It only watches what it is told to, and dsh-base ships that entry with root: [] — which is why a Host-side edit used to need a restart. One entry in the profile's cordis.patch.yml closes the gap:

- id: hmr
  name: "@deepseek-ai/dsh-hmr"
  config:
    base: /Users/kelonl/.dsh/profiles/plugins
    root:
      - .

The default ignore list still skips node_modules inside that directory.

How the amount is computed

One fold, on the Host. The plugin registers a sessionCost projection unit on the session-projection seam, so it sees the complete durable log rather than the paged, compaction-rewritten chat window. Every billed request attempt is attributed:

FactSource
tokensthe attempt's provider usage — assistant/message.usage, else the last usage chunk embedded in the attempt stream
modelthe settled assistant/message's message.source, falling back to the newest model/selection in effect (covers attempts that never assembled a message)
rate windowthe request's own clock (step/start, else the settlement), against the vendor's published peak rule

The add/replace bookkeeping mirrors the shipped tokenUsage unit exactly — a settlement replaces its (turn, step) slot, and llm/retry-started closes the replacement slot so a retried attempt adds rather than replaces — so the sum over these groups reconciles with tokenUsage bucket for bucket. The test suite asserts that reconciliation against an independent reimplementation.

Prices are resolved on the Host too. Each group goes out on the wire with one already-narrowed four-number rate set: the long-context tier when the vendor has one, otherwise the peak or base window the settings select. The browser multiplies and never chooses, so it cannot disagree with the Host about a price. The only table the browser ever holds is the preset one the Host generates for the settings page, and that one seeds an edit — it never prices a displayed amount.

Where prices come from

Presets, derived from the Harness's own catalog. @earendil-works/pi-ai — the catalog dsh-llm-pi-ai serves from — ships 41 providers and ~1495 models, each with cost: { input, output, cacheRead, cacheWrite } in USD per 1,000,000 tokens, the unit its own calculateCost divides by. Presets are built from it at startup, so every model the Harness can route to is priced, the numbers track the installed catalog, and nothing here needs updating when the catalog changes. Long-context tiers (45 models, all 272k today) are carried through and applied per request.

DeepSeek's official routes, in CNY. dsh-llm-deepseek is a separate adapter billed off the vendor's published page, so its two routes carry real peak and off-peak rates here.

Your overrides, on top. Anything the catalog does not price — a self-hosted or gateway endpoint, a negotiated discount, a rate you disagree with — goes in the plugin's settings.

Maintaining the table

Plugins (the sidebar entry) → this plugin's card. No automatic schema form exists in this build: the Plugins page renders only what a plugin claims for itself and draws on its own. This one claims two seats, so the same form is reachable from either:

SeatKeyWhere it appears
plugins.bundle.configdsh-session-costThe bundle card's page, between its description and its rows
plugins.row.configdsh-session-cost#session-costThe session-cost row's page, which also gains a configure control

Two things about that page cost real debugging time here and are worth knowing:

  • The form is read through bound readers. ConfigForm's getSnapshot and subscribe are class methods that read this.store, and React calls whatever it is handed detached. Passing form.getSnapshot through directly throws Cannot read properties of undefined (reading 'store') on the first render.
  • A crash is contained and printed. The slot renderer retires a crashed entry from its cell, one-shot, and never puts it back — so without the plugin's own error boundary an exception here would make the configuration silently vanish and read as "this plugin has no settings".

Every accepted write goes through the session-cost settings namespace's ConfigForm, which lands it in the profile patch by entry id. The editor writes only the fields you actually changed.

Adding a row normally starts from the picker. Import a configured model lists every route the Host currently reports grouped by provider, straight from remote.session.modelCatalog() — which is why a provider you configured in Settings → Models shows up here without typing its provider/model key. Picking one pre-fills the key and the display name, leaving only the rates; routes that already carry an override are retired in the list. If the catalog cannot be read the control says so, and manual entry still works.

The default currency is a select of the common symbols with a Custom… escape hatch for anything else. It applies to every override that names no symbol of its own; presets keep theirs (the catalog is $, DeepSeek official is ¥).

Where those preset rates come from

Importing fills in the rates too, from the same presets the Session fold charges with. The browser cannot read that catalog itself — @earendil-works/pi-ai is a Host dependency, and the plugin-visible wires do not carry a table this size (a Session-projection baseline re-sends every value on every frame and for every listed Session; a custom Remote namespace needs generated Typert contributions, which only the assembly can add).

So the Host half materializes it as a package-local client chunk: lib/client.rates.js, ~1500 routes as compact rate tuples, fetched with require.async("./client.rates.js") the first time a settings page opens. Nothing on the Session path pays for it, and an install the Host cannot write to simply has no pre-filled table — the page says so and you type the rates.

The chunk is a generated artifact: gitignored, written at startup, and rewritten only when the table changes. Its URL carries the client entry's artifact revision, so a rewrite also moves lib/client.js's filesystem metadata — the same signal the client-HMR watcher already reads as "this bundle was rebuilt".

FieldMeaning
enabledRender the pill at all. Off registers no projection, so no key reaches the browser.
iconLabelsDraw a segment label's vendor and model family as brand marks instead of spelling them. On by default; see Labels as marks.
periodauto charges each segment the window it fell in; peak / offpeak re-price every segment into one window.
currencySymbol for overrides that name none. Presets carry their own: the catalog is $, DeepSeek official is ¥. Each option names its currency in the interface's language — ¥ 人民币, $ US dollar — because a symbol alone is ambiguous: kr is the crown of three countries, and ¥ is the yuan here while the yen is JP¥. The order is fixed rather than sorted: the two currencies this plugin prices in first ($, ¥), then the majors, the Asia-Pacific ones, and the rest, so it reads the same in both languages.
pricesThe override table, keyed `
/` or bare ``; a qualified key wins.

Each override needs all four base rates. peak cannot be omitted — Schemastery resolves a nested object through its required members, so all-zero peak rates are the spelling of "this route charges one rate all day", which is also what the preset table means by them.

prices:
  my-gateway/qwen3-32b:
    label: My Qwen
    miss: 0.2
    hit: 0.02
    write: 0.2
    out: 0.4

Currencies are never converted — unless you ask for one figure

Every total stays in the currency its vendor billed: a session that used both a yuan-billed and a dollar-billed model shows ¥7.12 + $1.00, and the dialog lists them separately. Segment rows are never converted.

You can opt into a single figure, which sits at the right-hand end of the tray's title line:

SettingMeaning
displayCurrencyThe currency that figure is expressed in. Empty — the default — shows no figure at all.
showSavingsDraws the cache-hit and off-peak discounts as a struck-through list price before every subtotal and total. Off by default.
savingsCache, savingsOffpeakThe first two sub-options under it, both on: each withholds one discount from the comparison, so it can show the cache saving alone, the window saving alone, or both.
savingsPercentThe third, also on: names the saving as a share of list price, drawn between the two figures — ¥52.00 -88% ¥6.40, a discount written the way a price tag writes it. Only ever where a discount is shown, so a session that saved nothing gains no chip.
fxRatesYour own rates, e.g. {"¥": 0.1467}. Optional: they outrank every other source.

List price means the same tokens with both discounts put back: every input token charged at the cache-miss rate, at the price's peak window. Output only moves when the window does. A segment that saved nothing strikes nothing through, and a price with no peak window still shows the cache discount on its own.

Restore defaults clears the whole user layer — price list, currencies, rate table and price overrides — returning the namespace to the Host's own defaults. It is disabled while nothing is overridden, and confirms first, because it discards the override table.

The dock pill and the tray's title carry one value, from one function: the converted figure when a summary currency is configured, and the billed totals when it is not. So there is always a number to read next to "Session cost", and the two places never disagree. The itemised totals below the title stay per currency either way.

Every setting takes effect at once, and the two halves do it differently. Which currency the figure is expressed in, your own rates, the savings comparison and the label marks are read from the settings mirror in the browser, so they redraw in the same render — no event, no pull, nothing to wait for.

The rest shape the Host's own composition: the rate window, the price list, the override table, whether the pill is on. A projection is recomposed on session events alone, and the Host's registry offers no way to force one — but remote.session.projections reads every registered projection without activating an Agent, so the tray pulls once when one of those changes and shows what came back. The pull is held only until the stream delivers its own value, which by then is composed with the new settings, so nothing is pinned and the next session event is authoritative again. The probe runs that as a table over every option: the presentation ones never pull, the Host-shaped ones pull exactly once.

Rates come from three places, in this order:

  1. fxRates — what you entered. Nothing outranks it, so a rate typed by hand is never quietly "corrected" by a fetch.
  2. The ECB daily reference feed, read once per process the first time a figure needs it. EUR-based, no key, and the request carries no user data.
  3. A dated snapshot of that same feed, shipped with the plugin, which stands in when the feed is unreachable — so choosing a currency still produces a figure offline.

Three rules keep the figure honest. No rate here is invented: every published number is the ECB's, and the snapshot carries BUILTIN_AS_OF with it. A currency no source identifies — kr names three different crowns, and ₽ is unpublished — withholds the figure entirely rather than converting part of the total, which would be a number no rate produced; the tray then names the currency it could not rate. And the figure names its source on hover (a glyph beside the number was tried and removed: it was noise on every reading, and the tray says 「合计(下限)」 in words where the detail belongs) ("reference rates (ECB 2026-10-02)", "the rates you entered"), so it can never be read as a billed amount — the per-currency totals beneath it stay the source of truth. Only the policy travels to the browser; the multiplication happens next to the totals that already exist, so there is exactly one implementation of what a session costs.

That is also why the DeepSeek official routes carry a price-list choice rather than one price. The vendor publishes one list per platform — CNY on the domestic one, USD on the international one — and the two are rounded independently (one route's pair sits at ~6.67 CNY per USD, the other's at ~6.82), so neither is a conversion of the other. officialRates picks one:

ValueBehaviour
auto (default)Reads the wallet currency of the signed-in Platform account (deepseekAccount.getBalance → CNY / USD) and follows it.
cnyAlways the domestic list: ¥1 / ¥2 per 1M cache-miss, ¥0.02 / ¥0.04 cache-hit, ¥4 / ¥8 output.
usdAlways the international list: $0.15 / $0.30, $0.003 / $0.006, $0.60 / $1.20.

There is no cheaper signal inside the Harness: the DeepSeek adapter is configured with a credential reference rather than an endpoint, and both platforms answer on the same origin, so the wallet is what distinguishes them. auto therefore costs one Platform read, started the first time a price is actually needed and memoized for the process; a profile without the account plugin — or one whose account cannot be classified — keeps the domestic list. Pin cny or usd to skip the read entirely.

Labels as marks

A segment label is the catalog's own model name, so