@kenz1117/dsh-ui-usage-billing 1.6.7 → 1.7.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
@@ -338,7 +338,8 @@ function isBeijingWeekend(timeMs) {
338
338
  *
339
339
  * 编码为空格分隔的 `YYYYMMDD` 列表(按年分组、同年日期连续排列,便于逐年追加):
340
340
  * 每个键长度固定为 8,故 `includes` 不会跨键误匹配;相比「区间 + 运行时展开」
341
- * 省掉一段解析代码——client / index 两个 face 都要过 256 KiB 单文件上限。
341
+ * 省掉一段解析代码——client / index 两个 face 都要过本仓自设的体积成本线
342
+ * (sync.sh 的 256/320 KiB;DSH Store 对插件 JS bundle 本身无审查上限)。
342
343
  */
343
344
  const CHINA_HOLIDAYS = "20260925 20260926 20260927 20261001 20261002 20261003 20261004 20261005 20261006 20261007";
344
345
  /** 北京日期键 YYYYMMDD(与宿主时区无关)。 */
@@ -3558,6 +3559,7 @@ function createUsageAggregator(persistence, options = {}) {
3558
3559
  let ledgerNeedsSave = false;
3559
3560
  let lastLedgerSaveAt = 0;
3560
3561
  const unreadable = /* @__PURE__ */ new Map();
3562
+ const titleHints = /* @__PURE__ */ new Map();
3561
3563
  let inflight;
3562
3564
  let ledgerAppliedMigrations;
3563
3565
  let lastDoc;
@@ -3590,6 +3592,7 @@ function createUsageAggregator(persistence, options = {}) {
3590
3592
  const foldFingerprintOf = (registered, routes) => configFingerprint(subscriptionMatcher, officialProviderIds, routeAliases, searchEstimate, options.modelKeyAliases, registered, routeOriginsOf(routes));
3591
3593
  let foldFingerprint = foldFingerprintOf(/* @__PURE__ */ new Set(), {});
3592
3594
  const FOLD_CHUNK_EVENTS = 8e3;
3595
+ const FOLD_YIELD_SESSIONS = 8;
3593
3596
  /** 惰性失效(价目表时序):账本/缓存行若以「当时不可计价」的模型落盘,
3594
3597
  * 而价目表现在能计价了,该行必须重折——否则显示层按实时价算出的
3595
3598
  * 「目录价预估」与服务端 cost=0 并存,出现「明细比总额高」的双口径
@@ -3692,7 +3695,10 @@ function createUsageAggregator(persistence, options = {}) {
3692
3695
  let formatUnreadable = 0;
3693
3696
  let noLedgerUnreadable = 0;
3694
3697
  let staleLedgerSessions = 0;
3695
- for (const meta of metas) {
3698
+ for (const [metaIndex, meta] of metas.entries()) {
3699
+ if (metaIndex > 0 && metaIndex % FOLD_YIELD_SESSIONS === 0) await new Promise((resolve) => {
3700
+ setImmediate(resolve);
3701
+ });
3696
3702
  const id = String(meta.id);
3697
3703
  seen.add(id);
3698
3704
  const stamp = await stampOf(meta);
@@ -3798,8 +3804,13 @@ function createUsageAggregator(persistence, options = {}) {
3798
3804
  stamp,
3799
3805
  format
3800
3806
  });
3807
+ if (options.resolveSessionTitle !== void 0) {
3808
+ const hint = options.resolveSessionTitle(meta);
3809
+ if (hint !== void 0) titleHints.set(id, hint);
3810
+ }
3801
3811
  console.warn("[usage-billing] skip unreadable session", id, error);
3802
3812
  }
3813
+ if (included.has(id)) titleHints.delete(id);
3803
3814
  }
3804
3815
  for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
3805
3816
  for (const key of [...unreadable.keys()]) if (!seen.has(key)) unreadable.delete(key);
@@ -3813,12 +3824,17 @@ function createUsageAggregator(persistence, options = {}) {
3813
3824
  if (included.has(entry.id)) continue;
3814
3825
  try {
3815
3826
  const stale = (entry.foldVersion ?? 1) < 23 || entry.fingerprint !== foldFingerprint;
3827
+ const fold = deserializedFoldOf(entry.fold);
3828
+ const hint = titleHints.get(entry.id);
3816
3829
  folds.push({
3817
3830
  id: entry.id,
3818
3831
  ...entry.cwd === void 0 ? {} : { cwd: entry.cwd },
3819
3832
  ...entry.subOf === void 0 ? {} : { subOf: entry.subOf },
3820
3833
  ...stale ? { staleLedger: true } : {},
3821
- fold: deserializedFoldOf(entry.fold)
3834
+ fold: hint === void 0 ? fold : {
3835
+ ...fold,
3836
+ title: hint
3837
+ }
3822
3838
  });
3823
3839
  included.add(entry.id);
3824
3840
  if (stale) staleLedgerSessions += 1;
@@ -4055,6 +4071,9 @@ function createUsageAggregator(persistence, options = {}) {
4055
4071
  async flush() {
4056
4072
  if (inflight !== void 0) await inflight.catch(() => {});
4057
4073
  await saveLedger(true);
4074
+ },
4075
+ lastDocOf() {
4076
+ return lastDoc;
4058
4077
  }
4059
4078
  };
4060
4079
  }
@@ -7296,8 +7315,9 @@ function registerPricingRoutes(ctx, deps) {
7296
7315
  * TokenHub 等)+ 用户自定义余额 + 声明端点 + 余额差对账。5 分钟缓存(避免
7297
7316
  * 每 30s 后台轮询打上游),in-flight 去重(并发请求共享同一次进行中的刷新)。
7298
7317
  *
7299
- * 路由文件只关心 HTTP 缓存逻辑:5 分钟过期判定 + 失败时仍发旧缓存(不挂起
7300
- * 已 writeHead 的响应)。上游查询(queryBalances / queryCustomBalances /
7318
+ * 路由文件只关心 HTTP 缓存逻辑:5 分钟过期判定 + 刷新预算(超预算回旧
7319
+ * 缓存并标 stale,不把响应挂在长查询上)+ 失败时仍发旧缓存(不挂起已
7320
+ * writeHead 的响应)。上游查询(queryBalances / queryCustomBalances /
7301
7321
  * queryDeclaredEndpoints / reconcileBalanceDelta)在装配器里跑,路由只通过
7302
7322
  * `cached()` 与 `refreshOnce()` 拿到结果。
7303
7323
  */
@@ -7312,12 +7332,21 @@ function registerBalanceRoute(ctx, deps) {
7312
7332
  res.end(JSON.stringify(deps.cached().doc));
7313
7333
  return;
7314
7334
  }
7315
- try {
7316
- await deps.refreshOnce();
7317
- } catch (error) {
7335
+ let timedOut = false;
7336
+ const onFailure = (error) => {
7318
7337
  console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
7319
- }
7320
- res.end(JSON.stringify(deps.cached().doc));
7338
+ };
7339
+ await Promise.race([deps.refreshOnce().then(() => void 0, onFailure), new Promise((resolve) => {
7340
+ setTimeout(() => {
7341
+ timedOut = true;
7342
+ resolve();
7343
+ }, deps.responseBudgetMs).unref();
7344
+ })]);
7345
+ const doc = deps.cached().doc;
7346
+ res.end(JSON.stringify(timedOut ? {
7347
+ ...doc,
7348
+ stale: true
7349
+ } : doc));
7321
7350
  }
7322
7351
  }), "usage-billing: balance route");
