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