@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/README.md +118 -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 +422 -164
- package/lib/balance.js +3 -5
- package/lib/billing.js +319 -0
- package/lib/client.js +626 -70
- 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/subscriptions.js +59 -1
- 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",
|
|
@@ -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
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
820
|
+
const [body, quotaStatus] = await Promise.all([
|
|
727
821
|
requestJson(new URL("/api/user/self", spec.baseURL).href, { headers }, deps),
|
|
728
|
-
|
|
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
|
-
|
|
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
|
|
737
|
-
...(
|
|
738
|
-
currency:
|
|
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:
|
|
749
|
-
quotaUnitFallback:
|
|
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
|
|
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
|
|
773
|
-
...(used === null ? {} : { used: used
|
|
774
|
-
...(granted === null ? {} : { total: granted
|
|
775
|
-
currency:
|
|
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:
|
|
786
|
-
quotaUnitFallback:
|
|
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
|
|
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)
|
|
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
|
-
: "
|
|
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
|
-
|
|
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
|
|
1142
|
-
|
|
1143
|
-
|
|
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:
|
|
1147
|
-
|
|
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
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
|
1219
|
-
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);
|
|
1220
1446
|
return refresh(spec);
|
|
1221
1447
|
}
|
|
1222
1448
|
|
|
1223
|
-
async function
|
|
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
|
|
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
|
|
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
|
|