@kenz1117/dsh-ui-usage-billing 0.6.0 → 0.8.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 +13 -5
- package/lib/client.js +775 -284
- package/lib/index.js +462 -19
- package/lib/types/balance.d.ts +17 -1
- package/lib/types/client/UsageBilling.d.ts +3 -0
- package/lib/types/client/budget-store.d.ts +3 -0
- package/lib/types/client/live-cost.d.ts +27 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/plan-knowledge.d.ts +41 -0
- package/lib/types/client/pricing.d.ts +53 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/plans.d.ts +39 -0
- package/lib/types/pricing-fetch.d.ts +9 -1
- package/lib/types/pricing-shared.d.ts +58 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -4,9 +4,168 @@ import { join } from "node:path";
|
|
|
4
4
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
5
|
import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
6
6
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
7
|
+
//#region lib/types/client/plan-knowledge.js
|
|
8
|
+
/**
|
|
9
|
+
* Plan-knowledge reference (adapted from dsh-spend's `knowledge.js`, MIT):
|
|
10
|
+
* billing-plan shape (code = subscription, token = usage) for known providers,
|
|
11
|
+
* plus official token rates as a pricing fallback for providers whose models
|
|
12
|
+
* are not covered by the built-in catalog or models.dev.
|
|
13
|
+
*
|
|
14
|
+
* The cost model follows dsh-spend's dual-basis:
|
|
15
|
+
* - `code` plan (OpenCode Go, Coding/Token plans, etc.): the monthly cost is
|
|
16
|
+
* the subscription fee, counted into the projected month total.
|
|
17
|
+
* - `token` plan (pay-as-you-go API providers): cost = tokens × unit price.
|
|
18
|
+
*
|
|
19
|
+
* We keep our own live-pricing path (models.dev + OpenRouter) for token plans;
|
|
20
|
+
* this table only fills the subscription amount, the plan type, and a fallback
|
|
21
|
+
* rate for the few models those live sources may miss.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* 订阅/计划 provider id → plan 知识(引用 dsh-spend 的 code/token 双口径)。
|
|
25
|
+
* 覆盖我们实际会识别到的订阅通道;其余按量 API 不计入此表(默认 token)。
|
|
26
|
+
*/
|
|
27
|
+
const PLAN_KNOWLEDGE = {
|
|
28
|
+
"opencode-go": {
|
|
29
|
+
type: "code",
|
|
30
|
+
subscriptionCny: 70
|
|
31
|
+
},
|
|
32
|
+
opencode: {
|
|
33
|
+
type: "code",
|
|
34
|
+
subscriptionCny: 70
|
|
35
|
+
},
|
|
36
|
+
"kimi-coding": {
|
|
37
|
+
type: "code",
|
|
38
|
+
subscriptionCny: 0
|
|
39
|
+
},
|
|
40
|
+
"zai-coding-cn": {
|
|
41
|
+
type: "code",
|
|
42
|
+
subscriptionCny: 0
|
|
43
|
+
},
|
|
44
|
+
"zai-coding": {
|
|
45
|
+
type: "code",
|
|
46
|
+
subscriptionCny: 0
|
|
47
|
+
},
|
|
48
|
+
"qwen-token-plan": {
|
|
49
|
+
type: "code",
|
|
50
|
+
subscriptionCny: 0
|
|
51
|
+
},
|
|
52
|
+
"qwen-token-plan-cn": {
|
|
53
|
+
type: "code",
|
|
54
|
+
subscriptionCny: 0
|
|
55
|
+
},
|
|
56
|
+
"xiaomi-token-plan-ams": {
|
|
57
|
+
type: "code",
|
|
58
|
+
subscriptionCny: 0
|
|
59
|
+
},
|
|
60
|
+
"xiaomi-token-plan-cn": {
|
|
61
|
+
type: "code",
|
|
62
|
+
subscriptionCny: 0
|
|
63
|
+
},
|
|
64
|
+
"xiaomi-token-plan-sgp": {
|
|
65
|
+
type: "code",
|
|
66
|
+
subscriptionCny: 0
|
|
67
|
+
},
|
|
68
|
+
"volcengine-token-plan": {
|
|
69
|
+
type: "code",
|
|
70
|
+
subscriptionCny: 0
|
|
71
|
+
},
|
|
72
|
+
"ark-token-plan": {
|
|
73
|
+
type: "code",
|
|
74
|
+
subscriptionCny: 0
|
|
75
|
+
},
|
|
76
|
+
"doubao-token-plan": {
|
|
77
|
+
type: "code",
|
|
78
|
+
subscriptionCny: 0
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/** provider id(llm-pi-ai 设置键)→ plan 知识;未命中默认 token。 */
|
|
82
|
+
function planTypeOf(providerId) {
|
|
83
|
+
return PLAN_KNOWLEDGE[providerId]?.type ?? "token";
|
|
84
|
+
}
|
|
85
|
+
/** 订阅月费(人民币元);非 code 或未配置时为 0。 */
|
|
86
|
+
function subscriptionCnyOf(providerId) {
|
|
87
|
+
const entry = PLAN_KNOWLEDGE[providerId];
|
|
88
|
+
return entry?.type === "code" ? entry.subscriptionCny ?? 0 : 0;
|
|
89
|
+
}
|
|
90
|
+
const FALLBACK_RATES = [
|
|
91
|
+
{
|
|
92
|
+
key: "deepseek-v4-flash",
|
|
93
|
+
input: .14,
|
|
94
|
+
cacheHit: .0028,
|
|
95
|
+
output: .28
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "deepseek-v4-pro",
|
|
99
|
+
input: .435,
|
|
100
|
+
cacheHit: .003625,
|
|
101
|
+
output: .87
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: "gpt-5.6-sol",
|
|
105
|
+
input: 5,
|
|
106
|
+
cacheHit: .5,
|
|
107
|
+
output: 30
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: "gpt-5.6-terra",
|
|
111
|
+
input: 2,
|
|
112
|
+
cacheHit: .2,
|
|
113
|
+
output: 12
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
key: "gpt-5.6-luna",
|
|
117
|
+
input: .2,
|
|
118
|
+
cacheHit: .02,
|
|
119
|
+
output: 1.2
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: "glm-5.2",
|
|
123
|
+
input: 1.4,
|
|
124
|
+
cacheHit: .26,
|
|
125
|
+
output: 4.4
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "qwen3.8-max",
|
|
129
|
+
input: 2,
|
|
130
|
+
cacheHit: .21,
|
|
131
|
+
output: 6
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "kimi-k3",
|
|
135
|
+
input: 2.82,
|
|
136
|
+
cacheHit: .28,
|
|
137
|
+
output: 14.08
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: "grok-4.6",
|
|
141
|
+
input: 2,
|
|
142
|
+
cacheHit: .5,
|
|
143
|
+
output: 6
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: "gemini-2.5-pro",
|
|
147
|
+
input: 1.25,
|
|
148
|
+
cacheHit: .125,
|
|
149
|
+
output: 10
|
|
150
|
+
}
|
|
151
|
+
];
|
|
152
|
+
/** 运行时实时覆盖:undefined = 用内置目录与内置汇率(默认值降级)。 */
|
|
153
|
+
let liveRate;
|
|
154
|
+
let livePrices;
|
|
155
|
+
let liveExtraModels;
|
|
156
|
+
/**
|
|
157
|
+
* Apply the node half's live pricing snapshot. Absent fields keep the
|
|
158
|
+
* built-in catalog and rate; callers never fabricate values.
|
|
159
|
+
* @param pricing - the `/api/billing/pricing` response.
|
|
160
|
+
*/
|
|
161
|
+
function applyLivePricing(pricing) {
|
|
162
|
+
liveRate = pricing.rate;
|
|
163
|
+
livePrices = pricing.prices;
|
|
164
|
+
liveExtraModels = pricing.extraModels;
|
|
165
|
+
}
|
|
7
166
|
/** 当前汇率:实时覆盖优先,缺省回退内置固定值。 */
|
|
8
167
|
function currentRate() {
|
|
9
|
-
return 6.79;
|
|
168
|
+
return liveRate ?? 6.79;
|
|
10
169
|
}
|
|
11
170
|
/** Default share of traffic assumed to fall in the peak band (0..1). */
|
|
12
171
|
const DEFAULT_PEAK_SHARE = .5;
|
|
@@ -271,11 +430,22 @@ const MODEL_CATALOG = [
|
|
|
271
430
|
colorVar: "dsw-static-green-400",
|
|
272
431
|
price: {
|
|
273
432
|
currency: "CNY",
|
|
274
|
-
input:
|
|
275
|
-
cacheHit: .
|
|
276
|
-
output:
|
|
277
|
-
}
|
|
278
|
-
|
|
433
|
+
input: 1,
|
|
434
|
+
cacheHit: .02,
|
|
435
|
+
output: 2
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
key: "mimo-v2.5-pro",
|
|
440
|
+
name: "MiMo V2.5 Pro",
|
|
441
|
+
provider: "小米",
|
|
442
|
+
colorVar: "dsw-static-green-400",
|
|
443
|
+
price: {
|
|
444
|
+
currency: "CNY",
|
|
445
|
+
input: 3,
|
|
446
|
+
cacheHit: .025,
|
|
447
|
+
output: 6
|
|
448
|
+
}
|
|
279
449
|
},
|
|
280
450
|
{
|
|
281
451
|
key: "minimax",
|
|
@@ -541,14 +711,65 @@ const MODEL_KEY_ALIASES = {
|
|
|
541
711
|
"k3": "kimi-k3",
|
|
542
712
|
"kimi-k3": "kimi-k3"
|
|
543
713
|
};
|
|
714
|
+
/** 取一个计费键的实时单价(实时覆盖 > dsh-spend 官方价兜底)。 */
|
|
715
|
+
function livePriceOf(key) {
|
|
716
|
+
const resolved = MODEL_KEY_ALIASES[key] ?? key;
|
|
717
|
+
const live = livePrices?.[resolved];
|
|
718
|
+
if (live !== void 0) return live;
|
|
719
|
+
const fallback = FALLBACK_RATES.find((rate) => rate.key.toLowerCase() === resolved.toLowerCase());
|
|
720
|
+
if (fallback === void 0) return void 0;
|
|
721
|
+
return {
|
|
722
|
+
input: fallback.input,
|
|
723
|
+
cacheHit: fallback.cacheHit,
|
|
724
|
+
output: fallback.output
|
|
725
|
+
};
|
|
726
|
+
}
|
|
544
727
|
/** Lookup a model by its stats key; falls back to the generic `other` entry. */
|
|
545
728
|
function modelOf(key) {
|
|
546
729
|
const resolved = MODEL_KEY_ALIASES[key] ?? key;
|
|
547
|
-
|
|
730
|
+
const found = MODEL_CATALOG.find((entry) => entry.key === resolved);
|
|
731
|
+
const extra = liveExtraModels?.find((item) => item.key === resolved);
|
|
732
|
+
const base = found ?? (extra !== void 0 ? extraEntryOf(extra) : (() => {
|
|
548
733
|
const fallback = MODEL_CATALOG.at(-1);
|
|
549
734
|
if (fallback !== void 0) return fallback;
|
|
550
735
|
throw new Error("MODEL_CATALOG must not be empty");
|
|
551
|
-
})();
|
|
736
|
+
})());
|
|
737
|
+
const live = livePriceOf(resolved);
|
|
738
|
+
if (live === void 0) return base;
|
|
739
|
+
return {
|
|
740
|
+
...base,
|
|
741
|
+
price: {
|
|
742
|
+
currency: "USD",
|
|
743
|
+
input: live.input,
|
|
744
|
+
cacheHit: live.cacheHit,
|
|
745
|
+
output: live.output
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** models.dev 补充条目转为目录条目:USD 直价(走汇率换算),无峰谷分档。 */
|
|
750
|
+
function extraEntryOf(extra) {
|
|
751
|
+
return {
|
|
752
|
+
key: extra.key,
|
|
753
|
+
name: extra.name,
|
|
754
|
+
provider: extra.provider,
|
|
755
|
+
colorVar: "dsw-static-neutral-400",
|
|
756
|
+
price: {
|
|
757
|
+
currency: "USD",
|
|
758
|
+
input: extra.price.input,
|
|
759
|
+
cacheHit: extra.price.cacheHit,
|
|
760
|
+
output: extra.price.output
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* 模型是否可计价:内置目录、models.dev 补充、或 dsh-spend 官方价兜底命中。
|
|
766
|
+
* 聚合层的计价闸门(目录外模型不产生费用,避免兜底档误估)。
|
|
767
|
+
*/
|
|
768
|
+
function isPriced(key) {
|
|
769
|
+
const resolved = MODEL_KEY_ALIASES[key] ?? key;
|
|
770
|
+
if (MODEL_CATALOG.some((entry) => entry.key === resolved)) return true;
|
|
771
|
+
if ((liveExtraModels ?? []).some((item) => item.key === resolved)) return true;
|
|
772
|
+
return FALLBACK_RATES.some((rate) => rate.key.toLowerCase() === resolved.toLowerCase());
|
|
552
773
|
}
|
|
553
774
|
/**
|
|
554
775
|
* Price one band's token usage in CNY. The stats `input` field is the TOTAL
|
|
@@ -681,7 +902,7 @@ function foldUsage(acc, usage, key, subscription, timeMs) {
|
|
|
681
902
|
acc.output += usage.outputTokens;
|
|
682
903
|
acc.cacheHit += cacheHit;
|
|
683
904
|
acc.cacheMiss += cacheMiss;
|
|
684
|
-
if (!subscription &&
|
|
905
|
+
if (!subscription && isPriced(key)) acc.cost += computeCostAt(modelOf(key), {
|
|
685
906
|
input: cacheHit + cacheMiss,
|
|
686
907
|
cacheHit,
|
|
687
908
|
cacheMiss,
|
|
@@ -824,7 +1045,7 @@ function foldSession(events, subscriptionProviders) {
|
|
|
824
1045
|
state.output += usage.outputTokens;
|
|
825
1046
|
state.cacheHit += usage.cacheReadTokens ?? 0;
|
|
826
1047
|
state.cacheMiss += usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
827
|
-
if (!subscription &&
|
|
1048
|
+
if (!subscription && isPriced(modelKey)) {
|
|
828
1049
|
const buckets = {
|
|
829
1050
|
input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
|
|
830
1051
|
cacheHit: usage.cacheReadTokens ?? 0,
|
|
@@ -1198,6 +1419,125 @@ async function queryBalances(ctx, providers) {
|
|
|
1198
1419
|
return querier(ctx, env);
|
|
1199
1420
|
}));
|
|
1200
1421
|
}
|
|
1422
|
+
/** 点路径取值:`data.total_available` → 逐层下钻;任一缺失返回 undefined。 */
|
|
1423
|
+
function getPath(data, path) {
|
|
1424
|
+
let cursor = data;
|
|
1425
|
+
for (const segment of path.split(".")) {
|
|
1426
|
+
if (cursor === null || typeof cursor !== "object") return void 0;
|
|
1427
|
+
cursor = cursor[segment];
|
|
1428
|
+
}
|
|
1429
|
+
return cursor;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* 按 extract 规则从响应 JSON 求值。导出供测试:纯函数。
|
|
1433
|
+
* @param rule - 提取规则(const / path / add / subtract / divide)。
|
|
1434
|
+
* @param data - 响应 JSON。
|
|
1435
|
+
* @returns 数值;取不到或结果非有限数返回 undefined。
|
|
1436
|
+
*/
|
|
1437
|
+
function evalExtract(rule, data) {
|
|
1438
|
+
if (typeof rule.const === "number" && Number.isFinite(rule.const)) return rule.const;
|
|
1439
|
+
if (rule.op === "add" || rule.op === "subtract") {
|
|
1440
|
+
const paths = rule.paths ?? [];
|
|
1441
|
+
if (paths.length === 0) return void 0;
|
|
1442
|
+
let total;
|
|
1443
|
+
for (const path of paths) {
|
|
1444
|
+
const value = toNumber(getPath(data, path));
|
|
1445
|
+
if (value === void 0) return void 0;
|
|
1446
|
+
total = total === void 0 ? value : rule.op === "add" ? total + value : total - value;
|
|
1447
|
+
}
|
|
1448
|
+
return total;
|
|
1449
|
+
}
|
|
1450
|
+
const base = typeof rule.path === "string" ? toNumber(getPath(data, rule.path)) : void 0;
|
|
1451
|
+
if (base === void 0) return void 0;
|
|
1452
|
+
if (rule.op === "divide") {
|
|
1453
|
+
const by = rule.by;
|
|
1454
|
+
if (typeof by !== "number" || !Number.isFinite(by) || by === 0) return void 0;
|
|
1455
|
+
return base / by;
|
|
1456
|
+
}
|
|
1457
|
+
return base;
|
|
1458
|
+
}
|
|
1459
|
+
/** 请求头占位符解析:`{{ENV_NAME}}` 经凭据 seam 替换;解析失败返回 null。 */
|
|
1460
|
+
async function resolveHeaders(ctx, headers) {
|
|
1461
|
+
const resolved = {};
|
|
1462
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1463
|
+
const match = /^\{\{([A-Z0-9_]+)\}\}$/i.exec(value.trim());
|
|
1464
|
+
if (match === null) {
|
|
1465
|
+
resolved[key] = value;
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
const hit = await ctx.credentials.resolve(credentialRef(match[1] ?? ""));
|
|
1469
|
+
if (hit === void 0 || hit.value === "") return null;
|
|
1470
|
+
resolved[key] = value.replace(match[0], hit.value);
|
|
1471
|
+
}
|
|
1472
|
+
return resolved;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* 查询自定义 Provider 余额(插件 config 的 `customBalances`)。每个条目独立
|
|
1476
|
+
* 成败:占位符凭据缺失 → unconfigured;401/403 → unauthorized;网络或提取
|
|
1477
|
+
* 失败 → unreachable。
|
|
1478
|
+
* @param ctx - host context carrying the credentials seam.
|
|
1479
|
+
* @param configs - 自定义余额配置列表。
|
|
1480
|
+
* @returns 每个配置一行的余额结果。
|
|
1481
|
+
*/
|
|
1482
|
+
async function queryCustomBalances(ctx, configs) {
|
|
1483
|
+
return await Promise.all(configs.map(async (config) => {
|
|
1484
|
+
const provider = `custom:${config.label}`;
|
|
1485
|
+
const displayName = config.label;
|
|
1486
|
+
if (typeof config.url !== "string" || config.url === "") return {
|
|
1487
|
+
provider,
|
|
1488
|
+
displayName,
|
|
1489
|
+
error: "unconfigured"
|
|
1490
|
+
};
|
|
1491
|
+
const headers = await resolveHeaders(ctx, config.headers ?? {});
|
|
1492
|
+
if (headers === null) return {
|
|
1493
|
+
provider,
|
|
1494
|
+
displayName,
|
|
1495
|
+
error: "unconfigured"
|
|
1496
|
+
};
|
|
1497
|
+
const controller = new AbortController();
|
|
1498
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
|
|
1499
|
+
try {
|
|
1500
|
+
const response = await fetch(config.url, {
|
|
1501
|
+
method: config.method ?? "GET",
|
|
1502
|
+
headers: {
|
|
1503
|
+
accept: "application/json",
|
|
1504
|
+
...headers
|
|
1505
|
+
},
|
|
1506
|
+
signal: controller.signal
|
|
1507
|
+
});
|
|
1508
|
+
if (response.status === 401 || response.status === 403) return {
|
|
1509
|
+
provider,
|
|
1510
|
+
displayName,
|
|
1511
|
+
error: "unauthorized"
|
|
1512
|
+
};
|
|
1513
|
+
if (!response.ok) return {
|
|
1514
|
+
provider,
|
|
1515
|
+
displayName,
|
|
1516
|
+
error: "unreachable"
|
|
1517
|
+
};
|
|
1518
|
+
const remaining = evalExtract(config.extract.remaining, await response.json());
|
|
1519
|
+
if (remaining === void 0) return {
|
|
1520
|
+
provider,
|
|
1521
|
+
displayName,
|
|
1522
|
+
error: "unreachable"
|
|
1523
|
+
};
|
|
1524
|
+
return {
|
|
1525
|
+
provider,
|
|
1526
|
+
displayName,
|
|
1527
|
+
currency: config.unit ?? "CNY",
|
|
1528
|
+
totalBalance: remaining
|
|
1529
|
+
};
|
|
1530
|
+
} catch {
|
|
1531
|
+
return {
|
|
1532
|
+
provider,
|
|
1533
|
+
displayName,
|
|
1534
|
+
error: "unreachable"
|
|
1535
|
+
};
|
|
1536
|
+
} finally {
|
|
1537
|
+
clearTimeout(timer);
|
|
1538
|
+
}
|
|
1539
|
+
}));
|
|
1540
|
+
}
|
|
1201
1541
|
//#endregion
|
|
1202
1542
|
//#region lib/types/pricing-fetch.js
|
|
1203
1543
|
/**
|
|
@@ -1235,6 +1575,41 @@ const RATE_SOURCES = [{
|
|
|
1235
1575
|
}];
|
|
1236
1576
|
/** OpenRouter's public model list: per-token USD prices, no key needed. */
|
|
1237
1577
|
const ROUTER_URL = "https://openrouter.ai/api/v1/models";
|
|
1578
|
+
/** models.dev 公开目录:pi-ai 预制提供方的上游数据源(USD / 1M tokens)。
|
|
1579
|
+
* 接入它即与宿主「系统设置里预制的提供方模型」对齐——预制条目来自同一份数据。 */
|
|
1580
|
+
const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
1581
|
+
/**
|
|
1582
|
+
* models.dev provider id → 仪表盘厂商显示名。探测到的模型若其厂商显示名与此
|
|
1583
|
+
* 映射命中则用之;未命中的回退为探活模型自带的厂商名(系统配置里的显示名)。
|
|
1584
|
+
* 不作为过滤条件——只用于给 models.dev 条目补一个可读的厂商名。
|
|
1585
|
+
*/
|
|
1586
|
+
const MODELS_DEV_PROVIDERS = {
|
|
1587
|
+
deepseek: "DeepSeek",
|
|
1588
|
+
zhipu: "智谱 AI",
|
|
1589
|
+
zhipuai: "智谱 AI",
|
|
1590
|
+
zai: "智谱 AI",
|
|
1591
|
+
qwen: "阿里通义",
|
|
1592
|
+
alibaba: "阿里通义",
|
|
1593
|
+
moonshot: "月之暗面",
|
|
1594
|
+
moonshotai: "月之暗面",
|
|
1595
|
+
volcengine: "字节豆包",
|
|
1596
|
+
doubao: "字节豆包",
|
|
1597
|
+
minimax: "MiniMax",
|
|
1598
|
+
baidu: "百度文心",
|
|
1599
|
+
tencent: "腾讯混元",
|
|
1600
|
+
hunyuan: "腾讯混元",
|
|
1601
|
+
stepfun: "阶跃星辰",
|
|
1602
|
+
iflytek: "科大讯飞",
|
|
1603
|
+
sensetime: "商汤",
|
|
1604
|
+
baichuan: "百川智能",
|
|
1605
|
+
"01.ai": "零一万物",
|
|
1606
|
+
openai: "OpenAI",
|
|
1607
|
+
google: "Google",
|
|
1608
|
+
xai: "xAI",
|
|
1609
|
+
meta: "Meta",
|
|
1610
|
+
anthropic: "Anthropic",
|
|
1611
|
+
mistral: "Mistral"
|
|
1612
|
+
};
|
|
1238
1613
|
/**
|
|
1239
1614
|
* Built-in catalog key → OpenRouter model-id candidates. Matching prefers an
|
|
1240
1615
|
* exact id, then a single strong substring hit (the router id contains the
|
|
@@ -1335,19 +1710,70 @@ function buildPrices(models) {
|
|
|
1335
1710
|
}
|
|
1336
1711
|
return Object.keys(result).length > 0 ? result : void 0;
|
|
1337
1712
|
}
|
|
1713
|
+
/** 有限正数收窄(models.dev cost 字段的 durable 边界)。 */
|
|
1714
|
+
function asPrice(value) {
|
|
1715
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* models.dev 响应 → 目录外补充条目。不再按厂商白名单过滤:凡是有有效
|
|
1719
|
+
* cost 的模型都纳入(探活模型可能来自任何预制厂商,白名单会漏掉)。厂商
|
|
1720
|
+
* 显示名优先取映射,未命中用 provider id。导出供测试:纯函数。
|
|
1721
|
+
* @param data - `https://models.dev/api.json` 的响应体。
|
|
1722
|
+
* @returns 补充条目(按 provider 顺序稳定;仅含可计价的模型)。
|
|
1723
|
+
*/
|
|
1724
|
+
function buildExtraModels(data) {
|
|
1725
|
+
if (data === null || typeof data !== "object") return [];
|
|
1726
|
+
const catalogKeys = new Set([...MODEL_CATALOG.map((entry) => entry.key.toLowerCase()), ...Object.keys(MODEL_KEY_ALIASES).map((key) => MODEL_KEY_ALIASES[key]?.toLowerCase() ?? key.toLowerCase())]);
|
|
1727
|
+
const extras = [];
|
|
1728
|
+
for (const [providerId, providerDoc] of Object.entries(data)) {
|
|
1729
|
+
if (providerDoc === null || typeof providerDoc !== "object") continue;
|
|
1730
|
+
const models = providerDoc.models;
|
|
1731
|
+
if (models === null || typeof models !== "object") continue;
|
|
1732
|
+
for (const [modelId, modelDoc] of Object.entries(models)) {
|
|
1733
|
+
const catalogKey = (MODEL_KEY_ALIASES[modelId] ?? modelId).toLowerCase();
|
|
1734
|
+
if (catalogKeys.has(catalogKey)) continue;
|
|
1735
|
+
const key = catalogKey;
|
|
1736
|
+
if (modelDoc === null || typeof modelDoc !== "object") continue;
|
|
1737
|
+
const cost = modelDoc.cost;
|
|
1738
|
+
if (cost === null || typeof cost !== "object") continue;
|
|
1739
|
+
const input = asPrice(cost.input);
|
|
1740
|
+
const output = asPrice(cost.output);
|
|
1741
|
+
if (input === void 0 || output === void 0) continue;
|
|
1742
|
+
const cacheRead = asPrice(cost.cache_read) ?? input * .1;
|
|
1743
|
+
const name = modelDoc.name;
|
|
1744
|
+
extras.push({
|
|
1745
|
+
key,
|
|
1746
|
+
name: typeof name === "string" && name !== "" ? name : modelId,
|
|
1747
|
+
provider: MODELS_DEV_PROVIDERS[providerId.toLowerCase()] ?? providerId,
|
|
1748
|
+
price: {
|
|
1749
|
+
input,
|
|
1750
|
+
cacheHit: cacheRead,
|
|
1751
|
+
output
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return extras;
|
|
1757
|
+
}
|
|
1338
1758
|
/**
|
|
1339
1759
|
* Fetch the live pricing once at boot. Both upstreams run in parallel; a
|
|
1340
1760
|
* failure in either degrades independently to the built-in value.
|
|
1341
1761
|
* @returns the live pricing snapshot (builtin when everything failed).
|
|
1342
1762
|
*/
|
|
1343
1763
|
async function fetchLivePricing() {
|
|
1344
|
-
const [rate, models] = await Promise.all([
|
|
1764
|
+
const [rate, models, modelsDev] = await Promise.all([
|
|
1765
|
+
fetchRate(),
|
|
1766
|
+
fetchRouterModels(),
|
|
1767
|
+
fetchJson(MODELS_DEV_URL)
|
|
1768
|
+
]);
|
|
1345
1769
|
const prices = models === void 0 ? void 0 : buildPrices(models);
|
|
1346
|
-
|
|
1770
|
+
const extraModels = modelsDev === null ? void 0 : buildExtraModels(modelsDev);
|
|
1771
|
+
if (rate === void 0 && prices === void 0 && (extraModels === void 0 || extraModels.length === 0)) return { source: "builtin" };
|
|
1347
1772
|
return {
|
|
1348
1773
|
source: "live",
|
|
1349
1774
|
...rate !== void 0 ? { rate } : {},
|
|
1350
|
-
...prices !== void 0 ? { prices } : {}
|
|
1775
|
+
...prices !== void 0 ? { prices } : {},
|
|
1776
|
+
...extraModels !== void 0 && extraModels.length > 0 ? { extraModels } : {}
|
|
1351
1777
|
};
|
|
1352
1778
|
}
|
|
1353
1779
|
//#endregion
|
|
@@ -1485,14 +1911,20 @@ function kimiWindow(value, kind) {
|
|
|
1485
1911
|
const record = value;
|
|
1486
1912
|
const limit = numberOrNull(record.limit ?? record.total);
|
|
1487
1913
|
const remaining = numberOrNull(record.remaining);
|
|
1488
|
-
if (
|
|
1489
|
-
const
|
|
1914
|
+
if (remaining === null && limit === null && numberOrNull(record.percentage ?? record.usedPercent ?? record.used_percent) === null) return null;
|
|
1915
|
+
const hasLimit = limit !== null && limit > 0;
|
|
1916
|
+
let usedPercent;
|
|
1917
|
+
if (hasLimit) usedPercent = round1(clampPercent((limit - (remaining ?? 0)) / limit * 100) ?? 0);
|
|
1918
|
+
else {
|
|
1919
|
+
const percent = numberOrNull(record.percentage ?? record.usedPercent ?? record.used_percent);
|
|
1920
|
+
usedPercent = percent !== null ? round1(clampPercent(percent) ?? 0) : remaining === null || remaining <= 0 ? 100 : 0;
|
|
1921
|
+
}
|
|
1490
1922
|
const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
|
|
1491
1923
|
return {
|
|
1492
1924
|
kind,
|
|
1493
1925
|
usedPercent,
|
|
1494
1926
|
remainingPercent: round1(100 - usedPercent),
|
|
1495
|
-
remaining,
|
|
1927
|
+
...remaining !== null ? { remaining } : {},
|
|
1496
1928
|
...resetsAt === null ? {} : { resetsAt }
|
|
1497
1929
|
};
|
|
1498
1930
|
}
|
|
@@ -1999,6 +2431,7 @@ function apply(ctx, config = {}) {
|
|
|
1999
2431
|
let live = { source: "builtin" };
|
|
2000
2432
|
const refreshPricing = async () => {
|
|
2001
2433
|
live = await fetchLivePricing();
|
|
2434
|
+
applyLivePricing(live);
|
|
2002
2435
|
};
|
|
2003
2436
|
refreshPricing();
|
|
2004
2437
|
ctx.effect(() => {
|
|
@@ -2025,7 +2458,8 @@ function apply(ctx, config = {}) {
|
|
|
2025
2458
|
const providers = { ...await readPiAiProviders(ctx.settings) };
|
|
2026
2459
|
if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
|
|
2027
2460
|
const balances = await queryBalances(ctx, providers);
|
|
2028
|
-
|
|
2461
|
+
const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
|
|
2462
|
+
res.end(JSON.stringify({ balances: [...balances, ...custom] }));
|
|
2029
2463
|
}
|
|
2030
2464
|
}), "usage-billing: balance route");
|
|
2031
2465
|
let quotaCache = {
|
|
@@ -2037,12 +2471,21 @@ function apply(ctx, config = {}) {
|
|
|
2037
2471
|
const rows = [...await collectSubscriptions(keys, identified.filter((item) => item.adapter).map((item) => ({
|
|
2038
2472
|
provider: item.provider,
|
|
2039
2473
|
...item.region === void 0 ? {} : { region: item.region }
|
|
2040
|
-
})))]
|
|
2474
|
+
})))].map((row) => {
|
|
2475
|
+
const planType = planTypeOf(row.provider);
|
|
2476
|
+
const subscriptionAmount = subscriptionCnyOf(row.provider);
|
|
2477
|
+
return {
|
|
2478
|
+
...row,
|
|
2479
|
+
planType,
|
|
2480
|
+
...planType === "code" && subscriptionAmount > 0 ? { subscriptionAmount } : {}
|
|
2481
|
+
};
|
|
2482
|
+
});
|
|
2041
2483
|
for (const item of identified) if (!item.adapter) rows.push({
|
|
2042
2484
|
provider: item.provider,
|
|
2043
2485
|
displayName: item.displayName,
|
|
2044
2486
|
status: "ok",
|
|
2045
|
-
windows: []
|
|
2487
|
+
windows: [],
|
|
2488
|
+
planType: planTypeOf(item.provider)
|
|
2046
2489
|
});
|
|
2047
2490
|
quotaCache = {
|
|
2048
2491
|
at: Date.now(),
|
package/lib/types/balance.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* provider's key once and every surface reuses it.
|
|
15
15
|
*/
|
|
16
16
|
import type { Context } from '@deepseek-ai/cordis';
|
|
17
|
-
import type { ProviderBalance } from './pricing-shared.ts';
|
|
17
|
+
import type { CustomBalanceConfig, CustomBalanceExtract, ProviderBalance } from './pricing-shared.ts';
|
|
18
18
|
/**
|
|
19
19
|
* Query every configured provider's account balance. A provider is queried only
|
|
20
20
|
* when its llm-pi-ai route has an `apiKeyEnv`; absent routes answer
|
|
@@ -26,4 +26,20 @@ import type { ProviderBalance } from './pricing-shared.ts';
|
|
|
26
26
|
export declare function queryBalances(ctx: Context, providers: Readonly<Record<string, {
|
|
27
27
|
apiKeyEnv?: string;
|
|
28
28
|
}>>): Promise<readonly ProviderBalance[]>;
|
|
29
|
+
/**
|
|
30
|
+
* 按 extract 规则从响应 JSON 求值。导出供测试:纯函数。
|
|
31
|
+
* @param rule - 提取规则(const / path / add / subtract / divide)。
|
|
32
|
+
* @param data - 响应 JSON。
|
|
33
|
+
* @returns 数值;取不到或结果非有限数返回 undefined。
|
|
34
|
+
*/
|
|
35
|
+
export declare function evalExtract(rule: CustomBalanceExtract, data: unknown): number | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* 查询自定义 Provider 余额(插件 config 的 `customBalances`)。每个条目独立
|
|
38
|
+
* 成败:占位符凭据缺失 → unconfigured;401/403 → unauthorized;网络或提取
|
|
39
|
+
* 失败 → unreachable。
|
|
40
|
+
* @param ctx - host context carrying the credentials seam.
|
|
41
|
+
* @param configs - 自定义余额配置列表。
|
|
42
|
+
* @returns 每个配置一行的余额结果。
|
|
43
|
+
*/
|
|
44
|
+
export declare function queryCustomBalances(ctx: Context, configs: readonly CustomBalanceConfig[]): Promise<readonly ProviderBalance[]>;
|
|
29
45
|
//# sourceMappingURL=balance.d.ts.map
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
|
|
13
13
|
import type { SidebarFooterActionOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client';
|
|
14
14
|
import type { createBillingBudgetStore } from './budget-store.ts';
|
|
15
|
+
import { type CatalogModel } from './pricing.ts';
|
|
15
16
|
import { NS, type UsageBillingKey } from './locales.ts';
|
|
16
17
|
/** Model-connectivity health reported by the host model directory probe. */
|
|
17
18
|
export interface ModelHealth {
|
|
@@ -27,6 +28,8 @@ export interface ModelHealth {
|
|
|
27
28
|
okProviders: readonly string[];
|
|
28
29
|
/** Display names of providers whose catalog probe failed. */
|
|
29
30
|
badProviders: readonly string[];
|
|
31
|
+
/** 探活得到的模型清单(系统里实际配置/预制的模型;无价格,费率表据此对标)。 */
|
|
32
|
+
catalog?: readonly CatalogModel[];
|
|
30
33
|
}
|
|
31
34
|
/** 仪表盘分区 Tab id。 */
|
|
32
35
|
export type DashboardTab = 'overview' | 'trends' | 'providers' | 'details' | 'pricing';
|
|
@@ -16,6 +16,8 @@ export interface BudgetPrefsState {
|
|
|
16
16
|
tierAlertDays: Record<string, string>;
|
|
17
17
|
/** 最近一次余额不足通知的日期戳(YYYY-MM-DD):余额告警同样每天最多一次。 */
|
|
18
18
|
lastBalanceAlertDay: string;
|
|
19
|
+
/** 最近一次峰谷切换提醒的切换点时刻(毫秒):同一切换点只提醒一次。 */
|
|
20
|
+
lastTierSwitchAt: number;
|
|
19
21
|
}
|
|
20
22
|
/** 预算偏好的完整写面(组件只能经这些 action 写入);type 别名以兼容 ActionsDecl 的索引签名约束。 */
|
|
21
23
|
export type BudgetPrefsActions = {
|
|
@@ -23,6 +25,7 @@ export type BudgetPrefsActions = {
|
|
|
23
25
|
setAmount: (d: BudgetPrefsState, value: number) => void;
|
|
24
26
|
markTierAlerted: (d: BudgetPrefsState, tiers: readonly number[], day: string) => void;
|
|
25
27
|
markBalanceAlerted: (d: BudgetPrefsState, day: string) => void;
|
|
28
|
+
markTierSwitchAlerted: (d: BudgetPrefsState, at: number) => void;
|
|
26
29
|
};
|
|
27
30
|
/**
|
|
28
31
|
* Declare the budget-preferences store handle.
|