@tokenbuddy/tb-admin 1.0.36 → 1.0.38

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 (93) hide show
  1. package/dist/src/cli.js +98 -25
  2. package/dist/src/config.d.ts +8 -2
  3. package/dist/src/config.js +17 -5
  4. package/dist/src/display-format.js +6 -14
  5. package/dist/src/init-command.d.ts +50 -0
  6. package/dist/src/init-command.js +347 -0
  7. package/dist/src/providers/fly-io.d.ts +3 -0
  8. package/dist/src/providers/fly-io.js +137 -0
  9. package/dist/src/providers/provider-definition.d.ts +38 -0
  10. package/dist/src/providers/provider-definition.js +2 -0
  11. package/dist/src/seller.d.ts +2 -0
  12. package/dist/src/seller.js +30 -13
  13. package/dist/src/server-cmd.d.ts +1 -0
  14. package/dist/src/server-cmd.js +9 -2
  15. package/dist/src/ui-actions.d.ts +3 -0
  16. package/dist/src/ui-actions.js +199 -27
  17. package/dist/src/ui-command.js +3 -2
  18. package/dist/src/ui-state.d.ts +1 -3
  19. package/dist/src/ui-state.js +4 -8
  20. package/dist/src/ui-static.js +43 -15
  21. package/dist/src/workdir.d.ts +21 -0
  22. package/dist/src/workdir.js +50 -0
  23. package/package.json +9 -3
  24. package/templates/providers/fly.io/admin.toml.example +18 -0
  25. package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
  26. package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
  27. package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
  28. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
  29. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
  30. package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
  31. package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
  32. package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
  33. package/templates/providers/fly.io/env/deploy.env.example +12 -0
  34. package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
  35. package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
  36. package/templates/providers/fly.io/provider.toml.example +10 -0
  37. package/dist/src/bootstrap-registry.d.ts.map +0 -1
  38. package/dist/src/bootstrap-registry.js.map +0 -1
  39. package/dist/src/cli.d.ts.map +0 -1
  40. package/dist/src/cli.js.map +0 -1
  41. package/dist/src/client.d.ts.map +0 -1
  42. package/dist/src/client.js.map +0 -1
  43. package/dist/src/config.d.ts.map +0 -1
  44. package/dist/src/config.js.map +0 -1
  45. package/dist/src/display-format.d.ts.map +0 -1
  46. package/dist/src/display-format.js.map +0 -1
  47. package/dist/src/index.d.ts.map +0 -1
  48. package/dist/src/index.js.map +0 -1
  49. package/dist/src/provider.d.ts.map +0 -1
  50. package/dist/src/provider.js.map +0 -1
  51. package/dist/src/seller.d.ts.map +0 -1
  52. package/dist/src/seller.js.map +0 -1
  53. package/dist/src/server-cmd.d.ts.map +0 -1
  54. package/dist/src/server-cmd.js.map +0 -1
  55. package/dist/src/ui-actions.d.ts.map +0 -1
  56. package/dist/src/ui-actions.js.map +0 -1
  57. package/dist/src/ui-command.d.ts.map +0 -1
  58. package/dist/src/ui-command.js.map +0 -1
  59. package/dist/src/ui-server.d.ts.map +0 -1
  60. package/dist/src/ui-server.js.map +0 -1
  61. package/dist/src/ui-state.d.ts.map +0 -1
  62. package/dist/src/ui-state.js.map +0 -1
  63. package/dist/src/ui-static.d.ts.map +0 -1
  64. package/dist/src/ui-static.js.map +0 -1
  65. package/dist/src/upstream-balance-probe.d.ts.map +0 -1
  66. package/dist/src/upstream-balance-probe.js.map +0 -1
  67. package/dist/src/vendor-client.d.ts.map +0 -1
  68. package/dist/src/vendor-client.js.map +0 -1
  69. package/dist/src/vendor-commands.d.ts.map +0 -1
  70. package/dist/src/vendor-commands.js.map +0 -1
  71. package/src/bootstrap-registry.ts +0 -90
  72. package/src/cli.ts +0 -1614
  73. package/src/client.ts +0 -179
  74. package/src/config.ts +0 -194
  75. package/src/display-format.ts +0 -411
  76. package/src/index.ts +0 -11
  77. package/src/provider.ts +0 -150
  78. package/src/seller.ts +0 -538
  79. package/src/server-cmd.ts +0 -362
  80. package/src/ui-actions.ts +0 -1040
  81. package/src/ui-command.ts +0 -44
  82. package/src/ui-server.ts +0 -353
  83. package/src/ui-state.ts +0 -1318
  84. package/src/ui-static.ts +0 -673
  85. package/src/upstream-balance-probe.ts +0 -13
  86. package/src/vendor-client.ts +0 -23
  87. package/src/vendor-commands.ts +0 -65
  88. package/tests/admin.test.ts +0 -2162
  89. package/tests/seller.test.ts +0 -388
  90. package/tests/ui-state-fleet.test.ts +0 -526
  91. package/tests/ui-static-row.test.ts +0 -467
  92. package/tests/vendor-cli.test.ts +0 -241
  93. package/tsconfig.json +0 -8
