zoyluoblue
deepseek-harness-billing
DeepSeek Harness (dsh) plugin for DeepSeek API account balance — sidebar indicator, Settings page, and /balance command. Reads GET /user/balance.
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 17, 2026
- Updated
- Aug 17, 2026
Introduction
@zoytown/dsh-billing
English | 中文
@zoytown/dsh-billing is a DeepSeek Harness (dsh) plugin that shows your DeepSeek API account balance. It reads the platform's GET /user/balance endpoint and surfaces the result three ways: a capsule at the sidebar foot, a Balance section in Settings, and the /balance command. It registers no model-facing tool and appends no session event, so mounting it costs the conversation nothing.
| Sidebar | Settings → Balance |
|---|---|
![]() | ![]() |
What the platform actually exposes
Only the current balance. There is no usage or spend-history endpoint — /usage and /dashboard/billing/usage both return 404 — so this package reports what is left, never what was spent. Any per-session cost figure would be a local estimate from token counts, which is a separate concern and deliberately out of scope here.
{
"is_available": true,
"balance_infos": [
{ "currency": "CNY", "total_balance": "25.00", "granted_balance": "0.00", "topped_up_balance": "25.00" }
]
}
balance_infos is an array — an account can hold CNY and USD at once — and every consumer here renders all of it rather than the first entry.
Install
dsh plugin --profile web add @zoytown/dsh-billing
Installing from npm gets prebuilt code and needs no build permission. A git install (github:zoyluoblue/deepseek-harness-billing) fetches sources without running a build, so it is not supported yet — see Known limitations.
The bundle inserts three rows — the service (which is also the browser row), the /balance command, and the UI's data route. Each is useful without the others; disable any of them by id in your profile's cordis.patch.yml.
Config
| Key | Default | Meaning |
|---|---|---|
apiKey | omitted | Literal key. Prefer apiKeyEnv so no secret enters configuration; a non-empty literal wins. Carries role('secret'), so it never rides a describe() response. |
apiKeyEnv | DEEPSEEK_API_KEY | Credential reference resolved through ctx.credentials per read, or from the launch environment when that seam is absent. Reuses the LLM adapter's key — this package adds no new secret. |
baseURL | https://api.deepseek.com | Billing endpoint base; /user/balance is appended. Falls back to $DEEPSEEK_BILLING_BASE_URL. |
cacheTtlMs | 60000 | How long a successful snapshot stays fresh. |
timeoutMs | 10000 | Per-request abort ceiling. |
lowBalanceThreshold | 10 | Warn below this amount. 0 disables the floor, leaving the platform's own is_available verdict. |
Why not $DEEPSEEK_BASE_URL
That variable steers the chat-completions adapter, and users legitimately point it at gateways and self-hosted endpoints. /user/balance exists only on the official platform, so reusing it would turn a working proxy setup into a permanent 404 in the sidebar. The endpoint gets its own variable, exactly as dsh-web-search-deepseek gives its own to search.
A baseURL that does not serve the path fails as ENDPOINT_UNAVAILABLE with a message naming this cause, rather than as a generic HTTP error.
Caching
One policy in one place, because three surfaces can ask at the same moment and this is an account endpoint with no documented rate limit:
- a successful snapshot is served for
cacheTtlMs; - concurrent asks share one in-flight request, and one caller's cancellation never cancels the read the others joined;
- a failure is never cached — the next ask retries, and the last good snapshot is retained so a surface can show it beside the error;
- a committed
credentials/updatedfor this reference invalidates immediately.
There is no background polling. Freshness is driven by consumers asking.
Errors
BillingError.code is the branch point for a UI. A failed read must never render as a zero balance: "out of money" and "could not tell" are different facts.
| Code | Cause |
|---|---|
CREDENTIAL_MISSING | Nothing supplies the reference; no request is sent. |
UNAUTHORIZED | HTTP 401/403. |
ENDPOINT_UNAVAILABLE | HTTP 404 — almost always a baseURL pointing at a gateway. |
RATE_LIMITED | HTTP 429. |
HTTP_ERROR | Any other non-2xx. |
MALFORMED_RESPONSE | HTTP 200 whose body is not a balance document. |
NETWORK_ERROR | Transport failure, timeout, unparseable base, or a refused redirect. |
ABORTED | The caller cancelled. |
Two wire details this handles that a naive client does not: the endpoint returns a JSON error.message for an invalid key but plain text when the Authorization header is absent entirely, so the body is never parsed as JSON unconditionally; and redirects are refused with redirect: 'error' before the Location target is contacted, because following one would carry the bearer token to another host.
Commands
| Command | Effect |
|---|---|
/balance | Render the balance, using the cache. |
/balance refresh | Same, ignoring a fresh cache entry. |
Web UI
Two browser surfaces, both backed by one shared controller so opening the settings page while the capsule is already reading joins that read instead of firing a second one.
| Surface | Slot | What it shows |
|---|---|---|
| Sidebar capsule | sidebar.footer.action | The amount beside Settings; a 32px icon with a status pip in the 56px rail |
| Settings → Balance | settings.section | Every currency, the granted/topped-up split, and the current threshold |
The capsule distinguishes five states, and the rule the whole design turns on is that a failed read renders an em dash, never a number — "out of money" and "could not tell" must not look alike. unconfigured shows a dashed outline and no digits at all; low is the only state allowed to draw the eye, and it pairs amber with a warning triangle so colour is never the sole signal. The rail pip appears only for low and error: a healthy balance has no business flashing in peripheral vision.
Styling uses --dsw-alias-* semantic tokens exclusively — this plugin defines no theme, ships no light/dark selectors, and inherits both themes from ui-theme.
The data channel
The browser half reads GET /billing/balance from the billing-route row. It is a plain webserver route rather than a Typert Remote because a Remote needs a generated invocation descriptor from the harness's own codegen, which an out-of-tree package cannot produce.
That route answers account data, so it carries its own browser-trust fence against the two confused-deputy paths a local HTTP API opens — DNS rebinding (a page resolves its own domain to 127.0.0.1 so the socket reaches this server while Host names the attacker) and ordinary cross-site reads. Host must be loopback or listed in trustedHosts, and any attached Fetch-Metadata must say same-origin. It is deliberately stricter than the harness's own /api fence: no LAN-IP grants are derived, so anything past loopback must be named explicitly.
- id: billing-route
config:
trustedHosts: [] # add "host" or "host:port" only for an off-machine deployment
This is not authentication. It stops a browser being used as a proxy into loopback; it does not identify callers.
FAQ
How do I check my DeepSeek API account balance?
Call GET https://api.deepseek.com/user/balance with an Authorization: Bearer <DEEPSEEK_API_KEY> header. It returns is_available plus a balance_infos array holding one entry per currency. This plugin wraps that endpoint for DeepSeek Harness so the balance appears in the sidebar, in Settings, and via /balance.
How do I install a dsh plugin?
dsh plugin --profile <name> add <package>. For this one:
dsh plugin --profile web add @zoytown/dsh-billing
The command installs the package into the profile and appends its bundle to the profile's dsh.profile.bundles list. Remove it again with dsh plugin --profile web remove @zoytown/dsh-billing.
Why does my balance show "—" instead of a number?
Because the read failed — the plugin never prints a number it does not have. An em dash means "could not tell", which is deliberately distinct from a zero balance. Open Settings → Balance to see the classified reason (invalid key, endpoint unavailable, rate limited, network error).
Does this plugin show how much I have spent?
No. The DeepSeek platform exposes no usage or spend-history endpoint — /usage and /dashboard/billing/usage both return 404 — so this plugin reports only the remaining balance. Any per-session cost figure would be a local estimate derived from token counts, which this package deliberately does not do.
Can I use it with a gateway or a self-hosted DeepSeek endpoint?
Only for the chat-completions traffic, not for the balance. /user/balance exists only on the official platform, so this plugin uses its own baseURL (falling back to $DEEPSEEK_BILLING_BASE_URL) and never reuses $DEEPSEEK_BASE_URL. A baseURL that does not serve the path fails as ENDPOINT_UNAVAILABLE with a message naming that cause.
Do I need a second API key?
No. It resolves the same DEEPSEEK_API_KEY credential reference the LLM adapter uses, through ctx.credentials. Rotating the key in the Models page reaches the next balance query without a restart.
Does it cost tokens?
No. It registers no model-facing tool, contributes no system-prompt section, and appends no session event. Command results are rendered by the UI adapter and never enter model history.
Model Experience
None. This package registers no tool, contributes no system-prompt section, and appends no session event. Command results are rendered by the UI adapter and never enter model history.
Token effect
Zero. Neither registration nor invocation reaches a model request.
KV Cache effect
None; nothing here enters a request prefix.
Known limitations
- The warning floor is a bare number applied per currency, in that currency's own units. On a mixed CNY/USD account a threshold of
10chosen with CNY in mind also flags a$8.40balance. A per-currency map is the fix and is deferred until a real multi-currency account needs it;lowBalanceThreshold: 0disables the floor meanwhile. - No spend or usage reporting. The platform exposes no such endpoint; see above.
- No git-install support. The package ships no
preparescript, sodsh plugin add github:…would install sources without a build. Install from npm, or from apnpm packtarball; both carry prebuilt code and need no build permission. - The capsule refreshes rather than deep-linking. Clicking it re-reads the balance; opening the Balance settings page from it would need an "open settings" service the settings shell does not expose.
- The capsule shows one currency. A sidebar pill has no room for several, so it renders the first currency the platform listed and the settings page renders all of them. It never sums across currencies — adding CNY to USD would be a fabricated number.
- The browser half assumes a same-origin server. It fetches a relative path, which the Web app satisfies; an Electron shell loading
file://and bridging fetch over IPC would need its own transport. - Balance freshness is pull-driven. With no polling, a balance that drops between two asks is not noticed until something asks again.
- Amounts are never re-formatted. The platform's decimal strings pass through to presentation untouched, so a currency the platform reports in an unexpected shape renders in that shape.
Engineering conventions and the discoverability (SEO / GEO / AEO) rules for this repository live in DEVELOPMENT.md.
License
MIT

