@ychris12138/dsh-usage-stats 0.2.10 → 0.3.0

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