dsh-balance-widget 0.1.0 → 0.2.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
@@ -3,6 +3,7 @@
3
3
  [中文](README.md) | **English**
4
4
 
5
5
  <p align="center">
6
+ <img src="https://img.shields.io/npm/v/dsh-balance-widget?style=flat-square&label=npm" alt="npm">
6
7
  <img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
7
8
  <img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
8
9
  <img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
@@ -29,8 +30,10 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
29
30
 
30
31
  - **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
32
  - **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.
33
+ - **Last prompt cost (estimate)** — Parses the current session file and prices the last turn's token usage, answering "how much did that last prompt cost".
34
+ - **Today total cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
32
35
  - **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.
36
+ - **On-demand refresh** — No polling, no background requests; endpoints are only hit when you click the icon. Costs zero tokens to use.
34
37
 
35
38
  ## Why this plugin
36
39
 
@@ -42,13 +45,14 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
42
45
  ```
43
46
  host half (lib/index.js)
44
47
  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)
48
+ GET /api/dsh-balance/balance → official /user/balance (loopback-only guard)
49
+ GET /api/dsh-balance/last-cost → last prompt cost (session file + zstd decode)
50
+ GET /api/dsh-balance/today-cost → today's total across all sessions
47
51
 
48
52
  client half (lib/client.js)
49
53
  ctx.slots.inject("conversation.session.header.utilities")
50
54
  → 💰 icon (session header, top-right corner)
51
- → click fetches same-origin /api/dsh-balance/balance → popover with balance + cost + tokens
55
+ → click fetches same-origin APIs → popover with balance + three costs + tokens
52
56
  ```
53
57
 
54
58
  ## Installation
@@ -100,6 +104,7 @@ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective
100
104
 
101
105
  - Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
102
106
  - Balance route: after restarting dsh web, `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` should return `{ ok, balance_infos, modelId }`.
107
+ - Cost routes: `curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=<sessionId>"` and `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` should return `{ cost, inputTokens, outputTokens, modelId }`.
103
108
 
104
109
  ## License
105
110
 
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  **中文** | [English](README.en.md)
4
4
 
5
5
  <p align="center">
6
+ <img src="https://img.shields.io/npm/v/dsh-balance-widget?style=flat-square&label=npm" alt="npm">
6
7
  <img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
7
8
  <img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
8
9
  <img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
@@ -29,22 +30,25 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右
29
30
 
30
31
  - **账户余额** — 点击图标时经宿主代理查询 DeepSeek 官方 `GET /user/balance`,展示 `¥` 余额;API key 只在宿主进程内读取(凭据服务),浏览器不接触密钥。
31
32
  - **本会话成本(估算)** — 由会话的 `tokenUsage` 投影 × DeepSeek 官方峰谷定价表计算,随当前会话模型(默认 `deepseek-v4-flash`,可在配置中改为 `deepseek-v4-pro`)与北京时间高峰/空闲时段自动切换。
33
+ - **最近一次提问成本(估算)** — 从当前会话文件解析最后一个 turn 的 token 用量 × 单价,回答"刚才那条提问花了多少"。
34
+ - **今天总成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有会话,累加今天(自然日)的 token 用量 × 单价。
32
35
  - **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
33
- - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起余额查询,不消耗任何 token。
36
+ - **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起查询,不消耗任何 token。
34
37
 
35
38
  ## 架构
36
39
 
37
40
  ```
38
41
  host 半区 (lib/index.js)
39
42
  ctx.webServer.register:
40
- GET /api/dsh-balance/balance → 官方 /user/balance(loopback-only 守卫)
41
- GET /api/dsh-balance/cost → 保留路由(成本目前客户端计价)
43
+ GET /api/dsh-balance/balance → 官方 /user/balance(loopback-only 守卫)
44
+ GET /api/dsh-balance/last-cost → 当前会话最近一次提问成本(读会话文件 + zstd 解压)
45
+ GET /api/dsh-balance/today-cost → 今天所有会话总成本
42
46
  依赖:零外部 @deepseek-ai/* import,任何 profile 布局均可解析
43
47
 
44
48
  client 半区 (lib/client.js)
45
49
  ctx.slots.inject("conversation.session.header.utilities")
46
50
  → 💰 图标(会话头部右上角)
47
- → 点击 fetch 同源 /api/dsh-balance/balance → 弹层展示余额 + 成本 + token
51
+ → 点击 fetch 同源 API → 弹层展示余额 + 三种成本 + token
48
52
  ```
49
53
 
50
54
  ## 安装
