@linxin666/dsh-usage 0.3.23 → 0.3.24
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 +5 -7
- package/README.zh.md +5 -7
- package/lib/client.js +744 -73
- package/lib/client.js.map +1 -1
- package/lib/index.js +171 -201
- package/lib/types/client/UsageSectionCard.d.ts +0 -1
- package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -4
- package/lib/types/client/sidebar-entry-core.d.ts +87 -0
- package/lib/types/client/sidebar-entry.d.ts +35 -0
- package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
- 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/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +28 -73
- package/lib/types/index.d.ts +0 -2
- package/package.json +4 -3
- package/src/client/UsageSectionCard.tsx +0 -14
- package/src/client/UsageSidebarPanel.tsx +196 -0
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +22 -1
- package/src/client/locales.ts +18 -8
- package/src/client/sidebar-entry-core.ts +321 -0
- package/src/client/sidebar-entry.ts +101 -0
- package/src/client/sidebar-panel-mount.tsx +113 -0
- package/src/client/usage.module.css +252 -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/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +65 -214
- package/src/index.ts +1 -6
package/lib/index.js
CHANGED
|
@@ -162,6 +162,10 @@ function toNum(value) {
|
|
|
162
162
|
function str(value) {
|
|
163
163
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
164
164
|
}
|
|
165
|
+
/** Clamp a provider percentage into the renderable 0-100 band. */
|
|
166
|
+
function clampPercent(value) {
|
|
167
|
+
return Math.max(0, Math.min(100, value));
|
|
168
|
+
}
|
|
165
169
|
/** Format a number to a fixed 2-decimal display string. */
|
|
166
170
|
function money(value) {
|
|
167
171
|
return value.toFixed(2);
|
|
@@ -294,7 +298,28 @@ const KIMI_CODING = {
|
|
|
294
298
|
}
|
|
295
299
|
}
|
|
296
300
|
};
|
|
297
|
-
/**
|
|
301
|
+
/**
|
|
302
|
+
* GLM Coding Plan quota windows, keyed by the provider's own `unit` code
|
|
303
|
+
* rather than by row position so an added or reordered limit cannot shift a
|
|
304
|
+
* window's meaning: 3 = the rolling 5-hour window, 6 = the weekly window,
|
|
305
|
+
* 5 = the monthly one. Two row kinds carry quota — `TOKENS_LIMIT` (token
|
|
306
|
+
* plans) and `CREDIT_LIMIT` (credit plans, whose exact ratio is
|
|
307
|
+
* `currentValue` / `usage`) — while `TIME_LIMIT` is the MCP request cap and
|
|
308
|
+
* is not a plan window at all. Auth is the RAW key without a Bearer prefix.
|
|
309
|
+
*/
|
|
310
|
+
const GLM_UNIT_KEYS = {
|
|
311
|
+
3: "5h",
|
|
312
|
+
5: "month",
|
|
313
|
+
6: "week"
|
|
314
|
+
};
|
|
315
|
+
/** Percent from a credit row's exact ratio, else the provider's own percentage. */
|
|
316
|
+
function glmCreditPercent(row) {
|
|
317
|
+
const used = toNum(row.currentValue);
|
|
318
|
+
const limit = toNum(row.usage);
|
|
319
|
+
if (used !== void 0 && limit !== void 0 && limit > 0) return clampPercent(used / limit * 100);
|
|
320
|
+
const percentage = toNum(row.percentage);
|
|
321
|
+
return percentage === void 0 ? void 0 : clampPercent(percentage);
|
|
322
|
+
}
|
|
298
323
|
function glmPlan(host, ids) {
|
|
299
324
|
return {
|
|
300
325
|
ids,
|
|
@@ -319,11 +344,17 @@ function glmPlan(host, ids) {
|
|
|
319
344
|
for (const entry of limits) {
|
|
320
345
|
if (typeof entry !== "object" || entry === null) continue;
|
|
321
346
|
const row = entry;
|
|
322
|
-
const
|
|
323
|
-
|
|
347
|
+
const kind = str(row.type);
|
|
348
|
+
if (kind === "TIME_LIMIT") continue;
|
|
349
|
+
const key = GLM_UNIT_KEYS[toNum(row.unit) ?? NaN];
|
|
350
|
+
if (key === void 0) continue;
|
|
351
|
+
if (kind !== "TOKENS_LIMIT" && kind !== "CREDIT_LIMIT") continue;
|
|
324
352
|
windows.push({
|
|
325
|
-
key
|
|
326
|
-
percent:
|
|
353
|
+
key,
|
|
354
|
+
percent: kind === "CREDIT_LIMIT" ? glmCreditPercent(row) : (() => {
|
|
355
|
+
const percentage = toNum(row.percentage);
|
|
356
|
+
return percentage === void 0 ? void 0 : clampPercent(percentage);
|
|
357
|
+
})(),
|
|
327
358
|
resetsAt: toIso(row.nextResetTime)
|
|
328
359
|
});
|
|
329
360
|
}
|
|
@@ -537,7 +568,7 @@ const PROVIDER_ADAPTERS = [
|
|
|
537
568
|
moonshotBalance("api.moonshot.ai", "USD", ["moonshotai"]),
|
|
538
569
|
KIMI_CODING,
|
|
539
570
|
glmPlan("open.bigmodel.cn", ["zai-coding-cn"]),
|
|
540
|
-
glmPlan("api.z.ai", ["zai-coding"]),
|
|
571
|
+
glmPlan("api.z.ai", ["zai", "zai-coding"]),
|
|
541
572
|
OPENCODE_GO,
|
|
542
573
|
minimaxPlan("api.minimaxi.com", ["minimax-cn"]),
|
|
543
574
|
minimaxPlan("api.minimax.io", ["minimax"]),
|
|
@@ -574,6 +605,34 @@ function providerErrorMessage(status, body) {
|
|
|
574
605
|
return `HTTP ${status}${message === void 0 ? "" : `: ${message.slice(0, 120)}`}`;
|
|
575
606
|
}
|
|
576
607
|
//#endregion
|
|
608
|
+
//#region src/core/provider-routes.ts
|
|
609
|
+
/**
|
|
610
|
+
* Alias folding for the enumerated provider routes. Kept in core because the
|
|
611
|
+
* rule is pure — the host enumerates routes and this decides which of two
|
|
612
|
+
* routes serving one adapter family survives.
|
|
613
|
+
* @module @linxin666/dsh-usage/core/provider-routes
|
|
614
|
+
*/
|
|
615
|
+
/**
|
|
616
|
+
* Drop dormant catalog aliases that shadow a live route of the same adapter
|
|
617
|
+
* family. The pi-ai catalog ships route keys the runtime serves nothing on
|
|
618
|
+
* until a profile declares them: for DeepSeek the catalog entry `deepseek`
|
|
619
|
+
* sits beside the llm-deepseek runtime route `deepseek-official`, and both
|
|
620
|
+
* resolve the same credential (the family's `apiKeyEnv` fallback), so probing
|
|
621
|
+
* and rendering both turns one account into two identical balance rows.
|
|
622
|
+
*
|
|
623
|
+
* A family keeps every live route it has — two configured profiles are two
|
|
624
|
+
* real accounts, not aliases. A route with no adapter, or one whose family
|
|
625
|
+
* has no live route at all, is returned untouched.
|
|
626
|
+
*/
|
|
627
|
+
function foldAliasRoutes(routes) {
|
|
628
|
+
return routes.filter((route) => {
|
|
629
|
+
if (route.live) return true;
|
|
630
|
+
const family = adapterFor(route.id);
|
|
631
|
+
if (family === void 0) return true;
|
|
632
|
+
return !routes.some((other) => other.live && adapterFor(other.id) === family);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
577
636
|
//#region src/core/pricing.ts
|
|
578
637
|
/** The published peak windows: weekday 09:00-12:00 and 14:00-18:00. */
|
|
579
638
|
const PEAK_WINDOWS = [{
|
|
@@ -856,112 +915,15 @@ function deserializeLedger(value) {
|
|
|
856
915
|
/**
|
|
857
916
|
* The dsh-usage host service: folds live session usage into the persistent
|
|
858
917
|
* ledger, probes each configured provider's balance/coding-plan endpoint on
|
|
859
|
-
* a poll cycle, and
|
|
860
|
-
*
|
|
861
|
-
* overview document.
|
|
918
|
+
* a poll cycle, and serves the current provider's strip-ready view inside
|
|
919
|
+
* the overview document. Secrets stay in the host process; the browser only
|
|
920
|
+
* ever sees the overview document.
|
|
862
921
|
* @module @linxin666/dsh-usage/host/usage-service
|
|
863
922
|
*/
|
|
864
|
-
/** Source tag the plugin stamps onto pet announcements. */
|
|
865
|
-
const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
|
|
866
923
|
/** Probe timeout per HTTP call. */
|
|
867
924
|
const PROBE_TIMEOUT_MS = 1e4;
|
|
868
925
|
/** Ledger flush debounce. */
|
|
869
926
|
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
927
|
/** Best-effort typed service read: absent services resolve to undefined at runtime. */
|
|
966
928
|
function service(ctx, name) {
|
|
967
929
|
try {
|
|
@@ -1018,7 +980,7 @@ var UsageService = class {
|
|
|
1018
980
|
spendWatch;
|
|
1019
981
|
/** Per-live-session route attribution (WeakMap: disposed sessions age out). */
|
|
1020
982
|
sessionRoutes = /* @__PURE__ */ new WeakMap();
|
|
1021
|
-
/** The most recent route seen this boot; the
|
|
983
|
+
/** The most recent route seen this boot; the sidebar strip and the header highlight follow it. */
|
|
1022
984
|
current = { source: "default" };
|
|
1023
985
|
sessionListenerDisposer;
|
|
1024
986
|
pollTimer;
|
|
@@ -1031,7 +993,6 @@ var UsageService = class {
|
|
|
1031
993
|
/** True once loadPersisted finished; nothing may overwrite the files before that. */
|
|
1032
994
|
loaded = false;
|
|
1033
995
|
disposed = false;
|
|
1034
|
-
lastSignature;
|
|
1035
996
|
/** Last prune guard: once per local day, and whenever retention shrinks. */
|
|
1036
997
|
lastPrune;
|
|
1037
998
|
constructor(ctx, options) {
|
|
@@ -1098,7 +1059,7 @@ var UsageService = class {
|
|
|
1098
1059
|
return {
|
|
1099
1060
|
updatedAt: Date.now(),
|
|
1100
1061
|
providers,
|
|
1101
|
-
current:
|
|
1062
|
+
current: this.currentView(),
|
|
1102
1063
|
usage: {
|
|
1103
1064
|
today: this.daySummary(todayKey),
|
|
1104
1065
|
days: days.map((date) => {
|
|
@@ -1126,6 +1087,33 @@ var UsageService = class {
|
|
|
1126
1087
|
}
|
|
1127
1088
|
};
|
|
1128
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* The strip-ready current-provider view: the resolved display name plus
|
|
1092
|
+
* today's ledger totals for the provider's adapter family (the route
|
|
1093
|
+
* itself when adapter-less). `today` is absent on a day without usage —
|
|
1094
|
+
* a strip about nothing is noise, not information.
|
|
1095
|
+
*/
|
|
1096
|
+
currentView() {
|
|
1097
|
+
const provider = this.current.provider;
|
|
1098
|
+
if (provider === void 0) return { ...this.current };
|
|
1099
|
+
let snapshot = this.snapshots.get(provider);
|
|
1100
|
+
if (snapshot === void 0) {
|
|
1101
|
+
const family = adapterFor(provider);
|
|
1102
|
+
if (family !== void 0) {
|
|
1103
|
+
for (const [id, candidate] of this.snapshots) if (adapterFor(id) === family) {
|
|
1104
|
+
snapshot = candidate;
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const displayName = snapshot?.displayName ?? this.routeDisplayName(provider);
|
|
1110
|
+
const today = this.familyUsageToday(provider);
|
|
1111
|
+
return {
|
|
1112
|
+
...this.current,
|
|
1113
|
+
displayName,
|
|
1114
|
+
...today.calls > 0 && totalTokens(today) > 0 ? { today } : {}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1129
1117
|
/** One local day aggregated per provider. */
|
|
1130
1118
|
daySummary(dateKey) {
|
|
1131
1119
|
const { totals, providers } = summarizeDays([this.ledger.days[dateKey] ?? {}]);
|
|
@@ -1135,17 +1123,17 @@ var UsageService = class {
|
|
|
1135
1123
|
providers
|
|
1136
1124
|
};
|
|
1137
1125
|
}
|
|
1138
|
-
/**
|
|
1139
|
-
|
|
1126
|
+
/**
|
|
1127
|
+
* Today's ledger totals for one provider's adapter family — the exact
|
|
1128
|
+
* route when adapter-less, the merged family otherwise, so aliased routes
|
|
1129
|
+
* of one account (the deepseek catalog id and its runtime alias) fold
|
|
1130
|
+
* together.
|
|
1131
|
+
*/
|
|
1132
|
+
familyUsageToday(provider) {
|
|
1140
1133
|
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();
|
|
1134
|
+
let merged = emptyTotals();
|
|
1135
|
+
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);
|
|
1136
|
+
return merged;
|
|
1149
1137
|
}
|
|
1150
1138
|
/**
|
|
1151
1139
|
* Accrue the official DeepSeek family's real spend from observed CNY
|
|
@@ -1177,7 +1165,7 @@ var UsageService = class {
|
|
|
1177
1165
|
/**
|
|
1178
1166
|
* The display name for a route key: the LLM runtime's first, then the
|
|
1179
1167
|
* adapter's, then the id itself — the snapshot may not exist for
|
|
1180
|
-
* adapter-less routes, yet the
|
|
1168
|
+
* adapter-less routes, yet the sidebar strip still needs a legible title.
|
|
1181
1169
|
*/
|
|
1182
1170
|
routeDisplayName(provider) {
|
|
1183
1171
|
return this.listProviderRoutes().find((entry) => entry.id === provider)?.displayName || adapterFor(provider)?.displayName || provider;
|
|
@@ -1326,7 +1314,7 @@ var UsageService = class {
|
|
|
1326
1314
|
}, delayMs);
|
|
1327
1315
|
}
|
|
1328
1316
|
/**
|
|
1329
|
-
* One poll cycle: enumerate routes, resolve credentials, probe
|
|
1317
|
+
* One poll cycle: enumerate routes, resolve credentials, probe.
|
|
1330
1318
|
* A call while a cycle is already running joins that cycle instead of
|
|
1331
1319
|
* returning immediately, so a manual refresh always waits for real probes.
|
|
1332
1320
|
*/
|
|
@@ -1350,7 +1338,6 @@ var UsageService = class {
|
|
|
1350
1338
|
if (this.disposed) return;
|
|
1351
1339
|
this.accrueDeepSeekSpend();
|
|
1352
1340
|
await this.persistSnapshots();
|
|
1353
|
-
this.announceCurrent();
|
|
1354
1341
|
} finally {
|
|
1355
1342
|
this.pollInFlight = false;
|
|
1356
1343
|
this.pollPromise = void 0;
|
|
@@ -1359,17 +1346,28 @@ var UsageService = class {
|
|
|
1359
1346
|
this.pollPromise = cycle;
|
|
1360
1347
|
return cycle;
|
|
1361
1348
|
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Every route the service probes and renders: the runtime's live providers
|
|
1351
|
+
* plus the configurable directory's catalog entries, then alias-folded so a
|
|
1352
|
+
* dormant catalog entry cannot shadow the live route of the same adapter
|
|
1353
|
+
* family (see foldAliasRoutes).
|
|
1354
|
+
*/
|
|
1362
1355
|
listProviderRoutes() {
|
|
1363
1356
|
const routes = /* @__PURE__ */ new Map();
|
|
1364
1357
|
const runtime = service(this.ctx, "llm");
|
|
1365
1358
|
if (runtime !== void 0) try {
|
|
1366
|
-
for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id,
|
|
1367
|
-
|
|
1359
|
+
for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, {
|
|
1360
|
+
id: provider.id,
|
|
1361
|
+
displayName: provider.name,
|
|
1362
|
+
live: true
|
|
1363
|
+
});
|
|
1364
|
+
for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, {
|
|
1365
|
+
id: provider.provider,
|
|
1366
|
+
displayName: provider.displayName,
|
|
1367
|
+
live: false
|
|
1368
|
+
});
|
|
1368
1369
|
} catch {}
|
|
1369
|
-
return [...routes
|
|
1370
|
-
id,
|
|
1371
|
-
displayName
|
|
1372
|
-
}));
|
|
1370
|
+
return foldAliasRoutes([...routes.values()]);
|
|
1373
1371
|
}
|
|
1374
1372
|
async probeRoute(route, adapter) {
|
|
1375
1373
|
const credential = await this.resolveCredential(route.id);
|
|
@@ -1486,66 +1484,6 @@ var UsageService = class {
|
|
|
1486
1484
|
deepseekApiKeyEnv() {
|
|
1487
1485
|
return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
|
|
1488
1486
|
}
|
|
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
1487
|
};
|
|
1550
1488
|
//#endregion
|
|
1551
1489
|
//#region src/host/loopback.ts
|
|
@@ -1593,6 +1531,33 @@ function isLoopbackRequest(request) {
|
|
|
1593
1531
|
}
|
|
1594
1532
|
}
|
|
1595
1533
|
//#endregion
|
|
1534
|
+
//#region src/host/pair-access.ts
|
|
1535
|
+
/**
|
|
1536
|
+
* Whether this request may enter the plugin's host routes.
|
|
1537
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
1538
|
+
* @param request - the incoming HTTP request.
|
|
1539
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
1540
|
+
*/
|
|
1541
|
+
function isPairedOrLoopbackAllowed(ctx, request) {
|
|
1542
|
+
if (isLoopbackRequest(request)) return true;
|
|
1543
|
+
const fromGet = typeof ctx.get === "function" ? ctx.get("remoteWebUiPairing", false) : void 0;
|
|
1544
|
+
return (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)?.isPairedDevice(request) === true;
|
|
1545
|
+
}
|
|
1546
|
+
function isPairingAccess(value) {
|
|
1547
|
+
return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
|
|
1548
|
+
}
|
|
1549
|
+
//#endregion
|
|
1550
|
+
//#region src/host/access.ts
|
|
1551
|
+
/**
|
|
1552
|
+
* Whether this request may read the usage overview or trigger a refresh.
|
|
1553
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
1554
|
+
* @param request - the incoming HTTP request.
|
|
1555
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
1556
|
+
*/
|
|
1557
|
+
function isUsageAllowed(ctx, request) {
|
|
1558
|
+
return isPairedOrLoopbackAllowed(ctx, request);
|
|
1559
|
+
}
|
|
1560
|
+
//#endregion
|
|
1596
1561
|
//#region src/host/http.ts
|
|
1597
1562
|
/** Family-default JSON response headers; callers may append or override. */
|
|
1598
1563
|
const JSON_HEADERS = {
|
|
@@ -1615,16 +1580,21 @@ function writeJson(res, status, body, headers = {}) {
|
|
|
1615
1580
|
//#endregion
|
|
1616
1581
|
//#region src/host/routes.ts
|
|
1617
1582
|
/**
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1583
|
+
* Overview route: provider balances, plan quotas, and token usage totals.
|
|
1584
|
+
* Personal account data behind the family trust fence — loopback always
|
|
1585
|
+
* passes, and a live paired-device cookie passes too when remote-web-ui is
|
|
1586
|
+
* loaded (issue #1592: the sidebar usage panel reads this from a paired LAN
|
|
1587
|
+
* browser); every unpaired LAN request keeps its 403.
|
|
1588
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
1589
|
+
* @param service - the usage service.
|
|
1590
|
+
* @returns the route.
|
|
1621
1591
|
*/
|
|
1622
|
-
function makeUsageOverviewRoute(service) {
|
|
1592
|
+
function makeUsageOverviewRoute(ctx, service) {
|
|
1623
1593
|
return {
|
|
1624
1594
|
kind: "exact",
|
|
1625
1595
|
path: "/api/dsh-usage/overview",
|
|
1626
1596
|
handler: async (req, res) => {
|
|
1627
|
-
if (!
|
|
1597
|
+
if (!isUsageAllowed(ctx, req)) {
|
|
1628
1598
|
writeJson(res, 403, {
|
|
1629
1599
|
ok: false,
|
|
1630
1600
|
error: "forbidden: loopback-only"
|
|
@@ -1636,15 +1606,18 @@ function makeUsageOverviewRoute(service) {
|
|
|
1636
1606
|
};
|
|
1637
1607
|
}
|
|
1638
1608
|
/**
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1609
|
+
* Manual refresh: forces one probe cycle now and answers with the fresh
|
|
1610
|
+
* overview. Same trust fence as the overview route.
|
|
1611
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
1612
|
+
* @param service - the usage service.
|
|
1613
|
+
* @returns the route.
|
|
1641
1614
|
*/
|
|
1642
|
-
function makeUsageRefreshRoute(service) {
|
|
1615
|
+
function makeUsageRefreshRoute(ctx, service) {
|
|
1643
1616
|
return {
|
|
1644
1617
|
kind: "exact",
|
|
1645
1618
|
path: "/api/dsh-usage/refresh",
|
|
1646
1619
|
handler: async (req, res) => {
|
|
1647
|
-
if (!
|
|
1620
|
+
if (!isUsageAllowed(ctx, req)) {
|
|
1648
1621
|
writeJson(res, 403, {
|
|
1649
1622
|
ok: false,
|
|
1650
1623
|
error: "forbidden: loopback-only"
|
|
@@ -1679,15 +1652,12 @@ const USAGE_SETTINGS_NAMESPACE = "dsh-usage";
|
|
|
1679
1652
|
const Config = z.object({
|
|
1680
1653
|
enabled: z.boolean().default(true),
|
|
1681
1654
|
pollIntervalSec: z.number().min(30).max(3600).default(60),
|
|
1682
|
-
bubbleMode: z.string().default("always"),
|
|
1683
1655
|
retainDays: z.number().min(7).max(730).default(180)
|
|
1684
1656
|
});
|
|
1685
1657
|
function resolveConfig(config) {
|
|
1686
|
-
const bubbleMode = config?.bubbleMode === "change" || config?.bubbleMode === "off" ? config.bubbleMode : "always";
|
|
1687
1658
|
return {
|
|
1688
1659
|
enabled: config?.enabled ?? true,
|
|
1689
1660
|
pollIntervalSec: typeof config?.pollIntervalSec === "number" ? config.pollIntervalSec : 60,
|
|
1690
|
-
bubbleMode,
|
|
1691
1661
|
retainDays: typeof config?.retainDays === "number" ? config.retainDays : 180
|
|
1692
1662
|
};
|
|
1693
1663
|
}
|
|
@@ -1711,7 +1681,7 @@ const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
|
|
|
1711
1681
|
const begin = () => {
|
|
1712
1682
|
if (service !== next) return;
|
|
1713
1683
|
next.start();
|
|
1714
|
-
const disposers = [makeUsageOverviewRoute(next), makeUsageRefreshRoute(next)].map((route) => ctx.webServer.register(route));
|
|
1684
|
+
const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)].map((route) => ctx.webServer.register(route));
|
|
1715
1685
|
disposeRoutes = () => {
|
|
1716
1686
|
for (const dispose of disposers) try {
|
|
1717
1687
|
dispose();
|
|
@@ -14,7 +14,6 @@ import type { UsageStoreInstance } from './usage-store.ts';
|
|
|
14
14
|
export interface UsageSettings {
|
|
15
15
|
enabled?: boolean;
|
|
16
16
|
pollIntervalSec?: number;
|
|
17
|
-
bubbleMode?: string;
|
|
18
17
|
}
|
|
19
18
|
/** The registration-side face the section's slot entry injects. */
|
|
20
19
|
export interface UsageSectionFace {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar usage panel (issue #1592): the usage body seated directly under the
|
|
3
|
+
* sidebar entry row. The row carries the panel's controls (refresh, collapse
|
|
4
|
+
* chevron), so this component renders only the content: expanded, plan
|
|
5
|
+
* providers show their quota windows and balance-only providers their
|
|
6
|
+
* remaining amount; collapsed, a one-line strip shows the current session
|
|
7
|
+
* provider's today usage (tokens, calls, and cost when priced) from the same
|
|
8
|
+
* /api/dsh-usage/overview document the settings section renders. The open
|
|
9
|
+
* state lives in the mount (persisted to localStorage) and arrives through
|
|
10
|
+
* the open face; polling runs at 10s expanded, 30s collapsed, and pauses
|
|
11
|
+
* while the page is hidden.
|
|
12
|
+
* @module @linxin666/dsh-usage/client/UsageSidebarPanel
|
|
13
|
+
*/
|
|
14
|
+
import { type ReactNode } from 'react';
|
|
15
|
+
import type { UsageStoreInstance } from './usage-store.ts';
|
|
16
|
+
/** Expanded-state face the owning mount supplies (subscribe + current value). */
|
|
17
|
+
export interface OpenStateSource {
|
|
18
|
+
subscribe(listener: () => void): () => void;
|
|
19
|
+
isOpen(): boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Props of the sidebar panel (the apply body supplies the store and poller). */
|
|
22
|
+
export interface UsageSidebarPanelProps {
|
|
23
|
+
store: UsageStoreInstance;
|
|
24
|
+
/** Fetch one overview now. */
|
|
25
|
+
poll: () => void;
|
|
26
|
+
/** Expanded state owned by the mount; false renders nothing and pauses polling. */
|
|
27
|
+
open: OpenStateSource;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Render the sidebar usage panel body.
|
|
31
|
+
* @param props - store, the poll callback, and the mount's open-state face.
|
|
32
|
+
* @returns the panel content, or null while collapsed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function UsageSidebarPanel(props: UsageSidebarPanelProps): ReactNode;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
3
|
+
* plugin bundle.
|
|
4
|
+
*
|
|
5
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
6
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
7
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
8
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
9
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
10
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
11
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
12
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
13
|
+
* the count grows with the number of installed plugins.
|
|
14
|
+
*
|
|
15
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
16
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
17
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
18
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
19
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
20
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
21
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
22
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
23
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
24
|
+
* observer, cancels the pending frame and releases its records.
|
|
25
|
+
*
|
|
26
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
27
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
28
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
29
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
30
|
+
* others.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
34
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
35
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
36
|
+
*/
|
|
37
|
+
export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* Subscribe to body-level childList mutations.
|
|
40
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
41
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
42
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
43
|
+
* the last one).
|
|
44
|
+
*/
|
|
45
|
+
export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
|
|
@@ -46,10 +46,6 @@ export declare const zh: {
|
|
|
46
46
|
'usage.config.title': string;
|
|
47
47
|
'usage.config.enabled': string;
|
|
48
48
|
'usage.config.pollIntervalSec': string;
|
|
49
|
-
'usage.config.bubbleMode': string;
|
|
50
|
-
'usage.config.bubbleMode.always': string;
|
|
51
|
-
'usage.config.bubbleMode.change': string;
|
|
52
|
-
'usage.config.bubbleMode.off': string;
|
|
53
49
|
'usage.bank.title': string;
|
|
54
50
|
'usage.bank.hint': string;
|
|
55
51
|
'usage.bank.noUsage': string;
|
|
@@ -60,6 +56,15 @@ export declare const zh: {
|
|
|
60
56
|
'usage.bank.save': string;
|
|
61
57
|
'usage.bank.share': string;
|
|
62
58
|
'usage.bank.drawError': string;
|
|
59
|
+
'usage.sidebar.entry.label': string;
|
|
60
|
+
'usage.sidebar.entry.tooltip': string;
|
|
61
|
+
'usage.sidebar.toggle.collapse': string;
|
|
62
|
+
'usage.sidebar.toggle.expand': string;
|
|
63
|
+
'usage.sidebar.empty': string;
|
|
64
|
+
'usage.sidebar.error': string;
|
|
65
|
+
'usage.sidebar.loading': string;
|
|
66
|
+
'usage.sidebar.balanceLeft': string;
|
|
67
|
+
'usage.sidebar.todayUsage': string;
|
|
63
68
|
};
|
|
64
69
|
/** English mirror; every zh key present. */
|
|
65
70
|
export declare const en: Record<UsageKey, string>;
|