@ychris12138/dsh-usage-stats 0.2.9 → 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",
@@ -52,6 +100,7 @@ const ADAPTERS = new Set([
52
100
  "zai-token-plan",
53
101
  "kimi-token-plan",
54
102
  "minimax-token-plan",
103
+ "ollama",
55
104
  "declarative"
56
105
  ]);
57
106
  const SENSITIVE_HEADERS = new Set([
@@ -92,6 +141,48 @@ function round1(value) {
92
141
  return Math.round(value * 10) / 10;
93
142
  }
94
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
+
95
186
  function toIso(value) {
96
187
  if (value === null || value === void 0 || value === "") return null;
97
188
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -118,7 +209,34 @@ function statusOf(error) {
118
209
 
119
210
  function safeReasonOf(error) {
120
211
  const reason = nonEmptyString(error?.safeReason);
121
- 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;
122
240
  }
123
241
 
124
242
  async function resolveCredential(credentials, ref) {
@@ -171,34 +289,13 @@ async function requestJson(url, init, deps = {}) {
171
289
  return parseJsonResponse(response, deps.maxResponseBytes ?? MAX_RESPONSE_BYTES);
172
290
  }
173
291
 
174
- function schemeAdapter(scheme) {
175
- return `${scheme}-balance`;
176
- }
177
-
178
292
  function schemeOfAdapter(adapter) {
179
293
  return adapter.endsWith("-balance") ? adapter.slice(0, -8) : null;
180
294
  }
181
295
 
182
- function defaultAdapter(provider) {
183
- const providerId = provider.id;
184
- if (providerId === "opencode-go") return "opencode-go";
185
- if (providerId === "zai" || providerId === "zai-coding-cn") return "zai-token-plan";
186
- if (providerId === "kimi-coding" || providerId === "kimi-for-coding") return "kimi-token-plan";
187
- if (["minimax", "minimaxi", "minimax-cn", "minimax-coding"].includes(providerId)) return "minimax-token-plan";
188
- if (providerId === "passion") return "sub2api";
189
- try {
190
- const hostname = new URL(provider.baseURL).hostname.toLowerCase();
191
- if (hostname === "passionapi.com" || hostname.endsWith(".passionapi.com")) return "sub2api";
192
- } catch {
193
- // A malformed provider URL is handled by the adapter when it is queried.
194
- }
195
- const scheme = balanceSchemeOf(providerId);
196
- return scheme === null ? null : schemeAdapter(scheme);
197
- }
198
-
199
296
  function adapterMode(adapter, monitor) {
200
297
  if (adapter === "declarative") return monitor.mode;
201
- if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan"].includes(adapter)) return "subscription";
298
+ if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan", "ollama"].includes(adapter)) return "subscription";
202
299
  return "balance";
203
300
  }
204
301
 
@@ -250,9 +347,34 @@ function validateDeclarative(monitor, label) {
250
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`);
251
348
  }
252
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
+
253
374
  /** Validate and freeze the non-secret account-monitor configuration shape. */
254
375
  export function validateAccountConfig(raw = {}) {
255
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);
256
378
  const monitors = raw.monitors ?? {};
257
379
  if (monitors === null || typeof monitors !== "object" || Array.isArray(monitors)) throw new Error("monitors must be an object keyed by provider id");
258
380
  const normalized = {};
@@ -272,13 +394,13 @@ export function validateAccountConfig(raw = {}) {
272
394
  if (adapter === "declarative") validateDeclarative(value, label);
273
395
  normalized[providerId] = { ...value, providerId, adapter };
274
396
  }
275
- return { monitors: normalized };
397
+ return { monitors: normalized, refresh };
276
398
  }
277
399
 
278
400
  /** Bind one configured Harness provider to its explicit or built-in adapter. */
279
401
  export function resolveAccountSpec(provider, config = { monitors: {} }) {
280
402
  const monitor = config.monitors?.[provider.id] ?? {};
281
- const adapter = monitor.adapter ?? defaultAdapter(provider);
403
+ const adapter = resolveProviderIdentity(provider, config).accountAdapter;
282
404
  const mode = adapter === null ? null : adapterMode(adapter, monitor);
283
405
  const apiKeyRef = monitor.credentialRef
284
406
  ?? (adapter === "openrouter-balance" ? OPENROUTER_MANAGEMENT_REF : provider.apiKeyEnv);
@@ -326,74 +448,6 @@ function mapped(root, mapping) {
326
448
  return void 0;
327
449
  }
328
450
 
329
- function ipv4Private(octets) {
330
- const [a, b, c] = octets;
331
- return a === 0
332
- || a === 10
333
- || a === 127
334
- || a === 169 && b === 254
335
- || a === 172 && b >= 16 && b <= 31
336
- || a === 192 && b === 168
337
- || a === 192 && b === 0 && (c === 0 || c === 2)
338
- || a === 192 && b === 88 && c === 99
339
- || a === 100 && b >= 64 && b <= 127
340
- || a === 198 && (b === 18 || b === 19)
341
- || a === 198 && b === 51 && c === 100
342
- || a === 203 && b === 0 && c === 113
343
- || a >= 224;
344
- }
345
-
346
- function ipv6Bytes(address) {
347
- let value = address.toLowerCase().split("%")[0];
348
- let ipv4Tail = null;
349
- const lastColon = value.lastIndexOf(":");
350
- if (value.slice(lastColon + 1).includes(".")) {
351
- const octets = value.slice(lastColon + 1).split(".").map(Number);
352
- if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
353
- ipv4Tail = [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]];
354
- value = `${value.slice(0, lastColon)}:${ipv4Tail[0].toString(16)}:${ipv4Tail[1].toString(16)}`;
355
- }
356
- const halves = value.split("::");
357
- if (halves.length > 2) return null;
358
- const left = halves[0] === "" ? [] : halves[0].split(":");
359
- const right = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
360
- const missing = 8 - left.length - right.length;
361
- if (missing < 0 || halves.length === 1 && missing !== 0) return null;
362
- const words = [...left, ...Array(missing).fill("0"), ...right].map((part) => Number.parseInt(part || "0", 16));
363
- if (words.length !== 8 || words.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) return null;
364
- const bytes = [];
365
- for (const word of words) bytes.push(word >> 8, word & 0xff);
366
- return bytes;
367
- }
368
-
369
- /** True for loopback, private, link-local, documentation, multicast, and other non-public IP space. */
370
- export function isPrivateAddress(address) {
371
- const value = String(address ?? "").trim().replace(/^\[|\]$/g, "");
372
- if (isIP(value) === 4) return ipv4Private(value.split(".").map(Number));
373
- if (isIP(value) !== 6) return false;
374
- const bytes = ipv6Bytes(value);
375
- if (bytes === null) return true;
376
- if (bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff) return ipv4Private(bytes.slice(12));
377
- // Public provider endpoints should resolve to global unicast (2000::/3).
378
- // This conservative allow-range excludes loopback/unspecified, NAT64,
379
- // discard-only, ULA, link/site-local, multicast, and other special space.
380
- const globalUnicast = (bytes[0] & 0xe0) === 0x20;
381
- const word0 = (bytes[0] << 8) | bytes[1];
382
- const word1 = (bytes[2] << 8) | bytes[3];
383
- // IETF protocol assignments 2001:0000::/23 include benchmarking, ORCHID,
384
- // and tunnel mechanisms; 2002::/16 (6to4) embeds an unchecked IPv4 target.
385
- const ietfSpecial = word0 === 0x2001 && word1 <= 0x01ff;
386
- const sixToFour = word0 === 0x2002;
387
- const documentation = word0 === 0x2001 && word1 === 0x0db8
388
- || word0 === 0x3fff && (word1 & 0xf000) === 0;
389
- return !globalUnicast || ietfSpecial || sixToFour || documentation;
390
- }
391
-
392
- function privateHostname(hostname) {
393
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
394
- return host === "localhost" || host.endsWith(".localhost") || isPrivateAddress(host);
395
- }
396
-
397
451
  /**
398
452
  * RFC 2544 benchmarking range commonly used by proxy fake-IP DNS.
399
453
  *
@@ -467,7 +521,7 @@ export function selectResolvedAddress(url, rawAddresses, allowPrivateNetwork = f
467
521
 
468
522
  async function resolvePublicAddresses(url, spec, deps) {
469
523
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
470
- 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");
471
525
  if (isIP(hostname) !== 0) return [{ address: hostname, family: isIP(hostname) }];
472
526
  let addresses;
473
527
  try {
@@ -552,7 +606,7 @@ function pinnedRequest(url, address, init, deps, signal) {
552
606
  let size = 0;
553
607
  response.on("data", (chunk) => {
554
608
  size += chunk.length;
555
- 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"));
556
610
  else chunks.push(chunk);
557
611
  });
558
612
  response.on("end", () => {
@@ -604,7 +658,7 @@ function customURL(spec) {
604
658
  const base = new URL(spec.baseURL);
605
659
  const providerBase = nonEmptyString(spec.providerBaseURL) === null ? null : new URL(spec.providerBaseURL);
606
660
  if (base.protocol !== "https:" && spec.monitor.allowInsecure !== true) throw statusError("blocked", "custom monitor requires HTTPS");
607
- 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");
608
662
  if (providerBase !== null && base.origin !== providerBase.origin && spec.monitor.allowCrossOrigin !== true) throw statusError("blocked", "custom monitor cross-origin access requires allowCrossOrigin");
609
663
  const url = new URL(spec.monitor.request.path, base);
610
664
  if (url.origin !== base.origin) throw statusError("unsupported", "custom monitor request must stay on its configured origin");
@@ -655,6 +709,7 @@ function baseSnapshot(spec, status, now) {
655
709
  displayName: spec.displayName,
656
710
  mode: spec.mode ?? "balance",
657
711
  adapter: spec.adapter,
712
+ provenance: accountProvenance(spec),
658
713
  status,
659
714
  fetchedAt: now
660
715
  };
@@ -703,19 +758,58 @@ async function queryGeneral(spec, credential, deps, now) {
703
758
  return { ...baseSnapshot(spec, "ok", now), balance, alert: balanceAlert(balance, spec.monitor.warning) };
704
759
  }
705
760
 
706
- 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) {
707
797
  try {
708
798
  const body = await requestJson(new URL("/api/status", spec.baseURL).href, { headers: { accept: "application/json" } }, deps);
709
- const value = numberOrNull(body?.data?.quota_per_unit);
710
- if (value !== null && value > 0) return { value, fallback: false };
711
- // Old status schemas did not expose quota_per_unit.
712
- return { value: 500000, fallback: true };
799
+ return normalizeNewApiQuotaStatus(body);
713
800
  } catch (error) {
714
- if (error?.httpStatus === 404 || error?.httpStatus === 405) return { value: 500000, fallback: true };
801
+ if (error?.httpStatus === 404 || error?.httpStatus === 405) return legacyNewApiQuotaStatus();
715
802
  throw error;
716
803
  }
717
804
  }
718
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
+
719
813
  async function queryNewApiFallback(spec, credentials, deps, now) {
720
814
  const ref = spec.monitor.fallbackCredentialRef;
721
815
  const token = await resolveCredential(credentials, ref);
@@ -723,19 +817,20 @@ async function queryNewApiFallback(spec, credentials, deps, now) {
723
817
  const headers = { authorization: `Bearer ${token}`, accept: "application/json" };
724
818
  const userId = await resolveCredential(credentials, spec.monitor.fallbackUserIdRef);
725
819
  if (userId !== "") headers["new-api-user"] = userId;
726
- const [body, quotaUnit] = await Promise.all([
820
+ const [body, quotaStatus] = await Promise.all([
727
821
  requestJson(new URL("/api/user/self", spec.baseURL).href, { headers }, deps),
728
- quotaPerUnit(spec, deps)
822
+ queryNewApiQuotaStatus(spec, deps)
729
823
  ]);
730
- const unit = quotaUnit.value;
731
824
  if (body?.success === false || body?.data === null || typeof body?.data !== "object") throw statusError("invalid-response", "New API user response is invalid");
732
825
  const remainingQuota = numberOrNull(body.data.quota);
733
826
  const usedQuota = numberOrNull(body.data.used_quota);
734
- 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");
735
830
  const balance = {
736
- remaining: remainingQuota / unit,
737
- ...(usedQuota === null ? {} : { used: usedQuota / unit, total: (remainingQuota + usedQuota) / unit }),
738
- currency: "USD",
831
+ remaining,
832
+ ...(used === null ? {} : { used, total: newApiQuotaAmount(remainingQuota + usedQuota, quotaStatus) }),
833
+ currency: quotaStatus.displayType,
739
834
  unlimited: false,
740
835
  expiresAt: null
741
836
  };
@@ -745,8 +840,8 @@ async function queryNewApiFallback(spec, credentials, deps, now) {
745
840
  balance,
746
841
  alert: balanceAlert(balance, spec.monitor.warning),
747
842
  source: "management-fallback",
748
- quotaUnit: unit,
749
- quotaUnitFallback: quotaUnit.fallback
843
+ quotaUnit: quotaStatus.quotaPerUnit,
844
+ quotaUnitFallback: quotaStatus.quotaUnitFallback
750
845
  };
751
846
  }
752
847
 
@@ -764,15 +859,14 @@ async function queryNewApi(spec, credentials, credential, deps, now) {
764
859
  const granted = numberOrNull(body.data.total_granted);
765
860
  const used = numberOrNull(body.data.total_used);
766
861
  const available = numberOrNull(body.data.total_available);
767
- const quotaUnit = await quotaPerUnit(spec, deps);
768
- const unit = quotaUnit.value;
862
+ const quotaStatus = await queryNewApiQuotaStatus(spec, deps);
769
863
  const unlimited = booleanOrNull(body.data.unlimited_quota) === true;
770
864
  if (!unlimited && available === null) throw statusError("invalid-response", "New API token response is missing total_available");
771
865
  const balance = {
772
- remaining: available === null ? null : available / unit,
773
- ...(used === null ? {} : { used: used / unit }),
774
- ...(granted === null ? {} : { total: granted / unit }),
775
- 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,
776
870
  unlimited,
777
871
  expiresAt: numberOrNull(body.data.expires_at) > 0 ? toIso(body.data.expires_at) : null
778
872
  };
@@ -782,8 +876,8 @@ async function queryNewApi(spec, credentials, credential, deps, now) {
782
876
  balance,
783
877
  alert: unlimited ? { level: "normal", metric: "remaining-percent", value: 100 } : balanceAlert(balance, spec.monitor.warning),
784
878
  source: "token",
785
- quotaUnit: unit,
786
- quotaUnitFallback: quotaUnit.fallback
879
+ quotaUnit: quotaStatus.quotaPerUnit,
880
+ quotaUnitFallback: quotaStatus.quotaUnitFallback
787
881
  };
788
882
  }
789
883
 
@@ -948,7 +1042,8 @@ function sub2apiAuthSpec(spec) {
948
1042
  return {
949
1043
  ...spec,
950
1044
  adapter: "sub2api-auth",
951
- mode: "balance"
1045
+ mode: "balance",
1046
+ provenanceHint: "experimental"
952
1047
  };
953
1048
  }
954
1049
 
@@ -1026,7 +1121,7 @@ async function querySub2ApiAuth(spec, credentials, deps, now) {
1026
1121
  // unrecognized shape. Never include upstream-controlled content (JSON
1027
1122
  // property names, values, messages) in safeReason — a hostile upstream
1028
1123
  // could otherwise echo sensitive material across the server→browser
1029
- // boundary, since safeReasonOf() only truncates.
1124
+ // boundary. safeReasonOf() accepts only the fixed vocabulary above.
1030
1125
  if (balanceBody !== null && typeof balanceBody === "object") {
1031
1126
  error.safeReason = "sub2api-balance-shape-unrecognized";
1032
1127
  }
@@ -1090,9 +1185,28 @@ async function queryDeclarative(spec, credentials, deps, now) {
1090
1185
  /** Query one adapter and return a secret-free normalized account snapshot. */
1091
1186
  export async function queryAccount(spec, credentials, deps = {}) {
1092
1187
  const now = (deps.now ?? Date.now)();
1093
- 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
+ });
1094
1209
  try {
1095
- const safeDeps = deps.fetch === void 0 ? { ...deps, fetch: (url, init) => pinnedFetch(url, init, spec, deps) } : deps;
1096
1210
  // A relay provider with no built-in/explicit adapter may be a real
1097
1211
  // Sub2API panel. Only when it also has a model-configured API key do we
1098
1212
  // probe its public settings endpoint; a matching fingerprint selects the
@@ -1100,25 +1214,26 @@ export async function queryAccount(spec, credentials, deps = {}) {
1100
1214
  // adapters always win, and unkeyed relays are never probed.
1101
1215
  if (spec.adapter === null || spec.mode === null) {
1102
1216
  const providerKey = await resolveCredential(credentials, spec.apiKeyRef);
1103
- if (providerKey === "") return unavailableSnapshot(spec, "unsupported", now);
1217
+ if (providerKey === "") return finish(unavailableSnapshot(spec, "unsupported", now), false);
1104
1218
  const probeable = { ...spec, adapter: null, mode: "balance" };
1105
1219
  if (await probeSub2ApiPanel(probeable, safeDeps)) {
1106
- return await querySub2ApiAuth(sub2apiAuthSpec(probeable), credentials, safeDeps, now);
1220
+ return finish(await querySub2ApiAuth(sub2apiAuthSpec(probeable), credentials, safeDeps, now));
1107
1221
  }
1108
- return unavailableSnapshot(spec, "unsupported", now);
1222
+ return finish(unavailableSnapshot(spec, "unsupported", now), false);
1109
1223
  }
1110
- if (spec.adapter === "declarative") return await queryDeclarative(spec, credentials, safeDeps, now);
1111
- 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));
1112
1226
  const credential = await resolveCredential(credentials, spec.apiKeyRef);
1113
- if (spec.adapter !== "opencode-go" && credential === "") return unavailableSnapshot(spec, "not-configured", now, { missingCredentials: spec.apiKeyRef === void 0 ? [] : [spec.apiKeyRef] });
1114
- if (schemeOfAdapter(spec.adapter) !== null) return await queryBuiltInBalance(spec, credential, safeDeps, now);
1115
- if (spec.adapter === "general") return await queryGeneral(spec, credential, safeDeps, now);
1116
- if (spec.adapter === "new-api") return await queryNewApi(spec, credentials, credential, safeDeps, now);
1117
- 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));
1118
1232
  const subscriptionId = spec.adapter === "zai-token-plan" ? "zai"
1119
1233
  : spec.adapter === "kimi-token-plan" ? "kimi"
1120
1234
  : spec.adapter === "minimax-token-plan" ? "minimax"
1121
- : "opencode-go";
1235
+ : spec.adapter === "ollama" ? "ollama"
1236
+ : "opencode-go";
1122
1237
  const provider = await collectSubscription(subscriptionId, credentials, {
1123
1238
  apiKeyRef: spec.apiKeyRef,
1124
1239
  region: spec.monitor.region
@@ -1127,10 +1242,11 @@ export async function queryAccount(spec, credentials, deps = {}) {
1127
1242
  baseURL: spec.monitor.usageBaseURL
1128
1243
  }, safeDeps);
1129
1244
  const windows = Array.isArray(provider.windows) ? provider.windows : [];
1130
- 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 }) });
1131
1247
  } catch (error) {
1132
1248
  const reason = safeReasonOf(error);
1133
- return unavailableSnapshot(spec, statusOf(error), now, reason === null ? {} : { reason });
1249
+ return finish(unavailableSnapshot(spec, statusOf(error), now, reason === null ? {} : { reason }), false);
1134
1250
  }
1135
1251
  }
1136
1252
 
@@ -1138,29 +1254,65 @@ function isTransient(status) {
1138
1254
  return status === "unavailable" || status === "rate-limited" || status === "invalid-response";
1139
1255
  }
1140
1256
 
1141
- function withStaleData(previous, current) {
1142
- if (previous?.status !== "ok" || !isTransient(current.status)) return current;
1143
- 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 = {
1144
1276
  ...previous,
1145
1277
  status: current.status,
1146
- fetchedAt: current.fetchedAt,
1147
- lastSuccessAt: previous.lastSuccessAt ?? previous.fetchedAt,
1278
+ fetchedAt: attemptAt,
1279
+ lastAttemptAt: attemptAt,
1280
+ lastSuccessAt,
1281
+ provenance: current.provenance ?? previous.provenance ?? "unknown",
1148
1282
  stale: true
1149
1283
  };
1284
+ delete stale.ageMs;
1285
+ if (current.reason === void 0) delete stale.reason;
1286
+ else stale.reason = current.reason;
1287
+ return stale;
1150
1288
  }
1151
1289
 
1152
1290
  /**
1153
- * In-memory account cache with per-provider single-flight and forced bulk
1154
- * refresh. Background scheduling is owned by the server plugin so it can also
1155
- * 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.
1156
1294
  */
1157
1295
  export function createAccountService({ credentials, getProviders, config = { monitors: {} }, deps = {} }) {
1158
1296
  const cache = new Map();
1159
1297
  const inflight = new Map();
1160
- 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;
1161
1313
  // Long-lived Sub2API panel-detection cache, keyed by the provider's config
1162
1314
  // key. It lives on the service so auto-detection probes once per
1163
- // (provider × config) even across five-minute background refreshes; a caller
1315
+ // (provider × config) even across background refreshes; a caller
1164
1316
  // may still inject its own Map (e.g. tests) by passing deps.sub2apiDetection.
1165
1317
  const sub2apiDetection = deps.sub2apiDetection ?? new Map();
1166
1318
  const serviceDeps = { ...deps, sub2apiDetection };
@@ -1199,44 +1351,139 @@ export function createAccountService({ credentials, getProviders, config = { mon
1199
1351
  return (await specs()).find((spec) => spec.id === providerId) ?? null;
1200
1352
  }
1201
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
+
1202
1402
  async function refresh(spec) {
1203
1403
  const existing = inflight.get(spec.id);
1204
- 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);
1205
1407
  const promise = queryAccount(spec, credentials, serviceDeps).then((current) => {
1206
- const next = withStaleData(cache.get(spec.id)?.account, current);
1207
- cache.set(spec.id, { configKey: spec.configKey, account: next });
1208
- return next;
1209
- }).finally(() => inflight.delete(spec.id));
1210
- inflight.set(spec.id, promise);
1211
- 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;
1212
1436
  }
1213
1437
 
1214
- async function get(providerId, { force = false } = {}) {
1438
+ async function get(providerId, { force = false, activity = null } = {}) {
1215
1439
  const spec = await specById(providerId);
1216
1440
  if (spec === null) return null;
1441
+ if (activity !== null) touch(providerId, activity);
1217
1442
  const hit = cache.get(providerId);
1218
- const age = (deps.now ?? Date.now)() - (hit?.account?.fetchedAt ?? 0);
1219
- 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);
1220
1446
  return refresh(spec);
1221
1447
  }
1222
1448
 
1223
- async function refreshAll() {
1449
+ async function refreshableSpecs() {
1224
1450
  const all = await specs();
1225
- // Auto-detection only probes null-adapter relays that have a configured
1226
- // API key, so the background refresh stays bounded and never touches
1227
- // unrelated, unkeyed providers.
1228
1451
  const keyed = await Promise.all(all.map(async (spec) => ({
1229
1452
  spec,
1230
1453
  probe: spec.adapter === null
1231
1454
  ? (await resolveCredential(credentials, spec.apiKeyRef)) !== ""
1232
1455
  : true
1233
1456
  })));
1234
- 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));
1235
1481
  }
1236
1482
 
1237
1483
  async function providerViews() {
1238
1484
  return Promise.all((await specs()).map(async (spec) => {
1239
- 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());
1240
1487
  const credentialConfigured = account === void 0 && spec.apiKeyRef !== void 0
1241
1488
  ? await resolveCredential(credentials, spec.apiKeyRef) !== ""
1242
1489
  : false;
@@ -1248,6 +1495,12 @@ export function createAccountService({ credentials, getProviders, config = { mon
1248
1495
  configured: account === void 0 ? credentialConfigured : account.status !== "not-configured",
1249
1496
  status: account?.status ?? "pending",
1250
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,
1251
1504
  alert: account?.alert ?? null
1252
1505
  };
1253
1506
  }));
@@ -1262,10 +1515,15 @@ export function createAccountService({ credentials, getProviders, config = { mon
1262
1515
  return {
1263
1516
  get,
1264
1517
  refreshAll,
1518
+ refreshDue,
1519
+ nextRefreshAt,
1520
+ touch,
1521
+ setActiveProviders,
1522
+ subscribePolicyChanges,
1265
1523
  providerViews,
1266
1524
  subscriptionAccounts,
1267
1525
  validate: async () => { await specs(); },
1268
- cached: (providerId) => cache.get(providerId)?.account ?? null
1526
+ cached: (providerId) => withHealthAge(cache.get(providerId)?.account ?? null, now())
1269
1527
  };
1270
1528
  }
1271
1529