@jxgame2020/dsh-token-quota 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -23,6 +23,10 @@ import { LlmError } from "@deepseek-ai/dsh-llm";
23
23
  const TOKEN_QUOTA_NAMESPACE = "token-quota";
24
24
  /** Stable machine code thrown from `agent/request` when a model is over its daily cap. */
25
25
  const TOKEN_QUOTA_EXCEEDED_CODE = "TOKEN_QUOTA_EXCEEDED";
26
+ const TOKEN_QUOTA_DEFAULT_BALANCE = {
27
+ enabled: true,
28
+ pollMinutes: 5
29
+ };
26
30
  /** Default music settings: silent until the user flips the panel switch. */
27
31
  const TOKEN_QUOTA_DEFAULT_MUSIC = {
28
32
  enabled: false,
@@ -236,7 +240,11 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
236
240
  "pentatonic"
237
241
  ]).default(TOKEN_QUOTA_DEFAULT_MUSIC.style),
238
242
  onlyCurrentSession: z.boolean().default(TOKEN_QUOTA_DEFAULT_MUSIC.onlyCurrentSession)
239
- }).default({ ...TOKEN_QUOTA_DEFAULT_MUSIC })
243
+ }).default({ ...TOKEN_QUOTA_DEFAULT_MUSIC }),
244
+ balance: z.object({
245
+ enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_BALANCE.enabled),
246
+ pollMinutes: z.number().min(1).max(120).default(TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes)
247
+ }).default({ ...TOKEN_QUOTA_DEFAULT_BALANCE })
240
248
  });
241
249
  /** Two-digit zero-pad helper. */