@@ -96,6 +100,7 @@ dsh plugin --profile web add "link:$(pwd)"
96
100
 
97
101
  - 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
98
102
  - 余额路由:重启 dsh web 后 `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` 应返回 `{ ok, balance_infos, modelId }`
103
+ - 成本路由:`curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=<会话id>"` 与 `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` 应返回 `{ cost, inputTokens, outputTokens, modelId }`
99
104
 
100
105
  ## License
101
106
 
package/lib/client.js CHANGED
@@ -104,9 +104,12 @@ window.__ModuleLoader__.load({
104
104
  //#region lib/types/client/balance-api.js
105
105
  /** Same-origin balance route (host half registers it over ctx.webServer). */
106
106
  const BALANCE_ROUTE = "/api/dsh-balance/balance";
107
+ /** Same-origin cost routes. */
108
+ const LAST_COST_ROUTE = "/api/dsh-balance/last-cost";
109
+ const TODAY_COST_ROUTE = "/api/dsh-balance/today-cost";
107
110
  /**
108
111
  * Fetch the account balance from the host proxy.
109
- * @returns { ok, balanceInfos?, error? }
112
+ * @returns { ok, balanceInfos?, modelId?, error? }
110
113
  */
111
114
  async function fetchBalance() {
112
115
  try {
@@ -127,6 +130,25 @@ window.__ModuleLoader__.load({
127
130
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
128
131
  }
129
132
  }
133
+ /**
134
+ * Fetch a cost figure from the host (last turn or today's total).
135
+ * @param {string} route - LAST_COST_ROUTE or TODAY_COST_ROUTE.
136
+ * @param {string} [sessionId] - required for last-cost.
137
+ * @returns { ok, cost?, inputTokens?, outputTokens?, error? }
138
+ */
139
+ async function fetchCost(route, sessionId) {
140
+ try {
141
+ const url = sessionId === void 0 || sessionId === ""
142
+ ? route
143
+ : `${route}?session=${encodeURIComponent(sessionId)}`;
144
+ const response = await fetch(url, { headers: { accept: "application/json" } });
145
+ const payload = await response.json().catch(() => ({}));
146
+ if (!response.ok) return { ok: false, error: payload.error ?? `cost endpoint responded ${response.status}` };
147
+ return { ok: true, cost: payload.cost ?? 0, inputTokens: payload.inputTokens ?? 0, outputTokens: payload.outputTokens ?? 0 };
148
+ } catch (error) {
149
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
150
+ }
151
+ }
130
152
  /** Pick the display balance from balance_infos (prefer CNY, else first). */
131
153
  function displayBalance(balanceInfos) {
132
154
  if (!Array.isArray(balanceInfos) || balanceInfos.length === 0) return null;
@@ -149,6 +171,8 @@ window.__ModuleLoader__.load({
149
171
  const [open, setOpen] = (0, react.useState)(false);
150
172
  const [balance, setBalance] = (0, react.useState)(null);
151
173
  const [modelId, setModelId] = (0, react.useState)("deepseek-v4-flash");
174
+ const [lastCost, setLastCost] = (0, react.useState)(null);
175
+ const [todayCost, setTodayCost] = (0, react.useState)(null);
152
176
  const [error, setError] = (0, react.useState)(null);
153
177
  const [loading, setLoading] = (0, react.useState)(false);
154
178
  const usage = useProjection("tokenUsage");
@@ -159,14 +183,22 @@ window.__ModuleLoader__.load({
159
183
  if (next) {
160
184
  setLoading(true);
161
185
  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);
186
+ const [balanceResult, lastResult, todayResult] = await Promise.all([
187
+ fetchBalance(),
188
+ fetchCost(LAST_COST_ROUTE, sessionId),
189
+ fetchCost(TODAY_COST_ROUTE)
190
+ ]);
191
+ if (balanceResult.ok) {
192
+ setBalance(displayBalance(balanceResult.balanceInfos));
193
+ if (typeof balanceResult.modelId === "string" && balanceResult.modelId !== "") setModelId(balanceResult.modelId);
166
194
  } else {
167
195
  setBalance(null);
168
- setError(result.error);
196
+ setError(balanceResult.error);
169
197
  }
198
+ if (lastResult.ok) setLastCost(lastResult.cost);
199
+ else if (!lastResult.ok && lastResult.error) setError((error ?? "") + (error ? " " : "") + `last: ${lastResult.error}`);
200
+ if (todayResult.ok) setTodayCost(todayResult.cost);
201
+ else if (!todayResult.ok && todayResult.error) setError((error ?? "") + (error ? " " : "") + `today: ${todayResult.error}`);
170
202
  setLoading(false);
171
203
  }
172
204
  };
@@ -206,6 +238,26 @@ window.__ModuleLoader__.load({
206
238
  children: priced !== null ? formatCny(priced.total) : "—"
207
239
  })]
208
240
  })
241
+ }), (0, react_jsx_runtime.jsx)("div", {
242
+ className: styles.row,
243
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
244
+ children: [(0, react_jsx_runtime.jsx)("span", {
245
+ children: t("lastPromptCost")
246
+ }), (0, react_jsx_runtime.jsx)("span", {
247
+ className: styles.val,
248
+ children: loading ? "…" : lastCost !== null ? formatCny(lastCost) : "—"
249
+ })]
250
+ })
251
+ }), (0, react_jsx_runtime.jsx)("div", {
252
+ className: styles.row,
253
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
254
+ children: [(0, react_jsx_runtime.jsx)("span", {
255
+ children: t("todayCost")
256
+ }), (0, react_jsx_runtime.jsx)("span", {
257
+ className: styles.val,
258
+ children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
259
+ })]
260
+ })
209
261
  }), priced !== null && (0, react_jsx_runtime.jsx)("div", {
210
262
  className: styles.row,
211
263
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
@@ -243,20 +295,24 @@ window.__ModuleLoader__.load({
243
295
  const NS = "balance-widget";
244
296
  /** Simplified Chinese dictionary (the key-set source of truth). */
245
297
  const zh = {
246
- "aria": "查看账户余额与本轮成本",
298
+ "aria": "查看账户余额与成本",
247
299
  "title": "余额与成本",
248
300
  "balance": "账户余额",
249
301
  "sessionCost": "本会话成本(估算)",
302
+ "lastPromptCost": "最近一次提问(估算)",
303
+ "todayCost": "今天总成本(估算)",
250
304
  "tokens": "Token 用量",
251
305
  "hint": "价格按 DeepSeek 官方峰谷定价估算,实际以账单为准。",
252
306
  "close": "关闭"
253
307
  };
254
308
  /** English dictionary, checked complete against the zh key set. */
255
309
  const en = {
256
- "aria": "View account balance and session cost",
310
+ "aria": "View account balance and costs",
257
311
  "title": "Balance & cost",
258
312
  "balance": "Account balance",
259
313
  "sessionCost": "This session cost (est.)",
314
+ "lastPromptCost": "Last prompt (est.)",
315
+ "todayCost": "Today total (est.)",
260
316
  "tokens": "Token usage",
261
317
  "hint": "Prices estimated from DeepSeek official peak/off-peak rates; billing is authoritative.",
262
318
  "close": "Close"
package/lib/index.js CHANGED
@@ -22,7 +22,9 @@ export const inject = ["webServer"];
22
22
  /** Route paths shared with the browser half (spelled here, not imported). */
23
23
  export const API = {
24
24
  balance: "/api/dsh-balance/balance",
25
- cost: "/api/dsh-balance/cost"
25
+ cost: "/api/dsh-balance/cost",
26
+ lastCost: "/api/dsh-balance/last-cost",
27
+ todayCost: "/api/dsh-balance/today-cost"
26
28
  };
27
29
 
28
30
  /** Default DeepSeek API base (mirrors dsh-llm-deepseek's PUBLIC_BASE_URL). */
@@ -206,6 +208,202 @@ async function fetchBalance(ctx, config) {
206
208
  }
207
209
  }
208
210
 
211
+ /**
212
+ * Pricing table (CNY per 1M tokens, official 2026-08-17 peak/off-peak).
213
+ * Mirrors the client half; kept here so host-computed costs use the same
214
+ * numbers.
215
+ */
216
+ const PRICING = {
217
+ "deepseek-v4-flash": { peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 }, offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 } },
218
+ "deepseek-v4-pro": { peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 }, offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 } },
219
+ "deepseek-chat": { peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 }, offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 } },
220
+ "deepseek-reasoner": { peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 }, offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 } }
221
+ };
222
+ const PRICING_ALIASES = {
223
+ "deepseek-vision": "deepseek-v4-flash",
224
+ "deepseek-official": "deepseek-v4-flash"
225
+ };
226
+
227
+ /** Whether `date` falls inside a Beijing-time peak window. */
228
+ function isPeak(date) {
229
+ const hour = date.getHours();
230
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
231
+ }
232
+
233
+ /** Map a provider/model id to a pricing key. */
234
+ function pricingKey(modelId) {
235
+ if (modelId === void 0 || modelId === null) return "deepseek-v4-flash";
236
+ if (PRICING[modelId] !== void 0) return modelId;
237
+ const alias = PRICING_ALIASES[modelId];
238
+ if (alias !== void 0) return alias;
239
+ // heuristic: pro/reasoner-ish names price as pro
240
+ if (/pro|reasoner|r1/i.test(modelId)) return "deepseek-v4-pro";
241
+ return "deepseek-v4-flash";
242
+ }
243
+
244
+ /** Price one usage sample in CNY. */
245
+ function priceUsage(usage, modelId, timeMs) {
246
+ if (usage === void 0 || usage === null) return 0;
247
+ const input = usage.inputTokens ?? usage.uncachedInputTokens ?? 0;
248
+ const hit = usage.cacheReadTokens ?? 0;
249
+ const output = usage.outputTokens ?? 0;
250
+ if (input + hit + output <= 0) return 0;
251
+ const table = isPeak(new Date(timeMs ?? Date.now())) ? PRICING[pricingKey(modelId)].peak : PRICING[pricingKey(modelId)].offPeak;
252
+ return (input * table.inputMiss + hit * table.inputHit + output * table.output) / 1e6;
253
+ }
254
+
255
+ /**
256
+ * Resolve the DSH sessions root directory (defaults to ~/.dsh/sessions).
257
+ * Avoids importing dsh-home-paths to keep zero external dependencies.
258
+ */
259
+ function sessionsRoot() {
260
+ return `${process.env.HOME ?? ""}/.dsh/sessions`;
261
+ }
262
+
263
+ /** Strip a leading "session-" prefix if present (callers may pass either form). */
264
+ function normalizeSessionId(sessionId) {
265
+ return sessionId.startsWith("session-") ? sessionId.slice("session-".length) : sessionId;
266
+ }
267
+
268
+ /** Resolve the current session's JSONL path by walking the sessions tree. */
269
+ async function resolveSessionFile(sessionId) {
270
+ if (sessionId === void 0 || sessionId === "") return void 0;
271
+ const bareId = normalizeSessionId(sessionId);
272
+ const root = sessionsRoot();
273
+ try {
274
+ const fs = await awaitImportFs();
275
+ const dirs = fs.readdirSync(root, { withFileTypes: true });
276
+ for (const dir of dirs) {
277
+ if (!dir.isDirectory()) continue;
278
+ const candidate = `${root}/${dir.name}/session-${bareId}/session.jsonl.zstd`;
279
+ if (fs.existsSync(candidate)) return candidate;
280
+ }
281
+ } catch {
282
+ /* ignore filesystem errors */
283
+ }
284
+ return void 0;
285
+ }
286
+
287
+ let _fs;
288
+ function awaitImportFs() {
289
+ if (_fs === void 0) _fs = import("node:fs");
290
+ return _fs;
291
+ }
292
+
293
+ /**
294
+ * Decompress a .zstd session file to text via the zstd CLI (present on
295
+ * macOS/Linux with zstd installed). Returns undefined on failure.
296
+ */
297
+ async function decompressSession(path) {
298
+ const { execFile } = await import("node:child_process");
299
+ const { promisify } = await import("node:util");
300
+ const execFileP = promisify(execFile);
301
+ try {
302
+ const { stdout } = await execFileP("zstd", ["-d", "-c", path], { maxBuffer: 512 * 1024 * 1024 });
303
+ return stdout;
304
+ } catch {
305
+ return void 0;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Parse a decompressed session JSONL and return every assistant usage sample
311
+ * with its turn, time, and the model id from the nearest request/header.
312
+ */
313
+ function parseSessionUsages(text) {
314
+ const samples = [];
315
+ let currentModel = void 0;
316
+ for (const line of text.split("\n")) {
317
+ if (line === "") continue;
318
+ let event;
319
+ try {
320
+ event = JSON.parse(line);
321
+ } catch {
322
+ continue;
323
+ }
324
+ const type = event.type;
325
+ if (type === "request/header") {
326
+ currentModel = event.data?.header?.config?.model ?? currentModel;
327
+ } else if (type === "assistant/message") {
328
+ const usage = event.data?.usage;
329
+ if (usage !== void 0) {
330
+ samples.push({
331
+ turn: event.data?.turn ?? 0,
332
+ time: event.time ?? Date.now(),
333
+ model: currentModel,
334
+ usage
335
+ });
336
+ }
337
+ }
338
+ }
339
+ return samples;
340
+ }
341
+
342
+ /** Cost of the last turn (the most recent user prompt) in one session file. */
343
+ function lastTurnCost(samples) {
344
+ if (samples.length === 0) return { cost: 0, inputTokens: 0, outputTokens: 0 };
345
+ let lastTurn = -1;
346
+ for (const s of samples) if (s.turn > lastTurn) lastTurn = s.turn;
347
+ let cost = 0;
348
+ let input = 0;
349
+ let output = 0;
350
+ for (const s of samples) {
351
+ if (s.turn !== lastTurn) continue;
352
+ cost += priceUsage(s.usage, s.model, s.time);
353
+ input += (s.usage.inputTokens ?? s.usage.uncachedInputTokens ?? 0) + (s.usage.cacheReadTokens ?? 0);
354
+ output += s.usage.outputTokens ?? 0;
355
+ }
356
+ return { cost, inputTokens: input, outputTokens: output };
357
+ }
358
+
359
+ /**
360
+ * Sum today's usage across every session file. "Today" = events whose time
361
+ * falls on the same calendar day (local time) as now.
362
+ */
363
+ function todayCost(samplesBySession) {
364
+ const now = new Date();
365
+ const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
366
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000;
367
+ let cost = 0;
368
+ let input = 0;
369
+ let output = 0;
370
+ for (const samples of samplesBySession) {
371
+ for (const s of samples) {
372
+ if (s.time < dayStart || s.time >= dayEnd) continue;
373
+ cost += priceUsage(s.usage, s.model, s.time);
374
+ input += (s.usage.inputTokens ?? s.usage.uncachedInputTokens ?? 0) + (s.usage.cacheReadTokens ?? 0);
375
+ output += s.usage.outputTokens ?? 0;
376
+ }
377
+ }
378
+ return { cost, inputTokens: input, outputTokens: output };
379
+ }
380
+
381
+ /** Walk every session file under the sessions root, decompress, and parse. */
382
+ async function collectAllSessionSamples() {
383
+ const root = sessionsRoot();
384
+ const fs = (await awaitImportFs());
385
+ const files = [];
386
+ try {
387
+ const walk = (dir) => {
388
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
389
+ const full = `${dir}/${entry.name}`;
390
+ if (entry.isDirectory()) walk(full);
391
+ else if (entry.name === "session.jsonl.zstd") files.push(full);
392
+ }
393
+ };
394
+ walk(root);
395
+ } catch {
396
+ return [];
397
+ }
398
+ const perSession = [];
399
+ for (const file of files) {
400
+ const text = await decompressSession(file);
401
+ if (text === void 0) continue;
402
+ perSession.push(parseSessionUsages(text));
403
+ }
404
+ return perSession;
405
+ }
406
+
209
407
  /** Build the route table for the balance widget. */
210
408
  function makeRoutes(ctx, config) {
211
409
  return [
@@ -227,9 +425,41 @@ function makeRoutes(ctx, config) {
227
425
  path: API.cost,
228
426
  handler: async (req, res) => {
229
427
  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" });
428
+ // Legacy route: cost is priced per-session; keep responding for
429
+ // compatibility with older client builds.
430
+ writeJson(res, 200, { ok: true, note: "use /last-cost and /today-cost" });
431
+ }
432
+ },
433
+ {
434
+ kind: "exact",
435
+ path: API.lastCost,
436
+ handler: async (req, res) => {
437
+ if (!guard(req, res, "GET")) return;
438
+ const url = new URL(req.url ?? "/", "http://localhost");
439
+ const sessionId = url.searchParams.get("session") ?? "";
440
+ const file = await resolveSessionFile(sessionId);
441
+ if (file === void 0) {
442
+ writeJson(res, 404, { error: `session ${sessionId} not found`, modelId: config.modelId });
443
+ return;
444
+ }
445
+ const text = await decompressSession(file);
446
+ if (text === void 0) {
447
+ writeJson(res, 502, { error: "failed to read session file", modelId: config.modelId });
448
+ return;
449
+ }
450
+ const samples = parseSessionUsages(text);
451
+ const result = lastTurnCost(samples);
452
+ writeJson(res, 200, { ...result, modelId: config.modelId });
453
+ }
454
+ },
455
+ {
456
+ kind: "exact",
457
+ path: API.todayCost,
458
+ handler: async (req, res) => {
459
+ if (!guard(req, res, "GET")) return;
460
+ const perSession = await collectAllSessionSamples();
461
+ const result = todayCost(perSession);
462
+ writeJson(res, 200, { ...result, modelId: config.modelId });
233
463
  }
234
464
  }
235
465
  ];
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.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [