@linxin666/dsh-usage 0.3.23 → 0.4.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.
Files changed (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +7 -9
  3. package/README.zh.md +7 -9
  4. package/icon.svg +3 -0
  5. package/lib/client.js +621 -65
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +258 -271
  8. package/lib/types/client/UsageFootCard.d.ts +55 -0
  9. package/lib/types/client/UsageSectionCard.d.ts +3 -4
  10. package/lib/types/client/body-mutations.d.ts +45 -0
  11. package/lib/types/client/foot-card-mount.d.ts +34 -0
  12. package/lib/types/client/index.d.ts +21 -8
  13. package/lib/types/client/locales.d.ts +6 -4
  14. package/lib/types/core/provider-routes.d.ts +26 -0
  15. package/lib/types/core/types.d.ts +14 -1
  16. package/lib/types/host/access.d.ts +17 -0
  17. package/lib/types/host/http.d.ts +18 -1
  18. package/lib/types/host/pair-access.d.ts +34 -0
  19. package/lib/types/host/routes.d.ts +16 -7
  20. package/lib/types/host/usage-service.d.ts +32 -76
  21. package/lib/types/index.d.ts +14 -2
  22. package/package.json +20 -17
  23. package/src/client/UsageFootCard.tsx +300 -0
  24. package/src/client/UsageSectionCard.tsx +41 -23
  25. package/src/client/body-mutations.ts +133 -0
  26. package/src/client/foot-card-mount.tsx +112 -0
  27. package/src/client/index.ts +57 -16
  28. package/src/client/locales.ts +12 -8
  29. package/src/client/usage.module.css +188 -0
  30. package/src/core/adapters.ts +40 -6
  31. package/src/core/provider-routes.ts +37 -0
  32. package/src/core/types.ts +14 -1
  33. package/src/host/access.ts +21 -0
  34. package/src/host/http.ts +23 -1
  35. package/src/host/pair-access.ts +49 -0
  36. package/src/host/routes.ts +19 -10
  37. package/src/host/usage-service.ts +74 -218
  38. package/src/index.ts +59 -60
package/lib/index.js CHANGED
@@ -45,7 +45,7 @@ function mountOnce(packageName, fn) {
45
45
  });
46
46
  }
47
47
  //#endregion
48
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.1.5-rc.1_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-brand/lib/index.js
48
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.1.7-rc.1_@deepseek-ai+cordis@4.0.4/node_modules/@deepseek-ai/dsh-brand/lib/index.js
49
49
  /**
50
50
  * Duplicate-install-safe nominal primitive helpers.
51
51
  *
@@ -68,7 +68,7 @@ function brandString(value) {
68
68
  return value;
69
69
  }
70
70
  //#endregion
71
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.1.5-rc.1_@deepseek-ai+cordis@4.0.2_@deepseek-ai+dsh-inva_8353d2c97c08a345a5c3047263f2fe4a/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
71
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.1.7-rc.1_@deepseek-ai+cordis@4.0.4_@deepseek-ai+dsh-inva_9b80248df36b741e5eb76c73b242287a/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
72
72
  /**
73
73
  * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
74
74
  * *references* to secrets — environment-variable names — while providers own
@@ -149,6 +149,48 @@ function dshHome() {
149
149
  return resolveDshHome();
150
150
  }
151
151
  //#endregion
152
+ //#region src/host/http.ts
153
+ /** Family-default JSON response headers; callers may append or override. */
154
+ const JSON_HEADERS = {
155
+ "content-type": "application/json; charset=utf-8",
156
+ "referrer-policy": "no-referrer"
157
+ };
158
+ /**
159
+ * Request init that asks a remote origin for an uncompressed body.
160
+ *
161
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
162
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
163
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
164
+ * `content-encoding` header and automatic decompression, so a plain fetch
165
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
166
+ * consumer can read. Every host fetch that parses a remote body must request
167
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
168
+ * `identity` because a caller value has no decoder behind it in this host.
169
+ * @param init - the caller's request init (signal, headers, method, ...).
170
+ * @returns a copy with `accept-encoding: identity` merged in.
171
+ */
172
+ function withIdentityEncoding(init = {}) {
173
+ const headers = new Headers(init.headers);
174
+ headers.set("accept-encoding", "identity");
175
+ return {
176
+ ...init,
177
+ headers
178
+ };
179
+ }
180
+ /**
181
+ * Write one JSON response. Default headers are the family defaults
182
+ * (content-type and referrer-policy); caller headers are appended or
183
+ * override them.
184
+ */
185
+ function writeJson(res, status, body, headers = {}) {
186
+ const payload = JSON.stringify(body);
187
+ res.writeHead(status, {
188
+ ...JSON_HEADERS,
189
+ ...headers
190
+ });
191
+ res.end(payload);
192
+ }
193
+ //#endregion
152
194
  //#region src/core/adapters.ts
