# dsh-get-balance

DeepSeek Harness（dsh）余额与费用查询插件：

- **多账号查询**：一次枚举全部 DeepSeek 服务商（pi-ai 路由 / 官方路由 / 附加 Key），同一账号自动合并为一行，各账号余额一目了然；
- **实时统计**：会话内 token 用量实时统计，按用量实时估算费用，价格档在线可配（模型 × 高峰/空闲时段）；
- **中英双语**：界面文案自动跟随宿主语言；
- **简单易用**：统一弹框（余额 / 费用 / 价格设置）+ 侧边栏入口 + 会话头部实时按钮，交互直观、开箱即用。

![dsh-get-balance](assets/preview/1.png)

[English](README.md) · [界面预览](preview.md)

## 功能总览

| 功能 | 入口 | 说明 |
| --- | --- | --- |
| 余额查询 | 弹框 · 余额 Tab | **每个 API Key 行**展示「**今日消耗 ≈xx CNY \| 余额 xx CNY**」（数字绿色，消耗按该 key 的路由从费用统计匹配，无用量为 ≈0.00）；枚举全部 DeepSeek 服务商（`llm-pi-ai` 深链条目 / 官方 `llm-deepseek` 路由 / 附加 Key），经宿主 `credentials` 解析 key 调用官方 `/user/balance`；每行独立展示余额状态或失败原因。**同一账号折叠为一行**：多个路由解析到同一 API key（如 pi-ai 路由名为 `deepseek` 派生凭据引用 `DEEPSEEK_API_KEY`，与官方 `llm-deepseek` 默认引用撞名）时只显示一行，行上以 chip 标注每个共享该 key 的路由（悬停提示「与 xx 共用同一 API Key」），余额按唯一 key 只查询一次，同一账号不会以不同名称重复展示 |
| 费用估算 | 弹框 · 费用 Tab | **表格展示**：列为 token / 分类 / 输入（未命中）/ 输入（缓存命中）/ 输出 / 命中率 / 预估费用；每个 API Key 一组、**token 列合并四行**（最近一次提问 / 本会话 / 今日·本项目 / 今日·全部），首组为合计；数字 K/M/B/T/P 缩写（token 不区分官方与否，一律统计；**费用仅官方 Key（api.deepseek.com）计算**，非官方标注「不计费」）。**与余额 Tab 对齐**：已配置 provider 无论有无用量都逐组列出（未用量/未配置凭据者显示 0 用量、金额 —）。**多 provider 严格分账**：token 统计与金额预估按会话事件归属到各自 provider（分组），组内标注来源 chip（pi-ai 路由 / 官方路由）+ 官方/非官方 chip（别名路由按 baseURL 域名判定），互不混合；最近一次提问按每个样本自身模型匹配价格档 |
| 价格设置 | 弹框 · 价格 Tab | **二级平台 tab**（当前仅 DeepSeek，后续扩展其他平台定价）；DeepSeek 页为**官方价格表式排版**；模型 × 高峰/空闲双时段单价；时段窗口 + 时区滑块 + **周六日半价开关**（勾选后周六/周日整天按空闲单价计费）可配置；旧配置自动迁移为官方 V4 三档 |
| 会话头部按钮 | 会话头部 utilities | 实时「**当前会话 xxM \| ≈¥xx**」（token 紧凑缩写与金额均为绿色；数字变化为**上下轮播**动画）；点击即刷新；会话任务完成（宿主 running 回落）后自动刷新。会话中途可能切换 provider：按钮为**合并统计**，点击弹出**气泡弹框**逐 provider 列出（如 `ds-self 268K \| ≈¥0.41`），非官方行金额位显示「不计费」 |
| 侧边栏入口 | footer.action | 「余额」按钮：余额靠右对齐（货币符号前缀、数字绿色、数字变化为**上下轮播**动画），**多账号以 `\|` 分隔逐段显示**（每段对应一个服务商/账号），**取不到余额的账号显示红色 `--`**（悬停提示原因）；时段收敛为小圆点（高峰红 / 空闲绿），悬停气泡提示完整信息「当前为高峰时段 全价计费」/「当前为空闲时段 半价计费」（价词着色：全价红 / 半价绿） |
| 定时更新 | 弹框右上「定时更新」 | 按设定秒数自动刷新余额与费用；配置弹框（启动/停止互斥、输入框禁用）；间隔持久化 |
| 附加 API Key | 弹框 · 余额 Tab 底部 | 手动添加不在 providers 配置中的 key，脱敏回显，持久化到 `$DSH_HOME/dsh-get-balance.json` |
| 入口显隐 | 宿主设置 · 余额分区页 / 弹框 · 价格 Tab 顶部 | 「**在菜单中显示**」滑动开关（默认开启，`localStorage` key `dsh-get-balance.show-in-menu`）：关闭后侧边栏入口按钮渲染为 null（不占位、不再轮询余额）；两处开关同一偏好源，改一处另一处即时同步；入口关闭后仍可从宿主「设置 → 余额」分区页打开插件弹框 |

