@tokenbuddy/tb-admin 1.0.33 → 1.0.35
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/cli.d.ts.map +1 -1
- package/dist/src/cli.js +28 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/seller.d.ts +40 -1
- package/dist/src/seller.d.ts.map +1 -1
- package/dist/src/seller.js +132 -2
- package/dist/src/seller.js.map +1 -1
- package/dist/src/ui-actions.d.ts +2 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +8 -6
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-command.d.ts +1 -0
- package/dist/src/ui-command.d.ts.map +1 -1
- package/dist/src/ui-command.js +7 -2
- package/dist/src/ui-command.js.map +1 -1
- package/dist/src/ui-server.js +17 -0
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +31 -0
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +461 -115
- package/dist/src/ui-state.js.map +1 -1
- package/dist/src/ui-static.d.ts.map +1 -1
- package/dist/src/ui-static.js +267 -144
- package/dist/src/ui-static.js.map +1 -1
- package/dist/src/upstream-balance-probe.d.ts +2 -40
- package/dist/src/upstream-balance-probe.d.ts.map +1 -1
- package/dist/src/upstream-balance-probe.js +1 -378
- package/dist/src/upstream-balance-probe.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +31 -0
- package/src/seller.ts +179 -3
- package/src/ui-actions.ts +10 -6
- package/src/ui-command.ts +7 -2
- package/src/ui-server.ts +18 -1
- package/src/ui-state.ts +541 -115
- package/src/ui-static.ts +267 -144
- package/src/upstream-balance-probe.ts +13 -505
- package/tests/admin.test.ts +418 -39
- package/tests/seller.test.ts +51 -0
- package/tests/ui-state-fleet.test.ts +272 -3
- package/tests/ui-static-row.test.ts +273 -8
package/src/ui-state.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
export type RegistryStatus = "active" | "draining" | "offline" | "pending" | "unknown";
|
|
11
11
|
export type NodeStatus = RegistryStatus | "busy_capacity" | "auth_unknown";
|
|
12
12
|
export type UpstreamStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
|
|
13
|
+
export type PublishStatus = "checking" | "published" | "unpublished" | "registry_only" | "unknown";
|
|
14
|
+
export type DetailStatus = "pending" | "queued" | "loading" | "fresh" | "stale" | "error" | "skipped";
|
|
13
15
|
/**
|
|
14
16
|
* Step 13 v1.1: dataSource 标记一行 seller 在 fly.io apps list vs
|
|
15
17
|
* registry /registry/sellers 两个独立查询里的出现情况. UI 据此决定
|
|
@@ -39,6 +41,10 @@ export interface SellerRow {
|
|
|
39
41
|
discountRatio?: number;
|
|
40
42
|
capacityUsed?: number;
|
|
41
43
|
capacityLimit?: number;
|
|
44
|
+
resourceCpuPercent?: number;
|
|
45
|
+
resourceMemoryPercent?: number;
|
|
46
|
+
resourceMemoryRssMb?: number;
|
|
47
|
+
resourceMemoryLimitMb?: number;
|
|
42
48
|
ttftMs?: number;
|
|
43
49
|
avgInferenceMs?: number;
|
|
44
50
|
lastInferenceMs?: number;
|
|
@@ -48,9 +54,15 @@ export interface SellerRow {
|
|
|
48
54
|
lastSwitchAt?: string;
|
|
49
55
|
modelsCount?: number;
|
|
50
56
|
specs?: {
|
|
57
|
+
cpuKind?: string;
|
|
58
|
+
cpuCores?: number;
|
|
59
|
+
memoryMb?: number;
|
|
51
60
|
memoryGb?: number;
|
|
52
61
|
machines?: number;
|
|
62
|
+
runningMachines?: number;
|
|
63
|
+
volumeGb?: number;
|
|
53
64
|
region?: string;
|
|
65
|
+
regions?: string[];
|
|
54
66
|
modelsCount?: number;
|
|
55
67
|
};
|
|
56
68
|
error?: string;
|
|
@@ -80,11 +92,17 @@ export interface SellerRow {
|
|
|
80
92
|
* fly-only 行一定有值; both 行有值; registry-only 行 undefined.
|
|
81
93
|
*/
|
|
82
94
|
flyApp?: { name: string; status?: string; owner?: string; latestDeployAt?: string };
|
|
95
|
+
publishStatus?: PublishStatus;
|
|
96
|
+
detailStatus?: DetailStatus;
|
|
97
|
+
detailUpdatedAt?: string;
|
|
98
|
+
detailNextRefreshAt?: string;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
export interface SellerModelRow {
|
|
86
102
|
upstreamModel: string;
|
|
87
103
|
billingModel: string;
|
|
104
|
+
enabled: boolean;
|
|
105
|
+
configModel?: Record<string, unknown>;
|
|
88
106
|
inputPrice?: string;
|
|
89
107
|
outputPrice?: string;
|
|
90
108
|
ttftMs?: number;
|
|
@@ -165,6 +183,7 @@ export interface AdminUiStateOptions {
|
|
|
165
183
|
* 返回的 SellerAppJson 形如 { name, status, owner, latestDeployAt }.
|
|
166
184
|
*/
|
|
167
185
|
flyApps?: () => Promise<import("./seller.js").SellerAppJson[]>;
|
|
186
|
+
flyMachineSpecs?: (appName: string) => Promise<import("./seller.js").SellerMachineSpecs | undefined>;
|
|
168
187
|
}
|
|
169
188
|
|
|
170
189
|
interface ProfileMatch {
|
|
@@ -182,11 +201,18 @@ interface SellerSnapshot {
|
|
|
182
201
|
balance?: BalanceSnapshot;
|
|
183
202
|
}
|
|
184
203
|
|
|
204
|
+
interface SellerTarget {
|
|
205
|
+
entry: SellerRegistryEntry;
|
|
206
|
+
flyApp?: import("./seller.js").SellerAppJson;
|
|
207
|
+
dataSource: SellerDataSource;
|
|
208
|
+
}
|
|
209
|
+
|
|
185
210
|
export class AdminUiState {
|
|
186
211
|
private readonly configManager: ConfigManager;
|
|
187
212
|
private readonly options: AdminUiStateOptions;
|
|
188
213
|
private readonly fetchJson: (url: string, init?: RequestInit) => Promise<unknown>;
|
|
189
214
|
private readonly balanceCache = new BalanceProbeCache();
|
|
215
|
+
private readonly machineSpecsCache = new Map<string, import("./seller.js").SellerMachineSpecs | undefined>();
|
|
190
216
|
|
|
191
217
|
constructor(options: AdminUiStateOptions) {
|
|
192
218
|
this.options = options;
|
|
@@ -267,7 +293,7 @@ export class AdminUiState {
|
|
|
267
293
|
this.fetchFlyApps().catch((err: any) => {
|
|
268
294
|
return { __error: err.message } as any;
|
|
269
295
|
}),
|
|
270
|
-
this.
|
|
296
|
+
this.fetchManagedRegistry().catch((err: any) => {
|
|
271
297
|
return { __error: err.message, sellers: [] } as any;
|
|
272
298
|
})
|
|
273
299
|
]);
|
|
@@ -300,22 +326,33 @@ export class AdminUiState {
|
|
|
300
326
|
const rows: SellerRow[] = [];
|
|
301
327
|
const consumedFly = new Set<string>();
|
|
302
328
|
const consumedRegistry = new Set<string>();
|
|
329
|
+
const specsByApp = await this.fetchFlyMachineSpecsForApps(Array.from(flyByName.keys()));
|
|
330
|
+
const registryTargets: Array<{
|
|
331
|
+
entry: SellerRegistryEntry;
|
|
332
|
+
flyMatch?: import("./seller.js").SellerAppJson;
|
|
333
|
+
dataSource: SellerDataSource;
|
|
334
|
+
}> = [];
|
|
303
335
|
|
|
304
336
|
// Phase 1: registry first (因为有 id + url + 详细 metadata)
|
|
305
337
|
for (const entry of registryDoc.sellers || []) {
|
|
306
|
-
const flyMatch =
|
|
338
|
+
const flyMatch = findFlyAppForEntry(flyByName, entry);
|
|
307
339
|
const dataSource: SellerDataSource = flyMatch ? "both" : "registry";
|
|
308
340
|
if (flyMatch) {
|
|
309
341
|
consumedFly.add(flyMatch.name);
|
|
310
342
|
}
|
|
311
343
|
consumedRegistry.add(entry.id);
|
|
312
|
-
|
|
313
|
-
rows.push(snapshot.row);
|
|
344
|
+
registryTargets.push({ entry, flyMatch, dataSource });
|
|
314
345
|
}
|
|
346
|
+
rows.push(...await Promise.all(
|
|
347
|
+
registryTargets.map(async ({ entry, flyMatch, dataSource }) => {
|
|
348
|
+
const snapshot = await this.sellerSnapshot(entry, flyMatch, dataSource, { balanceTimeoutMs: 8000, machineSpecs: flyMatch ? specsByApp.get(flyMatch.name) : undefined });
|
|
349
|
+
return snapshot.row;
|
|
350
|
+
})
|
|
351
|
+
));
|
|
315
352
|
|
|
316
353
|
// Phase 2: fly-only apps (registry 没有)
|
|
317
|
-
|
|
318
|
-
|
|
354
|
+
const flyOnlyApps = Array.from(flyByName.values()).filter((app) => !consumedFly.has(app.name));
|
|
355
|
+
rows.push(...await Promise.all(flyOnlyApps.map(async (app) => {
|
|
319
356
|
const stubEntry: SellerRegistryEntry = {
|
|
320
357
|
id: app.name,
|
|
321
358
|
name: app.name,
|
|
@@ -325,20 +362,80 @@ export class AdminUiState {
|
|
|
325
362
|
paymentMethods: [],
|
|
326
363
|
models: []
|
|
327
364
|
};
|
|
328
|
-
const snapshot = await this.sellerSnapshot(stubEntry, app, "fly");
|
|
329
|
-
|
|
365
|
+
const snapshot = await this.sellerSnapshot(stubEntry, app, "fly", { includeOperator: true, balanceTimeoutMs: 8000, machineSpecs: specsByApp.get(app.name) });
|
|
366
|
+
return snapshot.row;
|
|
367
|
+
})));
|
|
368
|
+
|
|
369
|
+
return rows;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
public async sellerRegistryRows(): Promise<SellerRow[]> {
|
|
373
|
+
const registryDoc = await this.fetchManagedRegistry();
|
|
374
|
+
return (registryDoc.sellers || []).map((entry) => {
|
|
375
|
+
const match = this.matchSellerProfile(entry);
|
|
376
|
+
return {
|
|
377
|
+
...baseSellerRow(entry, match.name, "registry"),
|
|
378
|
+
publishStatus: "checking",
|
|
379
|
+
detailStatus: "pending"
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
public async sellerInventory(): Promise<SellerRow[]> {
|
|
385
|
+
const registryDoc = await this.fetchManagedRegistry();
|
|
386
|
+
const flyApps = await this.fetchFlyApps().catch(() => []);
|
|
387
|
+
const flyByName = new Map<string, import("./seller.js").SellerAppJson>();
|
|
388
|
+
for (const app of flyApps) {
|
|
389
|
+
if (app?.name) {
|
|
390
|
+
flyByName.set(app.name, app);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const specsByApp = await this.fetchFlyMachineSpecsForApps(Array.from(flyByName.keys()));
|
|
394
|
+
const rows: SellerRow[] = [];
|
|
395
|
+
const consumedFly = new Set<string>();
|
|
396
|
+
|
|
397
|
+
for (const entry of registryDoc.sellers || []) {
|
|
398
|
+
const flyMatch = findFlyAppForEntry(flyByName, entry);
|
|
399
|
+
const dataSource: SellerDataSource = flyMatch ? "both" : "registry";
|
|
400
|
+
if (flyMatch) {
|
|
401
|
+
consumedFly.add(flyMatch.name);
|
|
402
|
+
}
|
|
403
|
+
const match = this.matchSellerProfile(entry);
|
|
404
|
+
const row = baseSellerRow(entry, match.name, dataSource, flyMatch, flyMatch ? specsByApp.get(flyMatch.name) : undefined);
|
|
405
|
+
row.publishStatus = flyMatch ? "published" : "registry_only";
|
|
406
|
+
row.detailStatus = dataSource === "registry" ? "skipped" : "pending";
|
|
407
|
+
if (dataSource === "registry") {
|
|
408
|
+
row.registryAlert = true;
|
|
409
|
+
row.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
|
|
410
|
+
row.removeHint = "立即下线 (registry-only)";
|
|
411
|
+
}
|
|
412
|
+
rows.push(row);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const flyOnlyApps = Array.from(flyByName.values()).filter((app) => !consumedFly.has(app.name));
|
|
416
|
+
for (const app of flyOnlyApps) {
|
|
417
|
+
const stubEntry = sellerEntryFromFlyApp(app);
|
|
418
|
+
const row = baseSellerRow(stubEntry, undefined, "fly", app, specsByApp.get(app.name));
|
|
419
|
+
row.publishStatus = "unpublished";
|
|
420
|
+
row.detailStatus = "pending";
|
|
421
|
+
row.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
|
|
422
|
+
rows.push(row);
|
|
330
423
|
}
|
|
331
424
|
|
|
332
425
|
return rows;
|
|
333
426
|
}
|
|
334
427
|
|
|
428
|
+
public async refreshSellerRows(rows: SellerRow[]): Promise<SellerRow[]> {
|
|
429
|
+
return await Promise.all(rows.map((row) => this.refreshSellerRow(row)));
|
|
430
|
+
}
|
|
431
|
+
|
|
335
432
|
/**
|
|
336
433
|
* Step 13 v1.1: 拉 flyctl apps list --json. 默认走 seller.ts 真实
|
|
337
434
|
* flyctl spawn, 测试或受限环境可注入 options.flyApps closure.
|
|
338
435
|
*/
|
|
339
436
|
private async fetchFlyApps(): Promise<import("./seller.js").SellerAppJson[]> {
|
|
340
437
|
if (this.options.flyApps) {
|
|
341
|
-
return await this.options.flyApps();
|
|
438
|
+
return (await this.options.flyApps()).filter((app) => isSellerFlyAppName(app.name));
|
|
342
439
|
}
|
|
343
440
|
// 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
|
|
344
441
|
// 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
|
|
@@ -356,7 +453,7 @@ export class AdminUiState {
|
|
|
356
453
|
const runner = new mod.SellerCommandRunner(this.configManager);
|
|
357
454
|
const result = await runner.ls(true);
|
|
358
455
|
if (result && typeof result === "object" && "apps" in result) {
|
|
359
|
-
return (result as { apps: import("./seller.js").SellerAppJson[] }).apps;
|
|
456
|
+
return (result as { apps: import("./seller.js").SellerAppJson[] }).apps.filter((app) => isSellerFlyAppName(app.name));
|
|
360
457
|
}
|
|
361
458
|
return [];
|
|
362
459
|
} catch (err: any) {
|
|
@@ -364,36 +461,42 @@ export class AdminUiState {
|
|
|
364
461
|
}
|
|
365
462
|
}
|
|
366
463
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const document = await this.fetchRegistry();
|
|
371
|
-
let entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
|
|
372
|
-
let dataSource: SellerDataSource = "registry";
|
|
373
|
-
let flyApp: import("./seller.js").SellerAppJson | undefined;
|
|
374
|
-
if (!entry) {
|
|
375
|
-
const flyApps = await this.fetchFlyApps().catch(() => []);
|
|
376
|
-
const flyMatch = flyApps.find((app) => app.name === id || app.name === `tb-seller-${id}` || app.name === id.replace(/^tb-seller-/, ""));
|
|
377
|
-
if (!flyMatch) {
|
|
378
|
-
throw new Error(`seller \`${id}\` not found in fly.io apps or bootstrap registry`);
|
|
379
|
-
}
|
|
380
|
-
entry = {
|
|
381
|
-
id: flyMatch.name,
|
|
382
|
-
name: flyMatch.name,
|
|
383
|
-
app: flyMatch.name,
|
|
384
|
-
url: `https://${flyMatch.name}.fly.dev`,
|
|
385
|
-
supportedProtocols: [],
|
|
386
|
-
paymentMethods: [],
|
|
387
|
-
models: []
|
|
388
|
-
};
|
|
389
|
-
flyApp = flyMatch;
|
|
390
|
-
dataSource = "fly";
|
|
391
|
-
} else {
|
|
392
|
-
const flyApps = await this.fetchFlyApps().catch(() => []);
|
|
393
|
-
flyApp = entry.app ? flyApps.find((app) => app.name === entry!.app) : undefined;
|
|
394
|
-
dataSource = flyApp ? "both" : "registry";
|
|
464
|
+
private async fetchFlyMachineSpecsForApps(appNames: string[]): Promise<Map<string, import("./seller.js").SellerMachineSpecs | undefined>> {
|
|
465
|
+
if (!this.options.flyMachineSpecs && this.options.flyApps) {
|
|
466
|
+
return new Map(appNames.map((appName) => [appName, undefined]));
|
|
395
467
|
}
|
|
396
|
-
const
|
|
468
|
+
const entries: Array<readonly [string, import("./seller.js").SellerMachineSpecs | undefined]> = [];
|
|
469
|
+
for (const appName of appNames) {
|
|
470
|
+
entries.push([appName, await this.fetchFlyMachineSpecs(appName)] as const);
|
|
471
|
+
}
|
|
472
|
+
return new Map(entries);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async fetchFlyMachineSpecs(appName: string): Promise<import("./seller.js").SellerMachineSpecs | undefined> {
|
|
476
|
+
if (this.machineSpecsCache.has(appName)) {
|
|
477
|
+
return this.machineSpecsCache.get(appName);
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const specs = this.options.flyMachineSpecs
|
|
481
|
+
? await this.options.flyMachineSpecs(appName)
|
|
482
|
+
: await this.defaultFlyMachineSpecs(appName);
|
|
483
|
+
this.machineSpecsCache.set(appName, specs);
|
|
484
|
+
return specs;
|
|
485
|
+
} catch {
|
|
486
|
+
this.machineSpecsCache.set(appName, undefined);
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async defaultFlyMachineSpecs(appName: string): Promise<import("./seller.js").SellerMachineSpecs | undefined> {
|
|
492
|
+
const mod = await import("./seller.js");
|
|
493
|
+
const runner = new mod.SellerCommandRunner(this.configManager);
|
|
494
|
+
return runner.machineSpecs(appName);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
public async sellerDetail(id: string): Promise<SellerDetail> {
|
|
498
|
+
const { entry, flyApp, dataSource } = await this.resolveSellerTarget(id);
|
|
499
|
+
const snapshot = await this.sellerSnapshot(entry, flyApp, dataSource, { includeOperator: true, balanceTimeoutMs: 8000 });
|
|
397
500
|
const config = snapshot.config?.config || snapshot.config || {};
|
|
398
501
|
const upstreams = snapshot.upstreams || {};
|
|
399
502
|
return {
|
|
@@ -424,12 +527,75 @@ export class AdminUiState {
|
|
|
424
527
|
};
|
|
425
528
|
}
|
|
426
529
|
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
530
|
+
private async refreshSellerRow(row: SellerRow): Promise<SellerRow> {
|
|
531
|
+
const dataSource = row.dataSource || "both";
|
|
532
|
+
if (dataSource === "registry") {
|
|
533
|
+
return { ...row, nodeStatus: "unknown", detailStatus: "skipped", detailUpdatedAt: new Date().toISOString() };
|
|
534
|
+
}
|
|
535
|
+
const entry = sellerEntryFromRow(row);
|
|
536
|
+
const match = this.matchSellerProfile(entry);
|
|
537
|
+
const manifestPromise = this.probeManifest(entry.url);
|
|
538
|
+
if (!match.profile) {
|
|
539
|
+
const manifestOk = await manifestPromise;
|
|
540
|
+
return {
|
|
541
|
+
...row,
|
|
542
|
+
nodeStatus: manifestOk ? "active" : "unknown",
|
|
543
|
+
detailStatus: manifestOk ? "fresh" : "error",
|
|
544
|
+
detailUpdatedAt: new Date().toISOString(),
|
|
545
|
+
error: manifestOk ? row.error : (row.error || "No matching local admin profile; /manifest probe failed")
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const [manifestOk, status, upstreams, config] = await Promise.all([
|
|
550
|
+
manifestPromise,
|
|
551
|
+
this.fetchSellerAdminJson(match.profile, "/operator/status").catch((err: any) => ({ error: err.message })),
|
|
552
|
+
this.fetchSellerAdminJson(match.profile, "/operator/admin/upstreams").catch((err: any) => ({ error: err.message })),
|
|
553
|
+
this.fetchSellerAdminJson(match.profile, "/operator/admin/config").catch((err: any) => ({ error: err.message }))
|
|
554
|
+
]);
|
|
555
|
+
const balance = await this.operatorBalanceSnapshot(match.profile, 8000).catch(() => unavailableBalanceSnapshot("seller balance endpoint unavailable"));
|
|
556
|
+
const configDocument = config?.config || config || {};
|
|
557
|
+
const normalizedUpstreams = upstreamDocument(upstreams);
|
|
558
|
+
const upstreamUrl = stringValue(configDocument.upstreamUrl || normalizedUpstreams?.upstreamUrl || status?.upstream?.url || status?.upstreamUrl);
|
|
559
|
+
if (status?.error) {
|
|
560
|
+
return {
|
|
561
|
+
...row,
|
|
562
|
+
nodeStatus: manifestOk ? "active" : "unknown",
|
|
563
|
+
upstreamDomain: upstreamUrl ? (hostName(upstreamUrl) || row.upstreamDomain) : row.upstreamDomain,
|
|
564
|
+
upstreamStatus: upstreamStatus(normalizedUpstreams?.status || row.upstreamStatus),
|
|
565
|
+
discountRatio: numberValue(configDocument.discountRatio ?? normalizedUpstreams?.discountRatio) ?? row.discountRatio,
|
|
566
|
+
modelsCount: numberValue(normalizedUpstreams?.models?.length ?? row.modelsCount) ?? row.modelsCount,
|
|
567
|
+
...balanceFields(balance, row),
|
|
568
|
+
detailStatus: "error",
|
|
569
|
+
detailUpdatedAt: new Date().toISOString(),
|
|
570
|
+
error: status.error
|
|
571
|
+
};
|
|
432
572
|
}
|
|
573
|
+
const capacity = status?.capacity || {};
|
|
574
|
+
return {
|
|
575
|
+
...row,
|
|
576
|
+
nodeStatus: manifestOk ? "active" : nodeStatus(status?.status || row.nodeStatus),
|
|
577
|
+
upstreamDomain: upstreamUrl ? (hostName(upstreamUrl) || row.upstreamDomain) : row.upstreamDomain,
|
|
578
|
+
upstreamStatus: upstreamStatus(status?.upstream?.status || normalizedUpstreams?.status || row.upstreamStatus),
|
|
579
|
+
discountRatio: numberValue(configDocument.discountRatio ?? normalizedUpstreams?.discountRatio) ?? row.discountRatio,
|
|
580
|
+
capacityUsed: numberValue(capacity.activeConnections) ?? row.capacityUsed,
|
|
581
|
+
capacityLimit: numberValue(capacity.maxConnections) ?? row.capacityLimit,
|
|
582
|
+
...runtimeUsageFields(status?.runtime, row),
|
|
583
|
+
ttftMs: numberValue(status?.latency?.ttftMs) ?? row.ttftMs,
|
|
584
|
+
avgInferenceMs: numberValue(status?.latency?.avgInferenceMs) ?? row.avgInferenceMs,
|
|
585
|
+
lastInferenceMs: numberValue(status?.latency?.lastInferenceMs) ?? row.lastInferenceMs,
|
|
586
|
+
avgTokensPerSecond: numberValue(status?.latency?.avgTokensPerSecond) ?? row.avgTokensPerSecond,
|
|
587
|
+
lastTokensPerSecond: numberValue(status?.latency?.lastTokensPerSecond) ?? row.lastTokensPerSecond,
|
|
588
|
+
latencySamples: numberValue(status?.latency?.sampleCount) ?? row.latencySamples,
|
|
589
|
+
modelsCount: numberValue(normalizedUpstreams?.models?.length ?? row.modelsCount) ?? row.modelsCount,
|
|
590
|
+
...balanceFields(balance, row),
|
|
591
|
+
detailStatus: "fresh",
|
|
592
|
+
detailUpdatedAt: new Date().toISOString(),
|
|
593
|
+
error: undefined
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
public async rawSellerConfig(id: string): Promise<{ entry: SellerRegistryEntry; profileName?: string; config: any }> {
|
|
598
|
+
const { entry } = await this.resolveSellerTarget(id);
|
|
433
599
|
const match = this.matchSellerProfile(entry);
|
|
434
600
|
if (!match.profile) {
|
|
435
601
|
throw new Error(`seller \`${entry.id}\` has no matching local admin profile`);
|
|
@@ -438,17 +604,76 @@ export class AdminUiState {
|
|
|
438
604
|
return { entry, profileName: match.localProfile ? match.name : undefined, config: response.config || response };
|
|
439
605
|
}
|
|
440
606
|
|
|
607
|
+
private async resolveSellerTarget(id: string): Promise<SellerTarget> {
|
|
608
|
+
let document: SellerRegistryDocument | undefined;
|
|
609
|
+
let registryError: unknown;
|
|
610
|
+
try {
|
|
611
|
+
document = await this.fetchManagedRegistry();
|
|
612
|
+
} catch (err) {
|
|
613
|
+
registryError = err;
|
|
614
|
+
document = { version: 0, sellers: [] };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const flyApps = await this.fetchFlyApps().catch(() => []);
|
|
618
|
+
const flyByName = new Map(flyApps.map((app) => [app.name, app]));
|
|
619
|
+
const entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
|
|
620
|
+
if (entry) {
|
|
621
|
+
const flyApp = findFlyAppForEntry(flyByName, entry);
|
|
622
|
+
return { entry, flyApp, dataSource: flyApp ? "both" : "registry" };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const flyApp = flyApps.find((app) => app.name === id || app.name === `tb-seller-${id}` || app.name === id.replace(/^tb-seller-/, ""));
|
|
626
|
+
if (flyApp) {
|
|
627
|
+
return { entry: sellerEntryFromFlyApp(flyApp), flyApp, dataSource: "fly" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (registryError) {
|
|
631
|
+
throw registryError;
|
|
632
|
+
}
|
|
633
|
+
throw new Error(`seller \`${id}\` not found in fly.io apps or bootstrap registry`);
|
|
634
|
+
}
|
|
635
|
+
|
|
441
636
|
public async fetchRegistry(): Promise<SellerRegistryDocument> {
|
|
442
637
|
const profile = this.activeBootstrapProfile();
|
|
443
638
|
const baseUrl = this.options.url || profile.profile?.url;
|
|
444
639
|
if (!baseUrl) {
|
|
445
640
|
throw new Error("No bootstrap profile found. Configure an admin profile or pass --url.");
|
|
446
641
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
642
|
+
return normalizeRegistryDocument(
|
|
643
|
+
await this.fetchBootstrapJson(`${trimSlash(baseUrl)}/registry/sellers`),
|
|
644
|
+
"bootstrap registry response"
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
public async fetchManagedRegistry(): Promise<SellerRegistryDocument> {
|
|
649
|
+
const profile = this.activeBootstrapProfile();
|
|
650
|
+
const baseUrl = this.options.url || profile.profile?.url;
|
|
651
|
+
const token = this.options.token || profile.profile?.token;
|
|
652
|
+
if (!baseUrl) {
|
|
653
|
+
throw new Error("No bootstrap profile found. Configure an admin profile or pass --url.");
|
|
654
|
+
}
|
|
655
|
+
if (!token) {
|
|
656
|
+
return this.fetchRegistry();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const managed = normalizeRegistryDocument(
|
|
661
|
+
await this.fetchBootstrapJson(`${trimSlash(baseUrl)}/platform/sellers`, {
|
|
662
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
663
|
+
}),
|
|
664
|
+
"platform sellers response"
|
|
665
|
+
);
|
|
666
|
+
const publicDoc = await this.fetchRegistry().catch(() => undefined);
|
|
667
|
+
if (!publicDoc) {
|
|
668
|
+
return managed;
|
|
669
|
+
}
|
|
670
|
+
return mergeRegistryDocuments(publicDoc, managed);
|
|
671
|
+
} catch (err: any) {
|
|
672
|
+
if (isUnavailablePlatformSellersEndpoint(err)) {
|
|
673
|
+
return this.fetchRegistry();
|
|
674
|
+
}
|
|
675
|
+
throw err;
|
|
450
676
|
}
|
|
451
|
-
return document;
|
|
452
677
|
}
|
|
453
678
|
|
|
454
679
|
public activeBootstrapProfile(): ProfileMatch {
|
|
@@ -511,10 +736,11 @@ export class AdminUiState {
|
|
|
511
736
|
private async sellerSnapshot(
|
|
512
737
|
entry: SellerRegistryEntry,
|
|
513
738
|
flyApp: import("./seller.js").SellerAppJson | undefined,
|
|
514
|
-
dataSource: SellerDataSource
|
|
739
|
+
dataSource: SellerDataSource,
|
|
740
|
+
options: { includeOperator?: boolean; balanceTimeoutMs?: number; machineSpecs?: import("./seller.js").SellerMachineSpecs } = {}
|
|
515
741
|
): Promise<SellerSnapshot> {
|
|
516
742
|
const match = this.matchSellerProfile(entry);
|
|
517
|
-
const baseRow = baseSellerRow(entry, match.name, dataSource, flyApp);
|
|
743
|
+
const baseRow = baseSellerRow(entry, match.name, dataSource, flyApp, options.machineSpecs);
|
|
518
744
|
|
|
519
745
|
// Step 13: 立即下线 / Apply 按钮 hint 文案
|
|
520
746
|
if (dataSource === "registry") {
|
|
@@ -525,86 +751,82 @@ export class AdminUiState {
|
|
|
525
751
|
baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
|
|
526
752
|
}
|
|
527
753
|
|
|
528
|
-
if (dataSource === "
|
|
529
|
-
// fly-only 行没 registry entry, 也可能没 profile. 仍然跑 manifest
|
|
530
|
-
// probe (entry.url 来自 flyApp 推断 https://<name>.fly.dev), 但
|
|
531
|
-
// 没 profile 时只算 "unknown" (灰点) — 因为 vendor 没法管控一个
|
|
532
|
-
// 还没发布的 instance, 不该 throw auth_unknown 把 UI 卡死.
|
|
533
|
-
const manifestOk = await this.probeManifest(entry.url);
|
|
754
|
+
if (dataSource === "registry") {
|
|
534
755
|
return {
|
|
535
756
|
row: {
|
|
536
757
|
...baseRow,
|
|
537
|
-
nodeStatus:
|
|
538
|
-
error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
|
|
758
|
+
nodeStatus: "unknown"
|
|
539
759
|
}
|
|
540
760
|
};
|
|
541
761
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
762
|
+
|
|
763
|
+
let manifestOk = false;
|
|
764
|
+
const manifestPromise = this.probeManifest(entry.url).then((ok) => {
|
|
765
|
+
manifestOk = ok;
|
|
766
|
+
return ok;
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
if (dataSource === "fly" && (!options.includeOperator || !match.profile)) {
|
|
770
|
+
// fly-only 行没 registry entry, 也可能没 profile. 仍然跑 manifest
|
|
771
|
+
// probe (entry.url 来自 flyApp 推断 https://<name>.fly.dev), 但
|
|
772
|
+
// 没 profile 时只算 "unknown" (灰点) — 因为 vendor 没法管控一个
|
|
773
|
+
// 还没发布的 instance, 不该 throw auth_unknown 把 UI 卡死.
|
|
774
|
+
manifestOk = await manifestPromise;
|
|
545
775
|
return {
|
|
546
776
|
row: {
|
|
547
777
|
...baseRow,
|
|
548
|
-
nodeStatus: "unknown",
|
|
549
|
-
error: "
|
|
778
|
+
nodeStatus: manifestOk ? "active" : "unknown",
|
|
779
|
+
error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
|
|
550
780
|
}
|
|
551
781
|
};
|
|
552
782
|
}
|
|
553
783
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (dataSource === "registry") {
|
|
784
|
+
if (!match.profile) {
|
|
785
|
+
manifestOk = await manifestPromise;
|
|
557
786
|
return {
|
|
558
787
|
row: {
|
|
559
788
|
...baseRow,
|
|
560
|
-
nodeStatus: "unknown"
|
|
789
|
+
nodeStatus: manifestOk ? "active" : "unknown",
|
|
790
|
+
error: manifestOk ? "No matching local admin profile" : "No matching local admin profile; /manifest probe failed"
|
|
561
791
|
}
|
|
562
792
|
};
|
|
563
793
|
}
|
|
564
|
-
|
|
565
794
|
try {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const manifestOk = await this.probeManifest(entry.url);
|
|
569
|
-
if (manifestOk) {
|
|
570
|
-
return {
|
|
571
|
-
row: {
|
|
572
|
-
...baseRow,
|
|
573
|
-
nodeStatus: "active"
|
|
574
|
-
}
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
// /manifest 失败: 试 /operator/status (老路径, 需要 vendor profile)
|
|
578
|
-
if (!match.profile) {
|
|
579
|
-
return {
|
|
580
|
-
row: {
|
|
581
|
-
...baseRow,
|
|
582
|
-
nodeStatus: "auth_unknown",
|
|
583
|
-
error: "No matching local admin profile (post /manifest fallback)"
|
|
584
|
-
}
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
const [status, service, upstreams, config] = await Promise.all([
|
|
795
|
+
const [, status, service, upstreams, config] = await Promise.all([
|
|
796
|
+
manifestPromise,
|
|
588
797
|
this.fetchSellerAdminJson(match.profile, "/operator/status").catch((err: any) => ({ error: err.message })),
|
|
589
798
|
this.fetchSellerAdminJson(match.profile, "/operator/admin/service").catch((err: any) => ({ error: err.message })),
|
|
590
799
|
this.fetchSellerAdminJson(match.profile, "/operator/admin/upstreams").catch((err: any) => ({ error: err.message })),
|
|
591
800
|
this.fetchSellerAdminJson(match.profile, "/operator/admin/config").catch((err: any) => ({ error: err.message }))
|
|
592
801
|
]);
|
|
593
802
|
const configDocument = config?.config || config || {};
|
|
594
|
-
const balance = await this.balanceSnapshot(configDocument, upstreams);
|
|
803
|
+
const balance = await this.balanceSnapshot(match.profile, configDocument, upstreams, options.balanceTimeoutMs);
|
|
595
804
|
return {
|
|
596
805
|
status,
|
|
597
806
|
service,
|
|
598
807
|
upstreams,
|
|
599
808
|
config,
|
|
600
809
|
balance,
|
|
601
|
-
row: mergeSellerRow(
|
|
810
|
+
row: mergeSellerRow(
|
|
811
|
+
{
|
|
812
|
+
...baseRow,
|
|
813
|
+
nodeStatus: manifestOk ? "active" : baseRow.nodeStatus
|
|
814
|
+
},
|
|
815
|
+
entry,
|
|
816
|
+
status,
|
|
817
|
+
service,
|
|
818
|
+
upstreams,
|
|
819
|
+
configDocument,
|
|
820
|
+
balance,
|
|
821
|
+
manifestOk
|
|
822
|
+
)
|
|
602
823
|
};
|
|
603
824
|
} catch (err: any) {
|
|
825
|
+
await manifestPromise.catch(() => false);
|
|
604
826
|
return {
|
|
605
827
|
row: {
|
|
606
828
|
...baseRow,
|
|
607
|
-
nodeStatus: "unknown",
|
|
829
|
+
nodeStatus: manifestOk ? "active" : "unknown",
|
|
608
830
|
error: err.message
|
|
609
831
|
}
|
|
610
832
|
};
|
|
@@ -632,22 +854,58 @@ export class AdminUiState {
|
|
|
632
854
|
}
|
|
633
855
|
}
|
|
634
856
|
|
|
635
|
-
private async fetchSellerAdminJson(profile: AdminProfile, pathName: string): Promise<any> {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
857
|
+
private async fetchSellerAdminJson(profile: AdminProfile, pathName: string, options: { timeoutMs?: number } = {}): Promise<any> {
|
|
858
|
+
const controller = options.timeoutMs ? new AbortController() : undefined;
|
|
859
|
+
const timer = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
860
|
+
try {
|
|
861
|
+
return await this.fetchJson(`${trimSlash(profile.url)}${pathName}`, {
|
|
862
|
+
headers: {
|
|
863
|
+
"Content-Type": "application/json",
|
|
864
|
+
Authorization: `Bearer ${profile.token}`
|
|
865
|
+
},
|
|
866
|
+
...(controller ? { signal: controller.signal } : {})
|
|
867
|
+
});
|
|
868
|
+
} finally {
|
|
869
|
+
if (timer) {
|
|
870
|
+
clearTimeout(timer);
|
|
640
871
|
}
|
|
641
|
-
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async fetchBootstrapJson(url: string, init?: RequestInit): Promise<unknown> {
|
|
876
|
+
const controller = new AbortController();
|
|
877
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
878
|
+
try {
|
|
879
|
+
return await this.fetchJson(url, { ...init, signal: controller.signal });
|
|
880
|
+
} finally {
|
|
881
|
+
clearTimeout(timer);
|
|
882
|
+
}
|
|
642
883
|
}
|
|
643
884
|
|
|
644
|
-
private async balanceSnapshot(config: any, upstreams: any): Promise<BalanceSnapshot | undefined> {
|
|
885
|
+
private async balanceSnapshot(profile: AdminProfile, config: any, upstreams: any, timeoutMs?: number): Promise<BalanceSnapshot | undefined> {
|
|
645
886
|
if (config?.error) {
|
|
646
887
|
return undefined;
|
|
647
888
|
}
|
|
648
889
|
if (stringValue(config.upstreamBalanceProbe?.template) === "none") {
|
|
649
890
|
return undefined;
|
|
650
891
|
}
|
|
892
|
+
const operatorBalance = await this.operatorBalanceSnapshot(profile, timeoutMs).catch(() => undefined);
|
|
893
|
+
if (operatorBalance) {
|
|
894
|
+
return operatorBalance;
|
|
895
|
+
}
|
|
896
|
+
if (isRedactedConfigSecret(config.upstreamApiKey)) {
|
|
897
|
+
return {
|
|
898
|
+
rawAmount: null,
|
|
899
|
+
amountUsdMicros: null,
|
|
900
|
+
currency: null,
|
|
901
|
+
source: "unknown",
|
|
902
|
+
fetchedAt: Date.now(),
|
|
903
|
+
error: {
|
|
904
|
+
httpStatus: 0,
|
|
905
|
+
message: "seller balance endpoint unavailable"
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
}
|
|
651
909
|
return probeUpstreamBalance({
|
|
652
910
|
upstreamUrl: stringValue(config.upstreamUrl || upstreams?.upstreamUrl),
|
|
653
911
|
upstreamBalanceUrl: stringValue(config.upstreamBalanceUrl || upstreams?.upstreamBalanceUrl),
|
|
@@ -656,20 +914,32 @@ export class AdminUiState {
|
|
|
656
914
|
upstreamBalanceProbe: objectValue(config.upstreamBalanceProbe)
|
|
657
915
|
}, {
|
|
658
916
|
fetch: this.options.balanceFetch,
|
|
659
|
-
cache: this.balanceCache
|
|
917
|
+
cache: this.balanceCache,
|
|
918
|
+
timeoutMs
|
|
660
919
|
});
|
|
661
920
|
}
|
|
921
|
+
|
|
922
|
+
private async operatorBalanceSnapshot(profile: AdminProfile, timeoutMs?: number): Promise<BalanceSnapshot | undefined> {
|
|
923
|
+
return await this.fetchSellerAdminJson(profile, "/operator/admin/upstream-balance", { timeoutMs })
|
|
924
|
+
.then((response) => {
|
|
925
|
+
const responseObject = objectValue(response);
|
|
926
|
+
const balance = objectValue(responseObject?.balance) || responseObject;
|
|
927
|
+
return balance as BalanceSnapshot | undefined;
|
|
928
|
+
});
|
|
929
|
+
}
|
|
662
930
|
}
|
|
663
931
|
|
|
664
932
|
async function defaultFetchJson(url: string, init?: RequestInit): Promise<unknown> {
|
|
665
933
|
// Step 13 v1.1: 3s timeout 避免坏 host / DNS 卡死整个 admin web.
|
|
666
|
-
const controller = new AbortController();
|
|
667
|
-
const timer = setTimeout(() => controller.abort(), 3000);
|
|
934
|
+
const controller = init?.signal ? undefined : new AbortController();
|
|
935
|
+
const timer = controller ? setTimeout(() => controller.abort(), 3000) : undefined;
|
|
668
936
|
let response: Response;
|
|
669
937
|
try {
|
|
670
|
-
response = await fetch(url, { ...init, signal: controller.signal });
|
|
938
|
+
response = await fetch(url, controller ? { ...init, signal: controller.signal } : init);
|
|
671
939
|
} finally {
|
|
672
|
-
|
|
940
|
+
if (timer) {
|
|
941
|
+
clearTimeout(timer);
|
|
942
|
+
}
|
|
673
943
|
}
|
|
674
944
|
if (!response.ok) {
|
|
675
945
|
const text = await response.text();
|
|
@@ -683,8 +953,10 @@ function baseSellerRow(
|
|
|
683
953
|
entry: SellerRegistryEntry,
|
|
684
954
|
profile?: string,
|
|
685
955
|
dataSource: SellerDataSource = "registry",
|
|
686
|
-
flyApp?: import("./seller.js").SellerAppJson
|
|
956
|
+
flyApp?: import("./seller.js").SellerAppJson,
|
|
957
|
+
machineSpecs?: import("./seller.js").SellerMachineSpecs
|
|
687
958
|
): SellerRow {
|
|
959
|
+
const primaryRegion = entry.region || machineSpecs?.regions?.[0];
|
|
688
960
|
return {
|
|
689
961
|
id: entry.id,
|
|
690
962
|
name: entry.id,
|
|
@@ -696,15 +968,25 @@ function baseSellerRow(
|
|
|
696
968
|
// Step 13 v1.1: 绿点 base 改 unknown, 真正值由 sellerSnapshot
|
|
697
969
|
// 拿 /manifest 200 决定. 老逻辑直接复用 entry.status 是错的.
|
|
698
970
|
nodeStatus: "unknown",
|
|
699
|
-
region:
|
|
971
|
+
region: primaryRegion,
|
|
700
972
|
upstreamDomain: hostName(entry.url) || "unknown",
|
|
701
973
|
upstreamStatus: "unknown",
|
|
702
974
|
modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length,
|
|
703
975
|
specs: {
|
|
704
|
-
|
|
976
|
+
cpuKind: machineSpecs?.cpuKind,
|
|
977
|
+
cpuCores: machineSpecs?.cpuCores,
|
|
978
|
+
memoryMb: machineSpecs?.memoryMb,
|
|
979
|
+
memoryGb: machineSpecs?.memoryMb ? Number((machineSpecs.memoryMb / 1024).toFixed(2)) : undefined,
|
|
980
|
+
machines: machineSpecs?.machines,
|
|
981
|
+
runningMachines: machineSpecs?.runningMachines,
|
|
982
|
+
volumeGb: machineSpecs?.volumeGb,
|
|
983
|
+
region: primaryRegion,
|
|
984
|
+
regions: machineSpecs?.regions,
|
|
705
985
|
modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length
|
|
706
986
|
},
|
|
707
987
|
dataSource,
|
|
988
|
+
publishStatus: dataSource === "both" ? "published" : dataSource === "fly" ? "unpublished" : "unknown",
|
|
989
|
+
detailStatus: "pending",
|
|
708
990
|
flyApp: flyApp ? {
|
|
709
991
|
name: flyApp.name,
|
|
710
992
|
status: flyApp.status,
|
|
@@ -714,6 +996,54 @@ function baseSellerRow(
|
|
|
714
996
|
};
|
|
715
997
|
}
|
|
716
998
|
|
|
999
|
+
function isSellerFlyAppName(name: string | undefined): boolean {
|
|
1000
|
+
return Boolean(name && (name.startsWith("tbs-") || name.startsWith("tb-seller-")) && name !== "tb-seller");
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function sellerEntryFromFlyApp(app: import("./seller.js").SellerAppJson): SellerRegistryEntry {
|
|
1004
|
+
return {
|
|
1005
|
+
id: app.name,
|
|
1006
|
+
name: app.name,
|
|
1007
|
+
app: app.name,
|
|
1008
|
+
url: `https://${app.name}.fly.dev`,
|
|
1009
|
+
supportedProtocols: [],
|
|
1010
|
+
paymentMethods: [],
|
|
1011
|
+
models: []
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function findFlyAppForEntry(
|
|
1016
|
+
flyByName: Map<string, import("./seller.js").SellerAppJson>,
|
|
1017
|
+
entry: SellerRegistryEntry
|
|
1018
|
+
): import("./seller.js").SellerAppJson | undefined {
|
|
1019
|
+
for (const key of [entry.app, entry.id, entry.name]) {
|
|
1020
|
+
const normalized = stringValue(key);
|
|
1021
|
+
if (!normalized) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const match = flyByName.get(normalized);
|
|
1025
|
+
if (match) {
|
|
1026
|
+
return match;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return undefined;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function sellerEntryFromRow(row: SellerRow): SellerRegistryEntry {
|
|
1033
|
+
return {
|
|
1034
|
+
id: row.id,
|
|
1035
|
+
name: row.name,
|
|
1036
|
+
app: row.app || row.flyApp?.name || row.id,
|
|
1037
|
+
url: row.url,
|
|
1038
|
+
status: row.registryStatus,
|
|
1039
|
+
region: row.region,
|
|
1040
|
+
modelsCount: row.modelsCount,
|
|
1041
|
+
supportedProtocols: [],
|
|
1042
|
+
paymentMethods: [],
|
|
1043
|
+
models: []
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
717
1047
|
function mergeSellerRow(
|
|
718
1048
|
base: SellerRow,
|
|
719
1049
|
entry: SellerRegistryEntry,
|
|
@@ -721,7 +1051,8 @@ function mergeSellerRow(
|
|
|
721
1051
|
service: any,
|
|
722
1052
|
upstreams: any,
|
|
723
1053
|
config: any,
|
|
724
|
-
balance: BalanceSnapshot | undefined
|
|
1054
|
+
balance: BalanceSnapshot | undefined,
|
|
1055
|
+
manifestOk = false
|
|
725
1056
|
): SellerRow {
|
|
726
1057
|
const normalizedUpstreams = upstreamDocument(upstreams);
|
|
727
1058
|
const capacity = status?.capacity || service?.capacity || {};
|
|
@@ -729,12 +1060,13 @@ function mergeSellerRow(
|
|
|
729
1060
|
const error = firstError(status, service, upstreams);
|
|
730
1061
|
return {
|
|
731
1062
|
...base,
|
|
732
|
-
nodeStatus: error ? "unknown" : nodeStatus(status?.status || entry.status),
|
|
1063
|
+
nodeStatus: error ? (manifestOk ? "active" : "unknown") : (manifestOk ? "active" : nodeStatus(status?.status || entry.status)),
|
|
733
1064
|
upstreamDomain: hostName(upstreamUrl) || base.upstreamDomain,
|
|
734
1065
|
upstreamStatus: upstreamStatus(status?.upstream?.status || normalizedUpstreams?.status),
|
|
735
1066
|
discountRatio: numberValue(config?.discountRatio ?? normalizedUpstreams?.discountRatio),
|
|
736
1067
|
capacityUsed: numberValue(capacity.activeConnections),
|
|
737
1068
|
capacityLimit: numberValue(capacity.maxConnections),
|
|
1069
|
+
...runtimeUsageFields(status?.runtime, base),
|
|
738
1070
|
ttftMs: numberValue(status?.latency?.ttftMs),
|
|
739
1071
|
avgInferenceMs: numberValue(status?.latency?.avgInferenceMs),
|
|
740
1072
|
lastInferenceMs: numberValue(status?.latency?.lastInferenceMs),
|
|
@@ -750,26 +1082,65 @@ function mergeSellerRow(
|
|
|
750
1082
|
modelsCount: numberValue(service?.modelsCount ?? normalizedUpstreams?.models?.length ?? base.modelsCount),
|
|
751
1083
|
specs: {
|
|
752
1084
|
...base.specs,
|
|
753
|
-
region: entry.region,
|
|
1085
|
+
region: entry.region || base.specs?.region,
|
|
754
1086
|
modelsCount: numberValue(service?.modelsCount ?? normalizedUpstreams?.models?.length ?? base.modelsCount)
|
|
755
1087
|
},
|
|
756
1088
|
error
|
|
757
1089
|
};
|
|
758
1090
|
}
|
|
759
1091
|
|
|
1092
|
+
function runtimeUsageFields(runtime: any, fallback: SellerRow): Partial<SellerRow> {
|
|
1093
|
+
return {
|
|
1094
|
+
resourceCpuPercent: numberValue(runtime?.cpuPercent) ?? fallback.resourceCpuPercent,
|
|
1095
|
+
resourceMemoryPercent: numberValue(runtime?.memoryPercent) ?? fallback.resourceMemoryPercent,
|
|
1096
|
+
resourceMemoryRssMb: numberValue(runtime?.memoryRssMb) ?? fallback.resourceMemoryRssMb,
|
|
1097
|
+
resourceMemoryLimitMb: numberValue(runtime?.memoryLimitMb) ?? fallback.resourceMemoryLimitMb
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function balanceFields(balance: BalanceSnapshot | undefined, fallback: SellerRow): Partial<SellerRow> {
|
|
1102
|
+
if (!balance) {
|
|
1103
|
+
return {};
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
upstreamBalanceUsdMicros: Number.isFinite(balance.amountUsdMicros ?? NaN) ? (balance.amountUsdMicros as number) : undefined,
|
|
1107
|
+
upstreamBalanceCurrency: typeof balance.currency === "string" ? balance.currency : undefined,
|
|
1108
|
+
upstreamBalanceSource: balance.source,
|
|
1109
|
+
upstreamBalanceFetchedAt: new Date(balance.fetchedAt).toISOString(),
|
|
1110
|
+
upstreamBalanceError: balance.error?.message,
|
|
1111
|
+
upstreamRechargeUrl: fallback.upstreamRechargeUrl
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function unavailableBalanceSnapshot(message: string): BalanceSnapshot {
|
|
1116
|
+
return {
|
|
1117
|
+
rawAmount: null,
|
|
1118
|
+
amountUsdMicros: null,
|
|
1119
|
+
currency: null,
|
|
1120
|
+
source: "unknown",
|
|
1121
|
+
fetchedAt: Date.now(),
|
|
1122
|
+
error: {
|
|
1123
|
+
httpStatus: 0,
|
|
1124
|
+
message
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
760
1129
|
function modelRows(upstreams: any, config: any, status: any): SellerModelRow[] {
|
|
761
1130
|
const normalizedUpstreams = upstreamDocument(upstreams);
|
|
762
1131
|
const aliases = config.modelAliases || normalizedUpstreams.modelAliases || {};
|
|
763
|
-
const models = Array.isArray(
|
|
764
|
-
? normalizedUpstreams.models
|
|
765
|
-
: Array.isArray(config.models)
|
|
1132
|
+
const models = Array.isArray(config.models)
|
|
766
1133
|
? config.models
|
|
767
|
-
:
|
|
1134
|
+
: Array.isArray(normalizedUpstreams.models)
|
|
1135
|
+
? normalizedUpstreams.models
|
|
1136
|
+
: [];
|
|
768
1137
|
return models.map((model: any) => {
|
|
769
1138
|
const id = stringValue(model.id || model.name) || "unknown";
|
|
770
1139
|
return {
|
|
771
1140
|
upstreamModel: id,
|
|
772
1141
|
billingModel: stringValue(aliases[id]) || id,
|
|
1142
|
+
enabled: model.enabled !== false,
|
|
1143
|
+
configModel: objectValue(model),
|
|
773
1144
|
inputPrice: priceString(model.inputPriceMicrosPer1m),
|
|
774
1145
|
outputPrice: priceString(model.outputPriceMicrosPer1m),
|
|
775
1146
|
ttftMs: numberValue(model.ttftMs ?? status?.latency?.ttftMs),
|
|
@@ -819,6 +1190,9 @@ export function maskApiKey(value: unknown): string | undefined {
|
|
|
819
1190
|
if (!normalized) {
|
|
820
1191
|
return undefined;
|
|
821
1192
|
}
|
|
1193
|
+
if (isRedactedConfigSecret(normalized)) {
|
|
1194
|
+
return "configured";
|
|
1195
|
+
}
|
|
822
1196
|
const tail = normalized.replace(/\s+/g, "").slice(-4);
|
|
823
1197
|
return tail ? `**** **** **** ${tail}` : "****";
|
|
824
1198
|
}
|
|
@@ -865,6 +1239,10 @@ function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
|
865
1239
|
: undefined;
|
|
866
1240
|
}
|
|
867
1241
|
|
|
1242
|
+
function isRedactedConfigSecret(value: unknown): boolean {
|
|
1243
|
+
return value === "[redacted]";
|
|
1244
|
+
}
|
|
1245
|
+
|
|
868
1246
|
function priceString(value: unknown): string | undefined {
|
|
869
1247
|
const parsed = numberValue(value);
|
|
870
1248
|
if (parsed === undefined) {
|
|
@@ -890,3 +1268,51 @@ function firstError(...values: any[]): string | undefined {
|
|
|
890
1268
|
const hit = values.find((value) => value?.error);
|
|
891
1269
|
return hit?.error;
|
|
892
1270
|
}
|
|
1271
|
+
|
|
1272
|
+
function normalizeRegistryDocument(value: unknown, label: string): SellerRegistryDocument {
|
|
1273
|
+
if (!value || typeof value !== "object" || !Array.isArray((value as { sellers?: unknown }).sellers)) {
|
|
1274
|
+
throw new Error(`${label} did not include sellers`);
|
|
1275
|
+
}
|
|
1276
|
+
const document = value as Partial<SellerRegistryDocument> & { sellers: SellerRegistryEntry[] };
|
|
1277
|
+
return {
|
|
1278
|
+
version: numberValue(document.version) ?? 0,
|
|
1279
|
+
updatedAt: stringValue(document.updatedAt),
|
|
1280
|
+
purpose: stringValue(document.purpose),
|
|
1281
|
+
defaultSeller: stringValue(document.defaultSeller),
|
|
1282
|
+
notes: Array.isArray(document.notes) ? document.notes.map((note) => stringValue(note)).filter((note): note is string => Boolean(note)) : undefined,
|
|
1283
|
+
sellers: document.sellers
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function mergeRegistryDocuments(publicDoc: SellerRegistryDocument, managedDoc: SellerRegistryDocument): SellerRegistryDocument {
|
|
1288
|
+
const sellers = publicDoc.sellers.map((seller) => ({ ...seller }));
|
|
1289
|
+
for (const managed of managedDoc.sellers) {
|
|
1290
|
+
const index = sellers.findIndex((seller) => sameSellerEntry(seller, managed));
|
|
1291
|
+
if (index >= 0) {
|
|
1292
|
+
sellers[index] = { ...sellers[index], ...managed };
|
|
1293
|
+
} else {
|
|
1294
|
+
sellers.push(managed);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return {
|
|
1298
|
+
version: publicDoc.version || managedDoc.version,
|
|
1299
|
+
updatedAt: publicDoc.updatedAt || managedDoc.updatedAt,
|
|
1300
|
+
purpose: publicDoc.purpose || managedDoc.purpose,
|
|
1301
|
+
defaultSeller: publicDoc.defaultSeller || managedDoc.defaultSeller,
|
|
1302
|
+
notes: publicDoc.notes || managedDoc.notes,
|
|
1303
|
+
sellers
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function sameSellerEntry(a: SellerRegistryEntry, b: SellerRegistryEntry): boolean {
|
|
1308
|
+
const aKeys = new Set([a.id, a.app, a.name].map((value) => stringValue(value)).filter((value): value is string => Boolean(value)));
|
|
1309
|
+
return [b.id, b.app, b.name].some((value) => {
|
|
1310
|
+
const normalized = stringValue(value);
|
|
1311
|
+
return normalized ? aKeys.has(normalized) : false;
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function isUnavailablePlatformSellersEndpoint(err: unknown): boolean {
|
|
1316
|
+
const message = err instanceof Error ? err.message : String(err || "");
|
|
1317
|
+
return /HTTP Error (401|403|404)|Cannot GET \/platform\/sellers|vendor_auth|not found/i.test(message);
|
|
1318
|
+
}
|