@zhangferry-dev/tokendash 1.6.1 → 1.6.2

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.
Files changed (61) hide show
  1. package/README.md +146 -83
  2. package/dist/client/assets/index-Bw503sNp.css +1 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/daemon.cjs +3306 -0
  5. package/dist/daemon.cjs.map +7 -0
  6. package/dist/electron-server.cjs +1019 -28
  7. package/dist/electron-server.cjs.map +4 -4
  8. package/dist/server/ccusage.d.ts +7 -0
  9. package/dist/server/ccusage.js +69 -0
  10. package/dist/server/daemon.d.ts +12 -0
  11. package/dist/server/daemon.js +176 -0
  12. package/dist/server/index.js +22 -11
  13. package/dist/server/insightsCalculator.d.ts +15 -0
  14. package/dist/server/insightsCalculator.js +276 -0
  15. package/dist/server/quota/adapter.d.ts +47 -0
  16. package/dist/server/quota/adapter.js +41 -0
  17. package/dist/server/quota/adapters/claude.d.ts +2 -0
  18. package/dist/server/quota/adapters/claude.js +124 -0
  19. package/dist/server/quota/adapters/codex.d.ts +2 -0
  20. package/dist/server/quota/adapters/codex.js +188 -0
  21. package/dist/server/quota/adapters/glm.d.ts +2 -0
  22. package/dist/server/quota/adapters/glm.js +133 -0
  23. package/dist/server/quota/adapters/kimi.d.ts +2 -0
  24. package/dist/server/quota/adapters/kimi.js +184 -0
  25. package/dist/server/quota/adapters/minimax.d.ts +2 -0
  26. package/dist/server/quota/adapters/minimax.js +77 -0
  27. package/dist/server/quota/cache.d.ts +20 -0
  28. package/dist/server/quota/cache.js +44 -0
  29. package/dist/server/quota/credentialsFile.d.ts +13 -0
  30. package/dist/server/quota/credentialsFile.js +23 -0
  31. package/dist/server/quota/helpers.d.ts +39 -0
  32. package/dist/server/quota/helpers.js +93 -0
  33. package/dist/server/quota/index.d.ts +5 -0
  34. package/dist/server/quota/index.js +23 -0
  35. package/dist/server/quota/quotaService.d.ts +37 -0
  36. package/dist/server/quota/quotaService.js +141 -0
  37. package/dist/server/quota/schemas.d.ts +358 -0
  38. package/dist/server/quota/schemas.js +53 -0
  39. package/dist/server/quota/types.d.ts +65 -0
  40. package/dist/server/quota/types.js +10 -0
  41. package/dist/server/routes/api.js +15 -0
  42. package/dist/server/routes/insights.d.ts +2 -0
  43. package/dist/server/routes/insights.js +155 -0
  44. package/package.json +6 -10
  45. package/resources/entitlements.mac.plist +10 -0
  46. package/resources/icon-1024.png +0 -0
  47. package/resources/icon.icns +0 -0
  48. package/resources/icon.png +0 -0
  49. package/resources/product_menu.png +0 -0
  50. package/resources/readme-hero.png +0 -0
  51. package/dist/client/assets/index-_yA9tOzZ.css +0 -1
  52. package/electron/main.cjs +0 -516
  53. package/electron/npmSync.cjs +0 -62
  54. package/electron/preload.cjs +0 -36
  55. package/electron/serverReuse.cjs +0 -59
  56. package/electron/trayBadge.cjs +0 -27
  57. package/electron/trayHelper +0 -0
  58. package/electron/trayHelper.swift +0 -152
  59. package/electron/updateService.cjs +0 -220
  60. package/electron-builder.yml +0 -20
  61. /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const minimaxAdapter: QuotaAdapter;
@@ -0,0 +1,77 @@
1
+ import { QuotaError, baseSnapshot } from '../adapter.js';
2
+ import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromCounts, unixToIso } from '../helpers.js';
3
+ import { readStoredCredential } from '../credentialsFile.js';
4
+ export const minimaxAdapter = {
5
+ provider: 'minimax',
6
+ displayName: 'MiniMax Coding Plan',
7
+ async isConfigured() {
8
+ return !!resolveCredential();
9
+ },
10
+ async fetch() {
11
+ const cred = resolveCredential();
12
+ if (!cred) {
13
+ throw new QuotaError({ state: 'not_configured', message: 'set MINIMAX_API_KEY (Subscription Key)' });
14
+ }
15
+ let data;
16
+ try {
17
+ data = (await fetchJsonWithTimeout(`${cred.base}/v1/token_plan/remains`, {
18
+ headers: {
19
+ Authorization: `Bearer ${cred.key}`,
20
+ 'Content-Type': 'application/json',
21
+ },
22
+ }));
23
+ }
24
+ catch (err) {
25
+ throw classifyFetchError(err);
26
+ }
27
+ if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
28
+ throw new QuotaError({
29
+ state: 'upstream_unavailable',
30
+ message: data.base_resp.status_msg || `MiniMax error ${data.base_resp.status_code}`,
31
+ });
32
+ }
33
+ const windows = [];
34
+ for (const m of data.model_remains ?? []) {
35
+ const model = m.model_name ?? 'MiniMax';
36
+ const intervalTotal = m.current_interval_total_count ?? 0;
37
+ const intervalRemaining = m.current_interval_usage_count ?? 0;
38
+ // used = total - remaining (the "*_usage_count" fields are misnamed).
39
+ if (intervalTotal > 0) {
40
+ windows.push(windowFromCounts(`minimax_5h_${model}`, `5-Hour · ${model}`, Math.max(0, intervalTotal - intervalRemaining), intervalTotal, { durationMins: 300, resetsAt: unixToIso(m.end_time) }));
41
+ }
42
+ const weeklyTotal = m.current_weekly_total_count ?? 0;
43
+ const weeklyRemaining = m.current_weekly_usage_count ?? 0;
44
+ if (weeklyTotal > 0) {
45
+ windows.push(windowFromCounts(`minimax_weekly_${model}`, `Weekly · ${model}`, Math.max(0, weeklyTotal - weeklyRemaining), weeklyTotal, { durationMins: 10080 }));
46
+ }
47
+ }
48
+ const snap = baseSnapshot('minimax', 'MiniMax Coding Plan', { windows });
49
+ return { ...snap, status: { state: 'ok' } };
50
+ },
51
+ };
52
+ function classifyFetchError(err) {
53
+ if (err instanceof HttpError) {
54
+ const c = classifyHttpError(err);
55
+ return new QuotaError(c);
56
+ }
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
59
+ }
60
+ function resolveCredential() {
61
+ // 0. Key entered in-app (via the credential sheet) — highest priority.
62
+ const stored = readStoredCredential('minimax');
63
+ if (stored) {
64
+ const region = (process.env.MINIMAX_REGION || '').toLowerCase();
65
+ const base = stored.baseUrl || (region === 'cn' ? 'https://www.minimaxi.com' : 'https://www.minimax.io');
66
+ return { key: stored.apiKey, base };
67
+ }
68
+ const key = process.env.MINIMAX_API_KEY || process.env.MINIMAX_SUBSCRIPTION_KEY;
69
+ if (!key)
70
+ return null;
71
+ // minimax.io = global, minimaxi.com = China.
72
+ const region = (process.env.MINIMAX_REGION || '').toLowerCase();
73
+ const base = region === 'cn'
74
+ ? (process.env.MINIMAX_BASE_URL || 'https://www.minimaxi.com')
75
+ : (process.env.MINIMAX_BASE_URL || 'https://www.minimax.io');
76
+ return { key, base };
77
+ }
@@ -0,0 +1,20 @@
1
+ import type { QuotaSnapshot } from './types.js';
2
+ /**
3
+ * Per-provider quota cache.
4
+ *
5
+ * Keeps the last successful snapshot so a transient refresh failure returns
6
+ * stale-but-useful data (marked freshness "stale") instead of erasing it.
7
+ * The cache is in-memory only — quota snapshots are live and short-lived,
8
+ * so disk persistence (unlike the usage cache) adds no value.
9
+ */
10
+ export declare class QuotaCache {
11
+ private readonly ttlMs;
12
+ private readonly store;
13
+ constructor(ttlMs?: number);
14
+ /** Fresh cached snapshot, or null if expired / absent. */
15
+ getFresh(provider: string): QuotaSnapshot | null;
16
+ /** Last successful snapshot regardless of TTL (for stale-while-revalidate). */
17
+ getStale(provider: string): QuotaSnapshot | null;
18
+ set(snapshot: QuotaSnapshot): void;
19
+ clear(provider?: string): void;
20
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Per-provider quota cache.
3
+ *
4
+ * Keeps the last successful snapshot so a transient refresh failure returns
5
+ * stale-but-useful data (marked freshness "stale") instead of erasing it.
6
+ * The cache is in-memory only — quota snapshots are live and short-lived,
7
+ * so disk persistence (unlike the usage cache) adds no value.
8
+ */
9
+ export class QuotaCache {
10
+ ttlMs;
11
+ store = new Map();
12
+ constructor(ttlMs = 60_000) {
13
+ this.ttlMs = ttlMs;
14
+ }
15
+ /** Fresh cached snapshot, or null if expired / absent. */
16
+ getFresh(provider) {
17
+ const entry = this.store.get(provider);
18
+ if (!entry)
19
+ return null;
20
+ if (Date.now() > entry.expiresAt)
21
+ return null;
22
+ return { ...entry.snapshot, freshness: 'cached' };
23
+ }
24
+ /** Last successful snapshot regardless of TTL (for stale-while-revalidate). */
25
+ getStale(provider) {
26
+ const entry = this.store.get(provider);
27
+ if (!entry)
28
+ return null;
29
+ return { ...entry.snapshot, freshness: 'stale' };
30
+ }
31
+ set(snapshot) {
32
+ this.store.set(snapshot.provider, {
33
+ snapshot,
34
+ expiresAt: Date.now() + this.ttlMs,
35
+ updatedAt: Date.now(),
36
+ });
37
+ }
38
+ clear(provider) {
39
+ if (provider)
40
+ this.store.delete(provider);
41
+ else
42
+ this.store.clear();
43
+ }
44
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reads the cross-process credential bridge file written by the Swift app's
3
+ * CredentialSheet (~/.tokendash/credentials.json). Adapters check this FIRST —
4
+ * before env vars and settings.json — so a key entered in-app wins.
5
+ *
6
+ * Read fresh on each call (no cache) so a credential saved via the sheet takes
7
+ * effect on the very next quota refresh. The file is tiny, so the cost is nil.
8
+ */
9
+ export interface StoredCredential {
10
+ apiKey: string;
11
+ baseUrl?: string;
12
+ }
13
+ export declare function readStoredCredential(provider: string): StoredCredential | null;
@@ -0,0 +1,23 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ export function readStoredCredential(provider) {
5
+ try {
6
+ const path = join(homedir(), '.tokendash', 'credentials.json');
7
+ if (!existsSync(path))
8
+ return null;
9
+ const all = JSON.parse(readFileSync(path, 'utf8'));
10
+ const entry = all?.[provider];
11
+ if (entry && typeof entry === 'object' && typeof entry.apiKey === 'string') {
12
+ const apiKey = entry.apiKey;
13
+ if (!apiKey)
14
+ return null;
15
+ const baseUrl = entry.baseUrl;
16
+ return { apiKey, baseUrl: typeof baseUrl === 'string' ? baseUrl : undefined };
17
+ }
18
+ return null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
@@ -0,0 +1,39 @@
1
+ import type { QuotaWindow } from './types.js';
2
+ /** Shared helpers for provider adapters. */
3
+ /** Convert a Unix timestamp (seconds or ms) to ISO 8601, or undefined. */
4
+ export declare function unixToIso(unix: number | string | null | undefined): string | undefined;
5
+ /** Clamp a percentage to 0-100. */
6
+ export declare function clampPercent(v: number): number;
7
+ /** Build a QuotaWindow from used/limit percentages. */
8
+ export declare function windowFromPercent(id: string, label: string, usedPercent: number, opts?: {
9
+ durationMins?: number;
10
+ resetsAt?: string;
11
+ used?: number;
12
+ limit?: number;
13
+ modelName?: string;
14
+ isUnlimited?: boolean;
15
+ }): QuotaWindow;
16
+ /** Build a QuotaWindow from absolute used/limit counts. */
17
+ export declare function windowFromCounts(id: string, label: string, used: number, limit: number, opts?: {
18
+ durationMins?: number;
19
+ resetsAt?: string;
20
+ modelName?: string;
21
+ }): QuotaWindow;
22
+ /**
23
+ * Fetch JSON with a timeout. Adapters that hit HTTP APIs use this so they
24
+ * share redaction + abort behavior. Returns parsed JSON or throws.
25
+ */
26
+ export declare function fetchJsonWithTimeout(url: string, opts?: {
27
+ headers?: Record<string, string>;
28
+ timeoutMs?: number;
29
+ }): Promise<unknown>;
30
+ export declare class HttpError extends Error {
31
+ readonly status: number;
32
+ readonly body: string;
33
+ constructor(status: number, body: string);
34
+ }
35
+ /** Map an HTTP status to a coarse auth/upstream classification. */
36
+ export declare function classifyHttpError(err: HttpError): {
37
+ state: 'auth_failed' | 'rate_limited' | 'upstream_unavailable';
38
+ message: string;
39
+ };
@@ -0,0 +1,93 @@
1
+ /** Shared helpers for provider adapters. */
2
+ /** Convert a Unix timestamp (seconds or ms) to ISO 8601, or undefined. */
3
+ export function unixToIso(unix) {
4
+ if (unix === null || unix === undefined || unix === '')
5
+ return undefined;
6
+ const n = typeof unix === 'string' ? parseInt(unix, 10) : unix;
7
+ if (!Number.isFinite(n) || n <= 0)
8
+ return undefined;
9
+ // Codex/MiniMax use seconds or ms; disambiguate by magnitude.
10
+ const ms = n > 1e12 ? n : n * 1000;
11
+ return new Date(ms).toISOString();
12
+ }
13
+ /** Clamp a percentage to 0-100. */
14
+ export function clampPercent(v) {
15
+ if (!Number.isFinite(v))
16
+ return 0;
17
+ return Math.max(0, Math.min(100, v));
18
+ }
19
+ /** Round to 1 decimal place so 61.1999... → 61.2, integers unchanged. */
20
+ function round1(v) {
21
+ return Math.round(v * 10) / 10;
22
+ }
23
+ /** Build a QuotaWindow from used/limit percentages. */
24
+ export function windowFromPercent(id, label, usedPercent, opts = {}) {
25
+ const used = round1(clampPercent(usedPercent));
26
+ return {
27
+ id,
28
+ label,
29
+ usedPercent: used,
30
+ remainingPercent: round1(100 - used),
31
+ durationMins: opts.durationMins,
32
+ resetsAt: opts.resetsAt,
33
+ used: opts.used,
34
+ limit: opts.limit,
35
+ modelName: opts.modelName,
36
+ isUnlimited: opts.isUnlimited,
37
+ };
38
+ }
39
+ /** Build a QuotaWindow from absolute used/limit counts. */
40
+ export function windowFromCounts(id, label, used, limit, opts = {}) {
41
+ if (limit <= 0) {
42
+ return { id, label, usedPercent: 0, remainingPercent: 100, used, limit, isUnlimited: true, ...opts };
43
+ }
44
+ const pct = round1(clampPercent((used / limit) * 100));
45
+ return {
46
+ id,
47
+ label,
48
+ usedPercent: pct,
49
+ remainingPercent: round1(100 - pct),
50
+ used,
51
+ limit,
52
+ ...opts,
53
+ };
54
+ }
55
+ /**
56
+ * Fetch JSON with a timeout. Adapters that hit HTTP APIs use this so they
57
+ * share redaction + abort behavior. Returns parsed JSON or throws.
58
+ */
59
+ export async function fetchJsonWithTimeout(url, opts = {}) {
60
+ const ctrl = new AbortController();
61
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 8_000);
62
+ try {
63
+ const res = await fetch(url, { headers: opts.headers, signal: ctrl.signal });
64
+ if (!res.ok) {
65
+ const body = await res.text().catch(() => '');
66
+ throw new HttpError(res.status, body.slice(0, 200));
67
+ }
68
+ return await res.json();
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+ export class HttpError extends Error {
75
+ status;
76
+ body;
77
+ constructor(status, body) {
78
+ super(`HTTP ${status}`);
79
+ this.status = status;
80
+ this.body = body;
81
+ this.name = 'HttpError';
82
+ }
83
+ }
84
+ /** Map an HTTP status to a coarse auth/upstream classification. */
85
+ export function classifyHttpError(err) {
86
+ if (err.status === 401 || err.status === 403) {
87
+ return { state: 'auth_failed', message: 'credential rejected by provider' };
88
+ }
89
+ if (err.status === 429) {
90
+ return { state: 'rate_limited', message: 'provider throttled the request' };
91
+ }
92
+ return { state: 'upstream_unavailable', message: `provider returned HTTP ${err.status}` };
93
+ }
@@ -0,0 +1,5 @@
1
+ import { QuotaCache } from './cache.js';
2
+ import { QuotaService } from './quotaService.js';
3
+ export type { QuotaSnapshot, QuotaWindow, QuotaProviderStatus, QuotaResponse, QuotaProviderId } from './types.js';
4
+ export declare const quotaCache: QuotaCache;
5
+ export declare const quotaService: QuotaService;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Quota module entry point.
3
+ *
4
+ * Builds the singleton adapter registry + service. The rest of the server
5
+ * imports `quotaService` from here; adapters are wired in registration order,
6
+ * which becomes the dashboard display order.
7
+ */
8
+ import { QuotaAdapterRegistry } from './adapter.js';
9
+ import { QuotaCache } from './cache.js';
10
+ import { QuotaService } from './quotaService.js';
11
+ import { codexAdapter } from './adapters/codex.js';
12
+ import { claudeAdapter } from './adapters/claude.js';
13
+ import { glmAdapter } from './adapters/glm.js';
14
+ import { minimaxAdapter } from './adapters/minimax.js';
15
+ import { kimiAdapter } from './adapters/kimi.js';
16
+ const registry = new QuotaAdapterRegistry();
17
+ registry.register(claudeAdapter);
18
+ registry.register(codexAdapter);
19
+ registry.register(glmAdapter);
20
+ registry.register(minimaxAdapter);
21
+ registry.register(kimiAdapter);
22
+ export const quotaCache = new QuotaCache();
23
+ export const quotaService = new QuotaService(registry, quotaCache);
@@ -0,0 +1,37 @@
1
+ import type { QuotaSnapshot, QuotaProviderId, QuotaResponse } from './types.js';
2
+ import type { QuotaAdapterRegistry } from './adapter.js';
3
+ import { QuotaCache } from './cache.js';
4
+ /**
5
+ * Deep quota service. Owns discovery, concurrency, deduplication, caching,
6
+ * stale-while-revalidate, timeouts, and error classification. Adapters only
7
+ * detect + fetch + normalize; everything cross-cutting lives here.
8
+ */
9
+ export declare class QuotaService {
10
+ private readonly registry;
11
+ private readonly cache;
12
+ private readonly configuredCache;
13
+ /** Cap concurrent upstream calls so a slow provider can't block the others. */
14
+ private readonly fetchTimeoutMs;
15
+ /** In-flight promises keyed by provider id, to dedupe concurrent requests. */
16
+ private readonly inflight;
17
+ constructor(registry: QuotaAdapterRegistry, cache?: QuotaCache, configuredCache?: QuotaProviderId[] | null, fetchTimeoutMs?: number);
18
+ /**
19
+ * List provider ids that are configured locally. Cheap (no network).
20
+ * The dashboard only shows these — not-configured providers are excluded.
21
+ */
22
+ discover(): Promise<QuotaProviderId[]>;
23
+ /**
24
+ * Fetch one provider's snapshot. Fresh if available; stale-but-retained
25
+ * on failure; never throws (errors become structured statuses).
26
+ */
27
+ fetchOne(provider: QuotaProviderId): Promise<QuotaSnapshot | null>;
28
+ /**
29
+ * Fetch all configured providers concurrently. Partial success: one
30
+ * provider's failure never breaks the others. Order = registry order.
31
+ */
32
+ fetchAll(): Promise<QuotaResponse>;
33
+ /** Force a refresh of all configured providers, bypassing the cache. */
34
+ refreshAll(): Promise<QuotaResponse>;
35
+ private fetchWithTimeout;
36
+ private handleFailure;
37
+ }
@@ -0,0 +1,141 @@
1
+ import { QuotaError } from './adapter.js';
2
+ import { QuotaCache } from './cache.js';
3
+ import { validateQuotaSnapshot } from './schemas.js';
4
+ /**
5
+ * Deep quota service. Owns discovery, concurrency, deduplication, caching,
6
+ * stale-while-revalidate, timeouts, and error classification. Adapters only
7
+ * detect + fetch + normalize; everything cross-cutting lives here.
8
+ */
9
+ export class QuotaService {
10
+ registry;
11
+ cache;
12
+ configuredCache;
13
+ /** Cap concurrent upstream calls so a slow provider can't block the others. */
14
+ fetchTimeoutMs;
15
+ /** In-flight promises keyed by provider id, to dedupe concurrent requests. */
16
+ inflight = new Map();
17
+ constructor(registry, cache = new QuotaCache(), configuredCache = null, fetchTimeoutMs = 8_000) {
18
+ this.registry = registry;
19
+ this.cache = cache;
20
+ this.configuredCache = configuredCache;
21
+ this.fetchTimeoutMs = fetchTimeoutMs;
22
+ }
23
+ /**
24
+ * List provider ids that are configured locally. Cheap (no network).
25
+ * The dashboard only shows these — not-configured providers are excluded.
26
+ */
27
+ async discover() {
28
+ const all = this.registry.list();
29
+ const checks = await Promise.all(all.map(async (a) => ({ id: a.provider, configured: await safeIsConfigured(a) })));
30
+ return checks.filter((c) => c.configured).map((c) => c.id);
31
+ }
32
+ /**
33
+ * Fetch one provider's snapshot. Fresh if available; stale-but-retained
34
+ * on failure; never throws (errors become structured statuses).
35
+ */
36
+ async fetchOne(provider) {
37
+ // 1. Fresh cache hit
38
+ const fresh = this.cache.getFresh(provider);
39
+ if (fresh)
40
+ return fresh;
41
+ const adapter = this.registry.get(provider);
42
+ if (!adapter)
43
+ return null;
44
+ // 2. Dedupe concurrent requests for the same provider
45
+ let p = this.inflight.get(provider);
46
+ if (!p) {
47
+ p = this.fetchWithTimeout(adapter).finally(() => this.inflight.delete(provider));
48
+ this.inflight.set(provider, p);
49
+ }
50
+ return p;
51
+ }
52
+ /**
53
+ * Fetch all configured providers concurrently. Partial success: one
54
+ * provider's failure never breaks the others. Order = registry order.
55
+ */
56
+ async fetchAll() {
57
+ const ids = this.configuredCache ?? (await this.discover());
58
+ const byId = new Map();
59
+ const snapshots = await Promise.all(ids.map((id) => this.fetchOne(id)));
60
+ // Preserve registry order regardless of completion order.
61
+ for (const adapter of this.registry.list()) {
62
+ const snap = snapshots.find((s) => s?.provider === adapter.provider);
63
+ if (snap)
64
+ byId.set(adapter.provider, snap);
65
+ }
66
+ return { providers: this.registry.list().map((a) => byId.get(a.provider)).filter((s) => !!s) };
67
+ }
68
+ /** Force a refresh of all configured providers, bypassing the cache. */
69
+ async refreshAll() {
70
+ // Cache-clear only the freshness gate; stale data is still retained by fetchOne on failure.
71
+ this.configuredCache?.forEach(() => { });
72
+ for (const adapter of this.registry.list()) {
73
+ this.cache.clear(adapter.provider);
74
+ }
75
+ return this.fetchAll();
76
+ }
77
+ async fetchWithTimeout(adapter) {
78
+ try {
79
+ const snapshot = await withTimeout(adapter.fetch(), this.fetchTimeoutMs, adapter.provider);
80
+ const validated = validateQuotaSnapshot(snapshot);
81
+ this.cache.set(validated);
82
+ return validated;
83
+ }
84
+ catch (err) {
85
+ return this.handleFailure(adapter, err);
86
+ }
87
+ }
88
+ handleFailure(adapter, err) {
89
+ let status;
90
+ if (err instanceof QuotaError) {
91
+ status = err.status;
92
+ }
93
+ else if (err instanceof TimeoutError) {
94
+ status = { state: 'timed_out', message: `upstream did not respond within ${this.fetchTimeoutMs}ms` };
95
+ }
96
+ else {
97
+ status = { state: 'error', message: redact(err), category: 'unexpected' };
98
+ }
99
+ // Retain last good snapshot as stale.
100
+ const stale = this.cache.getStale(adapter.provider);
101
+ if (stale) {
102
+ return { ...stale, freshness: 'stale', status };
103
+ }
104
+ // No prior data — surface the structured error so the user can act on it.
105
+ return {
106
+ provider: adapter.provider,
107
+ displayName: adapter.displayName,
108
+ fetchedAt: new Date().toISOString(),
109
+ freshness: 'stale',
110
+ windows: [],
111
+ status,
112
+ };
113
+ }
114
+ }
115
+ class TimeoutError extends Error {
116
+ constructor(provider) {
117
+ super(`quota fetch timed out: ${provider}`);
118
+ this.name = 'TimeoutError';
119
+ }
120
+ }
121
+ function withTimeout(p, ms, provider) {
122
+ return new Promise((resolve, reject) => {
123
+ const timer = setTimeout(() => reject(new TimeoutError(provider)), ms);
124
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
125
+ });
126
+ }
127
+ async function safeIsConfigured(adapter) {
128
+ try {
129
+ return await adapter.isConfigured();
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ /** Strip anything that looks like a token/key from an error before it surfaces. */
136
+ function redact(err) {
137
+ const msg = err instanceof Error ? err.message : String(err);
138
+ return msg.replace(/(sk-[A-Za-z0-9_-]{6,})[A-Za-z0-9_-]*/g, '$1…')
139
+ .replace(/(Bearer\s+)[A-Za-z0-9._-]+/gi, '$1…')
140
+ .slice(0, 200);
141
+ }