@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.
Files changed (41) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +28 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/seller.d.ts +40 -1
  5. package/dist/src/seller.d.ts.map +1 -1
  6. package/dist/src/seller.js +132 -2
  7. package/dist/src/seller.js.map +1 -1
  8. package/dist/src/ui-actions.d.ts +2 -0
  9. package/dist/src/ui-actions.d.ts.map +1 -1
  10. package/dist/src/ui-actions.js +8 -6
  11. package/dist/src/ui-actions.js.map +1 -1
  12. package/dist/src/ui-command.d.ts +1 -0
  13. package/dist/src/ui-command.d.ts.map +1 -1
  14. package/dist/src/ui-command.js +7 -2
  15. package/dist/src/ui-command.js.map +1 -1
  16. package/dist/src/ui-server.js +17 -0
  17. package/dist/src/ui-server.js.map +1 -1
  18. package/dist/src/ui-state.d.ts +31 -0
  19. package/dist/src/ui-state.d.ts.map +1 -1
  20. package/dist/src/ui-state.js +461 -115
  21. package/dist/src/ui-state.js.map +1 -1
  22. package/dist/src/ui-static.d.ts.map +1 -1
  23. package/dist/src/ui-static.js +267 -144
  24. package/dist/src/ui-static.js.map +1 -1
  25. package/dist/src/upstream-balance-probe.d.ts +2 -40
  26. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  27. package/dist/src/upstream-balance-probe.js +1 -378
  28. package/dist/src/upstream-balance-probe.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli.ts +31 -0
  31. package/src/seller.ts +179 -3
  32. package/src/ui-actions.ts +10 -6
  33. package/src/ui-command.ts +7 -2
  34. package/src/ui-server.ts +18 -1
  35. package/src/ui-state.ts +541 -115
  36. package/src/ui-static.ts +267 -144
  37. package/src/upstream-balance-probe.ts +13 -505
  38. package/tests/admin.test.ts +418 -39
  39. package/tests/seller.test.ts +51 -0
  40. package/tests/ui-state-fleet.test.ts +272 -3
  41. 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.fetchRegistry().catch((err: any) => {
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 = entry.app ? flyByName.get(entry.app) : undefined;
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
- const snapshot = await this.sellerSnapshot(entry, flyMatch, dataSource);
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
- for (const app of flyByName.values()) {
318
- if (consumedFly.has(app.name)) continue;
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
- rows.push(snapshot.row);
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
- public async sellerDetail(id: string): Promise<SellerDetail> {
368
- // Step 13 v1.1: detail 页也走双源. 先看 entry 在不在 registry,
369
- // 不在则查 fly list (registry-only 行, 标红 detail).
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 snapshot = await this.sellerSnapshot(entry, flyApp, dataSource);
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
- public async rawSellerConfig(id: string): Promise<{ entry: SellerRegistryEntry; profileName?: string; config: any }> {
428
- const document = await this.fetchRegistry();
429
- const entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
430
- if (!entry) {
431
- throw new Error(`seller \`${id}\` not found in bootstrap registry`);
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
- const document = await this.fetchJson(`${trimSlash(baseUrl)}/registry/sellers`) as SellerRegistryDocument;
448
- if (!document || !Array.isArray(document.sellers)) {
449
- throw new Error("bootstrap registry response did not include sellers");
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 === "fly") {
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: manifestOk ? "active" : "unknown",
538
- error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
758
+ nodeStatus: "unknown"
539
759
  }
540
760
  };
541
761
  }
542
- if (dataSource === "both" && !match.profile) {
543
- // both vendor profile 缺 — 还是 unknown (灰点), 1.0.31 老行为
544
- // 会落 auth_unknown, 现在用 unknown 让 UI 不会显得像 "鉴权失败".
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: "No matching local admin profile"
778
+ nodeStatus: manifestOk ? "active" : "unknown",
779
+ error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
550
780
  }
551
781
  };
552
782
  }
553
783
 
554
- // Step 13: 绿点 = fetch <entry.url>/manifest 200 OK
555
- // (registry-only skip 这个 fetch, 因为 entry.url 可能指向死链)
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
- // 绿点路径: 优先 <entry.url>/manifest (无 auth 公共 endpoint).
567
- // fallback: 走 vendor token 调 /operator/status (老 1.0.31 行为).
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(baseRow, entry, status, service, upstreams, configDocument, balance)
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
- return this.fetchJson(`${trimSlash(profile.url)}${pathName}`, {
637
- headers: {
638
- "Content-Type": "application/json",
639
- Authorization: `Bearer ${profile.token}`
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
- clearTimeout(timer);
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: entry.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
- region: entry.region,
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(normalizedUpstreams.models)
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
+ }