153
195
  /** Parse a string/number into a finite number, else undefined. */
154
196
  function toNum(value) {
@@ -162,6 +204,10 @@ function toNum(value) {
162
204
  function str(value) {
163
205
  return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
164
206
  }
207
+ /** Clamp a provider percentage into the renderable 0-100 band. */
208
+ function clampPercent(value) {
209
+ return Math.max(0, Math.min(100, value));
210
+ }
165
211
  /** Format a number to a fixed 2-decimal display string. */
166
212
  function money(value) {
167
213
  return value.toFixed(2);
@@ -294,7 +340,28 @@ const KIMI_CODING = {
294
340
  }
295
341
  }
296
342
  };
297
- /** GLM Coding Plan quota; auth is the RAW key without a Bearer prefix. */
343
+ /**
344
+ * GLM Coding Plan quota windows, keyed by the provider's own `unit` code
345
+ * rather than by row position so an added or reordered limit cannot shift a
346
+ * window's meaning: 3 = the rolling 5-hour window, 6 = the weekly window,
347
+ * 5 = the monthly one. Two row kinds carry quota — `TOKENS_LIMIT` (token
348
+ * plans) and `CREDIT_LIMIT` (credit plans, whose exact ratio is
349
+ * `currentValue` / `usage`) — while `TIME_LIMIT` is the MCP request cap and
350
+ * is not a plan window at all. Auth is the RAW key without a Bearer prefix.
351
+ */
352
+ const GLM_UNIT_KEYS = {
353
+ 3: "5h",
354
+ 5: "month",
355
+ 6: "week"
356
+ };
357
+ /** Percent from a credit row's exact ratio, else the provider's own percentage. */
358
+ function glmCreditPercent(row) {
359
+ const used = toNum(row.currentValue);
360
+ const limit = toNum(row.usage);
361
+ if (used !== void 0 && limit !== void 0 && limit > 0) return clampPercent(used / limit * 100);
362
+ const percentage = toNum(row.percentage);
363
+ return percentage === void 0 ? void 0 : clampPercent(percentage);
364
+ }
298
365
  function glmPlan(host, ids) {
299
366
  return {
300
367
  ids,
@@ -319,11 +386,17 @@ function glmPlan(host, ids) {
319
386
  for (const entry of limits) {
320
387
  if (typeof entry !== "object" || entry === null) continue;
321
388
  const row = entry;
322
- const unit = toNum(row.unit);
323
- const percent = toNum(row.percentage);
389
+ const kind = str(row.type);
390
+ if (kind === "TIME_LIMIT") continue;
391
+ const key = GLM_UNIT_KEYS[toNum(row.unit) ?? NaN];
392
+ if (key === void 0) continue;
393
+ if (kind !== "TOKENS_LIMIT" && kind !== "CREDIT_LIMIT") continue;
324
394
  windows.push({
325
- key: unit === 3 ? "5h" : unit === 6 ? "week" : unit !== void 0 ? `unit-${unit}` : "window",
326
- percent: percent === void 0 ? void 0 : Math.max(0, Math.min(100, percent)),
395
+ key,
396
+ percent: kind === "CREDIT_LIMIT" ? glmCreditPercent(row) : (() => {
397
+ const percentage = toNum(row.percentage);
398
+ return percentage === void 0 ? void 0 : clampPercent(percentage);
399
+ })(),
327
400
  resetsAt: toIso(row.nextResetTime)
328
401
  });
329
402
  }
@@ -537,7 +610,7 @@ const PROVIDER_ADAPTERS = [
537
610
  moonshotBalance("api.moonshot.ai", "USD", ["moonshotai"]),
538
611
  KIMI_CODING,
539
612
  glmPlan("open.bigmodel.cn", ["zai-coding-cn"]),
540
- glmPlan("api.z.ai", ["zai-coding"]),
613
+ glmPlan("api.z.ai", ["zai", "zai-coding"]),
541
614
  OPENCODE_GO,
542
615
  minimaxPlan("api.minimaxi.com", ["minimax-cn"]),
543
616
  minimaxPlan("api.minimax.io", ["minimax"]),
@@ -574,6 +647,34 @@ function providerErrorMessage(status, body) {
574
647
  return `HTTP ${status}${message === void 0 ? "" : `: ${message.slice(0, 120)}`}`;
575
648
  }
576
649
  //#endregion
650
+ //#region src/core/provider-routes.ts
651
+ /**
652
+ * Alias folding for the enumerated provider routes. Kept in core because the
653
+ * rule is pure — the host enumerates routes and this decides which of two
654
+ * routes serving one adapter family survives.
655
+ * @module @linxin666/dsh-usage/core/provider-routes
656
+ */
657
+ /**
658
+ * Drop dormant catalog aliases that shadow a live route of the same adapter
659
+ * family. The pi-ai catalog ships route keys the runtime serves nothing on
660
+ * until a profile declares them: for DeepSeek the catalog entry `deepseek`
661
+ * sits beside the llm-deepseek runtime route `deepseek-official`, and both
662
+ * resolve the same credential (the family's `apiKeyEnv` fallback), so probing
663
+ * and rendering both turns one account into two identical balance rows.
664
+ *
665
+ * A family keeps every live route it has — two configured profiles are two
666
+ * real accounts, not aliases. A route with no adapter, or one whose family
667
+ * has no live route at all, is returned untouched.
668
+ */
669
+ function foldAliasRoutes(routes) {
670
+ return routes.filter((route) => {
671
+ if (route.live) return true;
672
+ const family = adapterFor(route.id);
673
+ if (family === void 0) return true;
674
+ return !routes.some((other) => other.live && adapterFor(other.id) === family);
675
+ });
676
+ }
677
+ //#endregion
577
678
  //#region src/core/pricing.ts
578
679
  /** The published peak windows: weekday 09:00-12:00 and 14:00-18:00. */
579
680
  const PEAK_WINDOWS = [{
@@ -856,112 +957,15 @@ function deserializeLedger(value) {
856
957
  /**
857
958
  * The dsh-usage host service: folds live session usage into the persistent
858
959
  * ledger, probes each configured provider's balance/coding-plan endpoint on
859
- * a poll cycle, and announces the current provider's status to the pet
860
- * bubble. Secrets stay in the host process; the browser only ever sees the
861
- * overview document.
960
+ * a poll cycle, and serves the current provider's strip-ready view inside
961
+ * the overview document. Secrets stay in the host process; the browser only
962
+ * ever sees the overview document.
862
963
  * @module @linxin666/dsh-usage/host/usage-service
863
964
  */
864
- /** Source tag the plugin stamps onto pet announcements. */
865
- const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
866
965
  /** Probe timeout per HTTP call. */
867
966
  const PROBE_TIMEOUT_MS = 1e4;
868
967
  /** Ledger flush debounce. */
869
968
  const FLUSH_DEBOUNCE_MS = 3e3;
870
- /** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
871
- const CURRENCY_SYMBOLS = {
872
- CNY: "¥",
873
- USD: "$",
874
- EUR: "€",
875
- GBP: "£"
876
- };
877
- /** Format a balance for display: symbol prefix when known, code suffix otherwise. */
878
- function formatMoney(currency, totalBalance) {
879
- const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()];
880
- if (symbol !== void 0) return symbol + totalBalance;
881
- return `${totalBalance} ${currency.toUpperCase()}`;
882
- }
883
- /** Map a used percent to the announcement tone. */
884
- function planTone(percent) {
885
- if (percent >= 90) return "low";
886
- if (percent >= 70) return "warn";
887
- return "ok";
888
- }
889
- /**
890
- * Build the raw pet announce payload for one provider snapshot, or
891
- * undefined when nothing worth announcing exists. Pure: every payload this
892
- * returns satisfies the pet's `parseAnnouncement` contract — plan
893
- * announcements require a numeric percent, so percent-less windows never
894
- * announce (the pet validator would silently drop them). A priced family
895
- * (DeepSeek) with spend today announces a cost bubble first; balance-only
896
- * families announce the balance; plan families announce their tightest
897
- * percent window.
898
- */
899
- function buildAnnouncement(snapshot, context) {
900
- const todayCost = context?.todayCost ?? 0;
901
- if (todayCost > 0) {
902
- const peak = context?.peak ?? false;
903
- const noteParts = [peak ? "高峰时段 计价×2" : "空闲时段 计价减半", ...snapshot.balance !== void 0 ? [`余额 ${formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance)}`] : []];
904
- return {
905
- kind: "cost",
906
- title: snapshot.displayName,
907
- amount: `今日 ${formatMoney("CNY", todayCost.toFixed(2))}`,
908
- ...noteParts.length > 0 ? { note: noteParts.join(" · ") } : {},
909
- tone: peak ? "warn" : "ok"
910
- };
911
- }
912
- if (snapshot.balance !== void 0) return {
913
- kind: "balance",
914
- title: snapshot.displayName,
915
- amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
916
- tone: "ok"
917
- };
918
- if (snapshot.plan === void 0) return void 0;
919
- const window = snapshot.plan.windows.filter((entry) => typeof entry.percent === "number").sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0];
920
- if (window === void 0 || window.percent === void 0) return void 0;
921
- return {
922
- kind: "plan",
923
- title: snapshot.displayName,
924
- percent: window.percent,
925
- ...window.resetsAt !== void 0 ? { resetAt: window.resetsAt } : {},
926
- ...snapshot.plan.planName !== void 0 ? { note: snapshot.plan.planName } : {},
927
- tone: planTone(window.percent)
928
- };
929
- }
930
- /**
931
- * Compact token-count display for the usage fallback bubble: `9805`,
932
- * `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
933
- * line this service speaks), so the magnitudes follow the zh convention.
934
- */
935
- function formatTokens(count) {
936
- const compact = (value) => {
937
- const rounded = Math.round(value * 10) / 10;
938
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
939
- };
940
- if (count >= 1e8) return compact(count / 1e8) + "亿";
941
- if (count >= 1e4) return compact(count / 1e4) + "万";
942
- return String(count);
943
- }
944
- /**
945
- * The usage fallback for the session's provider: today's token consumption
946
- * and call count, the one fact the ledger owns for every provider. Providers
947
- * without a readable balance/plan endpoint (relay stations, local runtimes,
948
- * token-plan vendors — and probeable providers whose probes are failing)
949
- * would otherwise leave the pet bubble permanently silent even while their
950
- * sessions run. Undefined when the provider has no usage today: a bubble
951
- * about nothing is noise, not information. Pure; the payload satisfies the
952
- * pet's `parseAnnouncement` contract.
953
- */
954
- function buildLedgerAnnouncement(input) {
955
- const total = totalTokens(input.totals);
956
- if (input.totals.calls <= 0 || total <= 0) return void 0;
957
- return {
958
- kind: "cost",
959
- title: input.displayName,
960
- amount: `今日 ${formatTokens(total)} tokens`,
961
- note: `${input.totals.calls} 次调用`,
962
- tone: "ok"
963
- };
964
- }
965
969
  /** Best-effort typed service read: absent services resolve to undefined at runtime. */
966
970
  function service(ctx, name) {
967
971
  try {
@@ -1018,7 +1022,7 @@ var UsageService = class {
1018
1022
  spendWatch;
1019
1023
  /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
1020
1024
  sessionRoutes = /* @__PURE__ */ new WeakMap();
1021
- /** The most recent route seen this boot; the pet bubble follows it. */
1025
+ /** The most recent route seen this boot; the sidebar strip and the header highlight follow it. */
1022
1026
  current = { source: "default" };
1023
1027
  sessionListenerDisposer;
1024
1028
  pollTimer;
@@ -1031,7 +1035,6 @@ var UsageService = class {
1031
1035
  /** True once loadPersisted finished; nothing may overwrite the files before that. */
1032
1036
  loaded = false;
1033
1037
  disposed = false;
1034
- lastSignature;
1035
1038
  /** Last prune guard: once per local day, and whenever retention shrinks. */
1036
1039
  lastPrune;
1037
1040
  constructor(ctx, options) {
@@ -1049,8 +1052,9 @@ var UsageService = class {
1049
1052
  }
1050
1053
  /**
1051
1054
  * Stop timers and flush pending ledger writes. The returned promise
1052
- * resolves after the final flush lands, so a successor instance (quick
1053
- * disable → enable) can serialize its first load behind it.
1055
+ * resolves after the final flush lands, so a successor instance (the Host
1056
+ * reloads the profile row on a config change) can serialize its first load
1057
+ * behind it.
1054
1058
  */
1055
1059
  stop() {
1056
1060
  this.disposed = true;
@@ -1059,7 +1063,7 @@ var UsageService = class {
1059
1063
  this.sessionListenerDisposer?.();
1060
1064
  return this.flushLedger();
1061
1065
  }
1062
- /** Re-apply options live (settings change); retention shrink prunes now. */
1066
+ /** Re-apply options to this running instance; retention shrink prunes now. */
1063
1067
  applyOptions(options) {
1064
1068
  this.options = options;
1065
1069
  this.pruneIfNeeded();
@@ -1098,7 +1102,7 @@ var UsageService = class {
1098
1102
  return {
1099
1103
  updatedAt: Date.now(),
1100
1104
  providers,
1101
- current: { ...this.current },
1105
+ current: this.currentView(),
1102
1106
  usage: {
1103
1107
  today: this.daySummary(todayKey),
1104
1108
  days: days.map((date) => {
@@ -1126,6 +1130,33 @@ var UsageService = class {
1126
1130
  }
1127
1131
  };
1128
1132
  }
1133
+ /**
1134
+ * The strip-ready current-provider view: the resolved display name plus
1135
+ * today's ledger totals for the provider's adapter family (the route
1136
+ * itself when adapter-less). `today` is absent on a day without usage —
1137
+ * a strip about nothing is noise, not information.
1138
+ */
1139
+ currentView() {
1140
+ const provider = this.current.provider;
1141
+ if (provider === void 0) return { ...this.current };
1142
+ let snapshot = this.snapshots.get(provider);
1143
+ if (snapshot === void 0) {
1144
+ const family = adapterFor(provider);
1145
+ if (family !== void 0) {
1146
+ for (const [id, candidate] of this.snapshots) if (adapterFor(id) === family) {
1147
+ snapshot = candidate;
1148
+ break;
1149
+ }
1150
+ }
1151
+ }
1152
+ const displayName = snapshot?.displayName ?? this.routeDisplayName(provider);
1153
+ const today = this.familyUsageToday(provider);
1154
+ return {
1155
+ ...this.current,
1156
+ displayName,
1157
+ ...today.calls > 0 && totalTokens(today) > 0 ? { today } : {}
1158
+ };
1159
+ }
1129
1160
  /** One local day aggregated per provider. */
1130
1161
  daySummary(dateKey) {
1131
1162
  const { totals, providers } = summarizeDays([this.ledger.days[dateKey] ?? {}]);
@@ -1135,17 +1166,17 @@ var UsageService = class {
1135
1166
  providers
1136
1167
  };
1137
1168
  }
1138
- /** Today's ledger spend for one provider's adapter family (0 when unpriced). */
1139
- familyCostToday(provider) {
1169
+ /**
1170
+ * Today's ledger totals for one provider's adapter family — the exact
1171
+ * route when adapter-less, the merged family otherwise, so aliased routes
1172
+ * of one account (the deepseek catalog id and its runtime alias) fold
1173
+ * together.
1174
+ */
1175
+ familyUsageToday(provider) {
1140
1176
  const family = adapterFor(provider);
1141
- if (family === void 0) return 0;
1142
- let cost = 0;
1143
- for (const row of this.daySummary(localDateKey(Date.now())).providers) if (adapterFor(row.provider) === family) cost += row.totals.cost;
1144
- return cost;
1145
- }
1146
- /** Today's ledger totals for one provider route (the exact id, not the family). */
1147
- providerUsageToday(provider) {
1148
- return this.daySummary(localDateKey(Date.now())).providers.find((entry) => entry.provider === provider)?.totals ?? emptyTotals();
1177
+ let merged = emptyTotals();
1178
+ for (const row of this.daySummary(localDateKey(Date.now())).providers) if (family === void 0 ? row.provider === provider : adapterFor(row.provider) === family) merged = addTotals(merged, row.totals);
1179
+ return merged;
1149
1180
  }
1150
1181
  /**
1151
1182
  * Accrue the official DeepSeek family's real spend from observed CNY
@@ -1177,7 +1208,7 @@ var UsageService = class {
1177
1208
  /**
1178
1209
  * The display name for a route key: the LLM runtime's first, then the
1179
1210
  * adapter's, then the id itself — the snapshot may not exist for
1180
- * adapter-less routes, yet the bubble still needs a legible title.
1211
+ * adapter-less routes, yet the sidebar strip still needs a legible title.
1181
1212
  */
1182
1213
  routeDisplayName(provider) {
1183
1214
  return this.listProviderRoutes().find((entry) => entry.id === provider)?.displayName || adapterFor(provider)?.displayName || provider;
@@ -1326,7 +1357,7 @@ var UsageService = class {
1326
1357
  }, delayMs);
1327
1358
  }
1328
1359
  /**
1329
- * One poll cycle: enumerate routes, resolve credentials, probe, announce.
1360
+ * One poll cycle: enumerate routes, resolve credentials, probe.
1330
1361
  * A call while a cycle is already running joins that cycle instead of
1331
1362
  * returning immediately, so a manual refresh always waits for real probes.
1332
1363
  */
@@ -1350,7 +1381,6 @@ var UsageService = class {
1350
1381
  if (this.disposed) return;
1351
1382
  this.accrueDeepSeekSpend();
1352
1383
  await this.persistSnapshots();
1353
- this.announceCurrent();
1354
1384
  } finally {
1355
1385
  this.pollInFlight = false;
1356
1386
  this.pollPromise = void 0;
@@ -1359,17 +1389,28 @@ var UsageService = class {
1359
1389
  this.pollPromise = cycle;
1360
1390
  return cycle;
1361
1391
  }
1392
+ /**
1393
+ * Every route the service probes and renders: the runtime's live providers
1394
+ * plus the configurable directory's catalog entries, then alias-folded so a
1395
+ * dormant catalog entry cannot shadow the live route of the same adapter
1396
+ * family (see foldAliasRoutes).
1397
+ */
1362
1398
  listProviderRoutes() {
1363
1399
  const routes = /* @__PURE__ */ new Map();
1364
1400
  const runtime = service(this.ctx, "llm");
1365
1401
  if (runtime !== void 0) try {
1366
- for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, provider.name);
1367
- for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName);
1402
+ for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, {
1403
+ id: provider.id,
1404
+ displayName: provider.name,
1405
+ live: true
1406
+ });
1407
+ for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, {
1408
+ id: provider.provider,
1409
+ displayName: provider.displayName,
1410
+ live: false
1411
+ });
1368
1412
  } catch {}
1369
- return [...routes].map(([id, displayName]) => ({
1370
- id,
1371
- displayName
1372
- }));
1413
+ return foldAliasRoutes([...routes.values()]);
1373
1414
  }
1374
1415
  async probeRoute(route, adapter) {
1375
1416
  const credential = await this.resolveCredential(route.id);
@@ -1399,10 +1440,10 @@ var UsageService = class {
1399
1440
  apiKey: credential.key,
1400
1441
  ...credential.accountId !== void 0 ? { accountId: credential.accountId } : {}
1401
1442
  });
1402
- const response = await fetch(spec.url, {
1443
+ const response = await fetch(spec.url, withIdentityEncoding({
1403
1444
  headers: spec.headers,
1404
1445
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1405
- });
1446
+ }));
1406
1447
  const body = await response.json().catch(() => void 0);
1407
1448
  if (!response.ok) throw new Error(providerErrorMessage(response.status, body));
1408
1449
  const parsed = half.parse(response.status, body);
@@ -1486,66 +1527,6 @@ var UsageService = class {
1486
1527
  deepseekApiKeyEnv() {
1487
1528
  return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
1488
1529
  }
1489
- /**
1490
- * Announce the current provider's spend, balance, or plan usage to the pet.
1491
- * In `change` mode only meaningful value changes re-announce; `off` skips.
1492
- * The TTL rides the poll interval (bubble_mode `always` re-announces every
1493
- * cycle, so a TTL of two cycles + margin keeps the bubble continuous
1494
- * across polls; the pet contract caps the ceiling). A route id the
1495
- * catalogs spell differently than the snapshot keys falls back to its
1496
- * adapter family's snapshot. When the provider has no announceable probe
1497
- * fact (no adapter, failing probes, percent-less windows) the bubble falls
1498
- * back to the provider's today ledger usage, and with neither fact nor
1499
- * usage it stays silent. Fully guarded: a malformed snapshot or a failing
1500
- * pet service must never break the poll loop, and a disposed service never
1501
- * announces.
1502
- */
1503
- announceCurrent() {
1504
- if (this.disposed || this.options.bubbleMode === "off") return;
1505
- try {
1506
- let provider = this.current.provider;
1507
- if (provider === void 0) {
1508
- const fallback = readNamespace(this.ctx, "agent-default-model");
1509
- if (fallback?.provider !== void 0) {
1510
- provider = fallback.provider;
1511
- this.current = {
1512
- provider: fallback.provider,
1513
- model: fallback.model,
1514
- source: "default"
1515
- };
1516
- } else return;
1517
- }
1518
- let snapshot = this.snapshots.get(provider);
1519
- if (snapshot === void 0) {
1520
- const family = adapterFor(provider);
1521
- if (family !== void 0) {
1522
- for (const [id, candidate] of this.snapshots) if (adapterFor(id) === family) {
1523
- snapshot = candidate;
1524
- break;
1525
- }
1526
- }
1527
- }
1528
- const displayName = snapshot?.displayName ?? this.routeDisplayName(provider);
1529
- let announcement = snapshot !== void 0 ? buildAnnouncement(snapshot, {
1530
- todayCost: this.familyCostToday(provider),
1531
- peak: deepseekPeriodAt(Date.now()).peak
1532
- }) : void 0;
1533
- if (announcement === void 0) announcement = buildLedgerAnnouncement({
1534
- displayName,
1535
- totals: this.providerUsageToday(provider)
1536
- });
1537
- if (announcement === void 0) return;
1538
- const signature = JSON.stringify(announcement);
1539
- if (this.options.bubbleMode === "change" && signature === this.lastSignature) return;
1540
- this.lastSignature = signature;
1541
- const ttlMs = Math.min(72e5, this.options.pollIntervalSec * 2e3 + 3e4);
1542
- service(this.ctx, "pet")?.announce({
1543
- source: USAGE_ANNOUNCE_SOURCE,
1544
- ttlMs,
1545
- ...announcement
1546
- });
1547
- } catch {}
1548
- }
1549
1530
  };
1550
1531
  //#endregion
1551
1532
  //#region src/host/loopback.ts
@@ -1593,38 +1574,50 @@ function isLoopbackRequest(request) {
1593
1574
  }
1594
1575
  }
1595
1576
  //#endregion
1596
- //#region src/host/http.ts
1597
- /** Family-default JSON response headers; callers may append or override. */
1598
- const JSON_HEADERS = {
1599
- "content-type": "application/json; charset=utf-8",
1600
- "referrer-policy": "no-referrer"
1601
- };
1577
+ //#region src/host/pair-access.ts
1602
1578
  /**
1603
- * Write one JSON response. Default headers are the family defaults
1604
- * (content-type and referrer-policy); caller headers are appended or
1605
- * override them.
1579
+ * Whether this request may enter the plugin's host routes.
1580
+ * @param ctx - host context; may expose remoteWebUiPairing.
1581
+ * @param request - the incoming HTTP request.
1582
+ * @returns true for loopback, or a live paired-device cookie.
1606
1583
  */
1607
- function writeJson(res, status, body, headers = {}) {
1608
- const payload = JSON.stringify(body);
1609
- res.writeHead(status, {
1610
- ...JSON_HEADERS,
1611
- ...headers
1612
- });
1613
- res.end(payload);
1584
+ function isPairedOrLoopbackAllowed(ctx, request) {
1585
+ if (isLoopbackRequest(request)) return true;
1586
+ const fromGet = typeof ctx.get === "function" ? ctx.get("remoteWebUiPairing", false) : void 0;
1587
+ return (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)?.isPairedDevice(request) === true;
1588
+ }
1589
+ function isPairingAccess(value) {
1590
+ return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
1591
+ }
1592
+ //#endregion
1593
+ //#region src/host/access.ts
1594
+ /**
1595
+ * Whether this request may read the usage overview or trigger a refresh.
1596
+ * @param ctx - host context; may expose remoteWebUiPairing.
1597
+ * @param request - the incoming HTTP request.
1598
+ * @returns true for loopback, or a live paired-device cookie.
1599
+ */
1600
+ function isUsageAllowed(ctx, request) {
1601
+ return isPairedOrLoopbackAllowed(ctx, request);
1614
1602
  }
1615
1603
  //#endregion
1616
1604
  //#region src/host/routes.ts
1617
1605
  /**
1618
- * Loopback-fenced overview route: provider balances, plan quotas, and token
1619
- * usage totals. Personal account data, so the loopback fence mirrors
1620
- * dsh-perf's stats surface; the browser runs on the same machine.
1606
+ * Overview route: provider balances, plan quotas, and token usage totals.
1607
+ * Personal account data behind the family trust fence — loopback always
1608
+ * passes, and a live paired-device cookie passes too when remote-web-ui is
1609
+ * loaded (issue #1592: the sidebar usage panel reads this from a paired LAN
1610
+ * browser); every unpaired LAN request keeps its 403.
1611
+ * @param ctx - host context; may expose remoteWebUiPairing.
1612
+ * @param service - the usage service.
1613
+ * @returns the route.
1621
1614
  */
1622
- function makeUsageOverviewRoute(service) {
1615
+ function makeUsageOverviewRoute(ctx, service) {
1623
1616
  return {
1624
1617
  kind: "exact",
1625
1618
  path: "/api/dsh-usage/overview",
1626
1619
  handler: async (req, res) => {
1627
- if (!isLoopbackRequest(req)) {
1620
+ if (!isUsageAllowed(ctx, req)) {
1628
1621
  writeJson(res, 403, {
1629
1622
  ok: false,
1630
1623
  error: "forbidden: loopback-only"
@@ -1636,15 +1629,18 @@ function makeUsageOverviewRoute(service) {
1636
1629
  };
1637
1630
  }
1638
1631
  /**
1639
- * Loopback-fenced manual refresh: forces one probe cycle now and answers
1640
- * with the fresh overview.
1632
+ * Manual refresh: forces one probe cycle now and answers with the fresh
1633
+ * overview. Same trust fence as the overview route.
1634
+ * @param ctx - host context; may expose remoteWebUiPairing.
1635
+ * @param service - the usage service.
1636
+ * @returns the route.
1641
1637
  */
1642
- function makeUsageRefreshRoute(service) {
1638
+ function makeUsageRefreshRoute(ctx, service) {
1643
1639
  return {
1644
1640
  kind: "exact",
1645
1641
  path: "/api/dsh-usage/refresh",
1646
1642
  handler: async (req, res) => {
1647
- if (!isLoopbackRequest(req)) {
1643
+ if (!isUsageAllowed(ctx, req)) {
1648
1644
  writeJson(res, 403, {
1649
1645
  ok: false,
1650
1646
  error: "forbidden: loopback-only"
@@ -1675,78 +1671,69 @@ function makeUsageRefreshRoute(service) {
1675
1671
  //#region src/index.ts
1676
1672
  const name = "dsh-usage";
1677
1673
  const inject = ["webServer"];
1674
+ /**
1675
+ * Family settings namespace this row serves. Since 0.1.7 the settings surface
1676
+ * addresses one form per profile entry id and the Host generates this row's
1677
+ * page from the Config schema below, so the namespace is only the alias the
1678
+ * family settings bridge (dsh-web-settings) resolves onto this row's entry id.
1679
+ */
1678
1680
  const USAGE_SETTINGS_NAMESPACE = "dsh-usage";
1679
1681
  const Config = z.object({
1680
1682
  enabled: z.boolean().default(true),
1681
1683
  pollIntervalSec: z.number().min(30).max(3600).default(60),
1682
- bubbleMode: z.string().default("always"),
1683
1684
  retainDays: z.number().min(7).max(730).default(180)
1684
1685
  });
1685
1686
  function resolveConfig(config) {
1686
- const bubbleMode = config?.bubbleMode === "change" || config?.bubbleMode === "off" ? config.bubbleMode : "always";
1687
1687
  return {
1688
1688
  enabled: config?.enabled ?? true,
1689
1689
  pollIntervalSec: typeof config?.pollIntervalSec === "number" ? config.pollIntervalSec : 60,
1690
- bubbleMode,
1691
1690
  retainDays: typeof config?.retainDays === "number" ? config.retainDays : 180
1692
1691
  };
1693
1692
  }
1693
+ /**
1694
+ * Tail of the process-wide flush chain; undefined while no predecessor's final
1695
+ * ledger flush is still in flight. The Host reloads this profile row when its
1696
+ * configuration changes, so a successor activation is a dispose + apply pair in
1697
+ * the same process: its first ledger load must serialize behind its
1698
+ * predecessor's final write (and behind the reloads before that one).
1699
+ */
1700
+ let pendingStop;
1701
+ /**
1702
+ * Host body. The plugin's own Config schema above is the settings document the
1703
+ * 0.1.7 Host serves for this row; the effective config arrives here as the
1704
+ * `config` argument the Loader resolved and the Host reloads the row when the
1705
+ * user saves a change, so nothing in this body observes or writes settings.
1706
+ * @param ctx - host root context.
1707
+ * @param config - the row's resolved configuration.
1708
+ */
1694
1709
  const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
1695
- let source = () => config ?? {};
1710
+ const value = resolveConfig(config);
1711
+ if (!value.enabled) return;
1712
+ let live = true;
1696
1713
  let service;
1697
- let pendingStop;
1698
1714
  let disposeRoutes;
1699
- const rearm = () => {
1700
- const value = resolveConfig(source());
1701
- if (!value.enabled) {
1702
- pendingStop = service?.stop();
1703
- service = void 0;
1704
- disposeRoutes?.();
1705
- disposeRoutes = void 0;
1706
- return;
1707
- }
1708
- if (service === void 0) {
1709
- const next = new UsageService(ctx, value);
1710
- service = next;
1711
- const begin = () => {
1712
- if (service !== next) return;
1713
- next.start();
1714
- const disposers = [makeUsageOverviewRoute(next), makeUsageRefreshRoute(next)].map((route) => ctx.webServer.register(route));
1715
- disposeRoutes = () => {
1716
- for (const dispose of disposers) try {
1717
- dispose();
1718
- } catch {}
1719
- };
1720
- };
1721
- if (pendingStop !== void 0) pendingStop.then(begin, begin);
1722
- else begin();
1723
- } else service.applyOptions(value);
1715
+ const begin = () => {
1716
+ if (!live) return;
1717
+ const next = new UsageService(ctx, value);
1718
+ service = next;
1719
+ next.start();
1720
+ const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)].map((route) => ctx.webServer.register(route));
1721
+ disposeRoutes = () => {
1722
+ for (const dispose of disposers) try {
1723
+ dispose();
1724
+ } catch {}
1725
+ };
1724
1726
  };
1725
- ctx.inject(["settings"], (settingsCtx) => {
1726
- try {
1727
- if (typeof settingsCtx.settings?.installSection === "function") settingsCtx.settings.installSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
1728
- setSource: (next) => {
1729
- source = next;
1730
- rearm();
1731
- },
1732
- onChange: rearm
1733
- });
1734
- else if (typeof settingsCtx.settings?.register === "function") {
1735
- const scope = settingsCtx.settings.register(USAGE_SETTINGS_NAMESPACE, Config, { base: config ?? {} });
1736
- source = () => scope?.get?.() ?? config ?? {};
1737
- scope?.watch?.(() => {
1738
- rearm();
1739
- });
1740
- rearm();
1741
- }
1742
- } catch {}
1743
- });
1744
1727
  ctx.effect(() => {
1745
- rearm();
1728
+ if (pendingStop === void 0) begin();
1729
+ else pendingStop = pendingStop.then(begin, begin).then(() => void 0);
1746
1730
  return () => {
1731
+ live = false;
1747
1732
  disposeRoutes?.();
1748
- service?.stop();
1733
+ disposeRoutes = void 0;
1734
+ const stopping = service?.stop();
1749
1735
  service = void 0;
1736
+ if (stopping !== void 0) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping);
1750
1737
  };
1751
1738
  }, "dsh-usage: runtime");
1752
1739
  });