## 计费与判定口径

| 项 | 规则 |
| --- | --- |
| 官方判定 | 会话 `request/context` 的 `provider` → 宿主设置中 baseURL → **域名 == `api.deepseek.com`**（尾斜杠/大小写归一；`api.deepseek.com.xx.com` 等伪装域名判为非官方） |
| 计费 | 仅官方请求：四桶 × 单价 ÷ 1e6（每百万 tokens），按事件发生时刻匹配高峰/空闲单价 |
| 统计 | 所有服务商/模型的 token 均统计数量；非官方按服务商逐条四桶展示、互不合并 |
| 时段 | 默认北京 9:00–12:00、14:00–18:00 为高峰，其余为空闲；空闲 = 高峰 × 0.5；开启「周六日半价」后周六/周日整天视为空闲 |
| 迁移 | 旧版扁平单价与旧内置默认档首次读取自动升级为官方 V4 三档 |

## 交互刷新路径

| 触发 | 效果 |
| --- | --- |
| 点击会话头部按钮 | 当前会话费用刷新一次 |
| 会话任务完成 | 监听宿主会话快照：**每次 AI 请求完成**（快照新增 assistant 消息，非流式逐 token）即刷新——头部按钮 token 与预估费用立即重算；仅当该请求走 DeepSeek 官方接口（api.deepseek.com）时 footer 余额才同步强制刷新（绕过 60s 缓存），非官方接口的请求不发起余额查询；一轮含多次请求时逐次更新 |
| 点击弹框【刷新】 | 余额刷新（绕过 60s 缓存） |
| 点击弹框【定时更新】 | 打开配置弹框，按设定秒数周期自动刷新（弹框与头部按钮均生效） |

## 结构

```
├── src/host/*.ts       # 宿主半边：index.ts（入口）、providers.ts（服务商枚举、
│                       #   官方域判定）、balance.ts、cost.ts（折叠 + 今日扫描 +
│                       #   峰谷定价 + 官方过滤）、ops.ts（op 分发）、
│                       #   config-file.ts（插件配置文件读写 + 旧 settings 迁移）、
│                       #   fence.ts、types.ts
├── src/client/*        # 浏览器半边：plugin.tsx（slots 注册 + 定时器）、
│                       #   BalanceModal.tsx（三 tab 弹框）、HeaderButton.tsx
│                       #   （会话头部按钮）、FooterButton.tsx（footer 入口）、
│                       #   PluginSettingsPage.tsx（宿主设置分区页）、
│                       #   prefs.ts（本地偏好）、rpc.ts、store.ts、
│                       #   i18n.ts、styles.ts、logo.ts
├── lib/index.js        # 宿主半边产物（tsdown，ESM），提交 git 以支持 git 安装
├── lib/client.js       # 浏览器半边产物（__ModuleLoader__ 工厂），提交 git
├── lib/types/          # 类型声明（tsc -b 生成）
├── scripts/            # verify-client.mjs（模拟宿主 seed 校验）
├── tsdown.config.ts    # tsdown 构建配置（宿主半边 + 客户端 banner 包装）
├── tsconfig.json       # solution：引用 tsconfig.host.json / tsconfig.client.json
├── cordis.patch.yml    # Bundle patch：按包名引用插件行
├── package.json        # dsh.bundle + dsh.client(web) manifest + peerDependencies
├── README.md           # English（默认）
└── README.zh-CN.md     # 本文件（中文）
```

## 安装

```sh
# 已发布：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   # 检查插件层
dsh --profile web                 # 启动
```

插件无需静态配置；附加 key、价格档与定时间隔均在弹框内编辑并持久化到
`$DSH_HOME/dsh-get-balance.json`。

### 插件配置文件

- 位置：`$DSH_HOME/dsh-get-balance.json`（与 `settings.yaml` 同目录）。
- 内容（读取时各字段均可缺省，非法值回退默认）：

  ```json
  {
    "version": 1,
    "extraKeys": [ { "id": "k1", "label": "主账号", "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
  }
  ```

- 每次查询现读文件、保存时原子写入（临时文件 + rename），**外部手改立即生效**
  （无需重启）；文件损坏时自动改名备份为 `dsh-get-balance.json.bak-<时间戳>`
  并回退默认值。
- 旧版本写入宿主 `settings.yaml` 的 `dsh-balance` 段数据会在首次运行时自动
  迁移到该文件，之后不再读写宿主默认设置。

## 发布

