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