@dougen/dsh-deepseek-usage 0.1.8 → 0.3.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.md +18 -74
- package/lib/client.js +35 -71
- package/lib/index.js +12 -227
- package/lib/pricing.js +5 -30
- package/package.json +2 -2
- package/screenshot.png +0 -0
package/README.md
CHANGED
|
@@ -2,103 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek 用量侧边栏插件(DeepSeek Harness Web UI)
|
|
4
4
|
|
|
5
|
-
> **⚠️ 使用须知**
|
|
6
|
-
> - 本插件界面文案仅提供**中文**,无多语言版本。
|
|
7
|
-
> - **仅适用于 DSH 的 Web UI**:命令行等其他非WEB界面不支持。
|
|
8
|
-
> - **今日花费为本地估算值**(非官方账单):按本地会话日志 token × 官方峰谷价格表计算。
|
|
9
|
-
> - **峰谷时段按北京时间(Asia/Shanghai)固定计算**,与服务器本地时区无关。
|
|
10
|
-
|
|
11
5
|
## 简介
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
## 界面截图
|
|
7
|
+
在 DSH **网页界面**的侧边栏底部常驻显示你的 **DeepSeek 账号余额**(放大显示)与**当前模型档单价**卡片,并提供高峰/空闲计费时段指示灯。
|
|
16
8
|
|
|
17
9
|