7323
7352
  }
@@ -7422,12 +7451,19 @@ function registerSubscriptionsRoute(ctx, deps) {
7422
7451
  handler: async (req, res) => {
7423
7452
  if (!guardLoopback(req, res, deps.trustedHosts)) return;
7424
7453
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7425
- if (Date.now() - deps.cached().at >= deps.cacheMs) try {
7426
- await deps.refreshOnce();
7427
- } catch (error) {
7454
+ let timedOut = false;
7455
+ if (Date.now() - deps.cached().at >= deps.cacheMs) await Promise.race([deps.refreshOnce().then(() => void 0, (error) => {
7428
7456
  console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
7429
- }
7430
- res.end(JSON.stringify({ quotas: deps.cached().quotas }));
7457
+ }), new Promise((resolve) => {
7458
+ setTimeout(() => {
7459
+ timedOut = true;
7460
+ resolve();
7461
+ }, deps.responseBudgetMs).unref();
7462
+ })]);
7463
+ res.end(JSON.stringify({
7464
+ quotas: deps.cached().quotas,
7465
+ ...timedOut ? { stale: true } : {}
7466
+ }));
7431
7467
  }
7432
7468
  }), "usage-billing: subscriptions route");
7433
7469
  }
@@ -7450,11 +7486,15 @@ function registerRelayQuotasRoute(ctx, deps) {
7450
7486
  handler: async (req, res) => {
7451
7487
  if (!guardLoopback(req, res, deps.trustedHosts)) return;
7452
7488
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7453
- if (Date.now() - deps.cached().at >= deps.cacheMs) try {
7454
- await deps.refreshOnce();
7455
- } catch (error) {
7489
+ let timedOut = false;
7490
+ if (Date.now() - deps.cached().at >= deps.cacheMs) await Promise.race([deps.refreshOnce().then(() => void 0, (error) => {
7456
7491
  console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
7457
- }
7492
+ }), new Promise((resolve) => {
7493
+ setTimeout(() => {
7494
+ timedOut = true;
7495
+ resolve();
7496
+ }, deps.responseBudgetMs).unref();
7497
+ })]);
7458
7498
  const diagnostics = deps.cached().quotas.map((row) => ({
7459
7499
  route: row.route,
7460
7500
  origin: row.origin,
@@ -7462,7 +7502,8 @@ function registerRelayQuotasRoute(ctx, deps) {
7462
7502
  }));
7463
7503
  res.end(JSON.stringify({
7464
7504
  quotas: deps.cached().quotas,
7465
- diagnostics
7505
+ diagnostics,
7506
+ ...timedOut ? { stale: true } : {}
7466
7507
  }));
7467
7508
  }
7468
7509
  }), "usage-billing: relay-quotas route");
