dsh-balance-widget 0.3.2 → 0.4.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/README.en.md CHANGED
@@ -11,6 +11,12 @@
11
11
 
12
12
  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.
13
13
 
14
+ ## Preview
15
+
16
+ | Balance always visible | Popover on click |
17
+ | --- | --- |
18
+ | ![Balance corner](docs/screenshot-corner.png) | ![Popover detail](docs/screenshot-popover.png) |
19
+
14
20
  ## How it differs from similar plugins
15
21
 
16
22
  | Aspect | This plugin | Others (dsh-balance / dsh-token-price / ...) |
@@ -32,6 +38,10 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
32
38
  - **Today total cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
33
39
  - **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
34
40
  - **One-click top-up** — a "Top up" link in the popover footer jumps to the official DeepSeek top-up page (platform.deepseek.com/top_up) in a new tab.
41
+ - **Sidebar card** — a persistent card at the sidebar footer shows balance, a remaining-ratio bar, and today's total; globally visible, refreshes every 60s.
42
+ - **Remaining-ratio bar** — blue (healthy) → amber (below lowThreshold) → red (below criticalThreshold).
43
+ - **Official price auto-sync** — fetches the DeepSeek official pricing page on startup and every 12h; falls back to built-in rates on failure.
44
+ - **Agent tool** — a `deepseek_billing` tool lets the model answer "how much balance do I have / how much did today cost".
35
45
  - **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
36
46
 
37
47
  ## Why this plugin
package/README.md CHANGED
@@ -11,6 +11,12 @@
11
11
 
12
12
  DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右上角(角落)渲染一个 💰 图标,点击弹出账户余额与本会话的估算成本。
13
13
 
14
+ ## 效果预览
15
+
16
+ | 余额常驻右上角 | 点击弹出详情 |
17
+ | --- | --- |
18
+ | ![余额常驻](docs/screenshot-corner.png) | ![弹框详情](docs/screenshot-popover.png) |
19
+
14
20
  ## 与同类插件的区别
15
21
 
16
22
  | 特点 | 本插件 | 同类插件(dsh-balance / dsh-token-price 等) |
@@ -32,6 +38,10 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右
32
38
  - **今天总成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有会话,累加今天(自然日)的 token 用量 × 单价。
33
39
  - **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
34
40
  - **一键充值** — 弹层底部「去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
41
+ - **侧边栏常驻卡片** — 侧边栏底部(设置上方)显示余额 + 剩余比例条 + 今日花费,全局可见,60 秒自动刷新。
42
+ - **余额剩余比例条** — 蓝色(充足)→ 琥珀(低于 lowThreshold)→ 红色(低于 criticalThreshold)三档。
43
+ - **官方价格自动同步** — 启动时 + 每 12 小时抓取 DeepSeek 官方定价页,改价自动跟进;失败回退内置价目表。
44
+ - **模型工具查询** — 新增 `deepseek_billing` 工具,可直接问模型"余额多少/今天花了多少"。
35
45
  - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
36
46
 
37
47
  ## 架构
package/lib/client.js CHANGED
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
10
  //#region lib/types/client/styles.js
11
- 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_btn[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_btn[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.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_head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}.dshbw_title{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_refresh{background:none;border:1px solid var(--dsw-alias-border-l2);cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;font-size:13px;line-height:1;padding:0;font-family:inherit}.dshbw_refresh:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_refresh[data-spinning=true]{animation:dshbw-spin .8s linear infinite}.dshbw_row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:2px 0}.dshbw_label{display:inline-flex;align-items:center;min-width:0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_val[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_val[data-level=\"2\"]{color:var(--dsw-alias-state-error-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{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px}.dshbw_link{background:none;border:none;cursor:pointer;color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit;text-decoration:none}.dshbw_link:hover{color:var(--dsw-alias-state-business-primary);text-decoration:underline}@keyframes dshbw-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}";
11
+ 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_btn[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_btn[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.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_head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}.dshbw_title{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_refresh{background:none;border:1px solid var(--dsw-alias-border-l2);cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;font-size:13px;line-height:1;padding:0;font-family:inherit}.dshbw_refresh:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_refresh[data-spinning=true]{animation:dshbw-spin .8s linear infinite}.dshbw_row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:2px 0}.dshbw_label{display:inline-flex;align-items:center;min-width:0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_val[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_val[data-level=\"2\"]{color:var(--dsw-alias-state-error-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{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px}.dshbw_link{background:none;border:none;cursor:pointer;color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit;text-decoration:none}.dshbw_link:hover{color:var(--dsw-alias-state-business-primary);text-decoration:underline}@keyframes dshbw-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}.dshbw_side{display:flex;flex-direction:column;gap:4px;padding:6px 8px;margin:0 8px 4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}.dshbw_side:hover{background:var(--dsw-alias-interactive-bg-hover)}.dshbw_sideTop{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;line-height:18px}.dshbw_sideAmount{font-weight:600}.dshbw_sideAmount[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_sideAmount[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.dshbw_bar{height:4px;border-radius:2px;background:var(--dsw-alias-border-l2);overflow:hidden}.dshbw_barFill{height:100%;border-radius:2px;transition:width .4s ease}.dshbw_barFill[data-level=\"0\"]{background:var(--dsw-alias-state-business-primary)}.dshbw_barFill[data-level=\"1\"]{background:var(--dsw-alias-state-warn-primary)}.dshbw_barFill[data-level=\"2\"]{background:var(--dsw-alias-state-error-primary)}.dshbw_sideFoot{display:flex;justify-content:space-between;font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary)}";
12
12
  const tagId = "dsh-balance-widget/styles.css";
13
13
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
14
14
  const tag = document.createElement("style");
@@ -30,7 +30,13 @@ window.__ModuleLoader__.load({
30
30
  "err": "dshbw_err",
31
31
  "hint": "dshbw_hint",
32
32
  "foot": "dshbw_foot",
33
- "link": "dshbw_link"
33
+ "link": "dshbw_link",
34
+ "side": "dshbw_side",
35
+ "sideTop": "dshbw_sideTop",
36
+ "sideAmount": "dshbw_sideAmount",
37
+ "bar": "dshbw_bar",
38
+ "barFill": "dshbw_barFill",
39
+ "sideFoot": "dshbw_sideFoot"
34
40
  };
35
41
  //#endregion
36
42
  //#region lib/types/client/pricing.js
@@ -455,6 +461,78 @@ window.__ModuleLoader__.load({
455
461
  });
456
462
  }
457
463
  //#endregion
464
+ //#region lib/types/client/SidebarBalanceCard.js
465
+ /**
466
+ * Sidebar footer card: balance + remaining-ratio bar + today cost.
467
+ * Root-scoped (global, not per-session) — fetches balance itself and
468
+ * refreshes every 60s, matching the corner widget cadence.
469
+ * @param {Object} props - wide (sidebar expanded), t (locale).
470
+ */
471
+ function SidebarBalanceCard({ wide, t }) {
472
+ const [balance, setBalance] = (0, react.useState)(null);
473
+ const [todayCost, setTodayCost] = (0, react.useState)(null);
474
+ const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
475
+ const [criticalThreshold, setCriticalThreshold] = (0, react.useState)(1);
476
+ const refresh = async () => {
477
+ const [bResult, tResult] = await Promise.all([
478
+ fetchBalance(),
479
+ fetchCost(TODAY_COST_ROUTE)
480
+ ]);
481
+ if (bResult.ok) {
482
+ setBalance(displayBalance(bResult.balanceInfos));
483
+ if (typeof bResult.lowThreshold === "number" && bResult.lowThreshold >= 0) setLowThreshold(bResult.lowThreshold);
484
+ if (typeof bResult.criticalThreshold === "number" && bResult.criticalThreshold >= 0) setCriticalThreshold(bResult.criticalThreshold);
485
+ }
486
+ if (tResult.ok) setTodayCost(tResult.cost);
487
+ };
488
+ (0, react.useEffect)(() => {
489
+ refresh();
490
+ const timer = setInterval(refresh, 60000);
491
+ return () => clearInterval(timer);
492
+ }, []);
493
+ const level = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
494
+ // Remaining-ratio: 100% at lowThreshold (or above), 0% at 0.
495
+ let percent = 100;
496
+ if (balance !== null && balance.total < lowThreshold) {
497
+ percent = lowThreshold <= 0 ? 100 : Math.max(0, Math.round(balance.total / lowThreshold * 100));
498
+ }
499
+ return (0, react_jsx_runtime.jsx)("div", {
500
+ className: styles.side,
501
+ title: t("title"),
502
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
503
+ children: [(0, react_jsx_runtime.jsx)("div", {
504
+ className: styles.sideTop,
505
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
506
+ children: [(0, react_jsx_runtime.jsx)("span", {
507
+ children: "💰"
508
+ }), (0, react_jsx_runtime.jsx)("span", {
509
+ className: styles.sideAmount,
510
+ "data-level": level > 0 ? level : undefined,
511
+ children: balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
512
+ })]
513
+ })
514
+ }), (0, react_jsx_runtime.jsx)("div", {
515
+ className: styles.bar,
516
+ "aria-label": t("side.bar"),
517
+ children: (0, react_jsx_runtime.jsx)("div", {
518
+ className: styles.barFill,
519
+ "data-level": level,
520
+ style: { width: `${percent}%` }
521
+ })
522
+ }), (0, react_jsx_runtime.jsx)("div", {
523
+ className: styles.sideFoot,
524
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
525
+ children: [(0, react_jsx_runtime.jsx)("span", {
526
+ children: t("todayCostShort")
527
+ }), (0, react_jsx_runtime.jsx)("span", {
528
+ children: todayCost !== null ? formatCny(todayCost) : "—"
529
+ })]
530
+ })
531
+ })]
532
+ })
533
+ });
534
+ }
535
+ //#endregion
458
536
  //#region lib/types/client/locales.js
459
537
  /** Dictionary namespace owned by this plugin. */
460
538
  const NS = "balance-widget";
@@ -480,6 +558,8 @@ window.__ModuleLoader__.load({
480
558
  "warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
481
559
  "warn.text.2": "⛔ 余额低于 ¥1,即将耗尽,请立即充值!",
482
560
  "updatedAt": "更新于",
561
+ "side.bar": "余额剩余比例",
562
+ "todayCostShort": "今日",
483
563
  "hint": "价格为估算值,实际以官方账单为准。",
484
564
  "topUp": "去充值",
485
565
  "close": "关闭"
@@ -506,6 +586,8 @@ window.__ModuleLoader__.load({
506
586
  "warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
507
587
  "warn.text.2": "⛔ Balance below ¥1, almost exhausted — top up now!",
508
588
  "updatedAt": "Updated",
589
+ "side.bar": "Balance remaining ratio",
590
+ "todayCostShort": "Today",
509
591
  "hint": "Prices are estimates; actual billing from the provider is authoritative.",
510
592
  "topUp": "Top up",
511
593
  "close": "Close"
@@ -534,6 +616,12 @@ window.__ModuleLoader__.load({
534
616
  locale: NS,
535
617
  inject: (sessionId) => ({ sessionId })
536
618
  }, BalanceWidget));
619
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
620
+ name: "sidebar.footer.action",
621
+ id: "balance-widget",
622
+ order: 10,
623
+ locale: NS
624
+ }, SidebarBalanceCard));
537
625
  }
538
626
  //#endregion
539
627
  exports.apply = apply;
package/lib/index.js CHANGED
@@ -221,14 +221,25 @@ async function fetchBalance(ctx, config) {
221
221
  /**
222
222
  * Pricing table (CNY per 1M tokens, official 2026-08-17 peak/off-peak).
223
223
  * Mirrors the client half; kept here so host-computed costs use the same
224
- * numbers.
224
+ * numbers. Mutable: syncOfficialPricing() may replace it with freshly parsed
225
+ * official rates (falling back to these defaults on failure).
225
226
  */
226
- const PRICING = {
227
+ const DEFAULT_PRICING = {
227
228
  "deepseek-v4-flash": { peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 }, offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 } },
228
229
  "deepseek-v4-pro": { peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 }, offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 } },
