@tokenbuddy/tb-admin 1.0.14 → 1.0.27

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 (55) hide show
  1. package/dist/src/bootstrap-registry.d.ts +1 -0
  2. package/dist/src/bootstrap-registry.d.ts.map +1 -1
  3. package/dist/src/bootstrap-registry.js.map +1 -1
  4. package/dist/src/cli.d.ts.map +1 -1
  5. package/dist/src/cli.js +294 -13
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/client.d.ts +12 -3
  8. package/dist/src/client.d.ts.map +1 -1
  9. package/dist/src/client.js +12 -8
  10. package/dist/src/client.js.map +1 -1
  11. package/dist/src/display-format.d.ts +39 -0
  12. package/dist/src/display-format.d.ts.map +1 -0
  13. package/dist/src/display-format.js +354 -0
  14. package/dist/src/display-format.js.map +1 -0
  15. package/dist/src/server-cmd.d.ts +25 -1
  16. package/dist/src/server-cmd.d.ts.map +1 -1
  17. package/dist/src/server-cmd.js +116 -16
  18. package/dist/src/server-cmd.js.map +1 -1
  19. package/dist/src/ui-actions.d.ts +90 -0
  20. package/dist/src/ui-actions.d.ts.map +1 -0
  21. package/dist/src/ui-actions.js +823 -0
  22. package/dist/src/ui-actions.js.map +1 -0
  23. package/dist/src/ui-command.d.ts +4 -0
  24. package/dist/src/ui-command.d.ts.map +1 -0
  25. package/dist/src/ui-command.js +37 -0
  26. package/dist/src/ui-command.js.map +1 -0
  27. package/dist/src/ui-server.d.ts +22 -0
  28. package/dist/src/ui-server.d.ts.map +1 -0
  29. package/dist/src/ui-server.js +261 -0
  30. package/dist/src/ui-server.js.map +1 -0
  31. package/dist/src/ui-state.d.ts +140 -0
  32. package/dist/src/ui-state.d.ts.map +1 -0
  33. package/dist/src/ui-state.js +438 -0
  34. package/dist/src/ui-state.js.map +1 -0
  35. package/dist/src/ui-static.d.ts +2 -0
  36. package/dist/src/ui-static.d.ts.map +1 -0
  37. package/dist/src/ui-static.js +469 -0
  38. package/dist/src/ui-static.js.map +1 -0
  39. package/dist/src/upstream-balance-probe.d.ts +41 -0
  40. package/dist/src/upstream-balance-probe.d.ts.map +1 -0
  41. package/dist/src/upstream-balance-probe.js +379 -0
  42. package/dist/src/upstream-balance-probe.js.map +1 -0
  43. package/package.json +1 -1
  44. package/src/bootstrap-registry.ts +1 -0
  45. package/src/cli.ts +335 -13
  46. package/src/client.ts +13 -8
  47. package/src/display-format.ts +398 -0
  48. package/src/server-cmd.ts +145 -20
  49. package/src/ui-actions.ts +958 -0
  50. package/src/ui-command.ts +39 -0
  51. package/src/ui-server.ts +322 -0
  52. package/src/ui-state.ts +614 -0
  53. package/src/ui-static.ts +472 -0
  54. package/src/upstream-balance-probe.ts +505 -0
  55. package/tests/admin.test.ts +1404 -2
@@ -1,10 +1,38 @@
1
1
  import { ConfigManager } from "../src/config.js";
2
2
  import { buildAdminCli } from "../src/cli.js";
3
- import { FlyProvider, parseFlyMachineIds } from "../src/server-cmd.js";
3
+ import { FlyProvider, parseFlyMachineIds, requirePublishedDockerImage } from "../src/server-cmd.js";
4
+ import { startAdminUiServer } from "../src/ui-server.js";
5
+ import { AdminUiState } from "../src/ui-state.js";
6
+ import { UiActions, type CreateSellerRequest, type UiActionResult } from "../src/ui-actions.js";
7
+ import { adminUiHtml } from "../src/ui-static.js";
8
+ import {
9
+ formatBalanceAmount,
10
+ formatCount,
11
+ formatDiscountRatio,
12
+ formatDuration,
13
+ formatMoney,
14
+ formatPercent,
15
+ formatPricePair,
16
+ formatSellerCapacity,
17
+ formatSellerStatus,
18
+ formatSellerId,
19
+ formatSpeed,
20
+ formatTimeCompact,
21
+ formatTimeFull,
22
+ formatTimeLedger,
23
+ sellerStatusTone,
24
+ statusTone,
25
+ UNKNOWN_VALUE
26
+ } from "../src/display-format.js";
27
+ import {
28
+ BalanceProbeCache,
29
+ probeUpstreamBalance
30
+ } from "../src/upstream-balance-probe.js";
4
31
  import {
5
32
  validateRegistryDocument
6
33
  } from "../src/bootstrap-registry.js";
7
34
  import * as fs from "fs";
35
+ import * as http from "http";
8
36
  import * as path from "path";
9
37
 
10
38
  const TEMP_CONF_PATH = path.resolve(__dirname, "../../data-test/admin-config.json");
