@linxin666/dsh-usage 0.3.7 → 0.3.10

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
@@ -1,206 +1,9 @@
1
- import { Service } from "@deepseek-ai/cordis";
2
1
  import z from "schemastery";
3
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
4
3
  import { dirname, isAbsolute, join } from "node:path";
4
+ import "@deepseek-ai/cordis";
5
5
  import { homedir } from "node:os";
6
6
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
7
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@file+..+..+.dsh-cohorts+0.1.2-alpha.1+deepseek-ai-dsh-setting_b5034a2f9729f21532041bfd08a6ac26/node_modules/@deepseek-ai/dsh-settings/lib/index.js
8
- /**
9
- * Structural secret redaction for settings values. `role('secret')` fields are
10
- * removed from a value before it crosses a wire boundary; a sidecar records
11
- * each schema-declared secret position and whether it currently holds a value,
12
- * so a configuration surface can render a write-only input without ever
13
- * receiving the secret itself.
14
- * @module @deepseek-ai/dsh-settings/redact
15
- */
16
- /** Whether a value is a plain data object the walker may recurse into. */
17
- function isRecord(value) {
18
- return typeof value === "object" && value !== null && !Array.isArray(value);
19
- }
20
- function walk(node, value, path, secrets) {
21
- if (node === void 0) return value;
22
- if (node.meta?.role === "secret") {
23
- secrets.push({
24
- path,
25
- set: value !== void 0
26
- });
27
- return;
28
- }
29
- switch (node.type) {
30
- case "object": {
31
- const properties = node.dict ?? {};
32
- const source = isRecord(value) ? value : void 0;
33
- const rebuilt = {};
34
- if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
35
- if (key in properties) continue;
36
- rebuilt[key] = entry;
37
- }
38
- for (const [key, child] of Object.entries(properties)) {
39
- const stripped = walk(child, source?.[key], [...path, key], secrets);
40
- if (stripped !== void 0) rebuilt[key] = stripped;
41
- }
42
- return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
43
- }
44
- case "dict": {
45
- if (!isRecord(value)) return value;
46
- const rebuilt = {};
47
- for (const [key, entry] of Object.entries(value)) {
48
- const stripped = walk(node.inner, entry, [...path, key], secrets);
49
- if (stripped !== void 0) rebuilt[key] = stripped;
50
- }
51
- return rebuilt;
52
- }
53
- case "array":
54
- if (!Array.isArray(value)) return value;
55
- return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
56
- default: return value;
57
- }
58
- }
59
- /**
60
- * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
61
- * per-namespace sections; plugins register a namespace schema and read the
62
- * resolved value, which layers schema defaults, the registrant's composition
63
- * `base`, and the user document section, in that order.
64
- * @module @deepseek-ai/dsh-settings
65
- */
66
- const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
67
- /**
68
- * Brand a raw string as a {@link SettingsNamespace}.
69
- * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
70
- * @returns the branded namespace.
71
- */
72
- function settingsNamespace(value) {
73
- if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
74
- return value;
75
- }
76
- /**
77
- * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
78
- * Service Definition's single change-detection predicate, exported so the invariant
79
- * companion checks exactly the implementation's relation.
80
- * @param a - one JSON-compatible value.
81
- * @param b - the other JSON-compatible value.
82
- * @returns whether the two values are structurally equal.
83
- */
84
- function deepEqualJson(a, b) {
85
- if (a === b) return true;
86
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
87
- if (Array.isArray(a) || Array.isArray(b)) {
88
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
89
- return a.every((entry, index) => deepEqualJson(entry, b[index]));
90
- }
91
- const left = a;
92
- const right = b;
93
- const keys = Object.keys(left);
94
- if (keys.length !== Object.keys(right).length) return false;
95
- return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
96
- }
97
- /** Whether a value is a plain data object (not an array, null, or class instance). */
98
- function isPlainObject(value) {
99
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
100
- const proto = Object.getPrototypeOf(value);
101
- return proto === Object.prototype || proto === null;
102
- }
103
- /** Apply one path op to a detached section, returning the next section. */
104
- function applyPathOp(section, op) {
105
- const [head, ...rest] = op.path;
106
- if (head === void 0) {
107
- if (op.op === "unset") return {};
108
- if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
109
- return { ...op.value };
110
- }
111
- if (rest.length === 0) {
112
- if (op.op === "set") return {
113
- ...section,
114
- [head]: op.value
115
- };
116
- const { [head]: _removed, ...kept } = section;
117
- return kept;
118
- }
119
- const child = section[head];
120
- if (!isPlainObject(child)) {
121
- if (op.op === "unset") return section;
122
- return {
123
- ...section,
124
- [head]: applyPathOp({}, {
125
- ...op,
126
- path: rest
127
- })
128
- };
129
- }
130
- return {
131
- ...section,
132
- [head]: applyPathOp(child, {
133
- ...op,
134
- path: rest
135
- })
136
- };
137
- }
138
- /**
139
- * Layer `over` onto `under`: plain objects merge recursively, every other
140
- * value (arrays included) replaces the lower layer wholesale. `over` never
141
- * carries `undefined` entries — sections come from parsed documents and write
142
- * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
143
- * cannot erase lower keys.
144
- */
145
- function mergeLayers(under, over) {
146
- if (over === void 0) return under;
147
- if (!isPlainObject(under) || !isPlainObject(over)) return over;
148
- const merged = { ...under };
149
- for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
150
- return merged;
151
- }
152
- /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
153
- function deepFreeze(value) {
154
- if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
155
- for (const entry of Object.values(value)) deepFreeze(entry);
156
- return Object.freeze(value);
157
- }
158
- Service.init;
159
- /**
160
- * Value mirror of the `FiberState` members {@link isUnloading} compares
161
- * against: a const enum has no runtime object to import, and the value is
162
- * needed at runtime (same rationale as the CLI boot driver's mirror).
163
- */
164
- const FIBER_DISPOSED = 4;
165
- const FIBER_UNLOADING = 5;
166
- /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
167
- function isUnloading(ctx) {
168
- const state = ctx.fiber.state;
169
- return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
170
- }
171
- /**
172
- * Install the canonical optional-settings consumer wiring: while a settings
173
- * service exists, register `ns` with the consumer's composition entry as the
174
- * `base` layer and point the source thunk at the resolved scope; when the
175
- * service goes away (disposal, provider reload), fall back to the entry so
176
- * the consumer keeps working exactly as composed. The registration rides the
177
- * scoped fiber, so no settings service ever mounted means none of this runs.
178
- * @param ctx - consumer plugin context owning the wiring.
179
- * @param ns - the consumer-owned settings namespace.
180
- * @param schema - schema resolving the namespace (typically the plugin Config).
181
- * @param entry - the consumer's composition entry config, used as `base`.
182
- * @param hooks - source sink and change notification.
183
- */
184
- function installSettingsSection(ctx, ns, schema, entry, hooks) {
185
- ctx.inject(["settings"], (sctx) => {
186
- const scope = sctx.settings.register(ns, schema, {
187
- base: entry,
188
- ...hooks.validate === void 0 ? {} : { validate: hooks.validate }
189
- });
190
- hooks.setSource(() => scope.get());
191
- sctx.effect(() => () => {
192
- if (isUnloading(ctx)) return;
193
- hooks.setSource(() => entry);
194
- hooks.onChange();
195
- });
196
- hooks.onChange();
197
- scope.watch(() => {
198
- if (isUnloading(ctx)) return;
199
- hooks.onChange();
200
- });
201
- });
202
- }
203
- //#endregion
204
7
  //#region src/mount-once.ts
