dsh-get-balance
September 9, 2026 · View on GitHub
A balance & cost plugin for DeepSeek Harness:
- Multi-account balance — enumerates every DeepSeek provider (pi-ai routes / official route / extra keys), folds the same account into one row, and shows every account balance at a glance;
- Real-time stats — live per-session token usage and cost estimation, with online-editable price tiers (model × peak/off-peak);
- Bilingual UI — copy follows the host language (Simplified Chinese / English);
- Friendly & simple — one unified modal (Balance / Cost / Prices) plus a sidebar entry and a live session-header button; intuitive and ready to use.

Simplified Chinese · Screenshots
Features
Balance tab
- Enumerates every DeepSeek provider:
- entries from the host
llm-pi-aisettings whosebaseURLpoints at DeepSeek, - the official
llm-deepseekroute, - manually attached extra API keys (see below).
- entries from the host
- Each API key row shows "Today spend ≈xx CNY | Balance xx CNY" (numbers green) — the key's own today cost (matched from the per-key cost stats by provider route, ≈0.00 when unused) plus its account balance.
- One row per account: when several routes resolve to the same API key
(e.g. a pi-ai route named
deepseekderives the credential refDEEPSEEK_API_KEY, colliding with the officialllm-deepseekdefault), they are folded into a single row — the row keeps its first entry and shows a chip for every route sharing the key (tooltip: "shares API key with …"). The balance query runs once per distinct key, so the same account is never displayed twice with different names. - For each provider the plugin resolves the configured
apiKeyEnvvia the hostcredentialsservice and calls the officialGET https://api.deepseek.com/user/balance(proxied by the host — keys never reach the browser). Each row showstotal_balance/granted_balance(+ topped-out flag) or the failure reason. Results are cached in memory for 60s; "Refresh" bypasses the cache. - Extra API keys: keys outside any providers config can be attached directly
from the modal (label + key, masked echo), persisted to
$DSH_HOME/dsh-get-balance.json.
Cost tab — per-API-key breakdown
- One table: columns token / category / input (miss) / input (cache hit) / output / hit rate / est. cost. Every API key is a group of four rows (Last question / This session / Today·project / Today·all) with the token cell merged across them (label + masked key + official/non-official chip); a Total group comes first, key groups follow sorted by token volume. Numbers are compact (K/M/B/T/P); hit rate = cache-hit ÷ all input-side tokens.
- Token usage is counted per key regardless of officialness; cost is only
computed for official keys (API domain
api.deepseek.com) — non-official keys show a "not billed" chip instead of an amount. Multiple official keys (several routes pointing at the official API) each get their own group and their own bill. - Multi-provider sessions are strictly split per provider: token stats and
cost estimates follow each request's own
request/contextprovider, and each group carries a source chip (pi-ai route / official route) plus the official/non-official chip (alias routes are judged by baseURL hostname, so a pi-ai route pointing atapi.deepseek.combills as official), so providers never mix. The Last question entry prices every sample with its own model (per-sample model fromrequest/context), not a session-wide model. - Aligned with the balance tab: every configured provider is listed as a group whether or not it has usage — providers without usage (or without a credential) show zero tokens and a "—" amount.
- Official detection: the
providerfield ofrequest/contextevents → that provider's baseURL in host settings → hostname equalsapi.deepseek.com(trailing slash / case normalized; lookalike domains such asapi.deepseek.com.xx.comare not official). - Last turn / session: in-memory event folding (same (turn,step)
last-value-wins semantics as the official
tokenUsageprojection). Today entries: on-demand scan ofdshHomePath('sessions')logs (.jsonland.jsonl.zstd, frame-wise zstd decode, per-file memoized).
Price settings tab — platform sub-tabs + official pricing-table layout
- Platform sub-tabs (currently DeepSeek only; more providers' pricing can be added later as additional sub-tabs).
- Mirrors the official price table layout minus the category column:
Model version(colspan=2) + one column per model; three metric groups (input cache-hit / cache-miss / output, each rowspan=2) + off-peak/peak rows; only the price cells are input boxes (peak red, off-peak green). - Each model has peak and off-peak sets of four prices (per million tokens: input / cache read / cache write / output, CNY).
- Periods are configurable: peak windows (cross-midnight supported) + a
timezone-offset slider (UTC-12..+12, shown as UTC±0 / UTC+8 / UTC-5)
- a "weekends half price" toggle (Saturdays & Sundays excluded from peak windows and billed at off-peak rates all day). Official default: Beijing 9:00–12:00 & 14:00–18:00 are peak; off-peak = peak × 0.5.
- Built-in fallback is the official V4 tiers (
deepseek-v4-flash/deepseek-v4-pro/deepseek-v4-flash-vision-exp). Old flat-format config migrates automatically on first read (legacy built-in defaults upgrade to the official three tiers).
Session-header live button
- Registered on
conversation.session.header.utilities, showing Session xxM | ≈¥xx (both green, digits roll vertically odometer-style) — the current session's total tokens (compact K/M/B/T/P format) and its estimated cost. - Clicking the button refreshes once; auto-refreshes at the configured interval;
refreshes on session switch; and refreshes the moment each AI request completes
(the host session snapshot gains a new
assistantmessage node — request-level, not per streamed token), so a turn with several requests updates after each one. Only completions that hit the official DeepSeek API (api.deepseek.com) also force-refresh the footer balance (bypassing its 60s cache) — requests to non-official endpoints update tokens & cost only, without a balance query. - Per-provider breakdown popover: since a session may switch providers
mid-way, the button shows the merged totals; clicking opens a bubble
popover listing each provider's own stats
(
ds-self 268K | ≈¥0.41, non-official rows show "not billed").
Entry button (sidebar footer)
sidebar.footer.actionBalance button: the label and a period dot on the left, the amounts right-aligned ("Balance ¥110.00 | ¥99.50") — currency symbol prefix, digits in green and rolling vertically odometer-style on change. One segment per provider (account), separated by|; an account whose balance could not be fetched (no API key configured / query failed) shows a red--placeholder (hover shows the reason). The period is a small dot (red for peak hours / green for off-peak, half price) whose hover tooltip shows the full info "Currently peak hours · full price billing" / "Currently off-peak hours · half price billing" (full price in red / half price in green); the amounts come from the balance API.- Show in menu preference (default on,
localStoragekeydsh-get-balance.show-in-menu): when off, the entry button renders nothing at all (no placeholder, and no balance polling behind it). The same switch sits at the top of the modal's Prices tab and at the top of the host Settings → Balance section page (settings.section, order 42, which stays reachable and is then the only way back in; both switches share one store, so flipping either updates the other immediately.
Auto refresh
- An Auto button (left of the Refresh button in the modal header) opens a config dialog: set the interval (seconds) → Start/Stop. While running, the input and the Start button are disabled; stopping re-enables them.
- At each interval, balance & cost refresh automatically (the modal when open, the
header button otherwise). The interval is persisted to
$DSH_HOME/dsh-get-balance.jsonand survives restarts.
Config / packaging
- Schemastery
Config(no required options) + a plugin-owned config file$DSH_HOME/dsh-get-balance.json: extra keys, price config and the auto-refresh interval are persisted there (JSON, versioned), never into the host'ssettings.yaml. Existing data stored in the olddsh-balancesettings namespace is migrated automatically on first run. dsh.bundle+dsh.client(web) manifests; the officialdeepseek-harnessproject is never modified — everything rides existing slots and the HTTP / command channel.
Plugin config file
-
Location:
$DSH_HOME/dsh-get-balance.json(next tosettings.yaml). -
Contents (all fields optional on read; invalid values fall back to defaults):
{ "version": 1, "extraKeys": [ { "id": "k1", "label": "main", "apiKey": "sk-..." } ], "prices": { "tiers": [ { "id": "deepseek-v4-flash", "name": "deepseek-v4-flash", "currency": "CNY", "match": "deepseek-v4-flash", "peak": { "input": 2.0, "cacheRead": 0.04, "cacheWrite": 0, "output": 8.0 }, "offPeak": { "input": 1.0, "cacheRead": 0.02, "cacheWrite": 0, "output": 4.0 } } ], "timezoneOffsetMinutes": 480, "peakWindows": [ { "start": "09:00", "end": "12:00" }, { "start": "14:00", "end": "18:00" } ], "weekendOffPeak": false }, "autoRefreshSeconds": 0 } -
It is read on every query and written atomically (temp file + rename) on save, so hand-editing takes effect immediately (no restart). A corrupted file is renamed to
dsh-get-balance.json.bak-<timestamp>and defaults are used.
Layout
├── src/host/*.ts # host half: index.ts, providers.ts (enum + official check),
│ # balance.ts, cost.ts (fold + today scan + period pricing +
│ # official filter), ops.ts, config-file.ts (plugin config
│ # file read/write + legacy settings migration),
│ # fence.ts, types.ts
├── src/client/* # browser half: plugin.tsx (slots + timer), BalanceModal.tsx,
│ # HeaderButton.tsx, FooterButton.tsx, rpc.ts, store.ts,
│ # i18n.ts, styles.ts, logo.ts
├── lib/index.js # host bundle (tsdown ESM), committed for git installs
├── lib/client.js # browser bundle (__ModuleLoader__ factory), committed
├── lib/types/ # declarations (tsc -b)
├── scripts/ # verify-client.mjs
├── tsdown.config.ts # tsdown config (host + client banner wrap)
├── tsconfig.json # solution: tsconfig.host.json / tsconfig.client.json
├── cordis.patch.yml # bundle patch
├── package.json # dsh.bundle + dsh.client(web) manifests + peerDependencies
├── README.md # this file (English, default)
└── README.zh-CN.md # Simplified Chinese docs
Install
# published: npm / tarball / GitHub
dsh plugin --profile web add dsh-get-balance
dsh plugin --profile web add ./dsh-get-balance-0.1.0.tgz
dsh plugin --profile web add github:you/dsh-get-balance#<sha>
dsh --profile web --dump-config # inspect the plugin layer
dsh --profile web # start
No static config required: extra keys, price config and the auto-refresh interval
are edited in the modal and persisted to $DSH_HOME/dsh-get-balance.json.
Release
Toolchain: tsc + tsdown (no vite): tsc -b type-checks and emits
declarations; tsdown (Rolldown core) bundles the host half
(lib/index.js, ESM) and the browser half (lib/client.js, single-file CJS
__ModuleLoader__ factory). Dependency manager: pnpm 10.
pnpm install # per pnpm-lock.yaml
pnpm run build # clean lib → tsc -b (types + declarations) → tsdown (both halves)
pnpm run verify # simulate the host seed to validate lib/client.js (optional)
pnpm publish # or pnpm pack / git push origin main (lib/ committed → git installs need no build)
Auto publish (GitHub Actions)
Pushing a v* tag (pnpm run release bumps the patch version, rebuilds and
tags) triggers .github/workflows/publish.yml:
- release job: Setup Node →
pnpm install --frozen-lockfile→pnpm run check→pnpm run build→pnpm pack→ create GitHub Release; - publish-npm job: publish to npm — requires the
NPM_TOKENrepo secret.
Development
Requirements: Node ≥ 26 + pnpm 10 (pinned via the packageManager field).
pnpm install # devDependencies: typescript, tsdown, @types/react, @deepseek-ai/* type packages…
pnpm run check # full-tree TypeScript check (tsc -b)
pnpm run build # rebuild both bundles after source changes (tsc -b && tsdown)
pnpm run verify # validate lib/client.js against a simulated host seed
Attach the local checkout to a dsh instance (from the plugin repo):
cd dsh-get-balance
dsh plugin --profile web add ./
The host loads
index.jsas native Node ESM, so@deepseek-ai/schemastery,@deepseek-ai/dsh-tools,@deepseek-ai/dsh-settings,@deepseek-ai/dsh-home-pathsmust resolve from the plugin directory (runpnpm installthere;node_modulesis gitignored). Host-half (src/host/) changes need a dsh restart; browser-half (src/client/) changes apply on a page refresh.
- Host half lives in
src/host/; browser half insrc/client/; - The
window.__ModuleLoader__.loadfactory wrap oflib/client.jsis generated by tsdown's banner/intro/footer; external deps (reactetc.) resolve through the host module table (seed) at runtime.
Implementation notes
- Browser ↔ host: HTTP route
/dsh-balance/api(POST JSON, hostwebServer+ trust fence) with actx.remote.commands.executefallback; errors carry acodethat the client localizes. - Credentials: the
credentialsservice is looked up lazily per request (not captured at apply time), avoiding a "no credential" state when the host service starts late; the providers op returnscredentialsPresentand per-entrykeySourcediagnostics. - Pricing:
(uncachedInput × p_input + cacheRead × p_cacheRead + cacheWrite × p_cacheWrite + output × p_output) / 1e6per million tokens, priced by each event's time (peak/off-peak). - Official filter:
request/contextprovider→ baseURL in host settings → hostname ==api.deepseek.com; non-official tokens are counted only (per-provider four buckets), never billed. - Today aggregation:
dshHomePath('sessions')/<projectKey>/<sessionId>/session.jsonl(.zstd), decoded frame-wise viazstdDecompressSyncfromnode:zlib. - Peer deps (
@deepseek-ai/cordis, dsh-tools, schemastery, dsh-settings, dsh-commands, dsh-session, dsh-api-remotes, client runtime / ui-slots / ui-settings / cordis-client-runner,react) are resolved by the host at install. - The official
deepseek-harnessproject is never modified; everything uses existing slots (sidebar.footer.action,settings.section,shell.overlay,conversation.session.header.utilities) and the HTTP / command channel. - Style isolation: every rule in the injected stylesheet is scoped to
.dshb-*with one deliberate exception —:where(div:has(> [data-slot="sidebar.footer.action"] > .dshb-footer-group)){flex-direction:column}, which stacks the host footer container (the host lays it out as a flex row, so several plugin entries would squeeze onto one line). It can only match a container that already holds this plugin's own entry, and:where()drops its specificity to 0 so the host can always override it. Keyframe names aredshb--prefixed and the style tag is markeddata-plugin-css="dsh-get-balance/settings.css"; no other global selector, no:root/body/*rule, no body-style mutation.