@@ -7617,10 +7658,21 @@ function registerUsageStatsRoute(ctx, deps) {
7617
7658
  res.end(statsBodyOf(fresh));
7618
7659
  return;
7619
7660
  }
7661
+ const recent = deps.lastDoc?.();
7662
+ if (recent !== void 0) {
7663
+ res.end(JSON.stringify({
7664
+ ...decorate(recent),
7665
+ staleSnapshot: true
7666
+ }));
7667
+ return;
7668
+ }
7620
7669
  for (const candidate of deps.candidates) {
7621
7670
  const doc = await deps.readSnapshot(candidate);
7622
7671
  if (doc === null) continue;
7623
- res.end(JSON.stringify(decorate(doc)));
7672
+ res.end(JSON.stringify({
7673
+ ...decorate(doc),
7674
+ staleSnapshot: true
7675
+ }));
7624
7676
  return;
7625
7677
  }
7626
7678
  res.end(JSON.stringify({ error: "usage stats unavailable" }));
@@ -7693,6 +7745,24 @@ const WARMUP_DELAY_MS = 3e3;
7693
7745
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
7694
7746
  const SUBSCRIPTION_CACHE_MS = 3e5;
7695
7747
  const BALANCE_CACHE_MS = 3e5;
7748
+ /**
7749
+ * 余额路由的总等待预算(毫秒):超预算立刻回上一次成功的余额(stale 标记),
7750
+ * 刷新在后台继续。取 8s——与上游单次查询超时(FETCH_TIMEOUT_MS)对齐:上游
7751
+ * 真的不可达时多等没有意义,先把响应还给浏览器,别占着同源连接(issue #104)。
7752
+ */
7753
+ const BALANCE_RESPONSE_BUDGET_MS = 8e3;
7754
+ /**
7755
+ * 订阅 / 中转站额度路由的总等待预算(毫秒):与余额路由同口径(issue #104)。
7756
+ * 上游额度接口不可达时(8s 超时 × 重试 ≈ 10~16s),响应被长期挂住会占满浏览器
7757
+ * 的同源连接池——多标签页各挂一条即让宿主自己的资源请求排队,界面整体卡死。
7758
+ */
7759
+ const QUOTA_RESPONSE_BUDGET_MS = 8e3;
7760
+ /**
7761
+ * 对账基线的最大可用年龄(毫秒):超过则「今日官方费用」偏小,用它对账会
7762
+ * 报假漂移——此时跳过本轮对账,等后台补算完成后再恢复(issue #104)。
7763
+ * 取 5 分钟:与 usage-stats 的 30s 轮询 + 5 分钟快照节流同一量级。
7764
+ */
7765
+ const RECONCILE_BASIS_MAX_AGE_MS = 3e5;
7696
7766
  /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
7697
7767
  const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
7698
7768
  /**
@@ -7932,6 +8002,48 @@ function buildWorkspaceTitleResolver(ctx) {
7932
8002
  };
7933
8003
  }
7934
8004
  /**
8005
+ * 构造「会话头 → 宿主侧会话标题」解析器(issue #103,可选依赖)。
8006
+ *
8007
+ * 插件的会话标题取自日志里的 `session/title` 事件——**日志读不动**时(宿主拒绝
8008
+ * 该格式/代际,插件折叠失败),只能吃账本旧行,标题会永远停在重命名前的值。
8009
+ * 宿主自己的会话投影缓存仍可能记得这个标题(`cachedSnapshot` / 既有能力
8010
+ * `cachedPredecessorTitle`:格式代际边缘的标题仍能跨边缘存活),用它兜底可让
8011
+ * 账单明细的标题跟随工作区。
8012
+ *
8013
+ * 宿主没有该服务(0.1.x 代际)或读取失败都返回 undefined:绝不抛错,行为与
8014
+ * 现状一致(可选依赖,不影响主流程)。
8015
+ * @param ctx - host context carrying the optional session projection cache.
8016
+ * @returns 标题解析函数;服务不可用时 undefined。
8017
+ */
8018
+ function buildSessionTitleResolver(ctx) {
8019
+ let cache;
8020
+ try {
8021
+ cache = ctx.get("sessionProjectionCache");
8022
+ } catch {
8023
+ return;
8024
+ }
8025
+ if (cache === void 0) return void 0;
8026
+ const titleOf = (values) => {
8027
+ const title = values?.title;
8028
+ return typeof title === "string" && title !== "" ? title : void 0;
8029
+ };
8030
+ const cachedSnapshot = cache.cachedSnapshot;
8031
+ const cachedPredecessorTitle = cache.cachedPredecessorTitle;
8032
+ if (typeof cachedSnapshot !== "function" && typeof cachedPredecessorTitle !== "function") return void 0;
8033
+ return (header) => {
8034
+ try {
8035
+ if (typeof cachedSnapshot === "function") {
8036
+ const title = titleOf(cachedSnapshot(header, ["title"])?.values);
8037
+ if (title !== void 0) return title;
8038
+ }
8039
+ if (typeof cachedPredecessorTitle === "function") return titleOf(cachedPredecessorTitle(header)?.values);
8040
+ return;
8041
+ } catch {
8042
+ return;
8043
+ }
8044
+ };
8045
+ }
8046
+ /**
7935
8047
  * 凭据 seam 的宽容解析:ref 名 → 非空值;失败/缺失返回 undefined(多 surface 共用,
7936
8048
  * 失败按结构化静默记录)。
7937
8049
  */