205
8
  /**
206
9
  * Host single-instance guard shared by the plugin family. The family bundle
@@ -242,7 +45,30 @@ function mountOnce(packageName, fn) {
242
45
  });
243
46
  }
244
47
  //#endregion
245
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@file+..+..+.dsh-cohorts+0.1.2-alpha.1+deepseek-ai-dsh-cred_2fd4ad69ebe2ff7cfc47e1238c01d827/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
48
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.1.2-alpha.2_@deepseek-ai+cordis@4.0.2_@deepseek-ai+dsh-invaria_1a52edab8cda52d70d5ed37d8a2ca6fd/node_modules/@deepseek-ai/dsh-brand/lib/index.js
49
+ /**
50
+ * Duplicate-install-safe nominal string helpers.
51
+ *
52
+ * A brand makes structurally-identical strings non-interchangeable at the type
53
+ * level: a `SessionId` cannot be passed where a `ToolCallId` is expected, even
54
+ * though both are plain strings at runtime. Comparison, logging, and
55
+ * serialization all behave as ordinary strings.
56
+ *
57
+ * This package owns no concrete id and keeps no runtime identity or mutable
58
+ * state, so independently installed copies produce interchangeable values.
59
+ *
60
+ * @module @deepseek-ai/dsh-brand
61
+ */
62
+ /**
63
+ * Apply a compile-time string brand without changing the value.
64
+ * @param value - string admitted by the domain that owns the target brand.
65
+ * @returns the same string with the requested compile-time brand.
66
+ */
67
+ function brandString(value) {
68
+ return value;
69
+ }
70
+ //#endregion
71
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.1.2-alpha.2_@deepseek-ai+cordis@4.0.2_@deepseek-ai+dsh-i_2732fefa2ea8a51d5123e14b95af2051/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
246
72
  /**
247
73
  * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
248
74
  * *references* to secrets — environment-variable names — while providers own
@@ -262,7 +88,7 @@ const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
262
88
  */
263
89
  function credentialRef(value) {
264
90
  if (!isCredentialRefName(value)) throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
265
- return value;
91
+ return brandString(value);
266
92
  }
267
93
  /**
268
94
  * Whether a raw string could name a reference at all. Consumers that receive
@@ -285,7 +111,7 @@ function isCredentialRefName(value) {
285
111
  */
286
112
  function credentialKey(scope, id) {
287
113
  for (const segment of [scope, id]) if (!KEY_SEGMENT_PATTERN.test(segment)) throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
288
- return `${scope}/${id}`;
114
+ return brandString(`${scope}/${id}`);
289
115
  }
290
116
  //#endregion
291
117
  //#region src/dsh-home.ts
@@ -349,8 +175,9 @@ function toIso(value) {
349
175
  }
350
176
  const text = str(value);
351
177
  if (text === void 0) return void 0;
178
+ if (/^\d+$/.test(text)) return toIso(Number(text));
352
179
  const date = new Date(text);
353
- return Number.isNaN(date.getTime()) ? text : date.toISOString();
180
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
354
181
  }
355
182
  /** Used-percent helper guarding zero/absent limits. */
