@tokenbuddy/tb-admin 1.0.14 → 1.0.27
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/dist/src/bootstrap-registry.d.ts +1 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -1
- package/dist/src/bootstrap-registry.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +294 -13
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +12 -3
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +12 -8
- package/dist/src/client.js.map +1 -1
- package/dist/src/display-format.d.ts +39 -0
- package/dist/src/display-format.d.ts.map +1 -0
- package/dist/src/display-format.js +354 -0
- package/dist/src/display-format.js.map +1 -0
- package/dist/src/server-cmd.d.ts +25 -1
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +116 -16
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +90 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +823 -0
- package/dist/src/ui-actions.js.map +1 -0
- package/dist/src/ui-command.d.ts +4 -0
- package/dist/src/ui-command.d.ts.map +1 -0
- package/dist/src/ui-command.js +37 -0
- package/dist/src/ui-command.js.map +1 -0
- package/dist/src/ui-server.d.ts +22 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +261 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +140 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +438 -0
- package/dist/src/ui-state.js.map +1 -0
- package/dist/src/ui-static.d.ts +2 -0
- package/dist/src/ui-static.d.ts.map +1 -0
- package/dist/src/ui-static.js +469 -0
- package/dist/src/ui-static.js.map +1 -0
- package/dist/src/upstream-balance-probe.d.ts +41 -0
- package/dist/src/upstream-balance-probe.d.ts.map +1 -0
- package/dist/src/upstream-balance-probe.js +379 -0
- package/dist/src/upstream-balance-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/bootstrap-registry.ts +1 -0
- package/src/cli.ts +335 -13
- package/src/client.ts +13 -8
- package/src/display-format.ts +398 -0
- package/src/server-cmd.ts +145 -20
- package/src/ui-actions.ts +958 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +322 -0
- package/src/ui-state.ts +614 -0
- package/src/ui-static.ts +472 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +1404 -2
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
export type BalanceCurrency = "USD" | "CNY" | null;
|
|
2
|
+
export type BalanceSource =
|
|
3
|
+
| "deepseek"
|
|
4
|
+
| "stepfun"
|
|
5
|
+
| "siliconflow"
|
|
6
|
+
| "openrouter"
|
|
7
|
+
| "novita"
|
|
8
|
+
| "newapi_generic"
|
|
9
|
+
| "usage_generic"
|
|
10
|
+
| "unknown";
|
|
11
|
+
|
|
12
|
+
export type BalanceProbeTemplate =
|
|
13
|
+
| "auto"
|
|
14
|
+
| "deepseek"
|
|
15
|
+
| "stepfun"
|
|
16
|
+
| "siliconflow"
|
|
17
|
+
| "openrouter"
|
|
18
|
+
| "novita"
|
|
19
|
+
| "newapi_generic"
|
|
20
|
+
| "usage_generic"
|
|
21
|
+
| "none";
|
|
22
|
+
|
|
23
|
+
export interface BalanceSnapshot {
|
|
24
|
+
rawAmount: number | null;
|
|
25
|
+
amountUsdMicros: number | null;
|
|
26
|
+
currency: BalanceCurrency;
|
|
27
|
+
source: BalanceSource;
|
|
28
|
+
fetchedAt: number;
|
|
29
|
+
error?: {
|
|
30
|
+
httpStatus: number;
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BalanceProbeConfig {
|
|
36
|
+
upstreamUrl?: string;
|
|
37
|
+
upstreamBalanceUrl?: string;
|
|
38
|
+
upstreamApiKey?: string;
|
|
39
|
+
upstreamUserId?: string;
|
|
40
|
+
upstreamBalanceProbe?: {
|
|
41
|
+
template?: BalanceProbeTemplate;
|
|
42
|
+
url?: string;
|
|
43
|
+
userId?: string;
|
|
44
|
+
rechargeUrl?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BalanceProbeOptions {
|
|
49
|
+
fetch?: typeof fetch;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
cnyUsdRate?: number;
|
|
53
|
+
cache?: BalanceProbeCache;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface BalanceRequestPlan {
|
|
57
|
+
source: BalanceSource;
|
|
58
|
+
url: string;
|
|
59
|
+
currency: Exclude<BalanceCurrency, null>;
|
|
60
|
+
requiresUserId: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 7000;
|
|
64
|
+
const FAILURE_CACHE_TTL_MS = 30000;
|
|
65
|
+
const DEFAULT_CNY_USD_RATE = 0.14;
|
|
66
|
+
|
|
67
|
+
export class BalanceProbeCache {
|
|
68
|
+
private readonly entries = new Map<string, { expiresAt: number; snapshot: BalanceSnapshot }>();
|
|
69
|
+
|
|
70
|
+
public get(key: string, now: number): BalanceSnapshot | undefined {
|
|
71
|
+
const entry = this.entries.get(key);
|
|
72
|
+
if (!entry || entry.expiresAt <= now) {
|
|
73
|
+
if (entry) {
|
|
74
|
+
this.entries.delete(key);
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return entry.snapshot;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public setFailure(key: string, snapshot: BalanceSnapshot, now: number): void {
|
|
82
|
+
this.entries.set(key, {
|
|
83
|
+
snapshot,
|
|
84
|
+
expiresAt: now + FAILURE_CACHE_TTL_MS
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const defaultBalanceProbeCache = new BalanceProbeCache();
|
|
90
|
+
|
|
91
|
+
export async function probeUpstreamBalance(
|
|
92
|
+
config: BalanceProbeConfig,
|
|
93
|
+
options: BalanceProbeOptions = {}
|
|
94
|
+
): Promise<BalanceSnapshot> {
|
|
95
|
+
const now = options.now || Date.now;
|
|
96
|
+
const fetchedAt = now();
|
|
97
|
+
const key = cacheKey(config);
|
|
98
|
+
const cache = options.cache || defaultBalanceProbeCache;
|
|
99
|
+
const cached = cache.get(key, fetchedAt);
|
|
100
|
+
if (cached) {
|
|
101
|
+
return cached;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const plan = requestPlan(config);
|
|
105
|
+
if (!plan) {
|
|
106
|
+
const snapshot = failure("unknown", fetchedAt, 0, "unsupported upstream - configure a balance parser");
|
|
107
|
+
cache.setFailure(key, snapshot, fetchedAt);
|
|
108
|
+
return snapshot;
|
|
109
|
+
}
|
|
110
|
+
if (!config.upstreamApiKey) {
|
|
111
|
+
const snapshot = failure(plan.source, fetchedAt, 0, "missing upstreamApiKey for balance probe");
|
|
112
|
+
cache.setFailure(key, snapshot, fetchedAt);
|
|
113
|
+
return snapshot;
|
|
114
|
+
}
|
|
115
|
+
const upstreamUserId = stringValue(config.upstreamBalanceProbe?.userId) || stringValue(config.upstreamUserId);
|
|
116
|
+
if (plan.requiresUserId && !upstreamUserId) {
|
|
117
|
+
const snapshot = failure(plan.source, fetchedAt, 0, "missing upstreamUserId for newapi upstream");
|
|
118
|
+
cache.setFailure(key, snapshot, fetchedAt);
|
|
119
|
+
return snapshot;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetchWithTimeout(
|
|
124
|
+
plan.url,
|
|
125
|
+
{
|
|
126
|
+
method: "GET",
|
|
127
|
+
headers: requestHeaders(config.upstreamApiKey, plan.requiresUserId ? upstreamUserId : undefined)
|
|
128
|
+
},
|
|
129
|
+
options.fetch || fetch,
|
|
130
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
131
|
+
);
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const snapshot = failure(plan.source, fetchedAt, response.status, httpErrorMessage(response.status));
|
|
134
|
+
cache.setFailure(key, snapshot, fetchedAt);
|
|
135
|
+
return snapshot;
|
|
136
|
+
}
|
|
137
|
+
const payload = await response.json() as unknown;
|
|
138
|
+
const snapshot = parseBalancePayload(payload, plan, fetchedAt, cnyUsdRate(options.cnyUsdRate));
|
|
139
|
+
if (snapshot.error) {
|
|
140
|
+
cache.setFailure(key, snapshot, fetchedAt);
|
|
141
|
+
}
|
|
142
|
+
return snapshot;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const snapshot = failure(plan.source, fetchedAt, 0, `network: ${err instanceof Error ? err.message : String(err)}`);
|
|
145
|
+
cache.setFailure(key, snapshot, fetchedAt);
|
|
146
|
+
return snapshot;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requestPlan(config: BalanceProbeConfig): BalanceRequestPlan | undefined {
|
|
151
|
+
const template = balanceProbeTemplateValue(config.upstreamBalanceProbe?.template);
|
|
152
|
+
if (template === "none") {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const balanceUrl = stringValue(config.upstreamBalanceProbe?.url) || stringValue(config.upstreamBalanceUrl);
|
|
156
|
+
const upstreamUrl = stringValue(config.upstreamUrl);
|
|
157
|
+
if (template && template !== "auto") {
|
|
158
|
+
return explicitRequestPlan(template, balanceUrl, upstreamUrl);
|
|
159
|
+
}
|
|
160
|
+
const host = hostName(balanceUrl || upstreamUrl);
|
|
161
|
+
if (host === "api.deepseek.com") {
|
|
162
|
+
return { source: "deepseek", url: "https://api.deepseek.com/user/balance", currency: "CNY", requiresUserId: false };
|
|
163
|
+
}
|
|
164
|
+
if (host === "api.stepfun.ai" || host === "api.stepfun.com") {
|
|
165
|
+
return { source: "stepfun", url: "https://api.stepfun.com/v1/accounts", currency: "CNY", requiresUserId: false };
|
|
166
|
+
}
|
|
167
|
+
if (host === "api.siliconflow.cn" || host === "api.siliconflow.com") {
|
|
168
|
+
const currency = host.endsWith(".cn") ? "CNY" : "USD";
|
|
169
|
+
const url = host.endsWith(".cn") ? "https://api.siliconflow.cn/v1/user/info" : "https://api.siliconflow.com/v1/user/info";
|
|
170
|
+
return { source: "siliconflow", url, currency, requiresUserId: false };
|
|
171
|
+
}
|
|
172
|
+
if (host === "openrouter.ai") {
|
|
173
|
+
return { source: "openrouter", url: "https://openrouter.ai/api/v1/credits", currency: "USD", requiresUserId: false };
|
|
174
|
+
}
|
|
175
|
+
if (host === "api.novita.ai") {
|
|
176
|
+
return { source: "novita", url: "https://api.novita.ai/v3/user/balance", currency: "USD", requiresUserId: false };
|
|
177
|
+
}
|
|
178
|
+
if (balanceUrl && isUsageEndpoint(balanceUrl)) {
|
|
179
|
+
return { source: "usage_generic", url: balanceUrl, currency: "USD", requiresUserId: false };
|
|
180
|
+
}
|
|
181
|
+
if (balanceUrl) {
|
|
182
|
+
return { source: "newapi_generic", url: balanceUrl, currency: "USD", requiresUserId: true };
|
|
183
|
+
}
|
|
184
|
+
const genericUsageUrl = upstreamUrl ? usageUrl(upstreamUrl) : undefined;
|
|
185
|
+
if (genericUsageUrl) {
|
|
186
|
+
return { source: "usage_generic", url: genericUsageUrl, currency: "USD", requiresUserId: false };
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function explicitRequestPlan(
|
|
192
|
+
template: Exclude<BalanceProbeTemplate, "auto" | "none">,
|
|
193
|
+
balanceUrl: string | undefined,
|
|
194
|
+
upstreamUrl: string | undefined
|
|
195
|
+
): BalanceRequestPlan | undefined {
|
|
196
|
+
if (template === "deepseek") {
|
|
197
|
+
return { source: "deepseek", url: balanceUrl || "https://api.deepseek.com/user/balance", currency: "CNY", requiresUserId: false };
|
|
198
|
+
}
|
|
199
|
+
if (template === "stepfun") {
|
|
200
|
+
return { source: "stepfun", url: balanceUrl || "https://api.stepfun.com/v1/accounts", currency: "CNY", requiresUserId: false };
|
|
201
|
+
}
|
|
202
|
+
if (template === "siliconflow") {
|
|
203
|
+
const host = hostName(balanceUrl || upstreamUrl);
|
|
204
|
+
const currency = host.endsWith(".com") ? "USD" : "CNY";
|
|
205
|
+
const url = balanceUrl || (currency === "USD" ? "https://api.siliconflow.com/v1/user/info" : "https://api.siliconflow.cn/v1/user/info");
|
|
206
|
+
return { source: "siliconflow", url, currency, requiresUserId: false };
|
|
207
|
+
}
|
|
208
|
+
if (template === "openrouter") {
|
|
209
|
+
return { source: "openrouter", url: balanceUrl || "https://openrouter.ai/api/v1/credits", currency: "USD", requiresUserId: false };
|
|
210
|
+
}
|
|
211
|
+
if (template === "novita") {
|
|
212
|
+
return { source: "novita", url: balanceUrl || "https://api.novita.ai/v3/user/balance", currency: "USD", requiresUserId: false };
|
|
213
|
+
}
|
|
214
|
+
if (template === "newapi_generic") {
|
|
215
|
+
return balanceUrl ? { source: "newapi_generic", url: balanceUrl, currency: "USD", requiresUserId: true } : undefined;
|
|
216
|
+
}
|
|
217
|
+
if (template === "usage_generic") {
|
|
218
|
+
const url = balanceUrl || (upstreamUrl ? usageUrl(upstreamUrl) : undefined);
|
|
219
|
+
return url ? { source: "usage_generic", url, currency: "USD", requiresUserId: false } : undefined;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function requestHeaders(upstreamApiKey: string, upstreamUserId: string | undefined): Record<string, string> {
|
|
225
|
+
const headers: Record<string, string> = {
|
|
226
|
+
"Authorization": `Bearer ${upstreamApiKey}`
|
|
227
|
+
};
|
|
228
|
+
if (upstreamUserId) {
|
|
229
|
+
headers["New-Api-User"] = upstreamUserId;
|
|
230
|
+
}
|
|
231
|
+
return headers;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function fetchWithTimeout(
|
|
235
|
+
url: string,
|
|
236
|
+
init: RequestInit,
|
|
237
|
+
fetchFn: typeof fetch,
|
|
238
|
+
timeoutMs: number
|
|
239
|
+
): Promise<Response> {
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
242
|
+
try {
|
|
243
|
+
return await fetchFn(url, { ...init, signal: controller.signal });
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseBalancePayload(
|
|
250
|
+
payload: unknown,
|
|
251
|
+
plan: BalanceRequestPlan,
|
|
252
|
+
fetchedAt: number,
|
|
253
|
+
cnyRate: number
|
|
254
|
+
): BalanceSnapshot {
|
|
255
|
+
const object = objectValue(payload);
|
|
256
|
+
if (!object) {
|
|
257
|
+
return failure(plan.source, fetchedAt, 200, "balance response must be an object");
|
|
258
|
+
}
|
|
259
|
+
if (plan.source === "deepseek") {
|
|
260
|
+
return parseDeepSeek(object, fetchedAt, cnyRate);
|
|
261
|
+
}
|
|
262
|
+
if (plan.source === "stepfun") {
|
|
263
|
+
return amountSnapshot(numberFrom(object.balance), "CNY", "stepfun", fetchedAt, cnyRate);
|
|
264
|
+
}
|
|
265
|
+
if (plan.source === "siliconflow") {
|
|
266
|
+
return parseSiliconFlow(object, plan.currency, fetchedAt, cnyRate);
|
|
267
|
+
}
|
|
268
|
+
if (plan.source === "openrouter") {
|
|
269
|
+
return parseOpenRouter(object, fetchedAt, cnyRate);
|
|
270
|
+
}
|
|
271
|
+
if (plan.source === "novita") {
|
|
272
|
+
const availableBalance = numberFrom(object.availableBalance);
|
|
273
|
+
return amountSnapshot(availableBalance === null ? null : availableBalance / 10000, "USD", "novita", fetchedAt, cnyRate);
|
|
274
|
+
}
|
|
275
|
+
if (plan.source === "newapi_generic") {
|
|
276
|
+
return parseNewApi(object, fetchedAt, cnyRate);
|
|
277
|
+
}
|
|
278
|
+
if (plan.source === "usage_generic") {
|
|
279
|
+
return parseUsageGeneric(object, fetchedAt, cnyRate);
|
|
280
|
+
}
|
|
281
|
+
return failure("unknown", fetchedAt, 0, "unsupported upstream - configure a balance parser");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseDeepSeek(payload: Record<string, unknown>, fetchedAt: number, cnyRate: number): BalanceSnapshot {
|
|
285
|
+
const infos = Array.isArray(payload.balance_infos) ? payload.balance_infos : [];
|
|
286
|
+
const first = objectValue(infos[0]);
|
|
287
|
+
const currency = currencyValue(first?.currency) || "CNY";
|
|
288
|
+
const rawAmount = numberFrom(first?.total_balance);
|
|
289
|
+
if (payload.is_available === false) {
|
|
290
|
+
return {
|
|
291
|
+
...amountSnapshot(rawAmount, currency, "deepseek", fetchedAt, cnyRate),
|
|
292
|
+
error: { httpStatus: 200, message: "Insufficient balance" }
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (!first) {
|
|
296
|
+
return amountSnapshot(null, currency, "deepseek", fetchedAt, cnyRate);
|
|
297
|
+
}
|
|
298
|
+
if (rawAmount === null) {
|
|
299
|
+
return failure("deepseek", fetchedAt, 200, "missing field: balance_infos[].total_balance", currency);
|
|
300
|
+
}
|
|
301
|
+
return amountSnapshot(rawAmount, currency, "deepseek", fetchedAt, cnyRate);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseSiliconFlow(
|
|
305
|
+
payload: Record<string, unknown>,
|
|
306
|
+
currency: Exclude<BalanceCurrency, null>,
|
|
307
|
+
fetchedAt: number,
|
|
308
|
+
cnyRate: number
|
|
309
|
+
): BalanceSnapshot {
|
|
310
|
+
const code = numberFrom(payload.code);
|
|
311
|
+
if (code !== null && code !== 20000) {
|
|
312
|
+
return failure("siliconflow", fetchedAt, 200, `upstream code: ${code}`, currency);
|
|
313
|
+
}
|
|
314
|
+
const data = objectValue(payload.data);
|
|
315
|
+
if (!data) {
|
|
316
|
+
return failure("siliconflow", fetchedAt, 200, "missing field: data", currency);
|
|
317
|
+
}
|
|
318
|
+
const snapshot = amountSnapshot(numberFrom(data.totalBalance), currency, "siliconflow", fetchedAt, cnyRate);
|
|
319
|
+
const status = stringValue(data.status);
|
|
320
|
+
if (status && status !== "ok") {
|
|
321
|
+
return {
|
|
322
|
+
...snapshot,
|
|
323
|
+
error: { httpStatus: 200, message: `upstream status: ${status}` }
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return snapshot;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseOpenRouter(payload: Record<string, unknown>, fetchedAt: number, cnyRate: number): BalanceSnapshot {
|
|
330
|
+
const data = objectValue(payload.data) || payload;
|
|
331
|
+
const totalCredits = numberFrom(data.total_credits);
|
|
332
|
+
const totalUsage = numberFrom(data.total_usage);
|
|
333
|
+
if (totalCredits === null || totalUsage === null) {
|
|
334
|
+
return failure("openrouter", fetchedAt, 200, "missing field: data.total_credits or data.total_usage", "USD");
|
|
335
|
+
}
|
|
336
|
+
return amountSnapshot(Math.max(0, totalCredits - totalUsage), "USD", "openrouter", fetchedAt, cnyRate);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function parseNewApi(payload: Record<string, unknown>, fetchedAt: number, cnyRate: number): BalanceSnapshot {
|
|
340
|
+
const data = objectValue(payload.data);
|
|
341
|
+
if (!data) {
|
|
342
|
+
return failure("newapi_generic", fetchedAt, 200, "missing field: data", "USD");
|
|
343
|
+
}
|
|
344
|
+
const quota = numberFrom(data.quota);
|
|
345
|
+
const usedQuota = numberFrom(data.used_quota);
|
|
346
|
+
if (quota === null || usedQuota === null) {
|
|
347
|
+
return failure("newapi_generic", fetchedAt, 200, "missing field: data.quota or data.used_quota", "USD");
|
|
348
|
+
}
|
|
349
|
+
return amountSnapshot((quota - usedQuota) / 500000, "USD", "newapi_generic", fetchedAt, cnyRate);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseUsageGeneric(payload: Record<string, unknown>, fetchedAt: number, cnyRate: number): BalanceSnapshot {
|
|
353
|
+
const quota = objectValue(payload.quota);
|
|
354
|
+
const rawAmount = numberFrom(payload.remaining) ?? numberFrom(quota?.remaining) ?? numberFrom(payload.balance);
|
|
355
|
+
const currency = currencyValue(payload.unit) || currencyValue(quota?.unit) || "USD";
|
|
356
|
+
if (rawAmount === null) {
|
|
357
|
+
return failure("usage_generic", fetchedAt, 200, "missing field: remaining, quota.remaining, or balance", currency);
|
|
358
|
+
}
|
|
359
|
+
const snapshot = amountSnapshot(rawAmount, currency, "usage_generic", fetchedAt, cnyRate);
|
|
360
|
+
const isValid = booleanValue(payload.is_active) ?? booleanValue(payload.isValid) ?? true;
|
|
361
|
+
if (!isValid) {
|
|
362
|
+
return {
|
|
363
|
+
...snapshot,
|
|
364
|
+
error: { httpStatus: 200, message: "upstream key is not active" }
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return snapshot;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function amountSnapshot(
|
|
371
|
+
rawAmount: number | null,
|
|
372
|
+
currency: Exclude<BalanceCurrency, null>,
|
|
373
|
+
source: BalanceSource,
|
|
374
|
+
fetchedAt: number,
|
|
375
|
+
cnyRate: number
|
|
376
|
+
): BalanceSnapshot {
|
|
377
|
+
return {
|
|
378
|
+
rawAmount,
|
|
379
|
+
amountUsdMicros: rawAmount === null ? null : Math.round(rawAmount * (currency === "CNY" ? cnyRate : 1) * 1000000),
|
|
380
|
+
currency,
|
|
381
|
+
source,
|
|
382
|
+
fetchedAt
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function failure(
|
|
387
|
+
source: BalanceSource,
|
|
388
|
+
fetchedAt: number,
|
|
389
|
+
httpStatus: number,
|
|
390
|
+
message: string,
|
|
391
|
+
currency: BalanceCurrency = null
|
|
392
|
+
): BalanceSnapshot {
|
|
393
|
+
return {
|
|
394
|
+
rawAmount: null,
|
|
395
|
+
amountUsdMicros: null,
|
|
396
|
+
currency,
|
|
397
|
+
source,
|
|
398
|
+
fetchedAt,
|
|
399
|
+
error: { httpStatus, message }
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function httpErrorMessage(status: number): string {
|
|
404
|
+
if (status === 401) {
|
|
405
|
+
return "unauthorized: check upstreamApiKey";
|
|
406
|
+
}
|
|
407
|
+
if (status === 403) {
|
|
408
|
+
return "forbidden: upstream rejected the credentials";
|
|
409
|
+
}
|
|
410
|
+
if (status === 429) {
|
|
411
|
+
return "rate limited";
|
|
412
|
+
}
|
|
413
|
+
if (status >= 500) {
|
|
414
|
+
return "upstream 5xx";
|
|
415
|
+
}
|
|
416
|
+
return `upstream http ${status}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function cnyUsdRate(value: number | undefined): number {
|
|
420
|
+
if (value !== undefined && Number.isFinite(value) && value > 0) {
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
const envValue = Number(process.env.TB_CNY_USD_RATE);
|
|
424
|
+
return Number.isFinite(envValue) && envValue > 0 ? envValue : DEFAULT_CNY_USD_RATE;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function cacheKey(config: BalanceProbeConfig): string {
|
|
428
|
+
return [
|
|
429
|
+
stringValue(config.upstreamBalanceProbe?.template),
|
|
430
|
+
stringValue(config.upstreamBalanceProbe?.url),
|
|
431
|
+
stringValue(config.upstreamBalanceProbe?.userId),
|
|
432
|
+
stringValue(config.upstreamBalanceUrl),
|
|
433
|
+
stringValue(config.upstreamUrl),
|
|
434
|
+
stringValue(config.upstreamApiKey),
|
|
435
|
+
stringValue(config.upstreamUserId)
|
|
436
|
+
].join("|");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function balanceProbeTemplateValue(value: unknown): BalanceProbeTemplate | undefined {
|
|
440
|
+
return value === "auto" ||
|
|
441
|
+
value === "deepseek" ||
|
|
442
|
+
value === "stepfun" ||
|
|
443
|
+
value === "siliconflow" ||
|
|
444
|
+
value === "openrouter" ||
|
|
445
|
+
value === "novita" ||
|
|
446
|
+
value === "newapi_generic" ||
|
|
447
|
+
value === "usage_generic" ||
|
|
448
|
+
value === "none"
|
|
449
|
+
? value
|
|
450
|
+
: undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function hostName(value: string | undefined): string {
|
|
454
|
+
if (!value) {
|
|
455
|
+
return "";
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
return new URL(value).hostname.replace(/^www\./, "");
|
|
459
|
+
} catch {
|
|
460
|
+
return "";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function usageUrl(value: string): string | undefined {
|
|
465
|
+
try {
|
|
466
|
+
const url = new URL(value);
|
|
467
|
+
url.pathname = "/v1/usage";
|
|
468
|
+
url.search = "";
|
|
469
|
+
url.hash = "";
|
|
470
|
+
return url.toString();
|
|
471
|
+
} catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isUsageEndpoint(value: string): boolean {
|
|
477
|
+
try {
|
|
478
|
+
return new URL(value).pathname.replace(/\/+$/, "") === "/v1/usage";
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
485
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
486
|
+
? value as Record<string, unknown>
|
|
487
|
+
: undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function stringValue(value: unknown): string | undefined {
|
|
491
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function numberFrom(value: unknown): number | null {
|
|
495
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
496
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function booleanValue(value: unknown): boolean | undefined {
|
|
500
|
+
return typeof value === "boolean" ? value : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function currencyValue(value: unknown): Exclude<BalanceCurrency, null> | undefined {
|
|
504
|
+
return value === "USD" || value === "CNY" ? value : undefined;
|
|
505
|
+
}
|