@nomxy/dsh-balance 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 siyin (dsh-balance)
4
+
5
+ The host half of this package — `lib/index.js` and `lib/pricing.js` — is derived
6
+ from `@dougen/dsh-deepseek-usage` (https://github.com/dougen/dsh-deepseek-usage),
7
+ which is distributed under the MIT License:
8
+
9
+ Copyright (c) 2026 dougen
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # dsh-balance
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness 侧边栏余额胶囊:把 **DeepSeek 官方账户余额** 和 **峰谷时段** 做成一个 20px 高的小胶囊,贴在侧栏页脚「名字那一行」的右侧,不占一整块地方。
4
+
5
+ `¥56.10 · 谷 1d9h`
6
+
7
+ - **余额**:来自 DeepSeek 官方 `GET /user/balance`,用 DSH 凭证服务里的 `DEEPSEEK_API_KEY`(不出本机)。
8
+ - **峰谷**:官方高峰/空闲时段实时判断,橙色「峰」/ 绿色「谷」,后面跟距下次切换的倒计时(`2d15h` / `5h47m` / `23m`),每 15 秒刷新。
9
+ - **当前档位单价**:鼠标悬停显示输入 / 输入缓存 / 输出单价(元 / 百万 tokens),以及余额是否可用。
10
+ - **交互**:点击立即刷新;后台每 30 秒心跳一次;侧栏折叠成 36px 图标栏时自动隐藏,展开即回来。
11
+ - **失败可见**:未配置 Key、余额接口超时或返回异常时胶囊显示 `—`,悬停能看到原因,不会编数字。
12
+
13
+ ## 安装
14
+
15
+ ```sh
16
+ dsh plugin --profile <你的 profile> add @nomxy/dsh-balance
17
+ ```
18
+
19
+ 装完刷新页面或重启 `dsh web` 即生效。要求 DSH `>= 0.2.1`(`dsh.client` + `dsh.bundle` 清单)。
20
+
21
+ ## 配置
22
+
23
+ 无需配置。余额需要 DSH 里已经配好 DeepSeek 的 API Key:
24
+
25
+ - 凭证引用:`DEEPSEEK_API_KEY`(与 `llm-deepseek` 同一引用);
26
+ - 想换网关地址时可用环境变量 `DEEPSEEK_BASE_URL`(注意:指向第三方网关时 API Key 会发给该地址)。
27
+
28
+ ## 隐私与安全
29
+
30
+ - 插件只做两件事:读凭证、请求官方余额接口。不收集、不上报,没有遥测。
31
+ - 宿主半只注册两条本机路由(`/api/dsh-balance`、`/api/dsh-balance/holidays`),且只接受 `Host` 为回环地址或本监听地址的请求(与 DSH 自身 `/api` 栅栏同一口径);跨站标记与异源 `Origin` 一律拒绝。
32
+ - 上游错误原文不会透出到界面(上游异常消息里可能带凭据片段)。
33
+
34
+ ## 已知限制
35
+
36
+ - 只支持 DeepSeek 官方平台余额;第三方路由上的同名模型不适用官方峰谷定价,此时只显示余额。
37
+ - 峰谷与节假日规则是内置表(`lib/pricing.js`),官方调整规则或放假日历时需要更新版本。
38
+ - 胶囊是通过 DOM 挂载进侧栏名字行的(DSH 没有「名字后面」这个槽位),因此侧栏结构大改时需要跟着调整选择器 `div[class*="triggerRow"]`。
39
+
40
+ ## 来源与致谢
41
+
42
+ 宿主半(`lib/index.js`、`lib/pricing.js`:余额读取、峰谷与节假日规则、单价表)取自
43
+ [@dougen/dsh-deepseek-usage](https://github.com/dougen/dsh-deepseek-usage)(MIT),仅把插件 id 与路由改名,并去掉了对 `@deepseek-ai/dsh-credentials` 的运行时依赖(`credentialRef` 是编译期 brand,运行时为恒等函数)。
44
+ 客户端半(`lib/client.js`)为本项目重写:不注册槽位,直接挂载紧凑胶囊。
45
+
46
+ ## License
47
+
48
+ MIT,见 [LICENSE](./LICENSE)(包含上游 MIT 声明)。
49
+
50
+ ---
51
+
52
+ ## English
53
+
54
+ A 20px balance chip for the DeepSeek Harness sidebar footer, sitting right of the name row: official DeepSeek balance plus the current peak/off-peak window with a countdown to the next switch, hover for the active model's unit prices, click to refresh. No telemetry; the host half only serves two loopback-only routes. Install with `dsh plugin --profile <profile> add @nomxy/dsh-balance`. Host half derived from `@dougen/dsh-deepseek-usage` (MIT).
@@ -0,0 +1,5 @@
1
+ # dsh-balance — 本地插件:侧栏名字行里的 DeepSeek 余额胶囊。
2
+ # Host 半注册 GET /api/dsh-balance(含 /holidays);Client 半不占槽位,直接把胶囊挂进 triggerRow。
3
+ - insert:
4
+ - id: balance-chip
5
+ name: '@nomxy/dsh-balance'
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="dsh-balance">
2
+ <rect x="4" y="26" width="88" height="44" rx="22" fill="#111827"/>
3
+ <text x="34" y="48" text-anchor="middle" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="26" font-weight="700" fill="#f9fafb">¥</text>
4
+ <circle cx="66" cy="48" r="7" fill="#22c55e"/>
5
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,238 @@
1
+ // dsh-balance — browser half.
2
+ //
3
+ // 不注册任何 sidebar 槽位:在侧边栏页脚"名字那一行"(triggerRow)里挂一个
4
+ // 紧凑胶囊(¥余额 + 峰/谷时段 + 距下次切换倒计时),位于名字与右侧指示灯之间。
5
+ // 每 30 秒以及点击时拉宿主路由 /api/dsh-balance(见 lib/index.js)。
6
+ window.__ModuleLoader__.load({
7
+ id: "@nomxy/dsh-balance",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ var exports = module.exports;
11
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
12
+
13
+ const USAGE_PATH = "/api/dsh-balance";
14
+ const POLL_MS = 30 * 1000;
15
+ const TICK_MS = 15 * 1000;
16
+ const CHIP_CLASS = "dsh-balance";
17
+
18
+ const CSS = `
19
+ .${CHIP_CLASS}{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 7px;flex:none;
20
+ box-sizing:border-box;border-radius:999px;border:1px solid var(--dsw-color-border,rgba(127,127,127,.3));
21
+ background:transparent;cursor:pointer;white-space:nowrap;font-variant-numeric:tabular-nums;
22
+ font:500 11.5px/18px var(--dsw-font-family,system-ui,-apple-system,"Segoe UI",sans-serif);
23
+ color:var(--dsw-color-text-secondary,#6b7280);transition:background .15s ease,color .15s ease}
24
+ .${CHIP_CLASS}:hover{background:var(--dsw-color-fill-hover,rgba(127,127,127,.14));color:var(--dsw-color-text,inherit)}
25
+ .${CHIP_CLASS}[data-error="1"]{color:var(--dsw-color-danger,#dc2626);border-color:currentColor}
26
+ .${CHIP_CLASS}__sep{opacity:.4;margin:0 1px}
27
+ .${CHIP_CLASS}__phase{font-weight:600;letter-spacing:.2px}
28
+ .${CHIP_CLASS}__phase[data-phase="peak"]{color:#d97706}
29
+ .${CHIP_CLASS}__phase[data-phase="idle"]{color:#15803d}
30
+ .${CHIP_CLASS}__phase[data-phase="none"]{display:none}
31
+ .${CHIP_CLASS}__cd{opacity:.75;font-weight:500}
32
+ div[class*="triggerRow"][class*="railRow"] .${CHIP_CLASS}{display:none}
33
+ `;
34
+
35
+ let styleEl = null;
36
+ let chip = null;
37
+ let amountEl = null;
38
+ let phaseEl = null;
39
+ let countdownEl = null;
40
+ let sepEl = null;
41
+ let timer = null;
42
+ let ticker = null;
43
+ let observer = null;
44
+ let unmounted = false;
45
+ let fetchedAt = 0;
46
+ let state = { total: null, currency: "CNY", peak: null, tier: null, next: null, error: null };
47
+
48
+ function symbolOf(currency) {
49
+ if (currency === "CNY" || currency === "RMB") return "¥";
50
+ if (currency === "USD") return "$";
51
+ return "";
52
+ }
53
+
54
+ function amountText() {
55
+ if (state.error !== null) return "—";
56
+ if (state.total === null) return "…";
57
+ return symbolOf(state.currency) + state.total.toFixed(2);
58
+ }
59
+
60
+ function priceLine(tier) {
61
+ if (!tier || tier.known === false) return "";
62
+ const parts = [];
63
+ if (typeof tier.input === "number") parts.push("输入 " + tier.input);
64
+ if (typeof tier.cacheRead === "number") parts.push("缓存 " + tier.cacheRead);
65
+ if (typeof tier.output === "number") parts.push("输出 " + tier.output);
66
+ return parts.length === 0 ? "" : "元/百万 tokens:" + parts.join(" · ");
67
+ }
68
+
69
+ /** 距下次峰谷切换的剩余时间:`2d15h` / `5h47m` / `23m`。 */
70
+ function countdownText(ms) {
71
+ if (!(ms > 0)) return "";
72
+ const totalMinutes = Math.floor(ms / 60000);
73
+ const days = Math.floor(totalMinutes / 1440);
74
+ const hours = Math.floor((totalMinutes % 1440) / 60);
75
+ const minutes = totalMinutes % 60;
76
+ if (days > 0) return days + "d" + hours + "h";
77
+ if (hours > 0) return hours + "h" + (minutes > 0 ? minutes + "m" : "");
78
+ return minutes + "m";
79
+ }
80
+
81
+ /** 剩余的切换毫秒数:宿主给的是"发起请求那一刻"的毫秒,按本地流逝扣掉。 */
82
+ function remainingMs() {
83
+ if (state.next === null || typeof state.next.ms !== "number") return NaN;
84
+ const elapsed = fetchedAt === 0 ? 0 : Date.now() - fetchedAt;
85
+ return state.next.ms - elapsed;
86
+ }
87
+
88
+ function render() {
89
+ if (chip === null) return;
90
+ amountEl.textContent = amountText();
91
+ chip.dataset.error = state.error === null ? "0" : "1";
92
+ if (state.peak === null) {
93
+ phaseEl.dataset.phase = "none";
94
+ phaseEl.textContent = "";
95
+ sepEl.style.display = "none";
96
+ countdownEl.textContent = "";
97
+ } else {
98
+ phaseEl.dataset.phase = state.peak ? "peak" : "idle";
99
+ phaseEl.textContent = state.peak ? "峰" : "谷";
100
+ sepEl.style.display = "";
101
+ const remaining = remainingMs();
102
+ const text = countdownText(remaining);
103
+ countdownEl.textContent = text === "" ? "" : text;
104
+ }
105
+ const lines = [];
106
+ lines.push(state.error === null ? "DeepSeek 余额,点击刷新" : "余额读取失败:" + state.error);
107
+ if (state.peak !== null) {
108
+ const remaining = remainingMs();
109
+ const text = countdownText(remaining);
110
+ lines.push((state.peak ? "高峰时段" : "空闲时段") + (text === "" ? "" : ",距切换 " + text));
111
+ }
112
+ const prices = priceLine(state.tier);
113
+ if (prices !== "") lines.push(prices);
114
+ chip.title = lines.filter((line) => line !== "").join("\n");
115
+ }
116
+
117
+ async function load() {
118
+ try {
119
+ const response = await fetch(USAGE_PATH, { cache: "no-store", credentials: "same-origin" });
120
+ const body = await response.json().catch(() => null);
121
+ if (!response.ok || body === null || body.ok !== true) {
122
+ const message = body && Array.isArray(body.errors) && body.errors.length > 0 ? body.errors[0] : "HTTP " + response.status;
123
+ state = Object.assign({}, state, { error: String(message) });
124
+ } else {
125
+ const balance = body.balance || {};
126
+ const total = typeof balance.total === "number" && Number.isFinite(balance.total) ? balance.total : null;
127
+ fetchedAt = Date.now();
128
+ state = {
129
+ total,
130
+ currency: typeof balance.currency === "string" ? balance.currency : "CNY",
131
+ peak: typeof body.peak === "boolean" ? body.peak : null,
132
+ tier: body.tier || null,
133
+ next: body.next || null,
134
+ error: Array.isArray(body.errors) && body.errors.length > 0 ? String(body.errors[0]) : null,
135
+ };
136
+ }
137
+ } catch (error) {
138
+ state = Object.assign({}, state, { error: "余额接口无法连接" });
139
+ }
140
+ render();
141
+ }
142
+
143
+ function buildChip() {
144
+ const node = document.createElement("button");
145
+ node.type = "button";
146
+ node.className = CHIP_CLASS;
147
+ node.dataset.error = "0";
148
+ const amount = document.createElement("span");
149
+ amount.textContent = "…";
150
+ const sep = document.createElement("span");
151
+ sep.className = CHIP_CLASS + "__sep";
152
+ sep.textContent = "·";
153
+ const phase = document.createElement("span");
154
+ phase.className = CHIP_CLASS + "__phase";
155
+ phase.dataset.phase = "none";
156
+ const countdown = document.createElement("span");
157
+ countdown.className = CHIP_CLASS + "__cd";
158
+ node.appendChild(amount);
159
+ node.appendChild(sep);
160
+ node.appendChild(phase);
161
+ node.appendChild(countdown);
162
+ node.addEventListener("click", (event) => {
163
+ event.preventDefault();
164
+ event.stopPropagation();
165
+ load();
166
+ });
167
+ amountEl = amount;
168
+ sepEl = sep;
169
+ phaseEl = phase;
170
+ countdownEl = countdown;
171
+ return node;
172
+ }
173
+
174
+ /** 挂到"名字那一行":紧跟名字按钮、位于连接/更新指示灯之前。 */
175
+ function ensureMounted() {
176
+ if (unmounted || chip === null) return;
177
+ const row = document.querySelector('div[class*="triggerRow"]');
178
+ if (row === null) return;
179
+ if (row.contains(chip)) return;
180
+ const anchor = row.children.length > 1 ? row.children[1] : null;
181
+ row.insertBefore(chip, anchor);
182
+ render();
183
+ }
184
+
185
+ function start() {
186
+ if (styleEl === null) {
187
+ styleEl = document.createElement("style");
188
+ styleEl.dataset.plugin = "dsh-balance";
189
+ styleEl.textContent = CSS;
190
+ document.head.appendChild(styleEl);
191
+ }
192
+ chip = buildChip();
193
+ ensureMounted();
194
+ load();
195
+ timer = setInterval(load, POLL_MS);
196
+ // 倒计时与峰谷文字每 15 秒重算一次,不必等下一次请求。
197
+ ticker = setInterval(render, TICK_MS);
198
+ let scheduled = false;
199
+ observer = new MutationObserver(() => {
200
+ if (scheduled) return;
201
+ scheduled = true;
202
+ setTimeout(() => {
203
+ scheduled = false;
204
+ ensureMounted();
205
+ }, 50);
206
+ });
207
+ observer.observe(document.documentElement, { childList: true, subtree: true });
208
+ }
209
+
210
+ function stop() {
211
+ unmounted = true;
212
+ if (observer !== null) observer.disconnect();
213
+ if (timer !== null) clearInterval(timer);
214
+ if (ticker !== null) clearInterval(ticker);
215
+ if (chip !== null) chip.remove();
216
+ if (styleEl !== null) styleEl.remove();
217
+ observer = null;
218
+ timer = null;
219
+ ticker = null;
220
+ chip = null;
221
+ styleEl = null;
222
+ }
223
+
224
+ const inject = [];
225
+
226
+ function apply(ctx) {
227
+ ctx.effect(() => {
228
+ start();
229
+ return () => stop();
230
+ }, "dsh-balance: sidebar chip");
231
+ }
232
+
233
+ exports.name = "@nomxy/dsh-balance";
234
+ exports.inject = inject;
235
+ exports.apply = apply;
236
+ return module.exports;
237
+ }
238
+ });
package/lib/index.js ADDED
@@ -0,0 +1,474 @@
1
+ /**
2
+ * dsh-deepseek-usage — host half.
3
+ *
4
+ * 注册一个 HTTP 路由:
5
+ *
6
+ * GET /api/deepseek-usage[?lang=zh|en]
7
+ * GET /api/deepseek-usage/holidays
8
+ *
9
+ * 返回(JSON):
10
+ * {
11
+ * ok: true,
12
+ * balance: { isAvailable, total, currency }, // 官方余额接口(API Key)
13
+ * tier: { input, cacheRead, cacheWrite, output, model, known },
14
+ * next: { peak, at, ms }, // 距下次峰谷切换(界面倒计时)
15
+ * peak, // 当前是否高峰
16
+ * updatedAt, errors,
17
+ * pricing: { via, requested, provider, used, known } // 【内部诊断】界面不消费
18
+ * }
19
+ *
20
+ * 界面只使用 balance / tier / next / peak / errors:插件内部的取价路径属于诊断信息,
21
+ * 保留在响应里供排查与测试,但不出现在 UI 上。
22
+ * errors 只放"用户能理解且需要行动"的余额类失败,不含任何上游原文——上游错误消息
23
+ * 可能带着凭据片段(undici 会把非法的 Authorization 值写进异常消息),直接透出等于
24
+ * 把 Key 印在侧边栏上。
25
+ *
26
+ * `lang` 参数(zh 默认)只影响错误消息的语言;UI 其余文案由 client 端本地化。
27
+ *
28
+ * 单价数据源:lib/pricing.js 的内置 PRICE_TABLE(DeepSeek 官方峰谷定价快照)。
29
+ * 官方调价时随插件版本更新该表——不联网抓取、不读写本地价格配置文件。
30
+ * 只有官方路由(provider 见 pricing.js 的 OFFICIAL_PROVIDERS)上的官方模型才计价:
31
+ * 第三方路由上的 deepseek-* 同名模型按各自规则扣费,用官方表算出来的数字与账单无关。
32
+ *
33
+ * 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
34
+ * 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
35
+ *
36
+ * 安全:路由只接受本机来源(见 isTrustedRequest)——与 DSH 自身的 /api 栅栏同一
37
+ * 口径:Host 必须是回环地址或本监听器绑定的地址,跨站请求标记与异源 Origin 一律拒绝。
38
+ * 另见 README 的"已知限制":本机其它进程仍可直接访问该端口,这是本地工具的固有边界。
39
+ */
40
+ // credentialRef() 只是编译期 string brand(运行时是恒等函数),这里用本地等价实现,
41
+ // 不依赖 @deepseek-ai/dsh-credentials 的模块解析,插件完全自包含。
42
+ const credentialRef = (value) => value;
43
+ import {
44
+ DEFAULT_MODEL,
45
+ HOLIDAY_RANGES,
46
+ hasHolidayCoverage,
47
+ isPeak,
48
+ nextChange,
49
+ unitAt,
50
+ } from "./pricing.js";
51
+
52
+ const name = "@nomxy/dsh-balance";
53
+ const inject = ["credentials", "webServer"];
54
+
55
+ const PUBLIC_BASE_URL = "https://api.deepseek.com";
56
+ /** 与 llm-deepseek 适配器对齐的环境变量覆盖。注意:指向第三方网关时 API Key 会发给该地址。 */
57
+ const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
58
+ const API_KEY_REF = credentialRef("DEEPSEEK_API_KEY");
59
+ const BALANCE_PATH = "/user/balance";
60
+ const ROUTE_PATH = "/api/dsh-balance";
61
+ /** 放假日期路由(浏览器侧启动拉一次,保证两边同一份日期表)。 */
62
+ const HOLIDAYS_PATH = "/api/dsh-balance/holidays";
63
+ const TIMEOUT_MS = 15000;
64
+ /** 余额响应体上限:网关返回异常大 body 时不让它占满内存。 */
65
+ const MAX_BODY_BYTES = 64 * 1024;
66
+ /**
67
+ * 余额快照缓存时长(毫秒)。客户端每 30s 心跳一次,多个标签页/多次点击会重复打
68
+ * 官方余额接口;缓存把这段时间内的重复请求合并成一次。
69
+ */
70
+ const BALANCE_CACHE_MS = 10_000;
71
+
72
+ const JSON_HEADERS = {
73
+ "content-type": "application/json; charset=utf-8",
74
+ "cache-control": "no-store",
75
+ };
76
+
77
+ /**
78
+ * 错误消息字典(zh/en)。只保留"余额为什么拿不到"这类用户能理解、且需要行动的
79
+ * 信息;不拼接任何上游原文或异常消息。
80
+ */
81
+ const MESSAGES = {
82
+ zh: {
83
+ noKey: "未配置 DEEPSEEK_API_KEY",
84
+ badKey: "DEEPSEEK_API_KEY 含有非法字符",
85
+ balanceHttp: (status) => `余额接口 HTTP ${status}`,
86
+ balanceTimeout: "余额接口超时",
87
+ balanceNetwork: "余额接口无法连接",
88
+ balancePayload: "余额接口返回了无法解析的内容",
89
+ balanceEmpty: "余额接口没有返回余额",
90
+ balanceUnavailable: "账户当前不可用",
91
+ forbidden: "仅允许本机访问",
92
+ method: "仅支持 GET",
93
+ internal: "插件内部错误",
94
+ },
95
+ en: {
96
+ noKey: "DEEPSEEK_API_KEY is not configured",
97
+ badKey: "DEEPSEEK_API_KEY contains invalid characters",
98
+ balanceHttp: (status) => `Balance API HTTP ${status}`,
99
+ balanceTimeout: "Balance API timed out",
100
+ balanceNetwork: "Balance API is unreachable",
101
+ balancePayload: "Balance API returned an unreadable response",
102
+ balanceEmpty: "Balance API returned no balance",
103
+ balanceUnavailable: "The account is currently unavailable",
104
+ forbidden: "Local access only",
105
+ method: "GET only",
106
+ internal: "Internal plugin error",
107
+ },
108
+ };
109
+
110
+ /**
111
+ * 放假日期表未覆盖当前年份时告警一次(每天一次)。
112
+ *
113
+ * 在请求路径上检查,而不是只在启动时:进程跨年长跑时,下一年的节假日仍按工作日
114
+ * 计价,而这正是用户真正会看到错误价格的时段——只在启动时告警覆盖不到那一刻。
115
+ */
116
+ let lastHolidayWarningDay = "";
117
+ function warnHolidayCoverage(ctx) {
118
+ if (hasHolidayCoverage()) return;
119
+ const now = Date.now();
120
+ const day = new Date(now + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
121
+ if (day === lastHolidayWarningDay) return;
122
+ lastHolidayWarningDay = day;
123
+ const year = new Date(now + 8 * 60 * 60 * 1000).getUTCFullYear();
124
+ ctx.logger.warn(
125
+ `dsh-deepseek-usage: 内置放假日期表未覆盖 ${year} 年(仅收录到 ${HOLIDAY_RANGES.at(-1)?.[1] ?? "未知"})`
126
+ + `,${year} 年法定节假日白天会被当作高峰时段:请按国务院办公厅通知更新 lib/pricing.js 的 HOLIDAY_RANGES`
127
+ + `(并同步 lib/client.js 的镜像表,并把 HOLIDAY_TABLE_THROUGH_YEAR 推到 ${year})`,
128
+ );
129
+ }
130
+
131
+ function sendJson(res, status, body) {
132
+ // 已经发过响应头时再写一次会抛 ERR_HTTP_HEADERS_SENT,直接结束即可。
133
+ if (res.headersSent) {
134
+ res.end();
135
+ return;
136
+ }
137
+ res.writeHead(status, JSON_HEADERS);
138
+ res.end(JSON.stringify(body));
139
+ }
140
+
141
+ function balanceUrl() {
142
+ const base = process.env[BASE_URL_ENV] ?? PUBLIC_BASE_URL;
143
+ return `${base.replace(/\/+$/, "")}${BALANCE_PATH}`;
144
+ }
145
+
146
+ /** 容忍字符串/数字,返回有限数或 NaN。 */
147
+ function toFinite(value) {
148
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
149
+ if (typeof value === "string" && value.trim() !== "") {
150
+ const n = Number(value);
151
+ return Number.isFinite(n) ? n : NaN;
152
+ }
153
+ return NaN;
154
+ }
155
+
156
+ /**
157
+ * 请求头里的 Host 是否是"本机"。
158
+ *
159
+ * 接受:回环地址(127/8 任意地址、::1、localhost)、本监听器绑定的地址。
160
+ * 与 DSH 自己的 /api 栅栏同一口径(见 app.asar 的 isTrustedApiRequest):只认回环
161
+ * 会让 `--host <局域网IP>` 与 `--trusted-host` 部署下插件永久 403。
162
+ */
163
+ function isLocalHost(req, bindHost) {
164
+ const raw = req.headers.host;
165
+ if (typeof raw !== "string" || raw === "") return false;
166
+ const host = hostNameOf(raw);
167
+ if (host === null) return false;
168
+ if (host === "localhost" || host === "::1") return true;
169
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
170
+ if (host === "::ffff:127.0.0.1") return true;
171
+ // 监听器绑定的地址(如 --host 10.0.0.7)同样是"自己"。
172
+ if (typeof bindHost === "string" && bindHost !== "") {
173
+ const bind = hostNameOf(bindHost);
174
+ if (bind !== null && bind !== "" && host === bind) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * 取 authority 里的主机名:小写、去掉端口与 IPv6 方括号。
181
+ * IPv6 不能按冒号切(`::1` 切出空串),所以先剥方括号再处理。
182
+ */
183
+ function hostNameOf(authority) {
184
+ let host = authority.trim().toLowerCase();
185
+ if (host.startsWith("[")) {
186
+ const end = host.indexOf("]");
187
+ if (end === -1) return null;
188
+ host = host.slice(1, end);
189
+ return host;
190
+ }
191
+ const idx = host.lastIndexOf(":");
192
+ if (idx !== -1 && host.indexOf(":") === idx) host = host.slice(0, idx);
193
+ return host === "" ? null : host;
194
+ }
195
+
196
+ /**
197
+ * 请求是否可以访问本插件的路由。
198
+ *
199
+ * 口径与 DSH 的 /api 栅栏一致:
200
+ * 1. Host 必须是本机(回环或监听器绑定地址);
201
+ * 2. 浏览器标记为跨站的请求一律拒绝;
202
+ * 3. 带 Origin 时,Origin 必须与 Host 同一 authority。
203
+ * 这样任意网页都无法跨站读走余额,DNS rebinding(Host 变成攻击者域名)同样被挡下。
204
+ */
205
+ function isTrustedRequest(req, bindHost) {
206
+ if (!isLocalHost(req, bindHost)) return false;
207
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
208
+ const origin = req.headers.origin;
209
+ if (typeof origin === "string" && origin !== "") {
210
+ let originHost;
211
+ try {
212
+ originHost = new URL(origin).host.toLowerCase();
213
+ } catch {
214
+ return false;
215
+ }
216
+ const host = String(req.headers.host ?? "").toLowerCase();
217
+ if (originHost !== host) return false;
218
+ }
219
+ return true;
220
+ }
221
+
222
+ /** 解析 ?lang= 参数。 */
223
+ function resolveQuery(req) {
224
+ const url = req.url ?? "";
225
+ const q = url.indexOf("?");
226
+ const kv = q === -1 ? [] : url.slice(q + 1).split("&");
227
+ let lang = "zh";
228
+ for (const item of kv) {
229
+ if (item === "lang=en" || item === "lang=en-US" || item === "lang=en-GB") lang = "en";
230
+ }
231
+ return { lang };
232
+ }
233
+
234
+ /**
235
+ * API Key 是否能安全地放进 Authorization 头。
236
+ * 凭据存储允许任意文本(多行值也是合法输入),而非法字符会让 undici 把整个
237
+ * Authorization 值写进异常消息——那道消息会一路流到卡片上。
238
+ */
239
+ function isUsableKey(value) {
240
+ if (typeof value !== "string") return false;
241
+ const key = value.trim();
242
+ if (key === "") return false;
243
+ // HTTP 头值只允许可见 ASCII(不含空格与控制字符)。
244
+ return /^[\x21-\x7e]+$/.test(key);
245
+ }
246
+
247
+ /** 余额响应体超限:与"无法解析"同一类问题。 */
248
+ class BalancePayloadError extends Error {}
249
+
250
+ /** 读取响应体,超过上限即视为失败。 */
251
+ async function readBoundedText(response) {
252
+ const declared = Number(response.headers.get("content-length"));
253
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) throw new BalancePayloadError();
254
+ const text = await response.text();
255
+ // text.length 是 UTF-16 码元数;按字节算才不会让多字节内容绕过上限。
256
+ if (Buffer.byteLength(text, "utf8") > MAX_BODY_BYTES) throw new BalancePayloadError();
257
+ return text;
258
+ }
259
+
260
+ /**
261
+ * 取一次余额快照。
262
+ *
263
+ * @param key 已解析出的 API Key(调用方已做过形状校验)。
264
+ * @returns {{ ok: boolean, balance: object, code: string|null, status?: number }}
265
+ * code 是失败分类(供 MESSAGES 选文案),成功时为 null。
266
+ */
267
+ async function fetchBalance(ctx, lang, key) {
268
+ const balance = { isAvailable: null, total: null, currency: null };
269
+
270
+ let response;
271
+ let text;
272
+ try {
273
+ response = await fetch(balanceUrl(), {
274
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
275
+ signal: AbortSignal.timeout(TIMEOUT_MS),
276
+ });
277
+ text = await readBoundedText(response);
278
+ } catch (error) {
279
+ if (error instanceof BalancePayloadError) {
280
+ return { ok: false, balance, code: "balancePayload" };
281
+ }
282
+ const name = error instanceof Error ? error.name : "";
283
+ if (name === "TimeoutError" || name === "AbortError") {
284
+ return { ok: false, balance, code: "balanceTimeout" };
285
+ }
286
+ return { ok: false, balance, code: "balanceNetwork" };
287
+ }
288
+
289
+ if (!response.ok) return { ok: false, balance, code: "balanceHttp", status: response.status };
290
+
291
+ let body;
292
+ try {
293
+ body = JSON.parse(text);
294
+ } catch {
295
+ return { ok: false, balance, code: "balancePayload" };
296
+ }
297
+
298
+ const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
299
+ const first = infos[0] || {};
300
+ const total = toFinite(first.total_balance);
301
+ balance.isAvailable = body?.is_available !== false;
302
+ balance.currency = typeof first.currency === "string" ? first.currency : null;
303
+ // total 缺失或非法时保留 null,由调用方当成"没拿到余额",不要写 NaN(JSON 会变成
304
+ // null,客户端 Number(null) 是 0,会把"读不到"显示成"余额 0 元")。
305
+ balance.total = Number.isFinite(total) ? total : null;
306
+ if (balance.total === null) return { ok: false, balance, code: "balanceEmpty" };
307
+ if (balance.isAvailable === false) return { ok: false, balance, code: "balanceUnavailable" };
308
+ return { ok: true, balance, code: null };
309
+ }
310
+
311
+ /**
312
+ * 余额快照缓存:同一时间窗内多个请求(多标签页、点击、心跳)共用一次上游调用。
313
+ *
314
+ * 只缓存成功结果——失败必须立刻能被下一次请求重试。缓存与在途请求都按 Key 值
315
+ * 分桶:换了 Key 就不会命中旧 Key 的缓存,也不会复用旧 Key 的在途请求
316
+ * (否则会短暂显示上一个 Key 的余额)。
317
+ */
318
+ const balanceCache = new Map();
319
+ const balanceInFlight = new Map();
320
+
321
+ /** 丢掉过期的缓存条目:换 Key 频繁时 Map 会一直长。 */
322
+ function evictStaleCache(now) {
323
+ for (const [key, entry] of balanceCache) {
324
+ if (now - entry.at >= BALANCE_CACHE_MS) balanceCache.delete(key);
325
+ }
326
+ }
327
+
328
+ async function readBalanceCached(ctx, lang) {
329
+ const now = Date.now();
330
+ let key = "";
331
+ try {
332
+ const hit = await ctx.credentials.resolve(API_KEY_REF);
333
+ if (hit === void 0) return { ok: false, balance: { isAvailable: null, total: null, currency: null }, code: "noKey" };
334
+ key = typeof hit.value === "string" ? hit.value.trim() : "";
335
+ } catch {
336
+ return { ok: false, balance: { isAvailable: null, total: null, currency: null }, code: "balanceNetwork" };
337
+ }
338
+ if (!isUsableKey(key)) {
339
+ return { ok: false, balance: { isAvailable: null, total: null, currency: null }, code: "badKey" };
340
+ }
341
+ evictStaleCache(now);
342
+ const cached = balanceCache.get(key);
343
+ if (cached !== undefined && now - cached.at < BALANCE_CACHE_MS) return cached.value;
344
+ const pending = balanceInFlight.get(key);
345
+ if (pending !== undefined) return pending;
346
+ const request = fetchBalance(ctx, lang, key)
347
+ .then((value) => {
348
+ if (value.ok) balanceCache.set(key, { at: Date.now(), value });
349
+ return value;
350
+ })
351
+ .finally(() => {
352
+ balanceInFlight.delete(key);
353
+ });
354
+ balanceInFlight.set(key, request);
355
+ return request;
356
+ }
357
+
358
+ function apply(ctx) {
359
+ const bindHost = typeof ctx.webServer?.host === "string" ? ctx.webServer.host : "";
360
+
361
+ ctx.effect(
362
+ () => ctx.webServer.register({
363
+ kind: "exact",
364
+ path: HOLIDAYS_PATH,
365
+ handler: async (req, res) => {
366
+ if (req.method !== "GET" && req.method !== "HEAD") {
367
+ return sendJson(res, 405, { ok: false, error: "method", message: MESSAGES.zh.method });
368
+ }
369
+ if (!isTrustedRequest(req, bindHost)) {
370
+ return sendJson(res, 403, { ok: false, error: "forbidden", message: MESSAGES.zh.forbidden });
371
+ }
372
+ sendJson(res, 200, { ok: true, ranges: HOLIDAY_RANGES });
373
+ },
374
+ }),
375
+ "dsh-deepseek-usage: holidays route",
376
+ );
377
+
378
+ ctx.effect(
379
+ () => ctx.webServer.register({
380
+ kind: "exact",
381
+ path: ROUTE_PATH,
382
+ handler: async (req, res) => {
383
+ try {
384
+ if (req.method !== "GET" && req.method !== "HEAD") {
385
+ return sendJson(res, 405, { ok: false, error: "method", message: MESSAGES.zh.method });
386
+ }
387
+ // ---- 仅本机访问:拦截 DNS rebinding、跨站读取与局域网直连 ----
388
+ if (!isTrustedRequest(req, bindHost)) {
389
+ return sendJson(res, 403, { ok: false, error: "forbidden", message: MESSAGES.zh.forbidden });
390
+ }
391
+
392
+ const { lang } = resolveQuery(req);
393
+ const msg = MESSAGES[lang] || MESSAGES.zh;
394
+
395
+ // 放假日期表过期时告警(每天一次)。放在请求路径上,跨年长跑也能覆盖到。
396
+ try {
397
+ warnHolidayCoverage(ctx);
398
+ } catch { /* 告警失败不影响卡片 */ }
399
+
400
+ // ---- 当前模型当前档单价(展示用)----
401
+ let model = DEFAULT_MODEL;
402
+ let provider = "";
403
+ try {
404
+ const adm = ctx.get("agentDefaultModel");
405
+ const selection = adm && typeof adm.currentSelection === "function" ? adm.currentSelection() : void 0;
406
+ if (selection && typeof selection.model === "string") model = selection.model;
407
+ if (selection && typeof selection.provider === "string") provider = selection.provider;
408
+ } catch {}
409
+ // 时刻在余额请求之后再取:请求耗时会跨过峰谷边界,先取会让 peak / 单价 /
410
+ // 倒计时三者互相矛盾(显示橙色高峰灯却给着空闲价)。
411
+ const now = Date.now();
412
+ const unit = unitAt(model, now, provider);
413
+
414
+ const errors = [];
415
+
416
+ // ---- 余额(官方接口,API Key)----
417
+ const snapshot = await readBalanceCached(ctx, lang);
418
+ const balance = snapshot.balance;
419
+ if (!snapshot.ok) {
420
+ const code = snapshot.code;
421
+ errors.push(
422
+ code === "balanceHttp"
423
+ ? msg.balanceHttp(snapshot.status)
424
+ : typeof msg[code] === "function"
425
+ ? msg[code]()
426
+ : msg[code] ?? msg.balanceNetwork,
427
+ );
428
+ }
429
+
430
+ // ---- 距下一次峰谷切换(界面用它在指示灯旁显示倒计时)----
431
+ const next = nextChange(now);
432
+
433
+ sendJson(res, 200, {
434
+ ok: true,
435
+ balance,
436
+ peak: isPeak(now),
437
+ // peak = 切换后所处的时段,at = 切换时刻,ms = 剩余毫秒
438
+ next,
439
+ tier: {
440
+ input: unit.input,
441
+ cacheRead: unit.cacheRead,
442
+ cacheWrite: unit.cacheWrite,
443
+ output: unit.output,
444
+ model: unit.model,
445
+ known: unit.known,
446
+ },
447
+ // 【内部诊断】取价路径:界面不再展示(卡片去技术化),保留字段
448
+ // 供排查问题与回归测试使用。
449
+ pricing: {
450
+ via: unit.via, // exact | alias | fallback
451
+ requested: unit.requested, // 当前启用的模型 id
452
+ provider: unit.provider, // 当前路由的 provider id
453
+ used: unit.model, // 实际取价使用的模型 id
454
+ known: unit.known, // false = 不计价,界面隐藏单价
455
+ },
456
+ updatedAt: now,
457
+ errors,
458
+ });
459
+ } catch (error) {
460
+ ctx.logger.warn("dsh-deepseek-usage: request failed");
461
+ ctx.logger.warn(error);
462
+ sendJson(res, 502, {
463
+ ok: false,
464
+ error: "internal",
465
+ message: MESSAGES.zh.internal,
466
+ });
467
+ }
468
+ },
469
+ }),
470
+ "dsh-deepseek-usage: route",
471
+ );
472
+ }
473
+
474
+ export { name, inject, apply };
package/lib/pricing.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * dsh-deepseek-usage — 定价表(元 / 百万 tokens)。
3
+ *
4
+ * 价格唯一来源:下方 PRICE_TABLE 内置表——DeepSeek 官方峰谷定价快照
5
+ * (2026-09-10 官方价格页)。官方调价时随插件版本更新本表。
6
+ *
7
+ * 模型改名(0.6.0 起):官方会把模型改名并把旧名保留为别名(如
8
+ * deepseek-v4-flash / deepseek-v4-flash-vision-exp → deepseek-flash)。旧名若
9
+ * 落到内置表就会长期显示过期价且无从察觉,因此 MODEL_ALIASES 维护"旧名 → 现名"
10
+ * 的显式映射,unitAt() 的 via 字段标出来源,供排查,避免静默算错。
11
+ *
12
+ * 不做"同家族唯一匹配"(flash / pro / chat / reasoner):家族词没有词边界,
13
+ * deepseek-v5-pro、deepseek-prompt-helper 都会命中 pro 并按上代价格计价;官方发布
14
+ * 下一代模型后,卡片会自信地显示旧价而不是隐藏。表里没有的模型一律 known=false。
15
+ *
16
+ * 只对 DeepSeek 官方路由 + 官方模型计价(isOfficialModel):第三方供应商的模型、
17
+ * 路由器把供应商名当模型名上报的情况(codebuddy、workbuddy 的 deepseek-v4.1-flash)
18
+ * 一律 known=false,界面据此隐藏档单价与峰谷指示灯——官方价格表与峰谷时段对它们不成立。
19
+ *
20
+ * 峰谷(官方口径):高峰时段为北京时间周一至周五(**不含中国法定节假日**)
21
+ * 9:00–12:00、14:00–18:00,其余(含整个周末、法定节假日全天、以及调休上班的
22
+ * 周末)为空闲时段;缓存写暂不计费(cacheWrite = 0)。
23
+ * 因此只有"落在工作日里的法定节假日"会改变判定结果——周末无论是否调休上班都
24
+ * 是空闲,见 HOLIDAY_RANGES。
25
+ *
26
+ * 本文件用于卡片"当前档单价"展示、峰谷指示灯,以及 nextChange() 给出的
27
+ * 「距下次峰谷切换倒计时」(卡片上显示为 `2d 15h` / `5h 47m` 这类英文单位时长)。
28
+ *
29
+ * 时区(0.1.7 起):峰谷判定与"每日"边界均按北京时间(Asia/Shanghai,固定
30
+ * UTC+8,无夏令时)计算,不随服务器本地时区变化——在任何时区部署的 DSH 上
31
+ * 显示一致。
32
+ */
33
+
34
+ /** 北京时间固定偏移(上海无夏令时)。 */
35
+ export const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
36
+
37
+ /**
38
+ * 价格表:input(缓存未命中)/ cacheRead(缓存命中)/ output 均为 [高峰, 空闲]
39
+ * 二元组;cacheWrite 为常量 0。官方改价时更新本表并发布新版本。
40
+ */
41
+ const PRICE_TABLE = {
42
+ 'deepseek-flash': { input: [2, 1], cacheRead: [0.04, 0.02], cacheWrite: 0, output: [8, 4] },
43
+ 'deepseek-v4-pro': { input: [9, 4.5], cacheRead: [0.3, 0.15], cacheWrite: 0, output: [27, 13.5] },
44
+ 'deepseek-chat': { input: [2, 2], cacheRead: [0.5, 0.5], cacheWrite: 0, output: [8, 8] },
45
+ 'deepseek-reasoner': { input: [4, 4], cacheRead: [1, 1], cacheWrite: 0, output: [16, 16] },
46
+ };
47
+
48
+ /**
49
+ * 旧模型名 → 现模型名(官方改名后旧名仍可调用,且按现模型价格计费)。
50
+ * 见官方价格页脚注:deepseek-v4-flash / deepseek-v4-flash-vision-exp 由
51
+ * DeepSeek-V4.1-Flash 提供服务,按 Flash 价格计费。
52
+ */
53
+ const MODEL_ALIASES = {
54
+ 'deepseek-v4-flash': 'deepseek-flash',
55
+ 'deepseek-v4-flash-vision-exp': 'deepseek-flash',
56
+ };
57
+
58
+ /** 默认模型(当 agentDefaultModel 不可用时的展示默认)。 */
59
+ export const DEFAULT_MODEL = 'deepseek-flash';
60
+
61
+ /**
62
+ * DeepSeek 官方模型路由的 provider id。官方有两条:API Key 路由
63
+ * (`@deepseek-ai/dsh-llm-deepseek-api-key`)与账号路由
64
+ * (`@deepseek-ai/dsh-llm-deepseek-account`)。
65
+ *
66
+ * 只按模型名前缀判官方是不够的:Qwen token-plan、NVIDIA、opencode、workbuddy 这些
67
+ * 第三方路由上都有 `deepseek-*` 同名模型,它们按各自的计价规则扣费。provider 缺失
68
+ * (旧宿主、路由未上报)时按"非官方"处理——宁可隐藏价格,也不显示一个与实际账单
69
+ * 无关的数字。
70
+ */
71
+ export const OFFICIAL_PROVIDERS = ['deepseek-official', 'deepseek-account'];
72
+
73
+ /** 高峰时段窗口(北京时间,含起点不含终点)。 */
74
+ export const PEAK_WINDOWS = [[9, 12], [14, 18]];
75
+ /** 官方口径:高峰仅限周一至周五(北京时间),周末整天为空闲时段。 */
76
+ export const PEAK_WEEKDAYS_ONLY = true;
77
+
78
+ /**
79
+ * 中国法定节假日放假日期(含起止,闭区间;北京时间的日历日)。
80
+ * 来源:国务院办公厅《关于 2026 年部分节假日安排的通知》(国办发明电〔2025〕7号)。
81
+ * 官方口径:法定节假日全天、调休上班的周末均为空闲时段——周末本来就空闲,所以
82
+ * 只需列"放假日期",落在工作日里的那几天才会把判定从高峰翻成空闲。
83
+ * 下一年的安排通常在前一年 11 月发布,届时更新本表与 HOLIDAY_TABLE_THROUGH_YEAR;
84
+ * 当前年份未被覆盖时由 lib/index.js 告警(见 hasHolidayCoverage)。
85
+ */
86
+ export const HOLIDAY_RANGES = [
87
+ ['2026-01-01', '2026-01-03'], // 元旦
88
+ ['2026-02-15', '2026-02-23'], // 春节
89
+ ['2026-04-04', '2026-04-06'], // 清明节
90
+ ['2026-05-01', '2026-05-05'], // 劳动节
91
+ ['2026-06-19', '2026-06-21'], // 端午节
92
+ ['2026-09-25', '2026-09-27'], // 中秋节
93
+ ['2026-10-01', '2026-10-07'], // 国庆节
94
+ ];
95
+
96
+ /**
97
+ * 把 [起, 止] 闭区间展开成日期键集合(`yyyy-mm-dd`,O(1) 查表)。
98
+ *
99
+ * 非法日期(如 2027-02-30)会被 Date.parse 滚到 03-02,把一个不存在的日子变成
100
+ * 真的假日。这里先校验解析结果与输入一致,不一致就跳过该段。
101
+ */
102
+ export function expandHolidayRanges(ranges) {
103
+ const set = new Set();
104
+ const DAY = 24 * 60 * 60 * 1000;
105
+ for (const [from, to] of ranges) {
106
+ if (typeof from !== 'string' || typeof to !== 'string') continue;
107
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) continue;
108
+ const start = Date.parse(`${from}T00:00:00Z`);
109
+ const end = Date.parse(`${to}T00:00:00Z`);
110
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
111
+ // 解析结果必须与输入同一天,否则说明输入本身不是合法日期(已滚动到别的日子)。
112
+ if (new Date(start).toISOString().slice(0, 10) !== from) continue;
113
+ if (new Date(end).toISOString().slice(0, 10) !== to) continue;
114
+ for (let t = start; t <= end; t += DAY) set.add(new Date(t).toISOString().slice(0, 10));
115
+ }
116
+ return set;
117
+ }
118
+
119
+ const HOLIDAY_DAYS = expandHolidayRanges(HOLIDAY_RANGES);
120
+
121
+ /** 某时刻的北京时间日期键(`yyyy-mm-dd`)。 */
122
+ function beijingDateKey(ms) {
123
+ return new Date(ms + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10);
124
+ }
125
+
126
+ /**
127
+ * 放假日期表覆盖到的年份(含)。
128
+ *
129
+ * 国务院办公厅通常在前一年 11 月发布次年安排,届时更新 HOLIDAY_RANGES 并把这个
130
+ * 常量 +1。不能用"表里有没有这一年的某一天"来判定覆盖:只补一条元旦记录就会让
131
+ * 判定通过,而春节、国庆仍然按工作日计价。
132
+ */
133
+ export const HOLIDAY_TABLE_THROUGH_YEAR = 2026;
134
+
135
+ /** 该时刻所在的北京时间年份是否已被放假日期表覆盖。 */
136
+ export function hasHolidayCoverage(ms = Date.now()) {
137
+ const year = new Date(ms + SHANGHAI_OFFSET_MS).getUTCFullYear();
138
+ return year <= HOLIDAY_TABLE_THROUGH_YEAR;
139
+ }
140
+
141
+ /** 某时刻是否为高峰时段(按北京时间;法定节假日全天与周末恒为空闲)。
142
+ *
143
+ * `holidays` 可选:浏览器侧镜像无法 import 本模块,测试与宿主以外的调用方可以传入
144
+ * 自己的日期集合;缺省用内置表,保证 `isPeak(ms)` 的既有语义不变。
145
+ */
146
+ export function isPeak(ms, holidays = HOLIDAY_DAYS) {
147
+ const now = Number.isFinite(ms) ? ms : Date.now();
148
+ if (holidays.has(beijingDateKey(now))) return false; // 法定节假日:全天空闲
149
+ const d = new Date(now + SHANGHAI_OFFSET_MS);
150
+ if (PEAK_WEEKDAYS_ONLY) {
151
+ const dow = d.getUTCDay(); // 0 = 周日
152
+ if (dow === 0 || dow === 6) return false;
153
+ }
154
+ const h = d.getUTCHours();
155
+ return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
156
+ }
157
+
158
+ /**
159
+ * 可能的切换时刻(北京时间的整点):高峰窗口的起止整点。
160
+ * 翻转只可能发生在这些整点上——窗口外的整点不改变 isPeak,工作日之外则整天不变。
161
+ */
162
+ const BOUNDARY_HOURS = [...new Set(PEAK_WINDOWS.flat())].sort((a, b) => a - b);
163
+
164
+ /**
165
+ * 距下一个计费时段边界的剩余时间(供卡片显示倒计时)。
166
+ *
167
+ * 与 isPeak 同源同口径(北京时间、工作日、PEAK_WINDOWS 半开区间),只是换一个视图:
168
+ * 返回「下一次 isPeak 结果发生翻转」的时刻。注意空闲时段里的下一次切换**不等于**下一个
169
+ * 整点:
170
+ * · 12:00–14:00 午休 → 下一次切换是 14:00(高峰开始);
171
+ * · 周五 18:00 之后、整个周末 → 下一次切换是下周一 09:00(跨 2 天多)。
172
+ * 因此倒计时必须能显示「天」。
173
+ *
174
+ * 实现:候选时刻就是 BOUNDARY_HOURS 落在各天的那些整点,从今天起逐日枚举、取第一个
175
+ * 让 isPeak 翻转的,因此返回的是**精确**时刻。纯算术,无时区/日历依赖。
176
+ * (旧实现逐分钟探测,返回的 at 最多晚 59.75 秒,倒计时会因此多显示 1m 或
177
+ * 提前进入 switching。)
178
+ *
179
+ * 任何口径变更必须同时改 isPeak / PEAK_WINDOWS / PEAK_WEEKDAYS_ONLY / HOLIDAY_RANGES,
180
+ * 以及 lib/client.js 里为浏览器侧镜像的同名常量(那边无法 import 本模块)。
181
+ *
182
+ * @returns {{ peak: boolean, at: number, ms: number }} peak=切换后所处的时段,
183
+ * at=切换发生的时刻(ms 时间戳,新时段在此刻生效),ms=剩余毫秒(不会为负)。
184
+ * 找不到切换点时返回 `at: null`——调用方据此判断"不知道",不要把它当成"此刻"。
185
+ */
186
+ export function nextChange(ms, holidays = HOLIDAY_DAYS) {
187
+ const now = Number.isFinite(ms) ? ms : Date.now();
188
+ const current = isPeak(now, holidays);
189
+ const DAY = 24 * 60 * 60 * 1000;
190
+ const hourMs = 60 * 60 * 1000;
191
+ // 北京时间的今天零点
192
+ const dayStart = Date.parse(`${beijingDateKey(now)}T00:00:00Z`) - SHANGHAI_OFFSET_MS;
193
+ // 上限 32 天:远超"长假最后一天 18:00 → 假期后第一个工作日 09:00"的最长空档
194
+ // (2026 年春节假期 9 天,空档 10.7 天)
195
+ for (let day = 0; day < 32; day += 1) {
196
+ const base = dayStart + day * DAY;
197
+ for (const hour of BOUNDARY_HOURS) {
198
+ const at = base + hour * hourMs;
199
+ if (at <= now) continue;
200
+ const peak = isPeak(at, holidays);
201
+ if (peak !== current) return { peak, at, ms: at - now };
202
+ }
203
+ }
204
+ // 理论上不可达(不变量:一个月内必有工作日整点边界)。返回 at:null 而不是 now,
205
+ // 否则调用方会显示 switching 并永久卡住。
206
+ return { peak: !current, at: null, ms: 0 };
207
+ }
208
+
209
+ /**
210
+ * 是否是 DeepSeek 官方路由上的官方模型(官方价格表与峰谷时段只对它们成立)。
211
+ *
212
+ * 两道条件缺一不可:
213
+ * 1. provider 是官方两条路由之一(见 OFFICIAL_PROVIDERS)。Qwen token-plan、
214
+ * NVIDIA、opencode、workbuddy 这些第三方路由上都有 `deepseek-*` 同名模型,
215
+ * 它们按各自的计价规则扣费,用官方表算出来的数字与账单无关。
216
+ * 2. 模型名以 `deepseek-` / `deepseek.` 开头,排除同一路由下的其它模型。
217
+ *
218
+ * provider 缺失(旧宿主、路由未上报)时按非官方处理:宁可隐藏,也不显示错价。
219
+ */
220
+ export function isOfficialModel(model, provider) {
221
+ if (typeof provider !== 'string' || !OFFICIAL_PROVIDERS.includes(provider.trim().toLowerCase())) return false;
222
+ return typeof model === 'string' && /^deepseek[-.]/i.test(model.trim());
223
+ }
224
+
225
+ const has = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
226
+
227
+ /** 别名(旧名 → 现名)。 */
228
+ function aliasOf(id) {
229
+ if (typeof id !== 'string' || id === '') return undefined;
230
+ return MODEL_ALIASES[id];
231
+ }
232
+
233
+ /** 在价格表里找模型:精确 → 别名。表里没有就是不知道,不做家族猜测。 */
234
+ function lookupId(id) {
235
+ if (typeof id !== 'string' || id === '') return null;
236
+ const key = id.trim().toLowerCase();
237
+ if (has(PRICE_TABLE, key)) return { id: key, via: 'exact' };
238
+ const alias = aliasOf(key);
239
+ if (alias && has(PRICE_TABLE, alias)) return { id: alias, via: 'alias' };
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * 解析模型的计价条目:精确 → 别名 → chat 档兜底(known=false)。
245
+ * 非官方模型(见 isOfficialModel)不查表,直接按 known=false 返回,由调用方隐藏
246
+ * 档单价与峰谷指示灯。
247
+ * @returns {{ price: object, id: string, via: 'exact'|'alias'|'fallback', known: boolean }}
248
+ */
249
+ function lookup(model, provider) {
250
+ const found = isOfficialModel(model, provider) ? lookupId(model) : null;
251
+ if (found) return { price: PRICE_TABLE[found.id], id: found.id, via: found.via, known: true };
252
+ return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', known: false };
253
+ }
254
+
255
+ /**
256
+ * 某模型在某一时刻的单位价格(元/百万 tokens)。
257
+ * 非官方模型与不在表里的官方模型都会走 chat 档兜底,但 known=false 会把这件事告诉
258
+ * 调用方:那份兜底价与它们的实际账单无关,界面应隐藏。
259
+ * via 说明取价路径(exact / alias / fallback),供排查与回归测试。
260
+ */
261
+ export function unitAt(model, ms, provider, holidays = HOLIDAY_DAYS) {
262
+ const found = lookup(model, provider);
263
+ const p = found.price;
264
+ const tier = isPeak(ms, holidays) ? 0 : 1;
265
+ return {
266
+ input: p.input[tier],
267
+ cacheRead: p.cacheRead[tier],
268
+ cacheWrite: p.cacheWrite,
269
+ output: p.output[tier],
270
+ model: found.id,
271
+ requested: typeof model === 'string' ? model : '',
272
+ provider: typeof provider === 'string' ? provider : '',
273
+ known: found.known,
274
+ via: found.via,
275
+ };
276
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@nomxy/dsh-balance",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Compact DeepSeek balance + peak/off-peak chip in the sidebar name row. · 侧边栏名字旁的 DeepSeek 余额与峰谷小胶囊。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "icon": "icon.svg",
8
+ "main": "lib/index.js",
9
+ "exports": {
10
+ ".": "./lib/index.js",
11
+ "./client": "./lib/client.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE",
19
+ "icon.svg"
20
+ ],
21
+ "keywords": [
22
+ "dsh",
23
+ "dsh-plugin",
24
+ "deepseek",
25
+ "deepseek-harness",
26
+ "usage",
27
+ "balance",
28
+ "billing",
29
+ "sidebar",
30
+ "ui"
31
+ ],
32
+ "engines": {
33
+ "dsh": ">=0.2.1-rc.1"
34
+ },
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "platform": "web"
41
+ }
42
+ }
43
+ }