229
230
  "deepseek-chat": { peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 }, offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 } },
230
231
  "deepseek-reasoner": { peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 }, offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 } }
231
232
  };
233
+ let pricingTable = { ...DEFAULT_PRICING };
234
+ /** Source of the current pricing: "default" | "synced". */
235
+ let pricingSource = "default";
236
+ /** Last successful sync time (ms epoch), or null. */
237
+ let pricingSyncedAt = null;
238
+
239
+ /** Official pricing page (parsed for peak/off-peak rates). */
240
+ const PRICING_URL = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
241
+ const PRICE_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000;
242
+
232
243
  const PRICING_ALIASES = {
233
244
  "deepseek-vision": "deepseek-v4-flash",
234
245
  "deepseek-official": "deepseek-v4-flash"
@@ -243,7 +254,7 @@ function isPeak(date) {
243
254
  /** Map a provider/model id to a pricing key. */
244
255
  function pricingKey(modelId) {
245
256
  if (modelId === void 0 || modelId === null) return "deepseek-v4-flash";
246
- if (PRICING[modelId] !== void 0) return modelId;
257
+ if (pricingTable[modelId] !== void 0) return modelId;
247
258
  const alias = PRICING_ALIASES[modelId];
248
259
  if (alias !== void 0) return alias;
249
260
  // heuristic: pro/reasoner-ish names price as pro
@@ -258,10 +269,104 @@ function priceUsage(usage, modelId, timeMs) {
258
269
  const hit = usage.cacheReadTokens ?? 0;
259
270
  const output = usage.outputTokens ?? 0;
260
271
  if (input + hit + output <= 0) return 0;
261
- const table = isPeak(new Date(timeMs ?? Date.now())) ? PRICING[pricingKey(modelId)].peak : PRICING[pricingKey(modelId)].offPeak;
272
+ const table = isPeak(new Date(timeMs ?? Date.now())) ? pricingTable[pricingKey(modelId)].peak : pricingTable[pricingKey(modelId)].offPeak;
262
273
  return (input * table.inputMiss + hit * table.inputHit + output * table.output) / 1e6;
263
274
  }
264
275
 
276
+ /**
277
+ * Parse the official pricing page into our pricingTable shape.
278
+ * The page is HTML; we look for per-model peak/off-peak "per 1M tokens" rates.
279
+ * Returns true on success (pricingTable updated), false on any failure
280
+ * (pricingTable left untouched).
281
+ */
282
+ async function syncOfficialPricing() {
283
+ try {
284
+ const controller = new AbortController();
285
+ const timer = setTimeout(() => controller.abort(), 15000);
286
+ const response = await fetch(PRICING_URL, { signal: controller.signal });
287
+ clearTimeout(timer);
288
+ if (!response.ok) return false;
289
+ const html = await response.text();
290
+ const parsed = parsePricingPage(html);
291
+ if (parsed === null || Object.keys(parsed).length === 0) return false;
292
+ // Merge over defaults so any model we cannot parse keeps its fallback.
293
+ const merged = { ...DEFAULT_PRICING };
294
+ for (const [model, rates] of Object.entries(parsed)) merged[model] = rates;
295
+ pricingTable = merged;
296
+ pricingSource = "synced";
297
+ pricingSyncedAt = Date.now();
298
+ return true;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Best-effort parse of the official pricing page HTML. Looks for the standard
306
+ * "deepseek-v4-flash" / "deepseek-v4-pro" model blocks and per-million-token
307
+ * rates. This is heuristic: it tolerates missing fields and returns a partial
308
+ * table rather than failing the whole sync.
309
+ */
310
+ function parsePricingPage(html) {
311
+ const out = {};
312
+ const modelIds = ["deepseek-v4-flash", "deepseek-v4-pro"];
313
+ for (const model of modelIds) {
314
+ const block = extractModelBlock(html, model);
315
+ if (block === null) continue;
316
+ const rates = parseRates(block);
317
+ if (rates !== null) out[model] = rates;
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /** Extract the HTML section mentioning a model id (crude but robust). */
323
+ function extractModelBlock(html, model) {
324
+ const idx = html.indexOf(model);
325
+ if (idx < 0) return null;
326
+ const start = Math.max(0, idx - 400);
327
+ const end = Math.min(html.length, idx + 3000);
328
+ return html.slice(start, end);
329
+ }
330
+
331
+ /**
332
+ * Parse per-1M-token rates from a block of HTML text. Looks for numbers near
333
+ * "输入"/"缓存命中"/"输出" (CN page) or "input"/"cache"/"output" (EN page),
334
+ * preferring the off-peak row. Returns { offPeak, peak } or null.
335
+ */
336
+ function parseRates(block) {
337
+ // Strip tags to plain text for simpler matching.
338
+ const text = block.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
339
+ const num = (pattern) => {
340
+ const m = text.match(pattern);
341
+ if (!m) return void 0;
342
+ const v = parseFloat(m[1].replace(/[¥$,\s]/g, ""));
343
+ return Number.isFinite(v) ? v : void 0;
344
+ };
345
+ // Try patterns for off-peak (空闲/off-peak) first, then any.
346
+ const offPeakInputMiss = num(/缓存未命中[^\d]*([\d.]+)/) ?? num(/cache[\s-]*miss[^\d]*([\d.]+)/);
347
+ const offPeakInputHit = num(/缓存命中[^\d]*([\d.]+)/) ?? num(/cache[\s-]*hit[^\d]*([\d.]+)/);
348
+ const offPeakOutput = num(/输出[^\d]*([\d.]+)/) ?? num(/output[^\d]*([\d.]+)/);
349
+ if (offPeakInputMiss === void 0 || offPeakOutput === void 0) return null;
350
+ const peakInputMiss = (offPeakInputMiss ?? 0) * 2;
351
+ const peakInputHit = (offPeakInputHit ?? 0) * 2;
352
+ const peakOutput = (offPeakOutput ?? 0) * 2;
353
+ return {
354
+ peak: { inputMiss: peakInputMiss, inputHit: peakInputHit, output: peakOutput },
355
+ offPeak: { inputMiss: offPeakInputMiss, inputHit: offPeakInputHit ?? 0, output: offPeakOutput }
356
+ };
357
+ }
358
+
359
+ /** Kick off the first sync (fire-and-forget) and schedule the interval. */
360
+ function startPricingSync() {
361
+ syncOfficialPricing().catch(() => {});
362
+ const timer = setInterval(() => {
363
+ syncOfficialPricing().catch(() => {});
364
+ }, PRICE_SYNC_INTERVAL_MS);
365
+ // Unref so the interval never keeps the process alive on its own.
366
+ if (typeof timer.unref === "function") timer.unref();
367
+ return timer;
368
+ }
369
+
265
370
  /**
266
371
  * Resolve the DSH sessions root directory (defaults to ~/.dsh/sessions).
267
372
  * Avoids importing dsh-home-paths to keep zero external dependencies.
@@ -525,7 +630,7 @@ function makeRoutes(ctx, config) {
525
630
  writeJson(res, 502, { error: outcome.error, modelId: config.modelId });
526
631
  return;
527
632
  }
528
- writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
633
+ writeJson(res, 200, { ...outcome.value, modelId: config.modelId, pricingSource, pricingSyncedAt });
529
634
  }
530
635
  },
531
636
  {
@@ -539,18 +644,83 @@ function makeRoutes(ctx, config) {
539
644
  return { value: todayCost(perSession) };
540
645
  };
541
646
  const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
542
- writeJson(res, 200, { ...outcome.value, modelId: config.modelId });
647
+ writeJson(res, 200, { ...outcome.value, modelId: config.modelId, pricingSource, pricingSyncedAt });
543
648
  }
544
649
  }
545
650
  ];
546
651
  }
547
652
 
653
+ /**
654
+ * Build the `deepseek_billing` agent tool: lets the model query balance and
655
+ * session costs directly ("how much balance do I have?"). Hand-constructed
656
+ * tool object (no defineTool import) to keep zero external dependencies;
657
+ * mirrors the shape defineTool produces.
658
+ */
659
+ function buildBillingTool(ctx, config) {
660
+ return {
661
+ name: "deepseek_billing",
662
+ description: "Query the DeepSeek account balance and estimated session costs. Use when the user asks about their balance, spending, or token costs. query = 'balance' (account balance), 'cost' (current session estimated cost), or 'both'.",
663
+ parameters: {
664
+ type: "object",
665
+ properties: {
666
+ query: {
667
+ type: "string",
668
+ enum: ["balance", "cost", "both"],
669
+ description: "What to query: balance, cost, or both."
670
+ }
671
+ },
672
+ required: ["query"]
673
+ },
674
+ async execute(args) {
675
+ const query = args?.query ?? "both";
676
+ const parts = [];
677
+ if (query === "balance" || query === "both") {
678
+ const result = await fetchBalance(ctx, config);
679
+ if (result.ok) {
680
+ const info = (result.balance_infos ?? [])[0];
681
+ if (info !== void 0) {
682
+ parts.push(`Balance: ${info.currency} ${info.total_balance} (granted ${info.granted_balance}, topped up ${info.topped_up_balance})`);
683
+ } else {
684
+ parts.push("Balance: unavailable (no balance info returned)");
685
+ }
686
+ } else {
687
+ parts.push(`Balance query failed: ${result.error}`);
688
+ }
689
+ }
690
+ if (query === "cost" || query === "both") {
691
+ try {
692
+ const perSession = await collectAllSessionSamples();
693
+ const today = todayCost(perSession);
694
+ parts.push(`Today's estimated cost: CNY ${today.cost.toFixed(4)} (${today.inputTokens} input tokens, ${today.outputTokens} output tokens)`);
695
+ } catch (error) {
696
+ parts.push(`Cost query failed: ${error instanceof Error ? error.message : String(error)}`);
697
+ }
698
+ }
699
+ return { result: parts.join("\n") };
700
+ }
701
+ };
702
+ }
703
+
548
704
  /** Cordis plugin apply: register routes on the host webServer. */
549
705
  export function apply(ctx, config) {
550
706
  const resolved = resolveConfig(config);
551
707
  const routes = makeRoutes(ctx, resolved);
552
708
  const disposers = routes.map((route) => ctx.webServer.register(route));
709
+ // Register the agent-facing billing tool when the tools service exists.
710
+ let toolDisposer;
711
+ try {
712
+ if (ctx.tools !== void 0 && typeof ctx.tools.register === "function") {
713
+ toolDisposer = ctx.tools.register(buildBillingTool(ctx, resolved));
714
+ }
715
+ } catch (error) {
716
+ ctx.logger?.warn?.("[dsh-balance-widget] tool registration failed: %s", error instanceof Error ? error.message : String(error));
717
+ }
718
+ // Kick off official price sync (fire-and-forget) and keep the interval
719
+ // handle for teardown.
720
+ const syncTimer = startPricingSync();
553
721
  ctx.effect(() => () => {
722
+ clearInterval(syncTimer);
554
723
  for (const dispose of disposers) dispose();
724
+ if (typeof toolDisposer === "function") toolDisposer();
555
725
  }, "dsh-balance-widget: routes");
556
726
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-balance-widget",
3
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.3.2",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [