@ychris12138/dsh-usage-stats 0.2.10 → 0.3.1

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/accounts.js CHANGED
@@ -9,15 +9,47 @@
9
9
  * @module dsh-usage-stats/accounts
10
10
  */
11
11
 
12
- import { balanceSchemeOf, queryBalance } from "./balance.js";
12
+ import { queryBalance } from "./balance.js";
13
+ import { isPrivateAddress, isPrivateHostname } from "./network.js";
14
+ import { resolveProviderIdentity } from "./provider-identity.js";
13
15
  import { collectSubscription } from "./subscriptions.js";
14
16
  import { lookup as dnsLookup } from "node:dns/promises";
15
17
  import { request as httpRequest } from "node:http";
16
18
  import { request as httpsRequest } from "node:https";
17
19
  import { isIP } from "node:net";
18
20
 
21
+ export { isPrivateAddress } from "./network.js";
22
+
19
23
  const DEFAULT_TIMEOUT_MS = 15000;
20
24
  const DEFAULT_REFRESH_MS = 300000;
25
+ const MAX_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
26
+ const DEFAULT_REFRESH_CONFIG = Object.freeze({
27
+ enabled: true,
28
+ activeMs: 60000,
29
+ detailMs: 120000,
30
+ backgroundMs: 900000
31
+ });
32
+ const DEFAULT_REFRESH_POLICY = Object.freeze({
33
+ activeMs: DEFAULT_REFRESH_CONFIG.activeMs,
34
+ detailMs: DEFAULT_REFRESH_CONFIG.detailMs,
35
+ backgroundMs: DEFAULT_REFRESH_CONFIG.backgroundMs,
36
+ rateLimitBaseMs: 300000,
37
+ rateLimitMaxMs: 3600000
38
+ });
39
+ const PROVENANCE_KINDS = new Set(["official", "provider", "configured", "experimental", "unknown"]);
40
+ const OFFICIAL_ADAPTERS = new Set([
41
+ "deepseek-balance",
42
+ "openrouter-balance",
43
+ "moonshot-balance",
44
+ "zai-balance",
45
+ "orcarouter-balance",
46
+ "opencode-go",
47
+ "zai-token-plan",
48
+ "kimi-token-plan",
49
+ "minimax-token-plan",
50
+ "ollama"
51
+ ]);
52
+ const PROVIDER_ADAPTERS = new Set(["new-api", "sub2api", "sub2api-auth"]);
21
53
  const MAX_RESPONSE_BYTES = 1024 * 1024;
22
54
  const OPENROUTER_MANAGEMENT_REF = "OPENROUTER_MANAGEMENT_KEY";
