@kenz1117/dsh-ui-usage-billing 1.1.14 → 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.
@@ -1435,6 +1451,7 @@ const MODEL_KEY_ALIASES = {
1435
1451
  "command-a": "command-a-03-2025",
1436
1452
  "command-r-08-2024": "command-r-08-2024",
1437
1453
  "command-r": "command-r-08-2024",
1454
+ "hy3": "hunyuan",
1438
1455
  "longcat-2.0": "longcat-2.0",
1439
1456
  "longcat-2": "longcat-2.0",
1440
1457
  "minicpm-v-4.5": "minicpm-v-4.5",
@@ -1523,8 +1540,49 @@ const CATALOG_CANON_INDEX = (() => {
1523
1540
  * @param id - 真实模型 id(日志里出现的形式)。
1524
1541
  * @returns 计费目录键。
1525
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
+ }
1526
1584
  function resolveCatalogKey(id) {
1527
- const exact = MODEL_KEY_ALIASES[id] ?? id;
1585
+ const exact = userModelAliases?.[id] ?? MODEL_KEY_ALIASES[id] ?? id;
1528
1586
  if (exact === id) {
1529
1587
  const canon = canonModelId(id);
1530
1588
  if (canon !== "") {
@@ -1533,6 +1591,10 @@ function resolveCatalogKey(id) {
1533
1591
  const extraHit = (liveExtraModels ?? []).find((item) => canonModelId(item.key) === canon);
1534
1592
  if (extraHit !== void 0) return extraHit.key;
1535
1593
  }
1594
+ for (const candidate of derivedKeyCandidates(id)) {
1595
+ const hit = lookupCandidate(candidate);
1596
+ if (hit !== void 0) return hit;
1597
+ }
1536
1598
  }
1537
1599
  return exact;
1538
1600
  }
@@ -1668,24 +1730,31 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE, nowMs = Dat
1668
1730
  return peak * peakShare + off * (1 - peakShare);
1669
1731
  }
1670
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
+ /**
1671
1741
  * 按调用时刻精确判定高峰/空闲档并计价(P0-1:替代固定比例混合)。时刻未知
1672
1742
  * (null/NaN,理论不发生在真实事件流)时回退 {@link DEFAULT_PEAK_SHARE} 混合,
1673
1743
  * 保持旧语义不低估。平档模型(无 offPeak)两个时段同价。限时促销与峰谷档
1674
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},周六日全天低谷)。
1675
1752
  * @param entry - the catalog entry whose prices apply.
1676
1753
  * @param buckets - token usage counts.
1677
1754
  * @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
1678
1755
  * @param peakShare - fallback mix used only when `timeMs` is missing.
1679
1756
  * @returns the estimated cost in CNY(USD 计价模型已按当前汇率折算)。
1680
1757
  */
1681
- /**
1682
- * v1 峰谷档判定(峰谷开闸起、周末全谷分界止):不豁免周末——该时段官方
1683
- * 高峰时段为每天 9-12 / 14-18(周六日同样计峰)。仅用于历史事件计费;
1684
- * 「当前时刻」的档位(提醒/时段条/费率展示)一律走 {@link tierAt} 现行规则。
1685
- */
1686
- function tariffV1At(timeMs) {
1687
- return isPeakHour((new Date(timeMs).getUTCHours() + 8) % 24) ? "peak" : "offPeak";
1688
- }
1689
1758
  function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
1690
1759
  if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return computeCost(entry, buckets, peakShare);
1691
1760
  const priced = applyPromo(entry, timeMs);
@@ -1739,9 +1808,9 @@ function formatTokens(value) {
1739
1808
  /**
1740
1809
  * 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
1741
1810
  * 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
1742
- * 与 pi-ai 内置提供方对齐(含各地区变体:qwen/xiaomi 的 token-plan、opencode 与
1743
- * opencode-go、zai-coding-cn);部署可在 plugin config 的 `subscriptionProviders`
1744
- * 中覆盖。
1811
+ * 此列表保留为显式配置的参考基线;聚合层缺省改用与订阅卡一致的
1812
+ * `isSubscriptionProviderId` 判定(覆盖本列表与全部 `*-token-plan` / `*-coding` 变体),
1813
+ * 部署仍可在 plugin config 的 `subscriptionProviders` 中显式覆盖。
1745
1814
  */
1746
1815
  const DEFAULT_SUBSCRIPTION_PROVIDERS = [
1747
1816
  "kimi-coding",
@@ -1752,7 +1821,9 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
1752
1821
  "qwen-token-plan-cn",
1753
1822
  "xiaomi-token-plan-ams",
1754
1823
  "xiaomi-token-plan-cn",
1755
- "xiaomi-token-plan-sgp"
1824
+ "xiaomi-token-plan-sgp",
1825
+ "tencent-token-plan",
1826
+ "grok"
1756
1827
  ];
1757
1828
  /**
1758
1829
  * 官方渠道 provider id 判定:`deepseek` 前缀(DeepSeek 官方直连)视为官方,
@@ -1762,6 +1833,26 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
1762
1833
  function isOfficialProvider(provider) {
1763
1834
  return /^deepseek(?:-[a-z0-9-]+)?$/i.test(provider.trim());
1764
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
+ }
1765
1856
  /** 由 baseURL 归一化出站点 origin(协议 + 主机 + 端口);解析失败回退原值。 */
1766
1857
  function siteOriginOf(baseURL) {
1767
1858
  try {
@@ -1774,11 +1865,17 @@ function siteOriginOf(baseURL) {
1774
1865
  * 把一个 provider 路由归类为站点引用。判定顺序(与路由在 provider 配置里的状态一致):
1775
1866
  * - 路由存在于当前配置且配了 baseURL → 中转站 `site`(按 origin 归组,同站多 key 合并);
1776
1867
  * - 路由存在于当前配置但无 baseURL → 厂商直连 `direct`;
1777
- * - 路由不在当前配置里 → `unknown`(改过名 / 删除过,是「读不到」而非「直连」)。
1868
+ * - 路由不在当前配置里:内置官方直连(`deepseek` / `deepseek-*` 形态,不经 llm-pi-ai
1869
+ * 网关)按 `direct` 归位——unknown 一刀切会把官方直连的费用堆进「未知路由」组、
1870
+ * 且不算官方渠道(回归修复);订阅豁免命中的通道(显式 `subscriptionProviders`
1871
+ * 配置或判定函数)同样按 `direct` 归位——订阅管理类插件注册的通道不经路由表,
1872
+ * unknown 桶既掩盖归属又按 token 误计费(issue #37 的 grok build);其余配置外
1873
+ * 名称保留 unknown(无法核实通道)。
1778
1874
  * @param provider - 会话日志里的 provider 路由名(request/header 的 `config.provider`)。
1779
1875
  * @param routes - 当前 provider 路由视图(来自 llm-pi-ai providers)。
1876
+ * @param opts - `subscription`:该调用是否已被订阅豁免判定命中。
1780
1877
  */
1781
- function siteRefOf(provider, routes) {
1878
+ function siteRefOf(provider, routes, opts = {}) {
1782
1879
  const view = routes[provider];
1783
1880
  if (view !== void 0) {
1784
1881
  if (view.baseURL !== void 0) return {
@@ -1791,6 +1888,14 @@ function siteRefOf(provider, routes) {
1791
1888
  provider
1792
1889
  };
1793
1890
  }
1891
+ if (isOfficialProvider(provider)) return {
1892
+ kind: "direct",
1893
+ provider
1894
+ };
1895
+ if (opts.subscription === true) return {
1896
+ kind: "direct",
1897
+ provider
1898
+ };
1794
1899
  return {
1795
1900
  kind: "unknown",
1796
1901
  provider
@@ -1957,7 +2062,8 @@ function serializeFold(fold) {
1957
2062
  turns: fold.turns,
1958
2063
  perf: fold.perf,
1959
2064
  roles: fold.roles,
1960
- lastActive: fold.lastActive
2065
+ lastActive: fold.lastActive,
2066
+ ...fold.title !== void 0 ? { title: fold.title } : {}
1961
2067
  };
1962
2068
  }
1963
2069
  /** Restore a JSON-safe ledger fold into the in-memory Map/Set representation. */
@@ -1976,7 +2082,8 @@ function deserializeFold(fold) {
1976
2082
  turns: fold.turns,
1977
2083
  perf: fold.perf,
1978
2084
  roles: fold.roles,
1979
- lastActive: fold.lastActive
2085
+ lastActive: fold.lastActive,
2086
+ ...fold.title !== void 0 ? { title: fold.title } : {}
1980
2087
  };
1981
2088
  }
1982
2089
  /** Runtime boundary for a user-editable/corrupt ledger file. Invalid rows are ignored. */
@@ -2099,13 +2206,16 @@ function freshFold() {
2099
2206
  * @param fold - 累计目标(原地修改)。
2100
2207
  * @param machine - 折叠状态机:进入时为上一批次末态,返回时为本批次末态。
2101
2208
  * @param events - 本批次事件(日志顺序)。
2102
- * @param subscriptionProviders - provider ids billed through subscription plans.
2103
- * @param officialProviderIds - 官方直连 provider 集合(undefined = 按 deepseek 前缀判定)。
2209
+ * @param subscriptionProviders - 订阅套餐匹配器(显式集合或判定函数)。
2210
+ * @param officialProviderIds - 官方直连 provider 集合(undefined = 按 {@link officialChannelOf} 通道判定)。
2104
2211
  * @param routes - 当前 provider 路由视图(中转站归组)。
2105
2212
  * @param searchCallEstimateCny - 联网搜索请求的单次费用估算(人民币元)。
2106
- * @param seedLength - fork 血缘边界(seq 低于它的事件是父会话种子,跳过)。
2213
+ * @param seedLength - fork 血缘边界(seq 低于它的事件是父会话种子,跳过);
2214
+ * 来源为存储元数据的 `inheritedEventCount`。
2215
+ * @param routeAliases - 历史路由别名(旧路由名 → 当前路由名),改名/删除路由的
2216
+ * 历史用量按别名归位,不再落「未知路由」桶。
2107
2217
  */
2108
- function foldInto(fold, machine, events, subscriptionProviders, officialProviderIds, routes, searchCallEstimateCny, seedLength) {
2218
+ function foldInto(fold, machine, events, subscriptionProviders, officialProviderIds, routes, searchCallEstimateCny, seedLength, routeAliases = {}) {
2109
2219
  let key = machine.key;
2110
2220
  let subscription = machine.subscription;
2111
2221
  let official = machine.official;
@@ -2152,11 +2262,13 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
2152
2262
  continue;
2153
2263
  }
2154
2264
  if (event.type === "request/header") {
2155
- const { model, provider } = event.data.header.config;
2265
+ const { model, provider: rawProvider } = event.data.header.config;
2266
+ const provider = routeAliases[rawProvider] ?? rawProvider;
2156
2267
  key = resolveCatalogKey(model);
2157
- subscription = subscriptionProviders.has(provider);
2158
- official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
2159
- 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);
2160
2272
  if (lastOpenStepKey !== void 0) {
2161
2273
  const stepState = steps.get(lastOpenStepKey);
2162
2274
  if (stepState !== void 0 && stepState.requestTime === void 0) stepState.requestTime = event.time;
@@ -2209,10 +2321,12 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
2209
2321
  if (usage === void 0) continue;
2210
2322
  const source = event.data.message?.source;
2211
2323
  if (source?.kind === "model" && typeof source.provider === "string" && typeof source.model === "string") {
2324
+ const provider = routeAliases[source.provider] ?? source.provider;
2212
2325
  key = resolveCatalogKey(source.model);
2213
- subscription = subscriptionProviders.has(source.provider);
2214
- official = officialProviderIds === void 0 ? isOfficialProvider(source.provider) : officialProviderIds.has(source.provider);
2215
- 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);
2216
2330
  }
2217
2331
  const modelKey = key;
2218
2332
  const day = dayStamp(event.time);
@@ -2340,14 +2454,29 @@ function percentile(values, p) {
2340
2454
  return a + (b - a) * (idx - lo);
2341
2455
  }
2342
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
+ /**
2343
2471
  * Create the incremental usage aggregator.
2344
2472
  * @param persistence - the session persistence service.
2345
2473
  * @param options - aggregation tuning (e.g. subscription-plan providers).
2346
2474
  * @returns the aggregator holding the per-session fold cache.
2347
2475
  */
2348
2476
  function createUsageAggregator(persistence, options = {}) {
2349
- 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);
2350
2478
  const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
2479
+ const routeAliases = options.routeAliases ?? {};
2351
2480
  const maxCacheSessions = options.maxCacheSessions ?? 400;
2352
2481
  const cache = /* @__PURE__ */ new Map();
2353
2482
  const ledger = /* @__PURE__ */ new Map();
@@ -2362,6 +2491,7 @@ function createUsageAggregator(persistence, options = {}) {
2362
2491
  /** 每次聚合取最新的 provider 路由视图(中转站零配置发现);缺省按空处理(全部未知路由)。 */
2363
2492
  const routesOf = () => options.resolveRoutes?.() ?? {};
2364
2493
  const searchEstimate = options.searchCallEstimateCny ?? .02;
2494
+ const foldFingerprint = configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate);
2365
2495
  const FOLD_CHUNK_EVENTS = 8e3;
2366
2496
  /** 把一份(全量或增量)折叠结果登记进 durable ledger;内容无变化时不落盘。 */
2367
2497
  const recordLedger = (id, cwd, stamp, fold) => {
@@ -2370,11 +2500,12 @@ function createUsageAggregator(persistence, options = {}) {
2370
2500
  id,
2371
2501
  ...cwd === void 0 ? {} : { cwd },
2372
2502
  ...stamp === null ? {} : { stamp },
2373
- foldVersion: 9,
2503
+ foldVersion: 13,
2504
+ fingerprint: foldFingerprint,
2374
2505
  fold: serializeFold(fold)
2375
2506
  };
2376
2507
  const row = ledger.get(id);
2377
- 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)) {
2378
2509
  ledger.set(id, entry);
2379
2510
  ledgerNeedsSave = true;
2380
2511
  }
@@ -2394,9 +2525,15 @@ function createUsageAggregator(persistence, options = {}) {
2394
2525
  console.warn("[usage-billing] failed to load durable usage ledger; rebuilding from current sessions:", error);
2395
2526
  }
2396
2527
  };
2397
- /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失 / locate 抛错)时返回 null,
2398
- * 让调用方每次重折。locate 调用也纳入 try,避免单个会话的 locate 异常把整份聚合拖垮。 */
2528
+ /** 失效键:宿主 0.1.3+ 走 adapter 暴露的 revision 令牌;0.1.2 形状退回
2529
+ * 日志文件 mtime+size(经 locate,拿不到或抛错时返回 null 让调用方重折)。
2530
+ * locate/stat 调用纳入 try,避免单个会话的异常把整份聚合拖垮。 */
2399
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
+ }
2400
2537
  try {
2401
2538
  const location = persistence.locate?.(meta);
2402
2539
  if (location === void 0) return null;
@@ -2460,7 +2597,7 @@ function createUsageAggregator(persistence, options = {}) {
2460
2597
  try {
2461
2598
  const cwd = meta.cwd;
2462
2599
  const ledgerRow = ledger.get(id);
2463
- if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 9) {
2600
+ if (ledgerRow !== void 0 && ledgerRow.stamp !== void 0 && stamp !== null && ledgerRow.stamp === stamp && (ledgerRow.foldVersion ?? 1) === 13 && ledgerRow.fingerprint === foldFingerprint) {
2464
2601
  const fold = deserializeFold(ledgerRow.fold);
2465
2602
  folds.push({
2466
2603
  id,
@@ -2473,7 +2610,7 @@ function createUsageAggregator(persistence, options = {}) {
2473
2610
  const previous = cache.get(id);
2474
2611
  if (previous?.machine !== void 0 && previous.lastSeq !== void 0) {
2475
2612
  const from = previous.lastSeq + 1;
2476
- const { events } = await persistence.readFrom(meta.id, from);
2613
+ const { events, inheritedEventCount } = await persistence.readFrom(meta.id, SessionLogOffset(from));
2477
2614
  const after = await stampOf(meta);
2478
2615
  if (stamp !== null && after !== stamp) {
2479
2616
  cache.delete(id);
@@ -2484,10 +2621,9 @@ function createUsageAggregator(persistence, options = {}) {
2484
2621
  cache.delete(id);
2485
2622
  continue;
2486
2623
  }
2487
- 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);
2488
2625
  refreshTurns(previous.fold, previous.machine);
2489
- const last = events[events.length - 1];
2490
- previous.lastSeq = typeof last.seq === "number" ? last.seq : previous.lastSeq;
2626
+ previous.lastSeq = events[events.length - 1]?.seq ?? previous.lastSeq;
2491
2627
  previous.stamp = stamp;
2492
2628
  cache.delete(id);
2493
2629
  cache.set(id, previous);
@@ -2500,13 +2636,13 @@ function createUsageAggregator(persistence, options = {}) {
2500
2636
  recordLedger(id, cwd, stamp, previous.fold);
2501
2637
  continue;
2502
2638
  }
2503
- const { events } = await persistence.readFrom(meta.id, 0);
2639
+ const { events, inheritedEventCount } = await persistence.readFrom(meta.id, SessionLogOffset(0));
2504
2640
  const after = await stampOf(meta);
2505
2641
  if (stamp !== null && after !== stamp) continue;
2506
2642
  const fold = freshFold();
2507
2643
  const machine = freshMachine();
2508
2644
  for (let start = 0; start < events.length; start += FOLD_CHUNK_EVENTS) {
2509
- 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);
2510
2646
  if (start + FOLD_CHUNK_EVENTS < events.length) await new Promise((resolve) => {
2511
2647
  setImmediate(resolve);
2512
2648
  });
@@ -2543,7 +2679,7 @@ function createUsageAggregator(persistence, options = {}) {
2543
2679
  for (const entry of ledger.values()) {
2544
2680
  if (included.has(entry.id)) continue;
2545
2681
  try {
2546
- const stale = (entry.foldVersion ?? 1) < 9;
2682
+ const stale = (entry.foldVersion ?? 1) < 13 || entry.fingerprint !== foldFingerprint;
2547
2683
  folds.push({
2548
2684
  id: entry.id,
2549
2685
  ...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
@@ -2579,7 +2715,7 @@ function createUsageAggregator(persistence, options = {}) {
2579
2715
  outputCost: 0
2580
2716
  };
2581
2717
  const perfModel = /* @__PURE__ */ new Map();
2582
- const perfHour = /* @__PURE__ */ new Map();
2718
+ const perfHourModel = /* @__PURE__ */ new Map();
2583
2719
  for (const { id: sessionId, cwd, fold, staleLedger } of folds) {
2584
2720
  mergeUsageInto(total, fold.total);
2585
2721
  roles.userChars += fold.roles.userChars;
@@ -2610,13 +2746,18 @@ function createUsageAggregator(persistence, options = {}) {
2610
2746
  if (sample.tps !== void 0) modelAccum.tps.push(sample.tps);
2611
2747
  if (sample.latencyMs !== void 0) modelAccum.latencies.push(sample.latencyMs);
2612
2748
  if (sample.estimated) modelAccum.estimated += 1;
2613
- 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);
2614
2755
  if (hourAccum === void 0) {
2615
2756
  hourAccum = {
2616
2757
  ttfts: [],
2617
2758
  tps: []
2618
2759
  };
2619
- perfHour.set(sample.hour, hourAccum);
2760
+ hourModels.set(sample.model, hourAccum);
2620
2761
  }
2621
2762
  hourAccum.ttfts.push(sample.ttftMs);
2622
2763
  if (sample.tps !== void 0) hourAccum.tps.push(sample.tps);
@@ -2676,14 +2817,14 @@ function createUsageAggregator(persistence, options = {}) {
2676
2817
  latencyAvg: acc.latencies.length === 0 ? 0 : mean(acc.latencies),
2677
2818
  estimatedSamples: acc.estimated
2678
2819
  }])),
2679
- byHour: Object.fromEntries([...perfHour].map(([hour, acc]) => [hour, {
2820
+ byHourModel: Object.fromEntries([...perfHourModel].map(([hour, models]) => [hour, Object.fromEntries([...models].map(([model, acc]) => [model, {
2680
2821
  samples: acc.ttfts.length,
2681
2822
  ttftAvg: mean(acc.ttfts),
2682
2823
  ...acc.tps.length === 0 ? {} : { tpsAvg: mean(acc.tps) }
2683
- }]))
2824
+ }]))]))
2684
2825
  };
2685
2826
  lastDoc = {
2686
- version: 3,
2827
+ version: 4,
2687
2828
  updatedAt: now,
2688
2829
  source: "session-logs",
2689
2830
  timezone: hostTimeZone(),
@@ -2806,6 +2947,186 @@ function createCooldownGate(options = {}) {
2806
2947
  };
2807
2948
  }
2808
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
2809
3130
  //#region lib/types/balance.js
2810
3131
  /**
2811
3132
  * Account-balance queries for the billing dashboard.
@@ -2850,13 +3171,6 @@ const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
2850
3171
  /** 智谱 GLM(大模型国内域)官方余额接口(open.bigmodel.cn/api/paas/v4/balance)。 */
2851
3172
  const ZHIPU_BALANCE_URL = "https://open.bigmodel.cn/api/paas/v4/balance";
2852
3173
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
2853
- function toNumber$1(value) {
2854
- if (typeof value === "number" && Number.isFinite(value)) return value;
2855
- if (typeof value === "string") {
2856
- const parsed = Number(value);
2857
- return Number.isFinite(parsed) ? parsed : void 0;
2858
- }
2859
- }
2860
3174
  /**
2861
3175
  * Fetch a Bearer-protected balance endpoint and normalize the HTTP outcome into
2862
3176
  * a shared {@link ProviderBalance} row. Each provider supplies its own
@@ -3141,6 +3455,26 @@ const QUERIERS = [
3141
3455
  route: "tokendance-space",
3142
3456
  displayName: "TokenDance",
3143
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
3144
3478
  }
3145
3479
  ];
3146
3480
  /**
@@ -3173,6 +3507,77 @@ async function queryBalances(ctx, providers) {
3173
3507
  return querier(ctx, env);
3174
3508
  }));
3175
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
+ }
3176
3581
  /** 点路径取值:`data.total_available` → 逐层下钻;任一缺失返回 undefined。 */
3177
3582
  function getPath(data, path) {
3178
3583
  let cursor = data;
@@ -3989,6 +4394,7 @@ const EMPTY_SUBSCRIPTION_KEYS = {
3989
4394
  opencodeApiKey: "",
3990
4395
  minmaxApiKey: "",
3991
4396
  openrouterApiKey: "",
4397
+ tencentCloudApi: "",
3992
4398
  zaiRegion: "global"
3993
4399
  };
3994
4400
  /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
@@ -4013,10 +4419,12 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
4013
4419
  "minimax-token-plan": "MiniMax Token Plan",
4014
4420
  "minimax-token-plan-cn": "MiniMax Token Plan(国内)",
4015
4421
  "minimax-cn": "MiniMax Token Plan(国内)",
4016
- "openrouter": "OpenRouter"
4422
+ "openrouter": "OpenRouter",
4423
+ "tencent-token-plan": "腾讯云 Token Plan",
4424
+ "grok": "Grok(X Premium)"
4017
4425
  };
4018
4426
  /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
4019
- 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");
4020
4428
  /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
4021
4429
  function isSubscriptionProviderId(providerId) {
4022
4430
  if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
@@ -4032,7 +4440,8 @@ const SUBSCRIPTION_ADAPTERS = {
4032
4440
  "minimax-cn": { collect: collectMiniMax },
4033
4441
  "minimax-token-plan": { collect: collectMiniMax },
4034
4442
  "minimax-token-plan-cn": { collect: collectMiniMax },
4035
- "openrouter": { collect: collectOpenRouter }
4443
+ "openrouter": { collect: collectOpenRouter },
4444
+ "tencent-token-plan": { collect: collectTencentTokenPlan }
4036
4445
  };
4037
4446
  /** 有额度适配器的 provider id 集合(识别用)。 */
4038
4447
  const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
@@ -4529,6 +4938,48 @@ async function collectOpenRouter(keys, config, timeoutMs) {
4529
4938
  }
4530
4939
  }
4531
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
+ /**
4532
4983
  * Collect quota for the given plans concurrently (adapter-backed plans only;
4533
4984
  * identified plans without an adapter are surfaced by the caller as "no
4534
4985
  * quota API" rows).
@@ -5087,6 +5538,10 @@ const SUBSCRIPTION_KEY_SOURCES = [
5087
5538
  {
5088
5539
  provider: "openrouter",
5089
5540
  key: "openrouterApiKey"
5541
+ },
5542
+ {
5543
+ provider: "tencent-token-plan",
5544
+ key: "tencentCloudApi"
5090
5545
  }
5091
5546
  ];
5092
5547
  /** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
@@ -5210,6 +5665,44 @@ async function readOpenCodeToken() {
5210
5665
  return "";
5211
5666
  }
5212
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
+ /**
5213
5706
  * Host plugin body: serve real aggregated usage to the browser dashboard.
5214
5707
  * @param ctx - host context carrying webServer and sessionPersistence.
5215
5708
  * @param config - optional statsPath override.
@@ -5239,8 +5732,10 @@ function apply(ctx, config = {}) {
5239
5732
  }).catch(() => {});
5240
5733
  };
5241
5734
  const workspaceTitleResolver = buildWorkspaceTitleResolver(ctx);
5242
- const aggregator = createUsageAggregator(ctx.sessionPersistence, {
5735
+ applyUserModelAliases(config.modelKeyAliases);
5736
+ const aggregator = createUsageAggregator(adaptSessionPersistence(ctx.sessionPersistence), {
5243
5737
  ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders },
5738
+ ...config.routeAliases === void 0 ? {} : { routeAliases: config.routeAliases },
5244
5739
  resolveRoutes: () => readPiAiProviderRoutes(ctx.settings),
5245
5740
  ...workspaceTitleResolver === void 0 ? {} : { resolveWorkspaceTitle: workspaceTitleResolver },
5246
5741
  ...config.searchCallEstimateCny === void 0 ? {} : { searchCallEstimateCny: config.searchCallEstimateCny },
@@ -5766,4 +6261,4 @@ function apply(ctx, config = {}) {
5766
6261
  }), "usage-billing: usage-stats route");
5767
6262
  }
5768
6263
  //#endregion
5769
- export { apply, createFileUsageLedgerStore, guardLoopback, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };
6264
+ export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };