NeutronStar714/dsh-budget-watcher

dsh-budget-watcher

适配 DeepSeek Harness 的预算监视器插件,让 API 花费变透明。通过图标展示账户余额、当前轮/会话的花费、以及任务花钱速率,提醒或帮助用户阻断突发性大量烧钱(例如未经过协商发放几十个 Subagent 等等)A Deepseek Harness plugin that makes cost transparent: floating window showing balance left on your API account, what the turns you ran actually cost, and how fast is your agent using your balance for tasks.

未分類balance-checkerbudget-trackerdsh-plugin-desktop

dsh-budget-watcher

English | 简体中文

A DeepSeek Harness (dsh) plugin that floats a small window over the conversation showing how much balance is left on your API account, what the turns you just ran actually cost and how fast is your balance decreasing when performing different tasks.

The expanded panel floating over a conversation

The expanded panel on a real account: the balance, this turn's cost beside the conversation's, both burn rates, and the live-burn chart drawn against the turn's own elapsed time. Captured at 0.6.2.

The same watcher collapsed to a pill

Collapsed to a pill — one click, or a double-click on the header — it leaves only the status dot and the amount. The dot keeps its colour, so a spend warning survives the collapse.

The headline is total_balance from DeepSeek's public balance API: topped-up money plus granted credit, which is the number that decides whether the next call works. The two parts are not shown separately — one figure is the honest report, because topped_up_balance alone reads 0.00 on an account living on granted credit and would say "broke" about an account that is working fine.

The cost figures are computed from the token usage DeepSeek already reports on every assistant message, which DSH already records. They spend no tokens of their own: no model call, no extra request, no estimation prompt. See What it costs you.

  • Collapse to a pill — one click, or double-click the header, leaves just the amount on screen.
  • Drag it anywhere — grab the expanded panel anywhere on it, not just its title bar. The position is remembered across reloads. The collapsed pill deliberately stays put, because a pill you can nudge by accident is a pill you cannot click.
  • Refresh on demand — or let it poll on its own schedule.

What it shows

Every figure below is read off the screenshot above, so the examples are real rather than illustrative.

ElementMeaning
¥51.33 CNYtotal_balance — the whole balance: topped-up money plus granted credit. The headline, and the only balance row.
Green dot🟢is_available: true — the account can make API calls.
Amber dot🟡The balance is stale, the last check failed, is_available is false, or the live burn is over the warning threshold.
Red dot🔴The spend limit was crossed and the plugin interrupted the turn. Reserved for that one event.
Red dot (other)Nothing could be read and no previous value is on screen.
just nowWhen the host last got an answer. A stale number is labelled, never passed off as current.
this turn ≈¥0.12 / ¥13.93 · 22 turnsThe turn you are waiting on, and after the slash the whole conversation — including the agents either of them spawned. One row, because "what this cost" and "what it all costs" is one thought.
live burn ≈¥0.00/h · 15sWhat the last 15 seconds cost, projected to an hour. The sudden-loss figure, and the one both thresholds act on. It reads ¥0.00/h here because nothing settled inside those 15 seconds — a true reading, not a fault. A pulsing dot marks a turn still in flight.
average burn ≈¥6.40/h · 1:09That turn's cost over its own elapsed time: ¥0.12 across 1 min 9 s. A running average, frozen the moment the turn ends.
The chartlive burn against the turn's elapsed time, 0:00 → 1:09. The blue line is anchored at the origin — nothing had been spent yet — and the amber warn ¥15 line is the warning threshold.
1 turn(s) unpricedA model with no published rate was seen, so those turns are reported rather than costed at a guess.
session ¥15.12 · 4 turnsThe conversation total on a row of its own — only when there is no turn yet to pair it with.
Not available for API callsis_available: false.
No API key is configured…The host found no key; the message names the credential reference to set.

The header already says DeepSeek balance, so the amount carries no caption and there is no second balance line. Every setting lives behind the gear button.

What it costs you