356
183
  function usedPercent(used, limit) {
@@ -362,8 +189,14 @@ function usedPercent(used, limit) {
362
189
  function bearer(apiKey) {
363
190
  return { authorization: `Bearer ${apiKey}` };
364
191
  }
192
+ /**
193
+ * The official pay-as-you-go balance. `deepseek` is the configurable-catalog
194
+ * route key; `deepseek-official` is the live provider route the llm-deepseek
195
+ * adapter registers (sessions and agent-default-model carry it), so both ids
196
+ * must resolve here or the current provider would never be probed.
197
+ */
365
198
  const DEEPSEEK = {
366
- ids: ["deepseek"],
199
+ ids: ["deepseek", "deepseek-official"],
367
200
  displayName: "DeepSeek",
368
201
  balance: {
369
202
  build: ({ apiKey }) => ({
@@ -620,6 +453,79 @@ function siliconFlow(host, ids, currency) {
620
453
  }
621
454
  };
622
455
  }
456
+ const ZENMUX = {
457
+ ids: ["zenmux"],
458
+ displayName: "ZenMux",
459
+ balance: {
460
+ build: ({ apiKey }) => ({
461
+ url: "https://zenmux.ai/api/v1/management/payg/balance",
462
+ headers: bearer(apiKey)
463
+ }),
464
+ parse: (status, body) => {
465
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
466
+ const data = body.data;
467
+ if (typeof data !== "object" || data === null) return void 0;
468
+ const credits = toNum(data.total_credits);
469
+ if (credits === void 0) return void 0;
470
+ return {
471
+ currency: "USD",
472
+ totalBalance: money(credits)
473
+ };
474
+ }
475
+ }
476
+ };
477
+ /**
478
+ * OpenAI Codex (ChatGPT subscription) quota over the OAuth access token the
479
+ * pi-ai grant stores — the one plan adapter whose credential is an OAuth
480
+ * token rather than an API key. A 401 here means the stored access token has
481
+ * expired; it refreshes when the harness next runs a Codex request, so the
482
+ * error line tells the user to use Codex once and refresh.
483
+ */
484
+ const OPENAI_CODEX = {
485
+ ids: ["openai-codex"],
486
+ displayName: "Codex (ChatGPT 订阅)",
487
+ plan: {
488
+ build: ({ apiKey, accountId }) => ({
489
+ url: "https://chatgpt.com/backend-api/wham/usage",
490
+ headers: {
491
+ authorization: `Bearer ${apiKey}`,
492
+ "user-agent": "codex-cli",
493
+ accept: "application/json",
494
+ ...accountId !== void 0 ? { "chatgpt-account-id": accountId } : {}
495
+ }
496
+ }),
497
+ parse: (status, body) => {
498
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
499
+ const rateLimit = body.rate_limit;
500
+ if (typeof rateLimit !== "object" || rateLimit === null) return void 0;
501
+ const windows = [];
502
+ const rows = rateLimit.primary_window !== void 0 || rateLimit.secondary_window !== void 0 ? [rateLimit.primary_window, rateLimit.secondary_window] : [];
503
+ for (const entry of rows) {
504
+ if (typeof entry !== "object" || entry === null) continue;
505
+ const row = entry;
506
+ const percent = toNum(row.used_percent);
507
+ if (percent === void 0) continue;
508
+ const seconds = toNum(row.limit_window_seconds);
509
+ windows.push({
510
+ key: codexWindowKey(seconds),
511
+ percent: Math.max(0, Math.min(100, percent)),
512
+ resetsAt: toIso(row.reset_at)
513
+ });
514
+ }
515
+ if (windows.length === 0) return void 0;
516
+ return { windows };
517
+ }
518
+ }
519
+ };
520
+ /** Map a Codex window length in seconds onto the shared window key vocabulary. */
521
+ function codexWindowKey(seconds) {
522
+ if (seconds === void 0) return "window";
523
+ if (seconds === 18e3) return "5h";
524
+ if (seconds === 604800) return "week";
525
+ if (seconds === 2592e3) return "month";
526
+ const hours = seconds / 3600;
527
+ return hours >= 24 ? `${Math.round(hours / 24)}_day` : `${Math.round(hours)}_hour`;
528
+ }
623
529
  /**
624
530
  * The adapter registry, in no particular order. Route keys come from the
625
531
  * pi-ai provider catalog plus the routes this deployment observed in user
@@ -635,36 +541,26 @@ const PROVIDER_ADAPTERS = [
635
541
  OPENCODE_GO,
636
542
  minimaxPlan("api.minimaxi.com", ["minimax-cn"]),
637
543
  minimaxPlan("api.minimax.io", ["minimax"]),
544
+ OPENAI_CODEX,
638
545
  OPENROUTER,
639
546
  siliconFlow("api.siliconflow.cn", ["siliconflow", "siliconflow-cn"], "CNY"),
640
547
  siliconFlow("api.siliconflow.com", ["siliconflow-intl"], "USD"),
641
- {
642
- ids: ["zenmux"],
643
- displayName: "ZenMux",
644
- balance: {
645
- build: ({ apiKey }) => ({
646
- url: "https://zenmux.ai/api/v1/management/payg/balance",
647
- headers: bearer(apiKey)
648
- }),
649
- parse: (status, body) => {
650
- if (status !== 200 || typeof body !== "object" || body === null) return void 0;
651
- const data = body.data;
652
- if (typeof data !== "object" || data === null) return void 0;
653
- const credits = toNum(data.total_credits);
654
- if (credits === void 0) return void 0;
655
- return {
656
- currency: "USD",
657
- totalBalance: money(credits)
658
- };
659
- }
660
- }
661
- }
548
+ ZENMUX
662
549
  ];
663
550
  /** Find the adapter serving a provider route key, if any. */
664
551
  function adapterFor(provider) {
665
552
  return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider));
666
553
  }
667
554
  /**
555
+ * Whether a provider route belongs to the official DeepSeek family: the only
556
+ * family with a spend price book and a settings-section-owned env credential
557
+ * (llm-deepseek) rather than a pi-ai profile. Drives the env fallback in
558
+ * credential resolution and the fold-time cost stamping.
559
+ */
560
+ function isDeepSeekProviderRoute(provider) {
561
+ return adapterFor(provider) === DEEPSEEK;
562
+ }
563
+ /**
668
564
  * Best-effort human message from a provider error body, for the per-provider
669
565
  * error line. Never throws; truncated to one short sentence.
670
566
  */
@@ -678,6 +574,99 @@ function providerErrorMessage(status, body) {
678
574
  return `HTTP ${status}${message === void 0 ? "" : `: ${message.slice(0, 120)}`}`;
679
575
  }
680
576
  //#endregion
577
+ //#region src/core/pricing.ts
578
+ /** The published peak windows: weekday 09:00-12:00 and 14:00-18:00. */
579
+ const PEAK_WINDOWS = [{
580
+ from: 540,
581
+ to: 720
582
+ }, {
583
+ from: 840,
584
+ to: 1080
585
+ }];
586
+ /** Beijing is UTC+8 year-round (no DST), so a fixed shift is exact. */
587
+ const BEIJING_UTC_OFFSET_MS = 8 * 36e5;
588
+ /** deepseek-v4-flash (and the flash-class vision experiment). */
589
+ const FLASH_PRICE = {
590
+ cacheHit: {
591
+ offPeak: .05,
592
+ peak: .1
593
+ },
594
+ inputMiss: {
595
+ offPeak: 1.5,
596
+ peak: 3
597
+ },
598
+ output: {
599
+ offPeak: 4.5,
600
+ peak: 9
601
+ }
602
+ };
603
+ /** deepseek-v4-pro. */
604
+ const PRO_PRICE = {
605
+ cacheHit: {
606
+ offPeak: .15,
607
+ peak: .3
608
+ },
609
+ inputMiss: {
610
+ offPeak: 4.5,
611
+ peak: 9
612
+ },
613
+ output: {
614
+ offPeak: 13.5,
615
+ peak: 27
616
+ }
617
+ };
618
+ function withinWindow(minuteOfDay) {
619
+ return PEAK_WINDOWS.find((window) => minuteOfDay >= window.from && minuteOfDay < window.to);
620
+ }
621
+ /**
622
+ * The DeepSeek billing period at `ms`, plus when it next flips. The clock is
623
+ * Beijing time regardless of the host timezone (UTC+8 has no DST, so a fixed
624
+ * shift is exact). `boundaryMs` is the instant the current period ends — the
625
+ * window's close while peaking, the next window's open otherwise.
626
+ */
627
+ function deepseekPeriodAt(ms) {
628
+ const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS);
629
+ const weekday = shifted.getUTCDay();
630
+ const minuteOfDay = shifted.getUTCHours() * 60 + shifted.getUTCMinutes();
631
+ const current = weekday >= 1 && weekday <= 5 ? withinWindow(minuteOfDay) : void 0;
632
+ if (current !== void 0) return {
633
+ peak: true,
634
+ boundaryMs: ms + (current.to - minuteOfDay) * 6e4 - shifted.getUTCSeconds() * 1e3 - shifted.getUTCMilliseconds()
635
+ };
636
+ for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
637
+ const day = new Date(ms + BEIJING_UTC_OFFSET_MS + dayOffset * 864e5);
638
+ if (day.getUTCDay() < 1 || day.getUTCDay() > 5) continue;
639
+ const realDayStart = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) - BEIJING_UTC_OFFSET_MS;
640
+ for (const window of PEAK_WINDOWS) {
641
+ if (dayOffset === 0 && window.from <= minuteOfDay) continue;
642
+ return {
643
+ peak: false,
644
+ boundaryMs: realDayStart + window.from * 6e4
645
+ };
646
+ }
647
+ }
648
+ return {
649
+ peak: false,
650
+ boundaryMs: ms + 864e5
651
+ };
652
+ }
653
+ /** The price row for a model id; unknown ids take the flash-class row (documented estimate). */
654
+ function priceFor(model) {
655
+ return model.includes("v4-pro") ? PRO_PRICE : FLASH_PRICE;
656
+ }
657
+ /**
658
+ * Estimate one call's DeepSeek spend in CNY from its token totals, priced in
659
+ * the billing period at `atMs`. Uncatalogued model ids take the flash-class
660
+ * row; ids from other providers never reach this function (the service gates
661
+ * by route family). Rounded to micro-CNY so the ledger stays readable.
662
+ */
663
+ function deepseekModelSpend(model, totals, atMs) {
664
+ const price = priceFor(model);
665
+ const column = deepseekPeriodAt(atMs).peak ? "peak" : "offPeak";
666
+ const spend = (totals.cacheReadTokens * price.cacheHit[column] + (totals.inputTokens + totals.cacheWriteTokens) * price.inputMiss[column] + totals.outputTokens * price.output[column]) / 1e6;
667
+ return Math.round(spend * 1e6) / 1e6;
668
+ }
669
+ //#endregion
681
670
  //#region src/core/types.ts
682
671
  /** A zeroed totals bucket. */
683
672
  function emptyTotals() {
@@ -687,7 +676,8 @@ function emptyTotals() {
687
676
  cacheReadTokens: 0,
688
677
  cacheWriteTokens: 0,
689
678
  reasoningTokens: 0,
690
- calls: 0
679
+ calls: 0,
680
+ cost: 0
691
681
  };
692
682
  }
693
683
  /** Add `right` into `left` in place. */
@@ -698,6 +688,7 @@ function addTotals(left, right) {
698
688
  left.cacheWriteTokens += right.cacheWriteTokens;
699
689
  left.reasoningTokens += right.reasoningTokens;
700
690
  left.calls += right.calls;
691
+ left.cost += right.cost;
701
692
  return left;
702
693
  }
703
694
  //#endregion
@@ -723,10 +714,21 @@ function createLedgerDocument() {
723
714
  };
724
715
  }
725
716
  /**
717
+ * Bucket keys come from untrusted directions (persisted JSON, provider and
718
+ * model ids out of session events), so they must never collide with
719
+ * `Object.prototype` plumbing: assigning `day['__proto__']` or reading
720
+ * `day['constructor']` would pollute every object in the host process.
721
+ */
722
+ function isSafeBucketKey(key) {
723
+ return key !== "__proto__" && key !== "constructor" && key !== "prototype";
724
+ }
725
+ /**
726
726
  * Fold one usage report into the ledger in place. `provider` is the route key
727
- * and `model` the provider-owned model id the step ran under.
727
+ * and `model` the provider-owned model id the step ran under. Reports keyed
728
+ * by prototype-plumbing names are dropped.
728
729
  */
729
730
  function foldUsage(doc, atMs, provider, model, usage) {
731
+ if (!isSafeBucketKey(provider) || !isSafeBucketKey(model)) return;
730
732
  if (usage.calls <= 0 && usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens <= 0) return;
731
733
  const dayKey = localDateKey(atMs);
732
734
  const day = doc.days[dayKey] ?? {};
@@ -741,6 +743,51 @@ function foldUsage(doc, atMs, provider, model, usage) {
741
743
  function totalTokens(totals) {
742
744
  return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens;
743
745
  }
746
+ /**
747
+ * Aggregate whole per-day maps (each `provider -> model -> totals`) into the
748
+ * per-provider/per-model summary the overview serves for one day or a range:
749
+ * disjoint bucket sums per provider, per-model rows heaviest first, and the
750
+ * grand total. Pure: reads its inputs, allocates fresh buckets.
751
+ */
752
+ function summarizeDays(days) {
753
+ const merged = /* @__PURE__ */ new Map();
754
+ for (const day of days) for (const [provider, models] of Object.entries(day)) {
755
+ if (!isSafeBucketKey(provider) || typeof models !== "object" || models === null) continue;
756
+ let modelMap = merged.get(provider);
757
+ if (modelMap === void 0) {
758
+ modelMap = /* @__PURE__ */ new Map();
759
+ merged.set(provider, modelMap);
760
+ }
761
+ for (const [model, totals] of Object.entries(models)) {
762
+ if (typeof totals !== "object" || totals === null) continue;
763
+ const bucket = modelMap.get(model) ?? emptyTotals();
764
+ addTotals(bucket, totals);
765
+ modelMap.set(model, bucket);
766
+ }
767
+ }
768
+ const providers = [];
769
+ const totals = emptyTotals();
770
+ for (const [provider, modelMap] of merged) {
771
+ const providerTotals = emptyTotals();
772
+ const modelRows = [...modelMap].map(([model, modelTotals]) => ({
773
+ model,
774
+ totals: modelTotals
775
+ }));
776
+ for (const row of modelRows) addTotals(providerTotals, row.totals);
777
+ modelRows.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals));
778
+ providers.push({
779
+ provider,
780
+ totals: providerTotals,
781
+ models: modelRows.slice(0, 12)
782
+ });
783
+ }
784
+ providers.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals));
785
+ for (const row of providers) addTotals(totals, row.totals);
786
+ return {
787
+ totals,
788
+ providers
789
+ };
790
+ }
744
791
  /** All local-date keys in the ledger, ascending. */
745
792
  function ledgerDayKeys(doc) {
746
793
  return Object.keys(doc.days).sort();
@@ -770,7 +817,8 @@ function reviveTotals(value) {
770
817
  cacheReadTokens: num("cacheReadTokens"),
771
818
  cacheWriteTokens: num("cacheWriteTokens"),
772
819
  reasoningTokens: num("reasoningTokens"),
773
- calls: num("calls")
820
+ calls: num("calls"),
821
+ cost: num("cost")
774
822
  };
775
823
  }
776
824
  /**
@@ -785,11 +833,13 @@ function deserializeLedger(value) {
785
833
  if (typeof days !== "object" || days === null) return doc;
786
834
  for (const [dateKey, providers] of Object.entries(days)) {
787
835
  if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || typeof providers !== "object" || providers === null) continue;
836
+ const atMs = (/* @__PURE__ */ new Date(dateKey + "T12:00:00")).getTime();
837
+ if (!Number.isFinite(atMs) || localDateKey(atMs) !== dateKey) continue;
788
838
  for (const [provider, models] of Object.entries(providers)) {
789
839
  if (typeof models !== "object" || models === null) continue;
790
840
  for (const [model, totals] of Object.entries(models)) {
791
841
  const revived = reviveTotals(totals);
792
- if (revived !== void 0) foldUsage(doc, (/* @__PURE__ */ new Date(dateKey + "T12:00:00")).getTime(), provider, model, revived);
842
+ if (revived !== void 0) foldUsage(doc, atMs, provider, model, revived);
793
843
  }
794
844
  }
795
845
  }
@@ -830,6 +880,47 @@ function planTone(percent) {
830
880
  if (percent >= 70) return "warn";
831
881
  return "ok";
832
882
  }
883
+ /**
884
+ * Build the raw pet announce payload for one provider snapshot, or
885
+ * undefined when nothing worth announcing exists. Pure: every payload this
886
+ * returns satisfies the pet's `parseAnnouncement` contract — plan
887
+ * announcements require a numeric percent, so percent-less windows never
888
+ * announce (the pet validator would silently drop them). A priced family
889
+ * (DeepSeek) with spend today announces a cost bubble first; balance-only
890
+ * families announce the balance; plan families announce their tightest
891
+ * percent window.
892
+ */
893
+ function buildAnnouncement(snapshot, context) {
894
+ const todayCost = context?.todayCost ?? 0;
895
+ if (todayCost > 0) {
896
+ const peak = context?.peak ?? false;
897
+ const noteParts = [peak ? "高峰时段 计价×2" : "空闲时段 计价减半", ...snapshot.balance !== void 0 ? [`余额 ${formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance)}`] : []];
898
+ return {
899
+ kind: "cost",
900
+ title: snapshot.displayName,
901
+ amount: `今日 ${formatMoney("CNY", todayCost.toFixed(2))}`,
902
+ ...noteParts.length > 0 ? { note: noteParts.join(" · ") } : {},
903
+ tone: peak ? "warn" : "ok"
904
+ };
905
+ }
906
+ if (snapshot.balance !== void 0) return {
907
+ kind: "balance",
908
+ title: snapshot.displayName,
909
+ amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
910
+ tone: "ok"
911
+ };
912
+ if (snapshot.plan === void 0) return void 0;
913
+ const window = snapshot.plan.windows.filter((entry) => typeof entry.percent === "number").sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0];
914
+ if (window === void 0 || window.percent === void 0) return void 0;
915
+ return {
916
+ kind: "plan",
917
+ title: snapshot.displayName,
918
+ percent: window.percent,
919
+ ...window.resetsAt !== void 0 ? { resetAt: window.resetsAt } : {},
920
+ ...snapshot.plan.planName !== void 0 ? { note: snapshot.plan.planName } : {},
921
+ tone: planTone(window.percent)
922
+ };
923
+ }
833
924
  /** Best-effort typed service read: absent services resolve to undefined at runtime. */