构建工具链为 **tsc + tsdown**（无 vite）：`tsc -b` 类型检查并生成声明文件，
`tsdown`（Rolldown 内核）打包宿主半边（`lib/index.js`，ESM）与浏览器半边
（`lib/client.js`，单文件 CJS `__ModuleLoader__` 工厂）。依赖管理使用 **pnpm 10**：

```sh
pnpm install     # 按 pnpm-lock.yaml 安装
pnpm run build   # 清理 lib → tsc -b（类型 + 声明）→ tsdown（双面产物）
pnpm run verify  # 模拟宿主模块表校验 lib/client.js（可选）
pnpm publish     # 或 pnpm pack / git push origin main（lib/ 已提交，git 安装免构建）
```

### 自动发布（GitHub Actions）

推送 `v*` tag（`pnpm run release` 自动 bump patch 版本、重建产物并打 tag）触发
[`.github/workflows/publish.yml`](.github/workflows/publish.yml)：

- **release job**：Setup Node → `pnpm install --frozen-lockfile` →
  `pnpm run check` → `pnpm run build` → `pnpm pack` → 创建 GitHub Release；
- **publish-npm job**：发布到 npm —— 需要仓库 secret `NPM_TOKEN`。

## 开发

要求：**Node ≥ 26 + pnpm 10**（`package.json` 的 `packageManager` 字段锁定版本）。

```sh
pnpm install           # devDependencies：typescript、tsdown、@types/react、@deepseek-ai/* 类型包等
pnpm run check         # 全树 TypeScript 类型检查（tsc -b）
pnpm run build         # 改完源码后重建双面产物（tsc -b && tsdown）
pnpm run verify        # 模拟宿主 seed 表校验 lib/client.js 可加载
```

本地接入 dsh 实例（插件仓库目录）：

```sh
cd dsh-get-balance
dsh plugin --profile web add ./
```

> 宿主以原生 Node ESM 加载 `index.js`，因此 `@deepseek-ai/schemastery`、
> `@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-settings`、
> `@deepseek-ai/dsh-home-paths` 必须可从插件目录解析（`node_modules` 已被
> gitignore），在插件目录内执行 `pnpm install` 即可。宿主半边（`src/host/`）
> 改动需**重启 dsh** 生效；浏览器半边（`src/client/`）改动**刷新页面**即可。

- 宿主半边位于 `src/host/`；浏览器半边位于 `src/client/`；
- `lib/client.js` 的 `window.__ModuleLoader__.load` 工厂包装由 tsdown 的
  banner/intro/footer 选项生成；外部依赖（`react` 等）保持 external，运行时经
  宿主模块表（seed）解析。

## 实现说明

- **浏览器 ↔ 宿主通信**：HTTP 路由 `/dsh-balance/api`（POST JSON，宿主
  `webServer` + 信任围栏），兜底 `ctx.remote.commands.execute`；错误携带
  `code`，客户端本地化。
- **凭据解析**：`credentials` 服务按请求**懒取**（不捕获于 apply 时），规避宿主
  服务晚启动导致的「未配置凭据」；providers op 返回 `credentialsPresent` 与每条
  `keySource`（env / file / project-env / user-env）诊断信息。
- **计费公式**：`(uncachedInput × p_input + cacheRead × p_cacheRead +
  cacheWrite × p_cacheWrite + output × p_output) / 1e6`，单价为每百万 tokens，
  按事件发生时刻匹配高峰/空闲单价。
- **官方过滤**：`request/context` 的 `provider` → 宿主设置中的 baseURL →
  域名 == `api.deepseek.com`；非官方 token 仅计数（逐服务商四桶），不参与金额。
- **今日聚合**：`dshHomePath('sessions')/<projectKey>/<sessionId>/session.jsonl(.zstd)`；
  zstd 经 `node:zlib` 的 `zstdDecompressSync` 逐帧解码。
- peer 依赖（`@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`）由宿主在安装时解析。
- **不修改**官方 `deepseek-harness` 项目；全部功能使用既有插槽
  （`sidebar.footer.action`、`settings.section`、`shell.overlay`、
  `conversation.session.header.utilities`）与 HTTP / 命令通道。
- **样式隔离**：注入的样式表除一条刻意保留的例外，全部限定在 `.dshb-*` 作用域内 ——
  `:where(div:has(> [data-slot="sidebar.footer.action"] > .dshb-footer-group)){flex-direction:column}`
  用于把宿主 footer 容器从默认 flex 横排改为纵向堆叠（否则多个插件入口会被挤在一行）。
  它只可能命中「容器内已存在本插件入口」的那一层，且外层 `:where()` 把优先级压到 0，
  宿主随时可覆盖。动画名统一 `dshb-` 前缀，style 标签带 `data-plugin-css="dsh-get-balance/settings.css"`
  标记；没有其它全局选择器，不写 `:root`/`body`/`*`，也不修改 body 行内样式。
