@ychris12138/dsh-usage-stats 0.2.6
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/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +17 -0
- package/cordis.patch.yml +5 -0
- package/docs/images/usage-panel.svg +176 -0
- package/lib/accounts.js +1272 -0
- package/lib/balance.js +126 -0
- package/lib/client.js +1531 -0
- package/lib/index.js +619 -0
- package/lib/subscriptions.js +610 -0
- package/lib/usage.js +276 -0
- package/package.json +72 -0
- package/scripts/install.mjs +142 -0
package/lib/accounts.js
ADDED
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified provider-account monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Adapters normalize monetary balances and subscription/token-plan windows to
|
|
5
|
+
* one discriminated account snapshot. Configuration is declarative: secrets
|
|
6
|
+
* are credential references, request paths are relative, and response fields
|
|
7
|
+
* are extracted with JSON Pointer rather than executable JavaScript.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-usage-stats/accounts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { balanceSchemeOf, queryBalance } from "./balance.js";
|
|
13
|
+
import { collectSubscription } from "./subscriptions.js";
|
|
14
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
15
|
+
import { request as httpRequest } from "node:http";
|
|
16
|
+
import { request as httpsRequest } from "node:https";
|
|
17
|
+
import { isIP } from "node:net";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
20
|
+
const DEFAULT_REFRESH_MS = 300000;
|
|
21
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
22
|
+
const OPENROUTER_MANAGEMENT_REF = "OPENROUTER_MANAGEMENT_KEY";
|
|
23
|
+
/**
|
|
24
|
+
* Real Sub2API panels expose a read-only public settings route used to detect
|
|
25
|
+
* the panel (auto-selecting the `sub2api-auth` adapter) and a balance route the
|
|
26
|
+
* provider's own inference API key can query — the same pattern as CC Switch's
|
|
27
|
+
* General usage template (GET {baseUrl}/user/balance with a Bearer api key).
|
|
28
|
+
*/
|
|
29
|
+
const SUB2API_BALANCE_PATH = "/user/balance";
|
|
30
|
+
const SUB2API_USAGE_STATS_PATH = "/api/v1/usage/stats?period=today";
|
|
31
|
+
const SUB2API_PUBLIC_SETTINGS_PATH = "/api/v1/settings/public";
|
|
32
|
+
const ACCOUNT_STATUSES = new Set([
|
|
33
|
+
"ok",
|
|
34
|
+
"not-configured",
|
|
35
|
+
"unauthorized",
|
|
36
|
+
"rate-limited",
|
|
37
|
+
"unavailable",
|
|
38
|
+
"invalid-response",
|
|
39
|
+
"blocked",
|
|
40
|
+
"unsupported"
|
|
41
|
+
]);
|
|
42
|
+
const ADAPTERS = new Set([
|
|
43
|
+
"deepseek-balance",
|
|
44
|
+
"openrouter-balance",
|
|
45
|
+
"moonshot-balance",
|
|
46
|
+
"zai-balance",
|
|
47
|
+
"general",
|
|
48
|
+
"new-api",
|
|
49
|
+
"sub2api",
|
|
50
|
+
"sub2api-auth",
|
|
51
|
+
"opencode-go",
|
|
52
|
+
"zai-token-plan",
|
|
53
|
+
"kimi-token-plan",
|
|
54
|
+
"minimax-token-plan",
|
|
55
|
+
"declarative"
|
|
56
|
+
]);
|
|
57
|
+
const SENSITIVE_HEADERS = new Set([
|
|
58
|
+
"authorization",
|
|
59
|
+
"api-key",
|
|
60
|
+
"cookie",
|
|
61
|
+
"host",
|
|
62
|
+
"proxy-authorization",
|
|
63
|
+
"proxy-authenticate",
|
|
64
|
+
"set-cookie",
|
|
65
|
+
"transfer-encoding",
|
|
66
|
+
"connection",
|
|
67
|
+
"upgrade",
|
|
68
|
+
"x-api-key"
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function nonEmptyString(value) {
|
|
72
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function numberOrNull(value) {
|
|
76
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
77
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
78
|
+
const parsed = Number(value);
|
|
79
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function booleanOrNull(value) {
|
|
85
|
+
if (typeof value === "boolean") return value;
|
|
86
|
+
if (value === 1 || value === "1" || value === "true") return true;
|
|
87
|
+
if (value === 0 || value === "0" || value === "false") return false;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function round1(value) {
|
|
92
|
+
return Math.round(value * 10) / 10;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toIso(value) {
|
|
96
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
97
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
98
|
+
const date = new Date(value < 20000000000 ? value * 1000 : value);
|
|
99
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
100
|
+
}
|
|
101
|
+
const date = new Date(String(value));
|
|
102
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function statusError(status, message, httpStatus, safeReason) {
|
|
106
|
+
const error = new Error(message);
|
|
107
|
+
error.providerStatus = status;
|
|
108
|
+
if (httpStatus !== void 0) error.httpStatus = httpStatus;
|
|
109
|
+
if (safeReason !== void 0) error.safeReason = safeReason;
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function statusOf(error) {
|
|
114
|
+
if (ACCOUNT_STATUSES.has(error?.providerStatus)) return error.providerStatus;
|
|
115
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") return "unavailable";
|
|
116
|
+
return "unavailable";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function safeReasonOf(error) {
|
|
120
|
+
const reason = nonEmptyString(error?.safeReason);
|
|
121
|
+
return reason === null ? null : reason.slice(0, 120);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function resolveCredential(credentials, ref) {
|
|
125
|
+
if (nonEmptyString(ref) === null || credentials === null || credentials === void 0 || typeof credentials.resolve !== "function") return "";
|
|
126
|
+
try {
|
|
127
|
+
const hit = await credentials.resolve(ref);
|
|
128
|
+
return nonEmptyString(hit?.value) ?? "";
|
|
129
|
+
} catch {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function responseStatus(status) {
|
|
135
|
+
if (status === 401 || status === 403) return "unauthorized";
|
|
136
|
+
if (status === 429) return "rate-limited";
|
|
137
|
+
if (status === 404 || status === 405) return "unsupported";
|
|
138
|
+
return status >= 500 ? "unavailable" : "invalid-response";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function parseJsonResponse(response, maxBytes = MAX_RESPONSE_BYTES) {
|
|
142
|
+
const declared = numberOrNull(response.headers?.get?.("content-length"));
|
|
143
|
+
if (declared !== null && declared > maxBytes) throw statusError("invalid-response", "upstream response exceeds the size limit", void 0, "upstream-too-large");
|
|
144
|
+
const contentType = response.headers?.get?.("content-type");
|
|
145
|
+
if (typeof contentType === "string" && contentType !== "" && !/\bjson\b/i.test(contentType)) {
|
|
146
|
+
throw statusError("invalid-response", "upstream did not return JSON", void 0, "upstream-not-json");
|
|
147
|
+
}
|
|
148
|
+
if (typeof response.arrayBuffer === "function") {
|
|
149
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
150
|
+
if (bytes.byteLength > maxBytes) throw statusError("invalid-response", "upstream response exceeds the size limit", void 0, "upstream-too-large");
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
153
|
+
} catch {
|
|
154
|
+
throw statusError("invalid-response", "upstream returned invalid JSON", void 0, "upstream-invalid-json");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return await response.json();
|
|
159
|
+
} catch {
|
|
160
|
+
throw statusError("invalid-response", "upstream returned invalid JSON", void 0, "upstream-invalid-json");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function requestJson(url, init, deps = {}) {
|
|
165
|
+
const response = await (deps.fetch ?? fetch)(url, {
|
|
166
|
+
...init,
|
|
167
|
+
redirect: "manual",
|
|
168
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
169
|
+
});
|
|
170
|
+
if (!response.ok) throw statusError(responseStatus(response.status), `upstream returned HTTP ${response.status}`, response.status);
|
|
171
|
+
return parseJsonResponse(response, deps.maxResponseBytes ?? MAX_RESPONSE_BYTES);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function schemeAdapter(scheme) {
|
|
175
|
+
return `${scheme}-balance`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function schemeOfAdapter(adapter) {
|
|
179
|
+
return adapter.endsWith("-balance") ? adapter.slice(0, -8) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
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
|
+
function adapterMode(adapter, monitor) {
|
|
200
|
+
if (adapter === "declarative") return monitor.mode;
|
|
201
|
+
if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan"].includes(adapter)) return "subscription";
|
|
202
|
+
return "balance";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertRelativePath(path, label) {
|
|
206
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) {
|
|
207
|
+
throw new Error(`${label} must be an absolute-path relative path beginning with /`);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const parsed = new URL(path, "https://usage.invalid");
|
|
211
|
+
if (parsed.origin !== "https://usage.invalid") throw new Error("origin changed");
|
|
212
|
+
} catch {
|
|
213
|
+
throw new Error(`${label} must be a relative path, not a URL`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validatePointer(pointer, label) {
|
|
218
|
+
if (pointer === void 0 || pointer === null) return;
|
|
219
|
+
const value = typeof pointer === "object" && pointer !== null ? pointer.pointer : pointer;
|
|
220
|
+
if (typeof value !== "string" || value !== "" && !value.startsWith("/")) throw new Error(`${label} must be a JSON Pointer`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateWarning(value, label) {
|
|
224
|
+
if (value === void 0) return;
|
|
225
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
226
|
+
for (const field of ["warnBelow", "criticalBelow"]) {
|
|
227
|
+
if (value[field] !== void 0 && numberOrNull(value[field]) === null) throw new Error(`${label}.${field} must be numeric`);
|
|
228
|
+
}
|
|
229
|
+
const warn = numberOrNull(value.warnBelow);
|
|
230
|
+
const critical = numberOrNull(value.criticalBelow);
|
|
231
|
+
if (warn !== null && critical !== null && critical > warn) throw new Error(`${label}.criticalBelow must not exceed warnBelow`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateDeclarative(monitor, label) {
|
|
235
|
+
if (monitor.mode !== "balance" && monitor.mode !== "subscription") throw new Error(`${label}.mode must be balance or subscription`);
|
|
236
|
+
if (monitor.request === null || typeof monitor.request !== "object" || Array.isArray(monitor.request)) throw new Error(`${label}.request must be an object`);
|
|
237
|
+
assertRelativePath(monitor.request.path, `${label}.request.path`);
|
|
238
|
+
if (monitor.request.method !== void 0 && monitor.request.method !== "GET") throw new Error(`${label}.request.method must be GET`);
|
|
239
|
+
const authType = monitor.request.auth?.type;
|
|
240
|
+
if (authType !== void 0 && !["bearer", "raw", "x-api-key"].includes(authType)) throw new Error(`${label}.request.auth.type is unsupported`);
|
|
241
|
+
for (const name of Object.keys(monitor.request.headers ?? {})) {
|
|
242
|
+
if (SENSITIVE_HEADERS.has(name.toLowerCase())) throw new Error(`${label}.request.headers cannot override ${name}`);
|
|
243
|
+
}
|
|
244
|
+
if (monitor.extract === null || typeof monitor.extract !== "object" || Array.isArray(monitor.extract)) throw new Error(`${label}.extract must be an object`);
|
|
245
|
+
for (const field of ["root", "valid", "invalidMessage", "plan", "remaining", "used", "total", "currency", "unlimited", "expiresAt", "items", "kind", "usedPercent", "remainingPercent", "resetsAt"]) {
|
|
246
|
+
validatePointer(monitor.extract[field], `${label}.extract.${field}`);
|
|
247
|
+
}
|
|
248
|
+
if (monitor.mode === "balance" && monitor.extract.remaining === void 0 && monitor.extract.total === void 0) throw new Error(`${label}.extract requires remaining or total`);
|
|
249
|
+
if (monitor.mode === "subscription" && monitor.extract.items === void 0) throw new Error(`${label}.extract.items is required`);
|
|
250
|
+
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
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Validate and freeze the non-secret account-monitor configuration shape. */
|
|
254
|
+
export function validateAccountConfig(raw = {}) {
|
|
255
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("account config must be an object");
|
|
256
|
+
const monitors = raw.monitors ?? {};
|
|
257
|
+
if (monitors === null || typeof monitors !== "object" || Array.isArray(monitors)) throw new Error("monitors must be an object keyed by provider id");
|
|
258
|
+
const normalized = {};
|
|
259
|
+
for (const [key, value] of Object.entries(monitors)) {
|
|
260
|
+
const label = `monitors.${key}`;
|
|
261
|
+
if (nonEmptyString(key) === null || value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
262
|
+
const providerId = nonEmptyString(value.providerId) ?? key;
|
|
263
|
+
const adapter = nonEmptyString(value.adapter);
|
|
264
|
+
if (adapter === null || !ADAPTERS.has(adapter)) throw new Error(`${label}.adapter is unsupported`);
|
|
265
|
+
if (value.usageBaseURL !== void 0) {
|
|
266
|
+
let url;
|
|
267
|
+
try { url = new URL(value.usageBaseURL); } catch { throw new Error(`${label}.usageBaseURL must be a valid URL`); }
|
|
268
|
+
if (url.username !== "" || url.password !== "") throw new Error(`${label}.usageBaseURL must not contain credentials`);
|
|
269
|
+
if (url.protocol !== "https:" && value.allowInsecure !== true) throw new Error(`${label}.usageBaseURL must use HTTPS unless allowInsecure is true`);
|
|
270
|
+
}
|
|
271
|
+
validateWarning(value.warning, `${label}.warning`);
|
|
272
|
+
if (adapter === "declarative") validateDeclarative(value, label);
|
|
273
|
+
normalized[providerId] = { ...value, providerId, adapter };
|
|
274
|
+
}
|
|
275
|
+
return { monitors: normalized };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Bind one configured Harness provider to its explicit or built-in adapter. */
|
|
279
|
+
export function resolveAccountSpec(provider, config = { monitors: {} }) {
|
|
280
|
+
const monitor = config.monitors?.[provider.id] ?? {};
|
|
281
|
+
const adapter = monitor.adapter ?? defaultAdapter(provider);
|
|
282
|
+
const mode = adapter === null ? null : adapterMode(adapter, monitor);
|
|
283
|
+
const apiKeyRef = monitor.credentialRef
|
|
284
|
+
?? (adapter === "openrouter-balance" ? OPENROUTER_MANAGEMENT_REF : provider.apiKeyEnv);
|
|
285
|
+
return {
|
|
286
|
+
id: provider.id,
|
|
287
|
+
displayName: provider.displayName ?? provider.id,
|
|
288
|
+
adapter,
|
|
289
|
+
mode,
|
|
290
|
+
// The apiKeyRef doubles as the "configured" indicator in provider views.
|
|
291
|
+
// sub2api-auth reuses the provider's own inference apiKeyEnv (CC Switch
|
|
292
|
+
// style), so the default apiKeyRef already points at it.
|
|
293
|
+
apiKeyRef,
|
|
294
|
+
baseURL: monitor.usageBaseURL ?? provider.baseURL,
|
|
295
|
+
providerBaseURL: provider.baseURL,
|
|
296
|
+
monitor,
|
|
297
|
+
configKey: JSON.stringify({ adapter, mode, provider, monitor })
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function decodePointerToken(token) {
|
|
302
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** RFC 6901 JSON Pointer lookup; missing paths return undefined. */
|
|
306
|
+
export function jsonPointer(value, pointer) {
|
|
307
|
+
if (pointer === "" || pointer === void 0 || pointer === null) return value;
|
|
308
|
+
if (typeof pointer !== "string" || !pointer.startsWith("/")) return void 0;
|
|
309
|
+
let current = value;
|
|
310
|
+
for (const raw of pointer.slice(1).split("/")) {
|
|
311
|
+
const key = decodePointerToken(raw);
|
|
312
|
+
if (current === null || current === void 0 || typeof current !== "object" || !Object.hasOwn(current, key)) return void 0;
|
|
313
|
+
current = current[key];
|
|
314
|
+
}
|
|
315
|
+
return current;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function mapped(root, mapping) {
|
|
319
|
+
if (mapping === void 0 || mapping === null) return void 0;
|
|
320
|
+
if (typeof mapping === "string") return jsonPointer(root, mapping);
|
|
321
|
+
if (typeof mapping === "object" && typeof mapping.pointer === "string") {
|
|
322
|
+
const value = jsonPointer(root, mapping.pointer);
|
|
323
|
+
const divisor = numberOrNull(mapping.divisor);
|
|
324
|
+
return divisor === null ? value : numberOrNull(value) === null ? void 0 : Number(value) / divisor;
|
|
325
|
+
}
|
|
326
|
+
return void 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
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
|
+
/**
|
|
398
|
+
* RFC 2544 benchmarking range commonly used by proxy fake-IP DNS.
|
|
399
|
+
*
|
|
400
|
+
* This range remains non-public for normal policy decisions. It is only
|
|
401
|
+
* accepted later as a proxy-synthetic DNS answer for HTTPS hostnames.
|
|
402
|
+
*/
|
|
403
|
+
function isBenchmarkFakeIpAddress(address) {
|
|
404
|
+
const value = String(address ?? "").trim().replace(/^\[|\]$/g, "");
|
|
405
|
+
if (isIP(value) !== 4) return false;
|
|
406
|
+
const [a, b] = value.split(".").map(Number);
|
|
407
|
+
return a === 198 && (b === 18 || b === 19);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A benchmarking-range address may represent a proxy/TUN synthetic mapping
|
|
412
|
+
* only when it came from resolving an HTTPS hostname.
|
|
413
|
+
*
|
|
414
|
+
* Literal https://198.18.x.x targets never enter this exception because the
|
|
415
|
+
* original URL hostname is itself an IP literal.
|
|
416
|
+
*/
|
|
417
|
+
function isHttpsProxySyntheticAddress(url, address) {
|
|
418
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
419
|
+
return url.protocol === "https:"
|
|
420
|
+
&& isIP(hostname) === 0
|
|
421
|
+
&& isBenchmarkFakeIpAddress(address);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Pick the connection address from validated DNS answers.
|
|
426
|
+
*
|
|
427
|
+
* Order of preference when private-network access is not explicitly enabled:
|
|
428
|
+
* 1. a genuinely public answer;
|
|
429
|
+
* 2. an IPv4 benchmarking-range (198.18.0.0/15) answer, only for an HTTPS
|
|
430
|
+
* hostname resolved through DNS (a Clash/Mihomo-style fake-IP mapping);
|
|
431
|
+
* 3. otherwise no usable address (reject).
|
|
432
|
+
*
|
|
433
|
+
* With allowPrivateNetwork the first answer is returned unchanged. This is a
|
|
434
|
+
* pure helper exported for offline policy tests; it performs no I/O.
|
|
435
|
+
*/
|
|
436
|
+
export function selectResolvedAddresses(url, rawAddresses, allowPrivateNetwork = false) {
|
|
437
|
+
const addresses = (
|
|
438
|
+
Array.isArray(rawAddresses)
|
|
439
|
+
? rawAddresses
|
|
440
|
+
: [rawAddresses]
|
|
441
|
+
).filter(
|
|
442
|
+
(entry) =>
|
|
443
|
+
typeof entry?.address === "string"
|
|
444
|
+
&& isIP(entry.address) !== 0
|
|
445
|
+
).map((entry) => ({
|
|
446
|
+
address: entry.address,
|
|
447
|
+
family: entry.family ?? isIP(entry.address)
|
|
448
|
+
}));
|
|
449
|
+
|
|
450
|
+
if (addresses.length === 0) return [];
|
|
451
|
+
if (allowPrivateNetwork) return addresses;
|
|
452
|
+
|
|
453
|
+
const publicAddresses = addresses.filter(
|
|
454
|
+
(entry) => !isPrivateAddress(entry.address)
|
|
455
|
+
);
|
|
456
|
+
if (publicAddresses.length > 0) return publicAddresses;
|
|
457
|
+
|
|
458
|
+
return addresses.filter(
|
|
459
|
+
(entry) => isHttpsProxySyntheticAddress(url, entry.address)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Backward-compatible single-address policy helper used by existing tests/callers. */
|
|
464
|
+
export function selectResolvedAddress(url, rawAddresses, allowPrivateNetwork = false) {
|
|
465
|
+
return selectResolvedAddresses(url, rawAddresses, allowPrivateNetwork)[0] ?? null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function resolvePublicAddresses(url, spec, deps) {
|
|
469
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
470
|
+
if (privateHostname(hostname) && spec.monitor.allowPrivateNetwork !== true) throw statusError("blocked", "account monitor private-network access requires allowPrivateNetwork");
|
|
471
|
+
if (isIP(hostname) !== 0) return [{ address: hostname, family: isIP(hostname) }];
|
|
472
|
+
let addresses;
|
|
473
|
+
try {
|
|
474
|
+
addresses = await (deps.lookup ?? dnsLookup)(hostname, { all: true, verbatim: true });
|
|
475
|
+
} catch {
|
|
476
|
+
throw statusError("unavailable", "account monitor hostname could not be resolved", void 0, "dns-resolution-failed");
|
|
477
|
+
}
|
|
478
|
+
if (!Array.isArray(addresses)) addresses = [addresses];
|
|
479
|
+
if (addresses.length === 0) throw statusError("unavailable", "account monitor hostname resolved to no addresses", void 0, "dns-resolution-failed");
|
|
480
|
+
const selected = selectResolvedAddresses(
|
|
481
|
+
url,
|
|
482
|
+
addresses,
|
|
483
|
+
spec.monitor.allowPrivateNetwork === true
|
|
484
|
+
);
|
|
485
|
+
if (selected.length === 0) {
|
|
486
|
+
throw statusError("blocked", "account monitor hostname resolves only to blocked network addresses");
|
|
487
|
+
}
|
|
488
|
+
return selected;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function crossOriginSensitive(spec) {
|
|
492
|
+
return spec.monitor.usageBaseURL !== void 0
|
|
493
|
+
|| spec.adapter === "general"
|
|
494
|
+
|| spec.adapter === "new-api"
|
|
495
|
+
|| spec.adapter === "sub2api-auth"
|
|
496
|
+
|| spec.adapter === "declarative"
|
|
497
|
+
|| schemeOfAdapter(spec.adapter ?? "") !== null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function assertTargetPolicy(rawUrl, spec, deps) {
|
|
501
|
+
const url = new URL(rawUrl);
|
|
502
|
+
if (url.username !== "" || url.password !== "") throw statusError("unsupported", "account monitor URL must not contain credentials");
|
|
503
|
+
if (url.protocol !== "https:" && spec.monitor.allowInsecure !== true) throw statusError("blocked", "account monitor requires HTTPS");
|
|
504
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw statusError("unsupported", "account monitor protocol is unsupported");
|
|
505
|
+
if (crossOriginSensitive(spec) && nonEmptyString(spec.providerBaseURL) !== null) {
|
|
506
|
+
const providerOrigin = new URL(spec.providerBaseURL).origin;
|
|
507
|
+
if (url.origin !== providerOrigin && spec.monitor.allowCrossOrigin !== true) throw statusError("blocked", "account monitor cross-origin access requires allowCrossOrigin");
|
|
508
|
+
}
|
|
509
|
+
const addresses = await resolvePublicAddresses(url, spec, deps);
|
|
510
|
+
return { url, addresses };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function responseHeaders(headers) {
|
|
514
|
+
return { get: (name) => {
|
|
515
|
+
const value = headers[String(name).toLowerCase()];
|
|
516
|
+
return Array.isArray(value) ? value.join(", ") : value === void 0 ? null : String(value);
|
|
517
|
+
} };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const RETRYABLE_CONNECTION_CODES = new Set([
|
|
521
|
+
"ENETUNREACH",
|
|
522
|
+
"EHOSTUNREACH",
|
|
523
|
+
"EADDRNOTAVAIL",
|
|
524
|
+
"ETIMEDOUT",
|
|
525
|
+
"ECONNREFUSED",
|
|
526
|
+
"ECONNRESET"
|
|
527
|
+
]);
|
|
528
|
+
|
|
529
|
+
function retryableConnectionError(error) {
|
|
530
|
+
return RETRYABLE_CONNECTION_CODES.has(error?.code);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function pinnedRequest(url, address, init, deps, signal) {
|
|
534
|
+
return new Promise((resolve, reject) => {
|
|
535
|
+
const transport = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
536
|
+
const request = transport(url, {
|
|
537
|
+
method: init?.method ?? "GET",
|
|
538
|
+
headers: init?.headers,
|
|
539
|
+
signal,
|
|
540
|
+
// Node 20+ enables network-family autoselection by default. Each outer
|
|
541
|
+
// attempt is already pinned to one policy-approved address, so disable
|
|
542
|
+
// the inner lookupAndConnectMultiple path and fix the intended family.
|
|
543
|
+
family: address.family,
|
|
544
|
+
autoSelectFamily: false,
|
|
545
|
+
servername: isIP(url.hostname.replace(/^\[|\]$/g, "")) === 0 ? url.hostname : void 0,
|
|
546
|
+
lookup: (_hostname, options, callback) => {
|
|
547
|
+
if (options?.all) callback(null, [address]);
|
|
548
|
+
else callback(null, address.address, address.family);
|
|
549
|
+
}
|
|
550
|
+
}, (response) => {
|
|
551
|
+
const chunks = [];
|
|
552
|
+
let size = 0;
|
|
553
|
+
response.on("data", (chunk) => {
|
|
554
|
+
size += chunk.length;
|
|
555
|
+
if (size > (deps.maxResponseBytes ?? MAX_RESPONSE_BYTES)) request.destroy(statusError("invalid-response", "upstream response exceeds the size limit"));
|
|
556
|
+
else chunks.push(chunk);
|
|
557
|
+
});
|
|
558
|
+
response.on("end", () => {
|
|
559
|
+
const body = Buffer.concat(chunks);
|
|
560
|
+
resolve({
|
|
561
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
562
|
+
status: response.statusCode,
|
|
563
|
+
headers: responseHeaders(response.headers),
|
|
564
|
+
arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
|
|
565
|
+
json: async () => JSON.parse(body.toString("utf8")),
|
|
566
|
+
text: async () => body.toString("utf8")
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
request.on("error", reject);
|
|
571
|
+
// Backstop: in the pinned-lookup path a connect-phase failure can surface
|
|
572
|
+
// directly on the socket before the request's own error forwarding has
|
|
573
|
+
// attached (#42 — an unhandled TLSSocket 'error' killed the whole dsh web
|
|
574
|
+
// process). Forward any socket-level error to the request so a transient
|
|
575
|
+
// network failure rejects this attempt instead of crashing the host.
|
|
576
|
+
request.on("socket", (socket) => {
|
|
577
|
+
socket.on("error", (error) => request.emit("error", error));
|
|
578
|
+
});
|
|
579
|
+
request.end();
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** HTTPS/HTTP transport that pins every attempted connection to an address already approved by the policy layer. */
|
|
584
|
+
async function pinnedFetch(rawUrl, init, spec, deps) {
|
|
585
|
+
const target = await assertTargetPolicy(rawUrl, spec, deps);
|
|
586
|
+
const signal = init?.signal ?? AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
587
|
+
const requestOne = deps.requestPinned ?? ((url, address, requestInit) => pinnedRequest(url, address, requestInit, deps, signal));
|
|
588
|
+
let lastRetryable = null;
|
|
589
|
+
for (const address of target.addresses) {
|
|
590
|
+
try {
|
|
591
|
+
return await requestOne(target.url, address, init, signal);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (!retryableConnectionError(error)) throw error;
|
|
594
|
+
lastRetryable = error;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (lastRetryable !== null) {
|
|
598
|
+
throw statusError("unavailable", "account monitor could not connect to any validated address", void 0, "all-addresses-unreachable");
|
|
599
|
+
}
|
|
600
|
+
throw statusError("unavailable", "account monitor has no validated connection address", void 0, "no-validated-address");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function customURL(spec) {
|
|
604
|
+
const base = new URL(spec.baseURL);
|
|
605
|
+
const providerBase = nonEmptyString(spec.providerBaseURL) === null ? null : new URL(spec.providerBaseURL);
|
|
606
|
+
if (base.protocol !== "https:" && spec.monitor.allowInsecure !== true) throw statusError("blocked", "custom monitor requires HTTPS");
|
|
607
|
+
if (privateHostname(base.hostname) && spec.monitor.allowPrivateNetwork !== true) throw statusError("blocked", "custom monitor private-network access requires allowPrivateNetwork");
|
|
608
|
+
if (providerBase !== null && base.origin !== providerBase.origin && spec.monitor.allowCrossOrigin !== true) throw statusError("blocked", "custom monitor cross-origin access requires allowCrossOrigin");
|
|
609
|
+
const url = new URL(spec.monitor.request.path, base);
|
|
610
|
+
if (url.origin !== base.origin) throw statusError("unsupported", "custom monitor request must stay on its configured origin");
|
|
611
|
+
return url.href;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function customHeaders(spec, credential) {
|
|
615
|
+
const headers = { accept: "application/json" };
|
|
616
|
+
for (const [name, value] of Object.entries(spec.monitor.request.headers ?? {})) {
|
|
617
|
+
if (!SENSITIVE_HEADERS.has(name.toLowerCase()) && typeof value === "string") headers[name] = value;
|
|
618
|
+
}
|
|
619
|
+
const type = spec.monitor.request.auth?.type;
|
|
620
|
+
if (credential !== "") {
|
|
621
|
+
if (type === "bearer") headers.authorization = `Bearer ${credential}`;
|
|
622
|
+
if (type === "raw") headers.authorization = credential;
|
|
623
|
+
if (type === "x-api-key") headers["x-api-key"] = credential;
|
|
624
|
+
}
|
|
625
|
+
return headers;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function balanceAlert(balance, warning) {
|
|
629
|
+
const remaining = numberOrNull(balance?.remaining);
|
|
630
|
+
const warnBelow = numberOrNull(warning?.warnBelow);
|
|
631
|
+
const criticalBelow = numberOrNull(warning?.criticalBelow);
|
|
632
|
+
if (remaining !== null && (warnBelow !== null || criticalBelow !== null)) {
|
|
633
|
+
if (criticalBelow !== null && remaining <= criticalBelow) return { level: "critical", metric: "balance", value: remaining, threshold: criticalBelow };
|
|
634
|
+
if (warnBelow !== null && remaining <= warnBelow) return { level: "warning", metric: "balance", value: remaining, threshold: warnBelow };
|
|
635
|
+
return { level: "normal", metric: "balance", value: remaining };
|
|
636
|
+
}
|
|
637
|
+
const total = numberOrNull(balance?.total);
|
|
638
|
+
if (remaining !== null && total !== null && total > 0) {
|
|
639
|
+
const value = round1(Math.max(0, Math.min(100, remaining / total * 100)));
|
|
640
|
+
return { level: value <= 10 ? "critical" : value <= 30 ? "warning" : "normal", metric: "remaining-percent", value };
|
|
641
|
+
}
|
|
642
|
+
return { level: "unknown", metric: "balance", value: remaining };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function subscriptionAlert(windows) {
|
|
646
|
+
const remaining = windows.map((entry) => numberOrNull(entry.remainingPercent)).filter((value) => value !== null);
|
|
647
|
+
if (remaining.length === 0) return { level: "unknown", metric: "remaining-percent", value: null };
|
|
648
|
+
const value = round1(Math.min(...remaining));
|
|
649
|
+
return { level: value <= 10 ? "critical" : value <= 30 ? "warning" : "normal", metric: "remaining-percent", value };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function baseSnapshot(spec, status, now) {
|
|
653
|
+
return {
|
|
654
|
+
id: spec.id,
|
|
655
|
+
displayName: spec.displayName,
|
|
656
|
+
mode: spec.mode ?? "balance",
|
|
657
|
+
adapter: spec.adapter,
|
|
658
|
+
status,
|
|
659
|
+
fetchedAt: now
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function unavailableSnapshot(spec, status, now, extra = {}) {
|
|
664
|
+
const base = baseSnapshot(spec, status, now);
|
|
665
|
+
if (base.mode === "subscription") return { ...base, windows: [], alert: subscriptionAlert([]), ...extra };
|
|
666
|
+
return { ...base, balance: null, alert: { level: "unknown", metric: "balance", value: null }, ...extra };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function queryBuiltInBalance(spec, credential, deps, now) {
|
|
670
|
+
const scheme = schemeOfAdapter(spec.adapter);
|
|
671
|
+
const raw = await queryBalance(scheme, spec.baseURL, credential, deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, deps.fetch ?? fetch);
|
|
672
|
+
const remaining = numberOrNull(raw.total);
|
|
673
|
+
if (remaining === null) throw statusError("invalid-response", "balance response is missing a numeric amount");
|
|
674
|
+
const used = numberOrNull(raw.used);
|
|
675
|
+
const total = numberOrNull(raw.limit);
|
|
676
|
+
const balance = {
|
|
677
|
+
remaining,
|
|
678
|
+
...(used === null ? {} : { used }),
|
|
679
|
+
...(total === null ? {} : { total }),
|
|
680
|
+
currency: nonEmptyString(raw.currency) ?? "USD",
|
|
681
|
+
unlimited: false,
|
|
682
|
+
expiresAt: null,
|
|
683
|
+
available: raw.isAvailable !== false,
|
|
684
|
+
breakdown: {
|
|
685
|
+
granted: numberOrNull(raw.granted),
|
|
686
|
+
toppedUp: numberOrNull(raw.toppedUp)
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
// DeepSeek's explicit `is_available` flag is an upstream account state.
|
|
690
|
+
// Other schemes infer this field from a numeric zero balance, which remains a
|
|
691
|
+
// valid successful response and should still render the critical balance.
|
|
692
|
+
const status = scheme === "deepseek" && raw.isAvailable === false ? "unavailable" : "ok";
|
|
693
|
+
return { ...baseSnapshot(spec, status, now), balance, alert: balanceAlert(balance, spec.monitor.warning) };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function queryGeneral(spec, credential, deps, now) {
|
|
697
|
+
const body = await requestJson(new URL("/user/balance", spec.baseURL).href, {
|
|
698
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/json" }
|
|
699
|
+
}, deps);
|
|
700
|
+
const remaining = numberOrNull(body?.balance);
|
|
701
|
+
if (remaining === null) throw statusError("invalid-response", "general balance response is missing balance");
|
|
702
|
+
const balance = { remaining, currency: nonEmptyString(body?.currency) ?? "USD", unlimited: false, expiresAt: null };
|
|
703
|
+
return { ...baseSnapshot(spec, "ok", now), balance, alert: balanceAlert(balance, spec.monitor.warning) };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function quotaPerUnit(spec, deps) {
|
|
707
|
+
try {
|
|
708
|
+
const body = await requestJson(new URL("/api/status", spec.baseURL).href, { headers: { accept: "application/json" } }, deps);
|
|
709
|
+
const value = numberOrNull(body?.data?.quota_per_unit);
|
|
710
|
+
if (value !== null && value > 0) return { value, fallback: false };
|
|
711
|
+
// Old status schemas did not expose quota_per_unit.
|
|
712
|
+
return { value: 500000, fallback: true };
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (error?.httpStatus === 404 || error?.httpStatus === 405) return { value: 500000, fallback: true };
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function queryNewApiFallback(spec, credentials, deps, now) {
|
|
720
|
+
const ref = spec.monitor.fallbackCredentialRef;
|
|
721
|
+
const token = await resolveCredential(credentials, ref);
|
|
722
|
+
if (token === "") return unavailableSnapshot(spec, "unsupported", now, { missingCredentials: ref === void 0 ? [] : [ref] });
|
|
723
|
+
const headers = { authorization: `Bearer ${token}`, accept: "application/json" };
|
|
724
|
+
const userId = await resolveCredential(credentials, spec.monitor.fallbackUserIdRef);
|
|
725
|
+
if (userId !== "") headers["new-api-user"] = userId;
|
|
726
|
+
const [body, quotaUnit] = await Promise.all([
|
|
727
|
+
requestJson(new URL("/api/user/self", spec.baseURL).href, { headers }, deps),
|
|
728
|
+
quotaPerUnit(spec, deps)
|
|
729
|
+
]);
|
|
730
|
+
const unit = quotaUnit.value;
|
|
731
|
+
if (body?.success === false || body?.data === null || typeof body?.data !== "object") throw statusError("invalid-response", "New API user response is invalid");
|
|
732
|
+
const remainingQuota = numberOrNull(body.data.quota);
|
|
733
|
+
const usedQuota = numberOrNull(body.data.used_quota);
|
|
734
|
+
if (remainingQuota === null) throw statusError("invalid-response", "New API user response is missing quota");
|
|
735
|
+
const balance = {
|
|
736
|
+
remaining: remainingQuota / unit,
|
|
737
|
+
...(usedQuota === null ? {} : { used: usedQuota / unit, total: (remainingQuota + usedQuota) / unit }),
|
|
738
|
+
currency: "USD",
|
|
739
|
+
unlimited: false,
|
|
740
|
+
expiresAt: null
|
|
741
|
+
};
|
|
742
|
+
return {
|
|
743
|
+
...baseSnapshot(spec, "ok", now),
|
|
744
|
+
plan: nonEmptyString(body.data.group) ?? void 0,
|
|
745
|
+
balance,
|
|
746
|
+
alert: balanceAlert(balance, spec.monitor.warning),
|
|
747
|
+
source: "management-fallback",
|
|
748
|
+
quotaUnit: unit,
|
|
749
|
+
quotaUnitFallback: quotaUnit.fallback
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function queryNewApi(spec, credentials, credential, deps, now) {
|
|
754
|
+
let body;
|
|
755
|
+
try {
|
|
756
|
+
body = await requestJson(new URL("/api/usage/token/", spec.baseURL).href, {
|
|
757
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/json" }
|
|
758
|
+
}, deps);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error?.httpStatus === 404 || error?.httpStatus === 405) return queryNewApiFallback(spec, credentials, deps, now);
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
if (body?.code !== true || body?.data === null || typeof body?.data !== "object") throw statusError("invalid-response", "New API token response is invalid");
|
|
764
|
+
const granted = numberOrNull(body.data.total_granted);
|
|
765
|
+
const used = numberOrNull(body.data.total_used);
|
|
766
|
+
const available = numberOrNull(body.data.total_available);
|
|
767
|
+
const quotaUnit = await quotaPerUnit(spec, deps);
|
|
768
|
+
const unit = quotaUnit.value;
|
|
769
|
+
const unlimited = booleanOrNull(body.data.unlimited_quota) === true;
|
|
770
|
+
if (!unlimited && available === null) throw statusError("invalid-response", "New API token response is missing total_available");
|
|
771
|
+
const balance = {
|
|
772
|
+
remaining: available === null ? null : available / unit,
|
|
773
|
+
...(used === null ? {} : { used: used / unit }),
|
|
774
|
+
...(granted === null ? {} : { total: granted / unit }),
|
|
775
|
+
currency: "USD",
|
|
776
|
+
unlimited,
|
|
777
|
+
expiresAt: numberOrNull(body.data.expires_at) > 0 ? toIso(body.data.expires_at) : null
|
|
778
|
+
};
|
|
779
|
+
return {
|
|
780
|
+
...baseSnapshot(spec, "ok", now),
|
|
781
|
+
plan: nonEmptyString(body.data.name) ?? void 0,
|
|
782
|
+
balance,
|
|
783
|
+
alert: unlimited ? { level: "normal", metric: "remaining-percent", value: 100 } : balanceAlert(balance, spec.monitor.warning),
|
|
784
|
+
source: "token",
|
|
785
|
+
quotaUnit: unit,
|
|
786
|
+
quotaUnitFallback: quotaUnit.fallback
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function amountWindow(kind, usedValue, limitValue, remainingValue, resetsAt) {
|
|
791
|
+
const limit = numberOrNull(limitValue);
|
|
792
|
+
if (limit === null || limit <= 0) return null;
|
|
793
|
+
const remaining = numberOrNull(remainingValue);
|
|
794
|
+
const used = numberOrNull(usedValue) ?? (remaining === null ? null : limit - remaining);
|
|
795
|
+
if (used === null) return null;
|
|
796
|
+
const usedPercent = round1(Math.max(0, Math.min(100, used / limit * 100)));
|
|
797
|
+
const reset = toIso(resetsAt);
|
|
798
|
+
return {
|
|
799
|
+
kind,
|
|
800
|
+
usedPercent,
|
|
801
|
+
remainingPercent: round1(100 - usedPercent),
|
|
802
|
+
...(reset === null ? {} : { resetsAt: reset })
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function sub2ApiWindowKind(value) {
|
|
807
|
+
const kind = nonEmptyString(value) ?? "quota";
|
|
808
|
+
if (kind === "5h") return "session";
|
|
809
|
+
if (kind === "1d") return "daily";
|
|
810
|
+
if (kind === "7d") return "weekly";
|
|
811
|
+
return kind;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function sub2ApiSubscription(spec, body, now) {
|
|
815
|
+
const windows = [];
|
|
816
|
+
if (body.mode === "quota_limited") {
|
|
817
|
+
const quota = body.quota;
|
|
818
|
+
if (quota === null || typeof quota !== "object" || Array.isArray(quota)) {
|
|
819
|
+
throw statusError("invalid-response", "Sub2API quota response is missing quota");
|
|
820
|
+
}
|
|
821
|
+
const total = amountWindow("quota", quota.used, quota.limit, quota.remaining, body.expires_at);
|
|
822
|
+
if (total !== null) windows.push(total);
|
|
823
|
+
for (const entry of Array.isArray(body.rate_limits) ? body.rate_limits : []) {
|
|
824
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
825
|
+
const window = amountWindow(sub2ApiWindowKind(entry.window), entry.used, entry.limit, entry.remaining, entry.reset_at);
|
|
826
|
+
if (window !== null) windows.push(window);
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
const subscription = body.subscription;
|
|
830
|
+
if (subscription === null || typeof subscription !== "object" || Array.isArray(subscription)) {
|
|
831
|
+
throw statusError("invalid-response", "Sub2API subscription response is missing subscription limits");
|
|
832
|
+
}
|
|
833
|
+
for (const period of ["daily", "weekly", "monthly"]) {
|
|
834
|
+
const window = amountWindow(
|
|
835
|
+
period,
|
|
836
|
+
subscription[`${period}_usage_usd`],
|
|
837
|
+
subscription[`${period}_limit_usd`],
|
|
838
|
+
null,
|
|
839
|
+
null
|
|
840
|
+
);
|
|
841
|
+
if (window !== null) windows.push(window);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (windows.length === 0) throw statusError("invalid-response", "Sub2API response has no usable quota windows");
|
|
845
|
+
return {
|
|
846
|
+
...baseSnapshot(spec, "ok", now),
|
|
847
|
+
mode: "subscription",
|
|
848
|
+
plan: nonEmptyString(body.planName) ?? nonEmptyString(body.plan_name) ?? "Sub2API",
|
|
849
|
+
windows,
|
|
850
|
+
alert: subscriptionAlert(windows)
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Parse a Sub2API /v1/usage body into a normalized account snapshot (balance or subscription windows). */
|
|
855
|
+
function parseSub2ApiUsage(spec, body, now) {
|
|
856
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) throw statusError("invalid-response", "Sub2API response must be an object");
|
|
857
|
+
if (body.isValid === false || body.is_active === false) throw statusError("unauthorized", "Sub2API key is invalid");
|
|
858
|
+
const hasSubscription = body.subscription !== null && typeof body.subscription === "object" && !Array.isArray(body.subscription);
|
|
859
|
+
if (body.mode === "quota_limited" || hasSubscription) return sub2ApiSubscription(spec, body, now);
|
|
860
|
+
const remaining = numberOrNull(body.balance ?? body.remaining);
|
|
861
|
+
if (remaining === null) throw statusError("invalid-response", "Sub2API response is missing a numeric balance");
|
|
862
|
+
const balance = {
|
|
863
|
+
remaining,
|
|
864
|
+
currency: nonEmptyString(body.unit) ?? "USD",
|
|
865
|
+
unlimited: false,
|
|
866
|
+
expiresAt: toIso(body.expires_at)
|
|
867
|
+
};
|
|
868
|
+
return {
|
|
869
|
+
...baseSnapshot(spec, "ok", now),
|
|
870
|
+
mode: "balance",
|
|
871
|
+
plan: nonEmptyString(body.planName) ?? nonEmptyString(body.plan_name) ?? void 0,
|
|
872
|
+
balance,
|
|
873
|
+
alert: balanceAlert(balance, spec.monitor.warning)
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function querySub2Api(spec, credential, deps, now) {
|
|
878
|
+
const body = await requestJson(new URL("/v1/usage", spec.baseURL).href, {
|
|
879
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/json" }
|
|
880
|
+
}, deps);
|
|
881
|
+
return parseSub2ApiUsage(spec, body, now);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Parse the Sub2API dashboard `{ code, message, data }` envelope.
|
|
886
|
+
*
|
|
887
|
+
* Sub2API reports business failures with HTTP 200 and a non-zero `code`, and
|
|
888
|
+
* envelopes can be missing entirely on some endpoints, so callers decide how
|
|
889
|
+
* strictly to validate the `data` payload. `code === 0` means success.
|
|
890
|
+
*/
|
|
891
|
+
function sub2apiEnvelope(body) {
|
|
892
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) throw statusError("invalid-response", "Sub2API response must be an object");
|
|
893
|
+
const code = body.code;
|
|
894
|
+
const message = typeof body.message === "string" ? body.message : "";
|
|
895
|
+
if (code !== 0) {
|
|
896
|
+
const err = statusError("invalid-response", message !== "" ? `Sub2API: ${message}` : "Sub2API returned a business error");
|
|
897
|
+
if (/invalid|unauthor|password|credential|login|expired|refresh/i.test(message)) err.providerStatus = "unauthorized";
|
|
898
|
+
throw err;
|
|
899
|
+
}
|
|
900
|
+
return body;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Detection cache for auto-detected Sub2API panels, keyed by the provider's
|
|
905
|
+
* config key so we only probe once per (provider × config) even across the
|
|
906
|
+
* five-minute background refreshes.
|
|
907
|
+
*/
|
|
908
|
+
function sub2apiDetection(deps) {
|
|
909
|
+
if (deps.sub2apiDetection === void 0 || deps.sub2apiDetection === null) deps.sub2apiDetection = new Map();
|
|
910
|
+
return deps.sub2apiDetection;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Probe whether a relay endpoint is a real Sub2API panel.
|
|
915
|
+
*
|
|
916
|
+
* Real Sub2API panels expose the public `GET /api/v1/settings/public` route
|
|
917
|
+
* (envelope `{ code: 0, data: { affiliate_enabled: boolean } }`) which neither
|
|
918
|
+
* One/New-API nor passion-style gateways provide, so this is a cheap, read-only,
|
|
919
|
+
* capability fingerprint for auto-detection. Results are cached per config key.
|
|
920
|
+
*/
|
|
921
|
+
async function probeSub2ApiPanel(spec, deps) {
|
|
922
|
+
const cache = sub2apiDetection(deps);
|
|
923
|
+
const key = spec.configKey;
|
|
924
|
+
if (cache.has(key)) return cache.get(key);
|
|
925
|
+
let detected = false;
|
|
926
|
+
try {
|
|
927
|
+
const body = await requestJson(new URL(SUB2API_PUBLIC_SETTINGS_PATH, spec.baseURL).href, {
|
|
928
|
+
headers: { accept: "application/json" }
|
|
929
|
+
}, deps);
|
|
930
|
+
const envelope = sub2apiEnvelope(body);
|
|
931
|
+
const settings = envelope?.data;
|
|
932
|
+
detected = settings !== null && typeof settings === "object" && !Array.isArray(settings)
|
|
933
|
+
&& typeof settings.affiliate_enabled === "boolean";
|
|
934
|
+
} catch {
|
|
935
|
+
detected = false;
|
|
936
|
+
}
|
|
937
|
+
cache.set(key, detected);
|
|
938
|
+
return detected;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Build a sub2api-auth spec from an auto-detected panel's provider. It reuses
|
|
943
|
+
* the provider's own inference apiKeyEnv (already configured in the model) —
|
|
944
|
+
* the same credential model as CC Switch's General usage template — so no
|
|
945
|
+
* separate panel credential is needed. The apiKeyRef stays the provider's.
|
|
946
|
+
*/
|
|
947
|
+
function sub2apiAuthSpec(spec) {
|
|
948
|
+
return {
|
|
949
|
+
...spec,
|
|
950
|
+
adapter: "sub2api-auth",
|
|
951
|
+
mode: "balance"
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Query a Sub2API panel's balance with the provider's own inference API key.
|
|
957
|
+
*
|
|
958
|
+
* No separate dashboard credential is required — the model-configured apiKeyEnv
|
|
959
|
+
* is reused. Two key-accessible endpoints are tried in order:
|
|
960
|
+
* 1. `GET {baseUrl}/user/balance` (CC Switch General shape, reads
|
|
961
|
+
* `response.balance`); some panels expose this and some do not.
|
|
962
|
+
* 2. `GET {baseUrl}/v1/usage` (the legacy Sub2API/passion shape parsed by
|
|
963
|
+
* `parseSub2ApiUsage`), which real panels commonly expose.
|
|
964
|
+
* Panels whose SPA serves HTML for unknown routes (returning non-JSON for
|
|
965
|
+
* `/user/balance`) fall through to `/v1/usage` instead of failing.
|
|
966
|
+
*/
|
|
967
|
+
async function querySub2ApiAuth(spec, credentials, deps, now) {
|
|
968
|
+
const credential = await resolveCredential(credentials, spec.apiKeyRef);
|
|
969
|
+
if (credential === "") return unavailableSnapshot(spec, "not-configured", now, { missingCredentials: [spec.apiKeyRef === void 0 ? "<apiKey>" : spec.apiKeyRef] });
|
|
970
|
+
|
|
971
|
+
// Try the CC Switch General /user/balance first; only "the route does not
|
|
972
|
+
// exist or is not JSON here" failures fall through to /v1/usage. Security
|
|
973
|
+
// policy, TLS, connection, rate-limit and auth failures are real errors and
|
|
974
|
+
// must not be silently swallowed — a masked security failure would make the
|
|
975
|
+
// panel look fine while the fallback endpoint hides the problem.
|
|
976
|
+
let balanceBody = null;
|
|
977
|
+
try {
|
|
978
|
+
balanceBody = await requestJson(new URL(SUB2API_BALANCE_PATH, spec.baseURL).href, {
|
|
979
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/json" }
|
|
980
|
+
}, deps);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
const fallbackable = error?.providerStatus === "unsupported"
|
|
983
|
+
|| error?.providerStatus === "invalid-response";
|
|
984
|
+
if (!fallbackable) throw error;
|
|
985
|
+
balanceBody = null;
|
|
986
|
+
}
|
|
987
|
+
const remaining = balanceBody === null ? null : (numberOrNull(balanceBody?.balance)
|
|
988
|
+
?? numberOrNull(balanceBody?.data?.balance)
|
|
989
|
+
?? numberOrNull(balanceBody?.remaining)
|
|
990
|
+
?? numberOrNull(balanceBody?.data?.remaining));
|
|
991
|
+
if (remaining !== null) {
|
|
992
|
+
// Today's actual cost is optional; when unavailable the account still shows balance.
|
|
993
|
+
let used = null;
|
|
994
|
+
try {
|
|
995
|
+
const usage = await requestJson(new URL(SUB2API_USAGE_STATS_PATH, spec.baseURL).href, {
|
|
996
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/json" }
|
|
997
|
+
}, deps);
|
|
998
|
+
const cost = numberOrNull(usage?.data?.total_actual_cost);
|
|
999
|
+
if (cost !== null) used = cost;
|
|
1000
|
+
} catch {
|
|
1001
|
+
// Usage is supplementary; a failure here must not hide the balance.
|
|
1002
|
+
}
|
|
1003
|
+
const balance = {
|
|
1004
|
+
remaining,
|
|
1005
|
+
...(used === null ? {} : { used }),
|
|
1006
|
+
currency: nonEmptyString(balanceBody?.unit) ?? "USD",
|
|
1007
|
+
unlimited: false,
|
|
1008
|
+
expiresAt: null
|
|
1009
|
+
};
|
|
1010
|
+
return {
|
|
1011
|
+
...baseSnapshot(spec, "ok", now),
|
|
1012
|
+
...(nonEmptyString(balanceBody?.planName) === null ? {} : { plan: balanceBody.planName }),
|
|
1013
|
+
balance,
|
|
1014
|
+
alert: balanceAlert(balance, spec.monitor.warning)
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Fall back to the panel's own /v1/usage (same model API key).
|
|
1019
|
+
try {
|
|
1020
|
+
const usageBody = await requestJson(new URL("/v1/usage", spec.baseURL).href, {
|
|
1021
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/json" }
|
|
1022
|
+
}, deps);
|
|
1023
|
+
return parseSub2ApiUsage(spec, usageBody, now);
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
// Fixed, bounded diagnostic: /user/balance was reachable but returned an
|
|
1026
|
+
// unrecognized shape. Never include upstream-controlled content (JSON
|
|
1027
|
+
// property names, values, messages) in safeReason — a hostile upstream
|
|
1028
|
+
// could otherwise echo sensitive material across the server→browser
|
|
1029
|
+
// boundary, since safeReasonOf() only truncates.
|
|
1030
|
+
if (balanceBody !== null && typeof balanceBody === "object") {
|
|
1031
|
+
error.safeReason = "sub2api-balance-shape-unrecognized";
|
|
1032
|
+
}
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function customBalance(spec, body, now) {
|
|
1038
|
+
const extract = spec.monitor.extract;
|
|
1039
|
+
const root = jsonPointer(body, extract.root ?? "");
|
|
1040
|
+
if (root === void 0) throw statusError("invalid-response", "custom response root is missing");
|
|
1041
|
+
const valid = mapped(root, extract.valid);
|
|
1042
|
+
if (valid === false) throw statusError("invalid-response", String(mapped(root, extract.invalidMessage) ?? "custom response is marked invalid"));
|
|
1043
|
+
const divisor = numberOrNull(extract.divisor) ?? 1;
|
|
1044
|
+
const remainingRaw = numberOrNull(mapped(root, extract.remaining) ?? mapped(root, extract.total));
|
|
1045
|
+
if (remainingRaw === null) throw statusError("invalid-response", "custom response is missing a numeric balance");
|
|
1046
|
+
const usedRaw = numberOrNull(mapped(root, extract.used));
|
|
1047
|
+
const totalRaw = numberOrNull(mapped(root, extract.total));
|
|
1048
|
+
const balance = {
|
|
1049
|
+
remaining: remainingRaw / divisor,
|
|
1050
|
+
...(usedRaw === null ? {} : { used: usedRaw / divisor }),
|
|
1051
|
+
...(totalRaw === null ? {} : { total: totalRaw / divisor }),
|
|
1052
|
+
currency: nonEmptyString(mapped(root, extract.currency)) ?? nonEmptyString(extract.currencyValue) ?? "USD",
|
|
1053
|
+
unlimited: booleanOrNull(mapped(root, extract.unlimited)) === true,
|
|
1054
|
+
expiresAt: toIso(mapped(root, extract.expiresAt))
|
|
1055
|
+
};
|
|
1056
|
+
return { ...baseSnapshot(spec, "ok", now), plan: nonEmptyString(mapped(root, extract.plan)) ?? void 0, balance, alert: balanceAlert(balance, spec.monitor.warning) };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function customSubscription(spec, body, now) {
|
|
1060
|
+
const extract = spec.monitor.extract;
|
|
1061
|
+
const root = jsonPointer(body, extract.root ?? "");
|
|
1062
|
+
const items = mapped(root, extract.items);
|
|
1063
|
+
if (!Array.isArray(items)) throw statusError("invalid-response", "custom response items must be an array");
|
|
1064
|
+
const windows = [];
|
|
1065
|
+
for (const item of items) {
|
|
1066
|
+
const used = numberOrNull(mapped(item, extract.usedPercent));
|
|
1067
|
+
const remaining = numberOrNull(mapped(item, extract.remainingPercent));
|
|
1068
|
+
if (used === null && remaining === null) continue;
|
|
1069
|
+
const usedPercent = round1(Math.max(0, Math.min(100, used ?? 100 - remaining)));
|
|
1070
|
+
const remainingPercent = round1(Math.max(0, Math.min(100, remaining ?? 100 - used)));
|
|
1071
|
+
windows.push({
|
|
1072
|
+
kind: nonEmptyString(mapped(item, extract.kind)) ?? "quota",
|
|
1073
|
+
usedPercent,
|
|
1074
|
+
remainingPercent,
|
|
1075
|
+
...(toIso(mapped(item, extract.resetsAt)) === null ? {} : { resetsAt: toIso(mapped(item, extract.resetsAt)) })
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (windows.length === 0) throw statusError("invalid-response", "custom response has no usable quota windows");
|
|
1079
|
+
return { ...baseSnapshot(spec, "ok", now), plan: nonEmptyString(mapped(root, extract.plan)) ?? void 0, windows, alert: subscriptionAlert(windows) };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function queryDeclarative(spec, credentials, deps, now) {
|
|
1083
|
+
const ref = spec.monitor.request.auth?.credentialRef ?? spec.apiKeyRef;
|
|
1084
|
+
const credential = await resolveCredential(credentials, ref);
|
|
1085
|
+
if (spec.monitor.request.auth !== void 0 && credential === "") return unavailableSnapshot(spec, "not-configured", now, { missingCredentials: ref === void 0 ? [] : [ref] });
|
|
1086
|
+
const body = await requestJson(customURL(spec), { method: "GET", headers: customHeaders(spec, credential) }, deps);
|
|
1087
|
+
return spec.mode === "subscription" ? customSubscription(spec, body, now) : customBalance(spec, body, now);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Query one adapter and return a secret-free normalized account snapshot. */
|
|
1091
|
+
export async function queryAccount(spec, credentials, deps = {}) {
|
|
1092
|
+
const now = (deps.now ?? Date.now)();
|
|
1093
|
+
if (spec === null || spec === void 0) return unavailableSnapshot({ id: "unknown", displayName: "Unknown", adapter: null, mode: "balance" }, "unsupported", now);
|
|
1094
|
+
try {
|
|
1095
|
+
const safeDeps = deps.fetch === void 0 ? { ...deps, fetch: (url, init) => pinnedFetch(url, init, spec, deps) } : deps;
|
|
1096
|
+
// A relay provider with no built-in/explicit adapter may be a real
|
|
1097
|
+
// Sub2API panel. Only when it also has a model-configured API key do we
|
|
1098
|
+
// probe its public settings endpoint; a matching fingerprint selects the
|
|
1099
|
+
// sub2api-auth adapter, which reuses that same provider API key. Explicit
|
|
1100
|
+
// adapters always win, and unkeyed relays are never probed.
|
|
1101
|
+
if (spec.adapter === null || spec.mode === null) {
|
|
1102
|
+
const providerKey = await resolveCredential(credentials, spec.apiKeyRef);
|
|
1103
|
+
if (providerKey === "") return unavailableSnapshot(spec, "unsupported", now);
|
|
1104
|
+
const probeable = { ...spec, adapter: null, mode: "balance" };
|
|
1105
|
+
if (await probeSub2ApiPanel(probeable, safeDeps)) {
|
|
1106
|
+
return await querySub2ApiAuth(sub2apiAuthSpec(probeable), credentials, safeDeps, now);
|
|
1107
|
+
}
|
|
1108
|
+
return unavailableSnapshot(spec, "unsupported", now);
|
|
1109
|
+
}
|
|
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);
|
|
1112
|
+
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);
|
|
1118
|
+
const subscriptionId = spec.adapter === "zai-token-plan" ? "zai"
|
|
1119
|
+
: spec.adapter === "kimi-token-plan" ? "kimi"
|
|
1120
|
+
: spec.adapter === "minimax-token-plan" ? "minimax"
|
|
1121
|
+
: "opencode-go";
|
|
1122
|
+
const provider = await collectSubscription(subscriptionId, credentials, {
|
|
1123
|
+
apiKeyRef: spec.apiKeyRef,
|
|
1124
|
+
region: spec.monitor.region
|
|
1125
|
+
?? (spec.adapter === "zai-token-plan" && String(spec.baseURL ?? "").includes("bigmodel.cn") ? "bigmodel-cn" : void 0)
|
|
1126
|
+
?? (spec.adapter === "minimax-token-plan" && String(spec.baseURL ?? "").includes("minimaxi.com") ? "cn" : void 0),
|
|
1127
|
+
baseURL: spec.monitor.usageBaseURL
|
|
1128
|
+
}, safeDeps);
|
|
1129
|
+
const windows = Array.isArray(provider.windows) ? provider.windows : [];
|
|
1130
|
+
return { ...baseSnapshot(spec, provider.status, now), plan: provider.plan, windows, alert: subscriptionAlert(windows), ...(provider.missingCredentials === void 0 ? {} : { missingCredentials: provider.missingCredentials }), ...(provider.reason === void 0 ? {} : { reason: provider.reason }) };
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
const reason = safeReasonOf(error);
|
|
1133
|
+
return unavailableSnapshot(spec, statusOf(error), now, reason === null ? {} : { reason });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function isTransient(status) {
|
|
1138
|
+
return status === "unavailable" || status === "rate-limited" || status === "invalid-response";
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function withStaleData(previous, current) {
|
|
1142
|
+
if (previous?.status !== "ok" || !isTransient(current.status)) return current;
|
|
1143
|
+
return {
|
|
1144
|
+
...previous,
|
|
1145
|
+
status: current.status,
|
|
1146
|
+
fetchedAt: current.fetchedAt,
|
|
1147
|
+
lastSuccessAt: previous.lastSuccessAt ?? previous.fetchedAt,
|
|
1148
|
+
stale: true
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* In-memory account cache with per-provider single-flight and forced bulk
|
|
1154
|
+
* refresh. Background scheduling is owned by the server plugin so it can also
|
|
1155
|
+
* refresh local token-usage aggregation in the same five-minute cycle.
|
|
1156
|
+
*/
|
|
1157
|
+
export function createAccountService({ credentials, getProviders, config = { monitors: {} }, deps = {} }) {
|
|
1158
|
+
const cache = new Map();
|
|
1159
|
+
const inflight = new Map();
|
|
1160
|
+
const refreshMs = deps.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
1161
|
+
// Long-lived Sub2API panel-detection cache, keyed by the provider's config
|
|
1162
|
+
// key. It lives on the service so auto-detection probes once per
|
|
1163
|
+
// (provider × config) even across five-minute background refreshes; a caller
|
|
1164
|
+
// may still inject its own Map (e.g. tests) by passing deps.sub2apiDetection.
|
|
1165
|
+
const sub2apiDetection = deps.sub2apiDetection ?? new Map();
|
|
1166
|
+
const serviceDeps = { ...deps, sub2apiDetection };
|
|
1167
|
+
|
|
1168
|
+
async function specs() {
|
|
1169
|
+
const providers = [...await getProviders()];
|
|
1170
|
+
if (deps.includeLegacyProviders !== false) {
|
|
1171
|
+
if (!providers.some((provider) => provider.id === "opencode-go")) providers.push({ id: "opencode-go", displayName: "OpenCode Go", apiKeyEnv: "OPENCODE_GO_API_KEY" });
|
|
1172
|
+
if (!providers.some((provider) => provider.id === "zai" || provider.id === "zai-coding-cn")) providers.push({ id: "zai", displayName: "Z.ai", apiKeyEnv: "ZAI_API_KEY", baseURL: "https://api.z.ai" });
|
|
1173
|
+
}
|
|
1174
|
+
const known = new Set(providers.map((provider) => provider.id));
|
|
1175
|
+
// Settings-backed providers can become visible after this plugin's
|
|
1176
|
+
// initial validation. A monitor with an explicit endpoint and credential
|
|
1177
|
+
// reference is self-contained, so materialize it as a provider instead of
|
|
1178
|
+
// failing startup on a transient provider-registry race.
|
|
1179
|
+
for (const [providerId, monitor] of Object.entries(config.monitors ?? {})) {
|
|
1180
|
+
if (known.has(providerId)) continue;
|
|
1181
|
+
const baseURL = nonEmptyString(monitor.usageBaseURL);
|
|
1182
|
+
const apiKeyEnv = nonEmptyString(monitor.credentialRef);
|
|
1183
|
+
if (baseURL !== null && apiKeyEnv !== null) {
|
|
1184
|
+
providers.push({
|
|
1185
|
+
id: providerId,
|
|
1186
|
+
displayName: nonEmptyString(monitor.displayName) ?? providerId,
|
|
1187
|
+
apiKeyEnv,
|
|
1188
|
+
baseURL
|
|
1189
|
+
});
|
|
1190
|
+
known.add(providerId);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const unknown = Object.keys(config.monitors ?? {}).filter((providerId) => !known.has(providerId));
|
|
1194
|
+
if (unknown.length > 0) throw new Error(`account monitor references unknown provider: ${unknown.join(", ")}`);
|
|
1195
|
+
return providers.map((provider) => resolveAccountSpec(provider, config));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function specById(providerId) {
|
|
1199
|
+
return (await specs()).find((spec) => spec.id === providerId) ?? null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function refresh(spec) {
|
|
1203
|
+
const existing = inflight.get(spec.id);
|
|
1204
|
+
if (existing !== void 0) return existing;
|
|
1205
|
+
const promise = queryAccount(spec, credentials, serviceDeps).then((current) => {
|
|
1206
|
+
const next = withStaleData(cache.get(spec.id)?.account, current);
|
|
1207
|
+
cache.set(spec.id, { configKey: spec.configKey, account: next });
|
|
1208
|
+
return next;
|
|
1209
|
+
}).finally(() => inflight.delete(spec.id));
|
|
1210
|
+
inflight.set(spec.id, promise);
|
|
1211
|
+
return promise;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
async function get(providerId, { force = false } = {}) {
|
|
1215
|
+
const spec = await specById(providerId);
|
|
1216
|
+
if (spec === null) return null;
|
|
1217
|
+
const hit = cache.get(providerId);
|
|
1218
|
+
const age = (deps.now ?? Date.now)() - (hit?.account?.fetchedAt ?? 0);
|
|
1219
|
+
if (!force && hit?.configKey === spec.configKey && age >= 0 && age < refreshMs) return hit.account;
|
|
1220
|
+
return refresh(spec);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function refreshAll() {
|
|
1224
|
+
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
|
+
const keyed = await Promise.all(all.map(async (spec) => ({
|
|
1229
|
+
spec,
|
|
1230
|
+
probe: spec.adapter === null
|
|
1231
|
+
? (await resolveCredential(credentials, spec.apiKeyRef)) !== ""
|
|
1232
|
+
: true
|
|
1233
|
+
})));
|
|
1234
|
+
return Promise.all(keyed.filter((entry) => entry.probe).map((entry) => refresh(entry.spec)));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function providerViews() {
|
|
1238
|
+
return Promise.all((await specs()).map(async (spec) => {
|
|
1239
|
+
const account = cache.get(spec.id)?.account;
|
|
1240
|
+
const credentialConfigured = account === void 0 && spec.apiKeyRef !== void 0
|
|
1241
|
+
? await resolveCredential(credentials, spec.apiKeyRef) !== ""
|
|
1242
|
+
: false;
|
|
1243
|
+
return {
|
|
1244
|
+
id: spec.id,
|
|
1245
|
+
displayName: spec.displayName,
|
|
1246
|
+
accountMode: account?.mode ?? spec.mode,
|
|
1247
|
+
adapter: spec.adapter ?? account?.adapter ?? null,
|
|
1248
|
+
configured: account === void 0 ? credentialConfigured : account.status !== "not-configured",
|
|
1249
|
+
status: account?.status ?? "pending",
|
|
1250
|
+
fetchedAt: account?.fetchedAt ?? null,
|
|
1251
|
+
alert: account?.alert ?? null
|
|
1252
|
+
};
|
|
1253
|
+
}));
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async function subscriptionAccounts() {
|
|
1257
|
+
const all = await specs();
|
|
1258
|
+
const accounts = await Promise.all(all.filter((spec) => spec.mode === "subscription" || spec.adapter === "sub2api").map((spec) => get(spec.id)));
|
|
1259
|
+
return accounts.filter((account) => account?.mode === "subscription");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return {
|
|
1263
|
+
get,
|
|
1264
|
+
refreshAll,
|
|
1265
|
+
providerViews,
|
|
1266
|
+
subscriptionAccounts,
|
|
1267
|
+
validate: async () => { await specs(); },
|
|
1268
|
+
cached: (providerId) => cache.get(providerId)?.account ?? null
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export const ACCOUNT_REFRESH_MS = DEFAULT_REFRESH_MS;
|