package/src/ui-state.ts DELETED
@@ -1,1318 +0,0 @@
1
- import { AdminClient } from "./client.js";
2
- import { ConfigManager, type AdminProfile } from "./config.js";
3
- import type { SellerRegistryDocument, SellerRegistryEntry } from "./bootstrap-registry.js";
4
- import {
5
- BalanceProbeCache,
6
- type BalanceSnapshot,
7
- probeUpstreamBalance
8
- } from "./upstream-balance-probe.js";
9
-
10
- export type RegistryStatus = "active" | "draining" | "offline" | "pending" | "unknown";
11
- export type NodeStatus = RegistryStatus | "busy_capacity" | "auth_unknown";
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";
15
- /**
16
- * Step 13 v1.1: dataSource 标记一行 seller 在 fly.io apps list vs
17
- * registry /registry/sellers 两个独立查询里的出现情况. UI 据此决定
18
- * 标红 / 立即下线 / Apply 按钮. 详细见
19
- * docs/processes/seller-fleet-data-sources.md.
20
- */
21
- export type SellerDataSource = "fly" | "registry" | "both";
22
-
23
- export interface SellerRow {
24
- id: string;
25
- name: string;
26
- description?: string;
27
- app?: string;
28
- profile?: string;
29
- url: string;
30
- registryStatus: RegistryStatus;
31
- nodeStatus: NodeStatus;
32
- region?: string;
33
- upstreamDomain: string;
34
- upstreamStatus: UpstreamStatus;
35
- upstreamBalanceUsdMicros?: number;
36
- upstreamBalanceCurrency?: string;
37
- upstreamBalanceSource?: string;
38
- upstreamBalanceFetchedAt?: string;
39
- upstreamBalanceError?: string;
40
- upstreamRechargeUrl?: string;
41
- discountRatio?: number;
42
- capacityUsed?: number;
43
- capacityLimit?: number;
44
- resourceCpuPercent?: number;
45
- resourceMemoryPercent?: number;
46
- resourceMemoryRssMb?: number;
47
- resourceMemoryLimitMb?: number;
48
- ttftMs?: number;
49
- avgInferenceMs?: number;
50
- lastInferenceMs?: number;
51
- avgTokensPerSecond?: number;
52
- lastTokensPerSecond?: number;
53
- latencySamples?: number;
54
- lastSwitchAt?: string;
55
- modelsCount?: number;
56
- specs?: {
57
- cpuKind?: string;
58
- cpuCores?: number;
59
- memoryMb?: number;
60
- memoryGb?: number;
61
- machines?: number;
62
- runningMachines?: number;
63
- volumeGb?: number;
64
- region?: string;
65
- regions?: string[];
66
- modelsCount?: number;
67
- };
68
- error?: string;
69
- /**
70
- * Step 13 v1.1: 数据源标记. UI 据此决定:
71
- * - "fly" → 灰点 + 「未发布」+ Apply 按钮 (走 vendor-bootstrap stage)
72
- * - "registry" → 整行标红 + 立即下线按钮 (registry-only = 重大事故)
73
- * - "both" → 正常色 + Activate / Drain 走 vendor path
74
- */
75
- dataSource: SellerDataSource;
76
- /**
77
- * Step 13 v1.1: 标红告警 (registry-only). UI 整行用 .registry-alert
78
- * class 标红, tooltip 提示事故 + 立即下线.
79
- */
80
- registryAlert?: boolean;
81
- /** Step 13 v1.1: 标红原因 (中文短句, UI tooltip 显示). */
82
- alertReason?: string;
83
- /** Step 13 v1.1: "未发布" 提示 (fly-only 行的 publishHint 按钮 caption). */
84
- publishHint?: string;
85
- /**
86
- * Step 13 v1.1: 立即下线按钮 caption (registry-only 行). 文案
87
- * 必须含 "registry-only" 让用户知道这**不**删 fly app. 详见 spec.
88
- */
89
- removeHint?: string;
90
- /**
91
- * Step 13 v1.1: 关联的 fly app (raw, 来源 flyctl apps list --json).
92
- * fly-only 行一定有值; both 行有值; registry-only 行 undefined.
93
- */
94
- flyApp?: { name: string; status?: string; owner?: string; latestDeployAt?: string };
95
- publishStatus?: PublishStatus;
96
- detailStatus?: DetailStatus;
97
- detailUpdatedAt?: string;
98
- detailNextRefreshAt?: string;
99
- }
100
-
101
- export interface SellerModelRow {
102
- upstreamModel: string;
103
- billingModel: string;
104
- enabled: boolean;
105
- configModel?: Record<string, unknown>;
106
- inputPrice?: string;
107
- outputPrice?: string;
108
- ttftMs?: number;
109
- avgInferenceMs?: number;
110
- avgTokensPerSecond?: number;
111
- latencySamples?: number;
112
- }
113
-
114
- export interface SellerDetail {
115
- row: SellerRow;
116
- configuration: {
117
- registryStatus: string;
118
- region?: string;
119
- upstreamUrl?: string;
120
- upstreamApiKeyMasked?: string;
121
- upstreamStatus: UpstreamStatus;
122
- upstreamBalance?: string;
123
- upstreamBalanceSource?: string;
124
- upstreamBalanceFetchedAt?: string;
125
- upstreamBalanceError?: string;
126
- upstreamBalanceProbeTemplate?: string;
127
- upstreamBalanceProbeUrl?: string;
128
- upstreamBalanceProbeUserId?: string;
129
- upstreamBalanceProbeRechargeUrl?: string;
130
- upstreamBalanceUrl?: string;
131
- upstreamUserId?: string;
132
- upstreamRechargeUrl?: string;
133
- markupRatio?: number;
134
- discountRatio?: number;
135
- maxConnections?: number;
136
- maxQueueDepth?: number;
137
- };
138
- models: SellerModelRow[];
139
- }
140
-
141
- export interface BootstrapSummary {
142
- status: "available" | "unavailable";
143
- url?: string;
144
- profile?: string;
145
- registryVersion?: number;
146
- registryUpdatedAt?: string;
147
- sellerEntries: number;
148
- defaultSeller?: string;
149
- regions: string[];
150
- error?: string;
151
- }
152
-
153
- export interface BootstrapConfigSummary {
154
- status: "available" | "unavailable";
155
- sellerRegistryPath?: string;
156
- bindHost?: string;
157
- bindPort?: number;
158
- allowLocalSellerUrls?: boolean;
159
- clawtip?: {
160
- payToMasked?: string;
161
- sm4KeyMasked?: string;
162
- skillSlug?: string;
163
- skillId?: string;
164
- description?: string;
165
- resourceUrl?: string;
166
- activationFeeFen?: number;
167
- microsPerFen?: number;
168
- };
169
- error?: string;
170
- }
171
-
172
- export interface AdminUiStateOptions {
173
- configManager: ConfigManager;
174
- configPath?: string;
175
- profile?: string;
176
- url?: string;
177
- token?: string;
178
- fetchJson?: (url: string, init?: RequestInit) => Promise<unknown>;
179
- balanceFetch?: typeof fetch;
180
- /**
181
- * Step 13 v1.1: flyctl apps list --json 替代. 默认调 seller.ts 的
182
- * runFlyctlJson 真 spawn flyctl. 测试用 closure 注入 fake 数据.
183
- * 返回的 SellerAppJson 形如 { name, status, owner, latestDeployAt }.
184
- */
185
- flyApps?: () => Promise<import("./seller.js").SellerAppJson[]>;
186
- flyMachineSpecs?: (appName: string) => Promise<import("./seller.js").SellerMachineSpecs | undefined>;
187
- }
188
-
189
- interface ProfileMatch {
190
- name?: string;
191
- profile?: AdminProfile;
192
- localProfile?: boolean;
193
- }
194
-
195
- interface SellerSnapshot {
196
- row: SellerRow;
197
- status?: any;
198
- service?: any;
199
- upstreams?: any;
200
- config?: any;
201
- balance?: BalanceSnapshot;
202
- }
203
-
204
- interface SellerTarget {
205
- entry: SellerRegistryEntry;
206
- flyApp?: import("./seller.js").SellerAppJson;
207
- dataSource: SellerDataSource;
208
- }
209
-
210
- export class AdminUiState {
211
- private readonly configManager: ConfigManager;
212
- private readonly options: AdminUiStateOptions;
213
- private readonly fetchJson: (url: string, init?: RequestInit) => Promise<unknown>;
214
- private readonly balanceCache = new BalanceProbeCache();
215
- private readonly machineSpecsCache = new Map<string, import("./seller.js").SellerMachineSpecs | undefined>();
216
-
217
- constructor(options: AdminUiStateOptions) {
218
- this.options = options;
219
- this.configManager = options.configPath ? new ConfigManager(options.configPath) : options.configManager;
220
- this.fetchJson = options.fetchJson || defaultFetchJson;
221
- }
222
-
223
- public async bootstrap(): Promise<BootstrapSummary> {
224
- try {
225
- const document = await this.fetchRegistry();
226
- const profile = this.activeBootstrapProfile();
227
- return {
228
- status: "available",
229
- url: profile.profile?.url || this.options.url,
230
- profile: profile.name,
231
- registryVersion: document.version,
232
- registryUpdatedAt: document.updatedAt,
233
- sellerEntries: document.sellers.length,
234
- defaultSeller: document.defaultSeller,
235
- regions: Array.from(new Set(document.sellers.map((seller) => seller.region).filter((region): region is string => Boolean(region)))).sort()
236
- };
237
- } catch (err: any) {
238
- const profile = this.activeBootstrapProfile();
239
- return {
240
- status: "unavailable",
241
- url: profile.profile?.url || this.options.url,
242
- profile: profile.name,
243
- sellerEntries: 0,
244
- regions: [],
245
- error: err.message
246
- };
247
- }
248
- }
249
-
250
- public async bootstrapConfig(): Promise<BootstrapConfigSummary> {
251
- try {
252
- const profile = this.activeBootstrapProfile();
253
- if (!profile.profile) {
254
- throw new Error("No bootstrap profile found. Configure an admin profile or pass --url and --token.");
255
- }
256
- const client = new AdminClient(profile.profile.url, profile.profile.token);
257
- const config = await client.get("/operator/config");
258
- return {
259
- status: "available",
260
- sellerRegistryPath: stringValue(config.sellerRegistryPath),
261
- bindHost: stringValue(config.bind?.host),
262
- bindPort: numberValue(config.bind?.port),
263
- allowLocalSellerUrls: Boolean(config.allowLocalSellerUrls),
264
- clawtip: {
265
- payToMasked: maskSecret(config.clawtip?.payTo),
266
- sm4KeyMasked: maskSecret(config.clawtip?.sm4KeyBase64),
267
- skillSlug: stringValue(config.clawtip?.skillSlug),
268
- skillId: stringValue(config.clawtip?.skillId),
269
- description: stringValue(config.clawtip?.description),
270
- resourceUrl: stringValue(config.clawtip?.resourceUrl),
271
- activationFeeFen: numberValue(config.clawtip?.activationFeeFen),
272
- microsPerFen: numberValue(config.clawtip?.microsPerFen)
273
- }
274
- };
275
- } catch (err: any) {
276
- return {
277
- status: "unavailable",
278
- error: err.message
279
- };
280
- }
281
- }
282
-
283
- /**
284
- * Step 13 v1.1: 双源 seller list.
285
- * - flyApps: flyctl apps list --json (走 fly token)
286
- * - fetchRegistry: GET <registry>/registry/sellers (走 vendor key)
287
- * **不**做 union / dedupe / merge. 渲染时按 (fly ∩ registry / fly /
288
- * registry) 标 dataSource, registry-only 行整行标红. 详见
289
- * docs/processes/seller-fleet-data-sources.md.
290
- */
291
- public async sellers(): Promise<SellerRow[]> {
292
- const [flyApps, registryDoc] = await Promise.all([
293
- this.fetchFlyApps().catch((err: any) => {
294
- return { __error: err.message } as any;
295
- }),
296
- this.fetchManagedRegistry().catch((err: any) => {
297
- return { __error: err.message, sellers: [] } as any;
298
- })
299
- ]);
300
-
301
- // Step 13: 算两个索引.
302
- // flyByName: app.name -> SellerAppJson (UI 用, 不会重复)
303
- // registryByKey: entry.id / entry.app -> entry (UI 用, 不会重复)
304
- // 行数 = max(fly 数量, registry 数量); 双源都有时合并到 1 行.
305
- const flyByName = new Map<string, import("./seller.js").SellerAppJson>();
306
- if (Array.isArray(flyApps)) {
307
- for (const app of flyApps) {
308
- if (app?.name) {
309
- flyByName.set(app.name, app);
310
- }
311
- }
312
- }
313
- const registryById = new Map<string, SellerRegistryEntry>();
314
- const registryByApp = new Map<string, SellerRegistryEntry>();
315
- for (const entry of registryDoc.sellers || []) {
316
- if (entry.id) registryById.set(entry.id, entry);
317
- if (entry.app) registryByApp.set(entry.app, entry);
318
- }
319
-
320
- // Step 13: 4 类行 (dataSource 决定):
321
- // - fly ∩ registry: 同一行 (both)
322
- // - fly only: dataSource=fly, 灰点 + "未发布" 提示
323
- // - registry only: dataSource=registry, **整行标红** + 立即下线按钮
324
- // - 都出现但 key 不 match (e.g. registry 没填 app 字段): 也当 both
325
- // (按 entry.url host 兜底 match)
326
- const rows: SellerRow[] = [];
327
- const consumedFly = new Set<string>();
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
- }> = [];
335
-
336
- // Phase 1: registry first (因为有 id + url + 详细 metadata)
337
- for (const entry of registryDoc.sellers || []) {
338
- const flyMatch = findFlyAppForEntry(flyByName, entry);
339
- const dataSource: SellerDataSource = flyMatch ? "both" : "registry";
340
- if (flyMatch) {
341
- consumedFly.add(flyMatch.name);
342
- }
343
- consumedRegistry.add(entry.id);
344
- registryTargets.push({ entry, flyMatch, dataSource });
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
- ));
352
-
353
- // Phase 2: fly-only apps (registry 没有)
354
- const flyOnlyApps = Array.from(flyByName.values()).filter((app) => !consumedFly.has(app.name));
355
- rows.push(...await Promise.all(flyOnlyApps.map(async (app) => {
356
- const stubEntry: SellerRegistryEntry = {
357
- id: app.name,
358
- name: app.name,
359
- app: app.name,
360
- url: `https://${app.name}.fly.dev`,
361
- supportedProtocols: [],
362
- paymentMethods: [],
363
- models: []
364
- };
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);
423
- }
424
-
425
- return rows;
426
- }
427
-
428
- public async refreshSellerRows(rows: SellerRow[]): Promise<SellerRow[]> {
429
- return await Promise.all(rows.map((row) => this.refreshSellerRow(row)));
430
- }
431
-
432
- /**
433
- * Step 13 v1.1: 拉 flyctl apps list --json. 默认走 seller.ts 真实
434
- * flyctl spawn, 测试或受限环境可注入 options.flyApps closure.
435
- */
436
- private async fetchFlyApps(): Promise<import("./seller.js").SellerAppJson[]> {
437
- if (this.options.flyApps) {
438
- return (await this.options.flyApps()).filter((app) => isSellerFlyAppName(app.name));
439
- }
440
- // 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
441
- // 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
442
- // 走 dynamic import; 失败 fallback 返空数组 (UI 不会崩, 只是
443
- // 标 dataSource="registry" + 标红).
444
- try {
445
- const mod = await import("./seller.js");
446
- const cfg = this.configManager.load();
447
- const profile = cfg.profiles[this.options.profile || "default"];
448
- const env = {
449
- ...process.env,
450
- ...(profile?.url ? { TB_REGISTRY_URL: profile.url } : {}),
451
- ...(profile?.token ? { TB_REGISTRY_TOKEN: profile.token } : {})
452
- };
453
- const runner = new mod.SellerCommandRunner(this.configManager);
454
- const result = await runner.ls(true);
455
- if (result && typeof result === "object" && "apps" in result) {
456
- return (result as { apps: import("./seller.js").SellerAppJson[] }).apps.filter((app) => isSellerFlyAppName(app.name));
457
- }
458
- return [];
459
- } catch (err: any) {
460
- return [];
461
- }
462
- }
463
-
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]));
467
- }
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 });
500
- const config = snapshot.config?.config || snapshot.config || {};
501
- const upstreams = snapshot.upstreams || {};
502
- return {
503
- row: snapshot.row,
504
- configuration: {
505
- registryStatus: snapshot.row.registryStatus,
506
- region: entry.region,
507
- upstreamUrl: stringValue(config.upstreamUrl || upstreams.upstreamUrl),
508
- upstreamApiKeyMasked: maskApiKey(config.upstreamApiKey || upstreams.upstreamApiKey),
509
- upstreamStatus: snapshot.row.upstreamStatus,
510
- upstreamBalance: balanceString(snapshot.balance),
511
- upstreamBalanceSource: snapshot.row.upstreamBalanceSource,
512
- upstreamBalanceFetchedAt: snapshot.row.upstreamBalanceFetchedAt,
513
- upstreamBalanceError: snapshot.row.upstreamBalanceError,
514
- upstreamBalanceProbeTemplate: stringValue(config.upstreamBalanceProbe?.template),
515
- upstreamBalanceProbeUrl: stringValue(config.upstreamBalanceProbe?.url || config.upstreamBalanceUrl),
516
- upstreamBalanceProbeUserId: stringValue(config.upstreamBalanceProbe?.userId || config.upstreamUserId),
517
- upstreamBalanceProbeRechargeUrl: stringValue(config.upstreamBalanceProbe?.rechargeUrl || config.upstreamRechargeUrl),
518
- upstreamBalanceUrl: stringValue(config.upstreamBalanceUrl),
519
- upstreamUserId: stringValue(config.upstreamUserId),
520
- upstreamRechargeUrl: snapshot.row.upstreamRechargeUrl,
521
- markupRatio: numberValue(config.markupRatio ?? upstreams.markupRatio),
522
- discountRatio: numberValue(config.discountRatio ?? upstreams.discountRatio),
523
- maxConnections: numberValue(config.maxConnections ?? snapshot.service?.capacity?.maxConnections ?? snapshot.status?.capacity?.maxConnections),
524
- maxQueueDepth: numberValue(config.maxQueueDepth ?? snapshot.service?.capacity?.maxQueueDepth ?? snapshot.status?.capacity?.maxQueueDepth)
525
- },
526
- models: modelRows(upstreams, config, snapshot.status)
527
- };
528
- }
529
-
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
- };
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);
599
- const match = this.matchSellerProfile(entry);
600
- if (!match.profile) {
601
- throw new Error(`seller \`${entry.id}\` has no matching local admin profile`);
602
- }
603
- const response = await this.fetchSellerAdminJson(match.profile, "/operator/admin/config");
604
- return { entry, profileName: match.localProfile ? match.name : undefined, config: response.config || response };
605
- }
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
-
636
- public async fetchRegistry(): Promise<SellerRegistryDocument> {
637
- const profile = this.activeBootstrapProfile();
638
- const baseUrl = this.options.url || profile.profile?.url;
639
- if (!baseUrl) {
640
- throw new Error("No bootstrap profile found. Configure an admin profile or pass --url.");
641
- }
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;
676
- }
677
- }
678
-
679
- public activeBootstrapProfile(): ProfileMatch {
680
- const envProfile = process.env.TOKENBUDDY_ADMIN_PROFILE;
681
- const target = this.options.profile || envProfile;
682
- if (this.options.url) {
683
- return {
684
- name: target,
685
- profile: this.options.token ? { url: this.options.url, token: this.options.token } : undefined,
686
- localProfile: false
687
- };
688
- }
689
- const config = this.configManager.load();
690
- const name = target || config.default_profile || Object.keys(config.profiles)[0];
691
- return {
692
- name,
693
- profile: name ? config.profiles[name] : undefined,
694
- localProfile: Boolean(name && config.profiles[name])
695
- };
696
- }
697
-
698
- private matchSellerProfile(entry: SellerRegistryEntry): ProfileMatch {
699
- const config = this.configManager.load();
700
- if (entry.profile && config.profiles[entry.profile]) {
701
- return { name: entry.profile, profile: config.profiles[entry.profile], localProfile: true };
702
- }
703
- const byUrl = Object.entries(config.profiles).find(([, profile]) => trimSlash(profile.url) === trimSlash(entry.url));
704
- if (byUrl) {
705
- return { name: byUrl[0], profile: byUrl[1], localProfile: true };
706
- }
707
- const entryHost = hostName(entry.url);
708
- const near = Object.entries(config.profiles).find(([name, profile]) => {
709
- const haystack = `${name} ${profile.url} ${hostName(profile.url)}`.toLowerCase();
710
- return [entry.id, entry.app, entryHost].some((value) => value && haystack.includes(value.toLowerCase()));
711
- });
712
- if (near) {
713
- return { name: near[0], profile: near[1], localProfile: true };
714
- }
715
- const provider = this.configManager.getSellerProvider("fly");
716
- if (provider?.operator_secret) {
717
- return {
718
- name: entry.app || entry.id,
719
- profile: {
720
- url: entry.url,
721
- token: provider.operator_secret
722
- },
723
- localProfile: false
724
- };
725
- }
726
- return {};
727
- }
728
-
729
- /**
730
- * Step 13 v1.1: seller snapshot.
731
- * - 双源数据已经定好 (dataSource 由调用方传入)
732
- * - nodeStatus = fetch <seller.url>/manifest 200 → active, 否则 unknown
733
- * (**不**用 registry entry.status 决定绿点)
734
- * - registry-only 行 (dataSource="registry") → 标红 (registryAlert=true)
735
- */
736
- private async sellerSnapshot(
737
- entry: SellerRegistryEntry,
738
- flyApp: import("./seller.js").SellerAppJson | undefined,
739
- dataSource: SellerDataSource,
740
- options: { includeOperator?: boolean; balanceTimeoutMs?: number; machineSpecs?: import("./seller.js").SellerMachineSpecs } = {}
741
- ): Promise<SellerSnapshot> {
742
- const match = this.matchSellerProfile(entry);
743
- const baseRow = baseSellerRow(entry, match.name, dataSource, flyApp, options.machineSpecs);
744
-
745
- // Step 13: 立即下线 / Apply 按钮 hint 文案
746
- if (dataSource === "registry") {
747
- baseRow.registryAlert = true;
748
- baseRow.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
749
- baseRow.removeHint = "立即下线 (registry-only)";
750
- } else if (dataSource === "fly") {
751
- baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
752
- }
753
-
754
- if (dataSource === "registry") {
755
- return {
756
- row: {
757
- ...baseRow,
758
- nodeStatus: "unknown"
759
- }
760
- };
761
- }
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;
775
- return {
776
- row: {
777
- ...baseRow,
778
- nodeStatus: manifestOk ? "active" : "unknown",
779
- error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
780
- }
781
- };
782
- }
783
-
784
- if (!match.profile) {
785
- manifestOk = await manifestPromise;
786
- return {
787
- row: {
788
- ...baseRow,
789
- nodeStatus: manifestOk ? "active" : "unknown",
790
- error: manifestOk ? "No matching local admin profile" : "No matching local admin profile; /manifest probe failed"
791
- }
792
- };
793
- }
794
- try {
795
- const [, status, service, upstreams, config] = await Promise.all([
796
- manifestPromise,
797
- this.fetchSellerAdminJson(match.profile, "/operator/status").catch((err: any) => ({ error: err.message })),
798
- this.fetchSellerAdminJson(match.profile, "/operator/admin/service").catch((err: any) => ({ error: err.message })),
799
- this.fetchSellerAdminJson(match.profile, "/operator/admin/upstreams").catch((err: any) => ({ error: err.message })),
800
- this.fetchSellerAdminJson(match.profile, "/operator/admin/config").catch((err: any) => ({ error: err.message }))
801
- ]);
802
- const configDocument = config?.config || config || {};
803
- const balance = await this.balanceSnapshot(match.profile, configDocument, upstreams, options.balanceTimeoutMs);
804
- return {
805
- status,
806
- service,
807
- upstreams,
808
- config,
809
- 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
- )
823
- };
824
- } catch (err: any) {
825
- await manifestPromise.catch(() => false);
826
- return {
827
- row: {
828
- ...baseRow,
829
- nodeStatus: manifestOk ? "active" : "unknown",
830
- error: err.message
831
- }
832
- };
833
- }
834
- }
835
-
836
- /**
837
- * Step 13 v1.1: 探 `<entry.url>/manifest` 拿绿点. 200/204 → true;
838
- * 任何非 2xx / 网络错 → false. 3s timeout 避免列表卡死.
839
- */
840
- private async probeManifest(url: string): Promise<boolean> {
841
- try {
842
- const controller = new AbortController();
843
- const timer = setTimeout(() => controller.abort(), 3000);
844
- const target = url.replace(/\/+$/, "") + "/manifest";
845
- const res = await fetch(target, {
846
- method: "GET",
847
- signal: controller.signal,
848
- headers: { "Content-Type": "application/json" }
849
- });
850
- clearTimeout(timer);
851
- return res.status >= 200 && res.status < 300;
852
- } catch {
853
- return false;
854
- }
855
- }
856
-
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);
871
- }
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
- }
883
- }
884
-
885
- private async balanceSnapshot(profile: AdminProfile, config: any, upstreams: any, timeoutMs?: number): Promise<BalanceSnapshot | undefined> {
886
- if (config?.error) {
887
- return undefined;
888
- }
889
- if (stringValue(config.upstreamBalanceProbe?.template) === "none") {
890
- return undefined;
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
- }
909
- return probeUpstreamBalance({
910
- upstreamUrl: stringValue(config.upstreamUrl || upstreams?.upstreamUrl),
911
- upstreamBalanceUrl: stringValue(config.upstreamBalanceUrl || upstreams?.upstreamBalanceUrl),
912
- upstreamApiKey: stringValue(config.upstreamApiKey),
913
- upstreamUserId: stringValue(config.upstreamUserId),
914
- upstreamBalanceProbe: objectValue(config.upstreamBalanceProbe)
915
- }, {
916
- fetch: this.options.balanceFetch,
917
- cache: this.balanceCache,
918
- timeoutMs
919
- });
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
- }
930
- }
931
-
932
- async function defaultFetchJson(url: string, init?: RequestInit): Promise<unknown> {
933
- // Step 13 v1.1: 3s timeout 避免坏 host / DNS 卡死整个 admin web.
934
- const controller = init?.signal ? undefined : new AbortController();
935
- const timer = controller ? setTimeout(() => controller.abort(), 3000) : undefined;
936
- let response: Response;
937
- try {
938
- response = await fetch(url, controller ? { ...init, signal: controller.signal } : init);
939
- } finally {
940
- if (timer) {
941
- clearTimeout(timer);
942
- }
943
- }
944
- if (!response.ok) {
945
- const text = await response.text();
946
- throw new Error(`HTTP Error ${response.status}: ${text || response.statusText}`);
947
- }
948
- const text = await response.text();
949
- return text ? JSON.parse(text) : {};
950
- }
951
-
952
- function baseSellerRow(
953
- entry: SellerRegistryEntry,
954
- profile?: string,
955
- dataSource: SellerDataSource = "registry",
956
- flyApp?: import("./seller.js").SellerAppJson,
957
- machineSpecs?: import("./seller.js").SellerMachineSpecs
958
- ): SellerRow {
959
- const primaryRegion = entry.region || machineSpecs?.regions?.[0];
960
- return {
961
- id: entry.id,
962
- name: entry.id,
963
- description: entry.name && entry.name !== entry.id ? entry.name : undefined,
964
- app: entry.app,
965
- profile: profile || entry.profile,
966
- url: entry.url,
967
- registryStatus: registryStatus(entry.status),
968
- // Step 13 v1.1: 绿点 base 改 unknown, 真正值由 sellerSnapshot
969
- // 拿 /manifest 200 决定. 老逻辑直接复用 entry.status 是错的.
970
- nodeStatus: "unknown",
971
- region: primaryRegion,
972
- upstreamDomain: hostName(entry.url) || "unknown",
973
- upstreamStatus: "unknown",
974
- modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length,
975
- specs: {
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,
985
- modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length
986
- },
987
- dataSource,
988
- publishStatus: dataSource === "both" ? "published" : dataSource === "fly" ? "unpublished" : "unknown",
989
- detailStatus: "pending",
990
- flyApp: flyApp ? {
991
- name: flyApp.name,
992
- status: flyApp.status,
993
- owner: flyApp.owner,
994
- latestDeployAt: flyApp.latestDeployAt
995
- } : undefined
996
- };
997
- }
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
-
1047
- function mergeSellerRow(
1048
- base: SellerRow,
1049
- entry: SellerRegistryEntry,
1050
- status: any,
1051
- service: any,
1052
- upstreams: any,
1053
- config: any,
1054
- balance: BalanceSnapshot | undefined,
1055
- manifestOk = false
1056
- ): SellerRow {
1057
- const normalizedUpstreams = upstreamDocument(upstreams);
1058
- const capacity = status?.capacity || service?.capacity || {};
1059
- const upstreamUrl = stringValue(config?.upstreamUrl || normalizedUpstreams?.upstreamUrl || service?.upstreamUrl) || entry.url;
1060
- const error = firstError(status, service, upstreams);
1061
- return {
1062
- ...base,
1063
- nodeStatus: error ? (manifestOk ? "active" : "unknown") : (manifestOk ? "active" : nodeStatus(status?.status || entry.status)),
1064
- upstreamDomain: hostName(upstreamUrl) || base.upstreamDomain,
1065
- upstreamStatus: upstreamStatus(status?.upstream?.status || normalizedUpstreams?.status),
1066
- discountRatio: numberValue(config?.discountRatio ?? normalizedUpstreams?.discountRatio),
1067
- capacityUsed: numberValue(capacity.activeConnections),
1068
- capacityLimit: numberValue(capacity.maxConnections),
1069
- ...runtimeUsageFields(status?.runtime, base),
1070
- ttftMs: numberValue(status?.latency?.ttftMs),
1071
- avgInferenceMs: numberValue(status?.latency?.avgInferenceMs),
1072
- lastInferenceMs: numberValue(status?.latency?.lastInferenceMs),
1073
- avgTokensPerSecond: numberValue(status?.latency?.avgTokensPerSecond),
1074
- lastTokensPerSecond: numberValue(status?.latency?.lastTokensPerSecond),
1075
- latencySamples: numberValue(status?.latency?.sampleCount),
1076
- upstreamBalanceUsdMicros: balance && Number.isFinite(balance.amountUsdMicros ?? NaN) ? (balance.amountUsdMicros as number) : undefined,
1077
- upstreamBalanceCurrency: typeof balance?.currency === "string" ? balance.currency : undefined,
1078
- upstreamBalanceSource: balance?.source,
1079
- upstreamBalanceFetchedAt: balance ? new Date(balance.fetchedAt).toISOString() : undefined,
1080
- upstreamBalanceError: balance?.error?.message,
1081
- upstreamRechargeUrl: stringValue(config?.upstreamBalanceProbe?.rechargeUrl || config?.upstreamRechargeUrl || normalizedUpstreams?.upstreamRechargeUrl),
1082
- modelsCount: numberValue(service?.modelsCount ?? normalizedUpstreams?.models?.length ?? base.modelsCount),
1083
- specs: {
1084
- ...base.specs,
1085
- region: entry.region || base.specs?.region,
1086
- modelsCount: numberValue(service?.modelsCount ?? normalizedUpstreams?.models?.length ?? base.modelsCount)
1087
- },
1088
- error
1089
- };
1090
- }
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
-
1129
- function modelRows(upstreams: any, config: any, status: any): SellerModelRow[] {
1130
- const normalizedUpstreams = upstreamDocument(upstreams);
1131
- const aliases = config.modelAliases || normalizedUpstreams.modelAliases || {};
1132
- const models = Array.isArray(config.models)
1133
- ? config.models
1134
- : Array.isArray(normalizedUpstreams.models)
1135
- ? normalizedUpstreams.models
1136
- : [];
1137
- return models.map((model: any) => {
1138
- const id = stringValue(model.id || model.name) || "unknown";
1139
- return {
1140
- upstreamModel: id,
1141
- billingModel: stringValue(aliases[id]) || id,
1142
- enabled: model.enabled !== false,
1143
- configModel: objectValue(model),
1144
- inputPrice: priceString(model.inputPriceMicrosPer1m),
1145
- outputPrice: priceString(model.outputPriceMicrosPer1m),
1146
- ttftMs: numberValue(model.ttftMs ?? status?.latency?.ttftMs),
1147
- avgInferenceMs: numberValue(model.avgInferenceMs ?? status?.latency?.avgInferenceMs),
1148
- avgTokensPerSecond: numberValue(model.avgTokensPerSecond ?? status?.latency?.avgTokensPerSecond),
1149
- latencySamples: numberValue(model.latencySamples ?? status?.latency?.sampleCount)
1150
- };
1151
- });
1152
- }
1153
-
1154
- function upstreamDocument(value: any): any {
1155
- if (Array.isArray(value?.upstreams)) {
1156
- return objectValue(value.upstreams[0]) || {};
1157
- }
1158
- return objectValue(value?.upstreams) || value || {};
1159
- }
1160
-
1161
- function registryStatus(value: unknown): RegistryStatus {
1162
- const normalized = String(value || "unknown").toLowerCase();
1163
- if (normalized === "active" || normalized === "draining" || normalized === "offline" || normalized === "pending") {
1164
- return normalized;
1165
- }
1166
- return "unknown";
1167
- }
1168
-
1169
- function nodeStatus(value: unknown): NodeStatus {
1170
- const normalized = String(value || "unknown").toLowerCase();
1171
- if (normalized === "active" || normalized === "busy_capacity" || normalized === "draining" || normalized === "pending" || normalized === "offline") {
1172
- return normalized;
1173
- }
1174
- if (normalized === "healthy") {
1175
- return "active";
1176
- }
1177
- return "unknown";
1178
- }
1179
-
1180
- function upstreamStatus(value: unknown): UpstreamStatus {
1181
- const normalized = String(value || "unknown").toLowerCase();
1182
- if (normalized === "healthy" || normalized === "degraded" || normalized === "unhealthy") {
1183
- return normalized;
1184
- }
1185
- return "unknown";
1186
- }
1187
-
1188
- export function maskApiKey(value: unknown): string | undefined {
1189
- const normalized = String(value || "").trim();
1190
- if (!normalized) {
1191
- return undefined;
1192
- }
1193
- if (isRedactedConfigSecret(normalized)) {
1194
- return "configured";
1195
- }
1196
- const tail = normalized.replace(/\s+/g, "").slice(-4);
1197
- return tail ? `**** **** **** ${tail}` : "****";
1198
- }
1199
-
1200
- function maskSecret(value: unknown): string | undefined {
1201
- const normalized = String(value || "").trim();
1202
- if (!normalized) {
1203
- return undefined;
1204
- }
1205
- const compact = normalized.replace(/\s+/g, "");
1206
- if (compact.length <= 8) {
1207
- return "********";
1208
- }
1209
- return `${compact.slice(0, 4)}...${compact.slice(-4)}`;
1210
- }
1211
-
1212
- function trimSlash(value: string): string {
1213
- return value.replace(/\/+$/, "");
1214
- }
1215
-
1216
- function hostName(value: string | undefined): string {
1217
- if (!value) {
1218
- return "";
1219
- }
1220
- try {
1221
- return new URL(value).hostname.replace(/^www\./, "");
1222
- } catch {
1223
- return "";
1224
- }
1225
- }
1226
-
1227
- function numberValue(value: unknown): number | undefined {
1228
- const parsed = Number(value);
1229
- return Number.isFinite(parsed) ? parsed : undefined;
1230
- }
1231
-
1232
- function stringValue(value: unknown): string | undefined {
1233
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
1234
- }
1235
-
1236
- function objectValue(value: unknown): Record<string, unknown> | undefined {
1237
- return value && typeof value === "object" && !Array.isArray(value)
1238
- ? value as Record<string, unknown>
1239
- : undefined;
1240
- }
1241
-
1242
- function isRedactedConfigSecret(value: unknown): boolean {
1243
- return value === "[redacted]";
1244
- }
1245
-
1246
- function priceString(value: unknown): string | undefined {
1247
- const parsed = numberValue(value);
1248
- if (parsed === undefined) {
1249
- return undefined;
1250
- }
1251
- return `$${(parsed / 1000000).toFixed(4)}/1M`;
1252
- }
1253
-
1254
- function balanceString(balance: BalanceSnapshot | undefined): string | undefined {
1255
- if (!balance) {
1256
- return undefined;
1257
- }
1258
- if (balance.rawAmount === null) {
1259
- return balance.error?.message;
1260
- }
1261
- const amount = Math.abs(balance.rawAmount) >= 100
1262
- ? balance.rawAmount.toFixed(0)
1263
- : balance.rawAmount.toFixed(2);
1264
- return `${balance.currency || "USD"} ${amount}`;
1265
- }
1266
-
1267
- function firstError(...values: any[]): string | undefined {
1268
- const hit = values.find((value) => value?.error);
1269
- return hit?.error;
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
- }