@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/README.md +99 -20
- package/SECURITY.md +3 -1
- package/docs/release-checklist.md +106 -0
- package/docs/release-notes-v0.3.0.md +25 -0
- package/lib/accounts.js +418 -171
- package/lib/balance.js +3 -5
- package/lib/billing.js +319 -0
- package/lib/client.js +622 -69
- package/lib/export.js +227 -0
- package/lib/index.js +435 -63
- package/lib/network.js +65 -0
- package/lib/pricing.js +391 -0
- package/lib/provider-identity.js +126 -0
- package/lib/usage.js +190 -13
- package/package.json +15 -5
package/lib/accounts.js
CHANGED
|
@@ -9,15 +9,46 @@
|
|
|
9
9
|
* @module dsh-usage-stats/accounts
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
820
|
+
const [body, quotaStatus] = await Promise.all([
|
|
737
821
|
requestJson(new URL("/api/user/self", spec.baseURL).href, { headers }, deps),
|
|
738
|
-
|
|
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
|
-
|
|
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
|
|
747
|
-
...(
|
|
748
|
-
currency:
|
|
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:
|
|
759
|
-
quotaUnitFallback:
|
|
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
|
|
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
|
|
783
|
-
...(used === null ? {} : { used: used
|
|
784
|
-
...(granted === null ? {} : { total: granted
|
|
785
|
-
currency:
|
|
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:
|
|
796
|
-
quotaUnitFallback:
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
1153
|
-
|
|
1154
|
-
|
|
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:
|
|
1158
|
-
|
|
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
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
|
1230
|
-
if (!force && hit?.configKey === spec.configKey
|
|
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
|
|
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
|
|
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
|
|
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
|
|