dsh-plugin-billing 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/client.js +872 -0
- package/lib/index.js +563 -0
- package/lib/prices.js +202 -0
- package/lib/pricing.js +108 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
- package/pricing.json +23 -0
- package/scripts/build-client.mjs +97 -0
- package/scripts/update-prices.mjs +132 -0
- package/scripts/verify.mjs +1474 -0
- package/src/client-body.js +642 -0
- package/src/pricing-body.js +161 -0
package/lib/prices.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方定价页的抓取与解析 —— **Host 侧唯一实现**。
|
|
3
|
+
*
|
|
4
|
+
* 两个入口共用它:
|
|
5
|
+
*
|
|
6
|
+
* - 设置里那个「立即更新」按钮(`POST /plugins/billing/prices/refresh`);
|
|
7
|
+
* - 命令行 `node scripts/update-prices.mjs`(想看差异、想改仓库表时用)。
|
|
8
|
+
*
|
|
9
|
+
* 设计取向:**宁可失败也不写错钱**。解析只依赖几条可校验的结构事实,任一不成立就抛错、
|
|
10
|
+
* 一个字节都不写 —— 因为把"空闲价当高峰价"这类错误静默写进去,界面上的金额会一直错下去。
|
|
11
|
+
*
|
|
12
|
+
* 四条自校验:
|
|
13
|
+
* 1. 时段标签必须严格交替「空闲/高峰」共 3 轮;
|
|
14
|
+
* 2. 价格数字个数必须 = 6 × 模型数;
|
|
15
|
+
* 3. 每档必须满足「空闲 = 高峰的一半」(官方口径);
|
|
16
|
+
* 4. 币种必须解析成人民币(误抓英文 USD 页会被拦下)。
|
|
17
|
+
*/
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** 官方中文页:直接给人民币,不需要汇率换算。 */
|
|
23
|
+
export const DEFAULT_PRICING_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing';
|
|
24
|
+
|
|
25
|
+
/** 三个计费档,顺序即官方页的行序。 */
|
|
26
|
+
export const PRICE_BUCKETS = ['cacheHit', 'cacheMiss', 'output'];
|
|
27
|
+
|
|
28
|
+
/** 旧模型名:官方注(1) 说它们由 V4.1-Flash 提供服务并按 Flash 价计费。 */
|
|
29
|
+
export const LEGACY_ALIASES = ['deepseek-v4-flash', 'deepseek-v4-flash-vision-exp'];
|
|
30
|
+
|
|
31
|
+
/** `$DSH_HOME`(默认 `~/.dsh`)。 */
|
|
32
|
+
export function dshHome(env = process.env) {
|
|
33
|
+
const configured = env.DSH_HOME;
|
|
34
|
+
return configured !== undefined && configured.trim() !== '' ? configured.trim() : join(homedir(), '.dsh');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 本机覆盖文件路径 —— 按钮写的就是它(不动仓库里的表)。 */
|
|
38
|
+
export function overridePath(env = process.env) {
|
|
39
|
+
return join(dshHome(env), 'billing-prices.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 去掉脚本/样式与标签,得到可比对的纯文本。 */
|
|
43
|
+
export function toPlainText(html) {
|
|
44
|
+
return html
|
|
45
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
46
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
47
|
+
.replace(/<[^>]+>/g, '\n')
|
|
48
|
+
.replace(/ /g, ' ')
|
|
49
|
+
.replace(/&/g, '&')
|
|
50
|
+
.replace(/</g, '<')
|
|
51
|
+
.replace(/>/g, '>')
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter((line) => line !== '')
|
|
55
|
+
.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 从官方页纯文本里解析高峰价表。
|
|
60
|
+
* @param text - `toPlainText()` 的结果。
|
|
61
|
+
* @returns `{ models, peakPrices, offPeak, currency }`。
|
|
62
|
+
* @throws 结构不符合预期时抛出带原因的错误(调用方应把它原样显示给用户)。
|
|
63
|
+
*/
|
|
64
|
+
export function parsePricingText(text) {
|
|
65
|
+
const lines = text.split('\n');
|
|
66
|
+
|
|
67
|
+
// 1. 模型名:取价格区之前的表头,按出现顺序去重。
|
|
68
|
+
const priceAt = lines.findIndex((line) => /^价格/.test(line));
|
|
69
|
+
if (priceAt < 0) throw new Error('页面里找不到「价格」行 —— 官方页结构可能变了');
|
|
70
|
+
const models = [];
|
|
71
|
+
for (const line of lines.slice(0, priceAt)) {
|
|
72
|
+
for (const match of line.matchAll(/deepseek-[a-z0-9][a-z0-9-]*/g)) {
|
|
73
|
+
if (!models.includes(match[0])) models.push(match[0]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (models.length === 0) throw new Error('表头里没解析出模型名');
|
|
77
|
+
const width = models.length;
|
|
78
|
+
|
|
79
|
+
// 2. 价格区:从「价格」到「并发限制」。
|
|
80
|
+
const endAt = lines.findIndex((line, index) => index > priceAt && /^并发限制/.test(line));
|
|
81
|
+
const segment = lines.slice(priceAt, endAt < 0 ? lines.length : endAt);
|
|
82
|
+
|
|
83
|
+
// 3. 时段标签必须严格交替:空闲、高峰 × 3。
|
|
84
|
+
const periods = segment
|
|
85
|
+
.filter((line) => /^(空闲时段|高峰时段)/.test(line))
|
|
86
|
+
.map((line) => (/^空闲时段/.test(line) ? 'off' : 'peak'));
|
|
87
|
+
const expected = ['off', 'peak', 'off', 'peak', 'off', 'peak'];
|
|
88
|
+
if (periods.length !== expected.length || periods.some((value, index) => value !== expected[index])) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`时段标签不符合「空闲/高峰 × 3」的预期(实际 ${JSON.stringify(periods)})—— 官方页结构变了,或官方调整了空闲价政策`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. 价格数字:按出现顺序取「数字 + 币种」。
|
|
95
|
+
const numbers = [];
|
|
96
|
+
let currency = null;
|
|
97
|
+
for (const line of segment) {
|
|
98
|
+
for (const match of line.matchAll(/([0-9]+(?:\.[0-9]+)?)\s*(元|美元)/g)) {
|
|
99
|
+
currency = currency ?? match[2];
|
|
100
|
+
if (match[2] !== currency) throw new Error('同一页里出现了两种币种,无法判断该用哪个');
|
|
101
|
+
numbers.push(Number(match[1]));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (currency !== '元') {
|
|
105
|
+
throw new Error(`期望人民币(元),实际拿到 ${currency ?? '(没解析到价格)'} —— 请确认抓的是官方中文页`);
|
|
106
|
+
}
|
|
107
|
+
// 归一化:页面上的「元」只是解析用的字面量,存下来的必须是币种代码,
|
|
108
|
+
// 否则界面会显示成 "元/百万 tokens"。
|
|
109
|
+
const currencyCode = 'CNY';
|
|
110
|
+
if (numbers.length !== expected.length * width) {
|
|
111
|
+
throw new Error(`价格数字个数 ${numbers.length} ≠ 6 × 模型数 ${width} —— 官方页结构变了`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. 行序是「一行之内先各模型的值」,即 [时段行][模型]。
|
|
115
|
+
const offPeak = {};
|
|
116
|
+
const peakPrices = {};
|
|
117
|
+
PRICE_BUCKETS.forEach((bucket, bucketIndex) => {
|
|
118
|
+
const off = numbers.slice(bucketIndex * 2 * width, (bucketIndex * 2 + 1) * width);
|
|
119
|
+
const peak = numbers.slice((bucketIndex * 2 + 1) * width, (bucketIndex * 2 + 2) * width);
|
|
120
|
+
models.forEach((model, modelIndex) => {
|
|
121
|
+
offPeak[model] = offPeak[model] ?? {};
|
|
122
|
+
peakPrices[model] = peakPrices[model] ?? {};
|
|
123
|
+
offPeak[model][bucket] = off[modelIndex];
|
|
124
|
+
peakPrices[model][bucket] = peak[modelIndex];
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 6. 交叉验证官方口径「空闲 = 高峰的一半」。对不上说明行序被改,宁可失败也不写错钱。
|
|
129
|
+
for (const model of models) {
|
|
130
|
+
for (const bucket of PRICE_BUCKETS) {
|
|
131
|
+
const peak = peakPrices[model][bucket];
|
|
132
|
+
const off = offPeak[model][bucket];
|
|
133
|
+
if (!(peak > 0) || !(off > 0)) throw new Error(`${model}.${bucket} 价格缺失或非正数`);
|
|
134
|
+
if (Math.abs(off * 2 - peak) > 1e-9) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${model}.${bucket} 不满足「空闲 = 高峰的一半」(空闲 ${off},高峰 ${peak})—— 行序或政策变了`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { models, peakPrices, offPeak, currency: currencyCode };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 补上旧模型名(按官方注(1):与 Flash 同价)。 */
|
|
146
|
+
export function withLegacyAliases(peakPrices) {
|
|
147
|
+
const flash = peakPrices['deepseek-flash'];
|
|
148
|
+
if (flash === undefined) return peakPrices;
|
|
149
|
+
const next = { ...peakPrices };
|
|
150
|
+
for (const alias of LEGACY_ALIASES) if (next[alias] === undefined) next[alias] = { ...flash };
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 抓取 + 解析官方价(不落盘)。
|
|
156
|
+
* @param options - `{ url, fetchImpl }`。
|
|
157
|
+
* @returns `{ models, peakPrices, currency, url }`。
|
|
158
|
+
*/
|
|
159
|
+
export async function fetchOfficialPrices(options = {}) {
|
|
160
|
+
const url = options.url ?? DEFAULT_PRICING_URL;
|
|
161
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
162
|
+
const response = await fetchImpl(url, {
|
|
163
|
+
headers: { 'user-agent': 'dsh-plugin-billing/update-prices' },
|
|
164
|
+
});
|
|
165
|
+
if (!response || response.ok !== true) {
|
|
166
|
+
throw new Error(`抓取官方定价页失败:HTTP ${response?.status ?? '无响应'}`);
|
|
167
|
+
}
|
|
168
|
+
const parsed = parsePricingText(toPlainText(await response.text()));
|
|
169
|
+
return { ...parsed, peakPrices: withLegacyAliases(parsed.peakPrices), url };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 抓取并写入**本机覆盖文件**(按钮/CLI 的 `--override` 都走这里)。
|
|
174
|
+
* @param options - `{ url, fetchImpl, target, env }`。
|
|
175
|
+
* @returns `{ models, peakPrices, source, path, currency }`。
|
|
176
|
+
*/
|
|
177
|
+
export async function refreshPrices(options = {}) {
|
|
178
|
+
const fetched = await fetchOfficialPrices(options);
|
|
179
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
180
|
+
const source = `DeepSeek 官方定价页 · ${today} 抓取`;
|
|
181
|
+
const target = options.target ?? overridePath(options.env);
|
|
182
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
183
|
+
writeFileSync(
|
|
184
|
+
target,
|
|
185
|
+
`${JSON.stringify(
|
|
186
|
+
{ source, sourceUrl: fetched.url, fetchedAt: today, currency: fetched.currency, peakPrices: fetched.peakPrices },
|
|
187
|
+
null,
|
|
188
|
+
2,
|
|
189
|
+
)}\n`,
|
|
190
|
+
'utf8',
|
|
191
|
+
);
|
|
192
|
+
return { models: fetched.models, peakPrices: fetched.peakPrices, source, path: target, currency: fetched.currency };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 读当前覆盖文件(给界面显示"上次更新于")。 */
|
|
196
|
+
export function readOverride(env = process.env) {
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(readFileSync(overridePath(env), 'utf8'));
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 侧价格读取:每次请求(带短缓存)读 `pricing.json`,并按需叠加本机覆盖文件。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要放在 Host 侧读、再经路由下发:这样**改价不用重新构建客户端 bundle、也不用重启服务**。
|
|
5
|
+
* 客户端 bundle 里内联的那份表只是兜底(路由不可用时仍能计价)。
|
|
6
|
+
*
|
|
7
|
+
* 覆盖文件:`$DSH_HOME/billing-prices.json`(不存在即忽略),形状可以是
|
|
8
|
+
* { "peakPrices": { "模型id": { "cacheHit": 0.04, "cacheMiss": 2, "output": 8 } }, "source": "..." }
|
|
9
|
+
* 或直接就是 `{ "模型id": { ... } }`。用于官方调价、或接入别的提供方时的临时修正。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** 内置表:与客户端 bundle 内联的是同一份文件(唯一事实来源)。 */
|
|
16
|
+
const BUILTIN_URL = new URL('../pricing.json', import.meta.url);
|
|
17
|
+
|
|
18
|
+
/** 默认缓存时长:足够热,又能让"编辑 JSON 后几秒内生效"。 */
|
|
19
|
+
const DEFAULT_TTL_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 校验一条单价记录。
|
|
23
|
+
* @param value - 候选值。
|
|
24
|
+
* @returns 是否是合法的高峰价记录。
|
|
25
|
+
*/
|
|
26
|
+
function isRate(value) {
|
|
27
|
+
if (value === null || typeof value !== 'object') return false;
|
|
28
|
+
return ['cacheHit', 'cacheMiss', 'output'].every((key) => {
|
|
29
|
+
const n = value[key];
|
|
30
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 读一个 JSON 文件;不存在或损坏时返回 undefined(不抛错)。 */
|
|
35
|
+
function readJson(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(url, 'utf8'));
|
|
38
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : undefined;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 创建价格读取器。
|
|
46
|
+
* @param options - `{ home, ttlMs }`;`home` 默认 `$DSH_HOME` 或 `~/.dsh`。
|
|
47
|
+
* @returns `{ read, files }`;`read()` 返回 `{ prices, source, currency, overrideUsed }`。
|
|
48
|
+
*/
|
|
49
|
+
export function createPricingReader(options = {}) {
|
|
50
|
+
const home = options.home ?? ((process.env.DSH_HOME ?? '').trim() || join(homedir(), '.dsh'));
|
|
51
|
+
const overrideUrl = options.overridePath ?? join(home, 'billing-prices.json');
|
|
52
|
+
const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs >= 0 ? options.ttlMs : DEFAULT_TTL_MS;
|
|
53
|
+
let cache;
|
|
54
|
+
|
|
55
|
+
const compute = () => {
|
|
56
|
+
const builtin = readJson(BUILTIN_URL) ?? {};
|
|
57
|
+
const prices = {};
|
|
58
|
+
for (const [model, rate] of Object.entries(builtin.peakPrices ?? {})) {
|
|
59
|
+
if (isRate(rate)) prices[model] = { cacheHit: rate.cacheHit, cacheMiss: rate.cacheMiss, output: rate.output };
|
|
60
|
+
}
|
|
61
|
+
let source = typeof builtin.source === 'string' ? builtin.source : 'built-in pricing.json';
|
|
62
|
+
let currency = typeof builtin.currency === 'string' ? builtin.currency : 'CNY';
|
|
63
|
+
let overrideUsed = false;
|
|
64
|
+
|
|
65
|
+
const override = readJson(overrideUrl);
|
|
66
|
+
if (override !== undefined) {
|
|
67
|
+
const extra = override.peakPrices ?? override.prices ?? override;
|
|
68
|
+
if (extra !== null && typeof extra === 'object') {
|
|
69
|
+
for (const [model, rate] of Object.entries(extra)) {
|
|
70
|
+
if (!isRate(rate)) continue;
|
|
71
|
+
prices[model] = { cacheHit: rate.cacheHit, cacheMiss: rate.cacheMiss, output: rate.output };
|
|
72
|
+
overrideUsed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (typeof override.source === 'string' && override.source !== '') source = override.source;
|
|
76
|
+
if (typeof override.currency === 'string' && override.currency !== '') currency = override.currency;
|
|
77
|
+
}
|
|
78
|
+
return { prices, source, currency, overrideUsed };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
/**
|
|
83
|
+
* 读当前价格表(带 TTL 缓存)。
|
|
84
|
+
* @returns `{ prices, source, currency, overrideUsed }`。
|
|
85
|
+
*/
|
|
86
|
+
read() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
if (cache !== undefined && now - cache.at < ttlMs) return cache.value;
|
|
89
|
+
const value = compute();
|
|
90
|
+
cache = { at: now, value };
|
|
91
|
+
return value;
|
|
92
|
+
},
|
|
93
|
+
/** 供启动日志/状态页展示用(不含敏感信息)。 */
|
|
94
|
+
files() {
|
|
95
|
+
return {
|
|
96
|
+
builtin: BUILTIN_URL.pathname,
|
|
97
|
+
override: overrideUrl,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
/**
|
|
101
|
+
* 丢弃缓存,让下一次 `read()` 重新读盘。
|
|
102
|
+
* 按钮刚写完覆盖文件就调它,界面无需等 TTL 过期。
|
|
103
|
+
*/
|
|
104
|
+
invalidate() {
|
|
105
|
+
cache = undefined;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-billing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Balance, this-turn and this-session cost beside the composer stats, with one-click price updates",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"icon": "./icon.svg",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./client": {
|
|
13
|
+
"default": "./lib/client.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./locale/*.json": "./locale/*.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"src",
|
|
21
|
+
"scripts",
|
|
22
|
+
"locale",
|
|
23
|
+
"icon.svg",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"pricing.json"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "node scripts/build-client.mjs",
|
|
29
|
+
"verify": "node scripts/verify.mjs"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"dsh",
|
|
33
|
+
"dsh-plugin",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"billing",
|
|
36
|
+
"usage",
|
|
37
|
+
"balance"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"dsh": {
|
|
41
|
+
"bundle": {
|
|
42
|
+
"patch": "./cordis.patch.yml"
|
|
43
|
+
},
|
|
44
|
+
"client": {
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-locale",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
48
|
+
],
|
|
49
|
+
"platform": "web"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/pricing.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"计费单价表 —— 唯一事实来源。客户端 bundle 会把它内联为兜底,Host 侧每次请求读取它并以路由字段下发,",
|
|
4
|
+
"因此改价只需编辑本文件(或 $DSH_HOME/billing-prices.json 覆盖),无需重新构建、也无需重启。",
|
|
5
|
+
"单位:元 / 百万 tokens,且只登记**高峰时段**价;空闲时段价由代码按倍率 0.5 折算(官方口径:空闲为高峰一半)。",
|
|
6
|
+
"高峰时段(北京时间):周一至周五 09:00-12:00、14:00-18:00。",
|
|
7
|
+
"字段:cacheHit=缓存命中输入,cacheMiss=缓存未命中输入(缓存写入并入此档),output=输出。"
|
|
8
|
+
],
|
|
9
|
+
"source": "DeepSeek 官方定价页 · 2026-09-18 抓取",
|
|
10
|
+
"sourceUrl": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing",
|
|
11
|
+
"fetchedAt": "2026-09-18",
|
|
12
|
+
"currency": "CNY",
|
|
13
|
+
"peakPrices": {
|
|
14
|
+
"deepseek-flash": { "cacheHit": 0.04, "cacheMiss": 2, "output": 8 },
|
|
15
|
+
"deepseek-v4-flash": { "cacheHit": 0.04, "cacheMiss": 2, "output": 8 },
|
|
16
|
+
"deepseek-v4-flash-vision-exp": { "cacheHit": 0.04, "cacheMiss": 2, "output": 8 },
|
|
17
|
+
"deepseek-v4-pro": { "cacheHit": 0.3, "cacheMiss": 9, "output": 27 }
|
|
18
|
+
},
|
|
19
|
+
"_aliases": {
|
|
20
|
+
"deepseek-v4-flash": "官方说明:该旧模型名对应模型已下线,请求由 DeepSeek-V4.1-Flash 提供服务,并按 Flash 价格计费,故与 deepseek-flash 同价。",
|
|
21
|
+
"deepseek-v4-flash-vision-exp": "同上(旧名,模型已下线,按 Flash 价格计费)。"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 组装浏览器侧 bundle `lib/client.js`。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不直接手写 bundle:外壳执行的 classic script 只能 `require` 统一基座里的
|
|
6
|
+
* 共享模块,无法 `import` 本地文件。于是把两个纯声明源文件拼进 factory 闭包,
|
|
7
|
+
* 并把 React / jsx-runtime / UI 原语接成闭包变量:
|
|
8
|
+
*
|
|
9
|
+
* src/pricing-body.js 单价表、高峰/空闲判定、计价
|
|
10
|
+
* src/client-body.js 文案字典、金额行组件、apply/inject
|
|
11
|
+
*
|
|
12
|
+
* 用法:node scripts/build-client.mjs
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const root = join(here, '..');
|
|
20
|
+
const PACKAGE_ID = 'dsh-plugin-billing';
|
|
21
|
+
|
|
22
|
+
const read = (relative) => readFileSync(join(root, relative), 'utf8').trimEnd();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 把 `pricing.json` 的高峰价表内联进客户端 bundle 的兜底位置。
|
|
26
|
+
*
|
|
27
|
+
* 这只是**兜底**:Host 侧每次请求都会读同一份文件并通过路由 `prices` 字段下发,客户端优先用它。
|
|
28
|
+
* 所以正常改价只需编辑 `pricing.json`(或 $DSH_HOME/billing-prices.json),
|
|
29
|
+
* 既不用重新跑本脚本,也不用重启服务。
|
|
30
|
+
*/
|
|
31
|
+
const pricing = JSON.parse(readFileSync(join(root, 'pricing.json'), 'utf8'));
|
|
32
|
+
const pricingBody = read('src/pricing-body.js').replace(
|
|
33
|
+
'{/*__PRICES__*/}',
|
|
34
|
+
JSON.stringify(pricing.peakPrices, null, 2).split('\n').join('\n'),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** 统一基座提供的共享模块(不属于 `dsh.client.external` 的额外请求)。 */
|
|
38
|
+
const CLOSURE = [
|
|
39
|
+
['react', 'react'],
|
|
40
|
+
['jsxRuntime', 'react/jsx-runtime'],
|
|
41
|
+
['primitives', '@deepseek-ai/dsh-client-ui-primitives'],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const bundle = `window.__ModuleLoader__.load({
|
|
45
|
+
id: ${JSON.stringify(PACKAGE_ID)},
|
|
46
|
+
factory: (require) => {
|
|
47
|
+
var module = { exports: {} };
|
|
48
|
+
var exports = module.exports;
|
|
49
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
50
|
+
${CLOSURE.map(([name, specifier]) => `\t\tlet ${name} = require(${JSON.stringify(specifier)});`).join('\n')}
|
|
51
|
+
//#region src/pricing-body.js —— 由 scripts/build-client.mjs 拼入(含 pricing.json 内联)
|
|
52
|
+
${pricingBody}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/client-body.js —— 由 scripts/build-client.mjs 拼入
|
|
55
|
+
${read('src/client-body.js')}
|
|
56
|
+
//#endregion
|
|
57
|
+
exports.apply = apply;
|
|
58
|
+
exports.inject = inject;
|
|
59
|
+
/** 测试接缝:仅供 scripts/verify.mjs 驱动纯函数,不是公开 API。 */
|
|
60
|
+
exports.__testing = {
|
|
61
|
+
foldTurns,
|
|
62
|
+
composeView,
|
|
63
|
+
priceTable,
|
|
64
|
+
selectNodes,
|
|
65
|
+
fallbackTranslate,
|
|
66
|
+
isPeak,
|
|
67
|
+
ratesFor,
|
|
68
|
+
bucketsOf,
|
|
69
|
+
addBuckets,
|
|
70
|
+
costOf,
|
|
71
|
+
formatMoney,
|
|
72
|
+
formatTokens,
|
|
73
|
+
CSS,
|
|
74
|
+
ROOT_CLASS,
|
|
75
|
+
PILL_CLASS,
|
|
76
|
+
SEP_CLASS,
|
|
77
|
+
ROW_CLASS,
|
|
78
|
+
ROW_TITLE_CLASS,
|
|
79
|
+
ROW_DESC_CLASS,
|
|
80
|
+
ROW_ERROR_CLASS,
|
|
81
|
+
PricesRow,
|
|
82
|
+
usePricesEpoch,
|
|
83
|
+
bumpPricesEpoch,
|
|
84
|
+
pricesEpochOf: () => pricesEpoch,
|
|
85
|
+
PRICES_URL,
|
|
86
|
+
PRICES_REFRESH_URL,
|
|
87
|
+
NS
|
|
88
|
+
};
|
|
89
|
+
return module.exports;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
writeFileSync(join(root, 'lib', 'client.js'), bundle, 'utf8');
|
|
95
|
+
console.log(`✔ 已生成 lib/client.js(${Buffer.byteLength(bundle)} 字节)`);
|
|
96
|
+
console.log(` 共享模块:${CLOSURE.map(([, s]) => s).join(', ')}`);
|
|
97
|
+
console.log(` 内联价格表:${Object.keys(pricing.peakPrices).length} 个模型(${pricing.source})`);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 命令行入口:对比/更新官方定价。
|
|
4
|
+
*
|
|
5
|
+
* 核心逻辑(抓取、四条结构自校验、写覆盖文件)都在 `lib/prices.js` —— **和界面上的
|
|
6
|
+
* 「立即更新」按钮共用同一份实现**,不存在两套解析代码各错各的。
|
|
7
|
+
*
|
|
8
|
+
* 这个脚本存在的意义是"想看清楚再改":默认只打印差异,改哪儿由你决定。
|
|
9
|
+
* 日常更新直接点设置里的按钮就行。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/update-prices.mjs # 只对比,不改文件(默认)
|
|
13
|
+
* node scripts/update-prices.mjs --write # 更新仓库里的 pricing.json(旧表备份为 .bak)
|
|
14
|
+
* node scripts/update-prices.mjs --override # 只写 $DSH_HOME/billing-prices.json(本机生效)
|
|
15
|
+
* node scripts/update-prices.mjs --url <页面地址> # 换数据源(调试用)
|
|
16
|
+
* node scripts/update-prices.mjs --help
|
|
17
|
+
*/
|
|
18
|
+
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { DEFAULT_PRICING_URL, fetchOfficialPrices, overridePath, refreshPrices } from '../lib/prices.js';
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const root = join(here, '..');
|
|
25
|
+
const PRICING_PATH = join(root, 'pricing.json');
|
|
26
|
+
|
|
27
|
+
/** 命令行里认识的开关;写错了宁可报错也不要"静默按默认跑"。 */
|
|
28
|
+
const KNOWN_FLAGS = new Set(['--help', '-h', '--write', '--override', '--url']);
|
|
29
|
+
|
|
30
|
+
/** 打印用法。 */
|
|
31
|
+
function printUsage() {
|
|
32
|
+
console.log(`用法:node scripts/update-prices.mjs [选项]
|
|
33
|
+
|
|
34
|
+
(不带选项) 只抓取 + 自校验 + 打印与本地表的差异,**不改任何文件**
|
|
35
|
+
--write 更新仓库里的 pricing.json(旧表备份为 pricing.json.bak)
|
|
36
|
+
--override 只写 $DSH_HOME/billing-prices.json(本机生效,不动仓库)
|
|
37
|
+
--url <地址> 换数据源,默认官方中文页 ${DEFAULT_PRICING_URL}
|
|
38
|
+
-h, --help 显示这段说明
|
|
39
|
+
|
|
40
|
+
自校验(任一不满足就拒绝写入,不会写错钱):
|
|
41
|
+
· 时段标签必须严格交替「空闲/高峰」共 3 轮
|
|
42
|
+
· 价格数字个数必须 = 6 × 模型数
|
|
43
|
+
· 每个档位必须满足「空闲 = 高峰的一半」(官方口径)
|
|
44
|
+
· 币种必须解析成人民币(误抓英文 USD 页会被拦下)
|
|
45
|
+
|
|
46
|
+
生效方式:Host 每 5 秒热读 pricing.json 与 $DSH_HOME/billing-prices.json,
|
|
47
|
+
写入后 ≤5 秒生效 —— 不用重建 bundle,也不用重启客户端。
|
|
48
|
+
界面上也可以直接点:设置 → 通用设置 → 用量价格表 → 立即更新。`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 打印两个价格表的差异。 */
|
|
52
|
+
function diffTable(before, after) {
|
|
53
|
+
const models = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
|
54
|
+
return models
|
|
55
|
+
.map((model) => {
|
|
56
|
+
const old = before[model];
|
|
57
|
+
const now = after[model];
|
|
58
|
+
const cell = (rate) => (rate === undefined ? '—' : `${rate.cacheHit}/${rate.cacheMiss}/${rate.output}`);
|
|
59
|
+
const changed = JSON.stringify(old) !== JSON.stringify(now);
|
|
60
|
+
return ` ${changed ? '≠' : '='} ${model.padEnd(32)} ${cell(old).padEnd(18)} → ${cell(now)}`;
|
|
61
|
+
})
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
const flag = (name) => argv.includes(name);
|
|
67
|
+
const value = (name) => {
|
|
68
|
+
const index = argv.indexOf(name);
|
|
69
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const unknown = argv.filter((token) => token.startsWith('-') && !KNOWN_FLAGS.has(token));
|
|
73
|
+
if (unknown.length > 0) {
|
|
74
|
+
console.error(`✘ 不认识的选项:${unknown.join(' ')}`);
|
|
75
|
+
printUsage();
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
if (flag('--help') || flag('-h')) {
|
|
79
|
+
printUsage();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const url = value('--url') ?? DEFAULT_PRICING_URL;
|
|
84
|
+
console.log(`数据源: ${url}`);
|
|
85
|
+
|
|
86
|
+
let fetched;
|
|
87
|
+
try {
|
|
88
|
+
fetched = await fetchOfficialPrices({ url });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`✘ ${error.message}`);
|
|
91
|
+
console.error(' (官方页改版或官方调整了空闲价政策时,宁可失败也不会写错钱)');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
console.log(`解析到 ${fetched.models.length} 个模型:${fetched.models.join(', ')}`);
|
|
95
|
+
console.log('结构自校验通过:时段标签交替、数字个数匹配、空闲 = 高峰的一半 ✅');
|
|
96
|
+
|
|
97
|
+
const current = JSON.parse(readFileSync(PRICING_PATH, 'utf8'));
|
|
98
|
+
console.log('\n高峰价(元 / 百万 tokens)差异: 缓存命中/未命中/输出');
|
|
99
|
+
console.log(diffTable(current.peakPrices ?? {}, fetched.peakPrices));
|
|
100
|
+
|
|
101
|
+
if (JSON.stringify(current.peakPrices) === JSON.stringify(fetched.peakPrices)) {
|
|
102
|
+
console.log('\n本地表已是最新 ✅(界面上点「立即更新」可以随时重新核对)');
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const write = flag('--write');
|
|
107
|
+
const override = flag('--override');
|
|
108
|
+
if (!write && !override) {
|
|
109
|
+
console.log('\n(默认只对比不改文件;确认无误后加 --write 更新仓库表,或加 --override 只在本机生效)');
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (override) {
|
|
114
|
+
const result = await refreshPrices({ url });
|
|
115
|
+
console.log(`\n✔ 已写入本机覆盖文件 ${result.path}(Host 立刻生效,无需重启)`);
|
|
116
|
+
console.log(` 数据源标注:${result.source}`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
121
|
+
const payload = {
|
|
122
|
+
...current,
|
|
123
|
+
source: `DeepSeek 官方定价页 · ${today} 抓取`,
|
|
124
|
+
sourceUrl: url,
|
|
125
|
+
fetchedAt: today,
|
|
126
|
+
currency: 'CNY',
|
|
127
|
+
peakPrices: fetched.peakPrices,
|
|
128
|
+
};
|
|
129
|
+
copyFileSync(PRICING_PATH, `${PRICING_PATH}.bak`);
|
|
130
|
+
writeFileSync(PRICING_PATH, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
131
|
+
console.log(`\n✔ 已更新 ${PRICING_PATH}(旧表备份为 pricing.json.bak;Host 5 秒内自动生效)`);
|
|
132
|
+
console.log(' 建议接着跑一次:node scripts/verify.mjs');
|