@@ -8086,6 +8198,7 @@ function apply(ctx, config = {}) {
8086
8198
  }).catch(() => {});
8087
8199
  };
8088
8200
  const workspaceTitleResolver = buildWorkspaceTitleResolver(ctx);
8201
+ const sessionTitleResolver = buildSessionTitleResolver(ctx);
8089
8202
  applyBuiltinCatalog(BUILTIN_MODEL_CATALOG, BUILTIN_MODEL_KEY_ALIASES);
8090
8203
  applyUserModelAliases(config.modelKeyAliases);
8091
8204
  let llmRegistryWarned = false;
@@ -8119,6 +8232,7 @@ function apply(ctx, config = {}) {
8119
8232
  resolveRegisteredProviders: registeredProvidersOf,
8120
8233
  ...config.modelKeyAliases === void 0 ? {} : { modelKeyAliases: config.modelKeyAliases },
8121
8234
  ...workspaceTitleResolver === void 0 ? {} : { resolveWorkspaceTitle: workspaceTitleResolver },
8235
+ ...sessionTitleResolver === void 0 ? {} : { resolveSessionTitle: sessionTitleResolver },
8122
8236
  ...config.searchCallEstimateCny === void 0 ? {} : { searchCallEstimateCny: config.searchCallEstimateCny },
8123
8237
  ledger: ledgerStore
8124
8238
  });