23
55
  /**
@@ -39,11 +71,29 @@ const ACCOUNT_STATUSES = new Set([
39
71
  "blocked",
40
72
  "unsupported"
41
73
  ]);
74
+ const SAFE_REASON_CODES = new Set([
75
+ "dns-resolution-failed",
76
+ "timeout",
77
+ "rate-limited",
78
+ "unauthorized",
79
+ "upstream-invalid-json",
80
+ "upstream-not-json",
81
+ "upstream-too-large",
82
+ "upstream-invalid-response",
83
+ "blocked-network",
84
+ "all-addresses-unreachable",
85
+ "no-validated-address",
86
+ "sub2api-balance-shape-unrecognized",
87
+ "unknown"
88
+ ]);
89
+ const HEALTH_ATTEMPTED = Symbol("account-health-attempted");
90
+ const HEALTH_SUCCEEDED = Symbol("account-health-succeeded");
42
91
  const ADAPTERS = new Set([
43
92
  "deepseek-balance",
44
93
  "openrouter-balance",
45
94
  "moonshot-balance",
46
95
  "zai-balance",
96
+ "orcarouter-balance",
47
97
  "general",
48
98
  "new-api",
49
99
  "sub2api",
@@ -93,6 +143,48 @@ function round1(value) {
93
143
  return Math.round(value * 10) / 10;
94
144
  }
95
145
 
146
+ /** Normalize how trustworthy an account endpoint binding is. */
147
+ export function accountProvenance(spec) {
148
+ if (PROVENANCE_KINDS.has(spec?.provenanceHint)) return spec.provenanceHint;
149
+ const adapter = nonEmptyString(spec?.adapter);
150
+ if (adapter === null) return "unknown";
151
+ if (adapter === "declarative" || adapter === "general") return "configured";
152
+ if (OFFICIAL_ADAPTERS.has(adapter)) return "official";
153
+ if (PROVIDER_ADAPTERS.has(adapter)) return "provider";
154
+ return "unknown";
155
+ }
156
+
157
+ /** Derive health age from the last successful sample without mutating cache state. */
158
+ export function withHealthAge(snapshot, now = Date.now()) {
159
+ if (snapshot === null || snapshot === void 0 || typeof snapshot !== "object") return snapshot;
160
+ const lastSuccessAt = typeof snapshot.lastSuccessAt === "number" && Number.isFinite(snapshot.lastSuccessAt)
161
+ ? snapshot.lastSuccessAt
162
+ : null;
163
+ return {
164
+ ...snapshot,
165
+ ageMs: lastSuccessAt === null ? null : Math.max(0, now - lastSuccessAt)
166
+ };
167
+ }
168
+
169
+ /** Pure central refresh policy; scheduling and I/O stay outside this function. */
170
+ export function refreshPolicy(state, now = Date.now(), overrides = {}) {
171
+ const intervals = { ...DEFAULT_REFRESH_POLICY, ...overrides };
172
+ const activity = state?.activity === "active" || state?.activity === "detail" ? state.activity : "background";
173
+ const lastAttemptAt = typeof state?.lastAttemptAt === "number" && Number.isFinite(state.lastAttemptAt)
174
+ ? state.lastAttemptAt
175
+ : null;
176
+ const priority = activity === "active" ? 3 : activity === "detail" ? 2 : 1;
177
+ if (lastAttemptAt === null) return { activity, priority, delayMs: 0, nextRefreshAt: now };
178
+ const normalDelayMs = activity === "active" ? intervals.activeMs : activity === "detail" ? intervals.detailMs : intervals.backgroundMs;
179
+ let delayMs = normalDelayMs;
180
+ if ((Number(state?.rateLimitFailures) || 0) > 0) {
181
+ const failures = Math.max(1, Math.floor(Number(state.rateLimitFailures) || 1));
182
+ const backoffDelayMs = Math.min(intervals.rateLimitMaxMs, intervals.rateLimitBaseMs * 2 ** Math.min(20, failures - 1));
183
+ delayMs = Math.max(normalDelayMs, backoffDelayMs);
184
+ }
185
+ return { activity, priority, delayMs, nextRefreshAt: lastAttemptAt + delayMs };
186
+ }
187
+
96
188
  function toIso(value) {
97
189
  if (value === null || value === void 0 || value === "") return null;
98
190
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -119,7 +211,34 @@ function statusOf(error) {
119
211
 
120
212
  function safeReasonOf(error) {
121
213
  const reason = nonEmptyString(error?.safeReason);
122
- return reason === null ? null : reason.slice(0, 120);
214
+ if (reason !== null && SAFE_REASON_CODES.has(reason)) return reason;
215
+ const status = statusOf(error);
216
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") return "timeout";
217
+ if (status === "rate-limited") return "rate-limited";
218
+ if (status === "unauthorized") return "unauthorized";
219
+ if (status === "blocked") return "blocked-network";
220
+ if (status === "invalid-response") return "upstream-invalid-response";
221
+ return status === "unavailable" ? "unknown" : null;
222
+ }
223
+
224
+ function providerReasonOf(reason, status) {
225
+ const value = nonEmptyString(reason);
226
+ if (value !== null && SAFE_REASON_CODES.has(value)) return value;
227
+ if (status === "rate-limited") return "rate-limited";
228
+ if (status === "unauthorized") return "unauthorized";
229
+ if (status === "blocked") return "blocked-network";
230
+ if (status === "invalid-response") return "upstream-invalid-response";
231
+ if (status === "unavailable") return "unknown";
232
+ return null;
233
+ }
234
+
235
+ /** Attach service-internal query facts without expanding the wire protocol. */
236
+ function annotateQuerySnapshot(snapshot, { attempted, succeeded }) {
237
+ Object.defineProperties(snapshot, {
238
+ [HEALTH_ATTEMPTED]: { value: attempted === true },
239
+ [HEALTH_SUCCEEDED]: { value: succeeded === true }
240
+ });
241
+ return snapshot;
123
242
  }
124
243
 
125
244
  async function resolveCredential(credentials, ref) {
@@ -172,40 +291,10 @@ async function requestJson(url, init, deps = {}) {
172
291
  return parseJsonResponse(response, deps.maxResponseBytes ?? MAX_RESPONSE_BYTES);
173
292
  }
174
293
 
175
- function schemeAdapter(scheme) {
176
- return `${scheme}-balance`;
177
- }
178
-
179
294
  function schemeOfAdapter(adapter) {
180
295
  return adapter.endsWith("-balance") ? adapter.slice(0, -8) : null;
181
296
  }
182
297
 
183
- function defaultAdapter(provider) {
184
- const providerId = provider.id;
185
- if (providerId === "opencode-go") return "opencode-go";
186
- if (providerId === "zai" || providerId === "zai-coding-cn") return "zai-token-plan";
187
- if (providerId === "kimi-coding" || providerId === "kimi-for-coding") return "kimi-token-plan";
188
- if (["minimax", "minimaxi", "minimax-cn", "minimax-coding"].includes(providerId)) return "minimax-token-plan";
189
- if (providerId === "passion") return "sub2api";
190
- try {
191
- const hostname = new URL(provider.baseURL).hostname.toLowerCase();
192
- if (hostname === "passionapi.com" || hostname.endsWith(".passionapi.com")) return "sub2api";
193
- // Ollama Cloud is detected by its canonical host so a user-configured
194
- // provider with a custom id still gets the quota adapter; local Ollama
195
- // (localhost:11434) never matches and stays without an account adapter.
196
- // The id check is gated on the hostname so a local install that happens
197
- // to use the canonical "ollama" id is not misread as a cloud account.
198
- // Subdomains (e.g. api.ollama.com) are recognized for identification
199
- // only: the usage query always targets the canonical ollama.com host,
200
- // which is where the /api/usage endpoint is served.
201
- if ((providerId === "ollama" || hostname === "ollama.com" || hostname.endsWith(".ollama.com")) && !privateHostname(hostname)) return "ollama";
202
- } catch {
203
- // A malformed provider URL is handled by the adapter when it is queried.
204
- }
205
- const scheme = balanceSchemeOf(providerId);
206
- return scheme === null ? null : schemeAdapter(scheme);
207
- }
208
-
209
298
  function adapterMode(adapter, monitor) {
210
299
  if (adapter === "declarative") return monitor.mode;
211
300
  if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan", "ollama"].includes(adapter)) return "subscription";
@@ -260,9 +349,34 @@ function validateDeclarative(monitor, label) {
260
349
  if (monitor.extract.divisor !== void 0 && (numberOrNull(monitor.extract.divisor) === null || Number(monitor.extract.divisor) === 0)) throw new Error(`${label}.extract.divisor must be a non-zero number`);
261
350
  }
262
351
 
352
+ function validateRefreshInterval(value, label, fallback) {
353
+ if (value === void 0) return fallback;
354
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)
355
+ || value < 60000 || value > MAX_REFRESH_INTERVAL_MS) {
356
+ throw new Error(`${label} must be an integer from 60000 to ${MAX_REFRESH_INTERVAL_MS} ms`);
357
+ }
358
+ return value;
359
+ }
360
+
361
+ function validateRefreshConfig(raw, disableBackgroundRefresh) {
362
+ if (raw === void 0) raw = {};
363
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("refresh must be an object");
364
+ if (disableBackgroundRefresh !== void 0 && typeof disableBackgroundRefresh !== "boolean") {
365
+ throw new Error("disableBackgroundRefresh must be a boolean");
366
+ }
367
+ if (raw.enabled !== void 0 && typeof raw.enabled !== "boolean") throw new Error("refresh.enabled must be a boolean");
368
+ return {
369
+ enabled: raw.enabled ?? (disableBackgroundRefresh === true ? false : DEFAULT_REFRESH_CONFIG.enabled),
370
+ activeMs: validateRefreshInterval(raw.activeMs, "refresh.activeMs", DEFAULT_REFRESH_CONFIG.activeMs),
371
+ detailMs: validateRefreshInterval(raw.detailMs, "refresh.detailMs", DEFAULT_REFRESH_CONFIG.detailMs),
372
+ backgroundMs: validateRefreshInterval(raw.backgroundMs, "refresh.backgroundMs", DEFAULT_REFRESH_CONFIG.backgroundMs)
373
+ };
374
+ }
375
+
263
376
  /** Validate and freeze the non-secret account-monitor configuration shape. */
264
377
  export function validateAccountConfig(raw = {}) {
265
378
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("account config must be an object");
379
+ const refresh = validateRefreshConfig(raw.refresh, raw.disableBackgroundRefresh);
266
380
  const monitors = raw.monitors ?? {};
267
381
  if (monitors === null || typeof monitors !== "object" || Array.isArray(monitors)) throw new Error("monitors must be an object keyed by provider id");
268
382
  const normalized = {};
@@ -282,13 +396,13 @@ export function validateAccountConfig(raw = {}) {
282
396
  if (adapter === "declarative") validateDeclarative(value, label);
283
397
  normalized[providerId] = { ...value, providerId, adapter };
284
398
  }
285
- return { monitors: normalized };
399
+ return { monitors: normalized, refresh };
286
400
  }
287
401
 
288
402
  /** Bind one configured Harness provider to its explicit or built-in adapter. */
289
403
  export function resolveAccountSpec(provider, config = { monitors: {} }) {
290
404
  const monitor = config.monitors?.[provider.id] ?? {};
291
- const adapter = monitor.adapter ?? defaultAdapter(provider);
405
+ const adapter = resolveProviderIdentity(provider, config).accountAdapter;
292
406
  const mode = adapter === null ? null : adapterMode(adapter, monitor);
293
407
  const apiKeyRef = monitor.credentialRef
294
408
  ?? (adapter === "openrouter-balance" ? OPENROUTER_MANAGEMENT_REF : provider.apiKeyEnv);
@@ -336,74 +450,6 @@ function mapped(root, mapping) {
336
450
  return void 0;
337
451
  }
338
452
 
339
- function ipv4Private(octets) {
340
- const [a, b, c] = octets;
341
- return a === 0
342
- || a === 10
343
- || a === 127
344
- || a === 169 && b === 254
345
- || a === 172 && b >= 16 && b <= 31
346
- || a === 192 && b === 168
347
- || a === 192 && b === 0 && (c === 0 || c === 2)
348
- || a === 192 && b === 88 && c === 99
349
- || a === 100 && b >= 64 && b <= 127
350
- || a === 198 && (b === 18 || b === 19)
351
- || a === 198 && b === 51 && c === 100
352
- || a === 203 && b === 0 && c === 113
353
- || a >= 224;
354
- }
355
-
356
- function ipv6Bytes(address) {
357
- let value = address.toLowerCase().split("%")[0];
358
- let ipv4Tail = null;
359
- const lastColon = value.lastIndexOf(":");
360
- if (value.slice(lastColon + 1).includes(".")) {
361
- const octets = value.slice(lastColon + 1).split(".").map(Number);
362
- if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
363
- ipv4Tail = [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]];
364
- value = `${value.slice(0, lastColon)}:${ipv4Tail[0].toString(16)}:${ipv4Tail[1].toString(16)}`;
365
- }
366
- const halves = value.split("::");
367
- if (halves.length > 2) return null;
368
- const left = halves[0] === "" ? [] : halves[0].split(":");
369
- const right = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
370
- const missing = 8 - left.length - right.length;
371
- if (missing < 0 || halves.length === 1 && missing !== 0) return null;
372
- const words = [...left, ...Array(missing).fill("0"), ...right].map((part) => Number.parseInt(part || "0", 16));
373
- if (words.length !== 8 || words.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) return null;
374
- const bytes = [];
375
- for (const word of words) bytes.push(word >> 8, word & 0xff);
376
- return bytes;
377
- }
378
-
379
- /** True for loopback, private, link-local, documentation, multicast, and other non-public IP space. */
380
- export function isPrivateAddress(address) {
381
- const value = String(address ?? "").trim().replace(/^\[|\]$/g, "");
382
- if (isIP(value) === 4) return ipv4Private(value.split(".").map(Number));
383
- if (isIP(value) !== 6) return false;
384
- const bytes = ipv6Bytes(value);
385
- if (bytes === null) return true;
386
- if (bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff) return ipv4Private(bytes.slice(12));
387
- // Public provider endpoints should resolve to global unicast (2000::/3).
388
- // This conservative allow-range excludes loopback/unspecified, NAT64,
389
- // discard-only, ULA, link/site-local, multicast, and other special space.
390
- const globalUnicast = (bytes[0] & 0xe0) === 0x20;
391
- const word0 = (bytes[0] << 8) | bytes[1];
392
- const word1 = (bytes[2] << 8) | bytes[3];
393
- // IETF protocol assignments 2001:0000::/23 include benchmarking, ORCHID,
394
- // and tunnel mechanisms; 2002::/16 (6to4) embeds an unchecked IPv4 target.
395
- const ietfSpecial = word0 === 0x2001 && word1 <= 0x01ff;
396
- const sixToFour = word0 === 0x2002;
397
- const documentation = word0 === 0x2001 && word1 === 0x0db8
398
- || word0 === 0x3fff && (word1 & 0xf000) === 0;
399
- return !globalUnicast || ietfSpecial || sixToFour || documentation;
400
- }
401
-
402
- function privateHostname(hostname) {
403
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
404
- return host === "localhost" || host.endsWith(".localhost") || isPrivateAddress(host);
405
- }
406
-
407
453
  /**
408
454
  * RFC 2544 benchmarking range commonly used by proxy fake-IP DNS.
409
455
  *
@@ -477,7 +523,7 @@ export function selectResolvedAddress(url, rawAddresses, allowPrivateNetwork = f
477
523
 
478
524
  async function resolvePublicAddresses(url, spec, deps) {
479
525
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
480
- if (privateHostname(hostname) && spec.monitor.allowPrivateNetwork !== true) throw statusError("blocked", "account monitor private-network access requires allowPrivateNetwork");
526
+ if (isPrivateHostname(hostname) && spec.monitor.allowPrivateNetwork !== true) throw statusError("blocked", "account monitor private-network access requires allowPrivateNetwork");
481
527
  if (isIP(hostname) !== 0) return [{ address: hostname, family: isIP(hostname) }];
482
528
  let addresses;
483
529
  try {
@@ -562,7 +608,7 @@ function pinnedRequest(url, address, init, deps, signal) {
562
608
  let size = 0;
563
609
  response.on("data", (chunk) => {
564
610
  size += chunk.length;
565
- if (size > (deps.maxResponseBytes ?? MAX_RESPONSE_BYTES)) request.destroy(statusError("invalid-response", "upstream response exceeds the size limit"));
611
+ if (size > (deps.maxResponseBytes ?? MAX_RESPONSE_BYTES)) request.destroy(statusError("invalid-response", "upstream response exceeds the size limit", void 0, "upstream-too-large"));
566
612
  else chunks.push(chunk);
567
613
  });
568
614
  response.on("end", () => {
@@ -614,7 +660,7 @@ function customURL(spec) {
614
660
  const base = new URL(spec.baseURL);
615
661
  const providerBase = nonEmptyString(spec.providerBaseURL) === null ? null : new URL(spec.providerBaseURL);
616
662
  if (base.protocol !== "https:" && spec.monitor.allowInsecure !== true) throw statusError("blocked", "custom monitor requires HTTPS");
617
- if (privateHostname(base.hostname) && spec.monitor.allowPrivateNetwork !== true) throw statusError("blocked", "custom monitor private-network access requires allowPrivateNetwork");
663
+ if (isPrivateHostname(base.hostname) && spec.monitor.allowPrivateNetwork !== true) throw statusError("blocked", "custom monitor private-network access requires allowPrivateNetwork");
618
664
  if (providerBase !== null && base.origin !== providerBase.origin && spec.monitor.allowCrossOrigin !== true) throw statusError("blocked", "custom monitor cross-origin access requires allowCrossOrigin");
619
665
  const url = new URL(spec.monitor.request.path, base);
620
666
  if (url.origin !== base.origin) throw statusError("unsupported", "custom monitor request must stay on its configured origin");
@@ -665,6 +711,7 @@ function baseSnapshot(spec, status, now) {
665
711
  displayName: spec.displayName,
666
712
  mode: spec.mode ?? "balance",
667
713
  adapter: spec.adapter,
714
+ provenance: accountProvenance(spec),
668
715
  status,
669
716
  fetchedAt: now
670
717
  };
@@ -688,7 +735,7 @@ async function queryBuiltInBalance(spec, credential, deps, now) {
688
735
  ...(used === null ? {} : { used }),
689
736
  ...(total === null ? {} : { total }),
690
737
  currency: nonEmptyString(raw.currency) ?? "USD",
691
- unlimited: false,
738
+ unlimited: raw.unlimited === true,
692
739
  expiresAt: null,
693
740
  available: raw.isAvailable !== false,
694
741
  breakdown: {
@@ -713,19 +760,58 @@ async function queryGeneral(spec, credential, deps, now) {
713
760
  return { ...baseSnapshot(spec, "ok", now), balance, alert: balanceAlert(balance, spec.monitor.warning) };
714
761
  }
715
762
 
716
- async function quotaPerUnit(spec, deps) {
763
+ const LEGACY_NEW_API_QUOTA_PER_UNIT = 500000;
764
+
765
+ function legacyNewApiQuotaStatus() {
766
+ return {
767
+ quotaPerUnit: LEGACY_NEW_API_QUOTA_PER_UNIT,
768
+ quotaUnitFallback: true,
769
+ displayType: "USD",
770
+ usdExchangeRate: 1
771
+ };
772
+ }
773
+
774
+ function normalizeNewApiQuotaStatus(body) {
775
+ const rawUnit = numberOrNull(body?.data?.quota_per_unit);
776
+ const quotaUnitFallback = rawUnit === null || rawUnit <= 0;
777
+ const quotaPerUnit = quotaUnitFallback ? LEGACY_NEW_API_QUOTA_PER_UNIT : rawUnit;
778
+ const rawDisplayType = body?.data?.quota_display_type;
779
+ let displayType = "USD";
780
+ if (rawDisplayType !== void 0 && rawDisplayType !== null && rawDisplayType !== "") {
781
+ const normalized = nonEmptyString(rawDisplayType);
782
+ if (normalized === null) throw statusError("invalid-response", "New API quota display type is invalid");
783
+ displayType = normalized.toUpperCase();
784
+ }
785
+ if (displayType === "USD") {
786
+ return { quotaPerUnit, quotaUnitFallback, displayType, usdExchangeRate: 1 };
787
+ }
788
+ if (displayType === "CNY") {
789
+ const usdExchangeRate = numberOrNull(body?.data?.usd_exchange_rate);
790
+ if (usdExchangeRate === null || usdExchangeRate <= 0) {
791
+ throw statusError("invalid-response", "New API CNY display requires a positive USD exchange rate");
792
+ }
793
+ return { quotaPerUnit, quotaUnitFallback, displayType, usdExchangeRate };
794
+ }
795
+ throw statusError("unsupported", "New API quota display type is unsupported");
796
+ }
797
+
798
+ async function queryNewApiQuotaStatus(spec, deps) {
717
799
  try {
718
800
  const body = await requestJson(new URL("/api/status", spec.baseURL).href, { headers: { accept: "application/json" } }, deps);
719
- const value = numberOrNull(body?.data?.quota_per_unit);
720
- if (value !== null && value > 0) return { value, fallback: false };
721
- // Old status schemas did not expose quota_per_unit.
722
- return { value: 500000, fallback: true };
801
+ return normalizeNewApiQuotaStatus(body);
723
802
  } catch (error) {
724
- if (error?.httpStatus === 404 || error?.httpStatus === 405) return { value: 500000, fallback: true };
803
+ if (error?.httpStatus === 404 || error?.httpStatus === 405) return legacyNewApiQuotaStatus();
725
804
  throw error;
726
805
  }
727
806
  }
728
807
 
808
+ function newApiQuotaAmount(value, quotaStatus) {
809
+ const rawQuota = numberOrNull(value);
810
+ // New API raw quota first converts to USD, then to the configured display
811
+ // currency. Both account paths share this exact factor for every component.
812
+ return rawQuota === null ? null : rawQuota / quotaStatus.quotaPerUnit * quotaStatus.usdExchangeRate;
813
+ }
814
+
729
815
  async function queryNewApiFallback(spec, credentials, deps, now) {
730
816
  const ref = spec.monitor.fallbackCredentialRef;
731
817
  const token = await resolveCredential(credentials, ref);
@@ -733,19 +819,20 @@ async function queryNewApiFallback(spec, credentials, deps, now) {
733
819
  const headers = { authorization: `Bearer ${token}`, accept: "application/json" };
734
820
  const userId = await resolveCredential(credentials, spec.monitor.fallbackUserIdRef);
735
821
  if (userId !== "") headers["new-api-user"] = userId;
736
- const [body, quotaUnit] = await Promise.all([
822
+ const [body, quotaStatus] = await Promise.all([
737
823
  requestJson(new URL("/api/user/self", spec.baseURL).href, { headers }, deps),
738
- quotaPerUnit(spec, deps)
824
+ queryNewApiQuotaStatus(spec, deps)
739
825
  ]);
740
- const unit = quotaUnit.value;
741
826
  if (body?.success === false || body?.data === null || typeof body?.data !== "object") throw statusError("invalid-response", "New API user response is invalid");
742
827
  const remainingQuota = numberOrNull(body.data.quota);
743
828
  const usedQuota = numberOrNull(body.data.used_quota);
744
- if (remainingQuota === null) throw statusError("invalid-response", "New API user response is missing quota");
829
+ const remaining = newApiQuotaAmount(remainingQuota, quotaStatus);
830
+ const used = newApiQuotaAmount(usedQuota, quotaStatus);
831
+ if (remaining === null) throw statusError("invalid-response", "New API user response is missing quota");
745
832
  const balance = {
746
- remaining: remainingQuota / unit,
747
- ...(usedQuota === null ? {} : { used: usedQuota / unit, total: (remainingQuota + usedQuota) / unit }),
748
- currency: "USD",
833
+ remaining,
834
+ ...(used === null ? {} : { used, total: newApiQuotaAmount(remainingQuota + usedQuota, quotaStatus) }),
835
+ currency: quotaStatus.displayType,
749
836
  unlimited: false,
750
837
  expiresAt: null
751
838
  };
@@ -755,8 +842,8 @@ async function queryNewApiFallback(spec, credentials, deps, now) {
755
842
  balance,
756
843
  alert: balanceAlert(balance, spec.monitor.warning),
757
844
  source: "management-fallback",
758
- quotaUnit: unit,
759
- quotaUnitFallback: quotaUnit.fallback
845
+ quotaUnit: quotaStatus.quotaPerUnit,
846
+ quotaUnitFallback: quotaStatus.quotaUnitFallback
760
847
  };
761
848
  }
762
849
 
@@ -774,15 +861,14 @@ async function queryNewApi(spec, credentials, credential, deps, now) {
774
861
  const granted = numberOrNull(body.data.total_granted);
775
862
  const used = numberOrNull(body.data.total_used);
776
863
  const available = numberOrNull(body.data.total_available);
777
- const quotaUnit = await quotaPerUnit(spec, deps);
778
- const unit = quotaUnit.value;
864
+ const quotaStatus = await queryNewApiQuotaStatus(spec, deps);
779
865
  const unlimited = booleanOrNull(body.data.unlimited_quota) === true;
780
866
  if (!unlimited && available === null) throw statusError("invalid-response", "New API token response is missing total_available");
781
867
  const balance = {
782
- remaining: available === null ? null : available / unit,
783
- ...(used === null ? {} : { used: used / unit }),
784
- ...(granted === null ? {} : { total: granted / unit }),
785
- currency: "USD",
868
+ remaining: newApiQuotaAmount(available, quotaStatus),
869
+ ...(used === null ? {} : { used: newApiQuotaAmount(used, quotaStatus) }),
870
+ ...(granted === null ? {} : { total: newApiQuotaAmount(granted, quotaStatus) }),
871
+ currency: quotaStatus.displayType,
786
872
  unlimited,
787
873
  expiresAt: numberOrNull(body.data.expires_at) > 0 ? toIso(body.data.expires_at) : null
788
874
  };
@@ -792,8 +878,8 @@ async function queryNewApi(spec, credentials, credential, deps, now) {
792
878
  balance,
793
879
  alert: unlimited ? { level: "normal", metric: "remaining-percent", value: 100 } : balanceAlert(balance, spec.monitor.warning),
794
880
  source: "token",
795
- quotaUnit: unit,
796
- quotaUnitFallback: quotaUnit.fallback
881
+ quotaUnit: quotaStatus.quotaPerUnit,
882
+ quotaUnitFallback: quotaStatus.quotaUnitFallback
797
883
  };
798
884
  }
799
885
 
@@ -958,7 +1044,8 @@ function sub2apiAuthSpec(spec) {
958
1044
  return {
959
1045
  ...spec,
960
1046
  adapter: "sub2api-auth",
961
- mode: "balance"
1047
+ mode: "balance",
1048
+ provenanceHint: "experimental"
962
1049
  };
963
1050
  }
964
1051
 
@@ -1036,7 +1123,7 @@ async function querySub2ApiAuth(spec, credentials, deps, now) {
1036
1123
  // unrecognized shape. Never include upstream-controlled content (JSON
1037
1124
  // property names, values, messages) in safeReason — a hostile upstream
1038
1125
  // could otherwise echo sensitive material across the server→browser
1039
- // boundary, since safeReasonOf() only truncates.
1126
+ // boundary. safeReasonOf() accepts only the fixed vocabulary above.
1040
1127
  if (balanceBody !== null && typeof balanceBody === "object") {
1041
1128
  error.safeReason = "sub2api-balance-shape-unrecognized";
1042
1129
  }
@@ -1100,9 +1187,28 @@ async function queryDeclarative(spec, credentials, deps, now) {
1100
1187
  /** Query one adapter and return a secret-free normalized account snapshot. */
1101
1188
  export async function queryAccount(spec, credentials, deps = {}) {
1102
1189
  const now = (deps.now ?? Date.now)();
1103
- if (spec === null || spec === void 0) return unavailableSnapshot({ id: "unknown", displayName: "Unknown", adapter: null, mode: "balance" }, "unsupported", now);
1190
+ if (spec === null || spec === void 0) {
1191
+ return annotateQuerySnapshot(
1192
+ unavailableSnapshot({ id: "unknown", displayName: "Unknown", adapter: null, mode: "balance" }, "unsupported", now),
1193
+ { attempted: false, succeeded: false }
1194
+ );
1195
+ }
1196
+ let attempted = false;
1197
+ const upstreamFetch = deps.fetch === void 0
1198
+ ? (url, init) => pinnedFetch(url, init, spec, deps)
1199
+ : deps.fetch;
1200
+ const safeDeps = {
1201
+ ...deps,
1202
+ fetch: (url, init) => {
1203
+ attempted = true;
1204
+ return upstreamFetch(url, init);
1205
+ }
1206
+ };
1207
+ const finish = (snapshot, succeeded = snapshot.status === "ok") => annotateQuerySnapshot(snapshot, {
1208
+ attempted: attempted || succeeded,
1209
+ succeeded
1210
+ });
1104
1211
  try {
1105
- const safeDeps = deps.fetch === void 0 ? { ...deps, fetch: (url, init) => pinnedFetch(url, init, spec, deps) } : deps;
1106
1212
  // A relay provider with no built-in/explicit adapter may be a real
1107
1213
  // Sub2API panel. Only when it also has a model-configured API key do we
1108
1214
  // probe its public settings endpoint; a matching fingerprint selects the
@@ -1110,21 +1216,21 @@ export async function queryAccount(spec, credentials, deps = {}) {
1110
1216
  // adapters always win, and unkeyed relays are never probed.
1111
1217
  if (spec.adapter === null || spec.mode === null) {
1112
1218
  const providerKey = await resolveCredential(credentials, spec.apiKeyRef);
1113
- if (providerKey === "") return unavailableSnapshot(spec, "unsupported", now);
1219
+ if (providerKey === "") return finish(unavailableSnapshot(spec, "unsupported", now), false);
1114
1220
  const probeable = { ...spec, adapter: null, mode: "balance" };
1115
1221
  if (await probeSub2ApiPanel(probeable, safeDeps)) {
1116
- return await querySub2ApiAuth(sub2apiAuthSpec(probeable), credentials, safeDeps, now);
1222
+ return finish(await querySub2ApiAuth(sub2apiAuthSpec(probeable), credentials, safeDeps, now));
1117
1223
  }
1118
- return unavailableSnapshot(spec, "unsupported", now);
1224
+ return finish(unavailableSnapshot(spec, "unsupported", now), false);
1119
1225
  }
1120
- if (spec.adapter === "declarative") return await queryDeclarative(spec, credentials, safeDeps, now);
1121
- if (spec.adapter === "sub2api-auth") return await querySub2ApiAuth(spec, credentials, safeDeps, now);
1226
+ if (spec.adapter === "declarative") return finish(await queryDeclarative(spec, credentials, safeDeps, now));
1227
+ if (spec.adapter === "sub2api-auth") return finish(await querySub2ApiAuth(spec, credentials, safeDeps, now));
1122
1228
  const credential = await resolveCredential(credentials, spec.apiKeyRef);
1123
- if (spec.adapter !== "opencode-go" && credential === "") return unavailableSnapshot(spec, "not-configured", now, { missingCredentials: spec.apiKeyRef === void 0 ? [] : [spec.apiKeyRef] });
1124
- if (schemeOfAdapter(spec.adapter) !== null) return await queryBuiltInBalance(spec, credential, safeDeps, now);
1125
- if (spec.adapter === "general") return await queryGeneral(spec, credential, safeDeps, now);
1126
- if (spec.adapter === "new-api") return await queryNewApi(spec, credentials, credential, safeDeps, now);
1127
- if (spec.adapter === "sub2api") return await querySub2Api(spec, credential, safeDeps, now);
1229
+ if (spec.adapter !== "opencode-go" && credential === "") return finish(unavailableSnapshot(spec, "not-configured", now, { missingCredentials: spec.apiKeyRef === void 0 ? [] : [spec.apiKeyRef] }), false);
1230
+ if (schemeOfAdapter(spec.adapter) !== null) return finish(await queryBuiltInBalance(spec, credential, safeDeps, now), true);
1231
+ if (spec.adapter === "general") return finish(await queryGeneral(spec, credential, safeDeps, now));
1232
+ if (spec.adapter === "new-api") return finish(await queryNewApi(spec, credentials, credential, safeDeps, now));
1233
+ if (spec.adapter === "sub2api") return finish(await querySub2Api(spec, credential, safeDeps, now));
1128
1234
  const subscriptionId = spec.adapter === "zai-token-plan" ? "zai"
1129
1235
  : spec.adapter === "kimi-token-plan" ? "kimi"
1130
1236
  : spec.adapter === "minimax-token-plan" ? "minimax"
@@ -1138,10 +1244,11 @@ export async function queryAccount(spec, credentials, deps = {}) {
1138
1244
  baseURL: spec.monitor.usageBaseURL
1139
1245
  }, safeDeps);
1140
1246
  const windows = Array.isArray(provider.windows) ? provider.windows : [];
1141
- return { ...baseSnapshot(spec, provider.status, now), plan: provider.plan, windows, alert: subscriptionAlert(windows), ...(provider.missingCredentials === void 0 ? {} : { missingCredentials: provider.missingCredentials }), ...(provider.reason === void 0 ? {} : { reason: provider.reason }) };
1247
+ const reason = providerReasonOf(provider.reason, provider.status);
1248
+ return finish({ ...baseSnapshot(spec, provider.status, now), plan: provider.plan, windows, alert: subscriptionAlert(windows), ...(provider.missingCredentials === void 0 ? {} : { missingCredentials: provider.missingCredentials }), ...(reason === null ? {} : { reason }) });
1142
1249
  } catch (error) {
1143
1250
  const reason = safeReasonOf(error);
1144
- return unavailableSnapshot(spec, statusOf(error), now, reason === null ? {} : { reason });
1251
+ return finish(unavailableSnapshot(spec, statusOf(error), now, reason === null ? {} : { reason }), false);
1145
1252
  }
1146
1253
  }
1147
1254
 
@@ -1149,29 +1256,65 @@ function isTransient(status) {
1149
1256
  return status === "unavailable" || status === "rate-limited" || status === "invalid-response";
1150
1257
  }
1151
1258
 
1152
- function withStaleData(previous, current) {
1153
- if (previous?.status !== "ok" || !isTransient(current.status)) return current;
1154
- return {
1259
+ function mergeRefreshHealth(previous, current) {
1260
+ const lastSuccessAt = previous?.lastSuccessAt
1261
+ ?? (previous?.status === "ok" ? previous.fetchedAt : null);
1262
+ const attempted = current[HEALTH_ATTEMPTED] === true;
1263
+ const successful = current[HEALTH_SUCCEEDED] === true || current.status === "ok";
1264
+ const attemptAt = attempted ? current.fetchedAt : previous?.lastAttemptAt ?? null;
1265
+ const currentWithHealth = {
1266
+ ...current,
1267
+ lastAttemptAt: attemptAt,
1268
+ lastSuccessAt: successful ? attemptAt : lastSuccessAt,
1269
+ stale: false
1270
+ };
1271
+ delete currentWithHealth.ageMs;
1272
+ const canRetain = !successful
1273
+ && lastSuccessAt !== null
1274
+ && (previous?.status === "ok" || previous?.stale === true)
1275
+ && isTransient(current.status);
1276
+ if (!canRetain) return currentWithHealth;
1277
+ const stale = {
1155
1278
  ...previous,
1156
1279
  status: current.status,
1157
- fetchedAt: current.fetchedAt,
1158
- lastSuccessAt: previous.lastSuccessAt ?? previous.fetchedAt,
1280
+ fetchedAt: attemptAt,
1281
+ lastAttemptAt: attemptAt,
1282
+ lastSuccessAt,
1283
+ provenance: current.provenance ?? previous.provenance ?? "unknown",
1159
1284
  stale: true
1160
1285
  };
1286
+ delete stale.ageMs;
1287
+ if (current.reason === void 0) delete stale.reason;
1288
+ else stale.reason = current.reason;
1289
+ return stale;
1161
1290
  }
1162
1291
 
1163
1292
  /**
1164
- * In-memory account cache with per-provider single-flight and forced bulk
1165
- * refresh. Background scheduling is owned by the server plugin so it can also
1166
- * refresh local token-usage aggregation in the same five-minute cycle.
1293
+ * In-memory account cache with per-provider single-flight, health history, and
1294
+ * adaptive due-time calculation. One server-owned scheduler coordinates it
1295
+ * with the existing local token-usage aggregation lifecycle.
1167
1296
  */
1168
1297
  export function createAccountService({ credentials, getProviders, config = { monitors: {} }, deps = {} }) {
1169
1298
  const cache = new Map();
1170
1299
  const inflight = new Map();
1171
- const refreshMs = deps.refreshMs ?? DEFAULT_REFRESH_MS;
1300
+ const refreshGenerations = new Map();
1301
+ const now = deps.now ?? Date.now;
1302
+ const refreshConfig = { ...DEFAULT_REFRESH_CONFIG, ...(config.refresh ?? {}) };
1303
+ const autoRefreshEnabled = refreshConfig.enabled !== false;
1304
+ const policyOverrides = {
1305
+ activeMs: refreshConfig.activeMs,
1306
+ detailMs: refreshConfig.detailMs,
1307
+ backgroundMs: refreshConfig.backgroundMs,
1308
+ ...(deps.refreshMs === void 0 ? {} : { backgroundMs: deps.refreshMs }),
1309
+ ...(deps.refreshPolicy ?? {})
1310
+ };
1311
+ const activeProviders = new Set();
1312
+ const activityTouches = new Map();
1313
+ const policyListeners = new Set();
1314
+ const activityTtlMs = deps.activityTtlMs ?? 600000;
1172
1315
  // Long-lived Sub2API panel-detection cache, keyed by the provider's config
1173
1316
  // key. It lives on the service so auto-detection probes once per
1174
- // (provider × config) even across five-minute background refreshes; a caller
1317
+ // (provider × config) even across background refreshes; a caller
1175
1318
  // may still inject its own Map (e.g. tests) by passing deps.sub2apiDetection.
1176
1319
  const sub2apiDetection = deps.sub2apiDetection ?? new Map();
1177
1320
  const serviceDeps = { ...deps, sub2apiDetection };
@@ -1210,44 +1353,139 @@ export function createAccountService({ credentials, getProviders, config = { mon
1210
1353
  return (await specs()).find((spec) => spec.id === providerId) ?? null;
1211
1354
  }
1212
1355
 
1356
+ function notifyPolicyChange() {
1357
+ for (const listener of policyListeners) listener();
1358
+ }
1359
+
1360
+ function subscribePolicyChanges(listener) {
1361
+ if (typeof listener !== "function") return () => {};
1362
+ policyListeners.add(listener);
1363
+ return () => policyListeners.delete(listener);
1364
+ }
1365
+
1366
+ function touch(providerId, activity) {
1367
+ if (typeof providerId !== "string" || providerId === "") return;
1368
+ if (activity !== "active" && activity !== "detail") return;
1369
+ const at = now();
1370
+ const before = activityOf(providerId, at);
1371
+ const previous = activityTouches.get(providerId) ?? {};
1372
+ activityTouches.set(providerId, { ...previous, [`${activity}At`]: at });
1373
+ if (activityOf(providerId, at) !== before) notifyPolicyChange();
1374
+ }
1375
+
1376
+ function setActiveProviders(providerIds) {
1377
+ const next = new Set([...(providerIds ?? [])].filter((providerId) => typeof providerId === "string" && providerId !== ""));
1378
+ const changed = next.size !== activeProviders.size || [...next].some((providerId) => !activeProviders.has(providerId));
1379
+ activeProviders.clear();
1380
+ for (const providerId of next) activeProviders.add(providerId);
1381
+ if (changed) notifyPolicyChange();
1382
+ }
1383
+
1384
+ function activityOf(providerId, at) {
1385
+ const touched = activityTouches.get(providerId);
1386
+ if (activeProviders.has(providerId) || at - (touched?.activeAt ?? -Infinity) < activityTtlMs) return "active";
1387
+ if (at - (touched?.detailAt ?? -Infinity) < activityTtlMs) return "detail";
1388
+ return "background";
1389
+ }
1390
+
1391
+ function policyOf(spec, at) {
1392
+ const hit = cache.get(spec.id);
1393
+ if (hit?.configKey !== spec.configKey) return refreshPolicy({ activity: activityOf(spec.id, at), status: "pending", lastAttemptAt: null }, at, policyOverrides);
1394
+ return refreshPolicy({
1395
+ activity: activityOf(spec.id, at),
1396
+ status: hit.account.status,
1397
+ rateLimitFailures: hit.rateLimitFailures ?? 0,
1398
+ // Scheduling must advance even when a local credential/config check
1399
+ // correctly produces no provider attempt (and lastAttemptAt stays null).
1400
+ lastAttemptAt: hit.lastEvaluatedAt ?? hit.account.lastAttemptAt ?? hit.account.fetchedAt ?? null
1401
+ }, at, policyOverrides);
1402
+ }
1403
+
1213
1404
  async function refresh(spec) {
1214
1405
  const existing = inflight.get(spec.id);
1215
- if (existing !== void 0) return existing;
1406
+ if (existing?.configKey === spec.configKey) return existing.promise;
1407
+ const generation = (refreshGenerations.get(spec.id) ?? 0) + 1;
1408
+ refreshGenerations.set(spec.id, generation);
1216
1409
  const promise = queryAccount(spec, credentials, serviceDeps).then((current) => {
1217
- const next = withStaleData(cache.get(spec.id)?.account, current);
1218
- cache.set(spec.id, { configKey: spec.configKey, account: next });
1219
- return next;
1220
- }).finally(() => inflight.delete(spec.id));
1221
- inflight.set(spec.id, promise);
1222
- return promise;
1410
+ const previous = cache.get(spec.id);
1411
+ const sameConfig = previous?.configKey === spec.configKey;
1412
+ const previousAccount = sameConfig ? previous.account : null;
1413
+ const previousRateLimitFailures = sameConfig ? previous.rateLimitFailures ?? 0 : 0;
1414
+ const next = mergeRefreshHealth(previousAccount, current);
1415
+ const successful = current[HEALTH_SUCCEEDED] === true || current.status === "ok";
1416
+ const rateLimitFailures = current.status === "rate-limited"
1417
+ ? previousRateLimitFailures + 1
1418
+ : successful ? 0 : previousRateLimitFailures;
1419
+ // A late completion from a replaced binding may resolve its own caller,
1420
+ // but must never overwrite the newer binding's cache state.
1421
+ if (refreshGenerations.get(spec.id) === generation) {
1422
+ cache.set(spec.id, {
1423
+ configKey: spec.configKey,
1424
+ account: next,
1425
+ rateLimitFailures,
1426
+ lastEvaluatedAt: current.fetchedAt
1427
+ });
1428
+ }
1429
+ return withHealthAge(next, now());
1430
+ });
1431
+ let entry;
1432
+ const tracked = promise.finally(() => {
1433
+ if (inflight.get(spec.id) === entry) inflight.delete(spec.id);
1434
+ });
1435
+ entry = { configKey: spec.configKey, promise: tracked };
1436
+ inflight.set(spec.id, entry);
1437
+ return tracked;
1223
1438
  }
1224
1439
 
1225
- async function get(providerId, { force = false } = {}) {
1440
+ async function get(providerId, { force = false, activity = null } = {}) {
1226
1441
  const spec = await specById(providerId);
1227
1442
  if (spec === null) return null;
1443
+ if (activity !== null) touch(providerId, activity);
1228
1444
  const hit = cache.get(providerId);
1229
- const age = (deps.now ?? Date.now)() - (hit?.account?.fetchedAt ?? 0);
1230
- if (!force && hit?.configKey === spec.configKey && age >= 0 && age < refreshMs) return hit.account;
1445
+ const at = now();
1446
+ if (!force && hit?.configKey === spec.configKey
1447
+ && (!autoRefreshEnabled || policyOf(spec, at).nextRefreshAt > at)) return withHealthAge(hit.account, at);
1231
1448
  return refresh(spec);
1232
1449
  }
1233
1450
 
1234
- async function refreshAll() {
1451
+ async function refreshableSpecs() {
1235
1452
  const all = await specs();
1236
- // Auto-detection only probes null-adapter relays that have a configured
1237
- // API key, so the background refresh stays bounded and never touches
1238
- // unrelated, unkeyed providers.
1239
1453
  const keyed = await Promise.all(all.map(async (spec) => ({
1240
1454
  spec,
1241
1455
  probe: spec.adapter === null
1242
1456
  ? (await resolveCredential(credentials, spec.apiKeyRef)) !== ""
1243
1457
  : true
1244
1458
  })));
1245
- return Promise.all(keyed.filter((entry) => entry.probe).map((entry) => refresh(entry.spec)));
1459
+ return keyed.filter((entry) => entry.probe).map((entry) => entry.spec);
1460
+ }
1461
+
1462
+ async function refreshAll() {
1463
+ // Auto-detection only probes null-adapter relays that have a configured
1464
+ // API key, so the background refresh stays bounded and never touches
1465
+ // unrelated, unkeyed providers.
1466
+ return Promise.all((await refreshableSpecs()).map((spec) => refresh(spec)));
1467
+ }
1468
+
1469
+ async function refreshDue({ force = false } = {}) {
1470
+ if (!autoRefreshEnabled && !force) return [];
1471
+ const at = now();
1472
+ const all = await refreshableSpecs();
1473
+ const due = force ? all : all.filter((spec) => policyOf(spec, at).nextRefreshAt <= at);
1474
+ return Promise.all(due.map((spec) => refresh(spec)));
1475
+ }
1476
+
1477
+ async function nextRefreshAt() {
1478
+ if (!autoRefreshEnabled) return null;
1479
+ const at = now();
1480
+ const all = await refreshableSpecs();
1481
+ if (all.length === 0) return null;
1482
+ return Math.min(...all.map((spec) => policyOf(spec, at).nextRefreshAt));
1246
1483
  }
1247
1484
 
1248
1485
  async function providerViews() {
1249
1486
  return Promise.all((await specs()).map(async (spec) => {
1250
- const account = cache.get(spec.id)?.account;
1487
+ const hit = cache.get(spec.id);
1488
+ const account = withHealthAge(hit?.configKey === spec.configKey ? hit.account : void 0, now());
1251
1489
  const credentialConfigured = account === void 0 && spec.apiKeyRef !== void 0
1252
1490
  ? await resolveCredential(credentials, spec.apiKeyRef) !== ""
1253
1491
  : false;
@@ -1259,6 +1497,12 @@ export function createAccountService({ credentials, getProviders, config = { mon
1259
1497
  configured: account === void 0 ? credentialConfigured : account.status !== "not-configured",
1260
1498
  status: account?.status ?? "pending",
1261
1499
  fetchedAt: account?.fetchedAt ?? null,
1500
+ stale: account?.stale ?? false,
1501
+ lastAttemptAt: account?.lastAttemptAt ?? null,
1502
+ lastSuccessAt: account?.lastSuccessAt ?? null,
1503
+ ageMs: account?.ageMs ?? null,
1504
+ provenance: account?.provenance ?? accountProvenance(spec),
1505
+ reason: account?.reason ?? null,
1262
1506
  alert: account?.alert ?? null
1263
1507
  };
1264
1508
  }));
@@ -1273,10 +1517,15 @@ export function createAccountService({ credentials, getProviders, config = { mon
1273
1517
  return {
1274
1518
  get,
1275
1519
  refreshAll,
1520
+ refreshDue,
1521
+ nextRefreshAt,
1522
+ touch,
1523
+ setActiveProviders,
1524
+ subscribePolicyChanges,
1276
1525
  providerViews,
1277
1526
  subscriptionAccounts,
1278
1527
  validate: async () => { await specs(); },
1279
- cached: (providerId) => cache.get(providerId)?.account ?? null
1528
+ cached: (providerId) => withHealthAge(cache.get(providerId)?.account ?? null, now())
1280
1529
  };
1281
1530
  }
1282
1531