# @zoytown/dsh-billing

[English](README.md) | 中文

`@zoytown/dsh-billing` 是一个**用于查看 DeepSeek API 账户余额的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)（`dsh`）插件**。它读取平台的 `GET /user/balance` 端点，并以三种方式呈现结果：侧边栏底部的余额胶囊、设置里的「余额」页、以及 `/balance` 命令。它**不注册任何面向模型的工具**，也**不追加任何 session 事件**，因此挂载它对会话零成本。

| 侧边栏 | 设置 → 余额 |
|---|---|
| ![dsh 侧边栏底部显示 DeepSeek 余额胶囊 ¥25.00，位于「设置」行旁边](assets/sidebar-capsule.png) | ![DeepSeek Harness 设置弹窗中选中「余额」栏，显示 ¥25.00 CNY 及充值/赠金拆分](assets/settings-balance.png) |

## 平台实际提供了什么

只有当前余额。没有用量或消费明细端点——`/usage` 与 `/dashboard/billing/usage` 均返回 404——所以本包报告的是**还剩多少**，而非**花了多少**。任何「本次会话花费」都只能是基于 token 数的本地估算，那是另一件事，刻意不在本包范围内。

```json
{
  "is_available": true,
  "balance_infos": [
    { "currency": "CNY", "total_balance": "25.00", "granted_balance": "0.00", "topped_up_balance": "25.00" }
  ]
}
```

`balance_infos` 是**数组**——一个账户可能同时持有 CNY 和 USD——本包所有消费者都完整渲染，而不是只取第一项。

## 安装

```sh
dsh plugin --profile web add @zoytown/dsh-billing
```

