@tokenbuddy/tb-admin 1.0.32 → 1.0.34

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