@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.
- package/dist/src/bootstrap-registry.d.ts +1 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -1
- package/dist/src/bootstrap-registry.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +294 -13
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +12 -3
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +12 -8
- package/dist/src/client.js.map +1 -1
- package/dist/src/display-format.d.ts +39 -0
- package/dist/src/display-format.d.ts.map +1 -0
- package/dist/src/display-format.js +354 -0
- package/dist/src/display-format.js.map +1 -0
- package/dist/src/server-cmd.d.ts +25 -1
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +116 -16
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +90 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +823 -0
- package/dist/src/ui-actions.js.map +1 -0
- package/dist/src/ui-command.d.ts +4 -0
- package/dist/src/ui-command.d.ts.map +1 -0
- package/dist/src/ui-command.js +37 -0
- package/dist/src/ui-command.js.map +1 -0
- package/dist/src/ui-server.d.ts +22 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +261 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +140 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +438 -0
- package/dist/src/ui-state.js.map +1 -0
- package/dist/src/ui-static.d.ts +2 -0
- package/dist/src/ui-static.d.ts.map +1 -0
- package/dist/src/ui-static.js +469 -0
- package/dist/src/ui-static.js.map +1 -0
- package/dist/src/upstream-balance-probe.d.ts +41 -0
- package/dist/src/upstream-balance-probe.d.ts.map +1 -0
- package/dist/src/upstream-balance-probe.js +379 -0
- package/dist/src/upstream-balance-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/bootstrap-registry.ts +1 -0
- package/src/cli.ts +335 -13
- package/src/client.ts +13 -8
- package/src/display-format.ts +398 -0
- package/src/server-cmd.ts +145 -20
- package/src/ui-actions.ts +958 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +322 -0
- package/src/ui-state.ts +614 -0
- package/src/ui-static.ts +472 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +1404 -2
package/tests/admin.test.ts
CHANGED
|
@@ -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([
|
|
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
|
+
}
|