从 npm 安装拿到的是预构建产物，无需任何构建授权。从 git 安装（`github:zoyluoblue/deepseek-harness-billing`）只会拉到源码而不会触发构建，**暂不支持**，见[已知限制](#已知限制)。

bundle 插入三行配置——服务（同时也是浏览器行）、`/balance` 命令、以及 UI 的数据路由。三者互不依赖；在自己 profile 的 `cordis.patch.yml` 里按 id 禁用任意一行即可。

## 配置

| 键 | 默认值 | 含义 |
|---|---|---|
| `apiKey` | 省略 | 字面量密钥。优先用 `apiKeyEnv`，避免密钥进入配置文件；非空字面量优先级更高。带 `role('secret')`，因此永不随 `describe()` 响应外泄。 |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 凭据引用，**每次读取时**经 `ctx.credentials` 解析；该接缝不存在时回退到启动环境。复用 LLM 适配器的同一个密钥——本包不新增密钥。 |
| `baseURL` | `https://api.deepseek.com` | 余额端点基址，会追加 `/user/balance`。回退到 `$DEEPSEEK_BILLING_BASE_URL`。 |
| `cacheTtlMs` | `60000` | 一次成功快照的保鲜时长。 |
| `timeoutMs` | `10000` | 单次请求的中止上限。 |
| `lowBalanceThreshold` | `10` | 低于此值告警。`0` 关闭该下限，仅保留平台自身的 `is_available` 判定。 |

### 为什么不复用 `$DEEPSEEK_BASE_URL`

那个变量用于引导 chat-completions 适配器，而用户完全有正当理由把它指向网关或自建端点。`/user/balance` **仅**存在于官方平台，复用它会让一套正常工作的代理配置在侧边栏里变成永久 404。因此该端点使用独立变量——这与 [`dsh-web-search-deepseek`](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/web/web-search-deepseek) 为搜索单独设变量的处理一致。

若 `baseURL` 不提供该路径，失败会归类为 `ENDPOINT_UNAVAILABLE`，并在文案中点明这个原因，而不是抛出一个泛化的 HTTP 错误。

## 缓存

策略集中在一处，因为三个界面可能同时发问，而这是一个**账户端点**、官方未文档化其限流：

- 一次成功快照在 `cacheTtlMs` 内直接复用；
- 并发请求共享**同一个**在途请求，且任一调用方的取消**不会**取消其他人共同等待的那次读取；
- **失败永不进缓存**——下次询问会重试，同时保留上一次成功的快照，供界面在错误旁一并展示；
- 本引用的 `credentials/updated` 提交后立即失效缓存。

**不做后台轮询**。新鲜度由消费者的询问驱动。

## 错误

`BillingError.code` 是界面的分支依据。失败绝不能渲染成余额为零：「没钱了」和「查不到」是两件不同的事。

| Code | 触发原因 |
|---|---|
| `CREDENTIAL_MISSING` | 无任何来源提供该引用；不会发出请求。 |
| `UNAUTHORIZED` | HTTP 401/403。 |
| `ENDPOINT_UNAVAILABLE` | HTTP 404——几乎总是 `baseURL` 指向了网关。 |
| `RATE_LIMITED` | HTTP 429。 |
| `HTTP_ERROR` | 其它非 2xx。 |
| `MALFORMED_RESPONSE` | HTTP 200 但响应体不是余额文档。 |
| `NETWORK_ERROR` | 传输失败、超时、基址无法解析，或重定向被拒。 |
| `ABORTED` | 调用方取消。 |

两个朴素客户端会踩、而本包已处理的细节：密钥无效时端点返回 JSON `error.message`，但**完全没有** `Authorization` 头时返回的是**纯文本**，因此响应体不会被无条件按 JSON 解析；以及使用 `redirect: 'error'` 在联系 `Location` 目标**之前**拒绝重定向，因为跟随重定向会把 bearer token 带到另一个主机。

## 命令

| 命令 | 效果 |
|---|---|
| `/balance` | 渲染余额，使用缓存。 |
| `/balance refresh` | 同上，但忽略未过期的缓存。 |

## Web UI

两个浏览器界面，共用同一个控制器：在胶囊已经在读取时打开设置页，会**加入**那次读取而不是再发一个请求。

| 界面 | 挂载点 | 展示内容 |
|---|---|---|
| 侧边栏胶囊 | `sidebar.footer.action` | 设置按钮旁的金额；56px 折叠轨下退为 32px 图标 + 状态角标 |
| 设置 → 余额 | `settings.section` | 全部币种、充值/赠金拆分、当前阈值 |

胶囊区分五种状态，整个设计的支点是：**失败渲染破折号，绝不渲染数字**——「没钱了」和「查不到」不能长得一样。`unconfigured` 用虚线轮廓且完全不出数字；`low` 是唯一允许抢注意力的状态，且琥珀色始终与警告三角同时出现，颜色永不是唯一信号。折叠轨的角标只在 `low` 和 `error` 出现：余额健康时没有理由在余光里闪。

样式只使用 `--dsw-alias-*` 语义 token——本插件不定义主题、不写任何明暗选择器，两套主题都由 `ui-theme` 继承而来。

### 数据通道

浏览器半边读取 `billing-route` 行提供的 `GET /billing/balance`。之所以用普通的 webserver 路由而非 Typert Remote：Remote 需要主仓代码生成的调用描述符，仓外的包无法产出。

该路由返回的是账户数据，因此自带浏览器信任围栏，防御本地 HTTP API 会打开的两条「代理人混淆」路径——**DNS rebinding**（页面把自己的域名解析到 127.0.0.1，套接字打到本服务而 `Host` 写的是攻击者域名）与普通的**跨站读取**。`Host` 必须是 loopback 或列在 `trustedHosts` 中，且浏览器附带的 Fetch-Metadata 必须表明同源。它比主仓自己的 `/api` 围栏**更严格**：不推导任何 LAN IP 授权，loopback 之外的一切都必须显式声明。

```yaml
- id: billing-route
  config:
    trustedHosts: []   # 仅当部署到本机之外时，填入 "host" 或 "host:port"
```

这不是身份认证。它阻止浏览器被当作通往 loopback 的代理，但不识别调用方。

## 常见问题

### DeepSeek 余额怎么查？

调用 `GET https://api.deepseek.com/user/balance`，带 `Authorization: Bearer <DEEPSEEK_API_KEY>` 请求头。返回 `is_available` 和一个 `balance_infos` 数组，每个币种一条。本插件把这个端点接进 DeepSeek Harness，让余额出现在侧边栏、设置页和 `/balance` 命令里。

### dsh 插件怎么安装？

`dsh plugin --profile <名称> add <包名>`。本插件：

```sh
dsh plugin --profile web add @zoytown/dsh-billing
```

该命令会把包装进 profile，并把它的 bundle 追加到 profile 的 `dsh.profile.bundles` 列表。卸载用 `dsh plugin --profile web remove @zoytown/dsh-billing`。

### 为什么余额显示的是「—」而不是数字？

因为这次读取失败了——插件绝不会显示一个它并不掌握的数字。破折号表示「查不到」，这与「余额为零」是刻意区分开的两种状态。打开 设置 → 余额 可以看到分类后的具体原因（密钥无效、端点不存在、被限流、网络错误）。

### 这个插件能看花了多少钱吗？

不能。DeepSeek 平台没有提供用量或消费明细端点——`/usage` 与 `/dashboard/billing/usage` 均返回 404——所以本插件只报告剩余余额。任何「本次会话花费」都只能是基于 token 数的本地估算，本包刻意不做。

### 能配合网关或自建 DeepSeek 端点用吗？

聊天补全可以，余额查询不行。`/user/balance` 只存在于官方平台，因此本插件使用独立的 `baseURL`（回退到 `$DEEPSEEK_BILLING_BASE_URL`），绝不复用 `$DEEPSEEK_BASE_URL`。若 `baseURL` 不提供该路径，会失败为 `ENDPOINT_UNAVAILABLE` 并在文案里点明这个原因。

### 需要单独再配一个 API key 吗？

不需要。它通过 `ctx.credentials` 解析的是 LLM 适配器用的同一个 `DEEPSEEK_API_KEY` 凭据引用。在「模型」页轮换密钥后，下一次余额查询即刻生效，无需重启。

### 会消耗 token 吗？

不会。它不注册面向模型的工具、不贡献 system prompt 段落、不追加 session 事件。命令结果由 UI 适配器直接渲染，永不进入模型历史。

## Model Experience

无。本包不注册工具、不贡献 system prompt 段落、不追加 session 事件。命令结果由 UI 适配器直接渲染，永不进入模型历史。

#### Token effect

零。注册与调用都不会到达任何模型请求。

#### KV Cache effect

无；本包不向请求前缀写入任何内容。

## 已知限制

- **告警下限是一个裸数字，按每币种、以该币种自身单位比较。** 在 CNY/USD 混合账户上，一个按 CNY 设定的 `10` 也会把 `$8.40` 标记为告警。正确解法是 per-currency 映射，推迟到真有多币种账户需要时再做；在此之前可用 `lowBalanceThreshold: 0` 关闭下限。
- **不提供消费/用量报告。** 平台未暴露此类端点，见[上文](#平台实际提供了什么)。
- **不支持 git 安装。** 本包未提供 `prepare` 脚本，`dsh plugin add github:…` 只会装到未构建的源码。请从 npm 安装，或使用 `pnpm pack` 产出的 tarball——两者都是预构建产物，无需构建授权。
- **胶囊点击是刷新，不是跳转。** 点它会重新读取余额；从它直接打开「余额」设置页需要设置外壳暴露一个「打开设置」服务，而目前没有。
- **胶囊只显示一个币种。** 侧边栏胶囊放不下多个，因此显示平台列出的第一个币种，完整列表在设置页。它绝不跨币种求和——把 CNY 加到 USD 上是个凭空捏造的数字。
- **浏览器半边假定同源服务。** 它请求相对路径，Web 应用满足这一点；以 `file://` 加载并通过 IPC 桥接 fetch 的 Electron 外壳需要另外的传输方式。
- **余额新鲜度由拉取驱动。** 没有轮询，两次询问之间发生的余额下降不会被察觉，直到下一次有人询问。
- **金额不做二次格式化。** 平台返回的十进制字符串原样透传到展示层，因此平台若以非预期形态报告某币种，界面就照该形态呈现。


本仓库的工程约定与可发现性（SEO / GEO / AEO）规范见 [DEVELOPMENT.md](DEVELOPMENT.md)。

## 许可

MIT