@@ -68,17 +96,42 @@ describe("Admin CLI Config Profile Management Tests", () => {
68
96
  const payments = program.commands.find((command) => command.name() === "payments");
69
97
  const upstreams = program.commands.find((command) => command.name() === "upstreams");
70
98
  const models = program.commands.find((command) => command.name() === "models");
99
+ const ui = program.commands.find((command) => command.name() === "ui");
71
100
 
72
101
  expect(bootstrap).toBeDefined();
73
102
  expect(sellers).toBeDefined();
74
- expect(sellers?.commands.map((command) => command.name()).sort()).toEqual(["get", "put", "validate"]);
103
+ expect(sellers?.commands.map((command) => command.name()).sort()).toEqual([
104
+ "add",
105
+ "get",
106
+ "list",
107
+ "put",
108
+ "remove",
109
+ "status",
110
+ "update",
111
+ "validate"
112
+ ]);
75
113
  expect(sellers?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
114
+ expect(sellers?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--force")).toBe(true);
115
+ expect(sellers?.commands.find((command) => command.name() === "add")?.options.some((option) => option.long === "--expect-version")).toBe(true);
116
+ expect(sellers?.commands.find((command) => command.name() === "update")?.options.some((option) => option.long === "--expect-version")).toBe(true);
117
+ expect(sellers?.commands.find((command) => command.name() === "status")?.options.some((option) => option.long === "--expect-version")).toBe(true);
118
+ expect(sellers?.commands.find((command) => command.name() === "remove")?.options.some((option) => option.long === "--expect-version")).toBe(true);
76
119
  expect(sellers?.commands.find((command) => command.name() === "validate")?.options.some((option) => option.long === "--file")).toBe(true);
120
+ const defaultSeller = bootstrap?.commands.find((command) => command.name() === "default-seller");
121
+ const registry = bootstrap?.commands.find((command) => command.name() === "registry");
122
+ expect(defaultSeller?.commands.map((command) => command.name()).sort()).toEqual(["set"]);
123
+ expect(defaultSeller?.commands.find((command) => command.name() === "set")?.options.some((option) => option.long === "--expect-version")).toBe(true);
124
+ expect(registry?.commands.map((command) => command.name()).sort()).toEqual(["diff", "import", "publish", "versions"]);
125
+ expect(registry?.commands.find((command) => command.name() === "import")?.options.some((option) => option.long === "--dry-run")).toBe(true);
126
+ expect(registry?.commands.find((command) => command.name() === "import")?.options.some((option) => option.long === "--force")).toBe(true);
77
127
  expect(sellerConfig).toBeDefined();
78
128
  expect(sellerConfig?.commands.map((command) => command.name()).sort()).toEqual(["get", "put", "validate"]);
79
129
  expect(sellerConfig?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
80
130
  expect(sellerConfig?.commands.find((command) => command.name() === "validate")?.options.some((option) => option.long === "--file")).toBe(true);
81
131
  expect(upstreams).toBeDefined();
132
+ expect(ui).toBeDefined();
133
+ expect(ui?.options.find((option) => option.long === "--host")?.defaultValue).toBe("127.0.0.1");
134
+ expect(ui?.options.find((option) => option.long === "--port")?.defaultValue).toBe(17822);
82
135
  expect(models).toBeDefined();
83
136
  expect(models?.options.some((option) => option.long === "--json")).toBe(true);
84
137
  expect(upstreams?.commands.map((command) => command.name()).sort()).toEqual(["get", "refresh", "update"]);
@@ -103,6 +156,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
103
156
  expect(deploy).toBeDefined();
104
157
  expect(create?.options.some((option) => option.long === "--fly-config" && option.required)).toBe(true);
105
158
  expect(create?.options.some((option) => option.long === "--image" && option.required)).toBe(true);
159
+ expect(create?.options.some((option) => option.long === "--initial-config")).toBe(true);
106
160
  expect(create?.options.some((option) => option.long === "--config")).toBe(false);
107
161
  expect(deploy?.options.some((option) => option.long === "--fly-config")).toBe(false);
108
162
  expect(deploy?.options.some((option) => option.long === "--image" && option.required)).toBe(true);
@@ -129,6 +183,59 @@ describe("Admin CLI Config Profile Management Tests", () => {
129
183
  })).toContain("Volumes: unchanged");
130
184
  });
131
185
 
186
+ test("seller create checks the published image before creating Fly resources", () => {
187
+ expect(() => requirePublishedDockerImage("registry.fly.io/tb-seller:missing", () => ({
188
+ ok: false,
189
+ error: "not found"
190
+ }))).toThrow("No Fly app was created");
191
+
192
+ const commands: string[] = [];
193
+ const provider = new FlyProvider(undefined, {
194
+ checkFlyctlInstalled: () => true,
195
+ imageInspector: () => ({ ok: false, error: "not found" }),
196
+ execSync: (command) => {
197
+ commands.push(command);
198
+ return "";
199
+ }
200
+ });
201
+
202
+ expect(() => provider.createSeller({
203
+ name: "tbs-test",
204
+ app: "tbs-test",
205
+ image: "registry.fly.io/tb-seller:missing",
206
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
207
+ operatorSecret: "operator-secret"
208
+ })).toThrow("seller image is not published or is not accessible");
209
+
210
+ expect(commands).toEqual([]);
211
+ });
212
+
213
+ test("FlyProvider passes configured Fly token to flyctl commands", () => {
214
+ const envs: Array<string | undefined> = [];
215
+ const provider = new FlyProvider({ token: "fly-token-value" }, {
216
+ checkFlyctlInstalled: () => true,
217
+ imageInspector: () => ({ ok: true }),
218
+ execSync: (_command, options) => {
219
+ envs.push(options?.env?.FLY_API_TOKEN);
220
+ return "";
221
+ },
222
+ spawnSync: (_command, _args, options) => {
223
+ envs.push(options?.env?.FLY_API_TOKEN);
224
+ return { status: 0, signal: null, stdout: "", stderr: "", pid: 1, output: [] };
225
+ }
226
+ });
227
+
228
+ provider.createSeller({
229
+ name: "tbs-test",
230
+ app: "tbs-test",
231
+ image: "registry.fly.io/tb-seller:latest",
232
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
233
+ operatorSecret: "operator-secret"
234
+ });
235
+
236
+ expect(envs).toEqual(["fly-token-value", "fly-token-value", "fly-token-value"]);
237
+ });
238
+
132
239
  test("parseFlyMachineIds reads machine ids and rejects unusable machine lists", () => {
133
240
  expect(parseFlyMachineIds(JSON.stringify([
134
241
  { id: "machine-1" },
@@ -158,4 +265,1299 @@ describe("Admin CLI Config Profile Management Tests", () => {
158
265
  badDefault.defaultSeller = "missing";
159
266
  expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
160
267
  });
268
+
269
+ test("tb-admin ui server binds loopback, rejects cross-origin APIs, and serves bootstrap data", async () => {
270
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
271
+ const started = await startAdminUiServer({
272
+ host: "127.0.0.1",
273
+ port: 0,
274
+ openBrowser: false,
275
+ configManager: mgr,
276
+ url: "https://bootstrap.example.test",
277
+ fetchJson: async () => ({
278
+ version: 7,
279
+ sellers: [{
280
+ id: "tbs-sin-06",
281
+ name: "tbs-sin-06",
282
+ url: "https://seller.example.test",
283
+ status: "active",
284
+ region: "sin",
285
+ supportedProtocols: ["responses"],
286
+ paymentMethods: ["clawtip"]
287
+ }]
288
+ })
289
+ });
290
+ try {
291
+ expect(started.url).not.toContain("session=");
292
+ const blocked = await fetch(`${started.url}api/bootstrap`, {
293
+ headers: { Origin: "http://malicious.example" }
294
+ });
295
+ expect(blocked.status).toBe(403);
296
+ await expect(blocked.json()).resolves.toMatchObject({ error: "invalid UI origin" });
297
+
298
+ const response = await fetch(`${started.url}api/bootstrap`);
299
+ await expect(response.json()).resolves.toMatchObject({
300
+ status: "available",
301
+ registryVersion: 7,
302
+ sellerEntries: 1,
303
+ regions: ["sin"]
304
+ });
305
+ } finally {
306
+ await new Promise<void>((resolve) => started.server.close(() => resolve()));
307
+ }
308
+
309
+ await expect(startAdminUiServer({
310
+ host: "0.0.0.0",
311
+ port: 0,
312
+ openBrowser: false,
313
+ configManager: mgr
314
+ })).rejects.toThrow("loopback");
315
+ });
316
+
317
+ test("tb-admin ui create seller returns a progress job", async () => {
318
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
319
+ mgr.setSellerProvider("fly", { operator_secret: "operator-token-value" });
320
+ const calls: string[][] = [];
321
+ const started = await startAdminUiServer({
322
+ host: "127.0.0.1",
323
+ port: 0,
324
+ openBrowser: false,
325
+ configManager: mgr,
326
+ url: "https://bootstrap.example.test",
327
+ fetchJson: async (url) => createSellerFetchJson(url),
328
+ commandRunner: async (args): Promise<UiActionResult> => {
329
+ if (isBootstrapSellersAdd(args)) {
330
+ const filePath = args[args.indexOf("--file") + 1];
331
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8")) as { id: string; status?: string };
332
+ expect(entry).toMatchObject({ id: "tbs-nrt-07", status: "active" });
333
+ }
334
+ calls.push(args);
335
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
336
+ }
337
+ });
338
+ try {
339
+ const response = await fetch(`${started.url}api/sellers`, {
340
+ method: "POST",
341
+ headers: { "Content-Type": "application/json" },
342
+ body: JSON.stringify({
343
+ sellerName: "tbs-nrt-07",
344
+ app: "tbs-nrt-07",
345
+ region: "nrt",
346
+ image: "registry.fly.io/tb-seller:latest",
347
+ upstreamWebsite: "https://openrouter.ai",
348
+ upstreamUrl: "https://openrouter.ai/api/v1",
349
+ upstreamApiKey: "fixture-upstream-key",
350
+ upstreamBalanceProbeTemplate: "openrouter",
351
+ upstreamBalanceProbeUrl: "https://openrouter.ai/api/v1/credits",
352
+ upstreamBalanceProbeRechargeUrl: "https://openrouter.ai/settings/credits",
353
+ maxConnections: 8,
354
+ maxQueueDepth: 4,
355
+ markupRatio: 1.2,
356
+ discountRatio: 1,
357
+ paymentMethods: ["clawtip", "mock"],
358
+ clawtipPayTo: "pay-to-seller",
359
+ clawtipSm4KeyBase64: "0123456789abcdef012345==",
360
+ clawtipSkillSlug: "tokenbuddy-seller",
361
+ clawtipSkillId: "si-tokenbuddy-seller",
362
+ clawtipDescription: "TokenBuddy Seller",
363
+ clawtipResourceUrl: "https://tbs-nrt-07.fly.dev",
364
+ clawtipActivationFeeFen: 1,
365
+ clawtipMicrosPerFen: 10000,
366
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
367
+ })
368
+ });
369
+ expect(response.status).toBe(202);
370
+ const created = await response.json() as { jobId: string };
371
+ expect(created.jobId).toBeTruthy();
372
+
373
+ let job: any;
374
+ for (let attempt = 0; attempt < 20; attempt += 1) {
375
+ const poll = await fetch(`${started.url}api/jobs/${created.jobId}`);
376
+ job = await poll.json();
377
+ if (job.status !== "running") {
378
+ break;
379
+ }
380
+ await new Promise((resolve) => setTimeout(resolve, 10));
381
+ }
382
+
383
+ expect(job.status).toBe("succeeded");
384
+ expect(job.events.map((event: any) => event.stepId)).toEqual([
385
+ "check_registry",
386
+ "validate_config",
387
+ "create_deployment",
388
+ "wait_seller",
389
+ "apply_config",
390
+ "refresh_models",
391
+ "publish_registry"
392
+ ]);
393
+ expect(job.events.find((event: any) => event.stepId === "validate_config").message).toContain("https://openrouter.ai/api");
394
+ expect(JSON.stringify(job)).not.toContain("fixture-upstream-key");
395
+ expect(JSON.stringify(job)).not.toContain("0123456789abcdef012345==");
396
+ expect(JSON.stringify(job)).not.toContain("operator-token-value");
397
+ const commandLines = calls.map((args) => args.join(" "));
398
+ expect(commandLines).toEqual([
399
+ expect.stringContaining("seller-config validate --file"),
400
+ expect.stringContaining("seller create tbs-nrt-07"),
401
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value status"),
402
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value seller-config put --file"),
403
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value upstreams refresh --auto-models"),
404
+ expect.stringContaining("bootstrap sellers add --file"),
405
+ ]);
406
+ expect(commandLines[5]).toContain("--expect-version 7");
407
+ expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
408
+ } finally {
409
+ await new Promise<void>((resolve) => started.server.close(() => resolve()));
410
+ }
411
+ });
412
+
413
+ test("AdminUiState reads seller list from bootstrap registry and masks seller detail API keys", async () => {
414
+ const fixture = await startFixtureAdminServer();
415
+ try {
416
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
417
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
418
+ mgr.setProfile("seller-sin", { url: fixture.baseUrl, token: "seller-token" });
419
+
420
+ const state = new AdminUiState({
421
+ configManager: mgr,
422
+ profile: "bootstrap",
423
+ balanceFetch: async (url, init) => {
424
+ expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
425
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-live-key-27f9");
426
+ return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
427
+ }
428
+ });
429
+ const sellers = await state.sellers();
430
+ expect(sellers).toHaveLength(1);
431
+ expect(sellers[0]).toMatchObject({
432
+ id: "tbs-sin-06",
433
+ upstreamDomain: "openrouter.ai",
434
+ capacityUsed: 3,
435
+ capacityLimit: 8,
436
+ ttftMs: 321,
437
+ avgInferenceMs: 640,
438
+ lastInferenceMs: 700,
439
+ latencySamples: 2,
440
+ upstreamStatus: "healthy",
441
+ upstreamBalanceUsdMicros: 47_950_000,
442
+ upstreamBalanceCurrency: "USD",
443
+ upstreamBalanceSource: "openrouter"
444
+ });
445
+
446
+ const detail = await state.sellerDetail("tbs-sin-06");
447
+ expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** 27f9");
448
+ expect(detail.configuration.upstreamBalance).toBe("USD 47.95");
449
+ expect(detail.configuration.upstreamBalanceSource).toBe("openrouter");
450
+ expect(detail.configuration.upstreamBalanceProbeTemplate).toBe("openrouter");
451
+ expect(detail.configuration.upstreamBalanceProbeUrl).toBe("https://openrouter.ai/api/v1/credits");
452
+ expect(detail.configuration.upstreamBalanceProbeRechargeUrl).toBe("https://openrouter.ai/settings/credits");
453
+ expect(JSON.stringify(detail)).not.toContain("fixture-live-key");
454
+ expect(detail.models[0]).toMatchObject({
455
+ upstreamModel: "openai/gpt-5.4",
456
+ billingModel: "gpt-5.4"
457
+ });
458
+ } finally {
459
+ await fixture.close();
460
+ }
461
+ });
462
+
463
+ test("AdminUiState uses Fly provider operator secret for registry sellers without local profiles", async () => {
464
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
465
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
466
+ const state = new AdminUiState({
467
+ configManager: mgr,
468
+ url: "https://bootstrap.example.test",
469
+ fetchJson: async (url, init) => {
470
+ const pathName = new URL(url).pathname;
471
+ if (pathName === "/registry/sellers") {
472
+ return {
473
+ version: 9,
474
+ sellers: [{
475
+ id: "tbs-openrouter-ai-qecae",
476
+ name: "tbs-openrouter-ai-qecae",
477
+ app: "tbs-openrouter-ai-qecae",
478
+ url: "https://tbs-openrouter-ai-qecae.fly.dev",
479
+ status: "active",
480
+ region: "sin",
481
+ modelsCount: 2,
482
+ supportedProtocols: ["chat_completions"],
483
+ paymentMethods: ["clawtip"]
484
+ }]
485
+ };
486
+ }
487
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer operator-secret");
488
+ if (pathName === "/operator/status") {
489
+ return { status: "healthy", upstream: { status: "healthy" }, capacity: { activeConnections: 1, maxConnections: 8 } };
490
+ }
491
+ if (pathName === "/operator/admin/service") {
492
+ return { sellerId: "node-seller-core", modelsCount: 2, capacity: { maxConnections: 8, maxQueueDepth: 4 } };
493
+ }
494
+ if (pathName === "/operator/admin/upstreams") {
495
+ return {
496
+ upstreams: [{
497
+ upstreamUrl: "https://openrouter.ai/api",
498
+ upstreamApiKey: "****abcd",
499
+ models: [{ id: "openai/gpt-5.4" }, { id: "anthropic/claude-opus-4.7" }]
500
+ }]
501
+ };
502
+ }
503
+ if (pathName === "/operator/admin/config") {
504
+ return {
505
+ config: {
506
+ upstreamUrl: "https://openrouter.ai/api",
507
+ upstreamApiKey: "live-secret-abcd",
508
+ upstreamBalanceProbe: { template: "none" },
509
+ markupRatio: 1.2,
510
+ discountRatio: 1,
511
+ maxConnections: 8,
512
+ maxQueueDepth: 4
513
+ }
514
+ };
515
+ }
516
+ throw new Error(`unexpected fetch url ${url}`);
517
+ }
518
+ });
519
+
520
+ const sellers = await state.sellers();
521
+ expect(sellers[0]).toMatchObject({
522
+ id: "tbs-openrouter-ai-qecae",
523
+ name: "tbs-openrouter-ai-qecae",
524
+ nodeStatus: "active",
525
+ upstreamDomain: "openrouter.ai",
526
+ profile: "tbs-openrouter-ai-qecae",
527
+ modelsCount: 2
528
+ });
529
+
530
+ const detail = await state.sellerDetail("tbs-openrouter-ai-qecae");
531
+ expect(detail.configuration.upstreamUrl).toBe("https://openrouter.ai/api");
532
+ expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** abcd");
533
+ expect(detail.models.map((model) => model.upstreamModel)).toEqual(["openai/gpt-5.4", "anthropic/claude-opus-4.7"]);
534
+ });
535
+
536
+ test("UiActions updates new registry seller config without a local profile", async () => {
537
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
538
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
539
+ const calls: string[][] = [];
540
+ const actions = new UiActions({
541
+ configManager: mgr,
542
+ url: "https://bootstrap.example.test",
543
+ fetchJson: async (url, init) => {
544
+ const pathName = new URL(url).pathname;
545
+ if (pathName === "/registry/sellers") {
546
+ return {
547
+ version: 10,
548
+ sellers: [{
549
+ id: "tbs-openrouter-ai-qecae",
550
+ name: "tbs-openrouter-ai-qecae",
551
+ app: "tbs-openrouter-ai-qecae",
552
+ url: "https://tbs-openrouter-ai-qecae.fly.dev",
553
+ status: "active",
554
+ supportedProtocols: ["chat_completions"],
555
+ paymentMethods: ["clawtip"]
556
+ }]
557
+ };
558
+ }
559
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer operator-secret");
560
+ if (pathName === "/operator/admin/config") {
561
+ return {
562
+ config: {
563
+ upstreamUrl: "https://openrouter.ai/api",
564
+ upstreamApiKey: "live-secret-abcd",
565
+ maxConnections: 8,
566
+ maxQueueDepth: 4
567
+ }
568
+ };
569
+ }
570
+ throw new Error(`unexpected fetch url ${url}`);
571
+ },
572
+ commandRunner: async (args): Promise<UiActionResult> => {
573
+ calls.push(args);
574
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
575
+ }
576
+ });
577
+
578
+ const result = await actions.updateSellerConfig("tbs-openrouter-ai-qecae", { maxConnections: 12 });
579
+
580
+ expect(result.ok).toBe(true);
581
+ expect(calls.map((args) => args.join(" "))).toEqual([
582
+ expect.stringContaining("--url https://tbs-openrouter-ai-qecae.fly.dev --token operator-secret seller-config validate --file"),
583
+ expect.stringContaining("--url https://tbs-openrouter-ai-qecae.fly.dev --token operator-secret seller-config put --file")
584
+ ]);
585
+ });
586
+
587
+ test("admin UI seller rows render missing telemetry without unknown data labels", () => {
588
+ const html = adminUiHtml();
589
+ expect(html).toContain("class=\"spinner\"");
590
+ expect(html).toContain("@keyframes spin");
591
+ expect(html).toContain("role=\"status\" aria-label=\"Loading sellers\"");
592
+ expect(html).toContain("id=\"createStatus\" class=\"status-line hidden\"");
593
+ expect(html).toContain("id=\"createProgress\"");
594
+ expect(html).toContain("create-progress");
595
+ expect(html).toContain("progress-step");
596
+ expect(html).toContain("progress-log");
597
+ expect(html).toContain("currentCreateJob");
598
+ expect(html).toContain("setCreateFormDisabled(true)");
599
+ expect(html).toContain("setCreateFormDisabled(false)");
600
+ expect(html).toContain("Retry create");
601
+ expect(html).toContain("data-progress-step");
602
+ expect(html).toContain("aria-expanded");
603
+ expect(html).toContain("progress-title");
604
+ expect(html).toContain("Show details");
605
+ expect(html).toContain("Hide details");
606
+ expect(html).toContain("/api/jobs/");
607
+ expect(html).toContain("function uiErrorMessage");
608
+ expect(html).toContain("Admin UI connection lost. Restart tb-admin ui and reload this page.");
609
+ expect(html).toContain("Admin profile authentication failed. Check the configured operator token.");
610
+ expect(html).toContain("renderCreateJob");
611
+ expect(html).toContain("pollCreateJob");
612
+ expect(html).toContain("Created and added to bootstrap registry.");
613
+ expect(html).toContain("upstreamUrl:\"https://openrouter.ai/api/v1\"");
614
+ expect(html).toContain("loadingSpinner(\"Loading sellers\")");
615
+ expect(html).toContain("loadingSpinner(\"Loading configuration\")");
616
+ expect(html).toContain("loadingSpinner(\"Loading models\")");
617
+ expect(html).toContain("sellerRefreshIntervalMs = 30000");
618
+ expect(html).toContain("function scheduleSellerRefresh()");
619
+ expect(html).toContain("sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs)");
620
+ expect(html).toContain("sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs)");
621
+ expect(html).toContain("Next refresh: ");
622
+ expect(html).not.toContain("sellerLastUpdated");
623
+ expect(html).not.toContain("Last updated:");
624
+ expect(html).not.toContain("s ago");
625
+ expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000)");
626
+ expect(html).toContain("function renderSellerRows(rows)");
627
+ expect(html).toContain("sellerRefreshLoaded = true");
628
+ expect(html).toContain("readonly-value");
629
+ expect(html).toContain("--seller-grid");
630
+ expect(html).toContain("grid-template-columns:var(--seller-grid)");
631
+ expect(html).toContain("#sellerRows{display:grid;gap:10px;width:100%;min-width:0}");
632
+ expect(html).toContain("gap:12px;width:100%;min-width:0");
633
+ expect(html).toContain("detailFieldsHtml");
634
+ expect(html).toContain("data-original");
635
+ // Design-spec header labels (TTFT not Latency, Disc not Discount)
636
+ expect(html).toContain(">TTFT</span>");
637
+ expect(html).toContain(">Disc</span>");
638
+ expect(html).not.toContain(">Latency</span>");
639
+ // Spec-compliant token formats
640
+ expect(html).toContain("tok/s");
641
+ expect(html).toContain("AVG speed");
642
+ expect(html).toContain("Samples");
643
+ // Spec-compliant unknown-value char (em dash) lives in formatter
644
+ expect(html).toContain("UNKNOWN_VALUE");
645
+ expect(html).toContain("formatDuration");
646
+ expect(html).toContain("formatSellerStatus");
647
+ expect(html).toContain("formatDiscountRatio");
648
+ expect(html).toContain("formatBalanceAmount");
649
+ // No glassmorphism per spec
650
+ expect(html).not.toContain("backdrop-filter");
651
+ // Detail field references
652
+ expect(html).toContain("avgTokensPerSecond");
653
+ expect(html).toContain("lastTokensPerSecond");
654
+ expect(html).toContain("lastInferenceMs");
655
+ expect(html).toContain("upstreamBalanceSource");
656
+ expect(html).toContain("upstreamBalanceFetchedAt");
657
+ expect(html).toContain("upstreamBalanceError");
658
+ expect(html).toContain("upstreamBalanceProbeTemplate");
659
+ expect(html).toContain("upstreamBalanceProbeUrl");
660
+ expect(html).toContain("upstreamBalanceProbeUserId");
661
+ expect(html).toContain("upstreamBalanceProbeRechargeUrl");
662
+ // Payment tabs
663
+ expect(html).toContain("paymentMethods");
664
+ expect(html).toContain("payment-tabs");
665
+ expect(html).toContain("payment-tab");
666
+ expect(html).toContain("payment-panel");
667
+ expect(html).toContain("pill-switch");
668
+ expect(html).toContain("data-payment-tab");
669
+ expect(html).toContain("data-payment-panel");
670
+ expect(html).toContain("data-payment-toggle");
671
+ expect(html).toContain("enabledPaymentMethods()");
672
+ expect(html).toContain("setupPaymentTabs()");
673
+ expect(html).toContain("togglePaymentMethod");
674
+ expect(html).toContain("selectPaymentTab");
675
+ expect(html).toContain("updatePaymentPanels");
676
+ expect(html).toContain("data-enabled");
677
+ expect(html).toContain("aria-pressed");
678
+ expect(html).toContain("启用即可使用 mock 支付方式,无需额外参数");
679
+ expect(html).toContain("required-star");
680
+ expect(html).toContain("Seller name");
681
+ expect(html).toContain("tbs-<seller-name>-<random>");
682
+ expect(html).toContain("Fly app name");
683
+ expect(html).toContain("Seller image");
684
+ expect(html).toContain("Fly config file");
685
+ expect(html).toContain("Balance probe URL (auto from template)");
686
+ expect(html).toContain("Recharge URL");
687
+ expect(html).toContain("randomAppSuffix");
688
+ expect(html).toContain("updateGeneratedCreateFields");
689
+ expect(html).toContain("appNameFromSellerName");
690
+ expect(html).toContain("balanceProbeUrlForTemplate");
691
+ expect(html).toContain("data-generated-summary=\"clawtip\"");
692
+ expect(html).toContain("clawtipPayTo");
693
+ expect(html).toContain("clawtipSm4KeyBase64");
694
+ expect(html).toContain("clawtipSkillSlug");
695
+ expect(html).toContain("clawtipSkillId");
696
+ expect(html).toContain("clawtipDescription");
697
+ expect(html).toContain("clawtipResourceUrl");
698
+ expect(html).toContain("clawtipActivationFeeFen");
699
+ expect(html).toContain("clawtipMicrosPerFen");
700
+ // Spec uses — (em dash) for unknown, not "not reported" or "n/a"
701
+ expect(html).not.toContain("not reported");
702
+ expect(html).not.toContain("\"n/a\"");
703
+ expect(html).toContain("aria-label=\"Seller specs\"");
704
+ expect(html).toContain("<svg viewBox=\"0 0 24 24\"");
705
+ expect(html).not.toContain(">i</span>");
706
+ expect(html).not.toContain(">×</button>");
707
+ expect(html).not.toContain("Loading sellers...");
708
+ expect(html).not.toContain("Loading seller data...");
709
+ expect(html).not.toContain("Loading configuration...");
710
+ expect(html).not.toContain("Loading models...");
711
+ expect(html).not.toContain("<div id=\"createStatus\" class=\"status-line\">Ready</div>");
712
+ expect(html).not.toContain("not set");
713
+ expect(html).not.toContain(" multiple");
714
+ });
715
+
716
+ test("UiActions validates then puts seller config without shell command strings", async () => {
717
+ const fixture = await startFixtureAdminServer();
718
+ try {
719
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
720
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
721
+ mgr.setProfile("seller-sin", { url: fixture.baseUrl, token: "seller-token" });
722
+ const calls: string[][] = [];
723
+ const actions = new UiActions({
724
+ configManager: mgr,
725
+ profile: "bootstrap",
726
+ commandRunner: async (args): Promise<UiActionResult> => {
727
+ calls.push(args);
728
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
729
+ }
730
+ });
731
+
732
+ const result = await actions.updateSellerConfig("tbs-sin-06", {
733
+ markupRatio: 1.4,
734
+ upstreamApiKey: "new-secret"
735
+ });
736
+
737
+ expect(result.ok).toBe(true);
738
+ expect(calls).toHaveLength(2);
739
+ expect(calls[0]).toContain("validate");
740
+ expect(calls[1]).toContain("put");
741
+ expect(calls.flat()).not.toContain("seller-config validate");
742
+ expect(calls[0]).toContain("--profile");
743
+ expect(calls[0]).toContain("seller-sin");
744
+ } finally {
745
+ await fixture.close();
746
+ }
747
+ });
748
+
749
+ test("probeUpstreamBalance parses OpenRouter balance and caches failed probes", async () => {
750
+ const cache = new BalanceProbeCache();
751
+ let calls = 0;
752
+ const openRouter = await probeUpstreamBalance({
753
+ upstreamUrl: "https://openrouter.ai/api",
754
+ upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
755
+ upstreamApiKey: "fixture-key"
756
+ }, {
757
+ now: () => 1000,
758
+ cache,
759
+ fetch: async (url, init) => {
760
+ calls += 1;
761
+ expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
762
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
763
+ return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
764
+ }
765
+ });
766
+
767
+ expect(openRouter).toMatchObject({
768
+ source: "openrouter",
769
+ rawAmount: 47.95,
770
+ amountUsdMicros: 47950000,
771
+ currency: "USD"
772
+ });
773
+ expect(calls).toBe(1);
774
+
775
+ const firstFailure = await probeUpstreamBalance({
776
+ upstreamUrl: "https://custom-upstream.example/v1",
777
+ upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
778
+ upstreamApiKey: "fixture-key"
779
+ }, {
780
+ now: () => 2000,
781
+ cache,
782
+ fetch: async () => {
783
+ calls += 1;
784
+ return jsonResponse({ success: false });
785
+ }
786
+ });
787
+ const cachedFailure = await probeUpstreamBalance({
788
+ upstreamUrl: "https://custom-upstream.example/v1",
789
+ upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
790
+ upstreamApiKey: "fixture-key"
791
+ }, {
792
+ now: () => 3000,
793
+ cache,
794
+ fetch: async () => {
795
+ throw new Error("cache miss");
796
+ }
797
+ });
798
+
799
+ expect(firstFailure.error?.message).toBe("missing upstreamUserId for newapi upstream");
800
+ expect(cachedFailure).toBe(firstFailure);
801
+ expect(calls).toBe(1);
802
+ });
803
+
804
+ test("probeUpstreamBalance sends New-Api-User for generic newapi balances", async () => {
805
+ const snapshot = await probeUpstreamBalance({
806
+ upstreamUrl: "https://custom-upstream.example/v1",
807
+ upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
808
+ upstreamApiKey: "fixture-key",
809
+ upstreamUserId: "12345"
810
+ }, {
811
+ now: () => 4000,
812
+ fetch: async (url, init) => {
813
+ expect(String(url)).toBe("https://custom-upstream.example/api/user/quota");
814
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
815
+ expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBe("12345");
816
+ return jsonResponse({ success: true, data: { quota: 50000000, used_quota: 1250000 } });
817
+ }
818
+ });
819
+
820
+ expect(snapshot).toMatchObject({
821
+ source: "newapi_generic",
822
+ rawAmount: 97.5,
823
+ amountUsdMicros: 97500000,
824
+ currency: "USD"
825
+ });
826
+ });
827
+
828
+ test("probeUpstreamBalance parses generic /v1/usage balances without user id", async () => {
829
+ const snapshot = await probeUpstreamBalance({
830
+ upstreamUrl: "https://code.shoestravel.xin",
831
+ upstreamApiKey: "fixture-key"
832
+ }, {
833
+ now: () => 5000,
834
+ fetch: async (url, init) => {
835
+ expect(String(url)).toBe("https://code.shoestravel.xin/v1/usage");
836
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
837
+ expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBeUndefined();
838
+ return jsonResponse({
839
+ balance: 37.96221984,
840
+ isValid: true,
841
+ remaining: 37.96221984,
842
+ unit: "USD"
843
+ });
844
+ }
845
+ });
846
+
847
+ expect(snapshot).toMatchObject({
848
+ source: "usage_generic",
849
+ rawAmount: 37.96221984,
850
+ amountUsdMicros: 37962220,
851
+ currency: "USD"
852
+ });
853
+ });
854
+
855
+ test("probeUpstreamBalance honors explicit balance probe templates", async () => {
856
+ const newApi = await probeUpstreamBalance({
857
+ upstreamUrl: "https://custom-upstream.example/v1",
858
+ upstreamApiKey: "fixture-key",
859
+ upstreamBalanceProbe: {
860
+ template: "newapi_generic",
861
+ url: "https://custom-upstream.example/api/user/self",
862
+ userId: "67890"
863
+ }
864
+ }, {
865
+ now: () => 7000,
866
+ fetch: async (url, init) => {
867
+ expect(String(url)).toBe("https://custom-upstream.example/api/user/self");
868
+ expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBe("67890");
869
+ return jsonResponse({ success: true, data: { quota: 1000000, used_quota: 500000 } });
870
+ }
871
+ });
872
+
873
+ expect(newApi).toMatchObject({
874
+ source: "newapi_generic",
875
+ rawAmount: 1,
876
+ amountUsdMicros: 1000000,
877
+ currency: "USD"
878
+ });
879
+
880
+ const usage = await probeUpstreamBalance({
881
+ upstreamUrl: "https://code.shoestravel.xin/v1",
882
+ upstreamApiKey: "fixture-key",
883
+ upstreamBalanceProbe: {
884
+ template: "usage_generic"
885
+ }
886
+ }, {
887
+ now: () => 8000,
888
+ fetch: async (url) => {
889
+ expect(String(url)).toBe("https://code.shoestravel.xin/v1/usage");
890
+ return jsonResponse({ remaining: 37.96221984, unit: "USD", isValid: true });
891
+ }
892
+ });
893
+
894
+ expect(usage).toMatchObject({
895
+ source: "usage_generic",
896
+ rawAmount: 37.96221984,
897
+ amountUsdMicros: 37962220,
898
+ currency: "USD"
899
+ });
900
+ });
901
+
902
+ test("probeUpstreamBalance reports inactive generic /v1/usage keys", async () => {
903
+ const snapshot = await probeUpstreamBalance({
904
+ upstreamUrl: "https://custom-upstream.example/v1",
905
+ upstreamBalanceUrl: "https://custom-upstream.example/v1/usage",
906
+ upstreamApiKey: "fixture-key"
907
+ }, {
908
+ now: () => 6000,
909
+ fetch: async () => jsonResponse({
910
+ remaining: 12.5,
911
+ unit: "USD",
912
+ isValid: false
913
+ })
914
+ });
915
+
916
+ expect(snapshot).toMatchObject({
917
+ source: "usage_generic",
918
+ rawAmount: 12.5,
919
+ amountUsdMicros: 12500000,
920
+ currency: "USD",
921
+ error: {
922
+ httpStatus: 200,
923
+ message: "upstream key is not active"
924
+ }
925
+ });
926
+ });
927
+
928
+ test("UiActions create validates, creates, then applies initial seller config", async () => {
929
+ const fixture = await startFixtureAdminServer();
930
+ try {
931
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
932
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
933
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
934
+ const calls: string[][] = [];
935
+ const actions = new UiActions({
936
+ configManager: mgr,
937
+ configPath: TEMP_CONF_PATH,
938
+ profile: "bootstrap",
939
+ fetchJson: async (url) => createSellerFetchJson(url),
940
+ commandRunner: async (args): Promise<UiActionResult> => {
941
+ if (isBootstrapSellersAdd(args)) {
942
+ const filePath = args[args.indexOf("--file") + 1];
943
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8")) as { id: string; status?: string };
944
+ expect(entry).toMatchObject({ id: "tbs-nrt-07", status: "active" });
945
+ }
946
+ calls.push(args);
947
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
948
+ }
949
+ });
950
+
951
+ const response = await actions.createSeller({
952
+ sellerName: "tbs-nrt-07",
953
+ app: "tbs-nrt-07",
954
+ region: "nrt",
955
+ image: "registry.fly.io/tb-seller:latest",
956
+ upstreamWebsite: "https://openrouter.ai",
957
+ upstreamUrl: "https://openrouter.ai/api/v1",
958
+ upstreamApiKey: "fixture-upstream-key",
959
+ upstreamBalanceProbeTemplate: "usage_generic",
960
+ upstreamBalanceProbeUrl: "https://code.shoestravel.xin/v1/usage",
961
+ upstreamBalanceProbeRechargeUrl: "https://code.shoestravel.xin/topup",
962
+ maxConnections: 8,
963
+ maxQueueDepth: 4,
964
+ markupRatio: 1.2,
965
+ discountRatio: 1,
966
+ paymentMethods: ["clawtip", "mock"],
967
+ clawtipPayTo: "pay-to-seller",
968
+ clawtipSm4KeyBase64: "0123456789abcdef012345==",
969
+ clawtipSkillSlug: "tokenbuddy-seller",
970
+ clawtipSkillId: "si-tokenbuddy-seller",
971
+ clawtipDescription: "TokenBuddy Seller",
972
+ clawtipResourceUrl: "https://tbs-nrt-07.fly.dev",
973
+ clawtipActivationFeeFen: 1,
974
+ clawtipMicrosPerFen: 10000,
975
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
976
+ });
977
+
978
+ expect(response.result.ok).toBe(true);
979
+ expect(response.configPut?.ok).toBe(true);
980
+ expect(response.modelsRefresh?.ok).toBe(true);
981
+ expect(response.registryPublish?.ok).toBe(true);
982
+ expect(response.publishRegistry).toBe("completed");
983
+ expect(response.configPreview.upstreamUrl).toBe("https://openrouter.ai/api");
984
+ expect(response.configPreview.sellerId).toBe("tbs-nrt-07");
985
+ expect(response.configPreview.manifestVersion).toBe("manifest.v1");
986
+ expect(response.configPreview.upstreamBalanceProbe).toEqual({
987
+ template: "usage_generic",
988
+ url: "https://code.shoestravel.xin/v1/usage",
989
+ userId: undefined,
990
+ rechargeUrl: "https://code.shoestravel.xin/topup"
991
+ });
992
+ expect(response.configPreview.upstreamBalanceUrl).toBe("https://code.shoestravel.xin/v1/usage");
993
+ expect(response.configPreview.upstreamRechargeUrl).toBe("https://code.shoestravel.xin/topup");
994
+ expect(response.configPreview.allowMock).toBe(true);
995
+ expect(response.configPreview.clawtip).toEqual({
996
+ payTo: "pay-to-seller",
997
+ sm4KeyBase64: "********",
998
+ skillSlug: "tokenbuddy-seller",
999
+ skillId: "si-tokenbuddy-seller",
1000
+ description: "TokenBuddy Seller",
1001
+ resourceUrl: "https://tbs-nrt-07.fly.dev",
1002
+ activationFeeFen: 1,
1003
+ microsPerFen: 10000
1004
+ });
1005
+ const commandLines = calls.map((args) => args.join(" "));
1006
+ expect(commandLines).toEqual([
1007
+ expect.stringContaining("seller-config validate --file"),
1008
+ expect.stringContaining("seller create tbs-nrt-07"),
1009
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret status"),
1010
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret seller-config put --file"),
1011
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret upstreams refresh --auto-models"),
1012
+ expect.stringContaining("bootstrap sellers add --file")
1013
+ ]);
1014
+ expect(commandLines[5]).toContain("--expect-version 7");
1015
+ expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
1016
+ expect(mgr.getProfile("tbs-nrt-07")).toEqual({
1017
+ url: "https://tbs-nrt-07.fly.dev",
1018
+ token: "operator-secret"
1019
+ });
1020
+ } finally {
1021
+ await fixture.close();
1022
+ }
1023
+ });
1024
+
1025
+ test("UiActions create retries transient upstream model refresh failures", async () => {
1026
+ const fixture = await startFixtureAdminServer();
1027
+ try {
1028
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
1029
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
1030
+ const registry = await createSellerFetchJson(`${fixture.baseUrl}/registry/sellers`);
1031
+ let refreshAttempts = 0;
1032
+ const calls: string[][] = [];
1033
+ const actions = new UiActions({
1034
+ configManager: mgr,
1035
+ url: fixture.baseUrl,
1036
+ fetchJson: async (url) => {
1037
+ const pathName = new URL(url).pathname;
1038
+ if (pathName === "/registry/sellers") {
1039
+ return registry;
1040
+ }
1041
+ return createSellerFetchJson(url);
1042
+ },
1043
+ commandRunner: async (args): Promise<UiActionResult> => {
1044
+ calls.push(args);
1045
+ if (args.includes("upstreams") && args.includes("refresh")) {
1046
+ refreshAttempts += 1;
1047
+ if (refreshAttempts < 3) {
1048
+ return { ok: false, stdout: "", stderr: "Error: Connection failed: fetch failed", command: ["node", "tb-admin", ...args] };
1049
+ }
1050
+ }
1051
+ if (args.includes("bootstrap") && args.includes("sellers") && args.includes("add")) {
1052
+ const filePath = args[args.indexOf("--file") + 1];
1053
+ const doc = JSON.parse(fs.readFileSync(filePath, "utf8"));
1054
+ expect(doc).toMatchObject({ id: "tbs-nrt-07" });
1055
+ expect(doc.models).toHaveLength(2);
1056
+ }
1057
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
1058
+ }
1059
+ });
1060
+
1061
+ const response = await actions.createSeller({
1062
+ sellerName: "tbs-nrt-07",
1063
+ app: "tbs-nrt-07",
1064
+ region: "nrt",
1065
+ image: "registry.fly.io/tb-seller:latest",
1066
+ upstreamWebsite: "https://openrouter.ai",
1067
+ upstreamUrl: "https://openrouter.ai/api/v1",
1068
+ upstreamApiKey: "fixture-upstream-key",
1069
+ upstreamBalanceProbeTemplate: "none",
1070
+ maxConnections: 8,
1071
+ maxQueueDepth: 4,
1072
+ markupRatio: 1.2,
1073
+ discountRatio: 1,
1074
+ paymentMethods: ["mock"],
1075
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
1076
+ });
1077
+
1078
+ expect(response.modelsRefresh?.ok).toBe(true);
1079
+ expect(response.registryPublish?.ok).toBe(true);
1080
+ expect(refreshAttempts).toBe(3);
1081
+ expect(calls.filter((args) => args.includes("upstreams") && args.includes("refresh"))).toHaveLength(3);
1082
+ } finally {
1083
+ await fixture.close();
1084
+ }
1085
+ }, 30000);
1086
+
1087
+ test("UiActions create publishes registry from wrapped upstream metadata", async () => {
1088
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
1089
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
1090
+ let published: any;
1091
+ const actions = new UiActions({
1092
+ configManager: mgr,
1093
+ url: "https://bootstrap.example.test",
1094
+ fetchJson: async (url) => createSellerFetchJson(url, "wrappedArray"),
1095
+ commandRunner: async (args): Promise<UiActionResult> => {
1096
+ if (args.includes("bootstrap") && args.includes("sellers") && args.includes("add")) {
1097
+ const filePath = args[args.indexOf("--file") + 1];
1098
+ published = JSON.parse(fs.readFileSync(filePath, "utf8"));
1099
+ }
1100
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
1101
+ }
1102
+ });
1103
+
1104
+ const response = await actions.createSeller(createSellerRequestFixture({
1105
+ sellerName: "openrouter-ai",
1106
+ app: "tbs-openrouter-ai-qecae"
1107
+ }));
1108
+
1109
+ expect(response.registryPublish?.ok).toBe(true);
1110
+ expect(published).toMatchObject({
1111
+ id: "tbs-openrouter-ai-qecae",
1112
+ name: "tbs-openrouter-ai-qecae",
1113
+ app: "tbs-openrouter-ai-qecae",
1114
+ status: "active",
1115
+ models: ["openai/gpt-5.4", "openai/gpt-5.4-mini"],
1116
+ supportedProtocols: ["chat_completions"]
1117
+ });
1118
+ expect(mgr.getProfile("tbs-openrouter-ai-qecae")).toEqual({
1119
+ url: "https://tbs-openrouter-ai-qecae.fly.dev",
1120
+ token: "operator-secret"
1121
+ });
1122
+ });
1123
+
1124
+ test("UiActions create falls back to public manifest models for registry publish", async () => {
1125
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
1126
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
1127
+ let published: any;
1128
+ const actions = new UiActions({
1129
+ configManager: mgr,
1130
+ url: "https://bootstrap.example.test",
1131
+ fetchJson: async (url) => createSellerFetchJson(url, "manifest"),
1132
+ commandRunner: async (args): Promise<UiActionResult> => {
1133
+ if (args.includes("bootstrap") && args.includes("sellers") && args.includes("add")) {
1134
+ const filePath = args[args.indexOf("--file") + 1];
1135
+ published = JSON.parse(fs.readFileSync(filePath, "utf8"));
1136
+ }
1137
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
1138
+ }
1139
+ });
1140
+
1141
+ const response = await actions.createSeller(createSellerRequestFixture({
1142
+ sellerName: "openrouter-ai",
1143
+ app: "tbs-openrouter-ai-qecae"
1144
+ }));
1145
+
1146
+ expect(response.registryPublish?.ok).toBe(true);
1147
+ expect(published).toMatchObject({
1148
+ id: "tbs-openrouter-ai-qecae",
1149
+ name: "tbs-openrouter-ai-qecae",
1150
+ app: "tbs-openrouter-ai-qecae",
1151
+ status: "active",
1152
+ models: ["anthropic/claude-opus-4.7"],
1153
+ supportedProtocols: ["chat_completions"]
1154
+ });
1155
+ expect(mgr.getProfile("tbs-openrouter-ai-qecae")).toEqual({
1156
+ url: "https://tbs-openrouter-ai-qecae.fly.dev",
1157
+ token: "operator-secret"
1158
+ });
1159
+ });
1160
+
1161
+ test("UiActions create supports mock-only payment without ClawTip parameters", async () => {
1162
+ const fixture = await startFixtureAdminServer();
1163
+ try {
1164
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
1165
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
1166
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
1167
+ const actions = new UiActions({
1168
+ configManager: mgr,
1169
+ profile: "bootstrap",
1170
+ commandRunner: async (args): Promise<UiActionResult> => {
1171
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
1172
+ }
1173
+ });
1174
+
1175
+ const response = await actions.createSeller({
1176
+ sellerName: "tbs-nrt-08",
1177
+ app: "tbs-nrt-08",
1178
+ region: "nrt",
1179
+ image: "registry.fly.io/tb-seller:latest",
1180
+ upstreamWebsite: "https://openrouter.ai",
1181
+ upstreamUrl: "https://openrouter.ai/api/v1",
1182
+ upstreamApiKey: "fixture-upstream-key",
1183
+ upstreamBalanceProbeTemplate: "none",
1184
+ maxConnections: 8,
1185
+ maxQueueDepth: 4,
1186
+ markupRatio: 1.2,
1187
+ discountRatio: 1,
1188
+ paymentMethods: ["mock"],
1189
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
1190
+ dryRun: true
1191
+ });
1192
+
1193
+ expect(response.result.ok).toBe(true);
1194
+ expect(response.readiness).toBeUndefined();
1195
+ expect(response.configPut).toBeUndefined();
1196
+ expect(response.configPreview.allowMock).toBe(true);
1197
+ expect(response.configPreview.clawtip).toBeUndefined();
1198
+ } finally {
1199
+ await fixture.close();
1200
+ }
1201
+ });
1202
+
1203
+ test("UiActions create requires ClawTip parameters for ClawTip payment", async () => {
1204
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
1205
+ const actions = new UiActions({
1206
+ configManager: mgr,
1207
+ profile: "bootstrap",
1208
+ commandRunner: async (): Promise<UiActionResult> => {
1209
+ throw new Error("create should fail before running commands");
1210
+ }
1211
+ });
1212
+
1213
+ await expect(actions.createSeller({
1214
+ sellerName: "tbs-nrt-09",
1215
+ app: "tbs-nrt-09",
1216
+ region: "nrt",
1217
+ image: "registry.fly.io/tb-seller:latest",
1218
+ upstreamWebsite: "https://openrouter.ai",
1219
+ upstreamUrl: "https://openrouter.ai/api/v1",
1220
+ upstreamApiKey: "fixture-upstream-key",
1221
+ upstreamBalanceProbeTemplate: "none",
1222
+ maxConnections: 8,
1223
+ maxQueueDepth: 4,
1224
+ markupRatio: 1.2,
1225
+ discountRatio: 1,
1226
+ paymentMethods: ["clawtip"],
1227
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
1228
+ })).rejects.toThrow("clawtipPayTo is required");
1229
+ });
1230
+
1231
+ test("UiActions subprocess runner uses the tb-admin bin entrypoint", async () => {
1232
+ const previousArgv = process.argv[1];
1233
+ process.argv[1] = path.resolve(process.env.HOME || "/tmp", "packages/admin-cli/bin/tb-admin.js");
1234
+ const { runTbAdmin } = await import("../src/ui-actions.js");
1235
+ try {
1236
+ const result = await runTbAdmin(["--help"], 30000);
1237
+ expect(result.ok).toBe(true);
1238
+ expect(result.command[1]).toMatch(/packages\/admin-cli\/bin\/tb-admin\.js$/);
1239
+ expect(fs.existsSync(result.command[1])).toBe(true);
1240
+ expect(result.stdout).toContain("Remote admin CLI");
1241
+ } finally {
1242
+ process.argv[1] = previousArgv;
1243
+ }
1244
+ });
1245
+ });
1246
+
1247
+ describe("Admin CLI Display Format Spec Compliance", () => {
1248
+ test("formatDuration uses ms under 1s and 2-decimal seconds above 1s", () => {
1249
+ expect(formatDuration(undefined)).toBe(UNKNOWN_VALUE);
1250
+ expect(formatDuration(0)).toBe("0ms");
1251
+ expect(formatDuration(272)).toBe("272ms");
1252
+ expect(formatDuration(999)).toBe("999ms");
1253
+ expect(formatDuration(1000)).toBe("1.00s");
1254
+ expect(formatDuration(3542)).toBe("3.54s");
1255
+ expect(formatDuration(11540)).toBe("11.54s");
1256
+ });
1257
+
1258
+ test("formatMoney uses 4 decimals by default and 6 for tiny ledger amounts", () => {
1259
+ expect(formatMoney(undefined)).toBe(UNKNOWN_VALUE);
1260
+ expect(formatMoney(0)).toBe("$0.0000");
1261
+ expect(formatMoney(138600)).toBe("$0.1386");
1262
+ expect(formatMoney(2_772_200)).toBe("$2.7722");
1263
+ expect(formatMoney(138600, { ledger: true })).toBe("$0.1386");
1264
+ expect(formatMoney(5782, { ledger: true })).toBe("$0.005782");
1265
+ });
1266
+
1267
+ test("formatDiscountRatio converts ratio to percentage per the design spec", () => {
1268
+ expect(formatDiscountRatio(undefined)).toBe(UNKNOWN_VALUE);
1269
+ expect(formatDiscountRatio(0.5)).toBe("50%");
1270
+ expect(formatDiscountRatio(0.01)).toBe("99%");
1271
+ expect(formatDiscountRatio(0.99)).toBe("1%");
1272
+ expect(formatDiscountRatio(1)).toBe("0%");
1273
+ expect(formatDiscountRatio(0)).toBe("100%");
1274
+ });
1275
+
1276
+ test("formatPercent rounds to whole percent", () => {
1277
+ expect(formatPercent(0.123)).toBe("12%");
1278
+ expect(formatPercent(0.987)).toBe("99%");
1279
+ expect(formatPercent(undefined)).toBe(UNKNOWN_VALUE);
1280
+ });
1281
+
1282
+ test("formatPricePair renders $/1M with 4 decimals per spec", () => {
1283
+ expect(formatPricePair(undefined, undefined)).toBe(UNKNOWN_VALUE);
1284
+ expect(formatPricePair(2_500_000, 3_000_000)).toBe("$2.5000 / $3.0000");
1285
+ });
1286
+
1287
+ test("formatCount uses en-US grouping", () => {
1288
+ expect(formatCount(undefined)).toBe(UNKNOWN_VALUE);
1289
+ expect(formatCount(7_529)).toBe("7,529");
1290
+ expect(formatCount(218_700)).toBe("218,700");
1291
+ });
1292
+
1293
+ test("formatTimeCompact emits HH:mm for today and MM/DD HH:mm otherwise", () => {
1294
+ const now = new Date();
1295
+ const todayIso = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 51).toISOString();
1296
+ const yesterdayIso = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 9, 5).toISOString();
1297
+ expect(formatTimeCompact(undefined)).toBe(UNKNOWN_VALUE);
1298
+ expect(formatTimeCompact(todayIso)).toBe("14:51");
1299
+ expect(formatTimeCompact(yesterdayIso)).toMatch(/^\d{2}\/\d{2} 09:05$/);
1300
+ });
1301
+
1302
+ test("formatTimeFull uses YYYY-MM-DD HH:mm:ss for detail panels", () => {
1303
+ expect(formatTimeFull(undefined)).toBe(UNKNOWN_VALUE);
1304
+ expect(formatTimeFull("2026-06-07T00:25:51.000Z")).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
1305
+ });
1306
+
1307
+ test("formatTimeLedger uses YYYY/MM/DD HH:mm:ss for audit tables", () => {
1308
+ expect(formatTimeLedger(undefined)).toBe(UNKNOWN_VALUE);
1309
+ expect(formatTimeLedger("2026-06-07T00:25:51.000Z")).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/);
1310
+ });
1311
+
1312
+ test("formatSellerId trims long tbs ids to 10 chars", () => {
1313
+ expect(formatSellerId(undefined)).toBe(UNKNOWN_VALUE);
1314
+ expect(formatSellerId("tbs-825edb")).toBe("tbs-825edb");
1315
+ expect(formatSellerId("tbs-openrouter-ai-k7p9x")).toBe("tbs-openro");
1316
+ });
1317
+
1318
+ test("formatSpeed renders to one decimal with tok/s suffix", () => {
1319
+ expect(formatSpeed(undefined)).toBe(UNKNOWN_VALUE);
1320
+ expect(formatSpeed(0)).toBe("0.0 tok/s");
1321
+ expect(formatSpeed(11.54)).toBe("11.5 tok/s");
1322
+ });
1323
+
1324
+ test("formatSellerCapacity renders used / limit with en-US grouping", () => {
1325
+ expect(formatSellerCapacity(undefined, undefined)).toBe(UNKNOWN_VALUE);
1326
+ expect(formatSellerCapacity(8, 16)).toBe("8 / 16");
1327
+ expect(formatSellerCapacity(undefined, 16)).toBe(`${UNKNOWN_VALUE} / 16`);
1328
+ expect(formatSellerCapacity(8, undefined)).toBe(`8 / ${UNKNOWN_VALUE}`);
1329
+ });
1330
+
1331
+ test("formatBalanceAmount uses 0 decimals for >=100 and 2 decimals otherwise", () => {
1332
+ expect(formatBalanceAmount(undefined, "USD")).toBe(UNKNOWN_VALUE);
1333
+ expect(formatBalanceAmount(47_950_000, "USD")).toBe("USD 47.95");
1334
+ expect(formatBalanceAmount(479_500_000, "USD")).toBe("USD 480");
1335
+ expect(formatBalanceAmount(50_000_000, "EUR")).toBe("EUR 50.00");
1336
+ });
1337
+
1338
+ test("formatSellerStatus maps all internal node statuses to the 7 canonical labels", () => {
1339
+ const canonical = new Set(["ok", "online", "configured", "pending", "degraded", "error", "unknown"]);
1340
+ for (const value of ["active", "healthy", "online", "configured", "pending", "draining", "degraded", "busy_capacity", "offline", "unhealthy", "error", "auth_unknown", "unknown", undefined, "weird-state"]) {
1341
+ expect(canonical.has(formatSellerStatus(value))).toBe(true);
1342
+ }
1343
+ expect(formatSellerStatus("active")).toBe("ok");
1344
+ expect(formatSellerStatus("draining")).toBe("degraded");
1345
+ expect(formatSellerStatus("busy_capacity")).toBe("degraded");
1346
+ expect(formatSellerStatus("offline")).toBe("error");
1347
+ expect(formatSellerStatus("auth_unknown")).toBe("unknown");
1348
+ });
1349
+
1350
+ test("statusTone / sellerStatusTone keep green/amber/red/blue/gray buckets stable", () => {
1351
+ expect(statusTone("ok")).toBe("green");
1352
+ expect(statusTone("online")).toBe("green");
1353
+ expect(statusTone("configured")).toBe("green");
1354
+ expect(statusTone("pending")).toBe("amber");
1355
+ expect(statusTone("degraded")).toBe("amber");
1356
+ expect(statusTone("error")).toBe("red");
1357
+ expect(statusTone("offline")).toBe("red");
1358
+ expect(statusTone("unknown")).toBe("gray");
1359
+ expect(statusTone("running")).toBe("blue");
1360
+ expect(sellerStatusTone("active")).toBe("green");
1361
+ expect(sellerStatusTone("draining")).toBe("amber");
1362
+ expect(sellerStatusTone("busy_capacity")).toBe("amber");
1363
+ expect(sellerStatusTone("offline")).toBe("red");
1364
+ expect(sellerStatusTone("auth_unknown")).toBe("gray");
1365
+ });
161
1366
  });
1367
+
1368
+ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
1369
+ const server = http.createServer((req, res) => {
1370
+ const pathName = new URL(req.url || "/", "http://127.0.0.1").pathname;
1371
+ if (pathName === "/registry/sellers") {
1372
+ sendJson(res, {
1373
+ version: 7,
1374
+ updatedAt: "2026-06-05T00:00:00.000Z",
1375
+ defaultSeller: "tbs-sin-06",
1376
+ sellers: [{
1377
+ id: "tbs-sin-06",
1378
+ name: "tbs-sin-06",
1379
+ profile: "seller-sin",
1380
+ app: "tbs-sin-06",
1381
+ url: "https://seller.example.test",
1382
+ status: "active",
1383
+ region: "sin",
1384
+ modelsCount: 1,
1385
+ sampleModels: ["openai/gpt-5.4"],
1386
+ supportedProtocols: ["responses"],
1387
+ paymentMethods: ["clawtip"]
1388
+ }]
1389
+ });
1390
+ return;
1391
+ }
1392
+ if (pathName === "/operator/status") {
1393
+ sendJson(res, {
1394
+ status: "healthy",
1395
+ capacity: { activeConnections: 3, maxConnections: 8, queueDepth: 0, maxQueueDepth: 4 },
1396
+ upstream: { status: "healthy" },
1397
+ latency: { ttftMs: 321, avgInferenceMs: 640, lastInferenceMs: 700, sampleCount: 2 }
1398
+ });
1399
+ return;
1400
+ }
1401
+ if (pathName === "/operator/admin/service") {
1402
+ sendJson(res, {
1403
+ sellerId: "tbs-sin-06",
1404
+ upstreamUrl: "https://openrouter.ai/api/v1",
1405
+ modelsCount: 1,
1406
+ capacity: { activeConnections: 3, maxConnections: 8, queueDepth: 0, maxQueueDepth: 4 }
1407
+ });
1408
+ return;
1409
+ }
1410
+ if (pathName === "/operator/admin/upstreams") {
1411
+ sendJson(res, {
1412
+ upstreamUrl: "https://openrouter.ai/api/v1",
1413
+ upstreamApiKey: "****27f9",
1414
+ markupRatio: 1.2,
1415
+ discountRatio: 1,
1416
+ modelAliases: { "openai/gpt-5.4": "gpt-5.4" },
1417
+ models: [{
1418
+ id: "openai/gpt-5.4",
1419
+ inputPriceMicrosPer1m: 1000000,
1420
+ outputPriceMicrosPer1m: 3000000
1421
+ }]
1422
+ });
1423
+ return;
1424
+ }
1425
+ if (pathName === "/operator/admin/config") {
1426
+ sendJson(res, {
1427
+ config: {
1428
+ upstreamUrl: "https://openrouter.ai/api/v1",
1429
+ upstreamApiKey: "fixture-live-key-27f9",
1430
+ upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
1431
+ upstreamRechargeUrl: "https://openrouter.ai/settings/credits",
1432
+ upstreamBalanceProbe: {
1433
+ template: "openrouter",
1434
+ url: "https://openrouter.ai/api/v1/credits",
1435
+ rechargeUrl: "https://openrouter.ai/settings/credits"
1436
+ },
1437
+ markupRatio: 1.2,
1438
+ discountRatio: 1,
1439
+ maxConnections: 8,
1440
+ maxQueueDepth: 4,
1441
+ modelAliases: { "openai/gpt-5.4": "gpt-5.4" },
1442
+ models: [{ id: "openai/gpt-5.4" }]
1443
+ }
1444
+ });
1445
+ return;
1446
+ }
1447
+ res.statusCode = 404;
1448
+ res.end();
1449
+ });
1450
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
1451
+ const address = server.address();
1452
+ if (!address || typeof address === "string") {
1453
+ throw new Error("fixture server did not bind a TCP port");
1454
+ }
1455
+ return {
1456
+ baseUrl: `http://127.0.0.1:${address.port}`,
1457
+ close: () => new Promise<void>((resolve) => server.close(() => resolve()))
1458
+ };
1459
+ }
1460
+
1461
+ function sendJson(res: http.ServerResponse, body: unknown): void {
1462
+ res.writeHead(200, { "Content-Type": "application/json" });
1463
+ res.end(JSON.stringify(body));
1464
+ }
1465
+
1466
+ function createSellerRequestFixture(overrides: Partial<CreateSellerRequest> = {}): CreateSellerRequest {
1467
+ return {
1468
+ sellerName: "tbs-nrt-07",
1469
+ app: "tbs-nrt-07",
1470
+ region: "nrt",
1471
+ image: "registry.fly.io/tb-seller:latest",
1472
+ upstreamWebsite: "https://openrouter.ai",
1473
+ upstreamUrl: "https://openrouter.ai/api/v1",
1474
+ upstreamApiKey: "fixture-upstream-key",
1475
+ upstreamBalanceProbeTemplate: "none",
1476
+ maxConnections: 8,
1477
+ maxQueueDepth: 4,
1478
+ markupRatio: 1.2,
1479
+ discountRatio: 1,
1480
+ paymentMethods: ["mock"],
1481
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
1482
+ ...overrides
1483
+ };
1484
+ }
1485
+
1486
+ async function createSellerFetchJson(url: string, mode: "default" | "wrapped" | "wrappedArray" | "manifest" = "default"): Promise<unknown> {
1487
+ const pathName = new URL(url).pathname;
1488
+ if (pathName === "/registry/sellers") {
1489
+ return {
1490
+ version: 7,
1491
+ sellers: []
1492
+ };
1493
+ }
1494
+ if (pathName === "/operator/admin/upstreams") {
1495
+ if (mode === "wrapped") {
1496
+ return {
1497
+ upstreams: {
1498
+ upstreamUrl: "https://openrouter.ai/api",
1499
+ models: [
1500
+ { id: "openai/gpt-5.4" },
1501
+ { id: "openai/gpt-5.4-mini" }
1502
+ ],
1503
+ supportedProtocols: ["chat_completions"]
1504
+ }
1505
+ };
1506
+ }
1507
+ if (mode === "wrappedArray") {
1508
+ return {
1509
+ upstreams: [{
1510
+ upstreamUrl: "https://openrouter.ai/api",
1511
+ models: [
1512
+ { id: "openai/gpt-5.4" },
1513
+ { id: "openai/gpt-5.4-mini" }
1514
+ ],
1515
+ supportedProtocols: ["chat_completions"]
1516
+ }]
1517
+ };
1518
+ }
1519
+ if (mode === "manifest") {
1520
+ return {
1521
+ upstreams: {
1522
+ upstreamUrl: "https://openrouter.ai/api",
1523
+ modelsCount: 1
1524
+ }
1525
+ };
1526
+ }
1527
+ return {
1528
+ upstreamUrl: "https://openrouter.ai/api",
1529
+ models: [
1530
+ { id: "openai/gpt-5.4" },
1531
+ { id: "openai/gpt-5.4-mini" }
1532
+ ],
1533
+ supportedProtocols: ["chat_completions"]
1534
+ };
1535
+ }
1536
+ if (pathName === "/operator/admin/service") {
1537
+ return {
1538
+ sellerId: "tbs-nrt-07",
1539
+ modelsCount: 2,
1540
+ supportedProtocols: ["chat_completions"]
1541
+ };
1542
+ }
1543
+ if (pathName === "/manifest" && mode === "manifest") {
1544
+ return {
1545
+ sellerId: "tbs-nrt-07",
1546
+ models: ["anthropic/claude-opus-4.7"],
1547
+ supportedProtocols: ["chat_completions"]
1548
+ };
1549
+ }
1550
+ throw new Error(`unexpected fetch url ${url}`);
1551
+ }
1552
+
1553
+ function isBootstrapSellersAdd(args: string[]): boolean {
1554
+ const addIndex = args.indexOf("add");
1555
+ return addIndex >= 2 && args[addIndex - 2] === "bootstrap" && args[addIndex - 1] === "sellers";
1556
+ }
1557
+
1558
+ function jsonResponse(body: unknown, status = 200): Response {
1559
+ return new Response(JSON.stringify(body), {
1560
+ status,
1561
+ headers: { "Content-Type": "application/json" }
1562
+ });
1563
+ }