# @zoytown/dsh-billing

English | [中文](README.zh.md)

`@zoytown/dsh-billing` is a **[DeepSeek Harness](https://github.com/deepseek-ai/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 |
|---|---|
| ![The dsh sidebar foot showing a DeepSeek balance capsule reading ¥25.00 beside the Settings row](assets/sidebar-capsule-en.png) | ![The DeepSeek Harness Settings dialog with a Balance section selected, showing ¥25.00 CNY split into topped-up and granted amounts](assets/settings-balance-en.png) |

## 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.

```json
{
  "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

```sh
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](#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`](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/web/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.

| 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.

```yaml
- 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:

```sh
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](#what-the-platform-actually-exposes).
- **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](DEVELOPMENT.md).

## License

MIT
