@dougen/dsh-deepseek-usage 0.1.7 → 0.2.1
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 +30 -63
- package/lib/browser-token.js +181 -0
- package/lib/client.js +47 -20
- package/lib/index.js +159 -200
- package/lib/pricing.js +4 -12
- package/package.json +2 -2
- package/screenshot.png +0 -0
package/README.md
CHANGED
|
@@ -2,99 +2,66 @@
|
|
|
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 账号用量**卡片:余额、今日消费金额、今日 Token、今日 API 请求次数,以及高峰/空闲计费时段指示灯。
|
|
16
8
|
|
|
17
9
|

|
|
18
10
|
|
|
19
|
-
侧边栏底部常驻卡片:余额、今日花费、今日 Token、今日 API 次数、峰谷状态灯与当前档单价。
|
|
20
|
-
|
|
21
11
|
## 功能
|
|
22
12
|
|
|
23
13
|
- 第一行:`余额 ¥110.00`
|
|
24
|
-
- 第二行:`今日
|
|
14
|
+
- 第二行:`今日 2.94 · 36.9M · 170`(今日消费金额 · 今日 Token · 今日 API 请求次数)
|
|
25
15
|
- 第三行:`↑1.5 · 💾0.05 · ↓4.5`(输入 / 缓存命中 / 输出 单价,按当前启用模型与峰谷时段自动切换)
|
|
26
|
-
- 右上角:高峰/空闲状态指示灯(高峰 9:00–12:00、14:00–18:00
|
|
27
|
-
- 每
|
|
28
|
-
-
|
|
16
|
+
- 右上角:高峰/空闲状态指示灯(高峰 9:00–12:00、14:00–18:00 北京时间,橙色;其余空闲,绿色)
|
|
17
|
+
- 每 5 分钟自动刷新;点击卡片任意位置可立即刷新
|
|
18
|
+
- 跟随应用明暗主题
|
|
29
19
|
|
|
30
20
|
## 安装
|
|
31
21
|
|
|
32
|
-
需要 DSH CLI
|
|
22
|
+
需要 DSH CLI,从 npm 安装:
|
|
33
23
|
|
|
34
24
|
```sh
|
|
35
25
|
dsh plugin --profile web add @dougen/dsh-deepseek-usage
|
|
36
26
|
```
|
|
37
27
|
|
|
38
|
-
|
|
28
|
+
安装后重启网页应用:`dsh web`,打开 http://127.0.0.1:3080 ,侧边栏底部(Cordis 面板按钮下方)即出现用量卡片。
|
|
39
29
|
|
|
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 面板按钮下方)即出现用量卡片。
|
|
53
|
-
|
|
54
|
-
> 手动方式:把包放进 profile 的 `node_modules`,并在 `~/.dsh/profiles/web/cordis.patch.yml` 追加:
|
|
55
|
-
>
|
|
56
|
-
> ```yaml
|
|
57
|
-
> - insert:
|
|
58
|
-
> - id: deepseek-usage
|
|
59
|
-
> name: @dougen/dsh-deepseek-usage
|
|
60
|
-
> ```
|
|
61
|
-
|
|
62
|
-
## 数据来源与统计口径
|
|
30
|
+
## 数据来源
|
|
63
31
|
|
|
64
32
|
| 数据 | 来源 |
|
|
65
33
|
|---|---|
|
|
66
|
-
| 余额 | DeepSeek 官方 `GET /user/balance
|
|
67
|
-
|
|
|
68
|
-
|
|
34
|
+
| 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
|
|
35
|
+
| 今日消费 / Token / API 请求次数 | DeepSeek **开放平台官方账单**(platform.deepseek.com 用量页面数据,约 5 分钟延迟) |
|
|
36
|
+
|
|
37
|
+
**无需手动配置任何 Token**:插件通过浏览器的调试接口(CDP)自动读取你本机浏览器中 DeepSeek 开放平台的登录状态。首次使用请先在浏览器中登录 [platform.deepseek.com](https://platform.deepseek.com)。
|
|
69
38
|
|
|
70
|
-
|
|
39
|
+
> 需要你的浏览器开启**远程调试**(一次性设置,设置后长期有效):在地址栏打开 `chrome://inspect/#remote-debugging`,勾选"允许远程调试";或用命令行启动浏览器时加参数 `--remote-debugging-port=9222`。
|
|
71
40
|
|
|
72
|
-
|
|
73
|
-
- **失败请求的用量也计入**:请求失败、没有最终消息时,流式 usage 样本仍被统计;
|
|
74
|
-
- **今日 = 北京时间(Asia/Shanghai)**的自然日,与服务器时区无关;
|
|
75
|
-
- 模型不在内置价格表时按 `deepseek-chat` 档计价,并在卡片错误提示中明示(避免静默算错)。
|
|
41
|
+
### 未登录时
|
|
76
42
|
|
|
77
|
-
|
|
43
|
+
如果检测不到平台登录,卡片第二行会显示 **`平台未登录,点击卡片进行登录`**:
|
|
78
44
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- ⚠️ 环境变量 `DEEPSEEK_BASE_URL`(与 llm-deepseek 适配器对齐)会把余额请求与 **API Key 一起发给该地址**;仅在你信任该网关/代理时配置。
|
|
45
|
+
1. 点击卡片,浏览器会打开 DeepSeek 开放平台页面
|
|
46
|
+
2. 登录完成后回到 DSH,再次点击卡片(或等待自动刷新)即可显示今日用量
|
|
82
47
|
|
|
83
|
-
##
|
|
48
|
+
## 常见问题
|
|
84
49
|
|
|
85
|
-
|
|
50
|
+
**登录了平台但卡片仍显示未登录?**
|
|
51
|
+
- 请确认登录的是 [platform.deepseek.com](https://platform.deepseek.com)(开放平台),而不是 chat.deepseek.com(网页版对话)。
|
|
52
|
+
- 请确认浏览器已开启远程调试(见上文"数据来源"),设置后可点击卡片强制刷新一次。
|
|
53
|
+
- 支持的浏览器:Chromium 内核(Chromium / Chrome / Edge / CentBrowser 等)。
|
|
86
54
|
|
|
87
|
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
- **未知模型提示**:价格表外模型按 chat 档计价并明示,不再静默算错(P5)。
|
|
92
|
-
- **时区修正**:峰谷判定与"今日"边界改为固定北京时间,不随服务器时区漂移(P6)。
|
|
93
|
-
- **依赖声明**:补充 `@deepseek-ai/dsh-host-webserver` peer 依赖(P8)。
|
|
55
|
+
**为什么显示"平台未登录"?**
|
|
56
|
+
- 浏览器未开启远程调试:在地址栏打开 `chrome://inspect/#remote-debugging`,勾选"允许远程调试"后重启浏览器(设置会持久保留)。
|
|
57
|
+
- 或启动浏览器时加参数:`--remote-debugging-port=9222`。
|
|
58
|
+
- 登录状态已过期:点击卡片重新登录即可。
|
|
94
59
|
|
|
95
|
-
|
|
60
|
+
**今日数据与平台页面不一致?**
|
|
61
|
+
- 官方用量数据本身有约 5 分钟延迟,且按北京时间(GMT+8)统计。
|
|
96
62
|
|
|
97
|
-
|
|
63
|
+
**支持哪些浏览器?**
|
|
64
|
+
- Chromium 内核浏览器均可(自动探测本机安装的 Chromium / Chrome / Edge / CentBrowser)。
|
|
98
65
|
|
|
99
66
|
## 卸载
|
|
100
67
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-deepseek-usage — 本机浏览器登录态读取(零第三方依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 通过 Chromium 系浏览器的 DevTools 调试服务(CDP,WebSocket)读取运行中
|
|
5
|
+
* 浏览器里 platform.deepseek.com 的登录态 userToken,无需用户手动配置。
|
|
6
|
+
*
|
|
7
|
+
* 工作原理:
|
|
8
|
+
* 1. 从候选浏览器的 User Data 目录读取 DevToolsActivePort 文件(浏览器开启
|
|
9
|
+
* 远程调试时生成),得到调试端口与 WebSocket 路径;
|
|
10
|
+
* 2. 用 Node 内置 WebSocket 连接浏览器级调试会话;
|
|
11
|
+
* 3. 在 platform.deepseek.com 页面 target 上执行 Runtime.evaluate 读取
|
|
12
|
+
* localStorage 中的 userToken(仅读取,不执行任何写入);
|
|
13
|
+
* 4. 浏览器中没有该页面时自动创建一个(保留复用,读取后续登录状态)。
|
|
14
|
+
*
|
|
15
|
+
* 实现仅使用 Node 内置能力(WebSocket / fetch / fs),无第三方依赖;
|
|
16
|
+
* 需要 Node 22+(原生 WebSocket)。
|
|
17
|
+
*
|
|
18
|
+
* 安全:token 仅在进程内存中使用,绝不落盘、不出本机(只用于向
|
|
19
|
+
* platform.deepseek.com 官方接口发起 Bearer 认证请求)。
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const PLATFORM_URL = "https://platform.deepseek.com";
|
|
25
|
+
/** userToken 值的最小合理长度(防御性过滤误匹配)。 */
|
|
26
|
+
const MIN_TOKEN_LEN = 20;
|
|
27
|
+
/** 单个 CDP 请求超时。 */
|
|
28
|
+
const RPC_TIMEOUT_MS = 8000;
|
|
29
|
+
/** createTarget 后等待页面加载的时长。 */
|
|
30
|
+
const PAGE_LOAD_WAIT_MS = 3000;
|
|
31
|
+
|
|
32
|
+
/** %LOCALAPPDATA% 下候选 Chromium 系浏览器的 User Data 目录(按优先级)。 */
|
|
33
|
+
function userDataDirs() {
|
|
34
|
+
const local = process.env.LOCALAPPDATA;
|
|
35
|
+
if (!local) return [];
|
|
36
|
+
return ["Chromium", "CentBrowser", "Google\\Chrome", "Microsoft\\Edge"]
|
|
37
|
+
.map((b) => join(local, b, "User Data"))
|
|
38
|
+
.filter((p) => existsSync(p));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 读取一个 User Data 目录的 DevToolsActivePort,返回 { port, wsPath } 或 null。 */
|
|
42
|
+
function readDevToolsEndpoint(userData) {
|
|
43
|
+
const file = join(userData, "DevToolsActivePort");
|
|
44
|
+
if (!existsSync(file)) return null;
|
|
45
|
+
try {
|
|
46
|
+
const lines = readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
47
|
+
const port = Number(lines[0]);
|
|
48
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535 || !lines[1]) return null;
|
|
49
|
+
return { port, wsPath: lines[1] };
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let conn = null; // { endpoint, ws, id, pending }
|
|
56
|
+
|
|
57
|
+
/** 连接浏览器级调试会话(复用已有连接,端口/路径变化时重连)。 */
|
|
58
|
+
async function connect(endpoint) {
|
|
59
|
+
if (conn && conn.endpoint.port === endpoint.port && conn.endpoint.wsPath === endpoint.wsPath && conn.ws.readyState === 1) {
|
|
60
|
+
return conn;
|
|
61
|
+
}
|
|
62
|
+
if (conn && conn.ws) {
|
|
63
|
+
try { conn.ws.close(); } catch {}
|
|
64
|
+
conn = null;
|
|
65
|
+
}
|
|
66
|
+
const ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}${endpoint.wsPath}`);
|
|
67
|
+
const opened = await Promise.race([
|
|
68
|
+
new Promise((res) => { ws.onopen = () => res(true); }),
|
|
69
|
+
new Promise((res) => { ws.onerror = () => res(false); }),
|
|
70
|
+
new Promise((res) => setTimeout(() => res(false), 5000)),
|
|
71
|
+
]);
|
|
72
|
+
if (!opened) {
|
|
73
|
+
try { ws.close(); } catch {}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
conn = { endpoint, ws, id: 0, pending: new Map() };
|
|
77
|
+
ws.onmessage = (ev) => {
|
|
78
|
+
let msg;
|
|
79
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
80
|
+
if (msg && msg.id && conn && conn.pending.has(msg.id)) {
|
|
81
|
+
const { resolve, timer } = conn.pending.get(msg.id);
|
|
82
|
+
conn.pending.delete(msg.id);
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
resolve(msg);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
ws.onclose = () => { conn = null; };
|
|
88
|
+
return conn;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 发送一条 CDP 命令,返回响应消息或 { timeout: true }。 */
|
|
92
|
+
function send(conn, method, params, sessionId) {
|
|
93
|
+
const mid = ++conn.id;
|
|
94
|
+
const payload = sessionId !== undefined
|
|
95
|
+
? { id: mid, sessionId, method, params }
|
|
96
|
+
: { id: mid, method, params };
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
if (conn.pending.has(mid)) {
|
|
100
|
+
conn.pending.delete(mid);
|
|
101
|
+
resolve({ timeout: true });
|
|
102
|
+
}
|
|
103
|
+
}, RPC_TIMEOUT_MS);
|
|
104
|
+
conn.pending.set(mid, { resolve, timer });
|
|
105
|
+
try {
|
|
106
|
+
conn.ws.send(JSON.stringify(payload));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
conn.pending.delete(mid);
|
|
110
|
+
resolve({ error: { message: e.message } });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 在指定 target 上执行表达式,返回结果值(仅读取操作)。 */
|
|
116
|
+
async function evalOnTarget(conn, targetId, expression) {
|
|
117
|
+
const att = await send(conn, "Target.attachToTarget", { targetId, flatten: true });
|
|
118
|
+
if (att.timeout || att.error || !att.result || !att.result.sessionId) return { error: "attach failed" };
|
|
119
|
+
const sessionId = att.result.sessionId;
|
|
120
|
+
try {
|
|
121
|
+
const ev = await send(conn, "Runtime.evaluate", { expression, returnByValue: true }, sessionId);
|
|
122
|
+
if (ev.timeout || ev.error) return { error: "evaluate failed" };
|
|
123
|
+
const value = ev.result && ev.result.result ? ev.result.result.value : undefined;
|
|
124
|
+
return { value: value === undefined ? null : value };
|
|
125
|
+
} finally {
|
|
126
|
+
await send(conn, "Target.detachFromTarget", { sessionId }).catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 找到已打开的 platform 页面 target;没有则创建一个(保留复用)。 */
|
|
131
|
+
async function resolvePlatformTarget(conn) {
|
|
132
|
+
const r = await send(conn, "Target.getTargets");
|
|
133
|
+
const infos = (r.result && r.result.targetInfos) || [];
|
|
134
|
+
const existing = infos.find((t) => t.type === "page" && t.url.startsWith(PLATFORM_URL));
|
|
135
|
+
if (existing) return existing.targetId;
|
|
136
|
+
const created = await send(conn, "Target.createTarget", { url: PLATFORM_URL });
|
|
137
|
+
if (created.timeout || created.error || !created.result || !created.result.targetId) return null;
|
|
138
|
+
const targetId = created.result.targetId;
|
|
139
|
+
// 等待页面完成加载后再读取。
|
|
140
|
+
await new Promise((res) => setTimeout(res, PAGE_LOAD_WAIT_MS));
|
|
141
|
+
return targetId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 从一个浏览器调试端点读取 platform userToken;失败返回 null。 */
|
|
145
|
+
async function readTokenFromEndpoint(endpoint) {
|
|
146
|
+
const c = await connect(endpoint);
|
|
147
|
+
if (!c) return null;
|
|
148
|
+
const targetId = await resolvePlatformTarget(c);
|
|
149
|
+
if (!targetId) return null;
|
|
150
|
+
const expression =
|
|
151
|
+
`(() => { try { const v = localStorage.getItem("userToken"); ` +
|
|
152
|
+
`if (!v) return null; return JSON.parse(v).value || null; } catch (e) { return null; } })()`;
|
|
153
|
+
const r = await evalOnTarget(c, targetId, expression);
|
|
154
|
+
return r && typeof r.value === "string" && r.value.length >= MIN_TOKEN_LEN ? r.value : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 获取 platform.deepseek.com 的登录态 userToken。
|
|
159
|
+
* 对偶发的调试服务不可用做有限重试,避免误报"未登录"。
|
|
160
|
+
* @returns {{ token: string } | { error: string }}
|
|
161
|
+
*/
|
|
162
|
+
export async function getPlatformToken() {
|
|
163
|
+
if (typeof WebSocket !== "function") {
|
|
164
|
+
return { error: "当前 Node 版本不支持 WebSocket,无法读取浏览器登录态" };
|
|
165
|
+
}
|
|
166
|
+
const attempts = [0, 1];
|
|
167
|
+
for (const attempt of attempts) {
|
|
168
|
+
if (attempt > 0) await new Promise((res) => setTimeout(res, 800));
|
|
169
|
+
for (const ud of userDataDirs()) {
|
|
170
|
+
const endpoint = readDevToolsEndpoint(ud);
|
|
171
|
+
if (!endpoint) continue;
|
|
172
|
+
try {
|
|
173
|
+
const token = await readTokenFromEndpoint(endpoint);
|
|
174
|
+
if (token) return { token };
|
|
175
|
+
} catch {
|
|
176
|
+
// 该浏览器调试端点不可用,尝试下一个。
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { error: "未检测到 DeepSeek 开放平台登录" };
|
|
181
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -15,15 +15,14 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
const h = react.createElement;
|
|
16
16
|
|
|
17
17
|
// ---- constants -------------------------------------------------
|
|
18
|
-
const POLL_MS = 60 * 1000;
|
|
18
|
+
const POLL_MS = 5 * 60 * 1000;
|
|
19
19
|
const USAGE_PATH = "/api/deepseek-usage";
|
|
20
20
|
|
|
21
21
|
// ---- helpers ---------------------------------------------------
|
|
22
|
-
function money(value
|
|
22
|
+
function money(value) {
|
|
23
23
|
const n = Number(value);
|
|
24
24
|
if (!Number.isFinite(n)) return "—";
|
|
25
|
-
|
|
26
|
-
return `${symbol}${n.toFixed(2)}`;
|
|
25
|
+
return n.toFixed(2);
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
function fmtTokens(value) {
|
|
@@ -90,11 +89,12 @@ window.__ModuleLoader__.load({
|
|
|
90
89
|
const [error, setError] = useState("");
|
|
91
90
|
const [spinning, setSpinning] = useState(false);
|
|
92
91
|
const mounted = useRef(true);
|
|
92
|
+
const retryRef = useRef(null);
|
|
93
93
|
|
|
94
|
-
const load = useCallback(async () => {
|
|
94
|
+
const load = useCallback(async (force) => {
|
|
95
95
|
setSpinning(true);
|
|
96
96
|
try {
|
|
97
|
-
const res = await fetch(USAGE_PATH, { cache: "no-store" });
|
|
97
|
+
const res = await fetch(USAGE_PATH + (force ? "?refresh=1" : ""), { cache: "no-store" });
|
|
98
98
|
let body = null;
|
|
99
99
|
try {
|
|
100
100
|
body = await res.json();
|
|
@@ -116,17 +116,20 @@ window.__ModuleLoader__.load({
|
|
|
116
116
|
useEffect(() => {
|
|
117
117
|
mounted.current = true;
|
|
118
118
|
load();
|
|
119
|
-
const timer = setInterval(load, POLL_MS);
|
|
119
|
+
const timer = setInterval(() => load(false), POLL_MS);
|
|
120
120
|
return () => {
|
|
121
121
|
mounted.current = false;
|
|
122
122
|
clearInterval(timer);
|
|
123
|
+
if (retryRef.current) {
|
|
124
|
+
clearInterval(retryRef.current);
|
|
125
|
+
retryRef.current = null;
|
|
126
|
+
}
|
|
123
127
|
};
|
|
124
128
|
}, [load]);
|
|
125
129
|
|
|
126
130
|
const balance = data && data.balance ? data.balance : null;
|
|
127
131
|
const today = data && data.today ? data.today : null;
|
|
128
132
|
const tier = data && data.tier ? data.tier : null;
|
|
129
|
-
const currency = balance && balance.currency ? balance.currency : "CNY";
|
|
130
133
|
const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
|
|
131
134
|
const tok = today && today.tokens ? today.tokens : null;
|
|
132
135
|
const dot = {
|
|
@@ -137,7 +140,28 @@ window.__ModuleLoader__.load({
|
|
|
137
140
|
background: data && data.peak ? C.peak : C.idle,
|
|
138
141
|
boxShadow: "0 0 4px rgba(0,0,0,.3)",
|
|
139
142
|
};
|
|
140
|
-
const
|
|
143
|
+
const loggedIn = !!(today && today.platform && today.platform.loggedIn);
|
|
144
|
+
const loginHint = (today && today.platform && today.platform.loginHint) || "平台未登录,点击卡片进行登录";
|
|
145
|
+
const handleClick = useCallback(() => {
|
|
146
|
+
if (loggedIn) {
|
|
147
|
+
load(true);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// 未登录:打开 DeepSeek 开放平台登录页,并短暂高频重试等待登录完成。
|
|
151
|
+
if (typeof window !== "undefined") window.open("https://platform.deepseek.com", "_blank");
|
|
152
|
+
load(true);
|
|
153
|
+
if (retryRef.current) return;
|
|
154
|
+
let tries = 0;
|
|
155
|
+
retryRef.current = setInterval(() => {
|
|
156
|
+
tries += 1;
|
|
157
|
+
load(true);
|
|
158
|
+
if (tries >= 6) {
|
|
159
|
+
clearInterval(retryRef.current);
|
|
160
|
+
retryRef.current = null;
|
|
161
|
+
}
|
|
162
|
+
}, 10000);
|
|
163
|
+
}, [loggedIn, load]);
|
|
164
|
+
const title = "DeepSeek 用量 · " + (loggedIn ? "点击刷新" : "点击登录") + (errs.length ? " · " + errs.join("; ") : "");
|
|
141
165
|
|
|
142
166
|
if (!wide) {
|
|
143
167
|
return h("div", {
|
|
@@ -157,9 +181,9 @@ window.__ModuleLoader__.load({
|
|
|
157
181
|
userSelect: "none",
|
|
158
182
|
},
|
|
159
183
|
title,
|
|
160
|
-
onClick:
|
|
184
|
+
onClick: handleClick,
|
|
161
185
|
},
|
|
162
|
-
h("span", { style: { fontWeight: 700, fontSize: 12, color: C.label } }, money(balance && balance.total
|
|
186
|
+
h("span", { style: { fontWeight: 700, fontSize: 12, color: C.label } }, money(balance && balance.total)),
|
|
163
187
|
h("span", { style: dot, title: data && data.peak ? "高峰时段" : "空闲时段" }));
|
|
164
188
|
}
|
|
165
189
|
|
|
@@ -174,7 +198,7 @@ window.__ModuleLoader__.load({
|
|
|
174
198
|
h("span", { style: { fontWeight: 600, whiteSpace: "nowrap" } }, value));
|
|
175
199
|
const Sep = () => h("span", { style: { color: C.sub, margin: "0 1px", fontSize: 10 } }, "·");
|
|
176
200
|
|
|
177
|
-
const costText = money(today && today.cost
|
|
201
|
+
const costText = money(today && today.cost);
|
|
178
202
|
const costShown = costText;
|
|
179
203
|
|
|
180
204
|
return h("div", {
|
|
@@ -198,16 +222,19 @@ window.__ModuleLoader__.load({
|
|
|
198
222
|
lineHeight: "15px",
|
|
199
223
|
},
|
|
200
224
|
title,
|
|
201
|
-
onClick:
|
|
225
|
+
onClick: handleClick,
|
|
202
226
|
},
|
|
203
227
|
h("div", { style: { display: "flex", alignItems: "baseline", minWidth: 0, paddingRight: 12 } },
|
|
204
|
-
grp("余额", h(IconMoney, null), money(balance && balance.total
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
228
|
+
grp("余额", h(IconMoney, null), money(balance && balance.total))),
|
|
229
|
+
!loggedIn
|
|
230
|
+
? h("div", { style: { display: "flex", alignItems: "center", minWidth: 0, color: C.err, fontWeight: 600, gap: 4 } },
|
|
231
|
+
h("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, loginHint))
|
|
232
|
+
: h("div", { style: { display: "flex", gap: 4, alignItems: "center", minWidth: 0, flexWrap: "nowrap" } },
|
|
233
|
+
grp("今日", h(IconMoney, null), costShown),
|
|
234
|
+
Sep(),
|
|
235
|
+
grp(null, h(IconToken, null), fmtTokens(tok && tok.total)),
|
|
236
|
+
Sep(),
|
|
237
|
+
grp(null, h(IconApi, null), today && today.calls !== null && today.calls !== undefined ? String(today.calls) : "—")),
|
|
211
238
|
h("span", {
|
|
212
239
|
style: { position: "absolute", top: 8, right: 9, ...dot },
|
|
213
240
|
title: data && data.peak ? "高峰时段 " + (data.peakHours || "") : "空闲时段",
|
package/lib/index.js
CHANGED
|
@@ -3,50 +3,42 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 注册一个 HTTP 路由:
|
|
5
5
|
*
|
|
6
|
-
* GET /api/deepseek-usage
|
|
6
|
+
* GET /api/deepseek-usage[?refresh=1]
|
|
7
7
|
*
|
|
8
8
|
* 返回(JSON):
|
|
9
9
|
* {
|
|
10
10
|
* ok: true,
|
|
11
|
-
* balance: { isAvailable, total, currency, granted, toppedUp },
|
|
11
|
+
* balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
|
|
12
12
|
* today: {
|
|
13
|
-
* cost,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* sessions // 今日有活动的会话数
|
|
13
|
+
* cost, costSource: 'official' | null, // 今日消费金额(官方账单)
|
|
14
|
+
* tokens: { input, output, cacheRead, cacheWrite, total }, // 今日 token(官方账单)
|
|
15
|
+
* calls, // 今日 API 请求次数(官方账单)
|
|
16
|
+
* platform: { loggedIn, loginHint? } // 平台登录态
|
|
18
17
|
* },
|
|
19
|
-
* peak, peakHours,
|
|
20
|
-
* tier: { input, cacheRead, cacheWrite, output, model, known },
|
|
18
|
+
* peak, peakHours, // 峰谷时段(北京时间)
|
|
19
|
+
* tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
|
|
21
20
|
* updatedAt, errors
|
|
22
21
|
* }
|
|
23
22
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* 数据源:
|
|
24
|
+
* - 余额:DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,与 llm-deepseek
|
|
25
|
+
* 同一引用,不出本机)。
|
|
26
|
+
* - 今日消费/请求次数/tokens:platform.deepseek.com 官方账单接口
|
|
27
|
+
* (/api/v0/usage/amount|cost?month=&year=),登录态 userToken 由
|
|
28
|
+
* lib/browser-token.js 自动从本机浏览器 Local Storage 读取,无需用户配置。
|
|
29
|
+
* 页面数据按 GMT+8,官方提示约 5 分钟延迟。
|
|
30
|
+
* - 未检测到平台登录或登录过期:today.platform.loggedIn = false,前端提示用户
|
|
31
|
+
* 点击卡片前往登录页。
|
|
28
32
|
*
|
|
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 绑定下的局域网访问。
|
|
33
|
+
* 安全:路由仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS
|
|
34
|
+
* rebinding 与 0.0.0.0 绑定下的局域网访问;userToken 仅在进程内存中使用。
|
|
42
35
|
*/
|
|
43
36
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
37
|
+
import { getPlatformToken } from "./browser-token.js";
|
|
44
38
|
import {
|
|
45
39
|
DEFAULT_MODEL,
|
|
46
40
|
PEAK_HOURS_TEXT,
|
|
47
|
-
costOf,
|
|
48
41
|
isPeak,
|
|
49
|
-
shanghaiDayKey,
|
|
50
42
|
startOfTodayShanghaiMs,
|
|
51
43
|
unitAt,
|
|
52
44
|
} from "./pricing.js";
|
|
@@ -61,14 +53,26 @@ const API_KEY_REF = credentialRef("DEEPSEEK_API_KEY");
|
|
|
61
53
|
const BALANCE_PATH = "/user/balance";
|
|
62
54
|
const ROUTE_PATH = "/api/deepseek-usage";
|
|
63
55
|
const TIMEOUT_MS = 15000;
|
|
64
|
-
|
|
65
|
-
const
|
|
56
|
+
|
|
57
|
+
const PLATFORM_BASE = "https://platform.deepseek.com";
|
|
58
|
+
const PLATFORM_USAGE_HEADERS = {
|
|
59
|
+
Accept: "application/json",
|
|
60
|
+
Referer: "https://platform.deepseek.com/usage",
|
|
61
|
+
Origin: "https://platform.deepseek.com",
|
|
62
|
+
};
|
|
63
|
+
/** userToken 内存缓存时长(浏览器 Local Storage 读取有复制开销,缓存避免高频重读)。 */
|
|
64
|
+
const PLATFORM_TOKEN_CACHE_MS = 60000;
|
|
65
|
+
|
|
66
|
+
const LOGIN_HINT = "平台未登录,点击卡片进行登录";
|
|
67
|
+
const LOGIN_EXPIRED_HINT = "平台登录已过期,点击卡片重新登录";
|
|
66
68
|
|
|
67
69
|
const JSON_HEADERS = {
|
|
68
70
|
"content-type": "application/json; charset=utf-8",
|
|
69
71
|
"cache-control": "no-store",
|
|
70
72
|
};
|
|
71
73
|
|
|
74
|
+
let tokenCache = { token: null, at: 0 };
|
|
75
|
+
|
|
72
76
|
function sendJson(res, status, body) {
|
|
73
77
|
res.writeHead(status, JSON_HEADERS);
|
|
74
78
|
res.end(JSON.stringify(body));
|
|
@@ -105,168 +109,120 @@ function isLocalHost(req) {
|
|
|
105
109
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
106
110
|
}
|
|
107
111
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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}`;
|
|
112
|
+
function hasRefreshFlag(req) {
|
|
113
|
+
const url = req.url ?? "";
|
|
114
|
+
const q = url.indexOf("?");
|
|
115
|
+
if (q === -1) return false;
|
|
116
|
+
return url.slice(q + 1).split("&").some((kv) => kv === "refresh=1");
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
/** 读取平台登录 token(带短缓存;refresh 时强制重读浏览器)。 */
|
|
120
|
+
async function resolvePlatformToken(force) {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (!force && tokenCache.token !== null && now - tokenCache.at < PLATFORM_TOKEN_CACHE_MS) {
|
|
123
|
+
return tokenCache.token;
|
|
124
|
+
}
|
|
125
|
+
const result = await getPlatformToken();
|
|
126
|
+
if (result.token) {
|
|
127
|
+
tokenCache = { token: result.token, at: now };
|
|
128
|
+
return result.token;
|
|
129
|
+
}
|
|
130
|
+
tokenCache = { token: null, at: now };
|
|
131
|
+
return null;
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const
|
|
134
|
+
/**
|
|
135
|
+
* 聚合 by_api_key/amount 响应(与平台用量页面同源)。
|
|
136
|
+
* biz_data = { start, end, bucket, models, series: [{ api_key, model, buckets: [{ time, usage: { REQUEST, PROMPT_CACHE_HIT_TOKEN, PROMPT_CACHE_MISS_TOKEN, RESPONSE_TOKEN } }] }] }
|
|
137
|
+
*/
|
|
138
|
+
function aggregateByApiKeyAmount(bizData) {
|
|
139
|
+
const acc = { input: 0, cacheRead: 0, output: 0, requests: 0 };
|
|
140
|
+
const series = bizData && Array.isArray(bizData.series) ? bizData.series : [];
|
|
141
|
+
for (const s of series) {
|
|
142
|
+
for (const b of s.buckets || []) {
|
|
143
|
+
const u = b && b.usage;
|
|
144
|
+
if (!u) continue;
|
|
145
|
+
acc.input += Number(u.PROMPT_CACHE_MISS_TOKEN) || 0;
|
|
146
|
+
acc.cacheRead += Number(u.PROMPT_CACHE_HIT_TOKEN) || 0;
|
|
147
|
+
acc.output += Number(u.RESPONSE_TOKEN) || 0;
|
|
148
|
+
acc.requests += Number(u.REQUEST) || 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return acc;
|
|
130
152
|
}
|
|
131
153
|
|
|
132
154
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
155
|
+
* 聚合 by_api_key/cost 响应(与平台用量页面同源)。
|
|
156
|
+
* biz_data = { start, end, bucket, data: [{ currency, series: [{ api_key, model, buckets: [{ time, cost }] }] }] }
|
|
157
|
+
* 优先取 CNY 币种组(与余额币种一致),无 CNY 时取第一组。
|
|
136
158
|
*/
|
|
137
|
-
function
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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;
|
|
159
|
+
function aggregateByApiKeyCost(bizData) {
|
|
160
|
+
const groups = bizData && Array.isArray(bizData.data) ? bizData.data : [];
|
|
161
|
+
const group = groups.find((g) => g && g.currency === "CNY") || groups[0];
|
|
162
|
+
let cost = 0;
|
|
163
|
+
for (const s of (group && group.series) || []) {
|
|
164
|
+
for (const b of s.buckets || []) {
|
|
165
|
+
cost += Number(b && b.cost) || 0;
|
|
170
166
|
}
|
|
171
|
-
steps.set(stepKey(data.turn, data.step, event.seq), { usage, model, time: event.time });
|
|
172
167
|
}
|
|
173
|
-
|
|
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;
|
|
168
|
+
return cost;
|
|
185
169
|
}
|
|
186
170
|
|
|
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
171
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* 会话;跨北京日(shanghaiDayKey 变化)时全部失效重扫。
|
|
172
|
+
* 获取官方今日用量(北京时间今天 00:00–24:00,接口与平台用量页面同源)。
|
|
173
|
+
* @returns {{ ok: true, tokens, calls, cost } | { authError: true } | { ok: false, error }}
|
|
197
174
|
*/
|
|
198
|
-
async function
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
175
|
+
async function fetchOfficialToday(token) {
|
|
176
|
+
const dayStart = Math.floor(startOfTodayShanghaiMs(Date.now()) / 1000);
|
|
177
|
+
const dayEnd = dayStart + 86400;
|
|
178
|
+
const headers = { ...PLATFORM_USAGE_HEADERS, Authorization: `Bearer ${token}` };
|
|
179
|
+
let amountRes;
|
|
180
|
+
let costRes;
|
|
181
|
+
try {
|
|
182
|
+
[amountRes, costRes] = await Promise.all([
|
|
183
|
+
fetch(`${PLATFORM_BASE}/api/v0/usage/by_api_key/amount?start=${dayStart}&end=${dayEnd}&tz=28800`, {
|
|
184
|
+
headers,
|
|
185
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
186
|
+
}),
|
|
187
|
+
fetch(`${PLATFORM_BASE}/api/v0/usage/by_api_key/cost?start=${dayStart}&end=${dayEnd}&tz=28800`, {
|
|
188
|
+
headers,
|
|
189
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
190
|
+
}),
|
|
191
|
+
]);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return { ok: false, error: `用量接口请求失败: ${error instanceof Error ? error.message : String(error)}` };
|
|
202
194
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (usageCache !== null && usageCacheDay === dayKey && now - usageCacheAt < USAGE_CACHE_MS) return usageCache;
|
|
206
|
-
if (usageCacheDay !== dayKey) {
|
|
207
|
-
usageCacheDay = dayKey;
|
|
208
|
-
sessionAggCache.clear();
|
|
195
|
+
if (amountRes.status === 401 || amountRes.status === 403 || costRes.status === 401 || costRes.status === 403) {
|
|
196
|
+
return { authError: true };
|
|
209
197
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 {}
|
|
198
|
+
if (!amountRes.ok || !costRes.ok) {
|
|
199
|
+
return { ok: false, error: `用量接口 HTTP ${amountRes.status}/${costRes.status}` };
|
|
200
|
+
}
|
|
201
|
+
let amountBody;
|
|
202
|
+
let costBody;
|
|
221
203
|
try {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const id = header.id;
|
|
227
|
-
seen.add(id);
|
|
228
|
-
const revision = hasSnapshots ? header.revision : void 0;
|
|
229
|
-
const cached = sessionAggCache.get(id);
|
|
230
|
-
if (cached !== void 0 && revision !== void 0 && cached.revision === revision) {
|
|
231
|
-
if (cached.agg !== null) {
|
|
232
|
-
addAgg(acc, cached.agg);
|
|
233
|
-
acc.sessions += 1;
|
|
234
|
-
}
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
let raw;
|
|
238
|
-
try {
|
|
239
|
-
raw = await persistence.readRaw(id);
|
|
240
|
-
} catch {
|
|
241
|
-
acc.skipped += 1;
|
|
242
|
-
sessionAggCache.delete(id);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if (raw === void 0 || raw === null || typeof raw.content !== "string") {
|
|
246
|
-
sessionAggCache.delete(id);
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
const agg = scanSessionRaw(raw.content, dayStart, defaultModel);
|
|
250
|
-
sessionAggCache.set(id, { revision, agg });
|
|
251
|
-
if (agg === null) continue;
|
|
252
|
-
addAgg(acc, agg);
|
|
253
|
-
acc.sessions += 1;
|
|
254
|
-
}
|
|
255
|
-
if (hasSnapshots) {
|
|
256
|
-
// 修剪已被删除的会话缓存。
|
|
257
|
-
for (const id of sessionAggCache.keys()) {
|
|
258
|
-
if (!seen.has(id)) sessionAggCache.delete(id);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
} catch (error) {
|
|
262
|
-
acc.usable = false;
|
|
263
|
-
acc.error = error instanceof Error ? error.message : String(error);
|
|
204
|
+
amountBody = await amountRes.json();
|
|
205
|
+
costBody = await costRes.json();
|
|
206
|
+
} catch {
|
|
207
|
+
return { ok: false, error: "用量接口响应解析失败" };
|
|
264
208
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
209
|
+
if (amountBody.code === 40002 || amountBody.code === 40003 || costBody.code === 40002 || costBody.code === 40003) {
|
|
210
|
+
return { authError: true };
|
|
211
|
+
}
|
|
212
|
+
const amounts = aggregateByApiKeyAmount(amountBody && amountBody.data && amountBody.data.biz_data);
|
|
213
|
+
const cost = aggregateByApiKeyCost(costBody && costBody.data && costBody.data.biz_data);
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
tokens: {
|
|
217
|
+
input: Math.round(amounts.input),
|
|
218
|
+
cacheRead: Math.round(amounts.cacheRead),
|
|
219
|
+
cacheWrite: 0,
|
|
220
|
+
output: Math.round(amounts.output),
|
|
221
|
+
total: Math.round(amounts.input + amounts.cacheRead + amounts.output),
|
|
222
|
+
},
|
|
223
|
+
calls: Math.round(amounts.requests),
|
|
224
|
+
cost: Math.round(cost * 1e6) / 1e6,
|
|
225
|
+
};
|
|
270
226
|
}
|
|
271
227
|
|
|
272
228
|
function apply(ctx) {
|
|
@@ -276,14 +232,15 @@ function apply(ctx) {
|
|
|
276
232
|
path: ROUTE_PATH,
|
|
277
233
|
handler: async (req, res) => {
|
|
278
234
|
try {
|
|
279
|
-
// ----
|
|
235
|
+
// ---- 仅本机访问:拦截 DNS rebinding 与局域网直接访问 ----
|
|
280
236
|
if (!isLocalHost(req)) {
|
|
281
237
|
return sendJson(res, 403, { ok: false, error: "forbidden", message: "仅允许本机访问" });
|
|
282
238
|
}
|
|
283
239
|
|
|
284
240
|
const errors = [];
|
|
241
|
+
const refresh = hasRefreshFlag(req);
|
|
285
242
|
|
|
286
|
-
// ----
|
|
243
|
+
// ---- 余额(官方接口,API Key)----
|
|
287
244
|
const balance = { isAvailable: null, total: null, currency: null, granted: null, toppedUp: null };
|
|
288
245
|
try {
|
|
289
246
|
const hit = await ctx.credentials.resolve(API_KEY_REF);
|
|
@@ -312,13 +269,32 @@ function apply(ctx) {
|
|
|
312
269
|
errors.push(`余额获取失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
313
270
|
}
|
|
314
271
|
|
|
315
|
-
// ----
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
272
|
+
// ---- 今日消费 / 请求次数 / tokens(官方账单,登录态自动获取)----
|
|
273
|
+
let today = { cost: null, costSource: null, tokens: null, calls: null, platform: { loggedIn: false, loginHint: LOGIN_HINT } };
|
|
274
|
+
const token = await resolvePlatformToken(refresh);
|
|
275
|
+
if (token === null) {
|
|
276
|
+
today.platform = { loggedIn: false, loginHint: LOGIN_HINT };
|
|
277
|
+
} else {
|
|
278
|
+
const usage = await fetchOfficialToday(token);
|
|
279
|
+
if (usage.authError) {
|
|
280
|
+
// 登录过期:清缓存,下次重读浏览器(用户重新登录后自动恢复)。
|
|
281
|
+
tokenCache = { token: null, at: Date.now() };
|
|
282
|
+
today.platform = { loggedIn: false, loginHint: LOGIN_EXPIRED_HINT };
|
|
283
|
+
} else if (usage.ok) {
|
|
284
|
+
today = {
|
|
285
|
+
cost: usage.cost,
|
|
286
|
+
costSource: "official",
|
|
287
|
+
tokens: usage.tokens,
|
|
288
|
+
calls: usage.calls,
|
|
289
|
+
platform: { loggedIn: true },
|
|
290
|
+
};
|
|
291
|
+
} else {
|
|
292
|
+
today.platform = { loggedIn: true };
|
|
293
|
+
errors.push(usage.error);
|
|
294
|
+
}
|
|
320
295
|
}
|
|
321
296
|
|
|
297
|
+
// ---- 当前模型当前档单价(展示用)----
|
|
322
298
|
const now = Date.now();
|
|
323
299
|
let model = DEFAULT_MODEL;
|
|
324
300
|
try {
|
|
@@ -327,29 +303,12 @@ function apply(ctx) {
|
|
|
327
303
|
if (selection && typeof selection.model === "string") model = selection.model;
|
|
328
304
|
} catch {}
|
|
329
305
|
const unit = unitAt(model, now);
|
|
330
|
-
|
|
331
|
-
const cost = usage.usable ? usage.cost : null;
|
|
332
|
-
const costSource = usage.usable ? "estimate" : null;
|
|
306
|
+
if (!unit.known) errors.push(`当前模型 ${model} 不在价格表,档单价按 deepseek-chat 展示`);
|
|
333
307
|
|
|
334
308
|
sendJson(res, 200, {
|
|
335
309
|
ok: true,
|
|
336
310
|
balance,
|
|
337
|
-
today
|
|
338
|
-
cost,
|
|
339
|
-
costSource,
|
|
340
|
-
tokens: usage.usable
|
|
341
|
-
? {
|
|
342
|
-
input: usage.input,
|
|
343
|
-
output: usage.output,
|
|
344
|
-
cacheRead: usage.cacheRead,
|
|
345
|
-
cacheWrite: usage.cacheWrite,
|
|
346
|
-
total: usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
|
|
347
|
-
}
|
|
348
|
-
: null,
|
|
349
|
-
calls: usage.usable ? usage.calls : null,
|
|
350
|
-
sessions: usage.usable ? usage.sessions : null,
|
|
351
|
-
skipped: usage.usable ? usage.skipped : null,
|
|
352
|
-
},
|
|
311
|
+
today,
|
|
353
312
|
peak: isPeak(now),
|
|
354
313
|
peakHours: PEAK_HOURS_TEXT,
|
|
355
314
|
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,6 +7,9 @@
|
|
|
7
7
|
* 与 isKnownModel() 让调用方提示用户,而不是静默算错。
|
|
8
8
|
* 价格调整时只需改 PRICE_TABLE 与 PEAK_WINDOWS。
|
|
9
9
|
*
|
|
10
|
+
* 本文件仅用于卡片第三行"当前档单价"展示;今日消费金额已直接来自官方账单
|
|
11
|
+
* (platform.deepseek.com),不再本地按 token×价格估算。
|
|
12
|
+
*
|
|
10
13
|
* 时区(0.1.7 起):峰谷判定与"今日"边界一律按北京时间(Asia/Shanghai,
|
|
11
14
|
* 固定 UTC+8,无夏令时)计算,不随服务器本地时区变化——在任何时区部署的
|
|
12
15
|
* DSH 上显示一致。
|
|
@@ -79,14 +82,3 @@ export function unitAt(model, ms) {
|
|
|
79
82
|
known,
|
|
80
83
|
};
|
|
81
84
|
}
|
|
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.1
|
|
4
|
-
"description": "DeepSeek 用量侧边栏插件:余额(官方 API
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "DeepSeek 用量侧边栏插件:余额(官方 API)、今日消费金额/Token/API 请求次数(官方账单,自动获取浏览器登录态)、高峰/空闲时段指示灯。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|
package/screenshot.png
CHANGED
|
Binary file
|