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 +9 -4
- package/README.md +9 -4
- package/lib/client.js +64 -8
- package/lib/index.js +234 -4
- package/package.json +1 -1
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;
|
|
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
|
|
46
|
-
GET /api/dsh-balance/cost
|
|
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
|
|
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
|
-
- **按需刷新** —
|
|
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
|
|
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 同源
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
231
|
-
//
|
|
232
|
-
writeJson(res, 200, { ok: true, note: "cost
|
|
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.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|