@dougen/dsh-deepseek-usage 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -20
- package/lib/client.js +103 -16
- package/lib/index.js +182 -14
- package/lib/pricing-store.js +253 -0
- package/lib/pricing.js +98 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,21 +10,23 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
|
|
|
10
10
|
|
|
11
11
|
- **Balance**: `¥ 110.00` large display (symbol follows account currency: `¥` CNY / `$` USD)
|
|
12
12
|
- **Unit pricing**: `→|1.5 ⚡0.05 |→4.5` (input / input cache / output, bottom-right of the card; switches with the active model and peak/off-peak window — **updates instantly when you switch models**)
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
13
|
+
- **Editable price table**: prices live in a JSON file (`<DSH_HOME>/dsh-deepseek-usage/pricing.json`), not in code. The plugin reads it at startup, so the card prices correctly **immediately after a restart**, and a successful official sync is written back into it. Hand-edit it for a gateway/manual pricing; changes apply without restarting (`autoUpdate: false` also turns the automatic sync off completely)
|
|
14
|
+
- **Auto price sync**: prices are re-fetched from the official DeepSeek pricing page on first use each day and when you click the card; the result is merged back into the price table (official entries overwrite same-named ones, your own entries are kept). The page is parsed **structurally (HTML table cells)**, not by matching surrounding text, so official layout/rewording changes no longer break it; a plain-text strategy remains as a fallback
|
|
15
|
+
- **Rename-proof model mapping**: when DeepSeek renames a model (e.g. `deepseek-v4-flash` → `deepseek-flash`) the old id is charged at the current model's price, and the card notes `Priced as deepseek-flash` instead of silently showing an outdated price
|
|
16
|
+
- **Visible sync state**: if the official page cannot be fetched/parsed, the card is blanked and says which prices it is using (hover for the error and the last sync time) instead of quietly showing stale numbers
|
|
16
17
|
- **Peak indicator**: top-right dot, orange during peak hours (Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time), green otherwise
|
|
17
|
-
- **Auto refresh**: every 5 minutes; click the card to refresh instantly
|
|
18
|
+
- **Auto refresh**: every 5 minutes; click the card to refresh instantly — plus a few quick retries while the host is still syncing, so the card converges within seconds
|
|
18
19
|
- **i18n**: UI copy follows the DSH locale (Chinese / English)
|
|
19
20
|
- Follows the app light/dark theme
|
|
20
21
|
|
|
21
22
|
- **余额**:`¥ 110.00` 大字显示(符号随账号币种自动切换:人民币 `¥` / 美元 `$`)
|
|
22
23
|
- **档单价**:`→|1.5 ⚡0.05 |→4.5`(输入 / 输入缓存 / 输出,位于卡片右下角;随当前启用模型与峰谷时段自动切换,**切换模型时立即同步**)
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
24
|
+
- **可编辑的价格表**:价格不再写在代码里,而是存在 JSON 文件 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`。插件启动时先读它,所以**重启后卡片立刻就是正确价格**;官方同步成功后会自动回写到这份表里。手改该文件即可用于中转/手动计价,改完**无需重启**(`autoUpdate: false` 还能彻底关掉自动同步)
|
|
25
|
+
- **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价,并把结果**合并回写**到价格表(官方条目覆盖同名项,你自己加的条目保留)。页面解析采用**结构化表格解析**(直接读 HTML 表格单元格),不再依赖"正文里某段文字长什么样",官方改版/改文案不会失效;另保留纯文本兜底策略
|
|
26
|
+
- **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash`)时,旧名按现名价格计费,卡片会注明 `按 deepseek-flash 计价`,不会静默显示旧价
|
|
27
|
+
- **同步状态可见**:官方页抓取/解析失败时,卡片会置空并说明当前用的是哪份价(悬停可看具体错误与上次同步时间),而不是悄悄显示旧价
|
|
26
28
|
- **峰谷指示灯**:卡片右上角,高峰(周一至周五 9:00–12:00、14:00–18:00 北京时间)橙色,空闲绿色
|
|
27
|
-
- **自动刷新**:每 5
|
|
29
|
+
- **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新;宿主正在后台同步时还会在几秒内快速补几次请求,卡片很快收敛到最新官方价
|
|
28
30
|
- **多语言**:界面文案跟随 DSH 语言(中文 / English)
|
|
29
31
|
- 跟随应用明暗主题
|
|
30
32
|
|
|
@@ -53,17 +55,51 @@ Restart the web app (`dsh web`), open http://127.0.0.1:3080 , and the usage card
|
|
|
53
55
|
| Data | Source |
|
|
54
56
|
|---|---|
|
|
55
57
|
| Balance | Official DeepSeek API `GET /user/balance` (uses `DEEPSEEK_API_KEY` configured in DSH) |
|
|
56
|
-
| Unit pricing
|
|
58
|
+
| Unit pricing | Local price table `<DSH_HOME>/dsh-deepseek-usage/pricing.json` (read at startup; editable). Refreshed from the official page ([api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)) on first daily use or on card click, then written back. Parsed from the HTML **table cells** (text fallback available); the built-in snapshot in code is only the seed for the very first run |
|
|
59
|
+
| Peak window | Built-in official rule: Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time (weekends are always off-peak) |
|
|
57
60
|
|
|
58
61
|
| 数据 | 来源 |
|
|
59
62
|
|---|---|
|
|
60
63
|
| 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
|
|
61
|
-
| 档单价
|
|
64
|
+
| 档单价 | 本地价格表 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(启动即读取,可手动编辑);每日首次使用或点击卡片时从官方价格页 [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) 同步并**回写**该表。官方页解析**读取 HTML 表格单元格**(另有纯文本兜底策略);代码里的内置快照只在首次运行时用于生成价格表 |
|
|
65
|
+
| 峰谷时段 | 内置官方口径:周一至周五 9:00–12:00、14:00–18:00(北京时间),周末恒为空闲 |
|
|
62
66
|
|
|
63
67
|
Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before use.
|
|
64
68
|
|
|
65
69
|
使用前请确保 DSH 已配置 `DEEPSEEK_API_KEY`(可在 DSH 设置中的模型页面配置)。
|
|
66
70
|
|
|
71
|
+
## Price table / 价格表
|
|
72
|
+
|
|
73
|
+
`<DSH_HOME>/dsh-deepseek-usage/pricing.json` (default `~/.dsh/dsh-deepseek-usage/pricing.json`; override with `DEEPSEEK_USAGE_PRICE_FILE`). Created from the built-in snapshot on first run, then maintained by the plugin:
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
{
|
|
77
|
+
"autoUpdate": true, // false = manual mode: never fetch, use this table as-is
|
|
78
|
+
"updatedAt": 1789090582880, // last write (ms or ISO string)
|
|
79
|
+
"updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
80
|
+
"strategy": "table", // table | flat | seed
|
|
81
|
+
"aliases": { "deepseek-v4-flash": "deepseek-flash" }, // old id → current id
|
|
82
|
+
"models": {
|
|
83
|
+
// input / cacheRead / output are [peak, off-peak] in CNY per 1M tokens
|
|
84
|
+
"deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- Full-line `//` comments are allowed; a bare `{"model-id": {...}}` map (no `models` wrapper) also works.
|
|
90
|
+
- Every successful official sync **merges** into this file: official entries overwrite same-named ones, your own entries stay.
|
|
91
|
+
- Edits are picked up without restarting the plugin (the file's mtime is checked per request).
|
|
92
|
+
- If the file is corrupt, the plugin keeps the last good prices and **never overwrites** it — fix it and it reloads.
|
|
93
|
+
- The card's peak-dot tooltip shows the file path and the time the displayed prices came from.
|
|
94
|
+
|
|
95
|
+
价格表文件位于 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(默认 `~/.dsh/dsh-deepseek-usage/pricing.json`,可用 `DEEPSEEK_USAGE_PRICE_FILE` 覆盖)。首次运行由内置快照生成,之后由插件维护:
|
|
96
|
+
|
|
97
|
+
- 允许**整行** `//` 注释;也接受省略 `models` 外壳的简写形式 `{"deepseek-flash": {...}}`。
|
|
98
|
+
- 每次官方同步成功都会**合并回写**本文件:官方页列出的模型覆盖同名条目,你自己加的条目保留。
|
|
99
|
+
- 手改后无需重启(每次请求会比对文件 mtime),改完点一下卡片即生效。
|
|
100
|
+
- 文件损坏时不会覆盖你的内容,会继续用上一次的价格并写日志;修好后自动重载。
|
|
101
|
+
- 卡片峰谷灯的悬停提示里会显示该文件路径,以及当前这份价是什么时候同步的。
|
|
102
|
+
|
|
67
103
|
## FAQ / 常见问题
|
|
68
104
|
|
|
69
105
|
**Balance shows "—"? / 余额显示"—"?**
|
|
@@ -76,25 +112,33 @@ Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before u
|
|
|
76
112
|
|
|
77
113
|
**Price differs from the official one? / 单价与官方价格不一致?**
|
|
78
114
|
|
|
79
|
-
- The unit price switches with the **active model** and the **peak/off-peak window
|
|
80
|
-
-
|
|
81
|
-
- *Prices not synced (built-in table)* — the official page could not be fetched/parsed; click the card to retry (hover
|
|
115
|
+
- The unit price switches with the **active model** and the **peak/off-peak window**. Prices come from the local price table (last successful official sync, or your own edits); the plugin re-syncs the official page on first daily use or on card click and writes the result back into that table.
|
|
116
|
+
- If a notice or an error appears, the card is blanked: balance, unit prices and the peak dot are hidden and only the notice text is shown (hover it for the exact error / last sync time). Purely informational notices (priced as a renamed model) are dismissable: **click the card to flip back to the normal balance/price card** — the notice stays reachable from the peak dot's tooltip. Sync failures and API errors always keep the card blanked; there, clicking only refreshes. The text tells you where the number comes from:
|
|
117
|
+
- *Prices not synced (built-in table)* — no usable price table **and** the official page could not be fetched/parsed; click the card to retry (hover for the error).
|
|
118
|
+
- *Sync failed; using last prices (09-11 09:36)* — the price table has the last good official prices; only the refresh failed.
|
|
82
119
|
- *Priced as `deepseek-flash`* — the active model id was renamed by DeepSeek; the current model's price is used.
|
|
83
|
-
- *Priced as built-in `…`* — the model is
|
|
120
|
+
- *Priced as built-in `…`* — the model is neither in the official page nor in the price table; the built-in entry is used.
|
|
121
|
+
- **After every `dsh` restart the card used to say "prices not synced (built-in table)"** — that was a race: sync state only lived in memory, so the first request after a restart was always answered from the built-in table. Since 0.7.0 the price table is persisted and read at startup, so restarts show real prices right away.
|
|
84
122
|
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
123
|
+
- 单价按**当前启用模型**与**峰谷时段**自动切换;价格取自本地价格表(上次成功同步到的官方价,或你自己改的价)。插件在每日首次使用或点击卡片时重新同步官方页,并把结果回写到该表。
|
|
124
|
+
- 一旦出现提示或错误,卡片会**置空**:不显示余额、档单价与峰谷灯,只显示提示文字(悬停看具体错误与上次同步时间)。其中纯说明性的良性提示(按现名计价)可以**点击卡片切回普通状态**(余额 + 档单价 + 峰谷灯),提示此时保留在峰谷灯的 hover 里。同步失败与接口错误属于异常,卡片始终置空(此时点击只刷新)。文字说明数字来自哪里:
|
|
125
|
+
- *价格未同步(用内置表)*:既没有可用价格表,官方页也抓不到;点击卡片可重试(悬停看具体错误)。
|
|
126
|
+
- *同步失败,仍用上次价格(09-11 09:36)*:价格表里是上次同步到的官方价,只是这次刷新失败了。
|
|
88
127
|
- *按 `deepseek-flash` 计价*:官方给该模型改名了,已按现名价格计费。
|
|
89
|
-
- *按内置 `…`
|
|
128
|
+
- *按内置 `…` 档计价*:该模型既不在官方价格页,也不在价格表里,用了内置条目。
|
|
129
|
+
- **以前每次 `dsh` 重启后卡片都会提示"价格未同步(用内置表)"**:那是竞态——同步状态只存在内存里,重启后的第一次请求只能用内置表回答。0.7.0 起价格表落盘并在启动时读取,重启后立即显示真实价格。
|
|
90
130
|
|
|
91
131
|
## Development / 开发与自检
|
|
92
132
|
|
|
93
133
|
```sh
|
|
94
|
-
npm test # 解析 +
|
|
134
|
+
npm test # 解析 + 定价 + 价格表 + 卡片布局 + 宿主路由(node --test)
|
|
95
135
|
npm run check:pricing # 联网抓取官方页并打印解析结果;失败时退出码非 0
|
|
96
136
|
```
|
|
97
137
|
|
|
138
|
+
模块划分:`lib/index.js` 宿主路由(同步节流、余额、状态透出)· `lib/pricing.js` 三层价格
|
|
139
|
+
(remote/file/seed)· `lib/pricing-store.js` 价格表读写(可编辑的 pricing.json)·
|
|
140
|
+
`lib/pricing-remote.js` 官方页抓取与解析 · `lib/client.js` 侧边栏卡片。
|
|
141
|
+
|
|
98
142
|
官方页再次改版时(自检失败 / 卡片提示"价格未同步"):
|
|
99
143
|
|
|
100
144
|
```sh
|
|
@@ -106,11 +150,18 @@ node lib/pricing-remote.js --check --save page.html
|
|
|
106
150
|
node lib/pricing-remote.js --check --html page.html --json
|
|
107
151
|
|
|
108
152
|
# 3. 把新页面存为 test/fixtures/official-pricing-zh.html,跑 npm test 防回归
|
|
153
|
+
# (若解析出的模型集合变了,再同步更新 lib/pricing.js 里的内置快照——
|
|
154
|
+
# 它只用于首次运行生成 pricing.json)
|
|
109
155
|
```
|
|
110
156
|
|
|
157
|
+
> 测试里的"重启"是给模块图打 `?graph=<tag>`(见 `test/host-route.test.mjs`),每个 tag
|
|
158
|
+
> 都拿到全新模块实例;peer 依赖 `@deepseek-ai/dsh-credentials` 由 `test/stubs/` 里的
|
|
159
|
+
> 替身接管,所以本仓库不需要 node_modules 也能跑宿主逻辑。
|
|
160
|
+
>
|
|
111
161
|
> DSH 沙箱内 `node --test test/` 可能因禁止子进程管道而报 `spawn EPERM`,此时用
|
|
112
162
|
> `node --test --test-isolation=none "test/*.test.mjs"`(Node ≥ 22.8),或逐个跑
|
|
113
|
-
> `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/
|
|
163
|
+
> `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/pricing-store.test.mjs`、
|
|
164
|
+
> `node test/client-layout.test.mjs`、`node test/host-route.test.mjs`。
|
|
114
165
|
|
|
115
166
|
## Uninstall / 卸载
|
|
116
167
|
|
package/lib/client.js
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 侧边栏底部用量卡片(注册进 `sidebar.footer.action` 槽位,独占一行,位于
|
|
4
4
|
// Cordis 面板按钮下方)。轮询宿主路由 `/api/deepseek-usage`(见 lib/index.js)
|
|
5
|
-
// 每 5
|
|
5
|
+
// 每 5 分钟一次,点击卡片任意位置可立即刷新;宿主还在后台同步官方价
|
|
6
|
+
// (pricing.syncing)时按 QUICK_RETRY_MS 阶梯快速补几次请求,卡片几秒内收敛。
|
|
6
7
|
// 卡片显示:余额(放大显示,符号随币种 ¥/$)+ 当前模型档单价(输入/输入缓存/
|
|
7
8
|
// 输出,图标 + 数字,位于卡片右下角)+ 峰谷指示灯(右上角)。
|
|
9
|
+
// 价格来源(宿主 pricing.source):remote=本次官方同步 / file=本地价格表
|
|
10
|
+
// (重启后即用它,属正常态)/ seed=内置兜底;峰谷灯 hover 里可看到来源、
|
|
11
|
+
// 上次同步时间与价格表文件路径。
|
|
8
12
|
// 样式仅使用 `--dsw-*` 主题变量(含兜底值),跟随明暗主题。
|
|
9
13
|
window.__ModuleLoader__.load({
|
|
10
14
|
id: "@dougen/dsh-deepseek-usage",
|
|
@@ -22,6 +26,9 @@ window.__ModuleLoader__.load({
|
|
|
22
26
|
// 常态卡片高度:上下 padding 8+8 + 余额行 lineHeight 28 + 边框 2 = 46。
|
|
23
27
|
// 异常态把卡片置空时用它兜住高度,侧边栏高度不跳动。
|
|
24
28
|
const CARD_MIN_HEIGHT = 46;
|
|
29
|
+
// 宿主正在后台同步官方价格(pricing.syncing)或本地接口刚重启时的快速重试节奏:
|
|
30
|
+
// 否则卡片要等满 5 分钟轮询才会从"本地价格表"变成最新官方价。
|
|
31
|
+
const QUICK_RETRY_MS = [4000, 8000, 15000, 30000];
|
|
25
32
|
|
|
26
33
|
// ---- i18n(DSH locale 服务;zh 为兜底)----
|
|
27
34
|
const NS = "dsh-deepseek-usage";
|
|
@@ -32,9 +39,12 @@ window.__ModuleLoader__.load({
|
|
|
32
39
|
"peak.peak": "高峰时段",
|
|
33
40
|
"peak.off": "空闲时段",
|
|
34
41
|
"price.stale": "价格未同步(用内置表)",
|
|
35
|
-
"price.lastGood": "
|
|
42
|
+
"price.lastGood": "同步失败,仍用上次价格({at})",
|
|
36
43
|
"price.alias": "按 {model} 计价",
|
|
37
44
|
"price.builtin": "按内置 {model} 档计价",
|
|
45
|
+
"price.file": "本地价格表({at} 同步)",
|
|
46
|
+
"price.manual": "本地价格表(自动同步已关闭)",
|
|
47
|
+
"price.filePath": "价格表:{path}",
|
|
38
48
|
};
|
|
39
49
|
const EN = {
|
|
40
50
|
"price.input": "Input",
|
|
@@ -43,9 +53,12 @@ window.__ModuleLoader__.load({
|
|
|
43
53
|
"peak.peak": "Peak hours",
|
|
44
54
|
"peak.off": "Off-peak hours",
|
|
45
55
|
"price.stale": "Prices not synced (built-in table)",
|
|
46
|
-
"price.lastGood": "Sync failed; using last prices",
|
|
56
|
+
"price.lastGood": "Sync failed; using last prices ({at})",
|
|
47
57
|
"price.alias": "Priced as {model}",
|
|
48
58
|
"price.builtin": "Priced as built-in {model}",
|
|
59
|
+
"price.file": "Local price table (synced {at})",
|
|
60
|
+
"price.manual": "Local price table (auto-sync off)",
|
|
61
|
+
"price.filePath": "Price table: {path}",
|
|
49
62
|
};
|
|
50
63
|
|
|
51
64
|
// ---- helpers ---------------------------------------------------
|
|
@@ -61,6 +74,15 @@ window.__ModuleLoader__.load({
|
|
|
61
74
|
return String(parseFloat(n.toFixed(2)));
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
/** 时间戳(ms)→ `MM-DD HH:mm`(本地时区);无效值返回空串。 */
|
|
78
|
+
function fmtWhen(value) {
|
|
79
|
+
const n = Number(value);
|
|
80
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
81
|
+
const d = new Date(n);
|
|
82
|
+
const p = (v) => String(v).padStart(2, "0");
|
|
83
|
+
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
// 主题变量(带兜底值)
|
|
65
87
|
const C = {
|
|
66
88
|
bg: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,.12))",
|
|
@@ -107,6 +129,9 @@ window.__ModuleLoader__.load({
|
|
|
107
129
|
// 状态(余额 + 单价 + 峰谷灯),再点切回提示。异常不受它影响,始终置空。
|
|
108
130
|
const [showNormal, setShowNormal] = useState(false);
|
|
109
131
|
const mounted = useRef(true);
|
|
132
|
+
// 快速重试:阶梯下标 + 定时器(宿主正在后台同步时,几秒后自己再看一眼)。
|
|
133
|
+
const retryStep = useRef(0);
|
|
134
|
+
const retryTimer = useRef(null);
|
|
110
135
|
const locale = ctx && ctx.locale;
|
|
111
136
|
|
|
112
137
|
// 订阅语言变化(DSH locale 服务;不可用时回退中文)。
|
|
@@ -127,6 +152,20 @@ window.__ModuleLoader__.load({
|
|
|
127
152
|
return ZH[key] || key;
|
|
128
153
|
};
|
|
129
154
|
|
|
155
|
+
// 价格还没到位时按 QUICK_RETRY_MS 阶梯补一次请求(封顶后回到 5 分钟轮询):
|
|
156
|
+
// 覆盖两种情况——宿主正在后台同步官方价(pricing.syncing)、本地接口刚重启/报错。
|
|
157
|
+
const scheduleRetry = () => {
|
|
158
|
+
if (!mounted.current) return;
|
|
159
|
+
const delay = QUICK_RETRY_MS[retryStep.current];
|
|
160
|
+
if (delay === undefined) return;
|
|
161
|
+
retryStep.current += 1;
|
|
162
|
+
if (retryTimer.current !== null) clearTimeout(retryTimer.current);
|
|
163
|
+
retryTimer.current = setTimeout(() => {
|
|
164
|
+
retryTimer.current = null;
|
|
165
|
+
load();
|
|
166
|
+
}, delay);
|
|
167
|
+
};
|
|
168
|
+
|
|
130
169
|
const load = useCallback(async (force) => {
|
|
131
170
|
try {
|
|
132
171
|
const res = await fetch(USAGE_PATH + "?lang=" + lang + (force ? "&refresh=1" : ""), { cache: "no-store" });
|
|
@@ -140,9 +179,15 @@ window.__ModuleLoader__.load({
|
|
|
140
179
|
if (!mounted.current) return;
|
|
141
180
|
setData(body);
|
|
142
181
|
setError("");
|
|
182
|
+
// 宿主还在拉官方价 → 价格只是"本地价格表"的历史值,几秒后再确认一次;
|
|
183
|
+
// 已经拿到官方价(或手动模式/同步失败)就不再补请求。
|
|
184
|
+
const pricing = body.pricing && typeof body.pricing === "object" ? body.pricing : null;
|
|
185
|
+
if (pricing && pricing.mode !== "manual" && pricing.syncing === true) scheduleRetry();
|
|
186
|
+
else retryStep.current = 0;
|
|
143
187
|
} catch (e) {
|
|
144
188
|
if (!mounted.current) return;
|
|
145
189
|
setError(e instanceof Error ? e.message : String(e));
|
|
190
|
+
scheduleRetry();
|
|
146
191
|
}
|
|
147
192
|
}, [lang]);
|
|
148
193
|
|
|
@@ -164,6 +209,10 @@ window.__ModuleLoader__.load({
|
|
|
164
209
|
return () => {
|
|
165
210
|
mounted.current = false;
|
|
166
211
|
clearInterval(timer);
|
|
212
|
+
if (retryTimer.current !== null) {
|
|
213
|
+
clearTimeout(retryTimer.current);
|
|
214
|
+
retryTimer.current = null;
|
|
215
|
+
}
|
|
167
216
|
try {
|
|
168
217
|
if (typeof off === "function") off();
|
|
169
218
|
} catch (e) { /* 忽略退订错误 */ }
|
|
@@ -175,22 +224,42 @@ window.__ModuleLoader__.load({
|
|
|
175
224
|
const pricing = data && data.pricing ? data.pricing : null;
|
|
176
225
|
const symbol = balance && balance.currency === "USD" ? "$" : "¥";
|
|
177
226
|
const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
|
|
178
|
-
// 价格来源提示:模型改名按现名计价 /
|
|
179
|
-
//
|
|
227
|
+
// 价格来源提示:模型改名按现名计价 / 价格来自本地价格表或内置表时明示,避免
|
|
228
|
+
// "官方已调价但卡片静静显示旧价"。
|
|
180
229
|
// friendly=true 表示只是"价格从哪来"的良性说明(可点击切回普通状态);
|
|
181
230
|
// friendly=false 表示同步失败/旧价兜底,属于异常,必须一直可见。
|
|
231
|
+
//
|
|
232
|
+
// 0.7.0 起价格有三层(宿主 pricing.source):
|
|
233
|
+
// remote = 本次已从官方页同步到;
|
|
234
|
+
// file = 本地价格表(上次同步到的官方价 / 用户手改价)→ 属于正常态,不置空卡片,
|
|
235
|
+
// 只有"这次同步确实失败"才提示,并写明这份价是什么时候的;
|
|
236
|
+
// seed = 内置兜底(本地价格表缺失/损坏且官方页也拿不到)。
|
|
237
|
+
// 另外宿主正在后台同步(syncing)时不提示:这是正常过程,不是"未同步"。
|
|
182
238
|
const hints = [];
|
|
183
239
|
const addHint = (text, friendly) => hints.push({ text, friendly });
|
|
240
|
+
const at = fmtWhen(pricing && (pricing.layerAt || pricing.syncedAt));
|
|
241
|
+
const withAt = (key) => t(key).replace("{at}", at || "—");
|
|
184
242
|
if (pricing && typeof pricing === "object") {
|
|
185
243
|
const used = pricing.used ? String(pricing.used) : "";
|
|
186
|
-
|
|
244
|
+
const via = String(pricing.via || "");
|
|
245
|
+
if (via === "remote-alias" || via === "remote-family" || via === "file-alias" || via === "file-family") {
|
|
187
246
|
addHint(t("price.alias").replace("{model}", used), true);
|
|
188
|
-
} else if (pricing.
|
|
189
|
-
|
|
190
|
-
|
|
247
|
+
} else if (pricing.syncing === true) {
|
|
248
|
+
// 后台正在拉官方页:此刻显示的是本地价格表里的价,属于正常过程,不提示。
|
|
249
|
+
} else if (pricing.source === "file") {
|
|
250
|
+
if (pricing.mode !== "manual" && pricing.syncOk === false) addHint(withAt("price.lastGood"), false);
|
|
251
|
+
} else if (pricing.source === "seed" && via !== "fallback") {
|
|
252
|
+
if (pricing.mode === "manual") {
|
|
253
|
+
// 手动模式下不联网,模型不在表里只是"用了内置条目",不是同步失败
|
|
254
|
+
addHint(t("price.builtin").replace("{model}", used), true);
|
|
255
|
+
} else if (pricing.syncOk === false) {
|
|
256
|
+
addHint(t("price.stale"), false);
|
|
257
|
+
} else {
|
|
258
|
+
addHint(t("price.builtin").replace("{model}", used), true);
|
|
259
|
+
}
|
|
191
260
|
} else if (pricing.syncOk === false && pricing.syncedAt) {
|
|
192
261
|
// 本次刷新失败,但仍在用上次成功同步到的官方价格
|
|
193
|
-
addHint(
|
|
262
|
+
addHint(withAt("price.lastGood"), false);
|
|
194
263
|
}
|
|
195
264
|
}
|
|
196
265
|
const hintText = hints.map((x) => x.text).join(" · ");
|
|
@@ -208,9 +277,25 @@ window.__ModuleLoader__.load({
|
|
|
208
277
|
boxShadow: "0 0 4px rgba(0,0,0,.3)",
|
|
209
278
|
};
|
|
210
279
|
// 指示灯 hover 提示只显示当前处于高峰/空闲时段,不拼时段明细(太长);
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
280
|
+
// 价格来源提示、价格表位置附在末尾——普通状态(良性提示被点开后)它是提示
|
|
281
|
+
// 唯一可见处,也是用户想知道"价从哪来、去哪儿改价"时的入口。
|
|
282
|
+
const fileInfo = pricing && pricing.file && typeof pricing.file === "object" ? pricing.file : null;
|
|
283
|
+
const sourceNote = !pricing
|
|
284
|
+
? ""
|
|
285
|
+
: pricing.mode === "manual"
|
|
286
|
+
? t("price.manual")
|
|
287
|
+
: pricing.source === "file"
|
|
288
|
+
? t("price.file").replace("{at}", at || "—")
|
|
289
|
+
: "";
|
|
290
|
+
const pathNote = fileInfo && typeof fileInfo.path === "string" && fileInfo.path !== ""
|
|
291
|
+
? t("price.filePath").replace("{path}", fileInfo.path)
|
|
292
|
+
: "";
|
|
293
|
+
const peakTitle = [
|
|
294
|
+
data && data.peak ? t("peak.peak") : t("peak.off"),
|
|
295
|
+
hintText,
|
|
296
|
+
sourceNote,
|
|
297
|
+
pathNote,
|
|
298
|
+
].filter((x) => x !== "").join(" · ");
|
|
214
299
|
// 点击卡片:刷新数据;若是纯良性提示,再在「提示态 / 普通态」之间切换。
|
|
215
300
|
const onCardClick = () => {
|
|
216
301
|
if (hints.length > 0 && !blocking) setShowNormal((v) => !v);
|
|
@@ -245,7 +330,9 @@ window.__ModuleLoader__.load({
|
|
|
245
330
|
// A2/A3(价格来源提示 / 错误)出现时,卡片直接置空:不画余额、档单价与峰谷灯,
|
|
246
331
|
// 只在空白卡片上显示提示文字——提示不再和余额数字、单价行抢同一块版面,从根本
|
|
247
332
|
// 上消除遮挡。minHeight 兜住常态卡片高度(CARD_MIN_HEIGHT),侧边栏不跳动;
|
|
248
|
-
//
|
|
333
|
+
// 文字多行时卡片自然变高。提示/错误字号取 12px(与卡片正文同级;原来 10px 可读性
|
|
334
|
+
// 差),字号变大后靠换行留在卡片内,不再用 nowrap + 省略号截断。纯良性提示可由
|
|
335
|
+
// 点击切回普通状态(见 blanked)。
|
|
249
336
|
// 窄卡片(!wide)没有文字空间,维持原样(提示在峰谷灯 hover 里)。
|
|
250
337
|
if (blanked) {
|
|
251
338
|
return h("div", {
|
|
@@ -273,13 +360,13 @@ window.__ModuleLoader__.load({
|
|
|
273
360
|
},
|
|
274
361
|
hints.length
|
|
275
362
|
? h("div", {
|
|
276
|
-
style: { color: C.sub, fontSize:
|
|
363
|
+
style: { color: C.sub, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
|
|
277
364
|
title: hintTitle || hintText,
|
|
278
365
|
}, hintText)
|
|
279
366
|
: null,
|
|
280
367
|
errs.length
|
|
281
368
|
? h("div", {
|
|
282
|
-
style: { color: C.err, fontSize:
|
|
369
|
+
style: { color: C.err, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
|
|
283
370
|
}, errs.join(" | "))
|
|
284
371
|
: null);
|
|
285
372
|
}
|
package/lib/index.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* ok: true,
|
|
11
11
|
* balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
|
|
12
12
|
* tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
|
|
13
|
-
* pricing: { source, via, requested, used, syncOk, syncedAt,
|
|
13
|
+
* pricing: { source, via, requested, used, syncOk, syncing, syncedAt, layerAt,
|
|
14
|
+
* mode, file: { path, updatedAt, ... } }, // 价格来源/同步状态
|
|
14
15
|
* peak, peakHours, // 峰谷时段(北京时间)
|
|
15
16
|
* updatedAt, errors
|
|
16
17
|
* }
|
|
@@ -18,16 +19,23 @@
|
|
|
18
19
|
* `lang` 参数(zh 默认)控制错误消息与峰谷文案的语言;UI 其余文案由
|
|
19
20
|
* client 端(DSH locale 服务)本地化。
|
|
20
21
|
*
|
|
21
|
-
* 单价数据源(0.
|
|
22
|
+
* 单价数据源(0.7.0 起,三层:remote → file → seed):
|
|
23
|
+
* - 本地价格配置表(lib/pricing-store.js):`<DSH_HOME>/dsh-deepseek-usage/pricing.json`
|
|
24
|
+
* (可用 DEEPSEEK_USAGE_PRICE_FILE 覆盖)。插件启动时先读它并注入 pricing.setFilePrices(),
|
|
25
|
+
* 所以**重启后卡片立刻就有价格**(上次同步到的官方价 / 用户手改的价),不会再退回内置表。
|
|
26
|
+
* 首次运行会把内置快照写进该文件,方便用户直接编辑;文件被手改后无需重启,下次请求
|
|
27
|
+
* (点击卡片即可)会按 mtime 自动重载。
|
|
22
28
|
* - 默认从 DeepSeek 官方"模型 & 价格"页自动同步(lib/pricing-remote.js):
|
|
23
29
|
* 每天首次成功同步一次,失败按 10 分钟退避重试;请求带 refresh=1(点击卡片)
|
|
24
|
-
*
|
|
30
|
+
* 时立即重拉并等待结果,便于官方刚调价后手动跟新。同步成功后**合并回写**本地价格表
|
|
31
|
+
* (官方页列出的模型覆盖同名条目,用户自加条目保留),下次重启即可直接用。
|
|
25
32
|
* - 解析优先走"结构化表格"策略(对官方改版排版不敏感),失败再退回文本策略;
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
33
|
+
* 两者都失败才使用上一层的价格(本地表 / 内置种子表 lib/pricing.js)。
|
|
34
|
+
* - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或本地/内置表,并通过
|
|
28
35
|
* pricing.syncOk / pricing.source 让界面提示"当前价格未同步",不静默展示旧价。
|
|
29
|
-
* -
|
|
30
|
-
*
|
|
36
|
+
* - 本地价格表里 autoUpdate=false 即"手动模式":完全不联网,档单价完全以该表为准。
|
|
37
|
+
* - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES / 价格表 aliases 映射到
|
|
38
|
+
* 现名计价。峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
|
|
31
39
|
*
|
|
32
40
|
* 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
|
|
33
41
|
* 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
|
|
@@ -41,11 +49,16 @@ import {
|
|
|
41
49
|
DEFAULT_MODEL,
|
|
42
50
|
PEAK_HOURS_TEXT,
|
|
43
51
|
PEAK_HOURS_TEXT_EN,
|
|
52
|
+
filePriceMeta,
|
|
44
53
|
isPeak,
|
|
45
54
|
remotePriceMeta,
|
|
55
|
+
seedAliases,
|
|
56
|
+
seedPrices,
|
|
57
|
+
setFilePrices,
|
|
46
58
|
setRemotePrices,
|
|
47
59
|
unitAt,
|
|
48
60
|
} from "./pricing.js";
|
|
61
|
+
import { loadPriceTable, priceTableStat, savePriceTable } from "./pricing-store.js";
|
|
49
62
|
import { fetchOfficialPrices } from "./pricing-remote.js";
|
|
50
63
|
|
|
51
64
|
const name = "dsh-deepseek-usage";
|
|
@@ -95,6 +108,130 @@ let lastForcedAt = 0; // 上次手动重拉时刻
|
|
|
95
108
|
/** 同步状态(透出到界面:同步失败时让用户看到"当前是内置/旧价",而不是静默展示)。 */
|
|
96
109
|
let remoteStatus = { ok: false, at: 0, error: null, models: 0, strategy: null };
|
|
97
110
|
|
|
111
|
+
// ---- 本地价格配置表状态(进程内镜像;文件才是持久层,见 lib/pricing-store.js) ----
|
|
112
|
+
/** 本地价格表当前状态:路径 / 上次写入时间 / mtime(探测手改)/ 模型数 / 是否自动同步。 */
|
|
113
|
+
let fileState = { path: "", exists: false, updatedAt: null, updatedFrom: null, mtimeMs: 0, models: 0, autoUpdate: true };
|
|
114
|
+
|
|
115
|
+
/** 把一份读取结果注入 pricing 层,并刷新进程内镜像。 */
|
|
116
|
+
function applyPriceFile(loaded, mtimeMs) {
|
|
117
|
+
const table = loaded.table;
|
|
118
|
+
setFilePrices(
|
|
119
|
+
table.models,
|
|
120
|
+
{
|
|
121
|
+
path: loaded.path,
|
|
122
|
+
updatedAt: table.updatedAt,
|
|
123
|
+
source: table.updatedFrom,
|
|
124
|
+
strategy: table.strategy,
|
|
125
|
+
autoUpdate: table.autoUpdate,
|
|
126
|
+
version: table.version,
|
|
127
|
+
},
|
|
128
|
+
table.aliases,
|
|
129
|
+
);
|
|
130
|
+
fileState = {
|
|
131
|
+
path: loaded.path,
|
|
132
|
+
exists: true,
|
|
133
|
+
updatedAt: table.updatedAt,
|
|
134
|
+
updatedFrom: table.updatedFrom,
|
|
135
|
+
mtimeMs: mtimeMs ?? priceTableStat({ path: loaded.path }).mtimeMs,
|
|
136
|
+
models: Object.keys(table.models).length,
|
|
137
|
+
autoUpdate: table.autoUpdate !== false,
|
|
138
|
+
};
|
|
139
|
+
return fileState;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 启动时载入本地价格配置表:有 → 直接注入(重启后卡片立刻有价);没有 → 用内置快照
|
|
144
|
+
* 生成一份,让用户有个可直接编辑的起点;损坏 → 只记日志、不覆盖文件(避免毁掉用户
|
|
145
|
+
* 正在编辑的内容),内存里仍用内置快照兜底。
|
|
146
|
+
*/
|
|
147
|
+
function initPriceFile(ctx) {
|
|
148
|
+
const loaded = loadPriceTable();
|
|
149
|
+
fileState.path = loaded.path;
|
|
150
|
+
for (const warning of loaded.warnings ?? []) ctx.logger.warn(`dsh-deepseek-usage: ${warning}`);
|
|
151
|
+
|
|
152
|
+
if (loaded.table) {
|
|
153
|
+
applyPriceFile(loaded);
|
|
154
|
+
if (loaded.table.autoUpdate === false) {
|
|
155
|
+
ctx.logger.info(`dsh-deepseek-usage: 已载入本地价格表 ${loaded.path}(${fileState.models} 个模型,手动模式:不再联网同步)`);
|
|
156
|
+
} else {
|
|
157
|
+
ctx.logger.info(
|
|
158
|
+
`dsh-deepseek-usage: 已载入本地价格表 ${loaded.path}(${fileState.models} 个模型,上次写入 ${fileState.updatedAt ? new Date(fileState.updatedAt).toISOString() : "未知"})`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const seed = { models: seedPrices(), aliases: seedAliases() };
|
|
165
|
+
if (loaded.exists) {
|
|
166
|
+
ctx.logger.warn(`dsh-deepseek-usage: 本地价格表无法解析(${loaded.error}),本次先用内置快照,文件未被覆盖:${loaded.path}`);
|
|
167
|
+
setFilePrices(seed.models, { path: loaded.path, updatedAt: null, source: "built-in", strategy: "seed", autoUpdate: true }, seed.aliases);
|
|
168
|
+
fileState = {
|
|
169
|
+
...fileState,
|
|
170
|
+
exists: true,
|
|
171
|
+
updatedFrom: "built-in",
|
|
172
|
+
models: Object.keys(seed.models).length,
|
|
173
|
+
autoUpdate: true,
|
|
174
|
+
mtimeMs: priceTableStat({ path: loaded.path }).mtimeMs, // 用户改好文件后 mtime 变化即可自动重载
|
|
175
|
+
};
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const written = savePriceTable({
|
|
181
|
+
models: seed.models,
|
|
182
|
+
aliases: seed.aliases,
|
|
183
|
+
updatedAt: Date.now(),
|
|
184
|
+
updatedFrom: "built-in",
|
|
185
|
+
strategy: "seed",
|
|
186
|
+
autoUpdate: true,
|
|
187
|
+
});
|
|
188
|
+
applyPriceFile({ path: written.path, table: written.doc });
|
|
189
|
+
ctx.logger.info(`dsh-deepseek-usage: 未找到本地价格表,已用内置快照生成 ${written.path}(可手动编辑,autoUpdate=false 可关闭自动同步)`);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
ctx.logger.warn(`dsh-deepseek-usage: 无法写入本地价格表 ${loaded.path}(${error instanceof Error ? error.message : String(error)}),本次用内置快照`);
|
|
192
|
+
setFilePrices(seed.models, { path: loaded.path, updatedAt: null, source: "built-in", strategy: "seed", autoUpdate: true }, seed.aliases);
|
|
193
|
+
fileState = { ...fileState, updatedFrom: "built-in", models: Object.keys(seed.models).length };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 手改价格表后免重启生效:每次请求先比 mtime,变了就重载(一次 stat,成本可忽略)。
|
|
199
|
+
* 只重载"能解析"的文件;解析失败保持现状并记日志。
|
|
200
|
+
*/
|
|
201
|
+
function reloadPriceFileIfChanged(ctx) {
|
|
202
|
+
if (!fileState.path) return;
|
|
203
|
+
const stat = priceTableStat({ path: fileState.path });
|
|
204
|
+
if (!stat.exists || stat.mtimeMs === fileState.mtimeMs) return;
|
|
205
|
+
const loaded = loadPriceTable({ path: fileState.path });
|
|
206
|
+
if (!loaded.table) {
|
|
207
|
+
fileState.mtimeMs = stat.mtimeMs; // 别每请求都重读坏文件
|
|
208
|
+
ctx.logger.warn(`dsh-deepseek-usage: 本地价格表改动后无法解析,继续用上一次的价格:${loaded.error}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
applyPriceFile(loaded, stat.mtimeMs);
|
|
212
|
+
for (const warning of loaded.warnings ?? []) ctx.logger.warn(`dsh-deepseek-usage: ${warning}`);
|
|
213
|
+
ctx.logger.info(`dsh-deepseek-usage: 本地价格表已重新载入 ${fileState.path}(${fileState.models} 个模型)`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 把官方同步结果合并回写本地价格表(官方页列出的模型覆盖同名条目,用户自加条目保留),
|
|
218
|
+
* 并同步刷新内存里的 file 层——这样"这次同步失败"的后续请求也还能用上最新一份官方价。
|
|
219
|
+
*/
|
|
220
|
+
function persistOfficialPrices(ctx, models, meta) {
|
|
221
|
+
try {
|
|
222
|
+
const written = savePriceTable({
|
|
223
|
+
models,
|
|
224
|
+
updatedAt: meta.fetchedAt || Date.now(),
|
|
225
|
+
updatedFrom: meta.source,
|
|
226
|
+
strategy: meta.strategy,
|
|
227
|
+
});
|
|
228
|
+
applyPriceFile({ path: written.path, table: written.doc }, priceTableStat({ path: written.path }).mtimeMs);
|
|
229
|
+
ctx.logger.info(`dsh-deepseek-usage: 官方价格已回写本地价格表 ${written.path}(共 ${fileState.models} 个模型)`);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
ctx.logger.warn(`dsh-deepseek-usage: 官方价格回写本地价格表失败(本次仍按内存里的官方价显示):${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
98
235
|
/** 北京时间日期键(yyyy-mm-dd)。 */
|
|
99
236
|
function beijingDayKey(ms) {
|
|
100
237
|
const d = new Date(ms + SHANGHAI_OFFSET_MS);
|
|
@@ -106,10 +243,13 @@ function beijingDayKey(ms) {
|
|
|
106
243
|
/**
|
|
107
244
|
* 保证官方价格已同步:每天首次自动拉取一次,失败则按 RETRY_GAP_MS 退避重试;
|
|
108
245
|
* force(点击卡片 refresh=1)时立即重拉并等待。
|
|
109
|
-
* 拉取成功 → pricing.setRemotePrices()
|
|
110
|
-
*
|
|
246
|
+
* 拉取成功 → pricing.setRemotePrices() 注入 + 合并回写本地价格表;失败 → 记日志并记录
|
|
247
|
+
* 状态,继续用本地表/上次数据,同时把失败状态透出到界面。
|
|
248
|
+
* 注意:非强制(自动)路径不在请求内等待拉取结果,界面用 pricing.syncing 表示"正在同步",
|
|
249
|
+
* 免得把一次正常的后台刷新误报成"未同步"。
|
|
111
250
|
*/
|
|
112
251
|
async function refreshRemote(ctx, force) {
|
|
252
|
+
if (!fileState.autoUpdate) return; // 手动模式:完全不联网
|
|
113
253
|
const now = Date.now();
|
|
114
254
|
if (!force) {
|
|
115
255
|
if (remoteDay === beijingDayKey(now)) return; // 今天已成功同步过
|
|
@@ -137,10 +277,11 @@ async function refreshRemote(ctx, force) {
|
|
|
137
277
|
ctx.logger.info(
|
|
138
278
|
`dsh-deepseek-usage: 官方价格已同步(${Object.keys(models).length} 个模型,策略 ${meta.strategy},来源 ${meta.source})`,
|
|
139
279
|
);
|
|
280
|
+
persistOfficialPrices(ctx, models, meta);
|
|
140
281
|
} catch (error) {
|
|
141
282
|
const message = error instanceof Error ? error.message : String(error);
|
|
142
283
|
remoteStatus = { ...remoteStatus, ok: false, error: message };
|
|
143
|
-
ctx.logger.warn(`dsh-deepseek-usage:
|
|
284
|
+
ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用本地价格表/上次价格: ${message}`);
|
|
144
285
|
} finally {
|
|
145
286
|
remoteInFlight = null;
|
|
146
287
|
}
|
|
@@ -202,6 +343,13 @@ function resolveQuery(req) {
|
|
|
202
343
|
}
|
|
203
344
|
|
|
204
345
|
function apply(ctx) {
|
|
346
|
+
// ---- 本地价格配置表:启动即载入(重启后卡片立刻有价,不再退回内置表)----
|
|
347
|
+
try {
|
|
348
|
+
initPriceFile(ctx);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
ctx.logger.warn(`dsh-deepseek-usage: 载入本地价格表失败,改用内置快照:${error instanceof Error ? error.message : String(error)}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
205
353
|
ctx.effect(
|
|
206
354
|
() => ctx.webServer.register({
|
|
207
355
|
kind: "exact",
|
|
@@ -216,7 +364,14 @@ function apply(ctx) {
|
|
|
216
364
|
const { lang, refresh } = resolveQuery(req);
|
|
217
365
|
const msg = MESSAGES[lang] || MESSAGES.zh;
|
|
218
366
|
|
|
219
|
-
// ----
|
|
367
|
+
// ---- 手改价格表免重启生效(mtime 变了才重读)----
|
|
368
|
+
try {
|
|
369
|
+
reloadPriceFileIfChanged(ctx);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
ctx.logger.warn(`dsh-deepseek-usage: 重载本地价格表失败:${error instanceof Error ? error.message : String(error)}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- 官方价格自动同步(每日首次后台拉取;点击卡片强制并等待;手动模式跳过)----
|
|
220
375
|
await refreshRemote(ctx, refresh);
|
|
221
376
|
|
|
222
377
|
// ---- 当前模型当前档单价(展示用)----
|
|
@@ -276,19 +431,32 @@ function apply(ctx) {
|
|
|
276
431
|
model: unit.model,
|
|
277
432
|
known: unit.known,
|
|
278
433
|
},
|
|
279
|
-
// 价格来源与同步状态:让"官方页改版/网络失败 →
|
|
434
|
+
// 价格来源与同步状态:让"官方页改版/网络失败 → 正在用旧价"可见,
|
|
280
435
|
// 而不是静默按过期价格展示。
|
|
281
436
|
pricing: {
|
|
282
|
-
source: unit.source, // remote
|
|
283
|
-
via: unit.via, // remote |
|
|
437
|
+
source: unit.source, // remote=本次官方页同步 / file=本地价格表 / seed=内置兜底
|
|
438
|
+
via: unit.via, // remote* | file* | alias | seed | fallback
|
|
284
439
|
requested: unit.requested, // 当前启用的模型 id
|
|
285
440
|
used: unit.model, // 实际取价使用的模型 id
|
|
441
|
+
mode: fileState.autoUpdate ? "auto" : "manual", // manual=价格表 autoUpdate:false,不联网
|
|
442
|
+
syncing: remoteInFlight !== null, // 后台正在拉官方页(别误报成"未同步")
|
|
286
443
|
syncOk: remoteStatus.ok,
|
|
287
444
|
syncedAt: remoteStatus.at || null,
|
|
445
|
+
layerAt: unit.layerAt, // 当前这份单价所属层的时间戳(remote/file)
|
|
288
446
|
strategy: remoteStatus.strategy, // table=结构化表格 / flat=文本兜底
|
|
289
447
|
modelCount: remoteStatus.models,
|
|
290
448
|
syncError: remoteStatus.error,
|
|
291
449
|
remoteMeta: remotePriceMeta(),
|
|
450
|
+
// 本地价格配置表(用户可编辑;路径透出给界面,方便知道去哪儿改价)
|
|
451
|
+
file: fileState.path
|
|
452
|
+
? {
|
|
453
|
+
path: fileState.path,
|
|
454
|
+
updatedAt: fileState.updatedAt,
|
|
455
|
+
updatedFrom: fileState.updatedFrom,
|
|
456
|
+
autoUpdate: fileState.autoUpdate,
|
|
457
|
+
models: fileState.models,
|
|
458
|
+
}
|
|
459
|
+
: null,
|
|
292
460
|
},
|
|
293
461
|
updatedAt: now,
|
|
294
462
|
errors,
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-deepseek-usage — 本地价格配置表(磁盘文件,lib/pricing.js 的数据源之一)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么有这一层:价格同步状态原来只活在进程内存里,dsh 一重启就没了,于是重启后
|
|
5
|
+
* 的第一张卡片只能用代码内置的价格表(表现为"价格未同步(用内置表)")。现在把
|
|
6
|
+
* "模型 → 档单价"从代码里抽出来存成配置文件:
|
|
7
|
+
*
|
|
8
|
+
* 启动 → 读本文件(上次同步到的官方价 / 用户手改的价)→ 卡片立刻有价
|
|
9
|
+
* 每天/点击 → 拉官方价格页 → 合并回写本文件(官方条目覆盖同名项,用户自加条目保留)
|
|
10
|
+
*
|
|
11
|
+
* 路径解析优先级:
|
|
12
|
+
* 1. 环境变量 DEEPSEEK_USAGE_PRICE_FILE(显式指定,测试/多实例用)
|
|
13
|
+
* 2. <DSH_HOME|~/.dsh>/dsh-deepseek-usage/pricing.json
|
|
14
|
+
*
|
|
15
|
+
* 文件格式(JSON;允许**整行** `//` 注释,方便手改):
|
|
16
|
+
* {
|
|
17
|
+
* "version": 1,
|
|
18
|
+
* "autoUpdate": true, // false = 手动模式:不再联网同步,直接用本表
|
|
19
|
+
* "updatedAt": 1789090582880, // 上次写入时间(ms;也接受 ISO 字符串)
|
|
20
|
+
* "updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
21
|
+
* "strategy": "table", // 官方页解析策略(table=结构化表格 / flat=文本兜底 / seed=内置快照)
|
|
22
|
+
* "aliases": { "deepseek-v4-flash": "deepseek-flash" },
|
|
23
|
+
* "models": {
|
|
24
|
+
* "deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
* 单价单位:元 / 百万 tokens;input / cacheRead / output 均为 [高峰, 空闲] 两档
|
|
28
|
+
* (cacheWrite 目前官方不计费,恒 0)。也接受"整个文件就是模型映射"的简写形式:
|
|
29
|
+
* { "deepseek-flash": { "input": [2, 1], ... } }
|
|
30
|
+
*
|
|
31
|
+
* 写入策略:读-改-写 + 原子替换(先写同目录 .tmp 再 rename),避免半截文件。
|
|
32
|
+
* 本文件不做任何网络访问,也不含运行时状态(解析 → 返回,交给调用方注入)。
|
|
33
|
+
*/
|
|
34
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
|
|
38
|
+
/** 价格表文件格式版本(结构不兼容时递增)。 */
|
|
39
|
+
export const PRICE_FILE_VERSION = 1;
|
|
40
|
+
/** 价格表路径覆盖(便于测试与多实例)。 */
|
|
41
|
+
const FILE_ENV = "DEEPSEEK_USAGE_PRICE_FILE";
|
|
42
|
+
/** DSH 家目录覆盖(与 dsh-home-paths 的约定一致)。 */
|
|
43
|
+
const HOME_ENV = "DSH_HOME";
|
|
44
|
+
/** 插件在 DSH 家目录下的子目录 / 文件名。 */
|
|
45
|
+
const DIR_NAME = "dsh-deepseek-usage";
|
|
46
|
+
const FILE_NAME = "pricing.json";
|
|
47
|
+
|
|
48
|
+
/** 写进文件的格式说明(JSON 无注释,只能占一个字段)。 */
|
|
49
|
+
const FILE_COMMENT =
|
|
50
|
+
"dsh-deepseek-usage 价格表:input/cacheRead/output 为 [高峰, 空闲] 两档单价,单位 元/百万 tokens;"
|
|
51
|
+
+ "autoUpdate=true 时插件每天首次使用(或点击卡片)会拉取官方价格页并回写本文件——"
|
|
52
|
+
+ "官方页列出的模型覆盖同名条目,你自己加的条目保留;autoUpdate=false 表示不再联网同步,完全以本表为准。"
|
|
53
|
+
+ "允许整行 // 注释。";
|
|
54
|
+
|
|
55
|
+
/** 当前生效的价格表路径(env > DSH_HOME/dsh-deepseek-usage/pricing.json > ~/.dsh/...)。 */
|
|
56
|
+
export function defaultPriceFilePath() {
|
|
57
|
+
const fromEnv = process.env[FILE_ENV];
|
|
58
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
59
|
+
const home = process.env[HOME_ENV];
|
|
60
|
+
const base = typeof home === "string" && home.trim() !== "" ? home.trim() : join(homedir(), ".dsh");
|
|
61
|
+
return join(base, DIR_NAME, FILE_NAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 只剥掉"整行 //"注释:URL 里的 `//` 在行中间,不会被误伤。 */
|
|
65
|
+
function stripLineComments(text) {
|
|
66
|
+
return text
|
|
67
|
+
.split(/\r?\n/)
|
|
68
|
+
.map((line) => (/^\s*\/\//.test(line) ? "" : line))
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** [高峰, 空闲] 二元组校验。 */
|
|
73
|
+
const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
|
|
74
|
+
|
|
75
|
+
/** 时间容忍数字(ms)/ ISO 字符串。 */
|
|
76
|
+
function toMs(value) {
|
|
77
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
78
|
+
if (typeof value === "string") {
|
|
79
|
+
const n = Number(value);
|
|
80
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
81
|
+
const t = Date.parse(value);
|
|
82
|
+
if (Number.isFinite(t)) return t;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 清洗模型映射:非法条目丢弃并记入 dropped(缺失的档位回退到 input 档)。 */
|
|
88
|
+
function cleanModels(raw) {
|
|
89
|
+
const models = {};
|
|
90
|
+
const dropped = [];
|
|
91
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { models, dropped };
|
|
92
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
93
|
+
if (typeof id !== "string" || id === "" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
94
|
+
dropped.push(String(id));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const input = okPair(entry.input) ? entry.input.slice() : null;
|
|
98
|
+
if (!input) {
|
|
99
|
+
dropped.push(id);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const cacheWrite = Number(entry.cacheWrite);
|
|
103
|
+
models[id] = {
|
|
104
|
+
input,
|
|
105
|
+
cacheRead: okPair(entry.cacheRead) ? entry.cacheRead.slice() : input.slice(),
|
|
106
|
+
cacheWrite: Number.isFinite(cacheWrite) && cacheWrite >= 0 ? cacheWrite : 0,
|
|
107
|
+
output: okPair(entry.output) ? entry.output.slice() : input.slice(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { models, dropped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 清洗别名映射(旧名 → 现名)。 */
|
|
114
|
+
function cleanAliases(raw) {
|
|
115
|
+
const aliases = {};
|
|
116
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return aliases;
|
|
117
|
+
for (const [from, to] of Object.entries(raw)) {
|
|
118
|
+
if (typeof from === "string" && from !== "" && typeof to === "string" && to !== "") aliases[from] = to;
|
|
119
|
+
}
|
|
120
|
+
return aliases;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 按键名排序(让文件 diff 稳定、便于人读)。 */
|
|
124
|
+
function sorted(obj) {
|
|
125
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析价格表 JSON 文本。
|
|
130
|
+
* @throws 根节点/模型条目完全不合法时抛错(调用方据此判定"文件损坏")。
|
|
131
|
+
* @returns {{ doc: { version, autoUpdate, updatedAt, updatedFrom, strategy, aliases, models }, dropped: string[] }}
|
|
132
|
+
*/
|
|
133
|
+
export function parsePriceTable(text) {
|
|
134
|
+
const raw = JSON.parse(stripLineComments(String(text)));
|
|
135
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("价格表根节点必须是对象");
|
|
136
|
+
|
|
137
|
+
// 显式结构(带 models/autoUpdate/... 字段)与简写结构(整个文件就是模型映射)都支持。
|
|
138
|
+
const explicit = ["models", "aliases", "autoUpdate", "updatedAt", "updatedFrom", "strategy", "version"].some(
|
|
139
|
+
(k) => raw[k] !== undefined,
|
|
140
|
+
);
|
|
141
|
+
const { models, dropped } = cleanModels(explicit ? raw.models : raw);
|
|
142
|
+
if (Object.keys(models).length === 0) {
|
|
143
|
+
throw new Error("价格表里没有可用模型条目(每项至少要有 input: [高峰, 空闲])");
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
doc: {
|
|
147
|
+
version: Number.isFinite(Number(raw.version)) ? Number(raw.version) : PRICE_FILE_VERSION,
|
|
148
|
+
autoUpdate: raw.autoUpdate !== false,
|
|
149
|
+
updatedAt: toMs(raw.updatedAt),
|
|
150
|
+
updatedFrom: typeof raw.updatedFrom === "string" && raw.updatedFrom !== "" ? raw.updatedFrom : null,
|
|
151
|
+
strategy: typeof raw.strategy === "string" && raw.strategy !== "" ? raw.strategy : null,
|
|
152
|
+
aliases: explicit ? sorted(cleanAliases(raw.aliases)) : {},
|
|
153
|
+
models: sorted(models),
|
|
154
|
+
},
|
|
155
|
+
dropped,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 读取价格表文件。
|
|
161
|
+
* @returns {{ path: string, exists: boolean, error: string|null, table: object|null, warnings: string[] }}
|
|
162
|
+
* exists=false 表示文件不存在(首次运行,error 为 null);table=null 且 error 非空表示文件损坏。
|
|
163
|
+
*/
|
|
164
|
+
export function loadPriceTable(options = {}) {
|
|
165
|
+
const path = options.path ?? defaultPriceFilePath();
|
|
166
|
+
let text;
|
|
167
|
+
try {
|
|
168
|
+
text = readFileSync(path, "utf8");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const code = error && typeof error === "object" ? error.code : void 0;
|
|
171
|
+
return {
|
|
172
|
+
path,
|
|
173
|
+
exists: false,
|
|
174
|
+
error: code === "ENOENT" ? null : error instanceof Error ? error.message : String(error),
|
|
175
|
+
table: null,
|
|
176
|
+
warnings: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const { doc, dropped } = parsePriceTable(text);
|
|
181
|
+
return {
|
|
182
|
+
path,
|
|
183
|
+
exists: true,
|
|
184
|
+
error: null,
|
|
185
|
+
table: doc,
|
|
186
|
+
warnings: dropped.length > 0 ? [`忽略 ${dropped.length} 个无法解析的模型条目:${dropped.join(", ")}`] : [],
|
|
187
|
+
};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
path,
|
|
191
|
+
exists: true,
|
|
192
|
+
error: error instanceof Error ? error.message : String(error),
|
|
193
|
+
table: null,
|
|
194
|
+
warnings: [],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 文件状态(用于"手改价格表后免重启生效"的变更探测)。 */
|
|
200
|
+
export function priceTableStat(options = {}) {
|
|
201
|
+
const path = options.path ?? defaultPriceFilePath();
|
|
202
|
+
try {
|
|
203
|
+
const st = statSync(path);
|
|
204
|
+
return { path, exists: true, mtimeMs: st.mtimeMs, size: st.size };
|
|
205
|
+
} catch {
|
|
206
|
+
return { path, exists: false, mtimeMs: 0, size: 0 };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 合并写回价格表:patch.models / patch.aliases 覆盖同名项,其余条目(含用户自加模型)
|
|
212
|
+
* 保留;未给出的字段沿用磁盘上的旧值。写盘为"临时文件 + rename"原子替换。
|
|
213
|
+
* @returns {{ path: string, doc: object }}
|
|
214
|
+
*/
|
|
215
|
+
export function savePriceTable(patch = {}, options = {}) {
|
|
216
|
+
const path = options.path ?? defaultPriceFilePath();
|
|
217
|
+
const base = loadPriceTable({ path }).table ?? {
|
|
218
|
+
version: PRICE_FILE_VERSION,
|
|
219
|
+
autoUpdate: true,
|
|
220
|
+
updatedAt: null,
|
|
221
|
+
updatedFrom: null,
|
|
222
|
+
strategy: null,
|
|
223
|
+
aliases: {},
|
|
224
|
+
models: {},
|
|
225
|
+
};
|
|
226
|
+
const { models } = cleanModels({ ...base.models, ...(patch.models ?? {}) });
|
|
227
|
+
const aliases = cleanAliases({ ...base.aliases, ...(patch.aliases ?? {}) });
|
|
228
|
+
const doc = {
|
|
229
|
+
$comment: FILE_COMMENT,
|
|
230
|
+
version: PRICE_FILE_VERSION,
|
|
231
|
+
autoUpdate: patch.autoUpdate ?? base.autoUpdate ?? true,
|
|
232
|
+
updatedAt: patch.updatedAt ?? Date.now(),
|
|
233
|
+
updatedFrom: patch.updatedFrom ?? base.updatedFrom ?? null,
|
|
234
|
+
strategy: patch.strategy ?? base.strategy ?? null,
|
|
235
|
+
aliases: sorted(aliases),
|
|
236
|
+
models: sorted(models),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
240
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
241
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
242
|
+
try {
|
|
243
|
+
renameSync(tmp, path);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
try {
|
|
246
|
+
rmSync(tmp, { force: true });
|
|
247
|
+
} catch {
|
|
248
|
+
/* 清理失败不影响报错 */
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return { path, doc };
|
|
253
|
+
}
|
package/lib/pricing.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-deepseek-usage — 定价表(元 / 百万 tokens)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
4
|
+
* 价格来源优先级(0.7.0 起,三层):
|
|
5
|
+
* - remote(进程内最新):DeepSeek 官方"模型 & 价格"页(lib/pricing-remote.js 抓取,
|
|
6
|
+
* 每日首次访问/点击卡片时刷新),解析结果经 setRemotePrices() 注入本模块——官方调价
|
|
7
|
+
* 只需页面更新,无需改本文件。
|
|
8
|
+
* - file(本地价格配置表):lib/pricing-store.js 维护的 pricing.json,内容是"上次同步到
|
|
9
|
+
* 的官方价 + 用户手改的价",经 setFilePrices() 注入。进程重启后 remote 层为空,卡片
|
|
10
|
+
* 直接用它,因此不会再退回内置表(也就不会再出现"价格未同步(用内置表)")。
|
|
11
|
+
* - seed(内置兜底):下方 PRICE_TABLE 内置种子表——DeepSeek 官方峰谷定价快照
|
|
12
|
+
* (2026-09-10,与当时官方价格页一致)。首次运行会拿它生成初始 pricing.json;
|
|
13
|
+
* 只有"本地价格表缺失/损坏且官方页也拿不到"时才会真正被展示。
|
|
10
14
|
*
|
|
11
15
|
* 模型改名(0.6.0 起):官方会把模型改名并把旧名保留为别名(如
|
|
12
16
|
* deepseek-v4-flash / deepseek-v4-flash-vision-exp → deepseek-flash)。旧名若
|
|
@@ -32,7 +36,8 @@ export const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
|
32
36
|
/**
|
|
33
37
|
* 内置种子表:input(缓存未命中)/ cacheRead(缓存命中)/ output 均为
|
|
34
38
|
* [高峰, 空闲] 二元组;cacheWrite 为常量 0。
|
|
35
|
-
*
|
|
39
|
+
* 只用于两处:首次运行生成 pricing.json(lib/pricing-store.js),以及"价格表缺失/
|
|
40
|
+
* 损坏且官方页也拿不到"时的最后兜底;正常运行时都会被 file / remote 层覆盖。
|
|
36
41
|
*/
|
|
37
42
|
const PRICE_TABLE = {
|
|
38
43
|
'deepseek-flash': { input: [2, 1], cacheRead: [0.04, 0.02], cacheWrite: 0, output: [8, 4] },
|
|
@@ -55,6 +60,12 @@ export const MODEL_ALIASES = {
|
|
|
55
60
|
let remoteTable = null;
|
|
56
61
|
/** 远端同步元信息(解析策略 / 抓取时间 / 来源),供界面展示同步状态。 */
|
|
57
62
|
let remoteMeta = null;
|
|
63
|
+
/** 本地价格配置表(setFilePrices 注入;重启后由它接管,见 lib/pricing-store.js)。 */
|
|
64
|
+
let fileTable = null;
|
|
65
|
+
/** 本地价格表元信息(路径 / 上次写入时间 / 来源)。 */
|
|
66
|
+
let fileMeta = null;
|
|
67
|
+
/** 本地价格表里的模型别名(旧名 → 现名),与内置 MODEL_ALIASES 合并使用。 */
|
|
68
|
+
let fileAliases = {};
|
|
58
69
|
|
|
59
70
|
/** 默认模型(当 agentDefaultModel 不可用时的展示默认)。 */
|
|
60
71
|
export const DEFAULT_MODEL = 'deepseek-flash';
|
|
@@ -84,15 +95,11 @@ export function isPeak(ms) {
|
|
|
84
95
|
return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
if (!table || typeof table !== 'object')
|
|
90
|
-
remoteTable = null;
|
|
91
|
-
remoteMeta = null;
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
const clean = {};
|
|
98
|
+
/** 清洗一张价格表(形状校验 + 档位回退),供 remote / file 两层共用。 */
|
|
99
|
+
function cleanTable(table) {
|
|
100
|
+
if (!table || typeof table !== 'object') return null;
|
|
95
101
|
const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
|
|
102
|
+
const clean = {};
|
|
96
103
|
for (const [id, e] of Object.entries(table)) {
|
|
97
104
|
if (typeof id !== 'string' || id === '' || !e || typeof e !== 'object') continue;
|
|
98
105
|
const input = okPair(e.input) ? e.input : null;
|
|
@@ -104,7 +111,12 @@ export function setRemotePrices(table, meta) {
|
|
|
104
111
|
output: okPair(e.output) ? e.output : input,
|
|
105
112
|
};
|
|
106
113
|
}
|
|
107
|
-
|
|
114
|
+
return Object.keys(clean).length > 0 ? clean : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 注入远端官方价格(parseOfficialPrices 的结果);传 null/空表即清除回下一层。 */
|
|
118
|
+
export function setRemotePrices(table, meta) {
|
|
119
|
+
remoteTable = cleanTable(table);
|
|
108
120
|
remoteMeta = remoteTable && meta && typeof meta === 'object' ? { ...meta } : null;
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -118,6 +130,38 @@ export function hasRemotePrices() {
|
|
|
118
130
|
return remoteTable !== null;
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 注入本地价格配置表(lib/pricing-store.js 的读取结果)。
|
|
135
|
+
* @param table 模型映射(models 字段)
|
|
136
|
+
* @param meta 元信息(path / updatedAt / source / strategy / autoUpdate)
|
|
137
|
+
* @param aliases 文件里的模型别名(旧名 → 现名),与内置 MODEL_ALIASES 合并
|
|
138
|
+
*/
|
|
139
|
+
export function setFilePrices(table, meta, aliases) {
|
|
140
|
+
fileTable = cleanTable(table);
|
|
141
|
+
fileMeta = fileTable && meta && typeof meta === 'object' ? { ...meta } : null;
|
|
142
|
+
fileAliases = aliases && typeof aliases === 'object' && !Array.isArray(aliases) ? { ...aliases } : {};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 本地价格表元信息(未载入时为 null)。 */
|
|
146
|
+
export function filePriceMeta() {
|
|
147
|
+
return fileMeta ? { ...fileMeta } : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 本地价格表是否可用。 */
|
|
151
|
+
export function hasFilePrices() {
|
|
152
|
+
return fileTable !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 内置种子价格表快照(深拷贝;用于首次运行生成 pricing.json)。 */
|
|
156
|
+
export function seedPrices() {
|
|
157
|
+
return JSON.parse(JSON.stringify(PRICE_TABLE));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 内置模型改名映射快照(用于首次运行生成 pricing.json)。 */
|
|
161
|
+
export function seedAliases() {
|
|
162
|
+
return { ...MODEL_ALIASES };
|
|
163
|
+
}
|
|
164
|
+
|
|
121
165
|
/** 模型家族名(用于官方改名后的兜底匹配)。 */
|
|
122
166
|
function familyOf(id) {
|
|
123
167
|
const m = /(flash|pro|chat|reasoner)/.exec(id);
|
|
@@ -126,41 +170,51 @@ function familyOf(id) {
|
|
|
126
170
|
|
|
127
171
|
const has = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
|
|
128
172
|
|
|
129
|
-
/**
|
|
130
|
-
function
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
173
|
+
/** 别名(旧名 → 现名):本地价格表的 aliases 覆盖内置 MODEL_ALIASES。 */
|
|
174
|
+
function aliasOf(id) {
|
|
175
|
+
if (typeof id !== 'string' || id === '') return undefined;
|
|
176
|
+
return has(fileAliases, id) ? fileAliases[id] : MODEL_ALIASES[id];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 在某一层价格表里找模型:精确 → 别名 → 同家族唯一匹配。 */
|
|
180
|
+
function layerLookup(table, tag, id) {
|
|
181
|
+
if (!table || typeof id !== 'string' || id === '') return null;
|
|
182
|
+
if (has(table, id)) return { id, via: tag };
|
|
183
|
+
const alias = aliasOf(id);
|
|
184
|
+
if (alias && has(table, alias)) return { id: alias, via: `${tag}-alias` };
|
|
135
185
|
const fam = familyOf(id);
|
|
136
186
|
if (fam) {
|
|
137
|
-
const hits = Object.keys(
|
|
187
|
+
const hits = Object.keys(table).filter((k) => familyOf(k) === fam);
|
|
138
188
|
// 仅在家族唯一时匹配,避免把 flash 档算成 pro 档。
|
|
139
|
-
if (hits.length === 1) return { id: hits[0], via:
|
|
189
|
+
if (hits.length === 1) return { id: hits[0], via: `${tag}-family` };
|
|
140
190
|
}
|
|
141
191
|
return null;
|
|
142
192
|
}
|
|
143
193
|
|
|
144
194
|
/**
|
|
145
|
-
*
|
|
146
|
-
* @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'alias'|'seed'|'fallback', known: boolean }}
|
|
195
|
+
* 解析模型的计价条目(优先级:remote → file → seed → chat 兜底)。
|
|
196
|
+
* @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'file'|'file-alias'|'file-family'|'alias'|'seed'|'fallback', source: 'remote'|'file'|'seed', known: boolean }}
|
|
147
197
|
*/
|
|
148
198
|
function lookup(model) {
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
199
|
+
const remote = layerLookup(remoteTable, 'remote', model);
|
|
200
|
+
if (remote) return { price: remoteTable[remote.id], id: remote.id, via: remote.via, source: 'remote', known: true };
|
|
201
|
+
|
|
202
|
+
// 进程重启后 remote 层为空 → 先用本地价格表(上次同步到的官方价 / 用户手改价)。
|
|
203
|
+
const file = layerLookup(fileTable, 'file', model);
|
|
204
|
+
if (file) return { price: fileTable[file.id], id: file.id, via: file.via, source: 'file', known: true };
|
|
151
205
|
|
|
152
|
-
//
|
|
153
|
-
const alias =
|
|
206
|
+
// 本地表也没有(首次运行、文件损坏、或模型确实不在表里)→ 内置种子兜底。
|
|
207
|
+
const alias = aliasOf(model);
|
|
154
208
|
if (alias && has(PRICE_TABLE, alias)) {
|
|
155
|
-
return { price: PRICE_TABLE[alias], id: alias, via: 'alias', known: true };
|
|
209
|
+
return { price: PRICE_TABLE[alias], id: alias, via: 'alias', source: 'seed', known: true };
|
|
156
210
|
}
|
|
157
211
|
if (has(PRICE_TABLE, model)) {
|
|
158
|
-
return { price: PRICE_TABLE[model], id: model, via: 'seed', known: true };
|
|
212
|
+
return { price: PRICE_TABLE[model], id: model, via: 'seed', source: 'seed', known: true };
|
|
159
213
|
}
|
|
160
|
-
return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', known: false };
|
|
214
|
+
return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', source: 'seed', known: false };
|
|
161
215
|
}
|
|
162
216
|
|
|
163
|
-
/**
|
|
217
|
+
/** 模型是否能解析出价格(远端精确/别名/家族,或本地表/内置表)。 */
|
|
164
218
|
export function isKnownModel(model) {
|
|
165
219
|
return lookup(model).known;
|
|
166
220
|
}
|
|
@@ -168,7 +222,9 @@ export function isKnownModel(model) {
|
|
|
168
222
|
/**
|
|
169
223
|
* 某模型在某一时刻的单位价格(元/百万 tokens)。
|
|
170
224
|
* 未知模型回退 chat 档;known=false 供调用方提示,避免静默算错。
|
|
171
|
-
* via
|
|
225
|
+
* via 说明取价路径,source 说明价格来自哪一层(remote 官方页本次同步 / file 本地价格表 /
|
|
226
|
+
* seed 内置兜底);layerAt 是该层数据的时间戳(remote.fetchedAt / file.updatedAt),
|
|
227
|
+
* 供界面说明"这份价是什么时候同步的"。
|
|
172
228
|
*/
|
|
173
229
|
export function unitAt(model, ms) {
|
|
174
230
|
const found = lookup(model);
|
|
@@ -183,6 +239,12 @@ export function unitAt(model, ms) {
|
|
|
183
239
|
requested: typeof model === 'string' ? model : '',
|
|
184
240
|
known: found.known,
|
|
185
241
|
via: found.via,
|
|
186
|
-
source: found.
|
|
242
|
+
source: found.source,
|
|
243
|
+
layerAt:
|
|
244
|
+
found.source === 'remote'
|
|
245
|
+
? (remoteMeta && Number.isFinite(remoteMeta.fetchedAt) ? remoteMeta.fetchedAt : null)
|
|
246
|
+
: found.source === 'file'
|
|
247
|
+
? (fileMeta && Number.isFinite(fileMeta.updatedAt) ? fileMeta.updatedAt : null)
|
|
248
|
+
: null,
|
|
187
249
|
};
|
|
188
250
|
}
|