242
250
  function pad2(value) {
@@ -274,6 +282,17 @@ function usageTokens(usage) {
274
282
  * Mount it beside the other rows (`@jxgame2020/dsh-token-quota`) and write
275
283
  * per-model limits through the `token-quota` settings namespace.
276
284
  */
285
+ /**
286
+ * Providers the host can query for account balance, keyed by route key. Each
287
+ * entry names the credential reference (environment-variable name), the
288
+ * balance endpoint base, and the optional `*_BASE_URL` override variable.
289
+ * Directory providers outside this map render as "not integrated for query".
290
+ */
291
+ const BALANCE_SOURCES = { deepseek: {
292
+ keyEnv: "DEEPSEEK_API_KEY",
293
+ baseURL: "https://api.deepseek.com",
294
+ baseEnv: "DEEPSEEK_BASE_URL"
295
+ } };
277
296
  var TokenQuotaService = class TokenQuotaService extends Service {
278
297
  static Config = z.object({ storagePath: z.string().default("") });
279
298
  storagePath;
@@ -295,7 +314,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
295
314
  dimWhenIdle: false,
296
315
  music: { ...TOKEN_QUOTA_DEFAULT_MUSIC },
297
316
  order: [],
298
- notes: []
317
+ notes: [],
318
+ balance: { ...TOKEN_QUOTA_DEFAULT_BALANCE }
299
319
  });
300
320
  /** Whether update checks are enabled (mirrors the settings document). */
301
321
  checkUpdates = true;
@@ -305,6 +325,14 @@ var TokenQuotaService = class TokenQuotaService extends Service {
305
325
  upgradeError = null;
306
326
  /** Timer for the periodic update check. */
307
327
  updateTimer;
328
+ /** Whether the account balance is enabled (mirrors the settings document). */
329
+ balanceEnabled = TOKEN_QUOTA_DEFAULT_BALANCE.enabled;
330
+ /** Cached per-provider account balances, served in the snapshot. */
331
+ balances = /* @__PURE__ */ new Map();
332
+ /** Timer for the periodic balance poll. */
333
+ balanceTimer;
334
+ /** In-flight balance refreshes by provider, to coalesce overlapping triggers. */
335
+ balanceFetches = /* @__PURE__ */ new Map();
308
336
  /** In-flight update check, to coalesce the periodic and manual triggers. */
309
337
  upgradeCheck;
310
338
  /** Per-session folded model key from the latest `request/header`. */
@@ -323,6 +351,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
323
351
  disposeRouteCheck;
324
352
  /** Disposer for the optional clear-log route (`POST /token-quota/clear-log`). */
325
353
  disposeRouteClear;
354
+ /** Disposer for the optional balance-refresh route (`POST /token-quota/refresh-balance`). */
355
+ disposeRouteBalance;
326
356
  /** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
327
357
  disposeRouteEvents;
328
358
  /** Live SSE subscribers receiving streamed music actions. */
@@ -349,6 +379,10 @@ var TokenQuotaService = class TokenQuotaService extends Service {
349
379
  checkUpdates: doc.checkUpdates ?? true,
350
380
  dimWhenIdle: doc.dimWhenIdle ?? false,
351
381
  order: Array.isArray(doc.order) ? doc.order : [],
382
+ balance: {
383
+ enabled: doc.balance?.enabled ?? TOKEN_QUOTA_DEFAULT_BALANCE.enabled,
384
+ pollMinutes: typeof doc.balance?.pollMinutes === "number" ? Math.max(1, Math.min(120, Math.round(doc.balance.pollMinutes))) : TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes
385
+ },
352
386
  notes: Array.isArray(doc.notes) ? doc.notes.slice(0, 5).flatMap((note) => {
353
387
  if (typeof note.id !== "string") return [];
354
388
  return [{
@@ -385,6 +419,12 @@ var TokenQuotaService = class TokenQuotaService extends Service {
385
419
  this.monitored = doc.monitored !== void 0 && doc.monitored.length > 0 ? new Set(doc.monitored) : void 0;
386
420
  this.checkUpdates = doc.checkUpdates;
387
421
  this.syncUpdateChecking();
422
+ const balanceEnabled = doc.balance?.enabled ?? TOKEN_QUOTA_DEFAULT_BALANCE.enabled;
423
+ const turnedOn = balanceEnabled && !this.balanceEnabled;
424
+ this.balanceEnabled = balanceEnabled;
425
+ if (!balanceEnabled) this.balances.clear();
426
+ this.syncBalancePolling();
427
+ if (turnedOn) this.refreshAllBalances();
388
428
  if (doc.reset !== void 0 && typeof doc.reset === "object" && doc.reset !== null && typeof doc.reset.offsetHours === "number" && typeof doc.reset.hour === "number" && typeof doc.reset.minute === "number") {
389
429
  const configured = doc.reset;
390
430
  this.reset = {
@@ -415,6 +455,8 @@ var TokenQuotaService = class TokenQuotaService extends Service {
415
455
  agent.ctx.on("agent/request", async (payload, next) => this.onRequest(payload, next), { prepend: true });
416
456
  });
417
457
  this.syncUpdateChecking();
458
+ this.syncBalancePolling();
459
+ if (this.balanceEnabled) this.refreshAllBalances();
418
460
  ctx.effect(() => () => {
419
461
  this.disposeLocal();
420
462
  }, "token-quota: flush on unload");
@@ -461,6 +503,39 @@ var TokenQuotaService = class TokenQuotaService extends Service {
461
503
  });
462
504
  }
463
505
  });
506
+ this.disposeRouteBalance = server.register({
507
+ kind: "exact",
508
+ path: "/token-quota/refresh-balance",
509
+ handler: (req, res) => {
510
+ if (req.method !== "POST") {
511
+ res.writeHead(405);
512
+ res.end();
513
+ return;
514
+ }
515
+ const chunks = [];
516
+ req.on("data", (chunk) => {
517
+ chunks.push(Buffer.from(chunk));
518
+ });
519
+ req.on("end", () => {
520
+ let provider;
521
+ try {
522
+ const raw = Buffer.concat(chunks).toString("utf8");
523
+ if (raw !== "") {
524
+ const parsed = JSON.parse(raw);
525
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.provider === "string") provider = parsed.provider;
526
+ }
527
+ } catch {}
528
+ (provider !== void 0 && provider in BALANCE_SOURCES ? this.refreshProviderBalance(provider) : this.refreshAllBalances()).finally(() => {
529
+ const body = JSON.stringify(this.readSnapshot());
530
+ res.writeHead(200, {
531
+ "content-type": "application/json",
532
+ "cache-control": "no-store"
533
+ });
534
+ res.end(body);
535
+ });
536
+ });
537
+ }
538
+ });
464
539
  this.disposeRouteClear = server.register({
465
540
  kind: "exact",
466
541
  path: "/token-quota/clear-log",
@@ -544,7 +619,7 @@ var TokenQuotaService = class TokenQuotaService extends Service {
544
619
  return this.upgradeCheck;
545
620
  }
546
621
  async doRefreshUpgrade() {
547
- const current = "0.1.35";
622
+ const current = "0.1.36";
548
623
  let latest;
549
624
  try {
550
625
  const response = await fetch(REGISTRY_LATEST_URL, {
@@ -595,6 +670,119 @@ var TokenQuotaService = class TokenQuotaService extends Service {
595
670
  this.syncUpdateChecking();
596
671
  }, UPDATE_CHECK_INTERVAL_MS);
597
672
  }
673
+ /**
674
+ * Start or stop the periodic balance poll to match the current setting. The
675
+ * interval comes from the settings document; a poll chain re-arms itself
676
+ * after each refresh. No fetch happens here — callers decide (startup and
677
+ * the enabled-transition in `onChange` fetch immediately).
678
+ */
679
+ syncBalancePolling() {
680
+ if (this.balanceTimer !== void 0) {
681
+ clearTimeout(this.balanceTimer);
682
+ this.balanceTimer = void 0;
683
+ }
684
+ if (!this.balanceEnabled) return;
685
+ const minutes = this.settingsSource().balance?.pollMinutes ?? TOKEN_QUOTA_DEFAULT_BALANCE.pollMinutes;
686
+ const schedule = () => {
687
+ if (!this.balanceEnabled) return;
688
+ this.balanceTimer = setTimeout(() => {
689
+ this.balanceTimer = void 0;
690
+ this.refreshAllBalances().finally(schedule);
691
+ }, Math.max(6e4, minutes * 6e4));
692
+ };
693
+ schedule();
694
+ }
695
+ /** Refresh every provider the host knows how to query. */
696
+ refreshAllBalances() {
697
+ return Promise.all(Object.keys(BALANCE_SOURCES).map((provider) => this.refreshProviderBalance(provider))).then(() => void 0);
698
+ }
699
+ /**
700
+ * Refresh one provider's cached account balance. The API key is resolved
701
+ * through the harness credential seam (`credentials.resolve`) with the
702
+ * environment as a fallback, so the Models page key is used with nothing to
703
+ * type. A failed fetch keeps the previous value and marks it as an error.
704
+ */
705
+ async refreshProviderBalance(provider) {
706
+ const source = BALANCE_SOURCES[provider];
707
+ if (source === void 0) return;
708
+ const existing = this.balanceFetches.get(provider);
709
+ if (existing !== void 0) return existing;
710
+ const run = async () => {
711
+ if (!this.balanceEnabled) return;
712
+ const credentials = this.ctx.get("credentials");
713
+ let key;
714
+ if (credentials !== void 0) try {
715
+ key = (await credentials.resolve(source.keyEnv))?.value;
716
+ } catch {}
717
+ if (key === void 0 || key === "") key = process.env[source.keyEnv];
718
+ if (key === void 0 || key === "") {
719
+ this.balances.set(provider, {
720
+ provider,
721
+ currency: "CNY",
722
+ total: 0,
723
+ isAvailable: false,
724
+ fetchedAt: Date.now(),
725
+ status: "unconfigured"
726
+ });
727
+ return;
728
+ }
729
+ const base = source.baseEnv !== void 0 ? process.env[source.baseEnv] ?? source.baseURL : source.baseURL;
730
+ try {
731
+ const controller = new AbortController();
732
+ const timer = setTimeout(() => {
733
+ controller.abort();
734
+ }, 1e4);
735
+ let res;
736
+ try {
737
+ res = await fetch(`${base}/user/balance`, {
738
+ headers: {
739
+ authorization: `Bearer ${key}`,
740
+ accept: "application/json"
741
+ },
742
+ signal: controller.signal
743
+ });
744
+ } finally {
745
+ clearTimeout(timer);
746
+ }
747
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
748
+ const data = await res.json();
749
+ const first = Array.isArray(data?.balance_infos) ? data.balance_infos[0] : void 0;
750
+ const num = (value) => {
751
+ const n = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
752
+ return Number.isFinite(n) ? n : 0;
753
+ };
754
+ this.balances.set(provider, {
755
+ provider,
756
+ currency: typeof first?.currency === "string" && first.currency !== "" ? first.currency : "CNY",
757
+ total: num(first?.total_balance),
758
+ granted: num(first?.granted_balance),
759
+ toppedUp: num(first?.topped_up_balance),
760
+ isAvailable: data?.is_available !== false,
761
+ fetchedAt: Date.now(),
762
+ status: "ok"
763
+ });
764
+ } catch (error) {
765
+ const previous = this.balances.get(provider);
766
+ const next = {
767
+ provider,
768
+ currency: previous?.currency ?? "CNY",
769
+ total: previous?.total ?? 0,
770
+ isAvailable: false,
771
+ fetchedAt: previous?.fetchedAt ?? Date.now(),
772
+ status: "error",
773
+ error: error instanceof Error ? error.message : String(error)
774
+ };
775
+ if (previous?.granted !== void 0) next.granted = previous.granted;
776
+ if (previous?.toppedUp !== void 0) next.toppedUp = previous.toppedUp;
777
+ this.balances.set(provider, next);
778
+ }
779
+ };
780
+ const promise = run().finally(() => {
781
+ this.balanceFetches.delete(provider);
782
+ });
783
+ this.balanceFetches.set(provider, promise);
784
+ return promise;
785
+ }
598
786
  /** Read the full per-cycle usage history (log dialog data). */
599
787
  readLog() {
600
788
  this.rollCycleIfNeeded();
@@ -654,12 +842,14 @@ var TokenQuotaService = class TokenQuotaService extends Service {
654
842
  });
655
843
  }
656
844
  entries.sort((left, right) => left.key.localeCompare(right.key));
657
- return {
845
+ const snapshot = {
658
846
  day: this.cycle,
659
847
  entries,
660
848
  upgrade: this.upgrade,
661
849
  upgradeError: this.upgradeError
662
850
  };
851
+ if (this.balanceEnabled && this.balances.size > 0) snapshot.balances = [...this.balances.values()];
852
+ return snapshot;
663
853
  }
664
854
  /** Today's used tokens for one model key, or `0`. */
665
855
  usedToday(key) {
@@ -896,6 +1086,11 @@ var TokenQuotaService = class TokenQuotaService extends Service {
896
1086
  if (this.disposeRouteLog !== void 0) this.disposeRouteLog();
897
1087
  if (this.disposeRouteCheck !== void 0) this.disposeRouteCheck();
898
1088
  if (this.disposeRouteClear !== void 0) this.disposeRouteClear();
1089
+ if (this.disposeRouteBalance !== void 0) this.disposeRouteBalance();
1090
+ if (this.balanceTimer !== void 0) {
1091
+ clearTimeout(this.balanceTimer);
1092
+ this.balanceTimer = void 0;
1093
+ }
899
1094
  if (this.disposeRouteEvents !== void 0) this.disposeRouteEvents();
900
1095
  for (const res of this.musicClients) try {
901
1096
  res.end();
@@ -24,6 +24,10 @@ export interface TokenQuotaPanelInjected {
24
24
  setModelOrder: (order: string[]) => void;
25
25
  /** Persist the floating scratchpad notes (content, title, placement). */
26
26
  saveNotes: (notes: TokenQuotaNote[]) => void;
27
+ /** Persist the account-balance display settings. */
28
+ setBalanceSettings: (enabled: boolean, pollMinutes: number) => void;
29
+ /** Ask the Host to refresh account balances (one provider, or all). */
30
+ refreshBalance: (provider?: string) => void;
27
31
  /** Toggle the live music output (requires a user gesture to start audio). */
28
32
  setMusicEnabled: (enabled: boolean) => void;
29
33
  /** Change the live-music master volume 0..1. */
@@ -44,5 +48,5 @@ export type TokenQuotaPanelComponentProps = PropsRuntime<'shell.overlay'> & Prop
44
48
  * @param props - composed slot props.
45
49
  * @returns the panel element tree.
46
50
  */
47
- export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, saveNotes, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
51
+ export declare function TokenQuotaPanel({ t, load, setLimit, selectModel, setMonitored, setOnFull, setReset, setCheckUpdates, checkUpdatesNow, clearLogBefore, setDimWhenIdle, setModelOrder, saveNotes, setBalanceSettings, refreshBalance, setMusicEnabled, setMusicVolume, setMusicStyle, setMusicOnlyCurrentSession, useStore, actions, useSessions, }: TokenQuotaPanelComponentProps): import("react").JSX.Element;
48
52
  //# sourceMappingURL=TokenQuotaPanel.d.ts.map