@kenz1117/dsh-ui-usage-billing 1.1.13 → 1.2.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
@@ -2,10 +2,12 @@ import { createRequire } from "node:module";
2
2
  import { mkdir, readFile, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { SessionLogOffset } from "@deepseek-ai/dsh-session/types";
5
6
  import { defineTool } from "@deepseek-ai/dsh-tools";
6
7
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
8
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
8
9
  import z from "@deepseek-ai/schemastery";
10
+ import { createHash, createHmac } from "node:crypto";
9
11
  //#region lib/types/client/plan-knowledge.js
10
12
  /**
11
13
  * Plan-knowledge reference (adapted from dsh-spend's `knowledge.js`, MIT):
@@ -192,6 +194,20 @@ let liveRate;
192
194
  let livePrices;
193
195
  let liveExtraModels;
194
196
  /**
197
+ * 用户自定义模型别名(插件配置 `modelKeyAliases`,聚合启动时注入):真实日志
198
+ * model id → 计费目录键。优先级高于内置别名表——目录外的新模型无需等发版,
199
+ * 配置一条别名即完成识别与计价(键必须是 MODEL_CATALOG 的既有 key)。
200
+ */
201
+ let userModelAliases;
202
+ /**
203
+ * 注入用户自定义模型别名(node 半区在插件启动时调用一次)。纯内存状态:
204
+ * 聚合折叠与客户端渲染共用同一份(两侧一致性由同一注入点保证)。
205
+ * @param aliases - `model id → 目录键` 映射;undefined/空 = 清除,回退内置表。
206
+ */
207
+ function applyUserModelAliases(aliases) {
208
+ userModelAliases = aliases !== void 0 && Object.keys(aliases).length > 0 ? aliases : void 0;
209
+ }
210
+ /**
195
211
  * Apply the node half's live pricing snapshot. Absent fields keep the
196
212
  * built-in catalog and rate; callers never fabricate values.
197
213
  * @param pricing - the `/api/billing/pricing` response.
@@ -316,6 +332,7 @@ function isBeijingWeekend(timeMs) {
316
332
  */
317
333
  /** DeepSeek 官方高峰时段说明(峰谷分时计费目录条目共用)。 */
318
334
  const DEEPSEEK_PEAK_HOURS = "09:00-12:00 / 14:00-18:00";
335
+ const GEMINI_PEAK_HOURS = "Standard / Flex";
319
336
  const MODEL_CATALOG = [
320
337
  {
321
338
  key: "flash",
@@ -974,7 +991,7 @@ const MODEL_CATALOG = [
974
991
  output: 6
975
992
  }
976
993
  },
977
- peakHours: "Standard / Flex",
994
+ peakHours: GEMINI_PEAK_HOURS,
978
995
  tierSemantics: "latency"
979
996
  },
980
997
  {
@@ -993,7 +1010,7 @@ const MODEL_CATALOG = [
993
1010
  output: 3.75
994
1011
  }
995
1012
  },
996
- peakHours: "Standard / Flex",
1013
+ peakHours: GEMINI_PEAK_HOURS,
997
1014
  tierSemantics: "latency"
998
1015
  },
