@tokenbuddy/tb-admin 1.0.35 → 1.0.37

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 +92 -19
  2. package/dist/src/config.d.ts +7 -1
  3. package/dist/src/config.js +16 -4
  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 +8 -2
  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
@@ -1,526 +0,0 @@
1
- /**
2
- * Step 13 v1.1: admin web seller list 双源 (fly + registry) 数据流测试.
3
- *
4
- * 原则: 不 mock flyctl. 测的是 AdminUiState 内部数据流, 用注入的
5
- * fetchJson (fake HTTP server) + flyApps (closure) 替代真 IO.
6
- *
7
- * 覆盖 (跟 docs/processes/seller-fleet-data-sources.md 一致):
8
- * 1. fly + registry both → 行 dataSource="both", 绿点 = /manifest 200
9
- * 2. fly only → 行 dataSource="fly", 灰点 (无 instance url 拉 manifest 失败),
10
- * 行带 "未发布" 提示
11
- * 3. registry only → 行 dataSource="registry", **整行标红 (registryAlert=true)**,
12
- * 行带 "未部署" 提示 + 立即下线按钮 (我们这里只验字段, UI 验由 ui-static 验)
13
- * 4. fly + registry both, 但 /manifest 失败 → 灰/红点, 仍 dataSource="both"
14
- *
15
- * 隔离外部依赖:
16
- * - node:http fake server 模拟 registry (/registry/sellers)
17
- * - node:http fake server 模拟 seller instance (/manifest)
18
- * - flyApps 通过 AdminUiStateOptions.flyApps 注入 closure
19
- *
20
- * 失败模式:
21
- * - 如果 4 个 case 任何一个 dataSource 错 / nodeStatus 错 / alert 漏, 立即 fail
22
- * - 永不调真 flyctl
23
- */
24
-
25
- import { createServer, type Server } from "node:http";
26
- import { AddressInfo } from "node:net";
27
- import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
28
- import { tmpdir } from "node:os";
29
- import { join } from "node:path";
30
- import { ConfigManager } from "../src/config.js";
31
- import { AdminUiState } from "../src/ui-state.js";
32
- import type { SellerAppJson } from "../src/seller.js";
33
-
34
- interface FakeManifestResponse {
35
- status: number;
36
- body?: unknown;
37
- }
38
-
39
- interface FakeServerHandle {
40
- url: string;
41
- setManifest: (sellerId: string, resp: FakeManifestResponse) => void;
42
- setManifestPath: (path: string, resp: FakeManifestResponse) => void;
43
- setOperatorJson: (path: string, resp: unknown) => void;
44
- setRegistry: (resp: { sellers: any[]; version: number }) => void;
45
- setManagedSellers: (sellers: any[]) => void;
46
- requests: () => Array<{ path: string; authorization?: string }>;
47
- close: () => Promise<void>;
48
- }
49
-
50
- function startFakeRegistry(): Promise<FakeServerHandle> {
51
- let registryDoc: { sellers: any[]; version: number } = { sellers: [], version: 0 };
52
- let managedSellers: any[] | undefined;
53
- const manifests = new Map<string, FakeManifestResponse>();
54
- const operatorJson = new Map<string, unknown>();
55
- const requests: Array<{ path: string; authorization?: string }> = [];
56
-
57
- return new Promise((resolve) => {
58
- const server = createServer((req, res) => {
59
- const url = new URL(req.url || "/", "http://localhost");
60
- requests.push({ path: url.pathname, authorization: req.headers.authorization });
61
- if (url.pathname === "/platform/sellers") {
62
- if (req.headers.authorization !== "Bearer fake-vendor-token") {
63
- res.writeHead(401, { "Content-Type": "application/json" });
64
- res.end(JSON.stringify({ error: "vendor_auth_required" }));
65
- return;
66
- }
67
- res.writeHead(200, { "Content-Type": "application/json" });
68
- res.end(JSON.stringify({ sellers: managedSellers ?? registryDoc.sellers }));
69
- return;
70
- }
71
- if (url.pathname === "/registry/sellers") {
72
- res.writeHead(200, { "Content-Type": "application/json" });
73
- res.end(JSON.stringify(registryDoc));
74
- return;
75
- }
76
- if (operatorJson.has(url.pathname)) {
77
- res.writeHead(200, { "Content-Type": "application/json" });
78
- res.end(JSON.stringify(operatorJson.get(url.pathname)));
79
- return;
80
- }
81
- // 1) 按 path 查 (setManifestPath 注册的)
82
- if (manifests.has(url.pathname)) {
83
- const resp = manifests.get(url.pathname)!;
84
- res.writeHead(resp.status, { "Content-Type": "application/json" });
85
- res.end(JSON.stringify(resp.body || {}));
86
- return;
87
- }
88
- // 2) 按 /manifest/{id} 模式查 (setManifest 注册的)
89
- const match = url.pathname.match(/^\/manifest\/([^/]+)$/);
90
- if (match) {
91
- const id = decodeURIComponent(match[1]);
92
- const resp = manifests.get(id) || { status: 404, body: { error: "not found" } };
93
- res.writeHead(resp.status, { "Content-Type": "application/json" });
94
- res.end(JSON.stringify(resp.body || {}));
95
- return;
96
- }
97
- res.writeHead(404);
98
- res.end("not found");
99
- });
100
- server.listen(0, "127.0.0.1", () => {
101
- const port = (server.address() as AddressInfo).port;
102
- const handle: FakeServerHandle = {
103
- url: `http://127.0.0.1:${port}`,
104
- setManifest: (id, resp) => {
105
- manifests.set(id, resp);
106
- },
107
- setManifestPath: (path, resp) => {
108
- manifests.set(path, resp);
109
- },
110
- setOperatorJson: (path, resp) => {
111
- operatorJson.set(path, resp);
112
- },
113
- setRegistry: (doc) => {
114
- registryDoc = doc;
115
- },
116
- setManagedSellers: (sellers) => {
117
- managedSellers = sellers;
118
- },
119
- requests: () => requests.slice(),
120
- close: () => new Promise<void>((r) => server.close(() => r()))
121
- };
122
- resolve(handle);
123
- });
124
- });
125
- }
126
-
127
- let tmpDir: string;
128
- let tmpConfigPath: string;
129
-
130
- function makeState(
131
- registryUrl: string,
132
- flyApps: Omit<SellerAppJson, "raw">[],
133
- token = "fake-vendor-token"
134
- ): AdminUiState {
135
- if (!tmpDir) {
136
- tmpDir = mkdtempSync(join(tmpdir(), "ui-fleet-test-"));
137
- tmpConfigPath = join(tmpDir, "admin.toml");
138
- }
139
- writeFileSync(
140
- tmpConfigPath,
141
- `[profiles.default]
142
- url = "${registryUrl}"
143
- token = "${token}"
144
- `,
145
- "utf8"
146
- );
147
- const configManager = new ConfigManager(tmpConfigPath);
148
- return new AdminUiState({
149
- configManager,
150
- profile: "default",
151
- url: registryUrl,
152
- token,
153
- flyApps: async () => flyApps.map((a) => ({ ...a, raw: {} })),
154
- fetchJson: (async (target: string, init?: RequestInit) => {
155
- const res = await fetch(target, init);
156
- if (!res.ok) {
157
- throw new Error(`HTTP Error ${res.status}: ${await res.text()}`);
158
- }
159
- return await res.json();
160
- }) as any
161
- });
162
- }
163
-
164
- describe("AdminUiState 双源 seller list (v1.1 spec)", () => {
165
- let fake: FakeServerHandle;
166
-
167
- beforeEach(async () => {
168
- fake = await startFakeRegistry();
169
- });
170
-
171
- afterEach(async () => {
172
- await fake.close();
173
- });
174
-
175
- afterAll(() => {
176
- if (tmpDir) {
177
- rmSync(tmpDir, { recursive: true, force: true });
178
- }
179
- });
180
-
181
- test("case 1: fly + registry both → dataSource=both, 绿点 = /manifest 200", async () => {
182
- fake.setRegistry({
183
- version: 7,
184
- sellers: [
185
- {
186
- id: "alpha",
187
- name: "Alpha Seller",
188
- app: "tb-seller-alpha",
189
- // entry.url 是 instance 自己的 url; probeManifest 会拼 + "/manifest"
190
- url: `${fake.url}/alpha-base`,
191
- status: "active",
192
- supportedProtocols: ["chat_completions"],
193
- paymentMethods: ["clawtip"],
194
- models: ["gpt-5.4"]
195
- }
196
- ]
197
- });
198
- // entry.url = ".../alpha-base", probe target = ".../alpha-base/manifest"
199
- fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
200
- const state = makeState(fake.url, [
201
- { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
202
- ]);
203
-
204
- const rows = await state.sellers();
205
- expect(rows).toHaveLength(1);
206
- expect(rows[0].dataSource).toBe("both");
207
- expect(rows[0].registryAlert).toBeFalsy();
208
- // 绿点: /manifest 200 → nodeStatus=active (manifest ok 映射 active)
209
- expect(rows[0].nodeStatus).toBe("active");
210
- expect(fake.requests()).toContainEqual({
211
- path: "/platform/sellers",
212
- authorization: "Bearer fake-vendor-token"
213
- });
214
- });
215
-
216
- test("sellerRegistryRows returns registry publish-checking rows before Fly inventory", async () => {
217
- fake.setRegistry({
218
- version: 7,
219
- sellers: [
220
- {
221
- id: "alpha",
222
- name: "Alpha Seller",
223
- app: "tb-seller-alpha",
224
- url: `${fake.url}/alpha-base`,
225
- status: "active",
226
- supportedProtocols: ["chat_completions"],
227
- paymentMethods: ["clawtip"],
228
- models: ["gpt-5.4"]
229
- }
230
- ]
231
- });
232
- const state = makeState(fake.url, [
233
- { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
234
- ]);
235
-
236
- const rows = await state.sellerRegistryRows();
237
- expect(rows).toHaveLength(1);
238
- expect(rows[0]).toMatchObject({
239
- id: "alpha",
240
- publishStatus: "checking",
241
- detailStatus: "pending",
242
- nodeStatus: "unknown"
243
- });
244
- expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
245
- });
246
-
247
- test("sellerInventory merges registry + Fly without probing seller details", async () => {
248
- fake.setRegistry({
249
- version: 7,
250
- sellers: [
251
- {
252
- id: "alpha",
253
- name: "Alpha Seller",
254
- app: "tb-seller-alpha",
255
- url: `${fake.url}/alpha-base`,
256
- status: "active",
257
- supportedProtocols: ["chat_completions"],
258
- paymentMethods: ["clawtip"],
259
- models: ["gpt-5.4"]
260
- }
261
- ]
262
- });
263
- fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
264
- const state = makeState(fake.url, [
265
- { name: "tb-seller-alpha", status: "running", owner: "vendor-a" },
266
- { name: "tb-seller-flyonly", status: "running", owner: "vendor-a" }
267
- ]);
268
-
269
- const rows = await state.sellerInventory();
270
- expect(rows).toHaveLength(2);
271
- expect(rows.find((row) => row.id === "alpha")).toMatchObject({
272
- dataSource: "both",
273
- publishStatus: "published",
274
- detailStatus: "pending",
275
- nodeStatus: "unknown"
276
- });
277
- expect(rows.find((row) => row.id === "tb-seller-flyonly")).toMatchObject({
278
- dataSource: "fly",
279
- publishStatus: "unpublished",
280
- detailStatus: "pending"
281
- });
282
- expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
283
- });
284
-
285
- test("sellerInventory filters non-seller Fly apps", async () => {
286
- fake.setRegistry({ version: 7, sellers: [] });
287
- const state = makeState(fake.url, [
288
- { name: "tb-registry", status: "running", owner: "vendor-a" },
289
- { name: "tb-seller", status: "running", owner: "vendor-a" },
290
- { name: "tbs-valid", status: "running", owner: "vendor-a" }
291
- ]);
292
-
293
- const rows = await state.sellerInventory();
294
- expect(rows.map((row) => row.app)).toEqual(["tbs-valid"]);
295
- });
296
-
297
- test("refreshSellerRows hydrates upstream domain from seller config", async () => {
298
- fake.setManifestPath("/manifest", { status: 200, body: { ok: true } });
299
- fake.setOperatorJson("/operator/status", {
300
- status: "healthy",
301
- upstream: { status: "healthy" },
302
- capacity: { activeConnections: 2, maxConnections: 8 },
303
- runtime: { cpuPercent: 12.5, memoryPercent: 48, memoryRssMb: 246, memoryLimitMb: 512 }
304
- });
305
- fake.setOperatorJson("/operator/admin/upstreams", {
306
- upstreams: [{
307
- upstreamUrl: "https://openrouter.ai/api/v1",
308
- discountRatio: 1,
309
- models: [{ id: "openai/gpt-5.4" }]
310
- }]
311
- });
312
- fake.setOperatorJson("/operator/admin/config", {
313
- config: {
314
- upstreamUrl: "https://openrouter.ai/api/v1",
315
- discountRatio: 1
316
- }
317
- });
318
- const state = makeState(fake.url, []);
319
-
320
- const rows = await state.refreshSellerRows([{
321
- id: "tbs-openrouter",
322
- name: "tbs-openrouter",
323
- app: "tbs-openrouter",
324
- url: fake.url,
325
- registryStatus: "active",
326
- nodeStatus: "unknown",
327
- upstreamDomain: "tbs-openrouter.fly.dev",
328
- upstreamStatus: "unknown",
329
- dataSource: "both",
330
- publishStatus: "published",
331
- detailStatus: "queued"
332
- }]);
333
-
334
- expect(rows[0]).toMatchObject({
335
- upstreamDomain: "openrouter.ai",
336
- upstreamStatus: "healthy",
337
- capacityUsed: 2,
338
- capacityLimit: 8,
339
- resourceCpuPercent: 12.5,
340
- resourceMemoryPercent: 48,
341
- modelsCount: 1,
342
- detailStatus: "fresh"
343
- });
344
- });
345
-
346
- test("case 1b: empty vendor-managed list keeps public registry publish state", async () => {
347
- fake.setRegistry({
348
- version: 9,
349
- sellers: [
350
- {
351
- id: "public-alpha",
352
- name: "Public Alpha",
353
- app: "tb-seller-alpha",
354
- url: `${fake.url}/alpha-base`,
355
- status: "active",
356
- supportedProtocols: ["chat_completions"],
357
- paymentMethods: ["clawtip"],
358
- models: ["gpt-5.4"]
359
- }
360
- ]
361
- });
362
- fake.setManagedSellers([]);
363
- fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
364
- const state = makeState(fake.url, [
365
- { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
366
- ]);
367
-
368
- const rows = await state.sellers();
369
- expect(rows).toHaveLength(1);
370
- expect(rows[0]).toMatchObject({
371
- id: "public-alpha",
372
- dataSource: "both",
373
- registryStatus: "active",
374
- nodeStatus: "active"
375
- });
376
- expect(rows[0].publishHint).toBeUndefined();
377
- });
378
-
379
- test("case 1c: vendor-managed auth failure falls back to public registry", async () => {
380
- fake.setRegistry({
381
- version: 9,
382
- sellers: [
383
- {
384
- id: "public-alpha",
385
- name: "Public Alpha",
386
- app: "tb-seller-alpha",
387
- url: `${fake.url}/alpha-base`,
388
- status: "active",
389
- supportedProtocols: ["chat_completions"],
390
- paymentMethods: ["clawtip"],
391
- models: ["gpt-5.4"]
392
- }
393
- ]
394
- });
395
- fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
396
- const state = makeState(fake.url, [
397
- { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
398
- ], "wrong-vendor-token");
399
-
400
- const rows = await state.sellers();
401
- expect(rows).toHaveLength(1);
402
- expect(rows[0]).toMatchObject({
403
- id: "public-alpha",
404
- dataSource: "both",
405
- registryStatus: "active",
406
- nodeStatus: "active"
407
- });
408
- expect(fake.requests()).toContainEqual({
409
- path: "/platform/sellers",
410
- authorization: "Bearer wrong-vendor-token"
411
- });
412
- expect(fake.requests()).toContainEqual({
413
- path: "/registry/sellers",
414
- authorization: undefined
415
- });
416
- });
417
-
418
- test("case 1d: registry entry without app matches Fly app by id", async () => {
419
- fake.setRegistry({
420
- version: 9,
421
- sellers: [
422
- {
423
- id: "tb-seller-alpha",
424
- name: "Alpha Seller",
425
- url: `${fake.url}/alpha-base`,
426
- status: "active",
427
- supportedProtocols: ["chat_completions"],
428
- paymentMethods: ["clawtip"],
429
- models: ["gpt-5.4"]
430
- }
431
- ]
432
- });
433
- fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
434
- const state = makeState(fake.url, [
435
- { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
436
- ]);
437
-
438
- const rows = await state.sellers();
439
- expect(rows).toHaveLength(1);
440
- expect(rows[0]).toMatchObject({
441
- id: "tb-seller-alpha",
442
- dataSource: "both",
443
- registryStatus: "active",
444
- nodeStatus: "active"
445
- });
446
- });
447
-
448
- test("case 2: fly only → dataSource=fly, 灰点, registryAlert=undefined", async () => {
449
- fake.setRegistry({ version: 7, sellers: [] });
450
- const state = makeState(fake.url, [
451
- { name: "tb-seller-flyonly", status: "running", owner: "vendor-a" }
452
- ]);
453
-
454
- const rows = await state.sellers();
455
- expect(rows).toHaveLength(1);
456
- expect(rows[0].dataSource).toBe("fly");
457
- expect(rows[0].nodeStatus).toBe("unknown");
458
- expect(rows[0].registryAlert).toBeFalsy();
459
- expect(rows[0].publishHint).toContain("未发布");
460
- });
461
-
462
- test("case 3: registry only → dataSource=registry, 整行标红, 立即下线提示", async () => {
463
- fake.setRegistry({
464
- version: 7,
465
- sellers: [
466
- {
467
- id: "ghost",
468
- name: "Ghost Seller",
469
- app: "tb-seller-ghost",
470
- url: "https://tbs-ghost.fly.dev",
471
- status: "active",
472
- supportedProtocols: ["chat_completions"],
473
- paymentMethods: ["clawtip"],
474
- models: ["gpt-5.4"]
475
- }
476
- ]
477
- });
478
- const state = makeState(fake.url, [
479
- // 注意: fly list 完全没有 tb-seller-ghost
480
- { name: "tb-seller-other", status: "running", owner: "vendor-a" }
481
- ]);
482
-
483
- const rows = await state.sellers();
484
- expect(rows).toHaveLength(2); // ghost + other
485
- const ghost = rows.find((r) => r.id === "ghost");
486
- expect(ghost).toBeDefined();
487
- expect(ghost!.dataSource).toBe("registry");
488
- expect(ghost!.registryAlert).toBe(true);
489
- expect(ghost!.alertReason).toContain("registry 收录了但 fly app 失踪");
490
- expect(ghost!.removeHint).toContain("立即下线 (registry-only)");
491
-
492
- const other = rows.find((r) => r.app === "tb-seller-other");
493
- expect(other).toBeDefined();
494
- expect(other!.dataSource).toBe("fly");
495
- expect(other!.registryAlert).toBeFalsy();
496
- });
497
-
498
- test("case 4: both, /manifest 失败 → 灰/红点, dataSource 仍 = both", async () => {
499
- fake.setRegistry({
500
- version: 7,
501
- sellers: [
502
- {
503
- id: "down",
504
- name: "Down Seller",
505
- app: "tb-seller-down",
506
- url: `${fake.url}/down-base`,
507
- status: "active",
508
- supportedProtocols: ["chat_completions"],
509
- paymentMethods: ["clawtip"],
510
- models: ["gpt-5.4"]
511
- }
512
- ]
513
- });
514
- fake.setManifestPath("/down-base/manifest", { status: 502, body: { error: "upstream down" } });
515
- const state = makeState(fake.url, [
516
- { name: "tb-seller-down", status: "running", owner: "vendor-a" }
517
- ]);
518
-
519
- const rows = await state.sellers();
520
- expect(rows).toHaveLength(1);
521
- expect(rows[0].dataSource).toBe("both");
522
- expect(rows[0].registryAlert).toBeFalsy();
523
- // /manifest 失败 → nodeStatus=unknown (不是 active)
524
- expect(rows[0].nodeStatus).toBe("unknown");
525
- });
526
- });