A research turn that fans out to sixty subagents is invisible from the conversation: one turn is open, no answer has arrived, and the balance is falling. Balance alone tells you that you are spending; it does not tell you how fast, which is the number you need to decide whether to stop.

The panel answers that with four figures, all derived from data already on disk:

  • this turn — what the turn you are waiting on has cost so far. It updates as each step settles, because usage is reported per assistant message rather than per turn.
  • live burn — what the last window cost (burnWindowMs, 15 seconds by default), projected to an hour. This is the sudden-loss detector and the figure the two thresholds act on. It is deliberately short and therefore jumpy: 0 whenever nothing has settled inside the window, which is true rather than broken. A 15-second window is the price of noticing within seconds instead of minutes.
  • average burn — that turn's cost divided by its elapsed time. A running average, recomputed once a second against the live clock and frozen when the turn ends, which is what makes two finished tasks comparable: one turn's rate is a fact about that task, not about the profile's recent mood.
  • session — every turn in this conversation plus every subagent session beneath it. A fan-out bills in the children, so counting only the session you are looking at would report the calm, not the fire. The N agents suffix tells you how many are in flight.

Two rates rather than one, because one number hid both: averaging a 15-second window into a task-long mean gives a figure that is neither immediate enough to catch a runaway nor stable enough to compare tasks.

Because the whole point is spotting the expensive task, this turn and both burns count the agents that turn spawned, attributed by time window rather than by turn number. A subagent has its own private turn sequence, so summing only the conversation's own turns would report a calm rate while sixty agents burn.

The settings tab lists recent turns hottest first — cost, rate and duration — so the expensive tasks are identifiable after the fact rather than only while they run.

The two thresholds, and the one that stops the turn

Both are expressed in the balance's own currency: CNY per hour on a CNY account, USD per hour on a USD one.

  • burnWarnPerHour turns the dot amber and tints the live burn figure. Purely informational.
  • terminateAbovePerHour goes further: when the live burn crosses it, the plugin interrupts the running turn, exactly as pressing stop does, and the dot turns red.

It is off by default (0), deliberately. Stopping a task is destructive and the rate is an estimate, so it has to be asked for. When it does fire: the stop is latched per turn number, so one runaway turn is interrupted once rather than once per poll; queued input you have already typed is preserved; and the session log records turn/end with reason: { kind: "aborted", reason: { kind: "hook", reason: "dsh-budget-watcher/over-budget" } }, so the turn's end has a stated cause rather than looking like a crash. See Known limitations for how precise the turn targeting is, which is the honest caveat.

Both thresholds and the burn window are on the settings tab.

The chart

Under the figures, and above the refresh row, the panel draws this turn's live burn rate against its own elapsed time — d(currency)/dt for the turn in hand. It is on by default and can be switched off (graphEnabled).

It spans the panel's full content width, so it lines up exactly with the this turn row — and it cannot make the panel any wider than the text already does, which is what keeps the panel narrow.

  • The x-axis is the turn, not a window. It grows one second at a time while the turn runs and stops the moment the turn ends, so the picture is one task start-to-finish rather than a sliding window that forgets what it saw. A frozen chart is a finished turn's whole history.
  • The y-axis only ever grows, and only when a new high-water mark is set. It never rescales downwards under the line mid-turn, which would make a falling rate look like a cliff. Gridlines land on 1, 2 or 5 times a power of ten, and the peak is labelled in the chart's accessible description.
  • Blue is the live burn, amber is burnWarnPerHour, and red is terminateAbovePerHour — drawn only when that is above 0, since a stop line at "off" would mean nothing. The caption beside each line takes that line's colour, so the pairing survives both themes.
  • A threshold above the drawn range is pinned to the top edge, dashed and marked ▲, rather than dropped. Hiding the line you set would be worse than showing it out of scale.
  • Every gridline on an axis shares one precision, chosen from the step, so an axis reads ¥0 / ¥20 / ¥40 rather than ¥10 above ¥5.00.
  • The line is anchored to the axis origin. Nothing has been spent at t=0, so the rate there is honestly zero; starting at the first settled step instead made the left of the plot look like missing data.
  • The line is the same windowed figure as the live burn row, so the two cannot disagree; the host samples it at each settled step and the panel extends it to now once a second.

Where the numbers come from

DeepSeek reports exact token usage on every assistant message, and DSH writes it into the session log. The plugin reads that log and multiplies by DeepSeek's published rates:

cost = uncached input × cache-miss rate
     + cache-read tokens × cache-hit rate
     + output tokens × output rate

Two details make the difference between an estimate and a wrong number:

  • DSH's inputTokens is the uncached half. The cache buckets are separate. Subtracting cache reads from inputTokens — the obvious-looking arithmetic — collapses the uncached half to zero on a well-cached turn and under-reports exactly the turns worth watching. dsh-llm-deepseek states the identity outright: totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens.
  • Retried attempts are billed. A failed attempt settles as assistant/attempt, which carries no usage field at all — its numbers survive only in the last usage chunk of its stream. Reading just usage would silently drop the cost of every retry.

Peak and off-peak rates are applied per call from the timestamp (peak is 01:00–04:00 and 06:00–10:00 UTC, Monday to Friday, which is 09:00–12:00 and 14:00–18:00 Beijing time on the same days). reasoningTokens is recorded but never priced separately: it is a subset of output tokens, so charging for it as well would double-bill.

Install

dsh plugin --profile <your-profile> add dsh-budget-watcher

For a profile installed from this repository instead of npm:

dsh plugin --profile <your-profile> add github:NeutronStar714/dsh-budget-watcher

Then restart that profile. dsh plugin add writes the dependency and appends the package to dsh.profile.bundles; a running process does not pick up a new bundle on its own. Configuration edits to the row are live afterwards when the profile sets "patchReload": "live".

Confirm it landed:

dsh plugin --profile <your-profile> list

The widget appears in the bottom-right corner as soon as the profile is up. If it does not, see Troubleshooting.

[!NOTE] The DSH desktop application owns its bundled profile exclusively, and the CLI refuses to modify it (error: profile "desktop" is managed exclusively by the Electron application). Install into that profile from the app's own Settings → Plugins page instead, then restart the app. The CLI path above is for profiles you create and run yourself, such as dsh web.

Uninstall

dsh plugin --profile <your-profile> remove dsh-budget-watcher

The API key

The plugin never asks for a key of its own. It reads the same DEEPSEEK_API_KEY credential reference the shipped DeepSeek adapter uses, so a key you already configured for inference is reused. Resolution order:

  1. apiKey from this plugin's config, when set.
  2. apiKeyEnv (default DEEPSEEK_API_KEY) through ctx.credentials — this is what the Web Models page writes.
  3. DEEPSEEK_API_KEY from the environment that launched dsh.

It is resolved at every refresh, so storing a key takes effect on the next poll without a restart. The key is used for one outbound request and is never written to the page, the route response, or a log.

[!IMPORTANT] This reads the public API balance at https://api.deepseek.com/user/balance, which is what an API key unlocks. It is not the same number as Settings → Account, which comes from the DeepSeek Platform web API through a signed-in OAuth grant. If you sign in with an account and never stored an API key, the widget reports that no key is configured. See Known limitations.

Configuration

Edit your profile's cordis.patch.yml, keeping the comments and replacing [] if the file is still empty:

- id: budget-watcher
  config:
    provider: deepseek
    apiKeyEnv: DEEPSEEK_API_KEY
    refreshIntervalMs: 60000
    currency: auto

A patch replaces the row's whole config object, so keep every override together. All fields are optional.

