@linxin666/dsh-usage 0.3.7
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/LICENSE +202 -0
- package/README.i18n.yaml +5 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +755 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1443 -0
- package/lib/types/client/UsageSectionCard.d.ts +36 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +62 -0
- package/lib/types/client/usage-store.d.ts +27 -0
- package/lib/types/core/adapters.d.ts +58 -0
- package/lib/types/core/ledger.d.ts +31 -0
- package/lib/types/core/types.d.ts +112 -0
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host/http.d.ts +42 -0
- package/lib/types/host/loopback.d.ts +24 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/usage-service.d.ts +82 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +92 -0
- package/src/client/UsageSectionCard.tsx +368 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +131 -0
- package/src/client/locales.ts +116 -0
- package/src/client/usage-store.ts +52 -0
- package/src/client/usage.module.css +311 -0
- package/src/core/adapters.ts +370 -0
- package/src/core/ledger.ts +108 -0
- package/src/core/types.ts +127 -0
- package/src/dsh-home.ts +42 -0
- package/src/host/http.ts +105 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/routes.ts +54 -0
- package/src/host/usage-service.ts +530 -0
- package/src/index.ts +90 -0
- package/src/mount-once.ts +48 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1443 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import z from "schemastery";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
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
|
+
//#region src/mount-once.ts
|
|
205
|
+
/**
|
|
206
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
207
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
208
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
209
|
+
* without this guard the second instance would still re-register the same
|
|
210
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
211
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
212
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
213
|
+
* package name in the client module host).
|
|
214
|
+
*
|
|
215
|
+
* The registry rides a global symbol so two module instances of the same
|
|
216
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
217
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
218
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
219
|
+
*/
|
|
220
|
+
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
221
|
+
function mountedSet() {
|
|
222
|
+
const registry = globalThis;
|
|
223
|
+
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
227
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
228
|
+
* any later mount of the same package name is a no-op.
|
|
229
|
+
* @param packageName - npm package identity shared by every install source.
|
|
230
|
+
* @param fn - the original plugin apply.
|
|
231
|
+
* @returns an apply of the same shape.
|
|
232
|
+
*/
|
|
233
|
+
function mountOnce(packageName, fn) {
|
|
234
|
+
return ((...args) => {
|
|
235
|
+
const mounted = mountedSet();
|
|
236
|
+
if (mounted.has(packageName)) return;
|
|
237
|
+
mounted.add(packageName);
|
|
238
|
+
args[0]?.effect?.(() => () => {
|
|
239
|
+
mounted.delete(packageName);
|
|
240
|
+
});
|
|
241
|
+
return fn(...args);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
//#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
|
|
246
|
+
/**
|
|
247
|
+
* Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
|
|
248
|
+
* *references* to secrets — environment-variable names — while providers own
|
|
249
|
+
* the actual values and their storage. Consumers resolve a reference once per
|
|
250
|
+
* operation, so a changed credential reaches the next operation without any
|
|
251
|
+
* plugin restart, and configuration surfaces describe a reference without
|
|
252
|
+
* ever seeing its value.
|
|
253
|
+
* @module @deepseek-ai/dsh-credentials
|
|
254
|
+
*/
|
|
255
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
256
|
+
/** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
|
|
257
|
+
const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
258
|
+
/**
|
|
259
|
+
* Brand a raw string as a {@link CredentialRef}.
|
|
260
|
+
* @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
|
|
261
|
+
* @returns the branded reference.
|
|
262
|
+
*/
|
|
263
|
+
function credentialRef(value) {
|
|
264
|
+
if (!isCredentialRefName(value)) throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Whether a raw string could name a reference at all. Consumers that receive
|
|
269
|
+
* environment-variable names from somewhere else — a provider library's own
|
|
270
|
+
* ambient discovery, a hook payload — ask this before resolving, because a name
|
|
271
|
+
* outside the grammar has no reference to miss and should read as "not set"
|
|
272
|
+
* rather than as a thrown error.
|
|
273
|
+
* @param value - candidate reference.
|
|
274
|
+
* @returns true when {@link credentialRef} would accept it.
|
|
275
|
+
*/
|
|
276
|
+
function isCredentialRefName(value) {
|
|
277
|
+
return REF_PATTERN.test(value);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Brand a scope and an id as a {@link CredentialKey}.
|
|
281
|
+
* @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
|
|
282
|
+
* @param id - that plugin's own addressing unit, such as a provider route key.
|
|
283
|
+
* @returns the branded key.
|
|
284
|
+
* @throws TypeError when either segment is not a lowercase hyphenated identifier.
|
|
285
|
+
*/
|
|
286
|
+
function credentialKey(scope, id) {
|
|
287
|
+
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}`;
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/dsh-home.ts
|
|
292
|
+
/**
|
|
293
|
+
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
294
|
+
* environment override wins, the platform home fallback follows. Mirrors
|
|
295
|
+
* what dsh-pet and dsh-liangshen each used to implement locally.
|
|
296
|
+
*/
|
|
297
|
+
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
298
|
+
function expandHome(path, home = homedir()) {
|
|
299
|
+
const j = home.startsWith("/") ? join$1 : join;
|
|
300
|
+
if (path === "~") return home;
|
|
301
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
|
|
302
|
+
return path;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the DSH home directory.
|
|
306
|
+
* @param env - process environment to read DSH_HOME from.
|
|
307
|
+
* @param home - platform home directory fallback (test seam).
|
|
308
|
+
* @returns the absolute DSH home path.
|
|
309
|
+
*/
|
|
310
|
+
function resolveDshHome(env = process.env, home = homedir()) {
|
|
311
|
+
const isPosix = home.startsWith("/");
|
|
312
|
+
const j = isPosix ? join$1 : join;
|
|
313
|
+
const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
|
|
314
|
+
const raw = env.DSH_HOME;
|
|
315
|
+
if (raw !== void 0 && raw.trim() !== "") {
|
|
316
|
+
const expanded = expandHome(raw.trim(), home);
|
|
317
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
|
|
318
|
+
}
|
|
319
|
+
return j(home, ".dsh");
|
|
320
|
+
}
|
|
321
|
+
/** Resolve the DSH home directory from the live environment. */
|
|
322
|
+
function dshHome() {
|
|
323
|
+
return resolveDshHome();
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/core/adapters.ts
|
|
327
|
+
/** Parse a string/number into a finite number, else undefined. */
|
|
328
|
+
function toNum(value) {
|
|
329
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
330
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
331
|
+
const parsed = Number(value);
|
|
332
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Read a string field that must be a non-empty string. */
|
|
336
|
+
function str(value) {
|
|
337
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
338
|
+
}
|
|
339
|
+
/** Format a number to a fixed 2-decimal display string. */
|
|
340
|
+
function money(value) {
|
|
341
|
+
return value.toFixed(2);
|
|
342
|
+
}
|
|
343
|
+
/** Millisecond epoch or ISO string → normalized ISO 8601, else undefined. */
|
|
344
|
+
function toIso(value) {
|
|
345
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
346
|
+
const ms = value < 0xe8d4a51000 ? value * 1e3 : value;
|
|
347
|
+
const date = new Date(ms);
|
|
348
|
+
return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
|
|
349
|
+
}
|
|
350
|
+
const text = str(value);
|
|
351
|
+
if (text === void 0) return void 0;
|
|
352
|
+
const date = new Date(text);
|
|
353
|
+
return Number.isNaN(date.getTime()) ? text : date.toISOString();
|
|
354
|
+
}
|
|
355
|
+
/** Used-percent helper guarding zero/absent limits. */
|
|
356
|
+
function usedPercent(used, limit) {
|
|
357
|
+
const usedNum = toNum(used);
|
|
358
|
+
const limitNum = toNum(limit);
|
|
359
|
+
if (usedNum === void 0 || limitNum === void 0 || limitNum <= 0) return void 0;
|
|
360
|
+
return Math.max(0, Math.min(100, usedNum / limitNum * 100));
|
|
361
|
+
}
|
|
362
|
+
function bearer(apiKey) {
|
|
363
|
+
return { authorization: `Bearer ${apiKey}` };
|
|
364
|
+
}
|
|
365
|
+
const DEEPSEEK = {
|
|
366
|
+
ids: ["deepseek"],
|
|
367
|
+
displayName: "DeepSeek",
|
|
368
|
+
balance: {
|
|
369
|
+
build: ({ apiKey }) => ({
|
|
370
|
+
url: "https://api.deepseek.com/user/balance",
|
|
371
|
+
headers: bearer(apiKey)
|
|
372
|
+
}),
|
|
373
|
+
parse: (status, body) => {
|
|
374
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
375
|
+
const infos = body.balance_infos;
|
|
376
|
+
if (!Array.isArray(infos) || infos.length === 0) return void 0;
|
|
377
|
+
const first = infos[0];
|
|
378
|
+
if (typeof first !== "object" || first === null) return void 0;
|
|
379
|
+
const currency = str(first.currency);
|
|
380
|
+
const total = str(first.total_balance);
|
|
381
|
+
if (currency === void 0 || total === void 0) return void 0;
|
|
382
|
+
return {
|
|
383
|
+
currency,
|
|
384
|
+
totalBalance: total
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
/** Moonshot pay-as-you-go balance; CN bills in CNY, international in USD. */
|
|
390
|
+
function moonshotBalance(host, currency, ids) {
|
|
391
|
+
return {
|
|
392
|
+
ids,
|
|
393
|
+
displayName: "Moonshot AI",
|
|
394
|
+
balance: {
|
|
395
|
+
build: ({ apiKey }) => ({
|
|
396
|
+
url: `https://${host}/v1/users/me/balance`,
|
|
397
|
+
headers: bearer(apiKey)
|
|
398
|
+
}),
|
|
399
|
+
parse: (status, body) => {
|
|
400
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
401
|
+
const data = body.data;
|
|
402
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
403
|
+
const available = toNum(data.available_balance);
|
|
404
|
+
if (available === void 0) return void 0;
|
|
405
|
+
return {
|
|
406
|
+
currency,
|
|
407
|
+
totalBalance: money(available)
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/** Kimi For Coding quota: top-level `usage` is the weekly summary, `limits[]` the per-window rows. */
|
|
414
|
+
const KIMI_CODING = {
|
|
415
|
+
ids: ["kimi-coding"],
|
|
416
|
+
displayName: "Kimi For Coding",
|
|
417
|
+
plan: {
|
|
418
|
+
build: ({ apiKey }) => ({
|
|
419
|
+
url: "https://api.kimi.com/coding/v1/usages",
|
|
420
|
+
headers: bearer(apiKey)
|
|
421
|
+
}),
|
|
422
|
+
parse: (status, body) => {
|
|
423
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
424
|
+
const root = body;
|
|
425
|
+
const windows = [];
|
|
426
|
+
const limits = root.limits;
|
|
427
|
+
if (Array.isArray(limits)) for (const entry of limits) {
|
|
428
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
429
|
+
const detail = entry.detail;
|
|
430
|
+
const window = entry.window;
|
|
431
|
+
if (typeof detail !== "object" || detail === null) continue;
|
|
432
|
+
const row = detail;
|
|
433
|
+
const duration = typeof window === "object" && window !== null ? toNum(window.duration) : void 0;
|
|
434
|
+
const unit = typeof window === "object" && window !== null ? str(window.timeUnit) : void 0;
|
|
435
|
+
const key = duration === 300 && unit === "TIME_UNIT_MINUTE" ? "5h" : duration !== void 0 ? `w-${duration}` : "window";
|
|
436
|
+
const percent = usedPercent(row.used, row.limit);
|
|
437
|
+
windows.push({
|
|
438
|
+
key,
|
|
439
|
+
name: str(row.name),
|
|
440
|
+
percent,
|
|
441
|
+
resetsAt: toIso(row.resetTime)
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const usage = root.usage;
|
|
445
|
+
if (typeof usage === "object" && usage !== null) {
|
|
446
|
+
const weekly = usage;
|
|
447
|
+
windows.push({
|
|
448
|
+
key: "week",
|
|
449
|
+
name: "Weekly",
|
|
450
|
+
percent: usedPercent(weekly.used, weekly.limit),
|
|
451
|
+
resetsAt: toIso(weekly.resetTime)
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (windows.length === 0) return void 0;
|
|
455
|
+
const user = root.user;
|
|
456
|
+
const membership = typeof user === "object" && user !== null ? user.membership : void 0;
|
|
457
|
+
return {
|
|
458
|
+
planName: typeof membership === "object" && membership !== null ? str(membership.level) : void 0,
|
|
459
|
+
windows
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
/** GLM Coding Plan quota; auth is the RAW key without a Bearer prefix. */
|
|
465
|
+
function glmPlan(host, ids) {
|
|
466
|
+
return {
|
|
467
|
+
ids,
|
|
468
|
+
displayName: "GLM Coding Plan",
|
|
469
|
+
plan: {
|
|
470
|
+
build: ({ apiKey }) => ({
|
|
471
|
+
url: `https://${host}/api/monitor/usage/quota/limit`,
|
|
472
|
+
headers: {
|
|
473
|
+
authorization: apiKey,
|
|
474
|
+
"accept-language": "en-US,en"
|
|
475
|
+
}
|
|
476
|
+
}),
|
|
477
|
+
parse: (status, body) => {
|
|
478
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
479
|
+
const root = body;
|
|
480
|
+
if (root.success !== true) return void 0;
|
|
481
|
+
const data = root.data;
|
|
482
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
483
|
+
const limits = data.limits;
|
|
484
|
+
if (!Array.isArray(limits)) return void 0;
|
|
485
|
+
const windows = [];
|
|
486
|
+
for (const entry of limits) {
|
|
487
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
488
|
+
const row = entry;
|
|
489
|
+
const unit = toNum(row.unit);
|
|
490
|
+
const percent = toNum(row.percentage);
|
|
491
|
+
windows.push({
|
|
492
|
+
key: unit === 3 ? "5h" : unit === 6 ? "week" : unit !== void 0 ? `unit-${unit}` : "window",
|
|
493
|
+
percent: percent === void 0 ? void 0 : Math.max(0, Math.min(100, percent)),
|
|
494
|
+
resetsAt: toIso(row.nextResetTime)
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (windows.length === 0) return void 0;
|
|
498
|
+
return {
|
|
499
|
+
planName: str(data.level),
|
|
500
|
+
windows
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** OpenCode Go quota: percent-only rolling/weekly/monthly windows. */
|
|
507
|
+
const OPENCODE_GO = {
|
|
508
|
+
ids: ["opencode-go"],
|
|
509
|
+
displayName: "OpenCode Go",
|
|
510
|
+
plan: {
|
|
511
|
+
build: ({ apiKey }) => ({
|
|
512
|
+
url: "https://opencode.ai/zen/go/v1/usage",
|
|
513
|
+
headers: bearer(apiKey)
|
|
514
|
+
}),
|
|
515
|
+
parse: (status, body) => {
|
|
516
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
517
|
+
const usage = body.usage;
|
|
518
|
+
if (typeof usage !== "object" || usage === null) return void 0;
|
|
519
|
+
const windows = [];
|
|
520
|
+
for (const [field, key] of [
|
|
521
|
+
["rolling", "5h"],
|
|
522
|
+
["weekly", "week"],
|
|
523
|
+
["monthly", "month"]
|
|
524
|
+
]) {
|
|
525
|
+
const entry = usage[field];
|
|
526
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
527
|
+
const row = entry;
|
|
528
|
+
const percent = toNum(row.percent);
|
|
529
|
+
windows.push({
|
|
530
|
+
key,
|
|
531
|
+
percent: percent === void 0 ? void 0 : Math.max(0, Math.min(100, percent)),
|
|
532
|
+
resetsAt: percent === 0 ? void 0 : toIso(row.resetsAt)
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
if (windows.length === 0) return void 0;
|
|
536
|
+
return { windows };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
/** MiniMax coding-plan remains: remaining-percent semantics, `general` model entry. */
|
|
541
|
+
function minimaxPlan(host, ids) {
|
|
542
|
+
return {
|
|
543
|
+
ids,
|
|
544
|
+
displayName: "MiniMax Coding Plan",
|
|
545
|
+
plan: {
|
|
546
|
+
build: ({ apiKey }) => ({
|
|
547
|
+
url: `https://${host}/v1/api/openplatform/coding_plan/remains`,
|
|
548
|
+
headers: bearer(apiKey)
|
|
549
|
+
}),
|
|
550
|
+
parse: (status, body) => {
|
|
551
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
552
|
+
const remains = body.model_remains;
|
|
553
|
+
if (!Array.isArray(remains)) return void 0;
|
|
554
|
+
const general = remains.find((entry) => typeof entry === "object" && entry !== null && entry.model_name === "general");
|
|
555
|
+
if (typeof general !== "object" || general === null) return void 0;
|
|
556
|
+
const row = general;
|
|
557
|
+
const windows = [];
|
|
558
|
+
const intervalRemaining = toNum(row.current_interval_remaining_percent);
|
|
559
|
+
if (intervalRemaining !== void 0) windows.push({
|
|
560
|
+
key: "5h",
|
|
561
|
+
percent: Math.max(0, Math.min(100, 100 - intervalRemaining)),
|
|
562
|
+
resetsAt: toIso(row.end_time)
|
|
563
|
+
});
|
|
564
|
+
if (row.current_weekly_status === 1) {
|
|
565
|
+
const weeklyRemaining = toNum(row.current_weekly_remaining_percent);
|
|
566
|
+
if (weeklyRemaining !== void 0) windows.push({
|
|
567
|
+
key: "week",
|
|
568
|
+
percent: Math.max(0, Math.min(100, 100 - weeklyRemaining)),
|
|
569
|
+
resetsAt: toIso(row.weekly_end_time)
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (windows.length === 0) return void 0;
|
|
573
|
+
return { windows };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const OPENROUTER = {
|
|
579
|
+
ids: ["openrouter"],
|
|
580
|
+
displayName: "OpenRouter",
|
|
581
|
+
balance: {
|
|
582
|
+
build: ({ apiKey }) => ({
|
|
583
|
+
url: "https://openrouter.ai/api/v1/credits",
|
|
584
|
+
headers: bearer(apiKey)
|
|
585
|
+
}),
|
|
586
|
+
parse: (status, body) => {
|
|
587
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
588
|
+
const data = body.data;
|
|
589
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
590
|
+
const credits = toNum(data.total_credits);
|
|
591
|
+
const used = toNum(data.total_usage) ?? 0;
|
|
592
|
+
if (credits === void 0) return void 0;
|
|
593
|
+
return {
|
|
594
|
+
currency: "USD",
|
|
595
|
+
totalBalance: money(credits - used)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
function siliconFlow(host, ids, currency) {
|
|
601
|
+
return {
|
|
602
|
+
ids,
|
|
603
|
+
displayName: "SiliconFlow",
|
|
604
|
+
balance: {
|
|
605
|
+
build: ({ apiKey }) => ({
|
|
606
|
+
url: `https://${host}/v1/user/info`,
|
|
607
|
+
headers: bearer(apiKey)
|
|
608
|
+
}),
|
|
609
|
+
parse: (status, body) => {
|
|
610
|
+
if (status !== 200 || typeof body !== "object" || body === null) return void 0;
|
|
611
|
+
const data = body.data;
|
|
612
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
613
|
+
const total = str(data.totalBalance);
|
|
614
|
+
if (total === void 0) return void 0;
|
|
615
|
+
return {
|
|
616
|
+
currency,
|
|
617
|
+
totalBalance: total
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The adapter registry, in no particular order. Route keys come from the
|
|
625
|
+
* pi-ai provider catalog plus the routes this deployment observed in user
|
|
626
|
+
* configuration (`zenmux`).
|
|
627
|
+
*/
|
|
628
|
+
const PROVIDER_ADAPTERS = [
|
|
629
|
+
DEEPSEEK,
|
|
630
|
+
moonshotBalance("api.moonshot.cn", "CNY", ["moonshotai-cn"]),
|
|
631
|
+
moonshotBalance("api.moonshot.ai", "USD", ["moonshotai"]),
|
|
632
|
+
KIMI_CODING,
|
|
633
|
+
glmPlan("open.bigmodel.cn", ["zai-coding-cn"]),
|
|
634
|
+
glmPlan("api.z.ai", ["zai-coding"]),
|
|
635
|
+
OPENCODE_GO,
|
|
636
|
+
minimaxPlan("api.minimaxi.com", ["minimax-cn"]),
|
|
637
|
+
minimaxPlan("api.minimax.io", ["minimax"]),
|
|
638
|
+
OPENROUTER,
|
|
639
|
+
siliconFlow("api.siliconflow.cn", ["siliconflow", "siliconflow-cn"], "CNY"),
|
|
640
|
+
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
|
+
}
|
|
662
|
+
];
|
|
663
|
+
/** Find the adapter serving a provider route key, if any. */
|
|
664
|
+
function adapterFor(provider) {
|
|
665
|
+
return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider));
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Best-effort human message from a provider error body, for the per-provider
|
|
669
|
+
* error line. Never throws; truncated to one short sentence.
|
|
670
|
+
*/
|
|
671
|
+
function providerErrorMessage(status, body) {
|
|
672
|
+
let message;
|
|
673
|
+
if (typeof body === "object" && body !== null) {
|
|
674
|
+
const root = body;
|
|
675
|
+
const nested = typeof root.error === "object" && root.error !== null ? root.error : void 0;
|
|
676
|
+
message = str(root.message) ?? str(root.msg) ?? (nested !== void 0 ? str(nested.message) : void 0);
|
|
677
|
+
}
|
|
678
|
+
return `HTTP ${status}${message === void 0 ? "" : `: ${message.slice(0, 120)}`}`;
|
|
679
|
+
}
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/core/types.ts
|
|
682
|
+
/** A zeroed totals bucket. */
|
|
683
|
+
function emptyTotals() {
|
|
684
|
+
return {
|
|
685
|
+
inputTokens: 0,
|
|
686
|
+
outputTokens: 0,
|
|
687
|
+
cacheReadTokens: 0,
|
|
688
|
+
cacheWriteTokens: 0,
|
|
689
|
+
reasoningTokens: 0,
|
|
690
|
+
calls: 0
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/** Add `right` into `left` in place. */
|
|
694
|
+
function addTotals(left, right) {
|
|
695
|
+
left.inputTokens += right.inputTokens;
|
|
696
|
+
left.outputTokens += right.outputTokens;
|
|
697
|
+
left.cacheReadTokens += right.cacheReadTokens;
|
|
698
|
+
left.cacheWriteTokens += right.cacheWriteTokens;
|
|
699
|
+
left.reasoningTokens += right.reasoningTokens;
|
|
700
|
+
left.calls += right.calls;
|
|
701
|
+
return left;
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/core/ledger.ts
|
|
705
|
+
/**
|
|
706
|
+
* The usage ledger: a pure fold from session usage facts into a per-day,
|
|
707
|
+
* per-provider, per-model totals document, plus its JSON serialization.
|
|
708
|
+
* Host-side state lives only in the document; the service owns persistence.
|
|
709
|
+
* @module @linxin666/dsh-usage/core/ledger
|
|
710
|
+
*/
|
|
711
|
+
/** Local-date key (`YYYY-MM-DD`) for an epoch ms timestamp. */
|
|
712
|
+
function localDateKey(ms) {
|
|
713
|
+
const date = new Date(ms);
|
|
714
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
715
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
716
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
717
|
+
}
|
|
718
|
+
/** An empty ledger document. */
|
|
719
|
+
function createLedgerDocument() {
|
|
720
|
+
return {
|
|
721
|
+
version: 1,
|
|
722
|
+
days: {}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
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.
|
|
728
|
+
*/
|
|
729
|
+
function foldUsage(doc, atMs, provider, model, usage) {
|
|
730
|
+
if (usage.calls <= 0 && usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens <= 0) return;
|
|
731
|
+
const dayKey = localDateKey(atMs);
|
|
732
|
+
const day = doc.days[dayKey] ?? {};
|
|
733
|
+
const models = day[provider] ?? {};
|
|
734
|
+
const totals = models[model] ?? emptyTotals();
|
|
735
|
+
addTotals(totals, usage);
|
|
736
|
+
models[model] = totals;
|
|
737
|
+
day[provider] = models;
|
|
738
|
+
doc.days[dayKey] = day;
|
|
739
|
+
}
|
|
740
|
+
/** Total tokens of a bucket (billed input + output; reasoning is inside output). */
|
|
741
|
+
function totalTokens(totals) {
|
|
742
|
+
return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens;
|
|
743
|
+
}
|
|
744
|
+
/** All local-date keys in the ledger, ascending. */
|
|
745
|
+
function ledgerDayKeys(doc) {
|
|
746
|
+
return Object.keys(doc.days).sort();
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Drop every day older than `retainDays` local days before `todayKey`, in
|
|
750
|
+
* place. Returns the number of pruned days.
|
|
751
|
+
*/
|
|
752
|
+
function pruneLedger(doc, todayKey, retainDays) {
|
|
753
|
+
const cutoff = /* @__PURE__ */ new Date(todayKey + "T00:00:00");
|
|
754
|
+
cutoff.setDate(cutoff.getDate() - retainDays);
|
|
755
|
+
const cutoffKey = localDateKey(cutoff.getTime());
|
|
756
|
+
let pruned = 0;
|
|
757
|
+
for (const key of Object.keys(doc.days)) if (key < cutoffKey) {
|
|
758
|
+
delete doc.days[key];
|
|
759
|
+
pruned += 1;
|
|
760
|
+
}
|
|
761
|
+
return pruned;
|
|
762
|
+
}
|
|
763
|
+
function reviveTotals(value) {
|
|
764
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
765
|
+
const source = value;
|
|
766
|
+
const num = (key) => typeof source[key] === "number" && Number.isFinite(source[key]) ? source[key] : 0;
|
|
767
|
+
return {
|
|
768
|
+
inputTokens: num("inputTokens"),
|
|
769
|
+
outputTokens: num("outputTokens"),
|
|
770
|
+
cacheReadTokens: num("cacheReadTokens"),
|
|
771
|
+
cacheWriteTokens: num("cacheWriteTokens"),
|
|
772
|
+
reasoningTokens: num("reasoningTokens"),
|
|
773
|
+
calls: num("calls")
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Parse a ledger document from untrusted JSON: unknown shapes resolve to an
|
|
778
|
+
* empty document, malformed entries are dropped, numbers are coerced to
|
|
779
|
+
* finite values. Never throws.
|
|
780
|
+
*/
|
|
781
|
+
function deserializeLedger(value) {
|
|
782
|
+
const doc = createLedgerDocument();
|
|
783
|
+
if (typeof value !== "object" || value === null) return doc;
|
|
784
|
+
const days = value.days;
|
|
785
|
+
if (typeof days !== "object" || days === null) return doc;
|
|
786
|
+
for (const [dateKey, providers] of Object.entries(days)) {
|
|
787
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || typeof providers !== "object" || providers === null) continue;
|
|
788
|
+
for (const [provider, models] of Object.entries(providers)) {
|
|
789
|
+
if (typeof models !== "object" || models === null) continue;
|
|
790
|
+
for (const [model, totals] of Object.entries(models)) {
|
|
791
|
+
const revived = reviveTotals(totals);
|
|
792
|
+
if (revived !== void 0) foldUsage(doc, (/* @__PURE__ */ new Date(dateKey + "T12:00:00")).getTime(), provider, model, revived);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return doc;
|
|
797
|
+
}
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/host/usage-service.ts
|
|
800
|
+
/**
|
|
801
|
+
* The dsh-usage host service: folds live session usage into the persistent
|
|
802
|
+
* ledger, probes each configured provider's balance/coding-plan endpoint on
|
|
803
|
+
* a poll cycle, and announces the current provider's status to the pet
|
|
804
|
+
* bubble. Secrets stay in the host process; the browser only ever sees the
|
|
805
|
+
* overview document.
|
|
806
|
+
* @module @linxin666/dsh-usage/host/usage-service
|
|
807
|
+
*/
|
|
808
|
+
/** Source tag the plugin stamps onto pet announcements. */
|
|
809
|
+
const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
|
|
810
|
+
/** Probe timeout per HTTP call. */
|
|
811
|
+
const PROBE_TIMEOUT_MS = 1e4;
|
|
812
|
+
/** Ledger flush debounce. */
|
|
813
|
+
const FLUSH_DEBOUNCE_MS = 3e3;
|
|
814
|
+
/** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
|
|
815
|
+
const CURRENCY_SYMBOLS = {
|
|
816
|
+
CNY: "¥",
|
|
817
|
+
USD: "$",
|
|
818
|
+
EUR: "€",
|
|
819
|
+
GBP: "£"
|
|
820
|
+
};
|
|
821
|
+
/** Format a balance for display: symbol prefix when known, code suffix otherwise. */
|
|
822
|
+
function formatMoney(currency, totalBalance) {
|
|
823
|
+
const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()];
|
|
824
|
+
if (symbol !== void 0) return symbol + totalBalance;
|
|
825
|
+
return `${totalBalance} ${currency.toUpperCase()}`;
|
|
826
|
+
}
|
|
827
|
+
/** Map a used percent to the announcement tone. */
|
|
828
|
+
function planTone(percent) {
|
|
829
|
+
if (percent >= 90) return "low";
|
|
830
|
+
if (percent >= 70) return "warn";
|
|
831
|
+
return "ok";
|
|
832
|
+
}
|
|
833
|
+
/** Best-effort typed service read: absent services resolve to undefined at runtime. */
|
|
834
|
+
function service(ctx, name) {
|
|
835
|
+
try {
|
|
836
|
+
return ctx.get(name);
|
|
837
|
+
} catch {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** Atomic JSON write through a temp file + rename. */
|
|
842
|
+
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);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Read a foreign settings namespace's resolved value (the llm adapter
|
|
849
|
+
* profiles, the agent default model). Unregistered namespaces read as
|
|
850
|
+
* undefined; nothing here throws into the poll loop.
|
|
851
|
+
*/
|
|
852
|
+
function readNamespace(ctx, ns) {
|
|
853
|
+
try {
|
|
854
|
+
const settings = service(ctx, "settings");
|
|
855
|
+
if (settings === void 0) return void 0;
|
|
856
|
+
return settings.get(settingsNamespace(ns));
|
|
857
|
+
} catch {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
var UsageService = class {
|
|
862
|
+
ctx;
|
|
863
|
+
options;
|
|
864
|
+
persistDir;
|
|
865
|
+
ledgerPath;
|
|
866
|
+
snapshotsPath;
|
|
867
|
+
ledger = createLedgerDocument();
|
|
868
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
869
|
+
/** Per-live-session route attribution (WeakMap: disposed sessions age out). */
|
|
870
|
+
sessionRoutes = /* @__PURE__ */ new WeakMap();
|
|
871
|
+
/** The most recent route seen this boot; the pet bubble follows it. */
|
|
872
|
+
current = { source: "default" };
|
|
873
|
+
sessionListenerDisposer;
|
|
874
|
+
pollTimer;
|
|
875
|
+
flushTimer;
|
|
876
|
+
pollInFlight = false;
|
|
877
|
+
disposed = false;
|
|
878
|
+
lastSignature;
|
|
879
|
+
constructor(ctx, options) {
|
|
880
|
+
this.ctx = ctx;
|
|
881
|
+
this.options = options;
|
|
882
|
+
this.persistDir = join(dshHome(), "dsh-usage");
|
|
883
|
+
this.ledgerPath = join(this.persistDir, "usage-ledger.json");
|
|
884
|
+
this.snapshotsPath = join(this.persistDir, "provider-snapshots.json");
|
|
885
|
+
}
|
|
886
|
+
/** Start the listeners, load persisted state, and arm the first poll. */
|
|
887
|
+
start() {
|
|
888
|
+
this.sessionListenerDisposer = this.ctx.on("session/event", (session, event) => this.onSessionEvent(session, event));
|
|
889
|
+
this.loadPersisted();
|
|
890
|
+
this.rearmPoll(2e3);
|
|
891
|
+
}
|
|
892
|
+
/** Stop timers and flush pending ledger writes. */
|
|
893
|
+
stop() {
|
|
894
|
+
this.disposed = true;
|
|
895
|
+
if (this.pollTimer !== void 0) clearTimeout(this.pollTimer);
|
|
896
|
+
if (this.flushTimer !== void 0) clearTimeout(this.flushTimer);
|
|
897
|
+
this.sessionListenerDisposer?.();
|
|
898
|
+
this.flushLedger();
|
|
899
|
+
}
|
|
900
|
+
/** Re-apply options live (settings change); the poll cycle picks them up. */
|
|
901
|
+
applyOptions(options) {
|
|
902
|
+
this.options = options;
|
|
903
|
+
}
|
|
904
|
+
/** Force one poll now (manual refresh route). */
|
|
905
|
+
async refresh() {
|
|
906
|
+
await this.pollNow();
|
|
907
|
+
}
|
|
908
|
+
/** Assemble the overview document the browser section renders. */
|
|
909
|
+
overview() {
|
|
910
|
+
const todayKey = localDateKey(Date.now());
|
|
911
|
+
const routes = this.listProviderRoutes();
|
|
912
|
+
const providers = [];
|
|
913
|
+
for (const route of routes) {
|
|
914
|
+
const adapter = adapterFor(route.id);
|
|
915
|
+
const snapshot = this.snapshots.get(route.id);
|
|
916
|
+
providers.push({
|
|
917
|
+
provider: route.id,
|
|
918
|
+
displayName: route.displayName || adapter?.displayName || route.id,
|
|
919
|
+
credential: snapshot?.credential ?? "none",
|
|
920
|
+
supported: adapter !== void 0 && (adapter.balance !== void 0 || adapter.plan !== void 0),
|
|
921
|
+
...snapshot?.balance !== void 0 ? { balance: snapshot.balance } : {},
|
|
922
|
+
...snapshot?.plan !== void 0 ? { plan: snapshot.plan } : {},
|
|
923
|
+
...snapshot?.error !== void 0 ? { error: snapshot.error } : {},
|
|
924
|
+
...snapshot?.updatedAt !== void 0 ? { updatedAt: snapshot.updatedAt } : {}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName));
|
|
928
|
+
const days = ledgerDayKeys(this.ledger).slice(-30);
|
|
929
|
+
return {
|
|
930
|
+
updatedAt: Date.now(),
|
|
931
|
+
providers,
|
|
932
|
+
current: { ...this.current },
|
|
933
|
+
usage: {
|
|
934
|
+
today: this.daySummary(todayKey),
|
|
935
|
+
days: days.map((date) => {
|
|
936
|
+
return {
|
|
937
|
+
date,
|
|
938
|
+
totals: this.daySummary(date).totals
|
|
939
|
+
};
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
/** One local day aggregated per provider. */
|
|
945
|
+
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
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
date: dateKey,
|
|
980
|
+
totals,
|
|
981
|
+
providers
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
onSessionEvent(session, event) {
|
|
985
|
+
try {
|
|
986
|
+
if (event.type === "request/header") {
|
|
987
|
+
const config = event.data.header?.config;
|
|
988
|
+
if (config?.provider !== void 0) {
|
|
989
|
+
this.sessionRoutes.set(session, {
|
|
990
|
+
provider: config.provider,
|
|
991
|
+
model: config.model ?? ""
|
|
992
|
+
});
|
|
993
|
+
this.current = {
|
|
994
|
+
provider: config.provider,
|
|
995
|
+
model: config.model,
|
|
996
|
+
source: "live"
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
} else if (event.type === "request/context") {
|
|
1000
|
+
const data = event.data;
|
|
1001
|
+
if (data.provider !== void 0) {
|
|
1002
|
+
this.sessionRoutes.set(session, {
|
|
1003
|
+
provider: data.provider,
|
|
1004
|
+
model: data.model ?? ""
|
|
1005
|
+
});
|
|
1006
|
+
this.current = {
|
|
1007
|
+
provider: data.provider,
|
|
1008
|
+
model: data.model,
|
|
1009
|
+
source: "live"
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
} else if (event.type === "assistant/message") {
|
|
1013
|
+
const usage = event.data.usage;
|
|
1014
|
+
if (usage === void 0) return;
|
|
1015
|
+
const route = this.sessionRoutes.get(session);
|
|
1016
|
+
if (route === void 0 || route.provider === "") return;
|
|
1017
|
+
foldUsage(this.ledger, Date.now(), route.provider, route.model || "unknown", this.totalsFrom(usage));
|
|
1018
|
+
this.scheduleFlush();
|
|
1019
|
+
}
|
|
1020
|
+
} catch {}
|
|
1021
|
+
}
|
|
1022
|
+
/** Normalize a provider TokenUsage into the ledger bucket (one call). */
|
|
1023
|
+
totalsFrom(usage) {
|
|
1024
|
+
const totals = emptyTotals();
|
|
1025
|
+
totals.inputTokens = usage.inputTokens;
|
|
1026
|
+
totals.outputTokens = usage.outputTokens;
|
|
1027
|
+
totals.cacheReadTokens = usage.cacheReadTokens ?? 0;
|
|
1028
|
+
totals.cacheWriteTokens = usage.cacheWriteTokens ?? 0;
|
|
1029
|
+
totals.reasoningTokens = usage.reasoningTokens ?? 0;
|
|
1030
|
+
totals.calls = 1;
|
|
1031
|
+
return totals;
|
|
1032
|
+
}
|
|
1033
|
+
async loadPersisted() {
|
|
1034
|
+
try {
|
|
1035
|
+
const [rawLedger, rawSnapshots] = await Promise.all([readFile(this.ledgerPath, "utf8").catch(() => void 0), readFile(this.snapshotsPath, "utf8").catch(() => void 0)]);
|
|
1036
|
+
if (rawLedger !== void 0) {
|
|
1037
|
+
this.ledger = deserializeLedger(JSON.parse(rawLedger));
|
|
1038
|
+
pruneLedger(this.ledger, localDateKey(Date.now()), this.options.retainDays);
|
|
1039
|
+
}
|
|
1040
|
+
if (rawSnapshots !== void 0) {
|
|
1041
|
+
const parsed = JSON.parse(rawSnapshots);
|
|
1042
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.providers === "object" && parsed.providers !== null) {
|
|
1043
|
+
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
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
} catch {}
|
|
1047
|
+
}
|
|
1048
|
+
scheduleFlush() {
|
|
1049
|
+
if (this.flushTimer !== void 0 || this.disposed) return;
|
|
1050
|
+
this.flushTimer = setTimeout(() => {
|
|
1051
|
+
this.flushTimer = void 0;
|
|
1052
|
+
this.flushLedger();
|
|
1053
|
+
}, FLUSH_DEBOUNCE_MS);
|
|
1054
|
+
}
|
|
1055
|
+
async flushLedger() {
|
|
1056
|
+
try {
|
|
1057
|
+
await mkdir(dirname(this.ledgerPath), { recursive: true });
|
|
1058
|
+
await writeJsonAtomic(this.ledgerPath, this.ledger);
|
|
1059
|
+
} catch {}
|
|
1060
|
+
}
|
|
1061
|
+
async persistSnapshots() {
|
|
1062
|
+
try {
|
|
1063
|
+
await mkdir(dirname(this.snapshotsPath), { recursive: true });
|
|
1064
|
+
await writeJsonAtomic(this.snapshotsPath, {
|
|
1065
|
+
version: 1,
|
|
1066
|
+
providers: Object.fromEntries(this.snapshots)
|
|
1067
|
+
});
|
|
1068
|
+
} catch {}
|
|
1069
|
+
}
|
|
1070
|
+
rearmPoll(delayMs) {
|
|
1071
|
+
if (this.disposed) return;
|
|
1072
|
+
if (this.pollTimer !== void 0) clearTimeout(this.pollTimer);
|
|
1073
|
+
this.pollTimer = setTimeout(() => {
|
|
1074
|
+
this.pollTimer = void 0;
|
|
1075
|
+
this.pollNow().finally(() => this.rearmPoll(Math.max(30, this.options.pollIntervalSec) * 1e3));
|
|
1076
|
+
}, delayMs);
|
|
1077
|
+
}
|
|
1078
|
+
/** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
|
|
1079
|
+
async pollNow() {
|
|
1080
|
+
if (this.pollInFlight || this.disposed) return;
|
|
1081
|
+
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);
|
|
1090
|
+
}
|
|
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
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
listProviderRoutes() {
|
|
1099
|
+
const routes = /* @__PURE__ */ new Map();
|
|
1100
|
+
const runtime = service(this.ctx, "llm");
|
|
1101
|
+
if (runtime !== void 0) try {
|
|
1102
|
+
for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, provider.name);
|
|
1103
|
+
for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName);
|
|
1104
|
+
} catch {}
|
|
1105
|
+
return [...routes].map(([id, displayName]) => ({
|
|
1106
|
+
id,
|
|
1107
|
+
displayName
|
|
1108
|
+
}));
|
|
1109
|
+
}
|
|
1110
|
+
async probeRoute(route, adapter) {
|
|
1111
|
+
const credential = await this.resolveCredential(route.id);
|
|
1112
|
+
const previous = this.snapshots.get(route.id);
|
|
1113
|
+
const snapshot = {
|
|
1114
|
+
provider: route.id,
|
|
1115
|
+
displayName: route.displayName || adapter.displayName,
|
|
1116
|
+
credential: credential.kind,
|
|
1117
|
+
supported: true,
|
|
1118
|
+
...previous?.balance !== void 0 ? { balance: previous.balance } : {},
|
|
1119
|
+
...previous?.plan !== void 0 ? { plan: previous.plan } : {},
|
|
1120
|
+
updatedAt: Date.now()
|
|
1121
|
+
};
|
|
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
|
+
const runProbe = async (kind) => {
|
|
1129
|
+
const half = adapter[kind];
|
|
1130
|
+
if (half === void 0) return void 0;
|
|
1131
|
+
try {
|
|
1132
|
+
const spec = half.build({ apiKey: credential.key });
|
|
1133
|
+
const response = await fetch(spec.url, {
|
|
1134
|
+
headers: spec.headers,
|
|
1135
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
1136
|
+
});
|
|
1137
|
+
const body = await response.json().catch(() => void 0);
|
|
1138
|
+
if (!response.ok) throw new Error(providerErrorMessage(response.status, body));
|
|
1139
|
+
const parsed = half.parse(response.status, body);
|
|
1140
|
+
if (parsed === void 0) throw new Error("unrecognized response shape");
|
|
1141
|
+
probed = true;
|
|
1142
|
+
const updatedAt = Date.now();
|
|
1143
|
+
if (kind === "balance") {
|
|
1144
|
+
const fact = parsed;
|
|
1145
|
+
return {
|
|
1146
|
+
currency: fact.currency,
|
|
1147
|
+
totalBalance: fact.totalBalance,
|
|
1148
|
+
updatedAt
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
const plan = parsed;
|
|
1152
|
+
return {
|
|
1153
|
+
planName: plan.planName,
|
|
1154
|
+
windows: plan.windows,
|
|
1155
|
+
updatedAt
|
|
1156
|
+
};
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
snapshot.error = error instanceof Error ? error.message.slice(0, 200) : String(error);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
const balance = await runProbe("balance");
|
|
1163
|
+
if (balance !== void 0) snapshot.balance = balance;
|
|
1164
|
+
const plan = await runProbe("plan");
|
|
1165
|
+
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";
|
|
1168
|
+
this.snapshots.set(route.id, snapshot);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Resolve the credential backing one route: pi-ai credential records
|
|
1172
|
+
* 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).
|
|
1175
|
+
*/
|
|
1176
|
+
async resolveCredential(provider) {
|
|
1177
|
+
const credentials = service(this.ctx, "credentials");
|
|
1178
|
+
if (credentials === void 0) return { kind: "none" };
|
|
1179
|
+
try {
|
|
1180
|
+
const record = await credentials.readRecord(credentialKey("llm-pi-ai", provider));
|
|
1181
|
+
if (record?.kind === "api-key" && typeof record.key === "string" && record.key !== "") return {
|
|
1182
|
+
kind: "api-key",
|
|
1183
|
+
key: record.key
|
|
1184
|
+
};
|
|
1185
|
+
if (record?.kind === "grant") return { kind: "oauth" };
|
|
1186
|
+
} catch {}
|
|
1187
|
+
try {
|
|
1188
|
+
const envName = this.piAiProfile(provider)?.apiKeyEnv ?? (provider === "deepseek" ? this.deepseekApiKeyEnv() : void 0);
|
|
1189
|
+
if (typeof envName === "string" && envName !== "") {
|
|
1190
|
+
const resolved = await credentials.resolve(credentialRef(envName));
|
|
1191
|
+
if (resolved?.value !== void 0 && resolved.value !== "") return {
|
|
1192
|
+
kind: "env",
|
|
1193
|
+
key: resolved.value
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
} catch {}
|
|
1197
|
+
return { kind: "none" };
|
|
1198
|
+
}
|
|
1199
|
+
/** The llm-pi-ai profile object for one provider route, when configured. */
|
|
1200
|
+
piAiProfile(provider) {
|
|
1201
|
+
return readNamespace(this.ctx, "llm-pi-ai")?.providers?.[provider];
|
|
1202
|
+
}
|
|
1203
|
+
/** The DeepSeek official adapter's credential reference name. */
|
|
1204
|
+
deepseekApiKeyEnv() {
|
|
1205
|
+
return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
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.
|
|
1210
|
+
*/
|
|
1211
|
+
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");
|
|
1249
|
+
try {
|
|
1250
|
+
pet?.announce({
|
|
1251
|
+
source: USAGE_ANNOUNCE_SOURCE,
|
|
1252
|
+
ttlMs: void 0,
|
|
1253
|
+
...announcement
|
|
1254
|
+
});
|
|
1255
|
+
} catch {}
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region src/host/loopback.ts
|
|
1260
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
1261
|
+
function isIPv4Loopback(v4) {
|
|
1262
|
+
const parts = v4.split(".");
|
|
1263
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1264
|
+
}
|
|
1265
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
1266
|
+
function isLoopbackAddress(address) {
|
|
1267
|
+
if (address === void 0) return false;
|
|
1268
|
+
const normalized = address.toLowerCase();
|
|
1269
|
+
if (normalized === "::1") return true;
|
|
1270
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
1271
|
+
return isIPv4Loopback(normalized);
|
|
1272
|
+
}
|
|
1273
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
1274
|
+
function isLoopbackHostname(hostname) {
|
|
1275
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
1276
|
+
return isIPv4Loopback(hostname);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
1280
|
+
* header, plus browser same-origin markers. The socket address is
|
|
1281
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
1282
|
+
*/
|
|
1283
|
+
function isLoopbackRequest(request) {
|
|
1284
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
1285
|
+
const host = request.headers.host;
|
|
1286
|
+
if (typeof host !== "string") return false;
|
|
1287
|
+
let hostUrl;
|
|
1288
|
+
try {
|
|
1289
|
+
hostUrl = new URL("http://" + host);
|
|
1290
|
+
} catch {
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
1294
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1295
|
+
const origin = request.headers.origin;
|
|
1296
|
+
if (origin === void 0) return true;
|
|
1297
|
+
try {
|
|
1298
|
+
return new URL(origin).host === hostUrl.host;
|
|
1299
|
+
} catch {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
//#endregion
|
|
1304
|
+
//#region src/host/http.ts
|
|
1305
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
1306
|
+
const JSON_HEADERS = {
|
|
1307
|
+
"content-type": "application/json; charset=utf-8",
|
|
1308
|
+
"referrer-policy": "no-referrer"
|
|
1309
|
+
};
|
|
1310
|
+
/**
|
|
1311
|
+
* Write one JSON response. Default headers are the family defaults
|
|
1312
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
1313
|
+
* override them.
|
|
1314
|
+
*/
|
|
1315
|
+
function writeJson(res, status, body, headers = {}) {
|
|
1316
|
+
const payload = JSON.stringify(body);
|
|
1317
|
+
res.writeHead(status, {
|
|
1318
|
+
...JSON_HEADERS,
|
|
1319
|
+
...headers
|
|
1320
|
+
});
|
|
1321
|
+
res.end(payload);
|
|
1322
|
+
}
|
|
1323
|
+
//#endregion
|
|
1324
|
+
//#region src/host/routes.ts
|
|
1325
|
+
/**
|
|
1326
|
+
* Loopback-fenced overview route: provider balances, plan quotas, and token
|
|
1327
|
+
* usage totals. Personal account data, so the loopback fence mirrors
|
|
1328
|
+
* dsh-perf's stats surface; the browser runs on the same machine.
|
|
1329
|
+
*/
|
|
1330
|
+
function makeUsageOverviewRoute(service) {
|
|
1331
|
+
return {
|
|
1332
|
+
kind: "exact",
|
|
1333
|
+
path: "/api/dsh-usage/overview",
|
|
1334
|
+
handler: async (req, res) => {
|
|
1335
|
+
if (!isLoopbackRequest(req)) {
|
|
1336
|
+
writeJson(res, 403, {
|
|
1337
|
+
ok: false,
|
|
1338
|
+
error: "forbidden: loopback-only"
|
|
1339
|
+
});
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
writeJson(res, 200, service.overview(), { "cache-control": "no-store" });
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Loopback-fenced manual refresh: forces one probe cycle now and answers
|
|
1348
|
+
* with the fresh overview.
|
|
1349
|
+
*/
|
|
1350
|
+
function makeUsageRefreshRoute(service) {
|
|
1351
|
+
return {
|
|
1352
|
+
kind: "exact",
|
|
1353
|
+
path: "/api/dsh-usage/refresh",
|
|
1354
|
+
handler: async (req, res) => {
|
|
1355
|
+
if (!isLoopbackRequest(req)) {
|
|
1356
|
+
writeJson(res, 403, {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
error: "forbidden: loopback-only"
|
|
1359
|
+
});
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (req.method !== "POST") {
|
|
1363
|
+
writeJson(res, 405, {
|
|
1364
|
+
ok: false,
|
|
1365
|
+
error: "method not allowed"
|
|
1366
|
+
});
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
try {
|
|
1370
|
+
await service.refresh();
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
writeJson(res, 500, {
|
|
1373
|
+
ok: false,
|
|
1374
|
+
error: error instanceof Error ? error.message : "refresh failed"
|
|
1375
|
+
});
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
writeJson(res, 200, service.overview(), { "cache-control": "no-store" });
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
//#endregion
|
|
1383
|
+
//#region src/index.ts
|
|
1384
|
+
const name = "dsh-usage";
|
|
1385
|
+
const inject = ["webServer"];
|
|
1386
|
+
const USAGE_SETTINGS_NAMESPACE = settingsNamespace("dsh-usage");
|
|
1387
|
+
const Config = z.object({
|
|
1388
|
+
enabled: z.boolean().default(true),
|
|
1389
|
+
pollIntervalSec: z.number().min(30).max(3600).default(60),
|
|
1390
|
+
bubbleMode: z.string().default("always"),
|
|
1391
|
+
retainDays: z.number().min(7).max(730).default(180)
|
|
1392
|
+
});
|
|
1393
|
+
function resolveConfig(config) {
|
|
1394
|
+
const bubbleMode = config?.bubbleMode === "change" || config?.bubbleMode === "off" ? config.bubbleMode : "always";
|
|
1395
|
+
return {
|
|
1396
|
+
enabled: config?.enabled ?? true,
|
|
1397
|
+
pollIntervalSec: typeof config?.pollIntervalSec === "number" ? config.pollIntervalSec : 60,
|
|
1398
|
+
bubbleMode,
|
|
1399
|
+
retainDays: typeof config?.retainDays === "number" ? config.retainDays : 180
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
|
|
1403
|
+
let source = () => config ?? {};
|
|
1404
|
+
let service;
|
|
1405
|
+
let disposeRoutes;
|
|
1406
|
+
const rearm = () => {
|
|
1407
|
+
const value = resolveConfig(source());
|
|
1408
|
+
if (!value.enabled) {
|
|
1409
|
+
service?.stop();
|
|
1410
|
+
service = void 0;
|
|
1411
|
+
disposeRoutes?.();
|
|
1412
|
+
disposeRoutes = void 0;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
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 {}
|
|
1423
|
+
};
|
|
1424
|
+
} else service.applyOptions(value);
|
|
1425
|
+
};
|
|
1426
|
+
installSettingsSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
1427
|
+
setSource: (next) => {
|
|
1428
|
+
source = next;
|
|
1429
|
+
rearm();
|
|
1430
|
+
},
|
|
1431
|
+
onChange: rearm
|
|
1432
|
+
});
|
|
1433
|
+
ctx.effect(() => {
|
|
1434
|
+
rearm();
|
|
1435
|
+
return () => {
|
|
1436
|
+
disposeRoutes?.();
|
|
1437
|
+
service?.stop();
|
|
1438
|
+
service = void 0;
|
|
1439
|
+
};
|
|
1440
|
+
}, "dsh-usage: runtime");
|
|
1441
|
+
});
|
|
1442
|
+
//#endregion
|
|
1443
|
+
export { Config, USAGE_SETTINGS_NAMESPACE, apply, inject, name, resolveConfig };
|