@@ -8414,21 +8528,22 @@ function apply(ctx, config = {}) {
8414
8528
  if (official !== void 0 && reconcilable) {
8415
8529
  const now = Date.now();
8416
8530
  const today = dayStamp(now);
8417
- let todayOfficialCost = 0;
8418
- try {
8419
- todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
8420
- } catch {
8421
- swallowed("reconcile-aggregate-skipped");
8531
+ const recent = aggregator.lastDocOf();
8532
+ if (recent !== void 0 && now - recent.updatedAt <= RECONCILE_BASIS_MAX_AGE_MS) {
8533
+ const todayOfficialCost = recent?.byDay[today]?.officialCost ?? 0;
8534
+ const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
8535
+ const refChanged = result.ref !== null && result.ref !== reconcileRef;
8536
+ reconcileRef = result.ref;
8537
+ if (result.event !== null && result.event.kind !== "flat") reconcile = {
8538
+ ...result.event,
8539
+ provider: official.displayName,
8540
+ ...official.currency === void 0 ? {} : { currency: official.currency }
8541
+ };
8542
+ if (refChanged) persistReconcileRef();
8543
+ } else {
8544
+ aggregator.aggregate().catch(() => {});
8545
+ swallowed("reconcile-basis-stale");
8422
8546
  }
8423
- const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
8424
- const refChanged = result.ref !== null && result.ref !== reconcileRef;
8425
- reconcileRef = result.ref;
8426
- if (result.event !== null && result.event.kind !== "flat") reconcile = {
8427
- ...result.event,
8428
- provider: official.displayName,
8429
- ...official.currency === void 0 ? {} : { currency: official.currency }
8430
- };
8431
- if (refChanged) persistReconcileRef();
8432
8547
  }
8433
8548
  const doc = {
8434
8549
  balances: [
@@ -8453,7 +8568,8 @@ function apply(ctx, config = {}) {
8453
8568
  trustedHosts,
8454
8569
  cached: () => balanceCache,
8455
8570
  refreshOnce: refreshBalanceOnce,
8456
- cacheMs: BALANCE_CACHE_MS
8571
+ cacheMs: BALANCE_CACHE_MS,
8572
+ responseBudgetMs: BALANCE_RESPONSE_BUDGET_MS
8457
8573
  });
8458
8574
  registerUsageToolRoute(ctx, {
8459
8575
  trustedHosts,
@@ -8513,7 +8629,8 @@ function apply(ctx, config = {}) {
8513
8629
  trustedHosts,
8514
8630
  cached: () => quotaCache,
8515
8631
  refreshOnce: refreshQuotasOnce,
8516
- cacheMs: SUBSCRIPTION_CACHE_MS
8632
+ cacheMs: SUBSCRIPTION_CACHE_MS,
8633
+ responseBudgetMs: QUOTA_RESPONSE_BUDGET_MS
8517
8634
  });
8518
8635
  let relayCache = {
8519
8636
  at: 0,
@@ -8552,7 +8669,8 @@ function apply(ctx, config = {}) {
8552
8669
  trustedHosts,
8553
8670
  cached: () => relayCache,
8554
8671
  refreshOnce: refreshRelayOnce,
8555
- cacheMs: SUBSCRIPTION_CACHE_MS
8672
+ cacheMs: SUBSCRIPTION_CACHE_MS,
8673
+ responseBudgetMs: QUOTA_RESPONSE_BUDGET_MS
8556
8674
  });
8557
8675
  registerUsageStatsRoute(ctx, {
8558
8676
  trustedHosts,
@@ -8562,7 +8680,8 @@ function apply(ctx, config = {}) {
8562
8680
  persistSnapshot,
8563
8681
  readSnapshot,
8564
8682
  packageVersion: PACKAGE_VERSION,
8565
- responseBudgetMs: RESPONSE_BUDGET_MS
8683
+ responseBudgetMs: RESPONSE_BUDGET_MS,
8684
+ lastDoc: () => aggregator.lastDocOf()
8566
8685
  });
8567
8686
  }
8568
8687
  //#endregion
@@ -164,6 +164,15 @@ export interface AggregateOptions {
164
164
  /** 工作区标题解析:给定会话 cwd 返回项目显示标题;undefined = 回退到 cwd 末级目录名
165
165
  * (host 的 workspaceRegistry 为可选依赖,缺失时不注入,行为保持不变)。 */
166
166
  resolveWorkspaceTitle?: (cwd: string) => string | undefined;
167
+ /**
168
+ * 会话标题兜底解析:给定会话头(宿主持久层列表元数据)返回宿主侧的会话标题。
169
+ *
170
+ * 只在**日志读不动**的会话上用(宿主拒绝该格式/代际,插件折叠失败):此时插件
171
+ * 只能吃账本里的旧行,标题会永远停在重命名前的值——宿主自己的投影缓存仍可能
172
+ * 记得这个标题(格式代际边缘的既定能力),用它兜底可让标题跟随工作区
173
+ * (issue #103)。读得动的会话一律以日志为准,本回调不参与。
174
+ */
175
+ resolveSessionTitle?: (header: SessionHeader) => string | undefined;
167
176
  /** 独立的持久用量账本。启用后,已经成功折叠过的会话即使随后从
168
177
  * sessionPersistence 中永久删除,也会继续计入累计用量。 */
169
178
  ledger?: UsageLedgerStore;
@@ -735,6 +744,16 @@ export interface UsageAggregator {
735
744
  * @returns 会话累计;日志与账本均无该会话时 undefined(调用方自行兜底)。
736
745
  */
737
746
  sessionUsageOf(sessionId: string): Promise<SessionUsageSummary | undefined>;
747
+ /**
748
+ * 最近一次**已完成**的聚合文档(不触发新的折叠)。
749
+ *
750
+ * 供路由的 stale-while-revalidate 兜底用:当本轮折叠超过响应预算(宿主单进程
751
+ * 事件循环被重活折叠占住)时,落盘快照最多落后一个节流窗口(分钟级),而这里
752
+ * 的内存文档只落后一个聚合周期——两者都是「上次算好的」,但内存那份更新。
753
+ * 从未完成过一次聚合时返回 undefined(调用方回落到磁盘快照)。
754
+ * @returns 最近一次完成的聚合文档;尚无完成聚合时 undefined。
755
+ */
756
+ lastDocOf(): UsageStatsDocument | undefined;
738
757
  /** 立刻落盘未保存的账本改动(无视节流),供插件卸载时调用;
739
758
  * 进行中的聚合先等完再存,折叠失败仍保存已成功部分。 */
740
759
  flush(): Promise<void>;
@@ -5,16 +5,20 @@
5
5
  import type { ProviderBalance, ReconcileNotice } from '../../pricing-shared.ts';
6
6
  /** Path to the account-balance endpoint served by this plugin's node half. */
7
7
  export declare const BALANCE_PATH = "/api/billing/balance";
8
+ /** 余额文档(服务端响应体的白名单切片)。 */
9
+ export interface BalanceDocSlice {
10
+ balances: readonly ProviderBalance[];
11
+ reconcile?: ReconcileNotice;
12
+ /** 服务端刷新超预算:本次余额来自上一次成功的查询(issue #104)。 */
13
+ stale?: boolean;
14
+ }
8
15
  /**
9
16
  * 一次拉取 `/api/billing/balance` 的完整响应(余额行 + 对账提示)。余额与对账
10
17
  * 提示来自同一响应体,拆成两个函数会导致每 30 秒对同一端点发两次请求,故合并
11
18
  * 为单次 fetch;失败返回空值(余额 []、对账 undefined),由调用方降级。
12
19
  * @returns the balances and reconcile notice (both degraded on any failure).
13
20
  */
14
- export declare function fetchBalanceDoc(): Promise<{
15
- balances: readonly ProviderBalance[];
16
- reconcile?: ReconcileNotice;
17
- }>;
21
+ export declare function fetchBalanceDoc(): Promise<BalanceDocSlice>;
18
22
  /**
19
23
  * 拉取官方余额差对账提示(drift 时非空),供余额面板展示;失败返回 undefined。
20
24
  * 复用 {@link fetchBalanceDoc} 的同一响应,导出供对账提示渲染测试单独解析。
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 插件自有端点(/api/billing/*)的统一取数封装:超时底座。
3
+ *
4
+ * 背景(issue #104):浏览器对同源最多 6 条并发连接,而旧实现里所有 fetch
5
+ * 都没有超时——服务端某次响应被上游查询拖住(报告者实测 45~60s)时,这条
6
+ * 连接会被长期占住;多标签页各挂一条即占满连接池,宿主自己的资源请求排队,
7
+ * 用户看到的是「整个 DSH 界面卡死」。
8
+ *
9
+ * 超时上限必须小于轮询间隔(30s),否则「超时中的请求」会与下一轮轮询重叠,
10
+ * 在途请求越堆越多。
11
+ */
12
+ /**
13
+ * 客户端取插件端点的默认超时(毫秒):小于 30s 轮询间隔,保证不与下一轮重叠。
14
+ *
15
+ * 取 12s:服务端三条轮询路由的总预算都是 8s(余额 / 订阅额度 / 中转站额度),
16
+ * usage-stats 是 1.5s——12s 给服务端留了 4s 余量,又远小于 30s 的轮询间隔,
17
+ * 超时的请求不会与下一轮重叠(issue #104 报告者建议值同为 12s)。
18
+ */
19
+ export declare const BILLING_FETCH_TIMEOUT_MS = 12000;
20
+ /** 取数选项:在标准 RequestInit 之上加一个超时覆盖。 */
21
+ export interface BillingFetchInit extends RequestInit {
22
+ /** 超时(毫秒);缺省 {@link BILLING_FETCH_TIMEOUT_MS}。 */
23
+ timeoutMs?: number;
24
+ }
25
+ /**
26
+ * 带超时的 fetch(插件端点专用)。
27
+ *
28
+ * 超时以 AbortController 实现(不用 AbortSignal.timeout):宿主 UI 跑在
29
+ * 浏览器里,但插件的客户端代码也会被测试与宿主内嵌环境加载,显式
30
+ * AbortController 在任何实现上都可用。
31
+ * @param path - 端点路径(相对当前源)。
32
+ * @param init - 请求选项;`timeoutMs` 覆盖默认超时,`signal` 透传外部取消。
33
+ * @returns the response; rejects when the request times out or fails.
34
+ */
35
+ export declare function fetchBilling(path: string, init?: BillingFetchInit): Promise<Response>;
36
+ //# sourceMappingURL=http.d.ts.map
@@ -5,9 +5,15 @@
5
5
  import type { SubscriptionQuota } from '../../pricing-shared.ts';
6
6
  /** Path to the subscription-plan quota endpoint served by this plugin's node half. */
7
7
  export declare const SUBSCRIPTIONS_PATH = "/api/billing/subscriptions";
8
+ /** 订阅额度响应(白名单切片)。 */
9
+ export interface SubscriptionsDoc {
10
+ quotas: readonly SubscriptionQuota[];
11
+ /** 服务端刷新超预算:额度来自上一次成功的查询(issue #104)。 */
12
+ stale?: boolean;
13
+ }
8
14
  /**
9
15
  * 拉取订阅套餐剩余额度(供订阅面板);失败返回空列表。
10
16
  * @returns the quota rows, or an empty list on any failure.
11
17
  */
12
- export declare function fetchSubscriptions(): Promise<readonly SubscriptionQuota[]>;
18
+ export declare function fetchSubscriptions(): Promise<SubscriptionsDoc>;
13
19
  //# sourceMappingURL=subscriptions.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 上下文占用快照 store:把宿主的 token-meter 投影从**会话作用域**搬到全局弹窗。
3
+ *
4
+ * 宿主能力(@deepseek-ai/dsh-token-meter,经会话作用域标准 props 的 useProjection 读):
5
+ * - `contextPressure`:{ pressureTokens?, projectedTokens?, contextWindow? }
6
+ * —— provider 上报的最近一次提示词占用、下一次请求的占用外推、路由容量上限。
7
+ * - `contextBreakdown`:{ systemTokens, toolsTokens, messageTokens }
8
+ * —— 下一次请求上下文的构成拆解(**启发式 token 估算**,三项之和不等于
9
+ * provider 锚定的 projectedTokens:估算器系统性低估 CJK 与 JSON schema)。
10
+ *
11
+ * 为什么需要这个 store:`contextPressure` / `contextBreakdown` 只在**会话作用域**
12
+ * 的槽位里可读(投影按会话分发),而仪表盘弹窗是全局的——侧边栏触发的弹窗不在
13
+ * 任何会话作用域内。解法:会话作用域里已有的即时代费用条(conversation.composer.dock
14
+ * 座位)顺手读一次投影并发布到本 store,弹窗订阅它。
15
+ *
16
+ * 降级:没有会话 / 宿主不提供该投影 / 老宿主没有 token-meter → store 保持空,
17
+ * 界面整块隐藏(**不显示 0、不显示占位假数据**)。
18
+ */
19
+ /** 上下文占用快照(两个投影的合并视图)。 */
20
+ export interface ContextOccupancy {
21
+ /** 下一次请求的提示词占用(provider 锚定 + 增量外推)——面板主指标。 */
22
+ projectedTokens?: number;
23
+ /** 最近一次请求的提示词占用(provider 上报值)。 */
24
+ pressureTokens?: number;
25
+ /** 路由容量(上下文窗口上限);适配器未上报时缺席。 */
26
+ contextWindow?: number;
27
+ /** 系统提示词占用(启发式估算)。 */
28
+ systemTokens?: number;
29
+ /** 工具 schema 占用(启发式估算)。 */
30
+ toolsTokens?: number;
31
+ /** 消息占用(启发式估算)。 */
32
+ messageTokens?: number;
33
+ }
34
+ /** 空快照:无数据(界面据此整块隐藏)。 */
35
+ export declare const EMPTY_CONTEXT: ContextOccupancy;
36
+ /** 上下文 store:写入方 = 会话作用域的费用条;读取方 = 全局仪表盘弹窗。 */
37
+ export interface ContextStore {
38
+ /** 发布新快照(内容未变时保持同一对象引用,避免无谓重渲染)。 */
39
+ publish(next: ContextOccupancy): void;
40
+ /** 订阅变更;立即收到当前值一次(若为空则收到 undefined)。 */
41
+ subscribe(listener: (value: ContextOccupancy | undefined) => void): () => void;
42
+ /** 读取当前快照(无数据返回 undefined)。 */
43
+ read(): ContextOccupancy | undefined;
44
+ }
45
+ /**
46
+ * 创建上下文 store。
47
+ * @returns store 实例。
48
+ */
49
+ export declare function createContextStore(): ContextStore;
50
+ /**
51
+ * 占用率(0..1):以外推占用除以窗口容量。
52
+ * 缺任一要素 → undefined(界面不画进度,不以 0 冒充)。
53
+ * @param occupancy - 占用快照。
54
+ * @returns 占用率或 undefined。
55
+ */
56
+ export declare function occupancyRatio(occupancy: ContextOccupancy | undefined): number | undefined;
57
+ /**
58
+ * 构成拆解的三项(顺序固定,便于渲染与比较)。
59
+ * @param occupancy - 占用快照。
60
+ * @returns 构成项数组(无任何构成数据时返回空数组)。
61
+ */
62
+ export declare function occupancyBreakdown(occupancy: ContextOccupancy | undefined): readonly {
63
+ key: 'system' | 'tools' | 'message';
64
+ tokens: number;
65
+ }[];
66
+ /** 页面级单例:同一页面只有一个计费插件实例,与其它模块级单例同口径。 */
67
+ export declare const contextStore: ContextStore;
68
+ //# sourceMappingURL=context-store.d.ts.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 智能体网络树:从会话行的 `parentId` 还原委派关系,并逐级算出「链合计」。
3
+ *
4
+ * 背景:宿主把子智能体落成**独立会话**,靠 `parentId` 指向委派它的对话。本仓的
5
+ * 账单页(明细 Tab)已经能把子智能体归并到父会话,但那是「账单视角」;本模块给出
6
+ * 「网络视角」——谁委派了谁、每条链总共花了多少,用来回答**「哪次委派最贵」**。
7
+ *
8
+ * 与展示层的 `buildSessionDisplay` 的分工:那一支做的是**同标题会话合并 + 展示树
9
+ * 挂靠**(账单表要按人可读的方式分组);本模块只做**委派关系 + 链合计**,不合并
10
+ * 同标题会话——网络视角里两个同名会话是两个独立智能体。
11
+ *
12
+ * 环防护:异常数据可能出现 `a.parentId=b && b.parentId=a`(互指父子),直接递归
13
+ * 会造出渲染死循环。建树时按「父链回溯」判环:命中环的节点按根处理(不挂到任何
14
+ * 父节点下),其余结构照常。
15
+ */
16
+ import type { SessionBillingRow } from './types.ts';
17
+ /** 网络树的一个节点(一个智能体 / 一个会话)。 */
18
+ export interface AgentNode {
19
+ id: string;
20
+ title?: string;
21
+ cwd?: string;
22
+ /** 自身调用数(不含后代)。 */
23
+ calls: number;
24
+ /** 自身费用(不含后代)。 */
25
+ cost: number;
26
+ /** 最后活跃时刻(毫秒)。 */
27
+ lastActive: number;
28
+ /** 自身 + 全部后代的费用合计。 */
29
+ chainCost: number;
30
+ /** 自身 + 全部后代的调用合计。 */
31
+ chainCalls: number;
32
+ /** 直接子智能体(按链合计倒序)。 */
33
+ children: AgentNode[];
34
+ /** 深度(根 = 0)。 */
35
+ depth: number;
36
+ }
37
+ /** 网络树汇总。 */
38
+ export interface AgentTreeStats {
39
+ /** 参与网络的智能体总数(含根)。 */
40
+ agents: number;
41
+ /** 委派边数(父 → 子 的条数)。 */
42
+ delegations: number;
43
+ /** 根(对话)数量。 */
44
+ roots: number;
45
+ /** 链合计最高的一支(根或子节点皆可)。 */
46
+ costliest?: AgentNode;
47
+ }
48
+ /**
49
+ * 建树:还原委派关系并算链合计。
50
+ * @param rows - 会话行(含 parentId 的原始行,不是展示层合并后的行)。
51
+ * @returns 根节点数组(按链合计倒序)。
52
+ */
53
+ export declare function buildAgentTree(rows: readonly SessionBillingRow[]): AgentNode[];
54
+ /**
55
+ * 网络树汇总统计。
56
+ * @param roots - 根节点数组。
57
+ * @returns 汇总(智能体数 / 委派数 / 对话数 / 最贵的一支)。
58
+ */
59
+ export declare function agentTreeStats(roots: readonly AgentNode[]): AgentTreeStats;
60
+ //# sourceMappingURL=agent-tree.d.ts.map
@@ -27,7 +27,7 @@ export interface ModelHealth {
27
27
  /** Idle health state before the probe settles. */
28
28
  export declare const IDLE_HEALTH: ModelHealth;
29
29
  /** 仪表盘分区 Tab id。 */
30
- export type DashboardTab = 'overview' | 'token' | 'trends' | 'providers' | 'pricing' | 'settings';
30
+ export type DashboardTab = 'overview' | 'context' | 'agents' | 'token' | 'trends' | 'providers' | 'pricing' | 'settings';
31
31
  import type { UsageBillingKey } from '../locales.ts';
32
32
  /**
33
33
  * Tab 定义(顺序即渲染顺序):概览=主数字/KPI/热力图,账单=厂商计费与订阅,
@@ -65,19 +65,7 @@ export interface KpiAgg {
65
65
  peakTokens: number;
66
66
  }
67
67
  /** v1.5 Tab 徽章数据信号:空字符串 / 数字 / 状态字。`kind` 决定色调。 */
68
- export type TabBadgeKind = 'warn' | 'hot' | 'violet' | 'neutral';
69
68
  /** v1.5 Tab 徽章。 */
70
- export interface TabBadge {
71
- label: string;
72
- kind: TabBadgeKind;
73
- }
74
- /** 各 Tab 数据信号聚合:调用方传 dashboardBadgeState(避免每个 Tab 重复声明) */
75
- export interface DashboardBadgeState {
76
- providersCount: number;
77
- tokenTotal: number;
78
- rateAgeMinutes: number | null;
79
- settingsDirty: number;
80
- }
81
69
  /** 会话明细行(与服务端 SessionUsageRow 同形;旧快照可能缺失整个 bySession)。 */
82
70
  export interface SessionBillingRow {
83
71
  id: string;
@@ -161,11 +149,22 @@ export interface UsageBillingInjected {
161
149
  monthCost: number;
162
150
  }) => void;
163
151
  registerOpen: (handler: () => void) => () => void;
152
+ /**
153
+ * 跳转到宿主会话(跨插件导航):面板点会话行时调用。
154
+ * 返回 'opened' 已跳转 / 'unavailable' 宿主无通道(老到没有)/ 'failed' 通道报错。
155
+ */
156
+ navigateToSession: (sessionId: string) => 'opened' | 'unavailable' | 'failed';
164
157
  }
165
158
  /** Usage stats structure from `.dsh-usage-stats.json`. */
166
159
  export interface UsageStats {
167
160
  /** 服务端聚合时间戳(毫秒);旧快照可能缺失。 */
168
161
  updatedAt?: number;
162
+ /**
163
+ * 本次下发的是「上次算好的」文档(聚合超响应预算或失败时的回落):
164
+ * 内存里最近一次完成的聚合,或落盘快照。面板据此提示数据滞后,
165
+ * 避免把旧值(如重命名前的会话标题)当成最新值读。
166
+ */
167
+ staleSnapshot?: boolean;
169
168
  /** 宿主进程时区(IANA 名 + UTC 偏移):天按此切分,副标题据此标注;旧快照可能缺失。 */
170
169
  timezone?: {
171
170
  name: string;