dsh-key-usage 0.1.2 → 0.1.4
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 +115 -24
- package/client/client.js +2793 -419
- package/client/client.js.map +1 -1
- package/docs/images/usage-daily.png +0 -0
- package/docs/images/usage-overview.png +0 -0
- package/docs/images/usage-quota.png +0 -0
- package/docs/images//346/214/211/346/250/241/345/236/213.png +0 -0
- package/docs/images//351/241/271/347/233/256/346/200/273/351/207/217.png +0 -0
- package/lib/index.js +73 -4
- package/lib/live-quota.js +96 -30
- package/lib/merge.js +8 -1
- package/lib/pricing-catalog.js +349 -0
- package/lib/pricing.js +500 -0
- package/lib/quota-snapshots.js +68 -0
- package/lib/routes.js +275 -28
- package/lib/session-usage.js +67 -46
- package/lib/sessions.js +98 -8
- package/package.json +18 -9
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计费规则:用户可维护的单价表,作为成本估算的一手依据(替代「台账反推均价」)。
|
|
3
|
+
*
|
|
4
|
+
* 规则本体存放在宿主设置服务的 `dsh-key-usage` 命名空间里(见 index.ts 的 Config),
|
|
5
|
+
* 本模块只做纯函数:归一化、优先级匹配、错峰判定、单位换算与费用 / 缓存省钱计算。
|
|
6
|
+
* 不读文件、不联网,因此可以完全离线单测。
|
|
7
|
+
*
|
|
8
|
+
* 成本优先级:台账当天的**官方成本**(routes 直接用台账字段)> 用户规则 > 内置官方价目 > 台账反推均价
|
|
9
|
+
* > 无(显示 —)。本模块负责「用户规则」与「内置官方价目」这两层。
|
|
10
|
+
*/
|
|
11
|
+
import { catalogRuleFor } from './pricing-catalog.js';
|
|
12
|
+
export const PRICING_UNITS = ['perMillionTokens', 'perThousandTokens', 'perCall'];
|
|
13
|
+
const UNIT_FACTORS = {
|
|
14
|
+
perMillionTokens: 1e-6,
|
|
15
|
+
perThousandTokens: 1e-3,
|
|
16
|
+
// 按次计费与 token 数无关,单价字段直接用「输入」档当每次调用价
|
|
17
|
+
perCall: 0,
|
|
18
|
+
};
|
|
19
|
+
/** 规则条数上限:设置里的值不可信,超出的部分直接丢弃,避免一次请求把 CPU 打满。 */
|
|
20
|
+
const MAX_RULES = 200;
|
|
21
|
+
export const EMPTY_PRICING = { version: 1, currency: 'CNY', rules: [] };
|
|
22
|
+
export const EMPTY_PRICING_VIEW = { config: EMPTY_PRICING, writable: false, revision: null };
|
|
23
|
+
/** 单位文字说明(界面与文档共用一份,避免两侧口径漂移)。 */
|
|
24
|
+
export const UNIT_LABELS = {
|
|
25
|
+
perMillionTokens: '每百万 tokens',
|
|
26
|
+
perThousandTokens: '每千 tokens',
|
|
27
|
+
perCall: '每次调用',
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* 设置服务里的 volatile 字段是 `{ get() }` 引用而不是裸值;
|
|
31
|
+
* 框架用 `updateVolatile` 原地换内容,所以每次现取即是最新值。
|
|
32
|
+
*/
|
|
33
|
+
export function unwrapSettingValue(value) {
|
|
34
|
+
if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
|
|
35
|
+
return value.get();
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function finiteOr(value, fallback) {
|
|
40
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
41
|
+
}
|
|
42
|
+
function textOr(value, fallback) {
|
|
43
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback;
|
|
44
|
+
}
|
|
45
|
+
function ratesOf(raw, fallback) {
|
|
46
|
+
return {
|
|
47
|
+
input: finiteOr(raw.input, fallback.input),
|
|
48
|
+
cacheRead: finiteOr(raw.cacheRead, fallback.cacheRead),
|
|
49
|
+
cacheWrite: finiteOr(raw.cacheWrite, fallback.cacheWrite),
|
|
50
|
+
output: finiteOr(raw.output, fallback.output),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `days` 归一:只接受 1–7 的整数并去重排序。
|
|
55
|
+
* 非法值(空数组 / 越界 / 非整数 / 非数组)一律**返回 undefined = 每天适用**:
|
|
56
|
+
* 这些值只会来自手改 JSON 或旧配置,把整段错峰作废会让金额突然变高,
|
|
57
|
+
* 而「每天适用」正是缺省语义,最保守也最接近用户原意(不静默改变已有行为)。
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeDays(raw) {
|
|
60
|
+
if (!Array.isArray(raw))
|
|
61
|
+
return undefined;
|
|
62
|
+
const days = new Set();
|
|
63
|
+
for (const entry of raw) {
|
|
64
|
+
if (typeof entry !== 'number' || !Number.isInteger(entry) || entry < 1 || entry > 7)
|
|
65
|
+
continue;
|
|
66
|
+
days.add(entry);
|
|
67
|
+
}
|
|
68
|
+
if (days.size === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
return [...days].sort((first, second) => first - second);
|
|
71
|
+
}
|
|
72
|
+
function windowsOf(raw) {
|
|
73
|
+
if (!Array.isArray(raw))
|
|
74
|
+
return [];
|
|
75
|
+
const windows = [];
|
|
76
|
+
for (const entry of raw) {
|
|
77
|
+
if (typeof entry !== 'object' || entry === null)
|
|
78
|
+
continue;
|
|
79
|
+
const from = entry.from;
|
|
80
|
+
const to = entry.to;
|
|
81
|
+
if (clockMinutes(from) === null || clockMinutes(to) === null)
|
|
82
|
+
continue;
|
|
83
|
+
const days = normalizeDays(entry.days);
|
|
84
|
+
windows.push({ from: from.trim(), to: to.trim(), ...(days === undefined ? {} : { days }) });
|
|
85
|
+
}
|
|
86
|
+
return windows;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 防御式解析设置值:设置服务里的内容是用户手填 / 导入的,任何字段都可能缺失或类型不对。
|
|
90
|
+
* 无法解释的规则整条丢弃(而不是填 0),否则 0 价会被当成「免费」静默算错。
|
|
91
|
+
*/
|
|
92
|
+
export function normalizePricing(raw) {
|
|
93
|
+
const value = unwrapSettingValue(raw);
|
|
94
|
+
if (typeof value !== 'object' || value === null)
|
|
95
|
+
return EMPTY_PRICING;
|
|
96
|
+
const record = value;
|
|
97
|
+
const currency = textOr(record.currency, EMPTY_PRICING.currency);
|
|
98
|
+
const rules = [];
|
|
99
|
+
const rawRules = Array.isArray(record.rules) ? record.rules.slice(0, MAX_RULES) : [];
|
|
100
|
+
for (const entry of rawRules) {
|
|
101
|
+
if (typeof entry !== 'object' || entry === null)
|
|
102
|
+
continue;
|
|
103
|
+
const rule = entry;
|
|
104
|
+
const provider = textOr(rule.provider, '*');
|
|
105
|
+
const model = textOr(rule.model, '*');
|
|
106
|
+
const unit = PRICING_UNITS.includes(rule.unit) ? rule.unit : 'perMillionTokens';
|
|
107
|
+
const base = ratesOf(rule, { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
|
|
108
|
+
let offPeak = null;
|
|
109
|
+
if (typeof rule.offPeak === 'object' && rule.offPeak !== null) {
|
|
110
|
+
const offPeakRecord = rule.offPeak;
|
|
111
|
+
const windows = windowsOf(offPeakRecord.windows);
|
|
112
|
+
// 没有可用窗口的错峰段等同于没填:留着会让人以为折扣生效了
|
|
113
|
+
if (windows.length > 0)
|
|
114
|
+
offPeak = { ...ratesOf(offPeakRecord, base), windows };
|
|
115
|
+
}
|
|
116
|
+
rules.push({
|
|
117
|
+
provider,
|
|
118
|
+
model,
|
|
119
|
+
unit,
|
|
120
|
+
currency: textOr(rule.currency, currency),
|
|
121
|
+
...base,
|
|
122
|
+
offPeak,
|
|
123
|
+
note: typeof rule.note === 'string' ? rule.note : '',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
version: typeof record.version === 'number' && Number.isFinite(record.version) ? record.version : 1,
|
|
128
|
+
currency,
|
|
129
|
+
rules,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** `HH:MM` → 当天分钟数;`24:00` 归一为 1440。无法解析返回 null。 */
|
|
133
|
+
export function clockMinutes(text) {
|
|
134
|
+
if (typeof text !== 'string')
|
|
135
|
+
return null;
|
|
136
|
+
const match = text.trim().match(/^(\d{1,2}):(\d{2})$/);
|
|
137
|
+
if (match === null)
|
|
138
|
+
return null;
|
|
139
|
+
const hours = Number(match[1]);
|
|
140
|
+
const minutes = Number(match[2]);
|
|
141
|
+
if (minutes > 59)
|
|
142
|
+
return null;
|
|
143
|
+
if (hours === 24)
|
|
144
|
+
return minutes === 0 ? 1440 : null;
|
|
145
|
+
if (hours > 23)
|
|
146
|
+
return null;
|
|
147
|
+
return hours * 60 + minutes;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 规则匹配:`provider+model` 精确 → `provider+*` → `*+model` → `*+*`。
|
|
151
|
+
* 同一档内按规则声明顺序取第一条,用户可以用上下移动调整优先级。
|
|
152
|
+
*/
|
|
153
|
+
export function matchPricingRule(rules, provider, model) {
|
|
154
|
+
const tiers = [
|
|
155
|
+
(rule) => rule.provider === provider && rule.model === model,
|
|
156
|
+
(rule) => rule.provider === provider && rule.model === '*',
|
|
157
|
+
(rule) => rule.provider === '*' && rule.model === model,
|
|
158
|
+
(rule) => rule.provider === '*' && rule.model === '*',
|
|
159
|
+
];
|
|
160
|
+
for (const matches of tiers) {
|
|
161
|
+
const hit = rules.find(matches);
|
|
162
|
+
if (hit !== undefined)
|
|
163
|
+
return hit;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 找一条可用于该行的价目:用户规则优先,没命中再查内置官方价目。
|
|
169
|
+
* 内置价目不落盘、用户也不可编辑,因此只有「查得到 / 查不到」两种结果。
|
|
170
|
+
*/
|
|
171
|
+
export function matchPricedRule(rules, provider, model) {
|
|
172
|
+
const userRule = matchPricingRule(rules, provider, model);
|
|
173
|
+
if (userRule !== null)
|
|
174
|
+
return { rule: userRule, source: 'rule' };
|
|
175
|
+
const catalogRule = catalogRuleFor(provider, model);
|
|
176
|
+
if (catalogRule !== null)
|
|
177
|
+
return { rule: catalogRule, source: 'catalog' };
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 一个时间点是否落在规则的错峰窗口内(按**本地时间**判断,跨午夜窗口取并集)。
|
|
182
|
+
* 星期几也取本地时间,与时间判定保持同一口径。
|
|
183
|
+
*/
|
|
184
|
+
export function isOffPeakAt(rule, at) {
|
|
185
|
+
if (rule.offPeak === null)
|
|
186
|
+
return false;
|
|
187
|
+
return isOffPeakAtMinutes(rule, at.getHours() * 60 + at.getMinutes(), isoDayOf(at));
|
|
188
|
+
}
|
|
189
|
+
/** ISO 星期:1=周一 … 7=周日(`Date.getDay()` 的 0=周日 映射过来)。 */
|
|
190
|
+
export function isoDayOf(at) {
|
|
191
|
+
const day = at.getDay();
|
|
192
|
+
return day === 0 ? 7 : day;
|
|
193
|
+
}
|
|
194
|
+
function isOffPeakAtMinutes(rule, minutes, day) {
|
|
195
|
+
if (rule.offPeak === null)
|
|
196
|
+
return false;
|
|
197
|
+
for (const window of rule.offPeak.windows) {
|
|
198
|
+
// days 缺省或非法都当作「每天」;这里再归一一次,防住绕过 normalizePricing 的调用方
|
|
199
|
+
const days = normalizeDays(window.days);
|
|
200
|
+
if (day !== undefined && days !== undefined && !days.includes(day))
|
|
201
|
+
continue;
|
|
202
|
+
const from = clockMinutes(window.from);
|
|
203
|
+
const to = clockMinutes(window.to);
|
|
204
|
+
if (from === null || to === null)
|
|
205
|
+
continue;
|
|
206
|
+
// 起止相同视为「全天」:用户写 00:00–00:00 想表达的是一整天
|
|
207
|
+
if (from === to)
|
|
208
|
+
return true;
|
|
209
|
+
if (from < to) {
|
|
210
|
+
if (minutes >= from && minutes < to)
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
else if (minutes >= from || minutes < to) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
/** 该规则此刻是否处于错峰(规则面板上的「错峰中」角标)。 */
|
|
220
|
+
export function isOffPeakNow(rule, at = new Date()) {
|
|
221
|
+
return isOffPeakAt(rule, at);
|
|
222
|
+
}
|
|
223
|
+
/** 规则生效的四档单价;`at` 为 null(无法定位时间点)时按标准价。 */
|
|
224
|
+
export function ratesAt(rule, at) {
|
|
225
|
+
if (at === null || rule.offPeak === null || !isOffPeakAt(rule, at)) {
|
|
226
|
+
return { rates: rule, offPeak: false };
|
|
227
|
+
}
|
|
228
|
+
return { rates: rule.offPeak, offPeak: true };
|
|
229
|
+
}
|
|
230
|
+
/** 展示口径 tokens:输入 + 输出 + 缓存读(不含 cacheWrite,与界面口径一致)。 */
|
|
231
|
+
export function displayTokensOf(row) {
|
|
232
|
+
if (typeof row.tokens === 'number')
|
|
233
|
+
return row.tokens;
|
|
234
|
+
return (row.inputTokens ?? 0) + (row.outputTokens ?? 0) + (row.cacheReadTokens ?? 0);
|
|
235
|
+
}
|
|
236
|
+
function costAtRates(row, rule, rates) {
|
|
237
|
+
if (rule.unit === 'perCall')
|
|
238
|
+
return (row.calls ?? 0) * rates.input;
|
|
239
|
+
const factor = UNIT_FACTORS[rule.unit];
|
|
240
|
+
return ((row.inputTokens ?? 0) * rates.input
|
|
241
|
+
+ (row.cacheReadTokens ?? 0) * rates.cacheRead
|
|
242
|
+
+ (row.cacheWriteTokens ?? 0) * rates.cacheWrite
|
|
243
|
+
+ (row.outputTokens ?? 0) * rates.output) * factor;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 错峰权重:一天里落在错峰窗口内的 tokens 占比(按小时槽取样,小时粒度取该小时中点判定)。
|
|
247
|
+
* 小时槽只有整天的分桶、没有「模型 × 小时」的拆分,所以这是近似值;调用方拿不到小时数据时按 0 处理。
|
|
248
|
+
* 只有整天的分桶、拿不到日期,因此带 `days` 的窗口在这里按「每天适用」算 —— 与实际一周里的
|
|
249
|
+
* 星期分布无关,属有意近似(单日范围的加权本就更粗)。
|
|
250
|
+
*/
|
|
251
|
+
export function offPeakTokenShare(rule, hours) {
|
|
252
|
+
if (rule.offPeak === null || hours === null || hours.length === 0)
|
|
253
|
+
return 0;
|
|
254
|
+
let total = 0;
|
|
255
|
+
let offPeak = 0;
|
|
256
|
+
for (let index = 0; index < hours.length; index += 1) {
|
|
257
|
+
const tokens = displayTokensOf(hours[index]);
|
|
258
|
+
if (tokens <= 0)
|
|
259
|
+
continue;
|
|
260
|
+
total += tokens;
|
|
261
|
+
if (isOffPeakAtMinutes(rule, index * 60 + 30))
|
|
262
|
+
offPeak += tokens;
|
|
263
|
+
}
|
|
264
|
+
return total > 0 ? offPeak / total : 0;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 按规则估算一组行的成本。
|
|
268
|
+
* @param at - 时间点;null 表示这段用量跨了峰谷、无法定位,按标准价算(不猜折扣)
|
|
269
|
+
* @param hours - 24 个小时槽(本地时间,索引即小时);给了就按错峰占比加权
|
|
270
|
+
*/
|
|
271
|
+
export function priceRowsByRules(rows, config, options = {}) {
|
|
272
|
+
const at = options.at ?? null;
|
|
273
|
+
const sharedHours = options.hours ?? null;
|
|
274
|
+
return rows.map((row) => {
|
|
275
|
+
const matched = matchPricedRule(config.rules, row.provider, row.model);
|
|
276
|
+
if (matched === null) {
|
|
277
|
+
return { cost: null, source: 'none', rule: null, offPeak: false, cacheSavings: 0 };
|
|
278
|
+
}
|
|
279
|
+
const rule = matched.rule;
|
|
280
|
+
const standard = costAtRates(row, rule, rule);
|
|
281
|
+
const share = rule.offPeak === null
|
|
282
|
+
? (at !== null && isOffPeakAt(rule, at) ? 1 : 0)
|
|
283
|
+
: offPeakTokenShare(rule, sharedHours);
|
|
284
|
+
const offPeakCost = rule.offPeak === null ? standard : costAtRates(row, rule, rule.offPeak);
|
|
285
|
+
const cost = standard * (1 - share) + offPeakCost * share;
|
|
286
|
+
return {
|
|
287
|
+
cost,
|
|
288
|
+
source: matched.source,
|
|
289
|
+
rule,
|
|
290
|
+
offPeak: share >= 1,
|
|
291
|
+
// 省下的钱 = 命中缓存的 tokens ×(未命中价 − 命中价),按要求只用标准价
|
|
292
|
+
cacheSavings: rule.unit === 'perCall' ? 0 : (row.cacheReadTokens ?? 0) * (rule.input - rule.cacheRead) * UNIT_FACTORS[rule.unit],
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function round4(value) {
|
|
297
|
+
return Math.round(value * 10000) / 10000;
|
|
298
|
+
}
|
|
299
|
+
function round2(value) {
|
|
300
|
+
return Math.round(value * 100) / 100;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 逐行定价:用户规则 > 内置官方价目,都没命中时回落调用方给的均价查价(台账反推均价)。
|
|
304
|
+
* @param averagePrice - 台账反推均价查价函数(由调用方注入,避免本模块依赖 ledger)
|
|
305
|
+
*/
|
|
306
|
+
export function rowCostsOf(rows, config, averagePrice = () => null, options = {}) {
|
|
307
|
+
const priced = priceRowsByRules(rows, config, options);
|
|
308
|
+
return rows.map((row, index) => {
|
|
309
|
+
const hit = priced[index];
|
|
310
|
+
if (hit.cost !== null) {
|
|
311
|
+
return { cost: round2(hit.cost), source: hit.source, rule: hit.rule, cacheSavings: hit.cacheSavings };
|
|
312
|
+
}
|
|
313
|
+
const average = averagePrice(row);
|
|
314
|
+
if (average !== null)
|
|
315
|
+
return { cost: average, source: 'estimate', rule: null, cacheSavings: 0 };
|
|
316
|
+
return { cost: null, source: 'none', rule: null, cacheSavings: 0 };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** 组合「用户规则 → 内置官方价目 → 台账反推均价」三层估算,给出金额、最弱来源、命中行数与缓存省钱。 */
|
|
320
|
+
export function costOfUsageRows(rows, config, averagePrice = () => null, options = {}) {
|
|
321
|
+
const priced = rowCostsOf(rows, config, averagePrice, options);
|
|
322
|
+
let total = 0;
|
|
323
|
+
let matched = false;
|
|
324
|
+
let ruleRows = 0;
|
|
325
|
+
let cacheSavings = 0;
|
|
326
|
+
let weakest = 'official';
|
|
327
|
+
for (const row of priced) {
|
|
328
|
+
if (row.cost === null)
|
|
329
|
+
continue;
|
|
330
|
+
total += row.cost;
|
|
331
|
+
matched = true;
|
|
332
|
+
cacheSavings += row.cacheSavings;
|
|
333
|
+
if (row.source === 'rule' || row.source === 'catalog')
|
|
334
|
+
ruleRows += 1;
|
|
335
|
+
weakest = weakestOf(weakest, row.source);
|
|
336
|
+
}
|
|
337
|
+
if (!matched)
|
|
338
|
+
return { cost: null, source: 'none', ruleRows: 0, cacheSavings: 0 };
|
|
339
|
+
return {
|
|
340
|
+
cost: round2(total),
|
|
341
|
+
source: weakest,
|
|
342
|
+
ruleRows,
|
|
343
|
+
cacheSavings: round4(cacheSavings),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** 两个来源取更弱的那个(与 routes 的 `weakestSource` 同一顺序)。 */
|
|
347
|
+
function weakestOf(current, candidate) {
|
|
348
|
+
const rank = { official: 0, rule: 1, catalog: 2, estimate: 3, none: 4 };
|
|
349
|
+
return rank[candidate] > rank[current] ? candidate : current;
|
|
350
|
+
}
|
|
351
|
+
export function addMoney(target, currency, amount) {
|
|
352
|
+
target[currency] = round4((target[currency] ?? 0) + amount);
|
|
353
|
+
return target;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 覆盖度统计(计费面板用):按 `provider|model` 数出命中用户规则 / 命中内置价目 / 回落均价 / 无依据,
|
|
357
|
+
* 并按**所有行**累加金额与缓存省钱(各按价目自己的币种分组,跨币种不换算不合计)。
|
|
358
|
+
*/
|
|
359
|
+
export function summarizeCoverage(rows, config, averagePrice = () => null) {
|
|
360
|
+
const costByCurrency = {};
|
|
361
|
+
const savingsByCurrency = {};
|
|
362
|
+
const savingsByModel = new Map();
|
|
363
|
+
const rulerKeys = new Set();
|
|
364
|
+
const catalogKeys = new Set();
|
|
365
|
+
const averagedKeys = new Set();
|
|
366
|
+
const unpricedKeys = new Set();
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
const key = row.provider + '|' + row.model;
|
|
369
|
+
const priced = priceRowsByRules([row], config)[0];
|
|
370
|
+
if (priced.cost === null || priced.rule === null) {
|
|
371
|
+
// 与成本口径一致:先看台账反推均价还能不能给出数字,再判「无依据」
|
|
372
|
+
if (averagePrice(row) !== null)
|
|
373
|
+
averagedKeys.add(key);
|
|
374
|
+
else
|
|
375
|
+
unpricedKeys.add(key);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (priced.source === 'catalog')
|
|
379
|
+
catalogKeys.add(key);
|
|
380
|
+
else
|
|
381
|
+
rulerKeys.add(key);
|
|
382
|
+
addMoney(costByCurrency, priced.rule.currency, priced.cost);
|
|
383
|
+
if (priced.cacheSavings <= 0)
|
|
384
|
+
continue;
|
|
385
|
+
addMoney(savingsByCurrency, priced.rule.currency, priced.cacheSavings);
|
|
386
|
+
const bucket = savingsByModel.get(key);
|
|
387
|
+
if (bucket === undefined) {
|
|
388
|
+
savingsByModel.set(key, {
|
|
389
|
+
provider: row.provider,
|
|
390
|
+
model: row.model,
|
|
391
|
+
currency: priced.rule.currency,
|
|
392
|
+
cacheReadTokens: row.cacheReadTokens ?? 0,
|
|
393
|
+
amount: priced.cacheSavings,
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
bucket.cacheReadTokens += row.cacheReadTokens ?? 0;
|
|
398
|
+
bucket.amount += priced.cacheSavings;
|
|
399
|
+
}
|
|
400
|
+
const savingsDetail = [...savingsByModel.values()]
|
|
401
|
+
.map((entry) => ({ ...entry, amount: round4(entry.amount) }))
|
|
402
|
+
.sort((first, second) => second.amount - first.amount);
|
|
403
|
+
return {
|
|
404
|
+
ruleModels: rulerKeys.size,
|
|
405
|
+
catalogModels: catalogKeys.size,
|
|
406
|
+
averagedModels: averagedKeys.size,
|
|
407
|
+
unpricedModels: unpricedKeys.size,
|
|
408
|
+
costByCurrency,
|
|
409
|
+
savingsByCurrency,
|
|
410
|
+
savingsDetail,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* DeepSeek 错峰窗口(本地时间):高峰 = 周一至周五 09:00–12:00 与 14:00–18:00,
|
|
415
|
+
* 其余时段全是错峰,因此三段窗口都限定 `days` 为工作日,周末另给一条全天窗口。
|
|
416
|
+
*/
|
|
417
|
+
const DEEPSEEK_RULE_OFFPEAK_WINDOWS = [
|
|
418
|
+
{ from: '00:00', to: '09:00', days: [1, 2, 3, 4, 5] },
|
|
419
|
+
{ from: '12:00', to: '14:00', days: [1, 2, 3, 4, 5] },
|
|
420
|
+
{ from: '18:00', to: '00:00', days: [1, 2, 3, 4, 5] },
|
|
421
|
+
{ from: '00:00', to: '00:00', days: [6, 7] },
|
|
422
|
+
];
|
|
423
|
+
/**
|
|
424
|
+
* 内置价目模板:只提供**结构与官方数值**,套用后由用户自行核对/修改。
|
|
425
|
+
* 数值来自 docs/05 附录 A.5(2026-10-13 抓取)。
|
|
426
|
+
* DeepSeek 取**中文文档的 CNY 价**(英文页同价不同币种):peak = 北京时间周一至周五
|
|
427
|
+
* 09:00–12:00 与 14:00–18:00,其余时段半价;错峰窗口因此必须带 `days`,
|
|
428
|
+
* 否则周末会被算成高峰。法定节假日没有日历可查,仍按高峰价算,已在 note 写明。
|
|
429
|
+
*/
|
|
430
|
+
export const PRICING_TEMPLATES = [
|
|
431
|
+
{
|
|
432
|
+
id: 'deepseek',
|
|
433
|
+
label: 'DeepSeek 官方',
|
|
434
|
+
note: 'CNY / 百万 tokens;错峰 = 高峰半价。高峰为北京时间周一至周五(仅工作日)09:00–12:00 与 14:00–18:00,其余时段(含周末全天)为错峰;中国法定节假日无法表达,会按高峰价算。来源:https://api-docs.deepseek.com/zh-cn/quick_start/pricing(2026-10-13)',
|
|
435
|
+
rules: [
|
|
436
|
+
{
|
|
437
|
+
provider: 'deepseek-official', model: 'deepseek-v4-pro', unit: 'perMillionTokens', currency: 'CNY',
|
|
438
|
+
input: 9, cacheRead: 0.3, cacheWrite: 9, output: 27,
|
|
439
|
+
offPeak: { windows: DEEPSEEK_RULE_OFFPEAK_WINDOWS, input: 4.5, cacheRead: 0.15, cacheWrite: 4.5, output: 13.5 },
|
|
440
|
+
note: 'v4-pro 高峰价;缓存写文档未单列,沿用未命中价。来源同上',
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
provider: 'deepseek-official', model: '*', unit: 'perMillionTokens', currency: 'CNY',
|
|
444
|
+
input: 2, cacheRead: 0.04, cacheWrite: 2, output: 8,
|
|
445
|
+
offPeak: { windows: DEEPSEEK_RULE_OFFPEAK_WINDOWS, input: 1, cacheRead: 0.02, cacheWrite: 1, output: 4 },
|
|
446
|
+
note: '该通道兜底按 Flash 档填写(高峰价);缓存写文档未单列,沿用未命中价。来源同上',
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
id: 'kimi',
|
|
452
|
+
label: 'Kimi(月之暗面)',
|
|
453
|
+
note: 'CNY / 百万 tokens。来源:https://www.kimi.com/help/kimi-api/api-pricing(2026-10-13);缓存写未单独公布,按输入价填写',
|
|
454
|
+
rules: [
|
|
455
|
+
{
|
|
456
|
+
provider: 'moonshotai', model: 'kimi-k3', unit: 'perMillionTokens', currency: 'CNY',
|
|
457
|
+
input: 20, cacheRead: 2, cacheWrite: 20, output: 100, offPeak: null,
|
|
458
|
+
note: 'K3:输入 ¥20 / 输出 ¥100 / 缓存写 ¥20 / 命中 ¥2',
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
provider: 'moonshotai', model: 'kimi-k2.7-code', unit: 'perMillionTokens', currency: 'CNY',
|
|
462
|
+
input: 6.5, cacheRead: 1.3, cacheWrite: 6.5, output: 27, offPeak: null,
|
|
463
|
+
note: 'K2.7 Code:输入 ¥6.5 / 输出 ¥27 / 命中 ¥1.30',
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
id: 'glm',
|
|
469
|
+
label: 'GLM(Z.ai)',
|
|
470
|
+
note: 'USD / 百万 tokens。来源:https://docs.z.ai/guides/overview/pricing(2026-10-13);缓存写未单独公布,按输入价填写',
|
|
471
|
+
rules: [
|
|
472
|
+
{
|
|
473
|
+
provider: 'zai', model: 'glm-5.3', unit: 'perMillionTokens', currency: 'USD',
|
|
474
|
+
input: 1.4, cacheRead: 0.26, cacheWrite: 1.4, output: 4.4, offPeak: null,
|
|
475
|
+
note: 'GLM-5.3:$1.4 / 缓存 $0.26 / $4.4',
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
provider: 'zai', model: 'glm-5.3-flash', unit: 'perMillionTokens', currency: 'USD',
|
|
479
|
+
input: 0.15, cacheRead: 0.03, cacheWrite: 0.15, output: 0.50, offPeak: null,
|
|
480
|
+
note: 'GLM-5.3-Flash:$0.15 / 缓存 $0.03 / $0.50',
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
];
|
|
485
|
+
/** 规则的键:导入合并与去重都用它(通道 + 模型)。 */
|
|
486
|
+
export function ruleKeyOf(rule) {
|
|
487
|
+
return rule.provider + '|' + rule.model;
|
|
488
|
+
}
|
|
489
|
+
/** 导入合并:同键覆盖、新键追加,保留原有顺序。 */
|
|
490
|
+
export function mergePricingRules(current, incoming) {
|
|
491
|
+
const merged = [...current];
|
|
492
|
+
for (const rule of incoming) {
|
|
493
|
+
const index = merged.findIndex((candidate) => ruleKeyOf(candidate) === ruleKeyOf(rule));
|
|
494
|
+
if (index >= 0)
|
|
495
|
+
merged[index] = rule;
|
|
496
|
+
else
|
|
497
|
+
merged.push(rule);
|
|
498
|
+
}
|
|
499
|
+
return merged.slice(0, MAX_RULES);
|
|
500
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 余额 / 窗口快照的本地持久化。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:宿主那份 `dsh-usage/provider-snapshots.json` 由别的组件写,本机实测从 2026-09-30
|
|
5
|
+
* 起就停更了 —— 实时查询一旦失败,界面就只剩十几天前的旧值(截图里的「已 13 天未更新」)。
|
|
6
|
+
* 这里在**每次实时查询成功后**立刻落一份自己的快照,失败时优先回落到它,
|
|
7
|
+
* 把数据断档压缩到「最近一次成功查询」为止。
|
|
8
|
+
*
|
|
9
|
+
* 约定:只写插件自己的文件(`$DSH_HOME/dsh-key-usage/quota-snapshots.json`),不碰宿主的文件;
|
|
10
|
+
* 读写失败一律忽略(返回空 / 不写),绝不影响接口可用性。
|
|
11
|
+
*/
|
|
12
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
function isObject(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
/** 读快照;文件缺失、损坏、结构不符一律返回空数组。 */
|
|
18
|
+
export function readQuotaSnapshots(path) {
|
|
19
|
+
let document;
|
|
20
|
+
try {
|
|
21
|
+
document = JSON.parse(readFileSync(path, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const providers = document?.providers;
|
|
27
|
+
if (!isObject(providers))
|
|
28
|
+
return [];
|
|
29
|
+
const entries = [];
|
|
30
|
+
for (const [id, raw] of Object.entries(providers)) {
|
|
31
|
+
if (!isObject(raw))
|
|
32
|
+
continue;
|
|
33
|
+
if (typeof raw.updatedAt !== 'number' || !Number.isFinite(raw.updatedAt))
|
|
34
|
+
continue;
|
|
35
|
+
const windows = Array.isArray(raw.windows)
|
|
36
|
+
? raw.windows.filter((window) => isObject(window) && typeof window.key === 'string' && typeof window.percent === 'number')
|
|
37
|
+
: null;
|
|
38
|
+
entries.push({
|
|
39
|
+
provider: typeof raw.provider === 'string' ? raw.provider : id,
|
|
40
|
+
displayName: typeof raw.displayName === 'string' ? raw.displayName : null,
|
|
41
|
+
updatedAt: raw.updatedAt,
|
|
42
|
+
balance: isObject(raw.balance) ? raw.balance : null,
|
|
43
|
+
windows: windows !== null && windows.length > 0 ? windows : null,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return entries;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 合并写入:本次成功的通道覆盖旧值,其余通道保留(避免一次只刷新部分通道时把别的通道抹掉)。
|
|
50
|
+
* 先写临时文件再改名,避免读到半截 JSON;任何异常都吞掉。
|
|
51
|
+
*/
|
|
52
|
+
export function writeQuotaSnapshots(path, entries) {
|
|
53
|
+
if (entries.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
const merged = new Map(readQuotaSnapshots(path).map((entry) => [entry.provider, entry]));
|
|
57
|
+
for (const entry of entries)
|
|
58
|
+
merged.set(entry.provider, entry);
|
|
59
|
+
const document = { version: 1, providers: Object.fromEntries(merged) };
|
|
60
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
61
|
+
const temporaryPath = path + '.tmp';
|
|
62
|
+
writeFileSync(temporaryPath, JSON.stringify(document, null, 2) + '\n', 'utf8');
|
|
63
|
+
renameSync(temporaryPath, path);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// 快照只是加速回落,写不了就不写
|
|
67
|
+
}
|
|
68
|
+
}
|