|
|
18
10
|
|
|
19
|
-
侧边栏底部常驻卡片:余额、今日花费、今日 Token、今日 API 次数、峰谷状态灯与当前档单价。
|
|
20
|
-
|
|
21
11
|
## 功能
|
|
22
12
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- 跟随应用明暗主题(`--dsw-*` 变量,含兜底值)
|
|
13
|
+
- **余额**:`¥ 110.00` 大字显示(符号随账号币种自动切换:人民币 `¥` / 美元 `$`)
|
|
14
|
+
- **档单价**:`↑¥1.50 · 💾¥0.05 · ↓¥4.50`(输入 / 输入缓存 / 输出,随当前启用模型与峰谷时段自动切换,符号跟随余额币种)
|
|
15
|
+
- **峰谷指示灯**:卡片右上角,高峰(9:00–12:00、14:00–18:00 北京时间)橙色,空闲绿色
|
|
16
|
+
- **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新,刷新时右下角显示旋转图标
|
|
17
|
+
- 跟随应用明暗主题
|
|
29
18
|
|
|
30
19
|
## 安装
|
|
31
20
|
|
|
32
|
-
需要 DSH CLI
|
|
21
|
+
需要 DSH CLI,从 npm 安装:
|
|
33
22
|
|
|
34
23
|
```sh
|
|
35
24
|
dsh plugin --profile web add @dougen/dsh-deepseek-usage
|
|
36
25
|
```
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
本地开发时也可以手动安装:
|
|
41
|
-
|
|
42
|
-
```sh
|
|
43
|
-
cd dsh-deepseek-usage
|
|
44
|
-
pnpm pack # 生成 dougen-dsh-deepseek-usage-<version>.tgz
|
|
45
|
-
dsh plugin --profile web add <生成的 .tgz>
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
安装之后:
|
|
49
|
-
|
|
50
|
-
1. 重启网页应用:`dsh web`(bundle 层在启动时读取)。
|
|
51
|
-
2. 打开 http://127.0.0.1:3080 。
|
|
52
|
-
3. 侧边栏底部(Cordis 面板按钮下方)即出现用量卡片。
|
|
27
|
+
安装后重启网页应用:`dsh web`,打开 http://127.0.0.1:3080 ,侧边栏底部(Cordis 面板按钮下方)即出现用量卡片。
|
|
53
28
|
|
|
54
|
-
|
|
55
|
-
>
|
|
56
|
-
> ```yaml
|
|
57
|
-
> - insert:
|
|
58
|
-
> - id: deepseek-usage
|
|
59
|
-
> name: @dougen/dsh-deepseek-usage
|
|
60
|
-
> ```
|
|
61
|
-
|
|
62
|
-
## 数据来源与统计口径
|
|
29
|
+
## 数据来源
|
|
63
30
|
|
|
64
31
|
| 数据 | 来源 |
|
|
65
32
|
|---|---|
|
|
66
|
-
| 余额 | DeepSeek 官方 `GET /user/balance
|
|
67
|
-
|
|
|
68
|
-
| 今日 Token / API 调用数 | 本地会话日志统计(官方 API 无用量查询接口),覆盖全部会话,含重启前历史 |
|
|
69
|
-
|
|
70
|
-
统计口径(v0.1.7 起,与 DSH 官方 token-meter 对齐):
|
|
71
|
-
|
|
72
|
-
- **按 (turn, step) 去重**:同一调用的流式 usage 与最终消息只计一次,绝不重复计数;
|
|
73
|
-
- **失败请求的用量也计入**:请求失败、没有最终消息时,流式 usage 样本仍被统计;
|
|
74
|
-
- **今日 = 北京时间(Asia/Shanghai)**的自然日,与服务器时区无关;
|
|
75
|
-
- 模型不在内置价格表时按 `deepseek-chat` 档计价,并在卡片错误提示中明示(避免静默算错)。
|
|
76
|
-
|
|
77
|
-
## 安全说明
|
|
78
|
-
|
|
79
|
-
- 用量路由 `GET /api/deepseek-usage` **仅接受本机 Host**(`127.0.0.1` / `localhost` / `::1`),可拦截 DNS rebinding 与局域网直接访问。
|
|
80
|
-
- ⚠️ 若将 DSH 配置为 `host: 0.0.0.0` 对外网开放,本插件会在入口拒绝非本机 Host 的请求,但**请勿依赖插件作为唯一防线**——余额为账号敏感信息,建议用反向代理 + 鉴权保护整个 DSH 服务。
|
|
81
|
-
- ⚠️ 环境变量 `DEEPSEEK_BASE_URL`(与 llm-deepseek 适配器对齐)会把余额请求与 **API Key 一起发给该地址**;仅在你信任该网关/代理时配置。
|
|
82
|
-
|
|
83
|
-
## 变更记录
|
|
84
|
-
|
|
85
|
-
### v0.1.8
|
|
86
|
-
|
|
87
|
-
- **修复 0.1.7 回归:今日用量统计全为 0**。根因:`listSnapshots()` 返回的会话条目是 `{ header: SessionHeader, revision }`,id 位于 `header.id` 嵌套字段;0.1.7 误取顶层 `header.id`(undefined),导致所有会话 `readRaw` 失败被跳过(skipped>0、统计为 0)。已修正并从真实会话日志端到端验证:统计恢复、无读取失败。
|
|
33
|
+
| 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
|
|
34
|
+
| 档单价 / 峰谷时段 | 内置价格表(DeepSeek 官方峰谷定价快照,2026-08-17 起) |
|
|
88
35
|
|
|
89
|
-
|
|
36
|
+
使用前请确保 DSH 已配置 `DEEPSEEK_API_KEY`(可在 DSH 设置中的模型页面配置)。
|
|
90
37
|
|
|
91
|
-
|
|
92
|
-
- **性能优化**:按会话修订值增量扫描,只重读变化的会话日志;跨北京日自动失效缓存(P2)。
|
|
93
|
-
- **安全加固**:路由仅接受本机 Host,拦截 DNS rebinding 与局域网访问(P3)。
|
|
94
|
-
- **字段语义修正**:余额响应 `available`(误用官方 `is_available`)改为 `isAvailable` 布尔(P4)。
|
|
95
|
-
- **未知模型提示**:价格表外模型按 chat 档计价并明示,不再静默算错(P5)。
|
|
96
|
-
- **时区修正**:峰谷判定与"今日"边界改为固定北京时间,不随服务器时区漂移(P6)。
|
|
97
|
-
- **依赖声明**:补充 `@deepseek-ai/dsh-host-webserver` peer 依赖(P8)。
|
|
38
|
+
## 常见问题
|
|
98
39
|
|
|
99
|
-
|
|
40
|
+
**余额显示"—"?**
|
|
41
|
+
- 未配置 `DEEPSEEK_API_KEY`:在 DSH 设置中配置后重启。
|
|
42
|
+
- 网络或接口异常:错误信息会显示在卡片下方。
|
|
100
43
|
|
|
101
|
-
|
|
44
|
+
**单价与官方价格不一致?**
|
|
45
|
+
- 单价按**当前启用模型**与**峰谷时段**自动切换;若当前模型不在内置价格表,会按 `deepseek-chat` 档展示并给出提示。
|
|
102
46
|
|
|
103
47
|
## 卸载
|
|
104
48
|
|
package/lib/client.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 侧边栏底部用量卡片(注册进 `sidebar.footer.action` 槽位,独占一行,位于
|
|
4
4
|
// Cordis 面板按钮下方)。轮询宿主路由 `/api/deepseek-usage`(见 lib/index.js)
|
|
5
|
-
// 每
|
|
5
|
+
// 每 5 分钟一次,点击卡片任意位置可立即刷新;右下角在刷新时显示旋转 ⟳ 图标。
|
|
6
|
+
// 卡片显示:余额(放大显示,人民币符号)+ 当前模型档单价(输入/输入缓存/输出)。
|
|
6
7
|
// 样式仅使用 `--dsw-*` 主题变量(含兜底值),跟随明暗主题。
|
|
7
8
|
window.__ModuleLoader__.load({
|
|
8
9
|
id: "@dougen/dsh-deepseek-usage",
|
|
@@ -15,23 +16,14 @@ window.__ModuleLoader__.load({
|
|
|
15
16
|
const h = react.createElement;
|
|
16
17
|
|
|
17
18
|
// ---- constants -------------------------------------------------
|
|
18
|
-
const POLL_MS = 60 * 1000;
|
|
19
|
+
const POLL_MS = 5 * 60 * 1000;
|
|
19
20
|
const USAGE_PATH = "/api/deepseek-usage";
|
|
20
21
|
|
|
21
22
|
// ---- helpers ---------------------------------------------------
|
|
22
|
-
function money(value
|
|
23
|
+
function money(value) {
|
|
23
24
|
const n = Number(value);
|
|
24
25
|
if (!Number.isFinite(n)) return "—";
|
|
25
|
-
|
|
26
|
-
return `${symbol}${n.toFixed(2)}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function fmtTokens(value) {
|
|
30
|
-
const n = Number(value);
|
|
31
|
-
if (!Number.isFinite(n)) return "—";
|
|
32
|
-
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
33
|
-
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
|
34
|
-
return String(n);
|
|
26
|
+
return n.toFixed(2);
|
|
35
27
|
}
|
|
36
28
|
|
|
37
29
|
function fmtPrice(value) {
|
|
@@ -60,18 +52,8 @@ window.__ModuleLoader__.load({
|
|
|
60
52
|
strokeWidth: 2.2,
|
|
61
53
|
strokeLinecap: "round",
|
|
62
54
|
strokeLinejoin: "round",
|
|
63
|
-
style: { flex: "none",
|
|
55
|
+
style: { flex: "none", display: "block" },
|
|
64
56
|
};
|
|
65
|
-
const IconMoney = () => h("svg", svgBase,
|
|
66
|
-
h("circle", { cx: 12, cy: 12, r: 9 }),
|
|
67
|
-
h("path", { d: "M12 6.5v11" }),
|
|
68
|
-
h("path", { d: "M9 9.5h6" }),
|
|
69
|
-
h("path", { d: "M9 14.5h6" }));
|
|
70
|
-
const IconToken = () => h("svg", svgBase,
|
|
71
|
-
h("rect", { x: 6.5, y: 6.5, width: 11, height: 11, rx: 2 }),
|
|
72
|
-
h("path", { d: "M9 2.5v4M15 2.5v4M9 17.5v4M15 17.5v4" }),
|
|
73
|
-
h("path", { d: "M2.5 9h4M2.5 15h4M17.5 9h4M17.5 15h4" }));
|
|
74
|
-
const IconApi = () => h("svg", svgBase, h("path", { d: "M13 2 3 14h7l-1 8 10-12h-7l1-8z" }));
|
|
75
57
|
const IconIn = () => h("svg", svgBase,
|
|
76
58
|
h("path", { d: "M12 19V5" }),
|
|
77
59
|
h("path", { d: "M5 12l7-7 7 7" }));
|
|
@@ -124,11 +106,9 @@ window.__ModuleLoader__.load({
|
|
|
124
106
|
}, [load]);
|
|
125
107
|
|
|
126
108
|
const balance = data && data.balance ? data.balance : null;
|
|
127
|
-
const today = data && data.today ? data.today : null;
|
|
128
109
|
const tier = data && data.tier ? data.tier : null;
|
|
129
|
-
const
|
|
110
|
+
const symbol = balance && balance.currency === "USD" ? "$" : "¥";
|
|
130
111
|
const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
|
|
131
|
-
const tok = today && today.tokens ? today.tokens : null;
|
|
132
112
|
const dot = {
|
|
133
113
|
width: 8,
|
|
134
114
|
height: 8,
|
|
@@ -159,23 +139,17 @@ window.__ModuleLoader__.load({
|
|
|
159
139
|
title,
|
|
160
140
|
onClick: load,
|
|
161
141
|
},
|
|
162
|
-
h("span", { style: {
|
|
142
|
+
h("span", { style: { display: "inline-flex", alignItems: "baseline", gap: 3 } },
|
|
143
|
+
h("span", { style: { fontWeight: 700, fontSize: 13, color: C.label } }, symbol),
|
|
144
|
+
h("span", { style: { fontWeight: 700, fontSize: 13, color: C.label } }, money(balance && balance.total))),
|
|
163
145
|
h("span", { style: dot, title: data && data.peak ? "高峰时段" : "空闲时段" }));
|
|
164
146
|
}
|
|
165
147
|
|
|
166
|
-
const
|
|
167
|
-
h("span", { style: { display: "inline-flex",
|
|
168
|
-
|
|
169
|
-
h("span", { style: {
|
|
170
|
-
|
|
171
|
-
const pr = (icon, value) =>
|
|
172
|
-
h("span", { style: { display: "inline-flex", gap: 2, alignItems: "center" } },
|
|
173
|
-
h("span", { style: { display: "inline-flex", alignItems: "center" } }, icon),
|
|
174
|
-
h("span", { style: { fontWeight: 600, whiteSpace: "nowrap" } }, value));
|
|
175
|
-
const Sep = () => h("span", { style: { color: C.sub, margin: "0 1px", fontSize: 10 } }, "·");
|
|
176
|
-
|
|
177
|
-
const costText = money(today && today.cost, currency);
|
|
178
|
-
const costShown = costText;
|
|
148
|
+
const Price = (icon, label, value) =>
|
|
149
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: 3, minWidth: 0, color: C.sub } },
|
|
150
|
+
h("span", { style: { display: "inline-flex", alignItems: "center" }, title: label }, icon),
|
|
151
|
+
h("span", { style: { fontWeight: 600, whiteSpace: "nowrap" } }, symbol + fmtPrice(value)));
|
|
152
|
+
const Sep = () => h("span", { style: { color: C.sub, margin: "0 3px", fontSize: 10 } }, "·");
|
|
179
153
|
|
|
180
154
|
return h("div", {
|
|
181
155
|
style: {
|
|
@@ -200,40 +174,30 @@ window.__ModuleLoader__.load({
|
|
|
200
174
|
title,
|
|
201
175
|
onClick: load,
|
|
202
176
|
},
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
177
|
+
// 第一行:余额(24px,符号与数字 3px 间距)+ 当前档单价(灰色,跟在余额后,字号不变)
|
|
178
|
+
h("div", { style: { display: "flex", alignItems: "baseline", minWidth: 0, paddingRight: 14, gap: 6 } },
|
|
179
|
+
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px", marginRight: 3 } }, symbol),
|
|
180
|
+
h("span", { style: { fontWeight: 700, fontSize: 24, color: C.label, lineHeight: "28px" } }, money(balance && balance.total)),
|
|
181
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: 3, marginLeft: 10 } },
|
|
182
|
+
Price(h(IconIn, null), "输入", tier && tier.input),
|
|
183
|
+
Sep(),
|
|
184
|
+
Price(h(IconCache, null), "输入缓存", tier && tier.cacheRead),
|
|
185
|
+
Sep(),
|
|
186
|
+
Price(h(IconOut, null), "输出", tier && tier.output))),
|
|
211
187
|
h("span", {
|
|
212
188
|
style: { position: "absolute", top: 8, right: 9, ...dot },
|
|
213
189
|
title: data && data.peak ? "高峰时段 " + (data.peakHours || "") : "空闲时段",
|
|
214
190
|
}),
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
: null,
|
|
225
|
-
h("span", {
|
|
226
|
-
className: "dsw-usage-time",
|
|
227
|
-
style: { display: "inline-flex", alignItems: "center", gap: 4, color: C.sub, fontSize: 10, flex: "none" },
|
|
228
|
-
},
|
|
229
|
-
spinning
|
|
230
|
-
? h("span", {
|
|
231
|
-
className: "dsw-usage-refresh",
|
|
232
|
-
style: { color: C.label, display: "inline-flex", alignItems: "center" },
|
|
233
|
-
title: "刷新中",
|
|
234
|
-
}, h("svg", { width: 11, height: 11, viewBox: "0 0 16 16", fill: "none", style: { animation: "dsh-usage-spin .8s linear infinite" } },
|
|
235
|
-
h("path", { d: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 1.5v3h-3", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" })))
|
|
236
|
-
: null)),
|
|
191
|
+
// 刷新指示:卡片右下角
|
|
192
|
+
h("span", {
|
|
193
|
+
className: "dsw-usage-refresh",
|
|
194
|
+
style: { position: "absolute", bottom: 8, right: 9, display: "inline-flex", alignItems: "center", color: C.sub },
|
|
195
|
+
title: "刷新中",
|
|
196
|
+
},
|
|
197
|
+
spinning
|
|
198
|
+
? h("svg", { width: 11, height: 11, viewBox: "0 0 16 16", fill: "none", style: { animation: "dsh-usage-spin .8s linear infinite" } },
|
|
199
|
+
h("path", { d: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 1.5v3h-3", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" }))
|
|
200
|
+
: null),
|
|
237
201
|
errs.length
|
|
238
202
|
? h("div", { style: { color: C.err, fontSize: 10, whiteSpace: "pre-wrap" } }, errs.join(" | "))
|
|
239
203
|
: null);
|
package/lib/index.js
CHANGED
|
@@ -8,46 +8,24 @@
|
|
|
8
8
|
* 返回(JSON):
|
|
9
9
|
* {
|
|
10
10
|
* ok: true,
|
|
11
|
-
* balance: { isAvailable, total, currency, granted, toppedUp },
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* costSource: 'estimate' | null,
|
|
15
|
-
* tokens: { input, output, cacheRead, cacheWrite, total }, // 今日 token 数(本地日志)
|
|
16
|
-
* calls, // 今日有用量报告的 API 调用数(本地日志,按 turn/step 去重)
|
|
17
|
-
* sessions // 今日有活动的会话数
|
|
18
|
-
* },
|
|
19
|
-
* peak, peakHours, // 峰谷时段(北京时间)
|
|
20
|
-
* tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
|
|
11
|
+
* balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
|
|
12
|
+
* tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
|
|
13
|
+
* peak, peakHours, // 峰谷时段(北京时间)
|
|
21
14
|
* updatedAt, errors
|
|
22
15
|
* }
|
|
23
16
|
*
|
|
24
|
-
*
|
|
25
|
-
* llm-deepseek
|
|
26
|
-
*
|
|
27
|
-
* 日志 token × 官方峰谷价格表(见 lib/pricing.js)。
|
|
17
|
+
* 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
|
|
18
|
+
* 与 llm-deepseek 同一引用,不出本机)。单价与峰谷时段来自内置价格表
|
|
19
|
+
* (lib/pricing.js,DeepSeek 官方峰谷定价快照)。
|
|
28
20
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* 只计一次,后者覆盖前者(last-wins);
|
|
32
|
-
* - 同时吸收 assistant/chunk 的 usage 样本,因此"请求失败、没有最终
|
|
33
|
-
* assistant/message"的调用其用量仍被计入(chunk 样本存活);
|
|
34
|
-
* - calls = 今日有用量报告的唯一调用数(近似 API 请求数,含失败请求)。
|
|
35
|
-
*
|
|
36
|
-
* 性能(0.1.7 起):按会话增量扫描——持久化后端会话修订值
|
|
37
|
-
* (listSnapshots)未变的会话复用缓存的聚合结果,只重读变化的会话;
|
|
38
|
-
* 跨北京日自动失效全部缓存。
|
|
39
|
-
*
|
|
40
|
-
* 安全(0.1.7 起):路由仅接受 Host 为 127.0.0.1 / localhost / ::1 的请求,
|
|
41
|
-
* 拒绝 DNS rebinding 与 0.0.0.0 绑定下的局域网访问。
|
|
21
|
+
* 安全:路由仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS
|
|
22
|
+
* rebinding 与 0.0.0.0 绑定下的局域网访问。
|
|
42
23
|
*/
|
|
43
24
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
44
25
|
import {
|
|
45
26
|
DEFAULT_MODEL,
|
|
46
27
|
PEAK_HOURS_TEXT,
|
|
47
|
-
costOf,
|
|
48
28
|
isPeak,
|
|
49
|
-
shanghaiDayKey,
|
|
50
|
-
startOfTodayShanghaiMs,
|
|
51
29
|
unitAt,
|
|
52
30
|
} from "./pricing.js";
|
|
53
31
|
|
|
@@ -61,8 +39,6 @@ const API_KEY_REF = credentialRef("DEEPSEEK_API_KEY");
|
|
|
61
39
|
const BALANCE_PATH = "/user/balance";
|
|
62
40
|
const ROUTE_PATH = "/api/deepseek-usage";
|
|
63
41
|
const TIMEOUT_MS = 15000;
|
|
64
|
-
/** 本地用量扫描的短缓存,避免多标签页/轮询反复解码全部日志。 */
|
|
65
|
-
const USAGE_CACHE_MS = 20000;
|
|
66
42
|
|
|
67
43
|
const JSON_HEADERS = {
|
|
68
44
|
"content-type": "application/json; charset=utf-8",
|
|
@@ -105,173 +81,6 @@ function isLocalHost(req) {
|
|
|
105
81
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
106
82
|
}
|
|
107
83
|
|
|
108
|
-
/**
|
|
109
|
-
* (turn, step) 去重键:同一调用的 usage chunk 与最终 message 共享 key,
|
|
110
|
-
* 后者覆盖前者;缺少 turn/step 时退化为按事件 seq 独立计(罕见,日志
|
|
111
|
-
* 规范下不该发生)。
|
|
112
|
-
*/
|
|
113
|
-
function stepKey(turn, step, seq) {
|
|
114
|
-
if (typeof turn === "number" && typeof step === "number") return `t:${turn}:s:${step}`;
|
|
115
|
-
return `e:${seq}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function freshAgg() {
|
|
119
|
-
return { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, unknownModels: new Set() };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function addAgg(target, src) {
|
|
123
|
-
target.calls += src.calls;
|
|
124
|
-
target.input += src.input;
|
|
125
|
-
target.output += src.output;
|
|
126
|
-
target.cacheRead += src.cacheRead;
|
|
127
|
-
target.cacheWrite += src.cacheWrite;
|
|
128
|
-
target.cost += src.cost;
|
|
129
|
-
for (const m of src.unknownModels) target.unknownModels.add(m);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* 顺序扫描一个会话的原始日志,返回该会话的今日聚合;无今日样本返回 null。
|
|
134
|
-
* 与 token-meter 相同的 last-wins 语义:同一 (turn, step) 的 usage 样本
|
|
135
|
-
* (流式 chunk 或最终 message)只计最后一次,绝不重复计数。
|
|
136
|
-
*/
|
|
137
|
-
function scanSessionRaw(content, dayStart, fallbackModel) {
|
|
138
|
-
const agg = freshAgg();
|
|
139
|
-
const steps = new Map();
|
|
140
|
-
for (const line of content.split("\n")) {
|
|
141
|
-
if (line === "") continue;
|
|
142
|
-
let event;
|
|
143
|
-
try {
|
|
144
|
-
event = JSON.parse(line);
|
|
145
|
-
} catch {
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (event === null || typeof event !== "object" || typeof event.time !== "number" || event.time < dayStart) continue;
|
|
149
|
-
const data = event.data;
|
|
150
|
-
if (data === null || typeof data !== "object") continue;
|
|
151
|
-
let usage;
|
|
152
|
-
let model;
|
|
153
|
-
if (
|
|
154
|
-
event.type === "assistant/chunk" &&
|
|
155
|
-
data.chunk !== null &&
|
|
156
|
-
typeof data.chunk === "object" &&
|
|
157
|
-
data.chunk.type === "usage" &&
|
|
158
|
-
data.chunk.usage
|
|
159
|
-
) {
|
|
160
|
-
// 流式 usage 样本:失败请求没有最终 message 时仍存活,必须计入。
|
|
161
|
-
usage = data.chunk.usage;
|
|
162
|
-
model = fallbackModel;
|
|
163
|
-
} else if (event.type === "assistant/message" && data.usage) {
|
|
164
|
-
// 最终样本:覆盖同 (turn, step) 的早期 chunk 样本。
|
|
165
|
-
usage = data.usage;
|
|
166
|
-
const source = data.message && data.message.source;
|
|
167
|
-
model = source && typeof source.model === "string" ? source.model : fallbackModel;
|
|
168
|
-
} else {
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
steps.set(stepKey(data.turn, data.step, event.seq), { usage, model, time: event.time });
|
|
172
|
-
}
|
|
173
|
-
if (steps.size === 0) return null;
|
|
174
|
-
for (const { usage, model, time } of steps.values()) {
|
|
175
|
-
const unit = unitAt(model, time);
|
|
176
|
-
if (!unit.known) agg.unknownModels.add(model);
|
|
177
|
-
agg.calls += 1;
|
|
178
|
-
agg.input += usage.inputTokens || 0;
|
|
179
|
-
agg.output += usage.outputTokens || 0;
|
|
180
|
-
agg.cacheRead += usage.cacheReadTokens || 0;
|
|
181
|
-
agg.cacheWrite += usage.cacheWriteTokens || 0;
|
|
182
|
-
agg.cost += costOf(usage, unit);
|
|
183
|
-
}
|
|
184
|
-
return agg;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let usageCache = null;
|
|
188
|
-
let usageCacheAt = 0;
|
|
189
|
-
let usageCacheDay = "";
|
|
190
|
-
/** sessionId -> { revision, agg },per-session 增量聚合缓存(P2)。 */
|
|
191
|
-
let sessionAggCache = new Map();
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* 扫描全部会话日志,统计今日(北京时间)token 用量 / API 调用数 / 花费估算。
|
|
195
|
-
* 增量策略:会话修订值(listSnapshots)未变 → 复用缓存聚合;只重读变化的
|
|
196
|
-
* 会话;跨北京日(shanghaiDayKey 变化)时全部失效重扫。
|
|
197
|
-
*/
|
|
198
|
-
async function scanTodayUsage(ctx) {
|
|
199
|
-
const persistence = ctx.get("sessionPersistence");
|
|
200
|
-
if (persistence === void 0 || typeof persistence.list !== "function" || typeof persistence.readRaw !== "function") {
|
|
201
|
-
return { usable: false, error: "sessionPersistence 不可用" };
|
|
202
|
-
}
|
|
203
|
-
const now = Date.now();
|
|
204
|
-
const dayKey = shanghaiDayKey(now);
|
|
205
|
-
if (usageCache !== null && usageCacheDay === dayKey && now - usageCacheAt < USAGE_CACHE_MS) return usageCache;
|
|
206
|
-
if (usageCacheDay !== dayKey) {
|
|
207
|
-
usageCacheDay = dayKey;
|
|
208
|
-
sessionAggCache.clear();
|
|
209
|
-
}
|
|
210
|
-
const dayStart = startOfTodayShanghaiMs(now);
|
|
211
|
-
const acc = freshAgg();
|
|
212
|
-
acc.usable = true;
|
|
213
|
-
acc.sessions = 0;
|
|
214
|
-
acc.skipped = 0;
|
|
215
|
-
let defaultModel = DEFAULT_MODEL;
|
|
216
|
-
try {
|
|
217
|
-
const adm = ctx.get("agentDefaultModel");
|
|
218
|
-
const selection = adm && typeof adm.currentSelection === "function" ? adm.currentSelection() : void 0;
|
|
219
|
-
if (selection && typeof selection.model === "string") defaultModel = selection.model;
|
|
220
|
-
} catch {}
|
|
221
|
-
try {
|
|
222
|
-
const hasSnapshots = typeof persistence.listSnapshots === "function";
|
|
223
|
-
const headers = hasSnapshots ? await persistence.listSnapshots() : await persistence.list();
|
|
224
|
-
const seen = new Set();
|
|
225
|
-
for (const header of headers) {
|
|
226
|
-
// listSnapshots 返回 { header: SessionHeader, revision },id 在 header.header.id;
|
|
227
|
-
// list 直接返回 SessionHeader[](顶层 id)。
|
|
228
|
-
const id = hasSnapshots ? header.header.id : header.id;
|
|
229
|
-
if (typeof id !== "string" || id === "") continue;
|
|
230
|
-
seen.add(id);
|
|
231
|
-
const revision = hasSnapshots ? header.revision : void 0;
|
|
232
|
-
const cached = sessionAggCache.get(id);
|
|
233
|
-
if (cached !== void 0 && revision !== void 0 && cached.revision === revision) {
|
|
234
|
-
if (cached.agg !== null) {
|
|
235
|
-
addAgg(acc, cached.agg);
|
|
236
|
-
acc.sessions += 1;
|
|
237
|
-
}
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
let raw;
|
|
241
|
-
try {
|
|
242
|
-
raw = await persistence.readRaw(id);
|
|
243
|
-
} catch {
|
|
244
|
-
acc.skipped += 1;
|
|
245
|
-
sessionAggCache.delete(id);
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
if (raw === void 0 || raw === null || typeof raw.content !== "string") {
|
|
249
|
-
sessionAggCache.delete(id);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
const agg = scanSessionRaw(raw.content, dayStart, defaultModel);
|
|
253
|
-
sessionAggCache.set(id, { revision, agg });
|
|
254
|
-
if (agg === null) continue;
|
|
255
|
-
addAgg(acc, agg);
|
|
256
|
-
acc.sessions += 1;
|
|
257
|
-
}
|
|
258
|
-
if (hasSnapshots) {
|
|
259
|
-
// 修剪已被删除的会话缓存。
|
|
260
|
-
for (const id of sessionAggCache.keys()) {
|
|
261
|
-
if (!seen.has(id)) sessionAggCache.delete(id);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
} catch (error) {
|
|
265
|
-
acc.usable = false;
|
|
266
|
-
acc.error = error instanceof Error ? error.message : String(error);
|
|
267
|
-
}
|
|
268
|
-
acc.cost = Math.round(acc.cost * 1e6) / 1e6;
|
|
269
|
-
usageCache = acc;
|
|
270
|
-
usageCacheAt = now;
|
|
271
|
-
usageCacheDay = dayKey;
|
|
272
|
-
return acc;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
84
|
function apply(ctx) {
|
|
276
85
|
ctx.effect(
|
|
277
86
|
() => ctx.webServer.register({
|
|
@@ -279,14 +88,14 @@ function apply(ctx) {
|
|
|
279
88
|
path: ROUTE_PATH,
|
|
280
89
|
handler: async (req, res) => {
|
|
281
90
|
try {
|
|
282
|
-
// ----
|
|
91
|
+
// ---- 仅本机访问:拦截 DNS rebinding 与局域网直接访问 ----
|
|
283
92
|
if (!isLocalHost(req)) {
|
|
284
93
|
return sendJson(res, 403, { ok: false, error: "forbidden", message: "仅允许本机访问" });
|
|
285
94
|
}
|
|
286
95
|
|
|
287
96
|
const errors = [];
|
|
288
97
|
|
|
289
|
-
// ----
|
|
98
|
+
// ---- 余额(官方接口,API Key)----
|
|
290
99
|
const balance = { isAvailable: null, total: null, currency: null, granted: null, toppedUp: null };
|
|
291
100
|
try {
|
|
292
101
|
const hit = await ctx.credentials.resolve(API_KEY_REF);
|
|
@@ -315,13 +124,7 @@ function apply(ctx) {
|
|
|
315
124
|
errors.push(`余额获取失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
316
125
|
}
|
|
317
126
|
|
|
318
|
-
// ----
|
|
319
|
-
const usage = await scanTodayUsage(ctx);
|
|
320
|
-
if (!usage.usable) errors.push(usage.error ?? "今日用量统计不可用");
|
|
321
|
-
if (usage.usable && usage.unknownModels.size > 0) {
|
|
322
|
-
errors.push(`未知模型按 chat 档计价: ${[...usage.unknownModels].join(", ")}`);
|
|
323
|
-
}
|
|
324
|
-
|
|
127
|
+
// ---- 当前模型当前档单价 + 峰谷(展示用)----
|
|
325
128
|
const now = Date.now();
|
|
326
129
|
let model = DEFAULT_MODEL;
|
|
327
130
|
try {
|
|
@@ -330,29 +133,11 @@ function apply(ctx) {
|
|
|
330
133
|
if (selection && typeof selection.model === "string") model = selection.model;
|
|
331
134
|
} catch {}
|
|
332
135
|
const unit = unitAt(model, now);
|
|
333
|
-
|
|
334
|
-
const cost = usage.usable ? usage.cost : null;
|
|
335
|
-
const costSource = usage.usable ? "estimate" : null;
|
|
136
|
+
if (!unit.known) errors.push(`当前模型 ${model} 不在价格表,档单价按 deepseek-chat 展示`);
|
|
336
137
|
|
|
337
138
|
sendJson(res, 200, {
|
|
338
139
|
ok: true,
|
|
339
140
|
balance,
|
|
340
|
-
today: {
|
|
341
|
-
cost,
|
|
342
|
-
costSource,
|
|
343
|
-
tokens: usage.usable
|
|
344
|
-
? {
|
|
345
|
-
input: usage.input,
|
|
346
|
-
output: usage.output,
|
|
347
|
-
cacheRead: usage.cacheRead,
|
|
348
|
-
cacheWrite: usage.cacheWrite,
|
|
349
|
-
total: usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
|
|
350
|
-
}
|
|
351
|
-
: null,
|
|
352
|
-
calls: usage.usable ? usage.calls : null,
|
|
353
|
-
sessions: usage.usable ? usage.sessions : null,
|
|
354
|
-
skipped: usage.usable ? usage.skipped : null,
|
|
355
|
-
},
|
|
356
141
|
peak: isPeak(now),
|
|
357
142
|
peakHours: PEAK_HOURS_TEXT,
|
|
358
143
|
tier: {
|
package/lib/pricing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-deepseek-usage —
|
|
2
|
+
* dsh-deepseek-usage — 定价表(元 / 百万 tokens)。
|
|
3
3
|
*
|
|
4
4
|
* DeepSeek 官方价格快照(2026-08-17 起 V4 系列峰谷定价,北京时间):
|
|
5
5
|
* 高峰时段 9:00–12:00 与 14:00–18:00(北京时间),其余为空闲时段(空闲=高峰半价)。
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* 与 isKnownModel() 让调用方提示用户,而不是静默算错。
|
|
8
8
|
* 价格调整时只需改 PRICE_TABLE 与 PEAK_WINDOWS。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 本文件用于卡片"当前档单价"展示与峰谷指示灯。
|
|
11
|
+
*
|
|
12
|
+
* 时区(0.1.7 起):峰谷判定按北京时间(Asia/Shanghai,固定 UTC+8,无夏令时)
|
|
13
|
+
* 计算,不随服务器本地时区变化——在任何时区部署的 DSH 上显示一致。
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
/** 北京时间固定偏移(上海无夏令时)。 */
|
|
@@ -36,21 +37,6 @@ export function shanghaiHour(ms) {
|
|
|
36
37
|
return new Date(ms + SHANGHAI_OFFSET_MS).getUTCHours();
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/** 某时刻所在北京日的零点(unix ms)。 */
|
|
40
|
-
export function startOfTodayShanghaiMs(now) {
|
|
41
|
-
const shifted = new Date(now + SHANGHAI_OFFSET_MS);
|
|
42
|
-
return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SHANGHAI_OFFSET_MS;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 某时刻所在北京日的日期键(YYYY-MM-DD),用于跨日失效。 */
|
|
46
|
-
export function shanghaiDayKey(now) {
|
|
47
|
-
const shifted = new Date(now + SHANGHAI_OFFSET_MS);
|
|
48
|
-
const y = shifted.getUTCFullYear();
|
|
49
|
-
const m = String(shifted.getUTCMonth() + 1).padStart(2, '0');
|
|
50
|
-
const d = String(shifted.getUTCDate()).padStart(2, '0');
|
|
51
|
-
return `${y}-${m}-${d}`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
40
|
/** 某时刻是否为高峰时段(按北京时间)。 */
|
|
55
41
|
export function isPeak(ms) {
|
|
56
42
|
const h = shanghaiHour(ms);
|
|
@@ -79,14 +65,3 @@ export function unitAt(model, ms) {
|
|
|
79
65
|
known,
|
|
80
66
|
};
|
|
81
67
|
}
|
|
82
|
-
|
|
83
|
-
/** 按单位价格计算一次 usage 的估算成本(元)。 */
|
|
84
|
-
export function costOf(usage, unit) {
|
|
85
|
-
return (
|
|
86
|
-
(usage.inputTokens * unit.input +
|
|
87
|
-
(usage.cacheReadTokens || 0) * unit.cacheRead +
|
|
88
|
-
(usage.cacheWriteTokens || 0) * unit.cacheWrite +
|
|
89
|
-
usage.outputTokens * unit.output) /
|
|
90
|
-
1e6
|
|
91
|
-
);
|
|
92
|
-
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougen/dsh-deepseek-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSeek 用量侧边栏插件:余额(官方 API
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "DeepSeek 用量侧边栏插件:余额(官方 API)、当前档单价与峰谷时段指示灯。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|
package/screenshot.png
CHANGED
|
Binary file
|