OptionDefaultPurpose
providerdeepseekWhich API account to read. Only deepseek is implemented; see Known limitations.
apiKey(empty)An explicit key. Prefer apiKeyEnv: a key written here lives in your profile file.
apiKeyEnvDEEPSEEK_API_KEYCredential reference resolved through ctx.credentials.
endpoint(the provider's)Override the balance endpoint. Intended for testing against a stand-in server.
refreshIntervalMs60000How long the host reuses one balance answer before calling DeepSeek again. Minimum 15000. This paces the upstream API call, not the panel — see Known limitations.
requestTimeoutMs10000Deadline for one balance request.
currencyautoWhich currency the cost figures and both thresholds are shown in. auto follows the featured balance wallet; CNY or USD force it. A closed set, because DeepSeek publishes rates in exactly those two currencies.
allowNonLoopbackfalseAllow the widget to read balance when the GUI is served on a non-loopback address.
costEnabledtrueEstimate what recent turns cost from the usage the provider already reports. Spends nothing.
graphEnabledtrueDraw the live-burn chart in the panel. Turning it off leaves every figure in place.
burnWindowMs15000Window for the live burn rate, in milliseconds (minimum 5000). Short on purpose: this is the figure that catches a sudden loss.
burnWarnPerHour2Per-hour spend above which the panel turns amber, in the balance's currency. 0 disables the warning.
terminateAbovePerHour0Per-hour spend above which the running turn is interrupted, in the balance's currency. 0 — the default — disables it.
usdToCnyautoExchange rate for models priced in USD by a third party. auto fetches the European Central Bank's daily reference rate; a number (7.2) uses that rate and makes no network request. Defaults to auto.

There is no exchange-rate setting, because there is no exchange rate: costs are priced from DeepSeek's published CNY or USD rate card, matching whichever currency the featured balance is held in. See Where the numbers come from.

To disable the plugin without uninstalling it:

- id: budget-watcher
  disabled: true

Settings

The panel's gear button opens a Budget tab in DSH's right sidebar: every option above as an editable field, with Save. The gear only appears when a tab could actually be registered, so it is never a control that does nothing.

Durations are shown in the units you think in — the balance cache and the live burn window in seconds — and converted back on save. The API key field is write-only: it starts blank, blank means leave it alone, and clearing it removes the key from the profile. The running key value is never sent to the page; the form only knows whether one is set.

Currency is a drop-down of auto / CNY / USD, not a text field: those are the only currencies DeepSeek publishes rates in, so anything else could only ever be wrong. The two thresholds are labelled with the currency the balance is actually held in.

The form scrolls, and Save is pinned to the bottom of it, so the button stays reachable however long the settings and the recent-turns list grow. The turn list is bounded and scrolls separately.

Saving goes through DSH's own config editor, so the change lands in your profile's cordis.patch.yml and is applied by the normal loader path — the same file and the same mechanism you would edit by hand:

- id: budget-watcher
  name: dsh-budget-watcher
  config:
    currency: CNY
    burnWindowMs: 15000
    burnWarnPerHour: 3.5
    terminateAbovePerHour: 0

A value set back to its default is removed rather than written, so the row goes back to inheriting. Config edits apply without restarting DSH.

If the row cannot be written — no config editor in the composition, or a home patch or --patch overlay that also sets it — the tab says so and disables Save rather than failing on click.

[!NOTE] This uses the plugin's own route plus the host configEditor service rather than DSH's generated remote.settings transport. The generated path needs a schemastery Config whose fields are all .volatile(), and a client-side form built on configForms; the route is smaller, is covered by tests, and was verified writing a real profile patch. Migrating to the generated transport is the natural next step if the plugin grows more settings.

How it works

Two halves in one package, which is what dsh.bundle.patch plus dsh.client describe.

Host half (index.js) does everything that needs a secret or a socket. It resolves the key, calls GET https://api.deepseek.com/user/balance with Authorization: Bearer <key>, normalizes the response, and serves it to the page as JSON over one route: GET /dsh-budget-watcher/balance.

  • Caching with single flight. One answer is reused for refreshIntervalMs, and concurrent requests share one in-flight upstream call, so ten open windows do not mean ten requests.
  • A failure keeps the last number. The previous balance stays on screen marked stale rather than blanking. An unreadable body is a protocol error, never a rendered 0.00.
  • ?refresh=1 bypasses the freshness window; that is what the refresh button does.
  • A same-origin fence. The route lives outside /api, so it applies its own checks: the Host header must name a loopback authority (which is what stops a DNS-rebinding page), a present Origin must match Host, and sec-fetch-site: cross-site is refused. Only GET is served.

Client half (lib/client.cjs) is a hand-written classic script in the loader's lazy-CJS format — no bundler, no JSX, no runtime dependencies. It registers into the frame-wide shell.overlay slot, the seat described by DSH as "above every column and outside their scroll containers", and draws with React.createElement and the --dsw-* theme tokens so it follows the active light or dark theme. Its stylesheet carries data-plugin / data-plugin-css, which is how the shell reclaims plugin CSS on unload.

Which conversation to price comes from the client, not from a guess. A root-scoped slot entry receives the frame's standard kit as props, so the panel reads the retained session the same way DSH's own title bar does — useSessions, selecting the row with retainedBy.mainView > 0 — and sends it as ?session=. The host falls back to the newest live root session when the parameter is absent, so an older client still gets an answer rather than nothing.

The package declares no @deepseek-ai/dsh* peer dependencies and imports none, so the compatibility gate cannot refuse it and there is nothing for pnpm to install. The one optional import, @deepseek-ai/schemastery for config validation, is dynamic and degrades to unvalidated config rather than failing the profile.

Files

PathWhat it is
index.jsHost half: config, credential resolution, the upstream request, caching, the /dsh-budget-watcher/balance route and its fence, and the cost payload.
lib/balance.jsPure helpers: normalizing the payload, choosing a currency, mapping HTTP failures to messages. No DSH or fetch imports, so it is directly testable.
lib/cost.jsPure cost model: DeepSeek's rate card, peak/off-peak selection, usage→USD, and the fold from session events to per-turn totals.
lib/ledger.jsWhich sessions count as one conversation's spend: the live tree, descendants, and a total that does not fall when a subagent finishes.
lib/client.cjsClient half: the floating window, drag/collapse state, session handshake, polling, and the panel stylesheet.
cordis.patch.ymlThe bundle patch inserting the loader row.
test/node --test suite: balance, cost, ledger, host (the route against a stubbed API) and client (the bundle in a VM with a stubbed loader).
docs/screenshotnew.pngThe expanded panel, captured at 0.6.2 on a real account. Named screenshotnew rather than screenshot so GitHub serves the current image instead of a cached copy of the previous one.
docs/screenshot_pill.pngThe same watcher collapsed to a pill, captured at 0.6.2.
README.zh-CN.md简体中文翻译。The English file is authoritative where the two differ.

Development

No build step and no dependencies — node --test is the whole toolchain.

node --test

To try a local checkout against a real profile without publishing:

dsh plugin --profile <your-profile> add link:/absolute/path/to/dsh-budget-watcher

Then restart the profile. Because the dependency is a symlink, edits to index.js are picked up by a profile restart, and edits to lib/client.cjs are served on the next request.

Troubleshooting

SymptomCause and fix
No widget at allThe profile was not restarted after dsh plugin add, or the row is disabled. Check dsh plugin --profile <name> list.
No API key is configuredStore a DeepSeek API key on the Models page, or export DEEPSEEK_API_KEY before launching dsh.
DeepSeek rejected the API keyThe key is wrong, revoked, or belongs to a different account.
Could not reach api.deepseek.comOffline, or a proxy that DSH's global dispatcher does not know about. See Known limitations.
forbidden: host is not a loopback authorityYou reached the GUI through a LAN name or a non-loopback reverse proxy. Set allowNonLoopback: true only if you accept that the route is then reachable by anyone who can reach that address.
The number never changesrefreshIntervalMs has not elapsed, so the host is still serving the cached answer. The relative timestamp tells you when it last moved.
The cost figures do not appear when you send a promptFixed in 0.6.3. Before that the panel polled on the balance-cache window, so a prompt sent just after a poll coul