@dougen/dsh-deepseek-usage 0.1.6 → 0.1.8

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 CHANGED
@@ -6,10 +6,11 @@ DeepSeek 用量侧边栏插件(DeepSeek Harness Web UI)
6
6
  > - 本插件界面文案仅提供**中文**,无多语言版本。
7
7
  > - **仅适用于 DSH 的 Web UI**:命令行等其他非WEB界面不支持。
8
8
  > - **今日花费为本地估算值**(非官方账单):按本地会话日志 token × 官方峰谷价格表计算。
9
+ > - **峰谷时段按北京时间(Asia/Shanghai)固定计算**,与服务器本地时区无关。
9
10
 
10
11
  ## 简介
11
12
 
12
- 给 DeepSeek Harness(DSH)**网页界面**用的插件:在**侧边栏底部**常驻显示你的 **DeepSeek 账号用量**卡片 —— 余额、今日花费、今日 Token、今日 API 请求次数与高峰/空闲计费时段指示灯。
13
+ 给 DeepSeek Harness(DSH)**网页界面**用的插件:在**侧边栏底部**常驻显示你的 **DeepSeek 账号用量**卡片 —— 余额、今日花费、今日 Token、今日 API 调用次数与高峰/空闲计费时段指示灯。
13
14
 
14
15
  ## 界面截图
15
16
 
@@ -22,7 +23,7 @@ DeepSeek 用量侧边栏插件(DeepSeek Harness Web UI)
22
23
  - 第一行:`余额 ¥110.00`
23
24
  - 第二行:`今日 ¥2.94 · 36.9M · 170`(今日花费为**本地估算**)
24
25
  - 第三行:`↑1.5 · 💾0.05 · ↓4.5`(输入 / 缓存命中 / 输出 单价,按当前启用模型与峰谷时段自动切换)
25
- - 右上角:高峰/空闲状态指示灯(高峰 9:00–12:00、14:00–18:00 为橙色;其余空闲为绿色;悬停提示时段)
26
+ - 右上角:高峰/空闲状态指示灯(高峰 9:00–12:00、14:00–18:00 **北京时间**为橙色;其余空闲为绿色;悬停提示时段)
26
27
  - 每 60 秒自动刷新;点击卡片任意位置也可手动刷新
27
28
  - 跟随应用明暗主题(`--dsw-*` 变量,含兜底值)
28
29
 
@@ -44,7 +45,6 @@ pnpm pack # 生成 dougen-dsh-deepseek-usa
44
45
  dsh plugin --profile web add <生成的 .tgz>
45
46
  ```
46
47
 
47
-
48
48
  安装之后:
49
49
 
50
50
  1. 重启网页应用:`dsh web`(bundle 层在启动时读取)。
@@ -59,14 +59,46 @@ dsh plugin --profile web add <生成的 .tgz>
59
59
  > name: @dougen/dsh-deepseek-usage
60
60
  > ```
61
61
 
62
- ## 数据来源
62
+ ## 数据来源与统计口径
63
63
 
64
64
  | 数据 | 来源 |
65
65
  |---|---|
66
66
  | 余额 | DeepSeek 官方 `GET /user/balance`(API Key 经凭证服务 `DEEPSEEK_API_KEY` 解析,不出本机) |
67
67
  | 今日花费 | **本地估算**:本地会话日志 token × 官方峰谷价格表(非官方账单) |
68
- | 今日 Token / API 次数 | 本地会话日志统计(官方 API 无用量查询接口),覆盖全部会话,含重启前历史 |
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)。已修正并从真实会话日志端到端验证:统计恢复、无读取失败。
88
+
89
+ ### v0.1.7
90
+
91
+ - **统计口径修正**:按 (turn, step) 去重并吸收流式 usage 样本,失败请求的用量不再漏计(P1)。
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)。
98
+
99
+ ### v0.1.6
69
100
 
101
+ - 初始发布(审计基线)。
70
102
 
71
103
  ## 卸载
72
104
 
package/lib/index.js CHANGED
@@ -8,16 +8,16 @@
8
8
  * 返回(JSON):
9
9
  * {
10
10
  * ok: true,
11
- * balance: { available, total, currency, granted, toppedUp }, // 官方余额接口
11
+ * balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口
12
12
  * today: {
13
13
  * cost, // 今日花费:本地日志估算(日志 token × 官方峰谷价格表)
14
14
  * costSource: 'estimate' | null,
15
15
  * tokens: { input, output, cacheRead, cacheWrite, total }, // 今日 token 数(本地日志)
16
- * calls, // 今日 API 请求数(本地日志)
16
+ * calls, // 今日有用量报告的 API 调用数(本地日志,按 turn/step 去重)
17
17
  * sessions // 今日有活动的会话数
18
18
  * },
19
- * peak, peakHours, // 峰谷时段
20
- * tier: { input, cacheRead, cacheWrite, output }, // 当前模型当前档单价
19
+ * peak, peakHours, // 峰谷时段(北京时间)
20
+ * tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
21
21
  * updatedAt, errors
22
22
  * }
23
23
  *
@@ -25,15 +25,37 @@
25
25
  * llm-deepseek 同一引用,不出本机)。今日 Token / API 次数只能从本地会话
26
26
  * 日志统计(官方无用量查询接口);今日花费为本地估算:
27
27
  * 日志 token × 官方峰谷价格表(见 lib/pricing.js)。
28
+ *
29
+ * 统计口径(0.1.7 起,与 DSH 官方 token-meter 对齐):
30
+ * - 按 (turn, step) 去重:同一调用的流式 usage chunk 与最终 assistant/message
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 绑定下的局域网访问。
28
42
  */
29
43
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
30
- import { DEFAULT_MODEL, PEAK_HOURS_TEXT, costOf, isPeak, unitAt } from "./pricing.js";
44
+ import {
45
+ DEFAULT_MODEL,
46
+ PEAK_HOURS_TEXT,
47
+ costOf,
48
+ isPeak,
49
+ shanghaiDayKey,
50
+ startOfTodayShanghaiMs,
51
+ unitAt,
52
+ } from "./pricing.js";
31
53
 
32
54
  const name = "dsh-deepseek-usage";
33
55
  const inject = ["credentials", "webServer"];
34
56
 
35
57
  const PUBLIC_BASE_URL = "https://api.deepseek.com";
36
- /** 与 llm-deepseek 适配器对齐的环境变量覆盖。 */
58
+ /** 与 llm-deepseek 适配器对齐的环境变量覆盖。注意:指向第三方网关时 API Key 会发给该地址。 */
37
59
  const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
38
60
  const API_KEY_REF = credentialRef("DEEPSEEK_API_KEY");
39
61
  const BALANCE_PATH = "/user/balance";
@@ -67,20 +89,111 @@ function toFinite(value) {
67
89
  return NaN;
68
90
  }
69
91
 
70
- /** 本地今日零点(unix ms)。 */
71
- function startOfTodayMs() {
72
- const d = new Date();
73
- d.setHours(0, 0, 0, 0);
74
- return d.getTime();
92
+ /** 仅接受本机 Host(127.0.0.1 / localhost / ::1),拦截 DNS rebinding 与局域网访问。 */
93
+ function isLocalHost(req) {
94
+ const raw = req.headers.host;
95
+ if (typeof raw !== "string" || raw === "") return false;
96
+ let host = raw.toLowerCase();
97
+ if (host.startsWith("[")) {
98
+ const end = host.indexOf("]");
99
+ if (end === -1) return false;
100
+ host = host.slice(1, end);
101
+ } else {
102
+ const idx = host.lastIndexOf(":");
103
+ if (idx !== -1) host = host.slice(0, idx);
104
+ }
105
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
106
+ }
107
+
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;
75
185
  }
76
186
 
77
187
  let usageCache = null;
78
188
  let usageCacheAt = 0;
189
+ let usageCacheDay = "";
190
+ /** sessionId -> { revision, agg },per-session 增量聚合缓存(P2)。 */
191
+ let sessionAggCache = new Map();
79
192
 
80
193
  /**
81
- * 扫描全部会话日志,统计今日(本地时区)token 用量 / API 请求数 / 花费估算。
82
- * 每条 `assistant/message` 按消息自带的 model(data.message.source.model)
83
- * 与事件时刻的峰谷单价计价。
194
+ * 扫描全部会话日志,统计今日(北京时间)token 用量 / API 调用数 / 花费估算。
195
+ * 增量策略:会话修订值(listSnapshots)未变 复用缓存聚合;只重读变化的
196
+ * 会话;跨北京日(shanghaiDayKey 变化)时全部失效重扫。
84
197
  */
85
198
  async function scanTodayUsage(ctx) {
86
199
  const persistence = ctx.get("sessionPersistence");
@@ -88,44 +201,65 @@ async function scanTodayUsage(ctx) {
88
201
  return { usable: false, error: "sessionPersistence 不可用" };
89
202
  }
90
203
  const now = Date.now();
91
- if (usageCache !== null && now - usageCacheAt < USAGE_CACHE_MS) return usageCache;
92
- const dayStart = startOfTodayMs();
93
- const acc = { usable: true, calls: 0, sessions: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, skipped: 0 };
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 {}
94
221
  try {
95
- const headers = await persistence.list();
222
+ const hasSnapshots = typeof persistence.listSnapshots === "function";
223
+ const headers = hasSnapshots ? await persistence.listSnapshots() : await persistence.list();
224
+ const seen = new Set();
96
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
+ }
97
240
  let raw;
98
241
  try {
99
- raw = await persistence.readRaw(header.id);
242
+ raw = await persistence.readRaw(id);
100
243
  } catch {
101
244
  acc.skipped += 1;
245
+ sessionAggCache.delete(id);
102
246
  continue;
103
247
  }
104
- if (raw === void 0 || raw === null || typeof raw.content !== "string") continue;
105
- let hit = false;
106
- for (const line of raw.content.split("\n")) {
107
- if (line === "") continue;
108
- let event;
109
- try {
110
- event = JSON.parse(line);
111
- } catch {
112
- continue;
113
- }
114
- if (event === null || typeof event !== "object" || event.type !== "assistant/message") continue;
115
- const usage = event.data && event.data.usage;
116
- if (usage === void 0 || usage === null || typeof event.time !== "number" || event.time < dayStart) continue;
117
- hit = true;
118
- const source = event.data.message && event.data.message.source;
119
- const model = source && typeof source.model === "string" ? source.model : void 0;
120
- const unit = unitAt(model, event.time);
121
- acc.calls += 1;
122
- acc.input += usage.inputTokens || 0;
123
- acc.output += usage.outputTokens || 0;
124
- acc.cacheRead += usage.cacheReadTokens || 0;
125
- acc.cacheWrite += usage.cacheWriteTokens || 0;
126
- acc.cost += costOf(usage, unit);
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);
127
262
  }
128
- if (hit) acc.sessions += 1;
129
263
  }
130
264
  } catch (error) {
131
265
  acc.usable = false;
@@ -134,6 +268,7 @@ async function scanTodayUsage(ctx) {
134
268
  acc.cost = Math.round(acc.cost * 1e6) / 1e6;
135
269
  usageCache = acc;
136
270
  usageCacheAt = now;
271
+ usageCacheDay = dayKey;
137
272
  return acc;
138
273
  }
139
274
 
@@ -144,10 +279,15 @@ function apply(ctx) {
144
279
  path: ROUTE_PATH,
145
280
  handler: async (req, res) => {
146
281
  try {
282
+ // ---- 仅本机访问(P3):拦截 DNS rebinding 与局域网直接访问 ----
283
+ if (!isLocalHost(req)) {
284
+ return sendJson(res, 403, { ok: false, error: "forbidden", message: "仅允许本机访问" });
285
+ }
286
+
147
287
  const errors = [];
148
288
 
149
289
  // ---- 余额(官方接口)----
150
- const balance = { available: null, total: null, currency: null, granted: null, toppedUp: null };
290
+ const balance = { isAvailable: null, total: null, currency: null, granted: null, toppedUp: null };
151
291
  try {
152
292
  const hit = await ctx.credentials.resolve(API_KEY_REF);
153
293
  if (hit === void 0) {
@@ -164,7 +304,7 @@ function apply(ctx) {
164
304
  const body = JSON.parse(text);
165
305
  const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
166
306
  const first = infos[0] || {};
167
- balance.available = body?.is_available !== false;
307
+ balance.isAvailable = body?.is_available !== false;
168
308
  balance.total = toFinite(first.total_balance);
169
309
  balance.currency = typeof first.currency === "string" ? first.currency : null;
170
310
  balance.granted = toFinite(first.granted_balance);
@@ -175,9 +315,12 @@ function apply(ctx) {
175
315
  errors.push(`余额获取失败: ${error instanceof Error ? error.message : String(error)}`);
176
316
  }
177
317
 
178
- // ---- 今日 token / API 次数 / 花费估算(本地日志)----
318
+ // ---- 今日 token / API 调用数 / 花费估算(本地日志)----
179
319
  const usage = await scanTodayUsage(ctx);
180
320
  if (!usage.usable) errors.push(usage.error ?? "今日用量统计不可用");
321
+ if (usage.usable && usage.unknownModels.size > 0) {
322
+ errors.push(`未知模型按 chat 档计价: ${[...usage.unknownModels].join(", ")}`);
323
+ }
181
324
 
182
325
  const now = Date.now();
183
326
  let model = DEFAULT_MODEL;
@@ -212,7 +355,14 @@ function apply(ctx) {
212
355
  },
213
356
  peak: isPeak(now),
214
357
  peakHours: PEAK_HOURS_TEXT,
215
- tier: { input: unit.input, cacheRead: unit.cacheRead, cacheWrite: unit.cacheWrite, output: unit.output },
358
+ tier: {
359
+ input: unit.input,
360
+ cacheRead: unit.cacheRead,
361
+ cacheWrite: unit.cacheWrite,
362
+ output: unit.output,
363
+ model: unit.model,
364
+ known: unit.known,
365
+ },
216
366
  updatedAt: now,
217
367
  errors,
218
368
  });
package/lib/pricing.js CHANGED
@@ -2,11 +2,19 @@
2
2
  * dsh-deepseek-usage — 定价引擎(元 / 百万 tokens)。
3
3
  *
4
4
  * DeepSeek 官方价格快照(2026-08-17 起 V4 系列峰谷定价,北京时间):
5
- * 高峰时段 9:00–12:00 与 14:00–18:00,其余为空闲时段(空闲=高峰半价)。
6
- * 缓存写暂不计费(cacheWrite = 0);未知模型回退 chat 档。
5
+ * 高峰时段 9:00–12:00 与 14:00–18:00(北京时间),其余为空闲时段(空闲=高峰半价)。
6
+ * 缓存写暂不计费(cacheWrite = 0);未知模型回退 chat 档,并通过 unitAt().known
7
+ * 与 isKnownModel() 让调用方提示用户,而不是静默算错。
7
8
  * 价格调整时只需改 PRICE_TABLE 与 PEAK_WINDOWS。
9
+ *
10
+ * 时区(0.1.7 起):峰谷判定与"今日"边界一律按北京时间(Asia/Shanghai,
11
+ * 固定 UTC+8,无夏令时)计算,不随服务器本地时区变化——在任何时区部署的
12
+ * DSH 上显示一致。
8
13
  */
9
14
 
15
+ /** 北京时间固定偏移(上海无夏令时)。 */
16
+ const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
17
+
10
18
  /** 每模型价格:input/cacheRead/output 均为 [高峰, 空闲] 二元组;cacheWrite 为常量 0。 */
11
19
  const PRICE_TABLE = {
12
20
  'deepseek-v4-flash': { input: [3.0, 1.5], cacheRead: [0.1, 0.05], cacheWrite: 0, output: [9.0, 4.5] },
@@ -15,22 +23,51 @@ const PRICE_TABLE = {
15
23
  'deepseek-reasoner': { input: [4, 4], cacheRead: [1, 1], cacheWrite: 0, output: [16, 16] },
16
24
  };
17
25
 
18
- /** 默认模型(当 agentDefaultModel 不可用时)。 */
26
+ /** 默认模型(当 agentDefaultModel 不可用时的展示默认)。 */
19
27
  export const DEFAULT_MODEL = 'deepseek-v4-flash';
20
28
 
21
- /** 高峰时段窗口(本地/北京时间,含起点不含终点)。 */
29
+ /** 高峰时段窗口(北京时间,含起点不含终点)。 */
22
30
  export const PEAK_WINDOWS = [[9, 12], [14, 18]];
23
31
  /** 高峰时段展示文案。 */
24
- export const PEAK_HOURS_TEXT = '9:00–12:00、14:00–18:00';
32
+ export const PEAK_HOURS_TEXT = '9:00–12:00、14:00–18:00(北京时间)';
33
+
34
+ /** 某时刻的北京时间小时(0–23)。 */
35
+ export function shanghaiHour(ms) {
36
+ return new Date(ms + SHANGHAI_OFFSET_MS).getUTCHours();
37
+ }
38
+
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
+ }
25
44
 
26
- /** 某时刻是否为高峰时段。 */
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
+ /** 某时刻是否为高峰时段(按北京时间)。 */
27
55
  export function isPeak(ms) {
28
- const h = new Date(ms).getHours();
56
+ const h = shanghaiHour(ms);
29
57
  return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
30
58
  }
31
59
 
32
- /** 某模型在某一时刻的单位价格(元/百万 tokens)。 */
60
+ /** 模型是否在价格表中。 */
61
+ export function isKnownModel(model) {
62
+ return typeof model === 'string' && Object.prototype.hasOwnProperty.call(PRICE_TABLE, model);
63
+ }
64
+
65
+ /**
66
+ * 某模型在某一时刻的单位价格(元/百万 tokens)。
67
+ * 未知模型回退 chat 档;known=false 供调用方提示,避免静默算错。
68
+ */
33
69
  export function unitAt(model, ms) {
70
+ const known = isKnownModel(model);
34
71
  const p = PRICE_TABLE[model] || PRICE_TABLE['deepseek-chat'];
35
72
  const tier = isPeak(ms) ? 0 : 1;
36
73
  return {
@@ -38,6 +75,8 @@ export function unitAt(model, ms) {
38
75
  cacheRead: p.cacheRead[tier],
39
76
  cacheWrite: p.cacheWrite,
40
77
  output: p.output[tier],
78
+ model: known ? model : 'deepseek-chat',
79
+ known,
41
80
  };
42
81
  }
43
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougen/dsh-deepseek-usage",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "DeepSeek 用量侧边栏插件:余额(官方 API)、今日花费(本地日志估算)、今日 Token 与 API 次数(本地日志)、高峰/空闲时段指示灯。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -27,7 +27,8 @@
27
27
  }
28
28
  },
29
29
  "peerDependencies": {
30
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6"
30
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
31
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6"
31
32
  },
32
33
  "keywords": [
33
34
  "dsh",