@linxin666/dsh-usage 0.3.7 → 0.3.9
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/README.i18n.yaml +4 -3
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +652 -48
- package/lib/client.js.map +1 -1
- package/lib/index.js +560 -368
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/core/adapters.d.ts +10 -1
- package/lib/types/core/ledger.d.ts +15 -2
- package/lib/types/core/pricing.d.ts +30 -0
- package/lib/types/core/types.d.ts +34 -1
- package/lib/types/host/usage-service.d.ts +72 -11
- package/lib/types/index.d.ts +2 -1
- package/package.json +15 -15
- package/src/client/UsageSectionCard.tsx +138 -55
- package/src/client/index.ts +11 -2
- package/src/client/locales.ts +8 -0
- package/src/client/usage.module.css +71 -12
- package/src/core/adapters.ts +84 -3
- package/src/core/ledger.ts +65 -3
- package/src/core/pricing.ts +109 -0
- package/src/core/types.ts +37 -2
- package/src/host/usage-service.ts +300 -131
- package/src/index.ts +30 -17
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,
|
|
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-
|
|
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()) ?
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
844
|
-
|
|
845
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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);
|
|
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
|
-
...
|
|
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
|
|
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
|
-
/**
|
|
1023
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
seen
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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({
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1174
|
-
*
|
|
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"
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1417
|
-
service
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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();
|