834
925
  function service(ctx, name) {
835
926
  try {
@@ -838,11 +929,27 @@ function service(ctx, name) {
838
929
  return;
839
930
  }
840
931
  }
841
- /** Atomic JSON write through a temp file + rename. */
932
+ /**
933
+ * Atomic JSON write through a unique temp file + fsync + rename. The temp
934
+ * name is per-call so two overlapping flushes can never interleave into one
935
+ * temp file, and the fsync closes the rename-lands-but-bytes-are-not-durable
936
+ * window.
937
+ */
842
938
  async function writeJsonAtomic(path, value) {
843
- const temp = path + ".tmp";
844
- await writeFile(temp, JSON.stringify(value, null, 1), "utf8");
845
- await rename(temp, path);
939
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
940
+ try {
941
+ const handle = await open(temp, "w");
942
+ try {
943
+ await handle.writeFile(JSON.stringify(value, null, 1), "utf8");
944
+ await handle.sync();
945
+ } finally {
946
+ await handle.close();
947
+ }
948
+ await rename(temp, path);
949
+ } catch (error) {
950
+ await unlink(temp).catch(() => {});
951
+ throw error;
952
+ }
846
953
  }
847
954
  /**
848
955
  * Read a foreign settings namespace's resolved value (the llm adapter
@@ -853,7 +960,7 @@ function readNamespace(ctx, ns) {
853
960
  try {
854
961
  const settings = service(ctx, "settings");
855
962
  if (settings === void 0) return void 0;
856
- return settings.get(settingsNamespace(ns));
963
+ return settings.get(ns);
857
964
  } catch {
858
965
  return;
859
966
  }
@@ -874,8 +981,16 @@ var UsageService = class {
874
981
  pollTimer;
875
982
  flushTimer;
876
983
  pollInFlight = false;
984
+ /** The running poll cycle; manual refresh joins it instead of no-oping. */
985
+ pollPromise;
986
+ /** Serialized ledger flushes: overlapping debounce/stop flushes queue, never interleave. */
987
+ flushChain = Promise.resolve();
988
+ /** True once loadPersisted finished; nothing may overwrite the files before that. */
989
+ loaded = false;
877
990
  disposed = false;
878
991
  lastSignature;
992
+ /** Last prune guard: once per local day, and whenever retention shrinks. */
993
+ lastPrune;
879
994
  constructor(ctx, options) {
880
995
  this.ctx = ctx;
881
996
  this.options = options;
@@ -889,19 +1004,24 @@ var UsageService = class {
889
1004
  this.loadPersisted();
890
1005
  this.rearmPoll(2e3);
891
1006
  }
892
- /** Stop timers and flush pending ledger writes. */
1007
+ /**
1008
+ * Stop timers and flush pending ledger writes. The returned promise
1009
+ * resolves after the final flush lands, so a successor instance (quick
1010
+ * disable → enable) can serialize its first load behind it.
1011
+ */
893
1012
  stop() {
894
1013
  this.disposed = true;
895
1014
  if (this.pollTimer !== void 0) clearTimeout(this.pollTimer);
896
1015
  if (this.flushTimer !== void 0) clearTimeout(this.flushTimer);
897
1016
  this.sessionListenerDisposer?.();
898
- this.flushLedger();
1017
+ return this.flushLedger();
899
1018
  }
900
- /** Re-apply options live (settings change); the poll cycle picks them up. */
1019
+ /** Re-apply options live (settings change); retention shrink prunes now. */
901
1020
  applyOptions(options) {
902
1021
  this.options = options;
1022
+ this.pruneIfNeeded();
903
1023
  }
904
- /** Force one poll now (manual refresh route). */
1024
+ /** Force one poll now (manual refresh route); joins an in-flight cycle. */
905
1025
  async refresh() {
906
1026
  await this.pollNow();
907
1027
  }
@@ -913,19 +1033,23 @@ var UsageService = class {
913
1033
  for (const route of routes) {
914
1034
  const adapter = adapterFor(route.id);
915
1035
  const snapshot = this.snapshots.get(route.id);
1036
+ const error = snapshot?.balanceError ?? snapshot?.planError;
916
1037
  providers.push({
917
1038
  provider: route.id,
918
1039
  displayName: route.displayName || adapter?.displayName || route.id,
919
1040
  credential: snapshot?.credential ?? "none",
920
1041
  supported: adapter !== void 0 && (adapter.balance !== void 0 || adapter.plan !== void 0),
1042
+ ...adapter?.balance !== void 0 ? { balanceSupported: true } : {},
1043
+ ...adapter?.plan !== void 0 ? { planSupported: true } : {},
921
1044
  ...snapshot?.balance !== void 0 ? { balance: snapshot.balance } : {},
922
1045
  ...snapshot?.plan !== void 0 ? { plan: snapshot.plan } : {},
923
- ...snapshot?.error !== void 0 ? { error: snapshot.error } : {},
1046
+ ...error !== void 0 ? { error } : {},
924
1047
  ...snapshot?.updatedAt !== void 0 ? { updatedAt: snapshot.updatedAt } : {}
925
1048
  });
926
1049
  }
927
1050
  providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName));
928
1051
  const days = ledgerDayKeys(this.ledger).slice(-30);
1052
+ const range = summarizeDays(days.map((key) => this.ledger.days[key] ?? {}));
929
1053
  return {
930
1054
  updatedAt: Date.now(),
931
1055
  providers,
@@ -937,50 +1061,33 @@ var UsageService = class {
937
1061
  date,
938
1062
  totals: this.daySummary(date).totals
939
1063
  };
940
- })
1064
+ }),
1065
+ range: {
1066
+ from: days[0] ?? todayKey,
1067
+ to: days[days.length - 1] ?? todayKey,
1068
+ totals: range.totals,
1069
+ providers: range.providers
1070
+ }
941
1071
  }
942
1072
  };
943
1073
  }
944
1074
  /** One local day aggregated per provider. */
945
1075
  daySummary(dateKey) {
946
- const day = this.ledger.days[dateKey] ?? {};
947
- const providers = Object.entries(day).map(([provider, models]) => {
948
- const totals = emptyTotals();
949
- const modelRows = Object.entries(models).map(([model, modelTotals]) => ({
950
- model,
951
- totals: modelTotals
952
- }));
953
- for (const row of modelRows) {
954
- totals.inputTokens += row.totals.inputTokens;
955
- totals.outputTokens += row.totals.outputTokens;
956
- totals.cacheReadTokens += row.totals.cacheReadTokens;
957
- totals.cacheWriteTokens += row.totals.cacheWriteTokens;
958
- totals.reasoningTokens += row.totals.reasoningTokens;
959
- totals.calls += row.totals.calls;
960
- }
961
- modelRows.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals));
962
- return {
963
- provider,
964
- totals,
965
- models: modelRows.slice(0, 12)
966
- };
967
- });
968
- providers.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals));
969
- const totals = emptyTotals();
970
- for (const row of providers) {
971
- totals.inputTokens += row.totals.inputTokens;
972
- totals.outputTokens += row.totals.outputTokens;
973
- totals.cacheReadTokens += row.totals.cacheReadTokens;
974
- totals.cacheWriteTokens += row.totals.cacheWriteTokens;
975
- totals.reasoningTokens += row.totals.reasoningTokens;
976
- totals.calls += row.totals.calls;
977
- }
1076
+ const { totals, providers } = summarizeDays([this.ledger.days[dateKey] ?? {}]);
978
1077
  return {
979
1078
  date: dateKey,
980
1079
  totals,
981
1080
  providers
982
1081
  };