999
1016
  {
@@ -1405,6 +1422,7 @@ const MODEL_KEY_ALIASES = {
1405
1422
  "deepseek-v4-flash": "flash",
1406
1423
  "deepseek-v4-flash-vision-exp": "flash-vision-exp",
1407
1424
  "deepseek-v4.1-flash": "flash",
1425
+ "deepseek-v4.1-flash-expires-on-0910": "flash",
1408
1426
  "deepseek-v4-pro": "pro",
1409
1427
  "glm-5.2": "glm",
1410
1428
  "glm-4.5-air": "glm-4.5-air",
@@ -1433,6 +1451,7 @@ const MODEL_KEY_ALIASES = {
1433
1451
  "command-a": "command-a-03-2025",
1434
1452
  "command-r-08-2024": "command-r-08-2024",
1435
1453
  "command-r": "command-r-08-2024",
1454
+ "hy3": "hunyuan",
1436
1455
  "longcat-2.0": "longcat-2.0",
1437
1456
  "longcat-2": "longcat-2.0",
1438
1457
  "minicpm-v-4.5": "minicpm-v-4.5",
@@ -1521,8 +1540,49 @@ const CATALOG_CANON_INDEX = (() => {
1521
1540
  * @param id - 真实模型 id(日志里出现的形式)。
1522
1541
  * @returns 计费目录键。
1523
1542
  */
1543
+ /**
1544
+ * 由一个未知模型 id 派生候选 id(仅当直接查全部未命中时才尝试):
1545
+ * - 剥离组织前缀(`deepseek/deepseek-v4-flash` → `deepseek-v4-flash`);
1546
+ * - 剥离尾部纯数字段(`deepseek-v4-flash-202605` / `-0731` → `deepseek-v4-flash`,
1547
+ * 覆盖 TokenHub / 官方按日期滚动的快照 id);
1548
+ * - 两者组合派生。目录键本身(如 `mistral-large-2512`、`command-a-03-2025`)
1549
+ * 在直接查就已命中,永不进入派生分支,不受剥段影响。
1550
+ */
1551
+ function derivedKeyCandidates(id) {
1552
+ const out = [];
1553
+ const push = (value) => {
1554
+ if (value !== "" && !out.includes(value)) out.push(value);
1555
+ };
1556
+ const stripTrailingDigits = (value) => {
1557
+ let base = value;
1558
+ for (;;) {
1559
+ const next = base.replace(/[-_]\d{3,}$/u, "");
1560
+ if (next === base || next === "") return;
1561
+ base = next;
1562
+ push(base);
1563
+ }
1564
+ };
1565
+ const slash = id.lastIndexOf("/");
1566
+ if (slash > 0 && slash < id.length - 1) {
1567
+ const bare = id.slice(slash + 1);
1568
+ push(bare);
1569
+ stripTrailingDigits(bare);
1570
+ }
1571
+ stripTrailingDigits(id);
1572
+ return out;
1573
+ }
1574
+ /** 查一个候选 id(用户别名 → 内置别名 → 目录归一化 → models.dev 补充);未命中返回 undefined。 */
1575
+ function lookupCandidate(candidate) {
1576
+ const alias = userModelAliases?.[candidate] ?? MODEL_KEY_ALIASES[candidate];
1577
+ if (alias !== void 0) return alias;
1578
+ const canon = canonModelId(candidate);
1579
+ if (canon === "") return void 0;
1580
+ const hit = CATALOG_CANON_INDEX.get(canon);
1581
+ if (hit !== void 0) return hit;
1582
+ return (liveExtraModels ?? []).find((item) => canonModelId(item.key) === canon)?.key;
1583
+ }
1524
1584
  function resolveCatalogKey(id) {
1525
- const exact = MODEL_KEY_ALIASES[id] ?? id;
1585
+ const exact = userModelAliases?.[id] ?? MODEL_KEY_ALIASES[id] ?? id;
1526
1586
  if (exact === id) {
1527
1587
  const canon = canonModelId(id);
1528
1588
  if (canon !== "") {
@@ -1531,6 +1591,10 @@ function resolveCatalogKey(id) {
1531
1591
  const extraHit = (liveExtraModels ?? []).find((item) => canonModelId(item.key) === canon);
1532
1592
  if (extraHit !== void 0) return extraHit.key;
1533
1593
  }
1594
+ for (const candidate of derivedKeyCandidates(id)) {
1595
+ const hit = lookupCandidate(candidate);
1596
+ if (hit !== void 0) return hit;
1597
+ }
1534
1598
  }
1535
1599
  return exact;
1536
1600
  }
@@ -1666,24 +1730,31 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE, nowMs = Dat
1666
1730
  return peak * peakShare + off * (1 - peakShare);
1667
1731
  }
1668
1732
  /**
1733
+ * v1 峰谷档判定(峰谷开闸起、周末全谷分界止):不豁免周末——该时段官方
1734
+ * 高峰时段为每天 9-12 / 14-18(周六日同样计峰)。仅用于历史事件计费;
1735
+ * 「当前时刻」的档位(提醒/时段条/费率展示)一律走 {@link tierAt} 现行规则。
1736
+ */
1737
+ function tariffV1At(timeMs) {
1738
+ return isPeakHour((new Date(timeMs).getUTCHours() + 8) % 24) ? "peak" : "offPeak";
1739
+ }
1740
+ /**
1669
1741
  * 按调用时刻精确判定高峰/空闲档并计价(P0-1:替代固定比例混合)。时刻未知
1670
1742
  * (null/NaN,理论不发生在真实事件流)时回退 {@link DEFAULT_PEAK_SHARE} 混合,
1671
1743
  * 保持旧语义不低估。平档模型(无 offPeak)两个时段同价。限时促销与峰谷档
1672
1744
  * 同口径:按事件时刻判定该笔流量当时享受的单价。
1745
+ *
1746
+ * 历史正确性(按变更节点分段适用规则,不统一套现行价重算历史):
1747
+ * - 早于 {@link PEAK_ERA_START_MS} 的事件按当时官方基础价
1748
+ * ({@link LEGACY_DEEPSEEK_BANDS})计费;
1749
+ * - 峰谷开闸至 {@link WEEKEND_OFFPEAK_START_MS} 之间按 v1 规则(周末不豁免,
1750
+ * 周六日 9-12 / 14-18 计峰);
1751
+ * - 周末全谷分界起按现行规则({@link tierAt},周六日全天低谷)。
1673
1752
  * @param entry - the catalog entry whose prices apply.
1674
1753
  * @param buckets - token usage counts.
1675
1754
  * @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
1676
1755
  * @param peakShare - fallback mix used only when `timeMs` is missing.
1677
1756
  * @returns the estimated cost in CNY(USD 计价模型已按当前汇率折算)。
1678
1757
  */
1679
- /**
1680
- * v1 峰谷档判定(峰谷开闸起、周末全谷分界止):不豁免周末——该时段官方
1681
- * 高峰时段为每天 9-12 / 14-18(周六日同样计峰)。仅用于历史事件计费;
1682
- * 「当前时刻」的档位(提醒/时段条/费率展示)一律走 {@link tierAt} 现行规则。
1683
- */
1684
- function tariffV1At(timeMs) {
1685
- return isPeakHour((new Date(timeMs).getUTCHours() + 8) % 24) ? "peak" : "offPeak";
1686
- }
1687
1758
  function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
1688
1759
  if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return computeCost(entry, buckets, peakShare);
1689
1760
  const priced = applyPromo(entry, timeMs);
@@ -1737,9 +1808,9 @@ function formatTokens(value) {
1737
1808
  /**
1738
1809
  * 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
1739
1810
  * 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
1740
- * 与 pi-ai 内置提供方对齐(含各地区变体:qwen/xiaomi 的 token-plan、opencode 与
1741
- * opencode-go、zai-coding-cn);部署可在 plugin config `subscriptionProviders`
1742
- * 中覆盖。
1811
+ * 此列表保留为显式配置的参考基线;聚合层缺省改用与订阅卡一致的
1812
+ * `isSubscriptionProviderId` 判定(覆盖本列表与全部 `*-token-plan` / `*-coding` 变体),
1813
+ * 部署仍可在 plugin config 的 `subscriptionProviders` 中显式覆盖。
1743
1814
  */
1744
1815
  const DEFAULT_SUBSCRIPTION_PROVIDERS = [
1745
1816
  "kimi-coding",
@@ -1750,7 +1821,9 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
1750
1821
  "qwen-token-plan-cn",
1751
1822
  "xiaomi-token-plan-ams",
1752
1823
  "xiaomi-token-plan-cn",
1753
- "xiaomi-token-plan-sgp"
1824
+ "xiaomi-token-plan-sgp",
1825
+ "tencent-token-plan",
1826
+ "grok"
1754
1827
  ];
1755
1828
  /**
1756
1829
  * 官方渠道 provider id 判定:`deepseek` 前缀(DeepSeek 官方直连)视为官方,
@@ -1760,6 +1833,26 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
1760
1833
  function isOfficialProvider(provider) {
1761
1834
  return /^deepseek(?:-[a-z0-9-]+)?$/i.test(provider.trim());
1762
1835
  }
1836
+ /** DeepSeek 官方直连端点的归一化 origin(`siteOriginOf` 口径):有 baseURL 的路由只有它算官方。 */
1837
+ const OFFICIAL_DEEPSEEK_ORIGIN = "https://api.deepseek.com";
1838
+ /**
1839
+ * 官方渠道判定(通道优先):显式 `officialProviderIds` 配置最优先;否则看站点归组——
1840
+ * 有 baseURL 的路由只有 origin 为 DeepSeek 官方域才算官方(修复:名为 `deepseek-*`
1841
+ * 的中转/网关路由曾被按 id 前缀误判为官方,腾讯网关的 DeepSeek 全被计成官方渠道);
1842
+ * 直连路由(配置在册、无 baseURL)与路由表查不到的未知名都退回按 provider id
1843
+ * 前缀判定——宿主内置的官方直连不经 llm-pi-ai 路由,其 provider 名不在路由表中,
1844
+ * 按 unknown 一刀切会把它错杀成三方(回归修复);配置外的同名网关残留可用
1845
+ * `routeAliases` 显式归位到真实通道。
1846
+ */
1847
+ function officialChannelOf(provider, ref, officialProviderIds) {
1848
+ if (officialProviderIds !== void 0) return officialProviderIds.has(provider);
1849
+ if (ref.kind === "site") return ref.origin === OFFICIAL_DEEPSEEK_ORIGIN;
1850
+ return isOfficialProvider(provider);
1851
+ }
1852
+ /** 一个 provider 是否走订阅套餐计费(豁免按 token 计价)。 */
1853
+ function isSubscriptionCall(subscription, provider) {
1854
+ return typeof subscription === "function" ? subscription(provider) : subscription.has(provider);
1855
+ }
1763
1856
  /** 由 baseURL 归一化出站点 origin(协议 + 主机 + 端口);解析失败回退原值。 */
1764
1857
  function siteOriginOf(baseURL) {
1765
1858
  try {
@@ -1772,11 +1865,17 @@ function siteOriginOf(baseURL) {
1772
1865
  * 把一个 provider 路由归类为站点引用。判定顺序(与路由在 provider 配置里的状态一致):
1773
1866
  * - 路由存在于当前配置且配了 baseURL → 中转站 `site`(按 origin 归组,同站多 key 合并);
1774
1867
  * - 路由存在于当前配置但无 baseURL → 厂商直连 `direct`;
1775
- * - 路由不在当前配置里 `unknown`(改过名 / 删除过,是「读不到」而非「直连」)。
1868
+ * - 路由不在当前配置里:内置官方直连(`deepseek` / `deepseek-*` 形态,不经 llm-pi-ai
1869
+ * 网关)按 `direct` 归位——unknown 一刀切会把官方直连的费用堆进「未知路由」组、
1870
+ * 且不算官方渠道(回归修复);订阅豁免命中的通道(显式 `subscriptionProviders`
1871
+ * 配置或判定函数)同样按 `direct` 归位——订阅管理类插件注册的通道不经路由表,
1872
+ * unknown 桶既掩盖归属又按 token 误计费(issue #37 的 grok build);其余配置外
1873
+ * 名称保留 unknown(无法核实通道)。
1776
1874
  * @param provider - 会话日志里的 provider 路由名(request/header 的 `config.provider`)。
1777
1875
  * @param routes - 当前 provider 路由视图(来自 llm-pi-ai providers)。
1876
+ * @param opts - `subscription`:该调用是否已被订阅豁免判定命中。
1778
1877
  */
1779
- function siteRefOf(provider, routes) {
1878
+ function siteRefOf(provider, routes, opts = {}) {
1780
1879
  const view = routes[provider];
1781
1880
  if (view !== void 0) {
1782
1881
  if (view.baseURL !== void 0) return {
@@ -1789,6 +1888,14 @@ function siteRefOf(provider, routes) {
1789
1888
  provider
1790
1889
  };
1791
1890
  }
1891
+ if (isOfficialProvider(provider)) return {
1892
+ kind: "direct",
1893
+ provider
1894
+ };
1895
+ if (opts.subscription === true) return {
1896
+ kind: "direct",
1897
+ provider
1898
+ };
1792
1899
  return {
1793
1900
  kind: "unknown",
1794
1901
  provider
@@ -1955,7 +2062,8 @@ function serializeFold(fold) {
1955
2062
  turns: fold.turns,
1956
2063
  perf: fold.perf,
1957
2064
  roles: fold.roles,
1958
- lastActive: fold.lastActive
2065
+ lastActive: fold.lastActive,
2066
+ ...fold.title !== void 0 ? { title: fold.title } : {}
1959
2067
  };
1960
2068
  }
1961
2069
  /** Restore a JSON-safe ledger fold into the in-memory Map/Set representation. */
@@ -1974,7 +2082,8 @@ function deserializeFold(fold) {
1974
2082
  turns: fold.turns,
1975
2083
  perf: fold.perf,
1976
2084
  roles: fold.roles,
1977
- lastActive: fold.lastActive
2085
+ lastActive: fold.lastActive,
2086
+ ...fold.title !== void 0 ? { title: fold.title } : {}
1978
2087
  };
1979
2088
  }
1980
2089
  /** Runtime boundary for a user-editable/corrupt ledger file. Invalid rows are ignored. */
@@ -2097,13 +2206,16 @@ function freshFold() {
2097
2206
  * @param fold - 累计目标(原地修改)。
2098
2207
  * @param machine - 折叠状态机:进入时为上一批次末态,返回时为本批次末态。
2099
2208
  * @param events - 本批次事件(日志顺序)。
2100
- * @param subscriptionProviders - provider ids billed through subscription plans.
2101
- * @param officialProviderIds - 官方直连 provider 集合(undefined = 按 deepseek 前缀判定)。
2209
+ * @param subscriptionProviders - 订阅套餐匹配器(显式集合或判定函数)。
2210
+ * @param officialProviderIds - 官方直连 provider 集合(undefined = 按 {@link officialChannelOf} 通道判定)。
2102
2211
  * @param routes - 当前 provider 路由视图(中转站归组)。
2103
2212
  * @param searchCallEstimateCny - 联网搜索请求的单次费用估算(人民币元)。
2104
- * @param seedLength - fork 血缘边界(seq 低于它的事件是父会话种子,跳过)。
2213
+ * @param seedLength - fork 血缘边界(seq 低于它的事件是父会话种子,跳过);
2214
+ * 来源为存储元数据的 `inheritedEventCount`。
2215
+ * @param routeAliases - 历史路由别名(旧路由名 → 当前路由名),改名/删除路由的
2216
+ * 历史用量按别名归位,不再落「未知路由」桶。
2105
2217
  */
2106
- function foldInto(fold, machine, events, subscriptionProviders, officialProviderIds, routes, searchCallEstimateCny, seedLength) {
2218
+ function foldInto(fold, machine, events, subscriptionProviders, officialProviderIds, routes, searchCallEstimateCny, seedLength, routeAliases = {}) {
2107
2219
  let key = machine.key;
2108
2220
  let subscription = machine.subscription;
2109
2221
  let official = machine.official;
@@ -2150,11 +2262,13 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
2150
2262
  continue;
2151
2263
  }
2152
2264
  if (event.type === "request/header") {
2153
- const { model, provider } = event.data.header.config;
2265
+ const { model, provider: rawProvider } = event.data.header.config;
2266
+ const provider = routeAliases[rawProvider] ?? rawProvider;
2154
2267
  key = resolveCatalogKey(model);
2155
- subscription = subscriptionProviders.has(provider);
2156
- official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
2157
- siteBucket = siteBucketKey(siteRefOf(provider, routes));
2268
+ subscription = isSubscriptionCall(subscriptionProviders, provider);
2269
+ const siteRef = siteRefOf(provider, routes, { subscription });
2270
+ official = officialChannelOf(provider, siteRef, officialProviderIds);
2271
+ siteBucket = siteBucketKey(siteRef);
2158
2272
  if (lastOpenStepKey !== void 0) {
2159
2273
  const stepState = steps.get(lastOpenStepKey);
2160
2274
  if (stepState !== void 0 && stepState.requestTime === void 0) stepState.requestTime = event.time;
@@ -2207,10 +2321,12 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
2207
2321
  if (usage === void 0) continue;
2208
2322
  const source = event.data.message?.source;
2209
2323
  if (source?.kind === "model" && typeof source.provider === "string" && typeof source.model === "string") {
2324
+ const provider = routeAliases[source.provider] ?? source.provider;
2210
2325
  key = resolveCatalogKey(source.model);
2211
- subscription = subscriptionProviders.has(source.provider);
2212
- official = officialProviderIds === void 0 ? isOfficialProvider(source.provider) : officialProviderIds.has(source.provider);
2213
- siteBucket = siteBucketKey(siteRefOf(source.provider, routes));
2326
+ subscription = isSubscriptionCall(subscriptionProviders, provider);
2327
+ const siteRef = siteRefOf(provider, routes, { subscription });
2328
+ official = officialChannelOf(provider, siteRef, officialProviderIds);
2329
+ siteBucket = siteBucketKey(siteRef);
2214
2330
  }
2215
2331
  const modelKey = key;
2216
2332
  const day = dayStamp(event.time);
@@ -2338,14 +2454,29 @@ function percentile(values, p) {
2338
2454
  return a + (b - a) * (idx - lo);
2339
2455
  }
2340
2456
  /**
2457
+ * 聚合配置指纹:影响折叠语义的全部配置(订阅豁免、官方名单、路由别名、搜索估值)
2458
+ * 的稳定序列化。账本行的复用判定携带该指纹——用户改配置后(如为 grok build 加
2459
+ * 订阅豁免),历史账本行立即失效并全量重折,配置变更即时生效(issue #37)。
2460
+ * 判定函数无法序列化,统一记为 `fn`(任何函数形态互视为同一指纹)。
2461
+ */
2462
+ function configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate) {
2463
+ return [
2464
+ typeof subscriptionMatcher === "function" ? "fn" : [...subscriptionMatcher].sort().join(","),
2465
+ officialProviderIds === void 0 ? "" : [...officialProviderIds].sort().join(","),
2466
+ Object.keys(routeAliases).sort().map((key) => `${key}=${routeAliases[key]}`).join(","),
2467
+ String(searchEstimate)
2468
+ ].join("|");
2469
+ }
2470
+ /**
2341
2471
  * Create the incremental usage aggregator.
2342
2472
  * @param persistence - the session persistence service.
2343
2473
  * @param options - aggregation tuning (e.g. subscription-plan providers).
2344
2474
  * @returns the aggregator holding the per-session fold cache.
2345
2475
  */
2346
2476
  function createUsageAggregator(persistence, options = {}) {
2347
- const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
2477
+ const subscriptionMatcher = options.subscriptionProviders === void 0 ? new Set(DEFAULT_SUBSCRIPTION_PROVIDERS) : new Set(options.subscriptionProviders);
2348
2478
  const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
2479
+ const routeAliases = options.routeAliases ?? {};
2349
2480
  const maxCacheSessions = options.maxCacheSessions ?? 400;
2350
2481
  const cache = /* @__PURE__ */ new Map();
2351
2482
  const ledger = /* @__PURE__ */ new Map();
@@ -2360,6 +2491,7 @@ function createUsageAggregator(persistence, options = {}) {
2360
2491
  /** 每次聚合取最新的 provider 路由视图(中转站零配置发现);缺省按空处理(全部未知路由)。 */
2361
2492
  const routesOf = () => options.resolveRoutes?.() ?? {};
2362
2493
  const searchEstimate = options.searchCallEstimateCny ?? .02;
2494
+ const foldFingerprint = configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate);
2363
2495
  const FOLD_CHUNK_EVENTS = 8e3;
2364
2496
  /** 把一份(全量或增量)折叠结果登记进 durable ledger;内容无变化时不落盘。 */
2365
2497
  const recordLedger = (id, cwd, stamp, fold) => {
@@ -2368,11 +2500,12 @@ function createUsageAggregator(persistence, options = {}) {
2368
2500
  id,
2369
2501
  ...cwd === void 0 ? {} : { cwd },
2370
2502
  ...stamp === null ? {} : { stamp },
2371
- foldVersion: 8,
2503
+ foldVersion: 13,
2504
+ fingerprint: foldFingerprint,
2372
2505
  fold: serializeFold(fold)
2373
2506
  };
2374
2507
  const row = ledger.get(id);
2375
- if (row === void 0 || row.stamp !== entry.stamp || row.cwd !== entry.cwd || row.foldVersion !== entry.foldVersion || stamp === null && JSON.stringify(row.fold) !== JSON.stringify(entry.fold)) {
2508
+ if (row === void 0 || row.stamp !== entry.stamp || row.cwd !== entry.cwd || row.foldVersion !== entry.foldVersion || row.fingerprint !== entry.fingerprint || stamp === null && JSON.stringify(row.fold) !== JSON.stringify(entry.fold)) {
2376
2509
  ledger.set(id, entry);
2377
2510
  ledgerNeedsSave = true;
2378
2511
  }
@@ -2392,9 +2525,15 @@ function createUsageAggregator(persistence, options = {}) {
2392
2525
  console.warn("[usage-billing] failed to load durable usage ledger; rebuilding from current sessions:", error);
2393
2526
  }
2394
2527
  };
2395
- /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失 / locate 抛错)时返回 null,
2396
- * 让调用方每次重折。locate 调用也纳入 try,避免单个会话的 locate 异常把整份聚合拖垮。 */
2528
+ /** 失效键:宿主 0.1.3+ adapter 暴露的 revision 令牌;0.1.2 形状退回
2529
+ * 日志文件 mtime+size(经 locate,拿不到或抛错时返回 null 让调用方重折)。
2530
+ * locate/stat 调用纳入 try,避免单个会话的异常把整份聚合拖垮。 */
2397
2531
  const stampOf = async (meta) => {
2532
+ if (persistence.stampOf !== void 0) try {
2533
+ return await persistence.stampOf(meta.id);
2534
+ } catch {
2535
+ return null;
2536
+ }
2398
2537
  try {
2399
2538
  const location = persistence.locate?.(meta);
2400
2539
  if (location === void 0) return null;
@@ -2458,7 +2597,7 @@ function createUsageAggregator(persistence, options = {}) {
2458
2597
  try {
2459
2598
  const cwd = meta.cwd;
2460
2599
  const ledgerRow = ledger.get(id);
2461
- if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 8) {
2600
+ if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 13 && ledgerRow.fingerprint === foldFingerprint) {
2462
2601
  const fold = deserializeFold(ledgerRow.fold);
2463
2602
  folds.push({
2464
2603
  id,
@@ -2471,7 +2610,7 @@ function createUsageAggregator(persistence, options = {}) {
2471
2610
  const previous = cache.get(id);
2472
2611
  if (previous?.machine !== void 0 && previous.lastSeq !== void 0) {
2473
2612
  const from = previous.lastSeq + 1;
2474
- const { events } = await persistence.readFrom(meta.id, from);
2613
+ const { events, inheritedEventCount } = await persistence.readFrom(meta.id, SessionLogOffset(from));
2475
2614
  const after = await stampOf(meta);
2476
2615
  if (stamp !== null && after !== stamp) {
2477
2616
  cache.delete(id);
@@ -2482,10 +2621,9 @@ function createUsageAggregator(persistence, options = {}) {
2482
2621
  cache.delete(id);
2483
2622
  continue;
2484
2623
  }
2485
- foldInto(previous.fold, previous.machine, events, subscriptionProviders, officialProviderIds, routesOf(), searchEstimate, meta.seedLength ?? 0);
2624
+ foldInto(previous.fold, previous.machine, events, subscriptionMatcher, officialProviderIds, routesOf(), searchEstimate, inheritedEventCount, routeAliases);
2486
2625
  refreshTurns(previous.fold, previous.machine);
2487
- const last = events[events.length - 1];
2488
- previous.lastSeq = typeof last.seq === "number" ? last.seq : previous.lastSeq;
2626
+ previous.lastSeq = events[events.length - 1]?.seq ?? previous.lastSeq;
2489
2627
  previous.stamp = stamp;
2490
2628
  cache.delete(id);
2491
2629
  cache.set(id, previous);
@@ -2498,13 +2636,13 @@ function createUsageAggregator(persistence, options = {}) {
2498
2636
  recordLedger(id, cwd, stamp, previous.fold);
2499
2637
  continue;
2500
2638
  }
2501
- const { events } = await persistence.readFrom(meta.id, 0);
2639
+ const { events, inheritedEventCount } = await persistence.readFrom(meta.id, SessionLogOffset(0));
2502
2640
  const after = await stampOf(meta);
2503
2641
  if (stamp !== null && after !== stamp) continue;
2504
2642
  const fold = freshFold();
2505
2643
  const machine = freshMachine();
2506
2644
  for (let start = 0; start < events.length; start += FOLD_CHUNK_EVENTS) {
2507
- foldInto(fold, machine, events.slice(start, start + FOLD_CHUNK_EVENTS), subscriptionProviders, officialProviderIds, routesOf(), searchEstimate, meta.seedLength ?? 0);
2645
+ foldInto(fold, machine, events.slice(start, start + FOLD_CHUNK_EVENTS), subscriptionMatcher, officialProviderIds, routesOf(), searchEstimate, inheritedEventCount, routeAliases);
2508
2646
  if (start + FOLD_CHUNK_EVENTS < events.length) await new Promise((resolve) => {
2509
2647
  setImmediate(resolve);
2510
2648
  });
@@ -2541,7 +2679,7 @@ function createUsageAggregator(persistence, options = {}) {
2541
2679
  for (const entry of ledger.values()) {
2542
2680
  if (included.has(entry.id)) continue;
2543
2681
  try {
2544
- const stale = (entry.foldVersion ?? 1) < 8;
2682
+ const stale = (entry.foldVersion ?? 1) < 13 || entry.fingerprint !== foldFingerprint;
2545
2683
  folds.push({
2546
2684
  id: entry.id,
2547
2685
  ...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
@@ -2577,7 +2715,7 @@ function createUsageAggregator(persistence, options = {}) {
2577
2715
  outputCost: 0
2578
2716
  };
2579
2717
  const perfModel = /* @__PURE__ */ new Map();
2580
- const perfHour = /* @__PURE__ */ new Map();
2718
+ const perfHourModel = /* @__PURE__ */ new Map();
2581
2719
  for (const { id: sessionId, cwd, fold, staleLedger } of folds) {
2582
2720
  mergeUsageInto(total, fold.total);
2583
2721
  roles.userChars += fold.roles.userChars;
@@ -2608,13 +2746,18 @@ function createUsageAggregator(persistence, options = {}) {
2608
2746
  if (sample.tps !== void 0) modelAccum.tps.push(sample.tps);
2609
2747
  if (sample.latencyMs !== void 0) modelAccum.latencies.push(sample.latencyMs);
2610
2748
  if (sample.estimated) modelAccum.estimated += 1;
2611
- let hourAccum = perfHour.get(sample.hour);
2749
+ let hourModels = perfHourModel.get(sample.hour);
2750
+ if (hourModels === void 0) {
2751
+ hourModels = /* @__PURE__ */ new Map();
2752
+ perfHourModel.set(sample.hour, hourModels);
2753
+ }
2754
+ let hourAccum = hourModels.get(sample.model);
2612
2755
  if (hourAccum === void 0) {
2613
2756
  hourAccum = {
2614
2757
  ttfts: [],
2615
2758
  tps: []
2616
2759
  };
2617
- perfHour.set(sample.hour, hourAccum);
2760
+ hourModels.set(sample.model, hourAccum);
2618
2761
  }
2619
2762
  hourAccum.ttfts.push(sample.ttftMs);
2620
2763
  if (sample.tps !== void 0) hourAccum.tps.push(sample.tps);
@@ -2674,14 +2817,14 @@ function createUsageAggregator(persistence, options = {}) {
2674
2817
  latencyAvg: acc.latencies.length === 0 ? 0 : mean(acc.latencies),
2675
2818
  estimatedSamples: acc.estimated
2676
2819
  }])),
2677
- byHour: Object.fromEntries([...perfHour].map(([hour, acc]) => [hour, {
2820
+ byHourModel: Object.fromEntries([...perfHourModel].map(([hour, models]) => [hour, Object.fromEntries([...models].map(([model, acc]) => [model, {
2678
2821
  samples: acc.ttfts.length,
2679
2822
  ttftAvg: mean(acc.ttfts),
2680
2823
  ...acc.tps.length === 0 ? {} : { tpsAvg: mean(acc.tps) }
2681
- }]))
2824
+ }]))]))
2682
2825
  };
2683
2826
  lastDoc = {
2684
- version: 3,
2827
+ version: 4,
2685
2828
  updatedAt: now,
2686
2829
  source: "session-logs",
2687
2830
  timezone: hostTimeZone(),
@@ -2804,6 +2947,186 @@ function createCooldownGate(options = {}) {
2804
2947
  };
2805
2948
  }
2806
2949
  //#endregion
2950
+ //#region lib/types/tc3.js
2951
+ /**
2952
+ * Tencent Cloud TokenHub control-plane access (cloud API 3.0, TC3-HMAC-SHA256).
2953
+ *
2954
+ * Shared by two surfaces that read the same Token Plan:
2955
+ * - `balance.ts` — the balance row (absolute remaining quota);
2956
+ * - `subscriptions.ts` — the subscription card (monthly window + plan name).
2957
+ *
2958
+ * Credentials are a cloud API key pair (`<SecretId>:<SecretKey>`), NOT the
2959
+ * TokenHub inference key. Field names on the control plane are not fully
2960
+ * stable, so parsing stays defensive (semantic-key scanning).
2961
+ */
2962
+ /** TokenHub 管控面 API 端点(cloud.tencent.cn/document/api/1823/132270)。 */
2963
+ const TOKENHUB_HOST = "tokenhub.tencentcloudapi.com";
2964
+ /** 云 API 3.0 产品名与版本(签名的 service 段与请求头都必须一致)。 */
2965
+ const TOKENHUB_SERVICE = "tokenhub";
2966
+ const TOKENHUB_VERSION = "2026-03-22";
2967
+ /** 请求地域:管控面对地域不敏感,取默认国内地域(文档地域列表含 ap-guangzhou)。 */
2968
+ const TOKENHUB_REGION = "ap-guangzhou";
2969
+ /** 管控面调用超时(毫秒):余额与订阅面板共用同一预算。 */
2970
+ const TOKENHUB_TIMEOUT_MS = 8e3;
2971
+ /** 腾讯云凭据引用值格式:`<SecretId>:<SecretKey>`(分隔符取首个冒号)。 */
2972
+ function parseTencentCredential(value) {
2973
+ const sep = value.indexOf(":");
2974
+ if (sep === -1) return void 0;
2975
+ const secretId = value.slice(0, sep).trim();
2976
+ const secretKey = value.slice(sep + 1).trim();
2977
+ if (secretId === "" || secretKey === "") return void 0;
2978
+ return {
2979
+ secretId,
2980
+ secretKey
2981
+ };
2982
+ }
2983
+ /**
2984
+ * 构造云 API 3.0 TC3-HMAC-SHA256 签名(官方签名方法 v3)。导出供测试:纯函数,
2985
+ * 输入确定则签名确定。Action 不参与签名——它走 `X-TC-Action` 请求头。
2986
+ * @param secretId - 云 API SecretId。
2987
+ * @param secretKey - 云 API SecretKey。
2988
+ * @param payload - 已序列化的请求体(含 Action/Version/Region 公共参数)。
2989
+ * @param timestamp - 签名时间戳(秒)。
2990
+ * @returns Authorization 头的值。
2991
+ */
2992
+ function tc3Authorization(secretId, secretKey, payload, timestamp) {
2993
+ const date = (/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString().slice(0, 10);
2994
+ const canonicalRequest = `POST\n/\n\n${`content-type:application/json; charset=utf-8\nhost:${TOKENHUB_HOST}\n`}\ncontent-type;host\n${createHash("sha256").update(payload).digest("hex")}`;
2995
+ const hashedCanonical = createHash("sha256").update(canonicalRequest).digest("hex");
2996
+ const stringToSign = `TC3-HMAC-SHA256\n${String(timestamp)}\n${date}/${TOKENHUB_SERVICE}/tc3_request\n${hashedCanonical}`;
2997
+ const kDate = createHmac("sha256", date).update(secretKey).digest();
2998
+ const kService = createHmac("sha256", kDate).update(TOKENHUB_SERVICE).digest();
2999
+ const kSigning = createHmac("sha256", kService).update("tc3_request").digest();
3000
+ const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
3001
+ return `TC3-HMAC-SHA256 Credential=${secretId}/${date}/${TOKENHUB_SERVICE}/tc3_request, SignedHeaders=content-type;host, Signature=${signature}`;
3002
+ }
3003
+ /**
3004
+ * 调用一次 TokenHub 管控面接口:TC3 签名 + 超时保护,返回响应 JSON 的 `Response`。
3005
+ * 业务错误(Response.Error)与 HTTP 层错误都带 `code` 抛出,调用方按
3006
+ * unauthorized / unreachable / invalid 归类。
3007
+ */
3008
+ async function callTokenHub(secretId, secretKey, action, params) {
3009
+ const payload = JSON.stringify({
3010
+ Action: action,
3011
+ Version: TOKENHUB_VERSION,
3012
+ Region: TOKENHUB_REGION,
3013
+ ...params
3014
+ });
3015
+ const timestamp = Math.floor(Date.now() / 1e3);
3016
+ const controller = new AbortController();
3017
+ const timer = setTimeout(() => controller.abort(), TOKENHUB_TIMEOUT_MS);
3018
+ try {
3019
+ const response = await fetch(`https://${TOKENHUB_HOST}/`, {
3020
+ method: "POST",
3021
+ headers: {
3022
+ "content-type": "application/json; charset=utf-8",
3023
+ host: TOKENHUB_HOST,
3024
+ "x-tc-action": action.toLowerCase(),
3025
+ "x-tc-version": TOKENHUB_VERSION,
3026
+ "x-tc-region": TOKENHUB_REGION,
3027
+ "x-tc-timestamp": String(timestamp),
3028
+ authorization: tc3Authorization(secretId, secretKey, payload, timestamp)
3029
+ },
3030
+ body: payload,
3031
+ signal: controller.signal
3032
+ });
3033
+ if (response.status === 401 || response.status === 403) throw Object.assign(/* @__PURE__ */ new Error("unauthorized"), { code: "unauthorized" });
3034
+ if (!response.ok) throw Object.assign(/* @__PURE__ */ new Error(`HTTP ${String(response.status)}`), {
3035
+ httpStatus: response.status,
3036
+ code: "unreachable"
3037
+ });
3038
+ const inner = (await response.json()).Response;
3039
+ if (inner === void 0) throw Object.assign(/* @__PURE__ */ new Error("no Response envelope"), { code: "invalid" });
3040
+ if (inner.Error !== void 0 && inner.Error !== null) {
3041
+ const err = inner.Error;
3042
+ const code = err.Code === "AuthFailure.SignatureFailure" || err.Code === "AuthFailure.SecretIdNotFound" ? "unauthorized" : "unreachable";
3043
+ throw Object.assign(new Error(String(err.Code ?? "api-error")), { code });
3044
+ }
3045
+ return inner;
3046
+ } finally {
3047
+ clearTimeout(timer);
3048
+ }
3049
+ }
3050
+ /** 数字归一化:上游可能给字符串数字;非有限数返回 undefined。 */
3051
+ function toNumber$1(value) {
3052
+ if (typeof value === "number" && Number.isFinite(value)) return value;
3053
+ if (typeof value === "string") {
3054
+ const parsed = Number(value);
3055
+ return Number.isFinite(parsed) ? parsed : void 0;
3056
+ }
3057
+ }
3058
+ /**
3059
+ * 在套餐余量对象里防御性提取「剩余额度」:官方 SubPackageBalance/PackageInfo
3060
+ * 的字段名未稳定公开(issue #18 调研期),按语义键名扫描——命中 remaining /
3061
+ * balance / left 语义键直接用;命中 total 与 used 则相减推导。数字一律经
3062
+ * {@link toNumber} 归一化(上游可能给字符串)。
3063
+ * 导出供测试:纯函数。
3064
+ * @param source - 套餐详情里的余量对象(PackageInfo / SubPackageBalance 等)。
3065
+ * @returns 剩余额度(上游单位,通常为 token 数或元);提取不到返回 undefined。
3066
+ */
3067
+ function pickRemainingQuota(source) {
3068
+ if (source === null || typeof source !== "object") return void 0;
3069
+ const numeric = Object.entries(source).filter(([, v]) => v !== null && toNumber$1(v) !== void 0);
3070
+ const byKey = (needles) => {
3071
+ for (const [key, value] of numeric) {
3072
+ const lower = key.toLowerCase();
3073
+ if (needles.some((n) => lower.includes(n))) {
3074
+ const num = toNumber$1(value);
3075
+ if (num !== void 0 && num >= 0) return num;
3076
+ }
3077
+ }
3078
+ };
3079
+ const remaining = byKey([
3080
+ "remain",
3081
+ "balance",
3082
+ "left",
3083
+ "available"
3084
+ ]);
3085
+ if (remaining !== void 0) return remaining;
3086
+ const total = byKey(["total"]);
3087
+ const used = byKey(["used", "consume"]);
3088
+ if (total !== void 0 && used !== void 0) return Math.max(0, total - used);
3089
+ }
3090
+ /**
3091
+ * 防御性提取「总额度」:语义键 total / limit / quota。与
3092
+ * {@link pickRemainingQuota} 配对使用——两者都能取到时订阅卡才能算出
3093
+ * 已用百分比窗口;只有剩余值时窗口保持为空(绝不猜总额度)。
3094
+ * 注意排除键名:`RemainingQuota` / `UsedQuota` 这类键同样含 quota 子串,
3095
+ * 必须先剔除剩余 / 已用语义,否则会把剩余值误当总额度(有单测守卫)。
3096
+ * @param source - 套餐详情里的额度对象(PackageInfo / SubPackageBalance 等)。
3097
+ */
3098
+ function pickTotalQuota(source) {
3099
+ if (source === null || typeof source !== "object") return void 0;
3100
+ const forbidden = [
3101
+ "remain",
3102
+ "used",
3103
+ "consume",
3104
+ "left",
3105
+ "available",
3106
+ "balance"
3107
+ ];
3108
+ for (const [key, value] of Object.entries(source)) {
3109
+ const lower = key.toLowerCase();
3110
+ if (!lower.includes("total") && !lower.includes("limit") && !lower.includes("quota")) continue;
3111
+ if (forbidden.some((marker) => lower.includes(marker))) continue;
3112
+ const num = toNumber$1(value);
3113
+ if (num !== void 0 && num > 0) return num;
3114
+ }
3115
+ }
3116
+ /** 套餐列表里提取第一个启用套餐的 TeamId:集合字段名做候选兼容。 */
3117
+ function firstEnabledTeamId(inner) {
3118
+ const candidates = inner.TeamSet ?? inner.TokenPlanSet ?? inner.PlanSet;
3119
+ if (!Array.isArray(candidates)) return void 0;
3120
+ for (const item of candidates) {
3121
+ if (item === null || typeof item !== "object") continue;
3122
+ const row = item;
3123
+ if (row.TeamId === void 0 && row.PlanId === void 0) continue;
3124
+ if (row.Status !== void 0 && row.Status !== "enable") continue;
3125
+ const id = row.TeamId ?? row.PlanId;
3126
+ if (typeof id === "string" && id !== "") return id;
3127
+ }
3128
+ }
3129
+ //#endregion
2807
3130
  //#region lib/types/balance.js
2808
3131
  /**
2809
3132
  * Account-balance queries for the billing dashboard.
@@ -2848,13 +3171,6 @@ const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
2848
3171
  /** 智谱 GLM(大模型国内域)官方余额接口(open.bigmodel.cn/api/paas/v4/balance)。 */
2849
3172
  const ZHIPU_BALANCE_URL = "https://open.bigmodel.cn/api/paas/v4/balance";
2850
3173
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
2851
- function toNumber$1(value) {
2852
- if (typeof value === "number" && Number.isFinite(value)) return value;
2853
- if (typeof value === "string") {
2854
- const parsed = Number(value);
2855
- return Number.isFinite(parsed) ? parsed : void 0;
2856
- }
2857
- }
2858
3174
  /**
2859
3175
  * Fetch a Bearer-protected balance endpoint and normalize the HTTP outcome into
2860
3176
  * a shared {@link ProviderBalance} row. Each provider supplies its own
@@ -3139,6 +3455,26 @@ const QUERIERS = [
3139
3455
  route: "tokendance-space",
3140
3456
  displayName: "TokenDance",
3141
3457
  querier: queryTokenDance
3458
+ },
3459
+ {
3460
+ route: "tencent-tokenhub",
3461
+ displayName: "腾讯云 TokenHub",
3462
+ querier: queryTencentTokenPlan
3463
+ },
3464
+ {
3465
+ route: "tokenhub",
3466
+ displayName: "腾讯云 TokenHub",
3467
+ querier: queryTencentTokenPlan
3468
+ },
3469
+ {
3470
+ route: "tencent",
3471
+ displayName: "腾讯云 TokenHub",
3472
+ querier: queryTencentTokenPlan
3473
+ },
3474
+ {
3475
+ route: "tencentcloud",
3476
+ displayName: "腾讯云 TokenHub",
3477
+ querier: queryTencentTokenPlan
3142
3478
  }
3143
3479
  ];
3144
3480
  /**
@@ -3171,6 +3507,77 @@ async function queryBalances(ctx, providers) {
3171
3507
  return querier(ctx, env);
3172
3508
  }));
3173
3509
  }
3510
+ /**
3511
+ * 查询腾讯云 TokenHub Token Plan 套餐余量。凭据值格式 `<SecretId>:<SecretKey>`
3512
+ * (云 API 密钥,非 TokenHub 推理 key)。链路:套餐列表取 TeamId → 套餐详情读
3513
+ * 主额度包余量。管控面字段名未完全稳定,解析按语义键防御提取。
3514
+ * @param ctx - host context carrying the credentials seam.
3515
+ * @param apiKeyEnv - credential reference resolving the `<SecretId>:<SecretKey>` pair.
3516
+ */
3517
+ async function queryTencentTokenPlan(ctx, apiKeyEnv) {
3518
+ const provider = "腾讯云 TokenHub";
3519
+ const hit = await ctx.credentials.resolve(credentialRef(apiKeyEnv));
3520
+ if (hit === void 0) return {
3521
+ provider,
3522
+ displayName: provider,
3523
+ error: "unconfigured"
3524
+ };
3525
+ if (!balanceGate.check(provider)) return {
3526
+ provider,
3527
+ displayName: provider,
3528
+ error: "unreachable"
3529
+ };
3530
+ const credential = parseTencentCredential(hit.value);
3531
+ if (credential === void 0) return {
3532
+ provider,
3533
+ displayName: provider,
3534
+ error: "unauthorized"
3535
+ };
3536
+ const doRequest = async () => {
3537
+ const teamId = firstEnabledTeamId(await callTokenHub(credential.secretId, credential.secretKey, "DescribeTokenPlanList", {}));
3538
+ if (teamId === void 0) return {
3539
+ provider,
3540
+ displayName: provider,
3541
+ error: "invalid"
3542
+ };
3543
+ const detail = await callTokenHub(credential.secretId, credential.secretKey, "DescribeTokenPlan", { TeamId: teamId });
3544
+ const remaining = pickRemainingQuota(detail.PackageInfo) ?? pickRemainingQuota(detail);
3545
+ if (remaining === void 0) {
3546
+ console.warn(`[usage-billing] balance response drifted for ${provider}: no remaining-quota field parsed`);
3547
+ return {
3548
+ provider,
3549
+ displayName: provider,
3550
+ error: "invalid"
3551
+ };
3552
+ }
3553
+ const plan = typeof detail.Name === "string" ? detail.Name : void 0;
3554
+ const exhausted = detail.StopReason === "EXHAUSTED";
3555
+ return {
3556
+ provider,
3557
+ displayName: provider,
3558
+ currency: "CNY",
3559
+ totalBalance: remaining,
3560
+ ...plan !== void 0 ? { plan } : {},
3561
+ ...exhausted ? { isAvailable: false } : {}
3562
+ };
3563
+ };
3564
+ try {
3565
+ const row = await withRetry(doRequest, {
3566
+ retries: 1,
3567
+ baseDelayMs: 250,
3568
+ maxDelayMs: 2e3
3569
+ });
3570
+ balanceGate.success(provider);
3571
+ return row;
3572
+ } catch (error) {
3573
+ balanceGate.fail(provider);
3574
+ return {
3575
+ provider,
3576
+ displayName: provider,
3577
+ error: error.code === "unauthorized" ? "unauthorized" : "unreachable"
3578
+ };
3579
+ }
3580
+ }
3174
3581
  /** 点路径取值:`data.total_available` → 逐层下钻;任一缺失返回 undefined。 */
3175
3582
  function getPath(data, path) {
3176
3583
  let cursor = data;
@@ -3987,6 +4394,7 @@ const EMPTY_SUBSCRIPTION_KEYS = {
3987
4394
  opencodeApiKey: "",
3988
4395
  minmaxApiKey: "",
3989
4396
  openrouterApiKey: "",
4397
+ tencentCloudApi: "",
3990
4398
  zaiRegion: "global"
3991
4399
  };
3992
4400
  /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
@@ -4011,10 +4419,12 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
4011
4419
  "minimax-token-plan": "MiniMax Token Plan",
4012
4420
  "minimax-token-plan-cn": "MiniMax Token Plan(国内)",
4013
4421
  "minimax-cn": "MiniMax Token Plan(国内)",
4014
- "openrouter": "OpenRouter"
4422
+ "openrouter": "OpenRouter",
4423
+ "tencent-token-plan": "腾讯云 Token Plan",
4424
+ "grok": "Grok(X Premium)"
4015
4425
  };
4016
4426
  /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
4017
- const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-cn|minimax-token-plan|minimax-token-plan-cn|openrouter)", "i");
4427
+ const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-cn|minimax-token-plan|minimax-token-plan-cn|openrouter|grok)", "i");
4018
4428
  /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
4019
4429
  function isSubscriptionProviderId(providerId) {
4020
4430
  if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
@@ -4030,7 +4440,8 @@ const SUBSCRIPTION_ADAPTERS = {
4030
4440
  "minimax-cn": { collect: collectMiniMax },
4031
4441
  "minimax-token-plan": { collect: collectMiniMax },
4032
4442
  "minimax-token-plan-cn": { collect: collectMiniMax },
4033
- "openrouter": { collect: collectOpenRouter }
4443
+ "openrouter": { collect: collectOpenRouter },
4444
+ "tencent-token-plan": { collect: collectTencentTokenPlan }
4034
4445
  };
4035
4446
  /** 有额度适配器的 provider id 集合(识别用)。 */
4036
4447
  const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
@@ -4527,6 +4938,48 @@ async function collectOpenRouter(keys, config, timeoutMs) {
4527
4938
  }
4528
4939
  }
4529
4940
  /**
4941
+ * 腾讯云 Token Plan(TokenHub 管控面)订阅额度。凭据是云 API 密钥对
4942
+ * `<SecretId>:<SecretKey>`(与余额面板同源、同格式),链路与 balance.ts 的
4943
+ * TokenHub 查询一致:DescribeTokenPlanList 取 TeamId → DescribeTokenPlan 读
4944
+ * 主额度包。管控面字段名未完全稳定:剩余与总额度都能取到才产出百分比窗口,
4945
+ * 只有剩余值时窗口留空(绝不猜总额度),plan 名照常展示。
4946
+ */
4947
+ async function collectTencentTokenPlan(keys, _config, _timeoutMs) {
4948
+ const provider = "tencent-token-plan";
4949
+ const displayName = SUBSCRIPTION_DISPLAY_NAMES[provider] ?? provider;
4950
+ const hint = "凭据需为腾讯云云 API 密钥对(SecretId:SecretKey,控制台访问管理获取),非 TokenHub 推理 key";
4951
+ const quota = (partial) => ({
4952
+ provider,
4953
+ displayName,
4954
+ windows: [],
4955
+ hint,
4956
+ ...partial
4957
+ });
4958
+ if (keys.tencentCloudApi === "") return quota({ status: "not-configured" });
4959
+ const credential = parseTencentCredential(keys.tencentCloudApi);
4960
+ if (credential === void 0) return quota({ status: "unauthorized" });
4961
+ try {
4962
+ const teamId = firstEnabledTeamId(await callTokenHub(credential.secretId, credential.secretKey, "DescribeTokenPlanList", {}));
4963
+ if (teamId === void 0) return quota({ status: "invalid-response" });
4964
+ const detail = await callTokenHub(credential.secretId, credential.secretKey, "DescribeTokenPlan", { TeamId: teamId });
4965
+ const remaining = pickRemainingQuota(detail.PackageInfo) ?? pickRemainingQuota(detail);
4966
+ const total = pickTotalQuota(detail.PackageInfo) ?? pickTotalQuota(detail);
4967
+ const plan = typeof detail.Name === "string" ? detail.Name : void 0;
4968
+ return quota({
4969
+ status: "ok",
4970
+ windows: remaining !== void 0 && total !== void 0 && total > 0 ? [{
4971
+ kind: "monthly",
4972
+ usedPercent: round1$1(clampPercent$1((1 - remaining / total) * 100) ?? 0),
4973
+ remainingPercent: round1$1(clampPercent$1(remaining / total * 100) ?? 0),
4974
+ remaining
4975
+ }] : [],
4976
+ ...plan !== void 0 ? { plan } : {}
4977
+ });
4978
+ } catch (error) {
4979
+ return quota({ status: statusOf$1(error) });
4980
+ }
4981
+ }
4982
+ /**
4530
4983
  * Collect quota for the given plans concurrently (adapter-backed plans only;
4531
4984
  * identified plans without an adapter are surfaced by the caller as "no
4532
4985
  * quota API" rows).
@@ -5085,6 +5538,10 @@ const SUBSCRIPTION_KEY_SOURCES = [
5085
5538
  {
5086
5539
  provider: "openrouter",
5087
5540
  key: "openrouterApiKey"
5541
+ },
5542
+ {
5543
+ provider: "tencent-token-plan",
5544
+ key: "tencentCloudApi"
5088
5545
  }
5089
5546
  ];
5090
5547
  /** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
@@ -5208,6 +5665,44 @@ async function readOpenCodeToken() {
5208
5665
  return "";
5209
5666
  }
5210
5667
  /**
5668
+ * 宿主 persistence 形状适配。宿主 0.1.3 起 SessionPersistence 改为
5669
+ * SessionHandle 模型(open(id,'read') 后经 handle.read(offset) 读,fork 边界
5670
+ * 挂在 handle.inheritedEventCount,list 返回 {header, revision} 快照行,
5671
+ * 0.1.2 的 readFrom/locate 消失)。这里按结构探测把两种宿主形状都收敛为
5672
+ * 聚合层期望的 0.1.2 面貌:0.1.2 直接带 readFrom 的原样直通;0.1.3 的
5673
+ * 读取转为 open → handle.read,revision 令牌经 stampOf 暴露给增量缓存。
5674
+ * 候选时刻的运行时对象是宿主注入的外部形状,结构断言即 durable 收窄点。
5675
+ * 导出供测试:纯函数(不触 ctx)。
5676
+ */
5677
+ function adaptSessionPersistence(raw) {
5678
+ if (typeof raw?.readFrom === "function") return raw;
5679
+ const host = raw;
5680
+ const revisions = /* @__PURE__ */ new Map();
5681
+ return {
5682
+ list: async () => {
5683
+ const snapshots = await host.list();
5684
+ revisions.clear();
5685
+ for (const snapshot of snapshots) revisions.set(String(snapshot.header.id), String(snapshot.revision));
5686
+ return snapshots.map((snapshot) => snapshot.header);
5687
+ },
5688
+ readFrom: async (id, fromSeq) => {
5689
+ const handle = await host.open(id, "read");
5690
+ try {
5691
+ const events = await handle.read(fromSeq);
5692
+ return {
5693
+ meta: handle.header,
5694
+ fromSeq: SessionLogOffset(fromSeq),
5695
+ inheritedEventCount: SessionLogOffset(handle.inheritedEventCount),
5696
+ events
5697
+ };
5698
+ } finally {
5699
+ await handle[Symbol.asyncDispose]?.();
5700
+ }
5701
+ },
5702
+ stampOf: async (id) => revisions.get(String(id)) ?? null
5703
+ };
5704
+ }
5705
+ /**
5211
5706
  * Host plugin body: serve real aggregated usage to the browser dashboard.
5212
5707
  * @param ctx - host context carrying webServer and sessionPersistence.
5213
5708
  * @param config - optional statsPath override.
@@ -5237,8 +5732,10 @@ function apply(ctx, config = {}) {
5237
5732
  }).catch(() => {});
5238
5733
  };
5239
5734
  const workspaceTitleResolver = buildWorkspaceTitleResolver(ctx);
5240
- const aggregator = createUsageAggregator(ctx.sessionPersistence, {
5735
+ applyUserModelAliases(config.modelKeyAliases);
5736
+ const aggregator = createUsageAggregator(adaptSessionPersistence(ctx.sessionPersistence), {
5241
5737
  ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders },
5738
+ ...config.routeAliases === void 0 ? {} : { routeAliases: config.routeAliases },
5242
5739
  resolveRoutes: () => readPiAiProviderRoutes(ctx.settings),
5243
5740
  ...workspaceTitleResolver === void 0 ? {} : { resolveWorkspaceTitle: workspaceTitleResolver },
5244
5741
  ...config.searchCallEstimateCny === void 0 ? {} : { searchCallEstimateCny: config.searchCallEstimateCny },
@@ -5764,4 +6261,4 @@ function apply(ctx, config = {}) {
5764
6261
  }), "usage-billing: usage-stats route");
5765
6262
  }
5766
6263
  //#endregion
5767
- export { apply, createFileUsageLedgerStore, guardLoopback, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };
6264
+ export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };