@tokenbuddy/tb-admin 1.0.15 → 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 (43) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +286 -13
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +12 -3
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +12 -8
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/display-format.d.ts +39 -0
  9. package/dist/src/display-format.d.ts.map +1 -0
  10. package/dist/src/display-format.js +354 -0
  11. package/dist/src/display-format.js.map +1 -0
  12. package/dist/src/server-cmd.d.ts +3 -0
  13. package/dist/src/server-cmd.d.ts.map +1 -1
  14. package/dist/src/server-cmd.js +32 -9
  15. package/dist/src/server-cmd.js.map +1 -1
  16. package/dist/src/ui-actions.d.ts +2 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +123 -63
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-command.js +1 -1
  21. package/dist/src/ui-command.js.map +1 -1
  22. package/dist/src/ui-server.d.ts +0 -1
  23. package/dist/src/ui-server.d.ts.map +1 -1
  24. package/dist/src/ui-server.js +25 -9
  25. package/dist/src/ui-server.js.map +1 -1
  26. package/dist/src/ui-state.d.ts +7 -1
  27. package/dist/src/ui-state.d.ts.map +1 -1
  28. package/dist/src/ui-state.js +55 -24
  29. package/dist/src/ui-state.js.map +1 -1
  30. package/dist/src/ui-static.d.ts.map +1 -1
  31. package/dist/src/ui-static.js +372 -47
  32. package/dist/src/ui-static.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli.ts +326 -13
  35. package/src/client.ts +13 -8
  36. package/src/display-format.ts +398 -0
  37. package/src/server-cmd.ts +35 -9
  38. package/src/ui-actions.ts +129 -72
  39. package/src/ui-command.ts +1 -1
  40. package/src/ui-server.ts +24 -10
  41. package/src/ui-state.ts +64 -25
  42. package/src/ui-static.ts +375 -47
  43. package/tests/admin.test.ts +573 -41
@@ -3,8 +3,27 @@ import { buildAdminCli } from "../src/cli.js";
3
3
  import { FlyProvider, parseFlyMachineIds, requirePublishedDockerImage } from "../src/server-cmd.js";
4
4
  import { startAdminUiServer } from "../src/ui-server.js";
5
5
  import { AdminUiState } from "../src/ui-state.js";
6
- import { UiActions, type UiActionResult } from "../src/ui-actions.js";
6
+ import { UiActions, type CreateSellerRequest, type UiActionResult } from "../src/ui-actions.js";
7
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";
8
27
  import {
9
28
  BalanceProbeCache,
10
29
  probeUpstreamBalance
@@ -81,9 +100,30 @@ describe("Admin CLI Config Profile Management Tests", () => {
81
100
 
82
101
  expect(bootstrap).toBeDefined();
83
102
  expect(sellers).toBeDefined();
84
- 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
+ ]);
85
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);
86
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);
87
127
  expect(sellerConfig).toBeDefined();
88
128
  expect(sellerConfig?.commands.map((command) => command.name()).sort()).toEqual(["get", "put", "validate"]);
89
129
  expect(sellerConfig?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
@@ -170,6 +210,32 @@ describe("Admin CLI Config Profile Management Tests", () => {
170
210
  expect(commands).toEqual([]);
171
211
  });
172
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
+
173
239
  test("parseFlyMachineIds reads machine ids and rejects unusable machine lists", () => {
174
240
  expect(parseFlyMachineIds(JSON.stringify([
175
241
  { id: "machine-1" },
@@ -200,7 +266,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
200
266
  expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
201
267
  });
202
268
 
203
- test("tb-admin ui server binds loopback, protects APIs with a session, and serves bootstrap data", async () => {
269
+ test("tb-admin ui server binds loopback, rejects cross-origin APIs, and serves bootstrap data", async () => {
204
270
  const mgr = new ConfigManager(TEMP_CONF_PATH);
205
271
  const started = await startAdminUiServer({
206
272
  host: "127.0.0.1",
@@ -222,10 +288,14 @@ describe("Admin CLI Config Profile Management Tests", () => {
222
288
  })
223
289
  });
224
290
  try {
225
- await expect(fetch(`${started.url.replace(/\?.*/, "")}api/bootstrap`).then((res) => res.status)).resolves.toBe(401);
226
- const response = await fetch(`${started.url.replace(/\?.*/, "")}api/bootstrap`, {
227
- headers: { "X-TokenBuddy-Ui-Session": started.sessionToken }
291
+ expect(started.url).not.toContain("session=");
292
+ const blocked = await fetch(`${started.url}api/bootstrap`, {
293
+ headers: { Origin: "http://malicious.example" }
228
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`);
229
299
  await expect(response.json()).resolves.toMatchObject({
230
300
  status: "available",
231
301
  registryVersion: 7,
@@ -256,22 +326,19 @@ describe("Admin CLI Config Profile Management Tests", () => {
256
326
  url: "https://bootstrap.example.test",
257
327
  fetchJson: async (url) => createSellerFetchJson(url),
258
328
  commandRunner: async (args): Promise<UiActionResult> => {
259
- if (isBootstrapSellersValidate(args)) {
329
+ if (isBootstrapSellersAdd(args)) {
260
330
  const filePath = args[args.indexOf("--file") + 1];
261
- const doc = JSON.parse(fs.readFileSync(filePath, "utf8")) as { sellers: Array<{ id: string; status?: string }> };
262
- expect(doc.sellers.find((seller) => seller.id === "tbs-nrt-07")?.status).toBe("pending");
331
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8")) as { id: string; status?: string };
332
+ expect(entry).toMatchObject({ id: "tbs-nrt-07", status: "active" });
263
333
  }
264
334
  calls.push(args);
265
335
  return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
266
336
  }
267
337
  });
268
338
  try {
269
- const response = await fetch(`${started.url.replace(/\?.*/, "")}api/sellers`, {
339
+ const response = await fetch(`${started.url}api/sellers`, {
270
340
  method: "POST",
271
- headers: {
272
- "Content-Type": "application/json",
273
- "X-TokenBuddy-Ui-Session": started.sessionToken
274
- },
341
+ headers: { "Content-Type": "application/json" },
275
342
  body: JSON.stringify({
276
343
  sellerName: "tbs-nrt-07",
277
344
  app: "tbs-nrt-07",
@@ -305,9 +372,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
305
372
 
306
373
  let job: any;
307
374
  for (let attempt = 0; attempt < 20; attempt += 1) {
308
- const poll = await fetch(`${started.url.replace(/\?.*/, "")}api/jobs/${created.jobId}`, {
309
- headers: { "X-TokenBuddy-Ui-Session": started.sessionToken }
310
- });
375
+ const poll = await fetch(`${started.url}api/jobs/${created.jobId}`);
311
376
  job = await poll.json();
312
377
  if (job.status !== "running") {
313
378
  break;
@@ -336,9 +401,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
336
401
  expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value status"),
337
402
  expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value seller-config put --file"),
338
403
  expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value upstreams refresh --auto-models"),
339
- expect.stringContaining("bootstrap sellers validate --file"),
340
- expect.stringContaining("bootstrap sellers put --file")
404
+ expect.stringContaining("bootstrap sellers add --file"),
341
405
  ]);
406
+ expect(commandLines[5]).toContain("--expect-version 7");
342
407
  expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
343
408
  } finally {
344
409
  await new Promise<void>((resolve) => started.server.close(() => resolve()));
@@ -373,7 +438,8 @@ describe("Admin CLI Config Profile Management Tests", () => {
373
438
  lastInferenceMs: 700,
374
439
  latencySamples: 2,
375
440
  upstreamStatus: "healthy",
376
- upstreamBalance: "USD 47.95",
441
+ upstreamBalanceUsdMicros: 47_950_000,
442
+ upstreamBalanceCurrency: "USD",
377
443
  upstreamBalanceSource: "openrouter"
378
444
  });
379
445
 
@@ -394,6 +460,130 @@ describe("Admin CLI Config Profile Management Tests", () => {
394
460
  }
395
461
  });
396
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
+
397
587
  test("admin UI seller rows render missing telemetry without unknown data labels", () => {
398
588
  const html = adminUiHtml();
399
589
  expect(html).toContain("class=\"spinner\"");
@@ -407,6 +597,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
407
597
  expect(html).toContain("currentCreateJob");
408
598
  expect(html).toContain("setCreateFormDisabled(true)");
409
599
  expect(html).toContain("setCreateFormDisabled(false)");
600
+ expect(html).toContain("Retry create");
410
601
  expect(html).toContain("data-progress-step");
411
602
  expect(html).toContain("aria-expanded");
412
603
  expect(html).toContain("progress-title");
@@ -414,25 +605,26 @@ describe("Admin CLI Config Profile Management Tests", () => {
414
605
  expect(html).toContain("Hide details");
415
606
  expect(html).toContain("/api/jobs/");
416
607
  expect(html).toContain("function uiErrorMessage");
417
- expect(html).toContain("Admin UI connection lost. Reopen the latest tb-admin UI URL and reload this page.");
418
- expect(html).toContain("Admin UI session expired. Reopen the latest tb-admin UI URL.");
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.");
419
610
  expect(html).toContain("renderCreateJob");
420
611
  expect(html).toContain("pollCreateJob");
421
- expect(html).toContain("Created and published to bootstrap registry.");
612
+ expect(html).toContain("Created and added to bootstrap registry.");
422
613
  expect(html).toContain("upstreamUrl:\"https://openrouter.ai/api/v1\"");
423
614
  expect(html).toContain("loadingSpinner(\"Loading sellers\")");
424
615
  expect(html).toContain("loadingSpinner(\"Loading configuration\")");
425
616
  expect(html).toContain("loadingSpinner(\"Loading models\")");
426
617
  expect(html).toContain("sellerRefreshIntervalMs = 30000");
427
- expect(html).toContain("Last updated: never");
428
618
  expect(html).toContain("function scheduleSellerRefresh()");
429
619
  expect(html).toContain("sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs)");
430
620
  expect(html).toContain("sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs)");
431
621
  expect(html).toContain("Next refresh: ");
432
- expect(html).toContain("s ago");
622
+ expect(html).not.toContain("sellerLastUpdated");
623
+ expect(html).not.toContain("Last updated:");
624
+ expect(html).not.toContain("s ago");
433
625
  expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000)");
434
626
  expect(html).toContain("function renderSellerRows(rows)");
435
- expect(html).toContain("sellerLastUpdatedAt = new Date()");
627
+ expect(html).toContain("sellerRefreshLoaded = true");
436
628
  expect(html).toContain("readonly-value");
437
629
  expect(html).toContain("--seller-grid");
438
630
  expect(html).toContain("grid-template-columns:var(--seller-grid)");
@@ -440,10 +632,25 @@ describe("Admin CLI Config Profile Management Tests", () => {
440
632
  expect(html).toContain("gap:12px;width:100%;min-width:0");
441
633
  expect(html).toContain("detailFieldsHtml");
442
634
  expect(html).toContain("data-original");
443
- expect(html).toContain("Latency");
444
- expect(html).toContain("avg n/a");
445
- expect(html).toContain("Avg infer");
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");
446
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");
447
654
  expect(html).toContain("lastInferenceMs");
448
655
  expect(html).toContain("upstreamBalanceSource");
449
656
  expect(html).toContain("upstreamBalanceFetchedAt");
@@ -452,6 +659,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
452
659
  expect(html).toContain("upstreamBalanceProbeUrl");
453
660
  expect(html).toContain("upstreamBalanceProbeUserId");
454
661
  expect(html).toContain("upstreamBalanceProbeRechargeUrl");
662
+ // Payment tabs
455
663
  expect(html).toContain("paymentMethods");
456
664
  expect(html).toContain("payment-tabs");
457
665
  expect(html).toContain("payment-tab");
@@ -489,7 +697,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
489
697
  expect(html).toContain("clawtipResourceUrl");
490
698
  expect(html).toContain("clawtipActivationFeeFen");
491
699
  expect(html).toContain("clawtipMicrosPerFen");
492
- expect(html).toContain("not reported");
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\"");
493
703
  expect(html).toContain("aria-label=\"Seller specs\"");
494
704
  expect(html).toContain("<svg viewBox=\"0 0 24 24\"");
495
705
  expect(html).not.toContain(">i</span>");
@@ -500,7 +710,6 @@ describe("Admin CLI Config Profile Management Tests", () => {
500
710
  expect(html).not.toContain("Loading models...");
501
711
  expect(html).not.toContain("<div id=\"createStatus\" class=\"status-line\">Ready</div>");
502
712
  expect(html).not.toContain("not set");
503
- expect(html).not.toContain("tok/s");
504
713
  expect(html).not.toContain(" multiple");
505
714
  });
506
715
 
@@ -729,10 +938,10 @@ describe("Admin CLI Config Profile Management Tests", () => {
729
938
  profile: "bootstrap",
730
939
  fetchJson: async (url) => createSellerFetchJson(url),
731
940
  commandRunner: async (args): Promise<UiActionResult> => {
732
- if (isBootstrapSellersValidate(args)) {
941
+ if (isBootstrapSellersAdd(args)) {
733
942
  const filePath = args[args.indexOf("--file") + 1];
734
- const doc = JSON.parse(fs.readFileSync(filePath, "utf8")) as { sellers: Array<{ id: string; status?: string }> };
735
- expect(doc.sellers.find((seller) => seller.id === "tbs-nrt-07")?.status).toBe("pending");
943
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8")) as { id: string; status?: string };
944
+ expect(entry).toMatchObject({ id: "tbs-nrt-07", status: "active" });
736
945
  }
737
946
  calls.push(args);
738
947
  return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
@@ -772,6 +981,8 @@ describe("Admin CLI Config Profile Management Tests", () => {
772
981
  expect(response.registryPublish?.ok).toBe(true);
773
982
  expect(response.publishRegistry).toBe("completed");
774
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");
775
986
  expect(response.configPreview.upstreamBalanceProbe).toEqual({
776
987
  template: "usage_generic",
777
988
  url: "https://code.shoestravel.xin/v1/usage",
@@ -798,13 +1009,153 @@ describe("Admin CLI Config Profile Management Tests", () => {
798
1009
  expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret status"),
799
1010
  expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret seller-config put --file"),
800
1011
  expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret upstreams refresh --auto-models"),
801
- expect.stringContaining("bootstrap sellers validate --file"),
802
- expect.stringContaining("bootstrap sellers put --file")
1012
+ expect.stringContaining("bootstrap sellers add --file")
803
1013
  ]);
1014
+ expect(commandLines[5]).toContain("--expect-version 7");
804
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);
805
1082
  } finally {
806
1083
  await fixture.close();
807
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
+ });
808
1159
  });
809
1160
 
810
1161
  test("UiActions create supports mock-only payment without ClawTip parameters", async () => {
@@ -879,12 +1230,13 @@ describe("Admin CLI Config Profile Management Tests", () => {
879
1230
 
880
1231
  test("UiActions subprocess runner uses the tb-admin bin entrypoint", async () => {
881
1232
  const previousArgv = process.argv[1];
882
- process.argv[1] = path.resolve(__dirname, "../dist/src/index.js");
1233
+ process.argv[1] = path.resolve(process.env.HOME || "/tmp", "packages/admin-cli/bin/tb-admin.js");
883
1234
  const { runTbAdmin } = await import("../src/ui-actions.js");
884
1235
  try {
885
1236
  const result = await runTbAdmin(["--help"], 30000);
886
1237
  expect(result.ok).toBe(true);
887
1238
  expect(result.command[1]).toMatch(/packages\/admin-cli\/bin\/tb-admin\.js$/);
1239
+ expect(fs.existsSync(result.command[1])).toBe(true);
888
1240
  expect(result.stdout).toContain("Remote admin CLI");
889
1241
  } finally {
890
1242
  process.argv[1] = previousArgv;
@@ -892,6 +1244,127 @@ describe("Admin CLI Config Profile Management Tests", () => {
892
1244
  });
893
1245
  });
894
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
+ });
1366
+ });
1367
+
895
1368
  async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
896
1369
  const server = http.createServer((req, res) => {
897
1370
  const pathName = new URL(req.url || "/", "http://127.0.0.1").pathname;
@@ -990,7 +1463,27 @@ function sendJson(res: http.ServerResponse, body: unknown): void {
990
1463
  res.end(JSON.stringify(body));
991
1464
  }
992
1465
 
993
- async function createSellerFetchJson(url: string): Promise<unknown> {
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> {
994
1487
  const pathName = new URL(url).pathname;
995
1488
  if (pathName === "/registry/sellers") {
996
1489
  return {
@@ -999,6 +1492,38 @@ async function createSellerFetchJson(url: string): Promise<unknown> {
999
1492
  };
1000
1493
  }
1001
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
+ }
1002
1527
  return {
1003
1528
  upstreamUrl: "https://openrouter.ai/api",
1004
1529
  models: [
@@ -1015,12 +1540,19 @@ async function createSellerFetchJson(url: string): Promise<unknown> {
1015
1540
  supportedProtocols: ["chat_completions"]
1016
1541
  };
1017
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
+ }
1018
1550
  throw new Error(`unexpected fetch url ${url}`);
1019
1551
  }
1020
1552
 
1021
- function isBootstrapSellersValidate(args: string[]): boolean {
1022
- const validateIndex = args.indexOf("validate");
1023
- return validateIndex >= 2 && args[validateIndex - 2] === "bootstrap" && args[validateIndex - 1] === "sellers";
1553
+ function isBootstrapSellersAdd(args: string[]): boolean {
1554
+ const addIndex = args.indexOf("add");
1555
+ return addIndex >= 2 && args[addIndex - 2] === "bootstrap" && args[addIndex - 1] === "sellers";
1024
1556
  }
1025
1557
 
1026
1558
  function jsonResponse(body: unknown, status = 200): Response {