dsh-balance-widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-balance-widget contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,106 @@
1
+ # dsh-balance-widget
2
+
3
+ [中文](README.md) | **English**
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
7
+ <img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
8
+ <img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
9
+ <img src="https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square" alt="Node 24">
10
+ <img src="https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square" alt="Zero deps">
11
+ </p>
12
+
13
+ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) Web GUI: a 💰 icon in the corner of the conversation session header. Click it to see your DeepSeek account balance and the current session's estimated spend.
14
+
15
+ ## How it differs from similar plugins
16
+
17
+ | Aspect | This plugin | Others (dsh-balance / dsh-token-price / ...) |
18
+ | --- | --- | --- |
19
+ | **Zero external dependencies** | ✅ Imports no `@deepseek-ai/*` packages, no native modules | ❌ Most depend on dsh SDK packages |
20
+ | **Node 24 ready** | ✅ Works out of the box on any profile layout | ⚠️ Many community plugins still error on Node 24 |
21
+ | **Boot stability** | ✅ Registers routes via official `ctx.webServer`; never conflicts with apiproxy | ⚠️ Some self-host HTTP servers that crash `dsh web` on boot |
22
+ | **On-demand queries** | ✅ Fetches balance only on click; no polling, zero background requests | Some always-on badges refresh on a timer |
23
+ | **Peak/off-peak pricing** | ✅ Built-in official 2026-08-17 rate table, auto-switches by window | Partial support |
24
+ | **Security** | ✅ API key stays in the host process; loopback-only guard | Varies |
25
+
26
+ **In one line**: *The zero-dependency, Node 24-ready balance/cost widget that never breaks `dsh web` boot.*
27
+
28
+ ## Features
29
+
30
+ - **Account balance** — On click, the host proxies DeepSeek's official `GET /user/balance` and shows the `¥` balance. The API key is resolved through the host credentials service and never leaves the host process; the browser only talks to same-origin routes.
31
+ - **Session cost (estimate)** — Computed from the session's `tokenUsage` projection × DeepSeek's official peak/off-peak price table. Follows the configured model (default `deepseek-v4-flash`, switchable to `deepseek-v4-pro`) and the Beijing-time peak/off-peak windows automatically.
32
+ - **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
33
+ - **On-demand refresh** — No polling, no background requests; the balance endpoint is only hit when you click the icon. Costs zero tokens to use.
34
+
35
+ ## Why this plugin
36
+
37
+ - **Zero external dependencies** — the host half imports no `@deepseek-ai/*` packages and no native modules, so it loads from any profile layout and works on **Node 24** (many community cordis plugins still lag on Node 24).
38
+ - **Uses official APIs only** — routes are registered through `ctx.webServer` (the same seam `dsh-ssh` uses) with loopback-only guards; no conflicting custom HTTP servers.
39
+
40
+ ## Architecture
41
+
42
+ ```
43
+ host half (lib/index.js)
44
+ ctx.webServer.register:
45
+ GET /api/dsh-balance/balance → official /user/balance (loopback-only guard)
46
+ GET /api/dsh-balance/cost → reserved route (cost priced client-side)
47
+
48
+ client half (lib/client.js)
49
+ ctx.slots.inject("conversation.session.header.utilities")
50
+ → 💰 icon (session header, top-right corner)
51
+ → click fetches same-origin /api/dsh-balance/balance → popover with balance + cost + tokens
52
+ ```
53
+
54
+ ## Installation
55
+
56
+ From npm (once published):
57
+
58
+ ```sh
59
+ dsh plugin --profile web add dsh-balance-widget
60
+ ```
61
+
62
+ From GitHub (development):
63
+
64
+ ```sh
65
+ git clone https://github.com/LL-cmyk-so/dsh-balance-widget.git
66
+ cd dsh-balance-widget
67
+ dsh plugin --profile web add "link:$(pwd)"
68
+ ```
69
+
70
+ Then restart `dsh web`.
71
+
72
+ ## Configuration
73
+
74
+ In `~/.dsh/profiles/web/cordis.patch.yml`:
75
+
76
+ ```yaml
77
+ - id: balance-widget
78
+ name: dsh-balance-widget
79
+ config:
80
+ balanceBaseURL: https://api.deepseek.com # official balance endpoint
81
+ balanceApiKeyEnv: DEEPSEEK_API_KEY # credential ref for the API key
82
+ requestTimeoutMs: 5000 # balance request timeout
83
+ modelId: deepseek-v4-flash # pricing model (or deepseek-v4-pro)
84
+ ```
85
+
86
+ ## Pricing
87
+
88
+ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective 2026-08-17. Peak windows are Beijing time 09:00–12:00 and 14:00–18:00; prices are double the off-peak rates:
89
+
90
+ | Model | Window | Cache hit (input) | Cache miss (input) | Output |
91
+ | --- | --- | --- | --- | --- |
92
+ | V4-Flash | Off-peak | 0.05 | 1.5 | 4.5 |
93
+ | V4-Flash | Peak | 0.10 | 3.0 | 9.0 |
94
+ | V4-Pro | Off-peak | 0.15 | 4.5 | 13.5 |
95
+ | V4-Pro | Peak | 0.30 | 9.0 | 27.0 |
96
+
97
+ `deepseek-chat` / `deepseek-reasoner` aliases map to Flash / Pro pricing respectively. Costs are **estimates**; actual billing from the provider is authoritative.
98
+
99
+ ## Verify
100
+
101
+ - Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
102
+ - Balance route: after restarting dsh web, `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` should return `{ ok, balance_infos, modelId }`.
103
+
104
+ ## License
105
+
106
+ MIT
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # dsh-balance-widget
2
+
3
+ **中文** | [English](README.en.md)
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
7
+ <img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
8
+ <img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
9
+ <img src="https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square" alt="Node 24">
10
+ <img src="https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square" alt="Zero deps">
11
+ </p>
12
+
13
+ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右上角(角落)渲染一个 💰 图标,点击弹出账户余额与本会话的估算成本。
14
+
15
+ ## 与同类插件的区别
16
+
17
+ | 特点 | 本插件 | 同类插件(dsh-balance / dsh-token-price 等) |
18
+ | --- | --- | --- |
19
+ | **零外部依赖** | ✅ 不 import 任何 `@deepseek-ai/*` 包,无原生模块 | ❌ 多数依赖 dsh SDK 包 |
20
+ | **Node 24 兼容** | ✅ 天然兼容(零依赖设计),任何 profile 布局可加载 | ⚠️ 不少社区插件在 Node 24 下报错 |
21
+ | **启动稳定性** | ✅ 用官方 `ctx.webServer` 注册路由,不与 apiproxy 冲突 | ⚠️ 有的自建 HTTP 服务导致 dsh web 启动崩溃 |
22
+ | **按需查询** | ✅ 点击才查余额,无轮询、零后台请求 | 有的常驻徽章定时刷新 |
23
+ | **峰谷定价** | ✅ 内置官方 2026-08-17 峰谷价表,自动按时段切换 | 部分支持 |
24
+ | **安全性** | ✅ API key 仅在宿主进程,loopback-only 守卫 | 参差不齐 |
25
+
26
+ **一句话**:*零依赖、Node 24 就绪、永不拖垮 dsh web 启动的余额/成本小部件。*
27
+
28
+ ## 功能
29
+
30
+ - **账户余额** — 点击图标时经宿主代理查询 DeepSeek 官方 `GET /user/balance`,展示 `¥` 余额;API key 只在宿主进程内读取(凭据服务),浏览器不接触密钥。
31
+ - **本会话成本(估算)** — 由会话的 `tokenUsage` 投影 × DeepSeek 官方峰谷定价表计算,随当前会话模型(默认 `deepseek-v4-flash`,可在配置中改为 `deepseek-v4-pro`)与北京时间高峰/空闲时段自动切换。
32
+ - **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
33
+ - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起余额查询,不消耗任何 token。
34
+
35
+ ## 架构
36
+
37
+ ```
38
+ host 半区 (lib/index.js)
39
+ ctx.webServer.register:
40
+ GET /api/dsh-balance/balance → 官方 /user/balance(loopback-only 守卫)
41
+ GET /api/dsh-balance/cost → 保留路由(成本目前客户端计价)
42
+ 依赖:零外部 @deepseek-ai/* import,任何 profile 布局均可解析
43
+
44
+ client 半区 (lib/client.js)
45
+ ctx.slots.inject("conversation.session.header.utilities")
46
+ → 💰 图标(会话头部右上角)
47
+ → 点击 fetch 同源 /api/dsh-balance/balance → 弹层展示余额 + 成本 + token
48
+ ```
49
+
50
+ ## 安装
51
+
52
+ npm 安装(发布后):
53
+
54
+ ```sh
55
+ dsh plugin --profile web add dsh-balance-widget
56
+ ```
57
+
58
+ GitHub 仓库安装(开发调试):
59
+
60
+ ```sh
61
+ git clone https://github.com/LL-cmyk-so/dsh-balance-widget.git
62
+ cd dsh-balance-widget
63
+ dsh plugin --profile web add "link:$(pwd)"
64
+ ```
65
+
66
+ 装完重启 `dsh web` 生效。
67
+
68
+ ## 配置
69
+
70
+ 在 `~/.dsh/profiles/web/cordis.patch.yml` 中:
71
+
72
+ ```yaml
73
+ - id: balance-widget
74
+ name: dsh-balance-widget
75
+ config:
76
+ balanceBaseURL: https://api.deepseek.com # 官方余额接口
77
+ balanceApiKeyEnv: DEEPSEEK_API_KEY # 凭据服务中的密钥 ref
78
+ requestTimeoutMs: 5000 # 余额查询超时
79
+ modelId: deepseek-v4-flash # 成本计价模型(可改 deepseek-v4-pro)
80
+ ```
81
+
82
+ ## 定价说明
83
+
84
+ 内置 DeepSeek 官方 2026-08-17 峰谷定价(元 / 百万 tokens),高峰时段为北京时间 09:00–12:00、14:00–18:00,价格为空闲时段两倍:
85
+
86
+ | 模型 | 时段 | 缓存命中(输入) | 缓存未命中(输入) | 输出 |
87
+ | --- | --- | --- | --- | --- |
88
+ | V4-Flash | 空闲 | 0.05 | 1.5 | 4.5 |
89
+ | V4-Flash | 高峰 | 0.10 | 3.0 | 9.0 |
90
+ | V4-Pro | 空闲 | 0.15 | 4.5 | 13.5 |
91
+ | V4-Pro | 高峰 | 0.30 | 9.0 | 27.0 |
92
+
93
+ `deepseek-chat` / `deepseek-reasoner` 别名分别映射到 Flash / Pro 价格。成本为**估算值**,实际以官方账单为准。
94
+
95
+ ## 验证
96
+
97
+ - 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
98
+ - 余额路由:重启 dsh web 后 `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` 应返回 `{ ok, balance_infos, modelId }`
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,8 @@
1
+ # dsh-balance-widget bundle patch: inserts the dual-face plugin row into the
2
+ # web profile roster. The row is a bare plugin by package name: the node half
3
+ # (exports ".") runs in the host process and registers the balance/cost API
4
+ # routes; the `dsh.client` declaration in package.json makes the browser half
5
+ # (exports "./client", served at /plugins/<id>/client.js) load in the web GUI.
6
+ - insert:
7
+ - id: balance-widget
8
+ name: 'dsh-balance-widget'
package/lib/client.js ADDED
@@ -0,0 +1,294 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-balance-widget",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react_jsx_runtime = require("react/jsx-runtime");
8
+ let react = require("react");
9
+ //#region lib/types/client/styles.js
10
+ const css = ".dshbw_wrap{position:relative;display:inline-flex;align-items:center}.dshbw_btn{background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit}.dshbw_btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_pop{position:absolute;top:calc(100% + 6px);right:0;z-index:60;min-width:230px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:12px 14px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-primary)}.dshbw_row{display:flex;justify-content:space-between;gap:12px;padding:2px 0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_err{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;margin-top:6px}.dshbw_hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin-top:6px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px}.dshbw_foot{margin-top:8px;text-align:right}";
11
+ const tagId = "dsh-balance-widget/styles.css";
12
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
13
+ const tag = document.createElement("style");
14
+ tag.dataset.plugin = "dsh-balance-widget";
15
+ tag.dataset.pluginCss = tagId;
16
+ tag.textContent = css;
17
+ document.head.appendChild(tag);
18
+ }
19
+ var styles = {
20
+ "wrap": "dshbw_wrap",
21
+ "btn": "dshbw_btn",
22
+ "pop": "dshbw_pop",
23
+ "row": "dshbw_row",
24
+ "val": "dshbw_val",
25
+ "err": "dshbw_err",
26
+ "hint": "dshbw_hint",
27
+ "foot": "dshbw_foot"
28
+ };
29
+ //#endregion
30
+ //#region lib/types/client/pricing.js
31
+ /**
32
+ * DeepSeek official pricing, in CNY per 1M tokens, effective 2026-08-17
33
+ * (peak/off-peak two-tier billing). Peak windows are Beijing time:
34
+ * 09:00-12:00 and 14:00-18:00; off-peak is half the peak price.
35
+ * Prices are hard-coded from the official announcement and are estimates
36
+ * for display — actual billing is authoritative from the provider.
37
+ */
38
+ const PRICING = {
39
+ "deepseek-v4-flash": {
40
+ peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 },
41
+ offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 }
42
+ },
43
+ "deepseek-v4-pro": {
44
+ peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 },
45
+ offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
46
+ },
47
+ "deepseek-chat": {
48
+ peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 },
49
+ offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 }
50
+ },
51
+ "deepseek-reasoner": {
52
+ peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 },
53
+ offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
54
+ }
55
+ };
56
+ /** Fall back to Flash pricing for unknown model ids. */
57
+ function pricingFor(modelId) {
58
+ return PRICING[modelId] ?? PRICING["deepseek-v4-flash"];
59
+ }
60
+ /**
61
+ * Whether `date` falls inside a Beijing-time peak window.
62
+ * The host's local timezone is used; the widget is for a CN deployment.
63
+ */
64
+ function isPeak(date) {
65
+ const hour = date.getHours();
66
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
67
+ }
68
+ /**
69
+ * Price one session's token usage into a CNY estimate.
70
+ * @param usage - tokenUsage projection value.
71
+ * @param modelId - provider model id used for pricing.
72
+ * @returns { input, output, total } in CNY, or null when usage is absent.
73
+ */
74
+ function priceSession(usage, modelId) {
75
+ if (usage === void 0 || usage === null) return null;
76
+ const input = usage.uncachedInputTokens ?? 0;
77
+ const hit = usage.cacheReadTokens ?? 0;
78
+ const write = usage.cacheWriteTokens ?? 0;
79
+ const output = usage.outputTokens ?? 0;
80
+ if (input + hit + write + output <= 0) return null;
81
+ const table = isPeak(new Date()) ? pricingFor(modelId).peak : pricingFor(modelId).offPeak;
82
+ const inputCost = (input * table.inputMiss + (hit + write) * table.inputHit) / 1e6;
83
+ const outputCost = (output * table.output) / 1e6;
84
+ return {
85
+ input: inputCost,
86
+ output: outputCost,
87
+ total: inputCost + outputCost
88
+ };
89
+ }
90
+ /** Format a CNY amount: ¥ + 3 significant decimals (drop trailing zeros). */
91
+ function formatCny(value) {
92
+ if (value === void 0 || value === null || !Number.isFinite(value)) return "—";
93
+ const rounded = Math.round(value * 1e4) / 1e4;
94
+ return `¥${rounded.toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 4 })}`;
95
+ }
96
+ /** Format token counts compactly: 1234 -> 1.2K. */
97
+ function formatTokens(value) {
98
+ if (value === void 0 || value === null) return "—";
99
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
100
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
101
+ return String(value);
102
+ }
103
+ //#endregion
104
+ //#region lib/types/client/balance-api.js
105
+ /** Same-origin balance route (host half registers it over ctx.webServer). */
106
+ const BALANCE_ROUTE = "/api/dsh-balance/balance";
107
+ /**
108
+ * Fetch the account balance from the host proxy.
109
+ * @returns { ok, balanceInfos?, error? }
110
+ */
111
+ async function fetchBalance() {
112
+ try {
113
+ const response = await fetch(BALANCE_ROUTE, { headers: { accept: "application/json" } });
114
+ if (!response.ok) {
115
+ let detail = "";
116
+ try {
117
+ detail = (await response.json()).error ?? "";
118
+ } catch (_error) {
119
+ /* ignore body parse failure */
120
+ }
121
+ return { ok: false, error: detail || `balance endpoint responded ${response.status}` };
122
+ }
123
+ const payload = await response.json();
124
+ if (payload.ok === false) return { ok: false, error: payload.error ?? "unknown balance error" };
125
+ return { ok: true, balanceInfos: payload.balance_infos ?? [], modelId: payload.modelId };
126
+ } catch (error) {
127
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
128
+ }
129
+ }
130
+ /** Pick the display balance from balance_infos (prefer CNY, else first). */
131
+ function displayBalance(balanceInfos) {
132
+ if (!Array.isArray(balanceInfos) || balanceInfos.length === 0) return null;
133
+ const cny = balanceInfos.find((entry) => (entry.currency ?? "").toUpperCase() === "CNY");
134
+ const entry = cny ?? balanceInfos[0];
135
+ const total = Number(entry.total_balance);
136
+ if (!Number.isFinite(total)) return null;
137
+ const symbol = (entry.currency ?? "").toUpperCase() === "CNY" ? "¥" : (entry.currency ?? "") + " ";
138
+ return { symbol, total, currency: entry.currency };
139
+ }
140
+ //#endregion
141
+ //#region lib/types/client/BalanceWidget.js
142
+ /**
143
+ * Corner widget: a small balance button in the session header utilities
144
+ * slot. Clicking opens a popover with the account balance and the current
145
+ * session's estimated spend (priced from the tokenUsage projection).
146
+ * @param {Object} props - useProjection (framework), sessionId (slot scope).
147
+ */
148
+ function BalanceWidget({ useProjection, sessionId, t }) {
149
+ const [open, setOpen] = (0, react.useState)(false);
150
+ const [balance, setBalance] = (0, react.useState)(null);
151
+ const [modelId, setModelId] = (0, react.useState)("deepseek-v4-flash");
152
+ const [error, setError] = (0, react.useState)(null);
153
+ const [loading, setLoading] = (0, react.useState)(false);
154
+ const usage = useProjection("tokenUsage");
155
+ const priced = priceSession(usage, modelId);
156
+ const toggle = async () => {
157
+ const next = !open;
158
+ setOpen(next);
159
+ if (next) {
160
+ setLoading(true);
161
+ setError(null);
162
+ const result = await fetchBalance();
163
+ if (result.ok) {
164
+ setBalance(displayBalance(result.balanceInfos));
165
+ if (typeof result.modelId === "string" && result.modelId !== "") setModelId(result.modelId);
166
+ } else {
167
+ setBalance(null);
168
+ setError(result.error);
169
+ }
170
+ setLoading(false);
171
+ }
172
+ };
173
+ return (0, react_jsx_runtime.jsx)("div", {
174
+ className: styles.wrap,
175
+ children: (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, {
176
+ children: [(0, react_jsx_runtime.jsxs)("button", {
177
+ type: "button",
178
+ className: styles.btn,
179
+ "aria-label": t("aria"),
180
+ title: t("title"),
181
+ onClick: toggle,
182
+ children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
183
+ children: `${balance.symbol}${balance.total.toFixed(2)}`
184
+ })]
185
+ }), open && (0, react_jsx_runtime.jsx)("div", {
186
+ className: styles.pop,
187
+ role: "dialog",
188
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
189
+ children: [(0, react_jsx_runtime.jsx)("div", {
190
+ className: styles.row,
191
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
192
+ children: [(0, react_jsx_runtime.jsx)("span", {
193
+ children: t("balance")
194
+ }), (0, react_jsx_runtime.jsx)("span", {
195
+ className: styles.val,
196
+ children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
197
+ })]
198
+ })
199
+ }), (0, react_jsx_runtime.jsx)("div", {
200
+ className: styles.row,
201
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
202
+ children: [(0, react_jsx_runtime.jsx)("span", {
203
+ children: t("sessionCost")
204
+ }), (0, react_jsx_runtime.jsx)("span", {
205
+ className: styles.val,
206
+ children: priced !== null ? formatCny(priced.total) : "—"
207
+ })]
208
+ })
209
+ }), priced !== null && (0, react_jsx_runtime.jsx)("div", {
210
+ className: styles.row,
211
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
212
+ children: [(0, react_jsx_runtime.jsx)("span", {
213
+ children: t("tokens")
214
+ }), (0, react_jsx_runtime.jsx)("span", {
215
+ className: styles.val,
216
+ children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
217
+ })]
218
+ })
219
+ }), error !== null && (0, react_jsx_runtime.jsx)("div", {
220
+ className: styles.err,
221
+ role: "status",
222
+ children: error
223
+ }), (0, react_jsx_runtime.jsx)("div", {
224
+ className: styles.hint,
225
+ children: t("hint")
226
+ }), (0, react_jsx_runtime.jsx)("div", {
227
+ className: styles.foot,
228
+ children: (0, react_jsx_runtime.jsx)("button", {
229
+ type: "button",
230
+ className: styles.btn,
231
+ onClick: () => setOpen(false),
232
+ children: t("close")
233
+ })
234
+ })]
235
+ })
236
+ })]
237
+ })
238
+ });
239
+ }
240
+ //#endregion
241
+ //#region lib/types/client/locales.js
242
+ /** Dictionary namespace owned by this plugin. */
243
+ const NS = "balance-widget";
244
+ /** Simplified Chinese dictionary (the key-set source of truth). */
245
+ const zh = {
246
+ "aria": "查看账户余额与本轮成本",
247
+ "title": "余额与成本",
248
+ "balance": "账户余额",
249
+ "sessionCost": "本会话成本(估算)",
250
+ "tokens": "Token 用量",
251
+ "hint": "价格按 DeepSeek 官方峰谷定价估算,实际以账单为准。",
252
+ "close": "关闭"
253
+ };
254
+ /** English dictionary, checked complete against the zh key set. */
255
+ const en = {
256
+ "aria": "View account balance and session cost",
257
+ "title": "Balance & cost",
258
+ "balance": "Account balance",
259
+ "sessionCost": "This session cost (est.)",
260
+ "tokens": "Token usage",
261
+ "hint": "Prices estimated from DeepSeek official peak/off-peak rates; billing is authoritative.",
262
+ "close": "Close"
263
+ };
264
+ //#endregion
265
+ //#region lib/types/client/index.js
266
+ /** Required services: the seat's slot registry and locale registry. */
267
+ const inject = [
268
+ "slots",
269
+ "locale"
270
+ ];
271
+ /**
272
+ * Client plugin body: register the corner balance widget in the
273
+ * conversation session header utilities slot.
274
+ * @param ctx - client root context.
275
+ */
276
+ function apply(ctx) {
277
+ ctx.effect(() => ctx.locale.register(NS, {
278
+ zh,
279
+ en
280
+ }), "dsh-balance-widget: dictionaries");
281
+ ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
282
+ name: "conversation.session.header.utilities",
283
+ id: "balance-widget",
284
+ order: 90,
285
+ locale: NS,
286
+ inject: (sessionId) => ({ sessionId })
287
+ }, BalanceWidget));
288
+ }
289
+ //#endregion
290
+ exports.apply = apply;
291
+ exports.inject = inject;
292
+ return module.exports;
293
+ }
294
+ });
package/lib/index.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * dsh-balance-widget — host half.
3
+ *
4
+ * Registers two loopback-only API routes over ctx.webServer:
5
+ * GET /api/dsh-balance/balance → DeepSeek account balance (official /user/balance)
6
+ * GET /api/dsh-balance/cost → cost endpoint (reserved; pricing is client-side)
7
+ *
8
+ * The API key is resolved through the host's credentials service (the same
9
+ * seam dsh-llm uses) and never leaves the host process: the browser only
10
+ * talks to these same-origin routes. Routes are guarded loopback-only,
11
+ * mirroring dsh-ssh's pattern. This module deliberately imports NO
12
+ * @deepseek-ai/* packages so it resolves from any profile layout (npm,
13
+ * workspace link, or flat fallback).
14
+ */
15
+
16
+ /** Stable cordis plugin name (also the client bundle mount id). */
17
+ export const name = "balance-widget";
18
+
19
+ /** Services required before the balance surface can mount. */
20
+ export const inject = ["webServer"];
21
+
22
+ /** Route paths shared with the browser half (spelled here, not imported). */
23
+ export const API = {
24
+ balance: "/api/dsh-balance/balance",
25
+ cost: "/api/dsh-balance/cost"
26
+ };
27
+
28
+ /** Default DeepSeek API base (mirrors dsh-llm-deepseek's PUBLIC_BASE_URL). */
29
+ const DEFAULT_BASE_URL = "https://api.deepseek.com";
30
+ /** Default credential ref, mirrors dsh-llm-deepseek's DEFAULT_API_KEY_ENV. */
31
+ const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
32
+ /** Default model used to price client-side session cost. */
33
+ const DEFAULT_MODEL_ID = "deepseek-v4-flash";
34
+
35
+ /**
36
+ * Config schema exposed to the cordis loader. Cordis calls
37
+ * `Config["~standard"].validate(config)` and expects a Standard Schema
38
+ * (version 1) result — the same interface @deepseek-ai/schemastery produces.
39
+ * We hand-build it here so the host half keeps ZERO external imports (the
40
+ * workspace-link layout cannot resolve @deepseek-ai/* from the plugin's own
41
+ * path, and importing nothing sidesteps that entirely).
42
+ */
43
+ const CONFIG_FIELDS = {
44
+ balanceBaseURL: "string",
45
+ balanceApiKeyEnv: "string",
46
+ requestTimeoutMs: "number",
47
+ modelId: "string"
48
+ };
49
+ const CONFIG_DEFAULTS = {
50
+ balanceBaseURL: DEFAULT_BASE_URL,
51
+ balanceApiKeyEnv: DEFAULT_API_KEY_ENV,
52
+ requestTimeoutMs: 5000,
53
+ modelId: DEFAULT_MODEL_ID
54
+ };
55
+
56
+ export const Config = {
57
+ "~standard": {
58
+ version: 1,
59
+ vendor: "dsh-balance-widget",
60
+ validate(value) {
61
+ if (value === void 0 || value === null) value = {};
62
+ if (typeof value !== "object") {
63
+ return { issues: [{ message: "config must be an object", path: [] }] };
64
+ }
65
+ const out = {};
66
+ for (const [key, type] of Object.entries(CONFIG_FIELDS)) {
67
+ const raw = value[key];
68
+ const fallback = CONFIG_DEFAULTS[key];
69
+ if (raw === void 0 || raw === null) {
70
+ out[key] = fallback;
71
+ continue;
72
+ }
73
+ if (type === "string" && typeof raw === "string" && raw !== "") {
74
+ out[key] = raw;
75
+ } else if (type === "number" && typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
76
+ out[key] = raw;
77
+ } else {
78
+ out[key] = fallback;
79
+ }
80
+ }
81
+ return { value: out };
82
+ }
83
+ },
84
+ shape: CONFIG_FIELDS,
85
+ defaults: CONFIG_DEFAULTS
86
+ };
87
+
88
+ /** Resolve config with defaults applied. */
89
+ function resolveConfig(raw) {
90
+ const value = raw ?? {};
91
+ return {
92
+ balanceBaseURL: typeof value.balanceBaseURL === "string" && value.balanceBaseURL !== ""
93
+ ? value.balanceBaseURL
94
+ : Config.defaults.balanceBaseURL,
95
+ balanceApiKeyEnv: typeof value.balanceApiKeyEnv === "string" && value.balanceApiKeyEnv !== ""
96
+ ? value.balanceApiKeyEnv
97
+ : Config.defaults.balanceApiKeyEnv,
98
+ requestTimeoutMs: typeof value.requestTimeoutMs === "number" && value.requestTimeoutMs > 0
99
+ ? value.requestTimeoutMs
100
+ : Config.defaults.requestTimeoutMs,
101
+ modelId: typeof value.modelId === "string" && value.modelId !== ""
102
+ ? value.modelId
103
+ : Config.defaults.modelId
104
+ };
105
+ }
106
+
107
+ /** Write a JSON response with a status code. */
108
+ function writeJson(res, status, body) {
109
+ const payload = JSON.stringify(body);
110
+ res.writeHead(status, {
111
+ "content-type": "application/json; charset=utf-8",
112
+ "content-length": Buffer.byteLength(payload)
113
+ });
114
+ res.end(payload);
115
+ }
116
+
117
+ /** Whether the request comes from the loopback interface. */
118
+ function isLoopbackRequest(req) {
119
+ const address = req.socket?.remoteAddress ?? "";
120
+ return address === "::1"
121
+ || address === "::ffff:127.0.0.1"
122
+ || address === "127.0.0.1"
123
+ || address === "localhost"
124
+ || address.startsWith("::ffff:127.");
125
+ }
126
+
127
+ /** Guard helper: loopback fence + method check. */
128
+ function guard(req, res, method) {
129
+ if (!isLoopbackRequest(req)) {
130
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
131
+ return false;
132
+ }
133
+ if ((req.method ?? "GET") !== method) {
134
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
135
+ return false;
136
+ }
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Resolve the DeepSeek API key through the host credentials service.
142
+ * Falls back to the launch environment, then process env. Returns undefined
143
+ * when absent everywhere.
144
+ */
145
+ async function resolveApiKey(ctx, config) {
146
+ try {
147
+ const credentials = ctx.get("credentials");
148
+ if (credentials !== void 0) {
149
+ const hit = await credentials.resolve(config.balanceApiKeyEnv);
150
+ if (hit !== void 0 && hit.value !== void 0 && hit.value !== "") return hit.value;
151
+ }
152
+ } catch (error) {
153
+ ctx.logger?.warn?.("[dsh-balance-widget] credentials resolve failed: %s", error instanceof Error ? error.message : String(error));
154
+ }
155
+ try {
156
+ const environment = ctx.get("launchEnvironment");
157
+ if (environment !== void 0) {
158
+ const entry = environment.get(config.balanceApiKeyEnv);
159
+ if (entry !== void 0 && entry.value.length > 0) return entry.value;
160
+ }
161
+ } catch {
162
+ /* fall through to process env */
163
+ }
164
+ const ambient = process.env[config.balanceApiKeyEnv];
165
+ if (ambient !== void 0 && ambient !== "") return ambient;
166
+ return void 0;
167
+ }
168
+
169
+ /**
170
+ * Query the official DeepSeek /user/balance endpoint.
171
+ * @returns { ok: true, ...payload } | { ok: false, error }
172
+ */
173
+ async function fetchBalance(ctx, config) {
174
+ let apiKey;
175
+ try {
176
+ apiKey = await resolveApiKey(ctx, config);
177
+ } catch (error) {
178
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
179
+ }
180
+ if (apiKey === void 0) {
181
+ return {
182
+ ok: false,
183
+ error: `no API key for ${config.balanceApiKeyEnv}; store it through the credentials service or export it in the launching environment`
184
+ };
185
+ }
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs);
188
+ try {
189
+ const response = await fetch(`${config.balanceBaseURL}/user/balance`, {
190
+ headers: { authorization: `Bearer ${apiKey}` },
191
+ signal: controller.signal
192
+ });
193
+ if (!response.ok) {
194
+ const text = await response.text().catch(() => "");
195
+ return { ok: false, error: `balance endpoint responded ${response.status}: ${text.slice(0, 200)}` };
196
+ }
197
+ const payload = await response.json();
198
+ return { ok: true, ...payload };
199
+ } catch (error) {
200
+ return {
201
+ ok: false,
202
+ error: error instanceof Error ? error.message : String(error)
203
+ };
204
+ } finally {
205
+ clearTimeout(timer);
206
+ }
207
+ }
208
+
209
+ /** Build the route table for the balance widget. */
210
+ function makeRoutes(ctx, config) {
211
+ return [
212
+ {
213
+ kind: "exact",
214
+ path: API.balance,
215
+ handler: async (req, res) => {
216
+ if (!guard(req, res, "GET")) return;
217
+ const result = await fetchBalance(ctx, config);
218
+ if (result.ok) {
219
+ writeJson(res, 200, { ...result, modelId: config.modelId });
220
+ } else {
221
+ writeJson(res, 502, { error: result.error, modelId: config.modelId });
222
+ }
223
+ }
224
+ },
225
+ {
226
+ kind: "exact",
227
+ path: API.cost,
228
+ handler: async (req, res) => {
229
+ if (!guard(req, res, "GET")) return;
230
+ // Cost is priced client-side from the tokenUsage projection; this
231
+ // route exists as the canonical ask point for future server pricing.
232
+ writeJson(res, 200, { ok: true, note: "cost is priced client-side from tokenUsage projection" });
233
+ }
234
+ }
235
+ ];
236
+ }
237
+
238
+ /** Cordis plugin apply: register routes on the host webServer. */
239
+ export function apply(ctx, config) {
240
+ const resolved = resolveConfig(config);
241
+ const routes = makeRoutes(ctx, resolved);
242
+ const disposers = routes.map((route) => ctx.webServer.register(route));
243
+ ctx.effect(() => () => {
244
+ for (const dispose of disposers) dispose();
245
+ }, "dsh-balance-widget: routes");
246
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "dsh-balance-widget",
3
+ "description": "Balance & cost widget for the dsh web GUI: a corner icon in the conversation session header that shows the DeepSeek account balance and the current session's estimated spend on click. Host half proxies the official /user/balance API over ctx.webServer (loopback-only); client half renders the icon and popover. Zero external dependencies — works on Node 24.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "files": [
8
+ "lib",
9
+ "cordis.patch.yml",
10
+ "README.md",
11
+ "README.en.md"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "default": "./lib/index.js"
16
+ },
17
+ "./client": {
18
+ "default": "./lib/client.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-runtime",
29
+ "@deepseek-ai/dsh-client-ui-conversation"
30
+ ],
31
+ "platform": "web"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.2.0"
36
+ },
37
+ "keywords": [
38
+ "deepseek",
39
+ "dsh",
40
+ "deepseek-harness",
41
+ "plugin",
42
+ "balance",
43
+ "cost",
44
+ "token",
45
+ "web-ui"
46
+ ],
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/LL-cmyk-so/dsh-balance-widget.git"
51
+ },
52
+ "author": "YOUR_NAME <YOUR_EMAIL>"
53
+ }