983
1082
  }
1083
+ /** Today's ledger spend for one provider's adapter family (0 when unpriced). */
1084
+ familyCostToday(provider) {
1085
+ const family = adapterFor(provider);
1086
+ if (family === void 0) return 0;
1087
+ let cost = 0;
1088
+ for (const row of this.daySummary(localDateKey(Date.now())).providers) if (adapterFor(row.provider) === family) cost += row.totals.cost;
1089
+ return cost;
1090
+ }
984
1091
  onSessionEvent(session, event) {
985
1092
  try {
986
1093
  if (event.type === "request/header") {
@@ -1014,13 +1121,17 @@ var UsageService = class {
1014
1121
  if (usage === void 0) return;
1015
1122
  const route = this.sessionRoutes.get(session);
1016
1123
  if (route === void 0 || route.provider === "") return;
1017
- foldUsage(this.ledger, Date.now(), route.provider, route.model || "unknown", this.totalsFrom(usage));
1124
+ foldUsage(this.ledger, Date.now(), route.provider, route.model || "unknown", this.totalsFrom(usage, route.provider, route.model || "unknown", Date.now()));
1018
1125
  this.scheduleFlush();
1019
1126
  }
1020
1127
  } catch {}
1021
1128
  }
1022
- /** Normalize a provider TokenUsage into the ledger bucket (one call). */
1023
- totalsFrom(usage) {
1129
+ /**
1130
+ * Normalize a provider TokenUsage into the ledger bucket (one call). The
1131
+ * DeepSeek official family is priced at the fold instant (its billing
1132
+ * period is time-of-day); other families stay unpriced (cost 0).
1133
+ */
1134
+ totalsFrom(usage, provider, model, atMs) {
1024
1135
  const totals = emptyTotals();
1025
1136
  totals.inputTokens = usage.inputTokens;
1026
1137
  totals.outputTokens = usage.outputTokens;
@@ -1028,14 +1139,23 @@ var UsageService = class {
1028
1139
  totals.cacheWriteTokens = usage.cacheWriteTokens ?? 0;
1029
1140
  totals.reasoningTokens = usage.reasoningTokens ?? 0;
1030
1141
  totals.calls = 1;
1142
+ if (isDeepSeekProviderRoute(provider)) totals.cost = deepseekModelSpend(model, totals, atMs);
1031
1143
  return totals;
1032
1144
  }
1033
1145
  async loadPersisted() {
1034
1146
  try {
1035
1147
  const [rawLedger, rawSnapshots] = await Promise.all([readFile(this.ledgerPath, "utf8").catch(() => void 0), readFile(this.snapshotsPath, "utf8").catch(() => void 0)]);
1036
1148
  if (rawLedger !== void 0) {
1037
- this.ledger = deserializeLedger(JSON.parse(rawLedger));
1149
+ const loaded = deserializeLedger(JSON.parse(rawLedger));
1150
+ for (const [dayKey, providers] of Object.entries(loaded.days)) {
1151
+ const atMs = (/* @__PURE__ */ new Date(dayKey + "T12:00:00")).getTime();
1152
+ for (const [provider, models] of Object.entries(providers)) for (const [model, totals] of Object.entries(models)) foldUsage(this.ledger, atMs, provider, model, totals);
1153
+ }
1038
1154
  pruneLedger(this.ledger, localDateKey(Date.now()), this.options.retainDays);
1155
+ this.lastPrune = {
1156
+ dayKey: localDateKey(Date.now()),
1157
+ retainDays: this.options.retainDays
1158
+ };
1039
1159
  }
1040
1160
  if (rawSnapshots !== void 0) {
1041
1161
  const parsed = JSON.parse(rawSnapshots);
@@ -1043,7 +1163,9 @@ var UsageService = class {
1043
1163
  for (const [provider, snapshot] of Object.entries(parsed.providers)) if (typeof snapshot === "object" && snapshot !== null && typeof snapshot.provider === "string") this.snapshots.set(provider, snapshot);
1044
1164
  }
1045
1165
  }
1046
- } catch {}
1166
+ } catch {} finally {
1167
+ this.loaded = true;
1168
+ }
1047
1169
  }
1048
1170
  scheduleFlush() {
1049
1171
  if (this.flushTimer !== void 0 || this.disposed) return;
@@ -1052,13 +1174,40 @@ var UsageService = class {
1052
1174
  this.flushLedger();
1053
1175
  }, FLUSH_DEBOUNCE_MS);
1054
1176
  }
1055
- async flushLedger() {
1177
+ /**
1178
+ * Queue one ledger flush behind the previous one. Debounce and stop-time
1179
+ * flushes serialize here, so two `writeJsonAtomic` calls can never run
1180
+ * concurrently on the same path.
1181
+ */
1182
+ flushLedger() {
1183
+ const next = this.flushChain.then(() => this.writeLedgerOnce());
1184
+ this.flushChain = next.catch(() => {});
1185
+ return next;
1186
+ }
1187
+ async writeLedgerOnce() {
1188
+ if (!this.loaded) return;
1056
1189
  try {
1057
1190
  await mkdir(dirname(this.ledgerPath), { recursive: true });
1058
1191
  await writeJsonAtomic(this.ledgerPath, this.ledger);
1059
1192
  } catch {}
1060
1193
  }
1194
+ /**
1195
+ * Prune days past retention. Runs at most once per local day and whenever
1196
+ * `retainDays` shrinks — not only at startup, so long-lived processes and
1197
+ * live settings changes both honor retention.
1198
+ */
1199
+ pruneIfNeeded() {
1200
+ const todayKey = localDateKey(Date.now());
1201
+ if (this.lastPrune?.dayKey === todayKey && this.lastPrune.retainDays === this.options.retainDays) return;
1202
+ const pruned = pruneLedger(this.ledger, todayKey, this.options.retainDays);
1203
+ this.lastPrune = {
1204
+ dayKey: todayKey,
1205
+ retainDays: this.options.retainDays
1206
+ };
1207
+ if (pruned > 0) this.scheduleFlush();
1208
+ }
1061
1209
  async persistSnapshots() {
1210
+ if (!this.loaded) return;
1062
1211
  try {
1063
1212
  await mkdir(dirname(this.snapshotsPath), { recursive: true });
1064
1213
  await writeJsonAtomic(this.snapshotsPath, {
@@ -1075,25 +1224,38 @@ var UsageService = class {
1075
1224
  this.pollNow().finally(() => this.rearmPoll(Math.max(30, this.options.pollIntervalSec) * 1e3));
1076
1225
  }, delayMs);
1077
1226
  }
1078
- /** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
1079
- async pollNow() {
1080
- if (this.pollInFlight || this.disposed) return;
1227
+ /**
1228
+ * One poll cycle: enumerate routes, resolve credentials, probe, announce.
1229
+ * A call while a cycle is already running joins that cycle instead of
1230
+ * returning immediately, so a manual refresh always waits for real probes.
1231
+ */
1232
+ pollNow() {
1233
+ if (this.pollInFlight) return this.pollPromise ?? Promise.resolve();
1234
+ if (this.disposed) return Promise.resolve();
1081
1235
  this.pollInFlight = true;
1082
- try {
1083
- const routes = this.listProviderRoutes();
1084
- const seen = /* @__PURE__ */ new Set();
1085
- for (const route of routes) {
1086
- seen.add(route.id);
1087
- const adapter = adapterFor(route.id);
1088
- if (adapter === void 0 || adapter.balance === void 0 && adapter.plan === void 0) continue;
1089
- await this.probeRoute(route, adapter);
1236
+ const cycle = (async () => {
1237
+ try {
1238
+ this.pruneIfNeeded();
1239
+ const routes = this.listProviderRoutes();
1240
+ const seen = /* @__PURE__ */ new Set();
1241
+ for (const route of routes) {
1242
+ if (this.disposed) return;
1243
+ seen.add(route.id);
1244
+ const adapter = adapterFor(route.id);
1245
+ if (adapter === void 0 || adapter.balance === void 0 && adapter.plan === void 0) continue;
1246
+ await this.probeRoute(route, adapter);
1247
+ }
1248
+ for (const id of [...this.snapshots.keys()]) if (!seen.has(id)) this.snapshots.delete(id);
1249
+ if (this.disposed) return;
1250
+ await this.persistSnapshots();
1251
+ this.announceCurrent();
1252
+ } finally {
1253
+ this.pollInFlight = false;
1254
+ this.pollPromise = void 0;
1090
1255
  }
1091
- for (const id of [...this.snapshots.keys()]) if (!seen.has(id)) this.snapshots.delete(id);
1092
- await this.persistSnapshots();
1093
- this.announceCurrent();
1094
- } finally {
1095
- this.pollInFlight = false;
1096
- }
1256
+ })();
1257
+ this.pollPromise = cycle;
1258
+ return cycle;
1097
1259
  }
1098
1260
  listProviderRoutes() {
1099
1261
  const routes = /* @__PURE__ */ new Map();
@@ -1110,26 +1272,31 @@ var UsageService = class {
1110
1272
  async probeRoute(route, adapter) {
1111
1273
  const credential = await this.resolveCredential(route.id);
1112
1274
  const previous = this.snapshots.get(route.id);
1275
+ if (credential.key === void 0) {
1276
+ this.snapshots.set(route.id, {
1277
+ provider: route.id,
1278
+ displayName: route.displayName || adapter.displayName,
1279
+ credential: credential.kind,
1280
+ supported: true,
1281
+ updatedAt: Date.now()
1282
+ });
1283
+ return;
1284
+ }
1113
1285
  const snapshot = {
1114
1286
  provider: route.id,
1115
1287
  displayName: route.displayName || adapter.displayName,
1116
1288
  credential: credential.kind,
1117
1289
  supported: true,
1118
- ...previous?.balance !== void 0 ? { balance: previous.balance } : {},
1119
- ...previous?.plan !== void 0 ? { plan: previous.plan } : {},
1120
1290
  updatedAt: Date.now()
1121
1291
  };
1122
- if (credential.key === void 0) {
1123
- snapshot.error = void 0;
1124
- this.snapshots.set(route.id, snapshot);
1125
- return;
1126
- }
1127
- let probed = false;
1128
1292
  const runProbe = async (kind) => {
1129
1293
  const half = adapter[kind];
1130
1294
  if (half === void 0) return void 0;
1131
1295
  try {
1132
- const spec = half.build({ apiKey: credential.key });
1296
+ const spec = half.build({
1297
+ apiKey: credential.key,
1298
+ ...credential.accountId !== void 0 ? { accountId: credential.accountId } : {}
1299
+ });
1133
1300
  const response = await fetch(spec.url, {
1134
1301
  headers: spec.headers,
1135
1302
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
@@ -1138,7 +1305,6 @@ var UsageService = class {
1138
1305
  if (!response.ok) throw new Error(providerErrorMessage(response.status, body));
1139
1306
  const parsed = half.parse(response.status, body);
1140
1307
  if (parsed === void 0) throw new Error("unrecognized response shape");
1141
- probed = true;
1142
1308
  const updatedAt = Date.now();
1143
1309
  if (kind === "balance") {
1144
1310
  const fact = parsed;
@@ -1155,23 +1321,27 @@ var UsageService = class {
1155
1321
  updatedAt
1156
1322
  };
1157
1323
  } catch (error) {
1158
- snapshot.error = error instanceof Error ? error.message.slice(0, 200) : String(error);
1324
+ const message = error instanceof Error ? error.message.slice(0, 200) : String(error);
1325
+ if (kind === "balance") snapshot.balanceError = message;
1326
+ else snapshot.planError = message;
1159
1327
  return;
1160
1328
  }
1161
1329
  };
1162
1330
  const balance = await runProbe("balance");
1163
1331
  if (balance !== void 0) snapshot.balance = balance;
1332
+ else if (previous?.balance !== void 0) snapshot.balance = previous.balance;
1164
1333
  const plan = await runProbe("plan");
1165
1334
  if (plan !== void 0) snapshot.plan = plan;
1166
- if (probed) snapshot.error = void 0;
1167
- if (snapshot.balance === void 0 && snapshot.plan === void 0 && snapshot.error === void 0) snapshot.error = "no credential configured";
1335
+ else if (previous?.plan !== void 0) snapshot.plan = previous.plan;
1168
1336
  this.snapshots.set(route.id, snapshot);
1169
1337
  }
1170
1338
  /**
1171
1339
  * Resolve the credential backing one route: pi-ai credential records
1172
1340
  * first, then the profile's apiKeyEnv reference, then the DeepSeek
1173
- * official adapter's env reference. OAuth grants resolve to a kind with
1174
- * no key (this plugin does not spend third-party OAuth budgets).
1341
+ * official adapter's env reference. OAuth grants hand their stored access
1342
+ * token to the oauth-aware adapters (Codex plan quota); a stale token
1343
+ * fails its probe with a 401 and refreshes the next time the harness
1344
+ * itself runs that provider.
1175
1345
  */
1176
1346
  async resolveCredential(provider) {
1177
1347
  const credentials = service(this.ctx, "credentials");
@@ -1182,10 +1352,20 @@ var UsageService = class {
1182
1352
  kind: "api-key",
1183
1353
  key: record.key
1184
1354
  };
1185
- if (record?.kind === "grant") return { kind: "oauth" };
1355
+ if (record?.kind === "grant" && typeof record.payload === "object" && record.payload !== null) {
1356
+ const payload = record.payload;
1357
+ const access = typeof payload.access === "string" && payload.access !== "" ? payload.access : void 0;
1358
+ const accountId = typeof payload.chatgpt_account_id === "string" && payload.chatgpt_account_id !== "" ? payload.chatgpt_account_id : void 0;
1359
+ if (access !== void 0) return {
1360
+ kind: "oauth",
1361
+ key: access,
1362
+ ...accountId !== void 0 ? { accountId } : {}
1363
+ };
1364
+ return { kind: "oauth" };
1365
+ }
1186
1366
  } catch {}
1187
1367
  try {
1188
- const envName = this.piAiProfile(provider)?.apiKeyEnv ?? (provider === "deepseek" ? this.deepseekApiKeyEnv() : void 0);
1368
+ const envName = this.piAiProfile(provider)?.apiKeyEnv ?? (isDeepSeekProviderRoute(provider) ? this.deepseekApiKeyEnv() : void 0);
1189
1369
  if (typeof envName === "string" && envName !== "") {
1190
1370
  const resolved = await credentials.resolve(credentialRef(envName));
1191
1371
  if (resolved?.value !== void 0 && resolved.value !== "") return {
@@ -1205,51 +1385,54 @@ var UsageService = class {
1205
1385
  return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
1206
1386
  }
1207
1387
  /**
1208
- * Announce the current provider's balance or plan usage to the pet. In
1209
- * `change` mode only meaningful value changes re-announce; `off` skips.
1388
+ * Announce the current provider's spend, balance, or plan usage to the pet.
1389
+ * In `change` mode only meaningful value changes re-announce; `off` skips.
1390
+ * The TTL rides the poll interval (bubble_mode `always` re-announces every
1391
+ * cycle, so a TTL of two cycles + margin keeps the bubble continuous
1392
+ * across polls; the pet contract caps the ceiling). A route id the
1393
+ * catalogs spell differently than the snapshot keys falls back to its
1394
+ * adapter family's snapshot. Fully guarded: a malformed snapshot or a
1395
+ * failing pet service must never break the poll loop, and a disposed
1396
+ * service never announces.
1210
1397
  */
1211
1398
  announceCurrent() {
1212
- if (this.options.bubbleMode === "off") return;
1213
- let provider = this.current.provider;
1214
- if (provider === void 0) {
1215
- const fallback = readNamespace(this.ctx, "agent-default-model");
1216
- if (fallback?.provider !== void 0) {
1217
- provider = fallback.provider;
1218
- this.current = {
1219
- provider: fallback.provider,
1220
- model: fallback.model,
1221
- source: "default"
1222
- };
1223
- } else return;
1224
- }
1225
- const snapshot = this.snapshots.get(provider);
1226
- if (snapshot === void 0) return;
1227
- let announcement;
1228
- if (snapshot.balance !== void 0) announcement = {
1229
- kind: "balance",
1230
- title: snapshot.displayName,
1231
- amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
1232
- tone: "ok"
1233
- };
1234
- else if (snapshot.plan !== void 0 && snapshot.plan.windows.length > 0) {
1235
- const window = [...snapshot.plan.windows].sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0];
1236
- announcement = {
1237
- kind: "plan",
1238
- title: snapshot.displayName,
1239
- ...window.percent !== void 0 ? { percent: window.percent } : {},
1240
- ...window.resetsAt !== void 0 ? { resetAt: window.resetsAt } : {},
1241
- ...snapshot.plan.planName !== void 0 ? { note: snapshot.plan.planName } : {},
1242
- tone: window.percent !== void 0 ? planTone(window.percent) : "ok"
1243
- };
1244
- } else return;
1245
- const signature = JSON.stringify(announcement);
1246
- if (this.options.bubbleMode === "change" && signature === this.lastSignature) return;
1247
- this.lastSignature = signature;
1248
- const pet = service(this.ctx, "pet");
1399
+ if (this.disposed || this.options.bubbleMode === "off") return;
1249
1400
  try {
1250
- pet?.announce({
1401
+ let provider = this.current.provider;
1402
+ if (provider === void 0) {
1403
+ const fallback = readNamespace(this.ctx, "agent-default-model");
1404
+ if (fallback?.provider !== void 0) {
1405
+ provider = fallback.provider;
1406
+ this.current = {
1407
+ provider: fallback.provider,
1408
+ model: fallback.model,
1409
+ source: "default"
1410
+ };
1411
+ } else return;
1412
+ }
1413
+ let snapshot = this.snapshots.get(provider);
1414
+ if (snapshot === void 0) {
1415
+ const family = adapterFor(provider);
1416
+ if (family !== void 0) {
1417
+ for (const [id, candidate] of this.snapshots) if (adapterFor(id) === family) {
1418
+ snapshot = candidate;
1419
+ break;
1420
+ }
1421
+ }
1422
+ }
1423
+ if (snapshot === void 0) return;
1424
+ const announcement = buildAnnouncement(snapshot, {
1425
+ todayCost: this.familyCostToday(provider),
1426
+ peak: deepseekPeriodAt(Date.now()).peak
1427
+ });
1428
+ if (announcement === void 0) return;
1429
+ const signature = JSON.stringify(announcement);
1430
+ if (this.options.bubbleMode === "change" && signature === this.lastSignature) return;
1431
+ this.lastSignature = signature;
1432
+ const ttlMs = Math.min(72e5, this.options.pollIntervalSec * 2e3 + 3e4);
1433
+ service(this.ctx, "pet")?.announce({
1251
1434
  source: USAGE_ANNOUNCE_SOURCE,
1252
- ttlMs: void 0,
1435
+ ttlMs,
1253
1436
  ...announcement
1254
1437
  });
1255
1438
  } catch {}
@@ -1383,7 +1566,7 @@ function makeUsageRefreshRoute(service) {
1383
1566
  //#region src/index.ts
1384
1567
  const name = "dsh-usage";
1385
1568
  const inject = ["webServer"];
1386
- const USAGE_SETTINGS_NAMESPACE = settingsNamespace("dsh-usage");
1569
+ const USAGE_SETTINGS_NAMESPACE = "dsh-usage";
1387
1570
  const Config = z.object({
1388
1571
  enabled: z.boolean().default(true),
1389
1572
  pollIntervalSec: z.number().min(30).max(3600).default(60),
@@ -1402,33 +1585,42 @@ function resolveConfig(config) {
1402
1585
  const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
1403
1586
  let source = () => config ?? {};
1404
1587
  let service;
1588
+ let pendingStop;
1405
1589
  let disposeRoutes;
1406
1590
  const rearm = () => {
1407
1591
  const value = resolveConfig(source());
1408
1592
  if (!value.enabled) {
1409
- service?.stop();
1593
+ pendingStop = service?.stop();
1410
1594
  service = void 0;
1411
1595
  disposeRoutes?.();
1412
1596
  disposeRoutes = void 0;
1413
1597
  return;
1414
1598
  }
1415
1599
  if (service === void 0) {
1416
- service = new UsageService(ctx, value);
1417
- service.start();
1418
- const disposers = [makeUsageOverviewRoute(service), makeUsageRefreshRoute(service)].map((route) => ctx.webServer.register(route));
1419
- disposeRoutes = () => {
1420
- for (const dispose of disposers) try {
1421
- dispose();
1422
- } catch {}
1600
+ const next = new UsageService(ctx, value);
1601
+ service = next;
1602
+ const begin = () => {
1603
+ if (service !== next) return;
1604
+ next.start();
1605
+ const disposers = [makeUsageOverviewRoute(next), makeUsageRefreshRoute(next)].map((route) => ctx.webServer.register(route));
1606
+ disposeRoutes = () => {
1607
+ for (const dispose of disposers) try {
1608
+ dispose();
1609
+ } catch {}
1610
+ };
1423
1611
  };
1612
+ if (pendingStop !== void 0) pendingStop.then(begin, begin);
1613
+ else begin();
1424
1614
  } else service.applyOptions(value);
1425
1615
  };
1426
- installSettingsSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
1427
- setSource: (next) => {
1428
- source = next;
1429
- rearm();
1430
- },
1431
- onChange: rearm
1616
+ ctx.inject(["settings"], (settingsCtx) => {
1617
+ settingsCtx.settings.installSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
1618
+ setSource: (next) => {
1619
+ source = next;
1620
+ rearm();
1621
+ },
1622
+ onChange: rearm
1623
+ });
1432
1624
  });
1433
1625
  ctx.effect(() => {
1434
1626
  rearm();