Back to home

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.

SidebarSettings → Balance
The dsh sidebar foot showing a DeepSeek balance capsule reading ¥25.00 beside the Settings rowThe DeepSeek Harness Settings dialog with a Balance section selected, showing ¥25.00 CNY split into topped-up and granted amounts

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

KeyDefaultMeaning
apiKeyomittedLiteral key. Prefer apiKeyEnv so no secret enters configuration; a non-empty literal wins. Carries role('secret'), so it never rides a describe() response.
apiKeyEnvDEEPSEEK_API_KEYCredential 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.
baseURLhttps://api.deepseek.comBilling endpoint base; /user/balance is appended. Falls back to $DEEPSEEK_BILLING_BASE_URL.
cacheTtlMs60000How long a successful snapshot stays fresh.
timeoutMs10000Per-request abort ceiling.
lowBalanceThreshold10Warn 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/updated for 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.

CodeCause
CREDENTIAL_MISSINGNothing supplies the reference; no request is sent.
UNAUTHORIZEDHTTP 401/403.
ENDPOINT_UNAVAILABLEHTTP 404 — almost always a baseURL pointing at a gateway.
RATE_LIMITEDHTTP 429.
HTTP_ERRORAny other non-2xx.
MALFORMED_RESPONSEHTTP 200 whose body is not a balance document.
NETWORK_ERRORTransport failure, timeout, unparseable base, or a refused redirect.
ABORTEDThe 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

CommandEffect
/balanceRender the balance, using the cache.
/balance refreshSame, 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.

SurfaceSlotWhat it shows
Sidebar capsulesidebar.footer.actionThe amount beside Settings; a 32px icon with a status pip in the 56px rail
Settings → Balancesettings.sectionEvery 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 10 chosen with CNY in mind also flags a $8.40 balance. A per-currency map is the fix and is deferred until a real multi-currency account needs it; lowBalanceThreshold: 0 disables the floor meanwhile.
  • No spend or usage reporting. The platform exposes no such endpoint; see above.
  • No git-install support. The package ships no prepare script, so dsh plugin add github:… would install sources without a build. Install from npm, or from a pnpm pack tarball; 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