@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/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: 4,
275
- cacheHit: .4,
276
- output: 12
277
- },
278
- estimated: true
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
- return MODEL_CATALOG.find((entry) => entry.key === resolved) ?? (() => {
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 && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCostAt(modelOf(key), {
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 && MODEL_CATALOG.some((entry) => entry.key === modelKey)) {
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([fetchRate(), fetchRouterModels()]);
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
- if (rate === void 0 && prices === void 0) return { source: "builtin" };
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 (limit === null || remaining === null || limit <= 0) return null;
1489
- const usedPercent = round1(clampPercent((limit - remaining) / limit * 100) ?? 0);
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
- res.end(JSON.stringify({ balances }));
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(),
@@ -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.