@tokenbuddy/tb-admin 1.0.14 → 1.0.15
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 +8 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/server-cmd.d.ts +22 -1
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +93 -16
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +88 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +763 -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 +23 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +245 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +134 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +407 -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 +144 -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 +9 -0
- package/src/server-cmd.ts +118 -19
- package/src/ui-actions.ts +901 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +308 -0
- package/src/ui-state.ts +575 -0
- package/src/ui-static.ts +144 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +871 -1
package/tests/admin.test.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
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 UiActionResult } from "../src/ui-actions.js";
|
|
7
|
+
import { adminUiHtml } from "../src/ui-static.js";
|
|
8
|
+
import {
|
|
9
|
+
BalanceProbeCache,
|
|
10
|
+
probeUpstreamBalance
|
|
11
|
+
} from "../src/upstream-balance-probe.js";
|
|
4
12
|
import {
|
|
5
13
|
validateRegistryDocument
|
|
6
14
|
} from "../src/bootstrap-registry.js";
|
|
7
15
|
import * as fs from "fs";
|
|
16
|
+
import * as http from "http";
|
|
8
17
|
import * as path from "path";
|
|
9
18
|
|
|
10
19
|
const TEMP_CONF_PATH = path.resolve(__dirname, "../../data-test/admin-config.json");
|
|
@@ -68,6 +77,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
68
77
|
const payments = program.commands.find((command) => command.name() === "payments");
|
|
69
78
|
const upstreams = program.commands.find((command) => command.name() === "upstreams");
|
|
70
79
|
const models = program.commands.find((command) => command.name() === "models");
|
|
80
|
+
const ui = program.commands.find((command) => command.name() === "ui");
|
|
71
81
|
|
|
72
82
|
expect(bootstrap).toBeDefined();
|
|
73
83
|
expect(sellers).toBeDefined();
|
|
@@ -79,6 +89,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
79
89
|
expect(sellerConfig?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
|
|
80
90
|
expect(sellerConfig?.commands.find((command) => command.name() === "validate")?.options.some((option) => option.long === "--file")).toBe(true);
|
|
81
91
|
expect(upstreams).toBeDefined();
|
|
92
|
+
expect(ui).toBeDefined();
|
|
93
|
+
expect(ui?.options.find((option) => option.long === "--host")?.defaultValue).toBe("127.0.0.1");
|
|
94
|
+
expect(ui?.options.find((option) => option.long === "--port")?.defaultValue).toBe(17822);
|
|
82
95
|
expect(models).toBeDefined();
|
|
83
96
|
expect(models?.options.some((option) => option.long === "--json")).toBe(true);
|
|
84
97
|
expect(upstreams?.commands.map((command) => command.name()).sort()).toEqual(["get", "refresh", "update"]);
|
|
@@ -103,6 +116,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
103
116
|
expect(deploy).toBeDefined();
|
|
104
117
|
expect(create?.options.some((option) => option.long === "--fly-config" && option.required)).toBe(true);
|
|
105
118
|
expect(create?.options.some((option) => option.long === "--image" && option.required)).toBe(true);
|
|
119
|
+
expect(create?.options.some((option) => option.long === "--initial-config")).toBe(true);
|
|
106
120
|
expect(create?.options.some((option) => option.long === "--config")).toBe(false);
|
|
107
121
|
expect(deploy?.options.some((option) => option.long === "--fly-config")).toBe(false);
|
|
108
122
|
expect(deploy?.options.some((option) => option.long === "--image" && option.required)).toBe(true);
|
|
@@ -129,6 +143,33 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
129
143
|
})).toContain("Volumes: unchanged");
|
|
130
144
|
});
|
|
131
145
|
|
|
146
|
+
test("seller create checks the published image before creating Fly resources", () => {
|
|
147
|
+
expect(() => requirePublishedDockerImage("registry.fly.io/tb-seller:missing", () => ({
|
|
148
|
+
ok: false,
|
|
149
|
+
error: "not found"
|
|
150
|
+
}))).toThrow("No Fly app was created");
|
|
151
|
+
|
|
152
|
+
const commands: string[] = [];
|
|
153
|
+
const provider = new FlyProvider(undefined, {
|
|
154
|
+
checkFlyctlInstalled: () => true,
|
|
155
|
+
imageInspector: () => ({ ok: false, error: "not found" }),
|
|
156
|
+
execSync: (command) => {
|
|
157
|
+
commands.push(command);
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(() => provider.createSeller({
|
|
163
|
+
name: "tbs-test",
|
|
164
|
+
app: "tbs-test",
|
|
165
|
+
image: "registry.fly.io/tb-seller:missing",
|
|
166
|
+
flyConfig: "deploy/fly.io/fly.tb-seller.toml",
|
|
167
|
+
operatorSecret: "operator-secret"
|
|
168
|
+
})).toThrow("seller image is not published or is not accessible");
|
|
169
|
+
|
|
170
|
+
expect(commands).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
132
173
|
test("parseFlyMachineIds reads machine ids and rejects unusable machine lists", () => {
|
|
133
174
|
expect(parseFlyMachineIds(JSON.stringify([
|
|
134
175
|
{ id: "machine-1" },
|
|
@@ -158,4 +199,833 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
158
199
|
badDefault.defaultSeller = "missing";
|
|
159
200
|
expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
|
|
160
201
|
});
|
|
202
|
+
|
|
203
|
+
test("tb-admin ui server binds loopback, protects APIs with a session, and serves bootstrap data", async () => {
|
|
204
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
205
|
+
const started = await startAdminUiServer({
|
|
206
|
+
host: "127.0.0.1",
|
|
207
|
+
port: 0,
|
|
208
|
+
openBrowser: false,
|
|
209
|
+
configManager: mgr,
|
|
210
|
+
url: "https://bootstrap.example.test",
|
|
211
|
+
fetchJson: async () => ({
|
|
212
|
+
version: 7,
|
|
213
|
+
sellers: [{
|
|
214
|
+
id: "tbs-sin-06",
|
|
215
|
+
name: "tbs-sin-06",
|
|
216
|
+
url: "https://seller.example.test",
|
|
217
|
+
status: "active",
|
|
218
|
+
region: "sin",
|
|
219
|
+
supportedProtocols: ["responses"],
|
|
220
|
+
paymentMethods: ["clawtip"]
|
|
221
|
+
}]
|
|
222
|
+
})
|
|
223
|
+
});
|
|
224
|
+
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 }
|
|
228
|
+
});
|
|
229
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
230
|
+
status: "available",
|
|
231
|
+
registryVersion: 7,
|
|
232
|
+
sellerEntries: 1,
|
|
233
|
+
regions: ["sin"]
|
|
234
|
+
});
|
|
235
|
+
} finally {
|
|
236
|
+
await new Promise<void>((resolve) => started.server.close(() => resolve()));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await expect(startAdminUiServer({
|
|
240
|
+
host: "0.0.0.0",
|
|
241
|
+
port: 0,
|
|
242
|
+
openBrowser: false,
|
|
243
|
+
configManager: mgr
|
|
244
|
+
})).rejects.toThrow("loopback");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("tb-admin ui create seller returns a progress job", async () => {
|
|
248
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
249
|
+
mgr.setSellerProvider("fly", { operator_secret: "operator-token-value" });
|
|
250
|
+
const calls: string[][] = [];
|
|
251
|
+
const started = await startAdminUiServer({
|
|
252
|
+
host: "127.0.0.1",
|
|
253
|
+
port: 0,
|
|
254
|
+
openBrowser: false,
|
|
255
|
+
configManager: mgr,
|
|
256
|
+
url: "https://bootstrap.example.test",
|
|
257
|
+
fetchJson: async (url) => createSellerFetchJson(url),
|
|
258
|
+
commandRunner: async (args): Promise<UiActionResult> => {
|
|
259
|
+
if (isBootstrapSellersValidate(args)) {
|
|
260
|
+
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");
|
|
263
|
+
}
|
|
264
|
+
calls.push(args);
|
|
265
|
+
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
const response = await fetch(`${started.url.replace(/\?.*/, "")}api/sellers`, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: {
|
|
272
|
+
"Content-Type": "application/json",
|
|
273
|
+
"X-TokenBuddy-Ui-Session": started.sessionToken
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
sellerName: "tbs-nrt-07",
|
|
277
|
+
app: "tbs-nrt-07",
|
|
278
|
+
region: "nrt",
|
|
279
|
+
image: "registry.fly.io/tb-seller:latest",
|
|
280
|
+
upstreamWebsite: "https://openrouter.ai",
|
|
281
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
282
|
+
upstreamApiKey: "fixture-upstream-key",
|
|
283
|
+
upstreamBalanceProbeTemplate: "openrouter",
|
|
284
|
+
upstreamBalanceProbeUrl: "https://openrouter.ai/api/v1/credits",
|
|
285
|
+
upstreamBalanceProbeRechargeUrl: "https://openrouter.ai/settings/credits",
|
|
286
|
+
maxConnections: 8,
|
|
287
|
+
maxQueueDepth: 4,
|
|
288
|
+
markupRatio: 1.2,
|
|
289
|
+
discountRatio: 1,
|
|
290
|
+
paymentMethods: ["clawtip", "mock"],
|
|
291
|
+
clawtipPayTo: "pay-to-seller",
|
|
292
|
+
clawtipSm4KeyBase64: "0123456789abcdef012345==",
|
|
293
|
+
clawtipSkillSlug: "tokenbuddy-seller",
|
|
294
|
+
clawtipSkillId: "si-tokenbuddy-seller",
|
|
295
|
+
clawtipDescription: "TokenBuddy Seller",
|
|
296
|
+
clawtipResourceUrl: "https://tbs-nrt-07.fly.dev",
|
|
297
|
+
clawtipActivationFeeFen: 1,
|
|
298
|
+
clawtipMicrosPerFen: 10000,
|
|
299
|
+
flyConfig: "deploy/fly.io/fly.tb-seller.toml"
|
|
300
|
+
})
|
|
301
|
+
});
|
|
302
|
+
expect(response.status).toBe(202);
|
|
303
|
+
const created = await response.json() as { jobId: string };
|
|
304
|
+
expect(created.jobId).toBeTruthy();
|
|
305
|
+
|
|
306
|
+
let job: any;
|
|
307
|
+
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
|
+
});
|
|
311
|
+
job = await poll.json();
|
|
312
|
+
if (job.status !== "running") {
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
expect(job.status).toBe("succeeded");
|
|
319
|
+
expect(job.events.map((event: any) => event.stepId)).toEqual([
|
|
320
|
+
"check_registry",
|
|
321
|
+
"validate_config",
|
|
322
|
+
"create_deployment",
|
|
323
|
+
"wait_seller",
|
|
324
|
+
"apply_config",
|
|
325
|
+
"refresh_models",
|
|
326
|
+
"publish_registry"
|
|
327
|
+
]);
|
|
328
|
+
expect(job.events.find((event: any) => event.stepId === "validate_config").message).toContain("https://openrouter.ai/api");
|
|
329
|
+
expect(JSON.stringify(job)).not.toContain("fixture-upstream-key");
|
|
330
|
+
expect(JSON.stringify(job)).not.toContain("0123456789abcdef012345==");
|
|
331
|
+
expect(JSON.stringify(job)).not.toContain("operator-token-value");
|
|
332
|
+
const commandLines = calls.map((args) => args.join(" "));
|
|
333
|
+
expect(commandLines).toEqual([
|
|
334
|
+
expect.stringContaining("seller-config validate --file"),
|
|
335
|
+
expect.stringContaining("seller create tbs-nrt-07"),
|
|
336
|
+
expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value status"),
|
|
337
|
+
expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value seller-config put --file"),
|
|
338
|
+
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")
|
|
341
|
+
]);
|
|
342
|
+
expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
|
|
343
|
+
} finally {
|
|
344
|
+
await new Promise<void>((resolve) => started.server.close(() => resolve()));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("AdminUiState reads seller list from bootstrap registry and masks seller detail API keys", async () => {
|
|
349
|
+
const fixture = await startFixtureAdminServer();
|
|
350
|
+
try {
|
|
351
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
352
|
+
mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
|
|
353
|
+
mgr.setProfile("seller-sin", { url: fixture.baseUrl, token: "seller-token" });
|
|
354
|
+
|
|
355
|
+
const state = new AdminUiState({
|
|
356
|
+
configManager: mgr,
|
|
357
|
+
profile: "bootstrap",
|
|
358
|
+
balanceFetch: async (url, init) => {
|
|
359
|
+
expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
|
|
360
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-live-key-27f9");
|
|
361
|
+
return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
const sellers = await state.sellers();
|
|
365
|
+
expect(sellers).toHaveLength(1);
|
|
366
|
+
expect(sellers[0]).toMatchObject({
|
|
367
|
+
id: "tbs-sin-06",
|
|
368
|
+
upstreamDomain: "openrouter.ai",
|
|
369
|
+
capacityUsed: 3,
|
|
370
|
+
capacityLimit: 8,
|
|
371
|
+
ttftMs: 321,
|
|
372
|
+
avgInferenceMs: 640,
|
|
373
|
+
lastInferenceMs: 700,
|
|
374
|
+
latencySamples: 2,
|
|
375
|
+
upstreamStatus: "healthy",
|
|
376
|
+
upstreamBalance: "USD 47.95",
|
|
377
|
+
upstreamBalanceSource: "openrouter"
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const detail = await state.sellerDetail("tbs-sin-06");
|
|
381
|
+
expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** 27f9");
|
|
382
|
+
expect(detail.configuration.upstreamBalance).toBe("USD 47.95");
|
|
383
|
+
expect(detail.configuration.upstreamBalanceSource).toBe("openrouter");
|
|
384
|
+
expect(detail.configuration.upstreamBalanceProbeTemplate).toBe("openrouter");
|
|
385
|
+
expect(detail.configuration.upstreamBalanceProbeUrl).toBe("https://openrouter.ai/api/v1/credits");
|
|
386
|
+
expect(detail.configuration.upstreamBalanceProbeRechargeUrl).toBe("https://openrouter.ai/settings/credits");
|
|
387
|
+
expect(JSON.stringify(detail)).not.toContain("fixture-live-key");
|
|
388
|
+
expect(detail.models[0]).toMatchObject({
|
|
389
|
+
upstreamModel: "openai/gpt-5.4",
|
|
390
|
+
billingModel: "gpt-5.4"
|
|
391
|
+
});
|
|
392
|
+
} finally {
|
|
393
|
+
await fixture.close();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("admin UI seller rows render missing telemetry without unknown data labels", () => {
|
|
398
|
+
const html = adminUiHtml();
|
|
399
|
+
expect(html).toContain("class=\"spinner\"");
|
|
400
|
+
expect(html).toContain("@keyframes spin");
|
|
401
|
+
expect(html).toContain("role=\"status\" aria-label=\"Loading sellers\"");
|
|
402
|
+
expect(html).toContain("id=\"createStatus\" class=\"status-line hidden\"");
|
|
403
|
+
expect(html).toContain("id=\"createProgress\"");
|
|
404
|
+
expect(html).toContain("create-progress");
|
|
405
|
+
expect(html).toContain("progress-step");
|
|
406
|
+
expect(html).toContain("progress-log");
|
|
407
|
+
expect(html).toContain("currentCreateJob");
|
|
408
|
+
expect(html).toContain("setCreateFormDisabled(true)");
|
|
409
|
+
expect(html).toContain("setCreateFormDisabled(false)");
|
|
410
|
+
expect(html).toContain("data-progress-step");
|
|
411
|
+
expect(html).toContain("aria-expanded");
|
|
412
|
+
expect(html).toContain("progress-title");
|
|
413
|
+
expect(html).toContain("Show details");
|
|
414
|
+
expect(html).toContain("Hide details");
|
|
415
|
+
expect(html).toContain("/api/jobs/");
|
|
416
|
+
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.");
|
|
419
|
+
expect(html).toContain("renderCreateJob");
|
|
420
|
+
expect(html).toContain("pollCreateJob");
|
|
421
|
+
expect(html).toContain("Created and published to bootstrap registry.");
|
|
422
|
+
expect(html).toContain("upstreamUrl:\"https://openrouter.ai/api/v1\"");
|
|
423
|
+
expect(html).toContain("loadingSpinner(\"Loading sellers\")");
|
|
424
|
+
expect(html).toContain("loadingSpinner(\"Loading configuration\")");
|
|
425
|
+
expect(html).toContain("loadingSpinner(\"Loading models\")");
|
|
426
|
+
expect(html).toContain("sellerRefreshIntervalMs = 30000");
|
|
427
|
+
expect(html).toContain("Last updated: never");
|
|
428
|
+
expect(html).toContain("function scheduleSellerRefresh()");
|
|
429
|
+
expect(html).toContain("sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs)");
|
|
430
|
+
expect(html).toContain("sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs)");
|
|
431
|
+
expect(html).toContain("Next refresh: ");
|
|
432
|
+
expect(html).toContain("s ago");
|
|
433
|
+
expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000)");
|
|
434
|
+
expect(html).toContain("function renderSellerRows(rows)");
|
|
435
|
+
expect(html).toContain("sellerLastUpdatedAt = new Date()");
|
|
436
|
+
expect(html).toContain("readonly-value");
|
|
437
|
+
expect(html).toContain("--seller-grid");
|
|
438
|
+
expect(html).toContain("grid-template-columns:var(--seller-grid)");
|
|
439
|
+
expect(html).toContain("#sellerRows{display:grid;gap:10px;width:100%;min-width:0}");
|
|
440
|
+
expect(html).toContain("gap:12px;width:100%;min-width:0");
|
|
441
|
+
expect(html).toContain("detailFieldsHtml");
|
|
442
|
+
expect(html).toContain("data-original");
|
|
443
|
+
expect(html).toContain("Latency");
|
|
444
|
+
expect(html).toContain("avg n/a");
|
|
445
|
+
expect(html).toContain("Avg infer");
|
|
446
|
+
expect(html).toContain("Samples");
|
|
447
|
+
expect(html).toContain("lastInferenceMs");
|
|
448
|
+
expect(html).toContain("upstreamBalanceSource");
|
|
449
|
+
expect(html).toContain("upstreamBalanceFetchedAt");
|
|
450
|
+
expect(html).toContain("upstreamBalanceError");
|
|
451
|
+
expect(html).toContain("upstreamBalanceProbeTemplate");
|
|
452
|
+
expect(html).toContain("upstreamBalanceProbeUrl");
|
|
453
|
+
expect(html).toContain("upstreamBalanceProbeUserId");
|
|
454
|
+
expect(html).toContain("upstreamBalanceProbeRechargeUrl");
|
|
455
|
+
expect(html).toContain("paymentMethods");
|
|
456
|
+
expect(html).toContain("payment-tabs");
|
|
457
|
+
expect(html).toContain("payment-tab");
|
|
458
|
+
expect(html).toContain("payment-panel");
|
|
459
|
+
expect(html).toContain("pill-switch");
|
|
460
|
+
expect(html).toContain("data-payment-tab");
|
|
461
|
+
expect(html).toContain("data-payment-panel");
|
|
462
|
+
expect(html).toContain("data-payment-toggle");
|
|
463
|
+
expect(html).toContain("enabledPaymentMethods()");
|
|
464
|
+
expect(html).toContain("setupPaymentTabs()");
|
|
465
|
+
expect(html).toContain("togglePaymentMethod");
|
|
466
|
+
expect(html).toContain("selectPaymentTab");
|
|
467
|
+
expect(html).toContain("updatePaymentPanels");
|
|
468
|
+
expect(html).toContain("data-enabled");
|
|
469
|
+
expect(html).toContain("aria-pressed");
|
|
470
|
+
expect(html).toContain("启用即可使用 mock 支付方式,无需额外参数");
|
|
471
|
+
expect(html).toContain("required-star");
|
|
472
|
+
expect(html).toContain("Seller name");
|
|
473
|
+
expect(html).toContain("tbs-<seller-name>-<random>");
|
|
474
|
+
expect(html).toContain("Fly app name");
|
|
475
|
+
expect(html).toContain("Seller image");
|
|
476
|
+
expect(html).toContain("Fly config file");
|
|
477
|
+
expect(html).toContain("Balance probe URL (auto from template)");
|
|
478
|
+
expect(html).toContain("Recharge URL");
|
|
479
|
+
expect(html).toContain("randomAppSuffix");
|
|
480
|
+
expect(html).toContain("updateGeneratedCreateFields");
|
|
481
|
+
expect(html).toContain("appNameFromSellerName");
|
|
482
|
+
expect(html).toContain("balanceProbeUrlForTemplate");
|
|
483
|
+
expect(html).toContain("data-generated-summary=\"clawtip\"");
|
|
484
|
+
expect(html).toContain("clawtipPayTo");
|
|
485
|
+
expect(html).toContain("clawtipSm4KeyBase64");
|
|
486
|
+
expect(html).toContain("clawtipSkillSlug");
|
|
487
|
+
expect(html).toContain("clawtipSkillId");
|
|
488
|
+
expect(html).toContain("clawtipDescription");
|
|
489
|
+
expect(html).toContain("clawtipResourceUrl");
|
|
490
|
+
expect(html).toContain("clawtipActivationFeeFen");
|
|
491
|
+
expect(html).toContain("clawtipMicrosPerFen");
|
|
492
|
+
expect(html).toContain("not reported");
|
|
493
|
+
expect(html).toContain("aria-label=\"Seller specs\"");
|
|
494
|
+
expect(html).toContain("<svg viewBox=\"0 0 24 24\"");
|
|
495
|
+
expect(html).not.toContain(">i</span>");
|
|
496
|
+
expect(html).not.toContain(">×</button>");
|
|
497
|
+
expect(html).not.toContain("Loading sellers...");
|
|
498
|
+
expect(html).not.toContain("Loading seller data...");
|
|
499
|
+
expect(html).not.toContain("Loading configuration...");
|
|
500
|
+
expect(html).not.toContain("Loading models...");
|
|
501
|
+
expect(html).not.toContain("<div id=\"createStatus\" class=\"status-line\">Ready</div>");
|
|
502
|
+
expect(html).not.toContain("not set");
|
|
503
|
+
expect(html).not.toContain("tok/s");
|
|
504
|
+
expect(html).not.toContain(" multiple");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("UiActions validates then puts seller config without shell command strings", async () => {
|
|
508
|
+
const fixture = await startFixtureAdminServer();
|
|
509
|
+
try {
|
|
510
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
511
|
+
mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
|
|
512
|
+
mgr.setProfile("seller-sin", { url: fixture.baseUrl, token: "seller-token" });
|
|
513
|
+
const calls: string[][] = [];
|
|
514
|
+
const actions = new UiActions({
|
|
515
|
+
configManager: mgr,
|
|
516
|
+
profile: "bootstrap",
|
|
517
|
+
commandRunner: async (args): Promise<UiActionResult> => {
|
|
518
|
+
calls.push(args);
|
|
519
|
+
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const result = await actions.updateSellerConfig("tbs-sin-06", {
|
|
524
|
+
markupRatio: 1.4,
|
|
525
|
+
upstreamApiKey: "new-secret"
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(result.ok).toBe(true);
|
|
529
|
+
expect(calls).toHaveLength(2);
|
|
530
|
+
expect(calls[0]).toContain("validate");
|
|
531
|
+
expect(calls[1]).toContain("put");
|
|
532
|
+
expect(calls.flat()).not.toContain("seller-config validate");
|
|
533
|
+
expect(calls[0]).toContain("--profile");
|
|
534
|
+
expect(calls[0]).toContain("seller-sin");
|
|
535
|
+
} finally {
|
|
536
|
+
await fixture.close();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("probeUpstreamBalance parses OpenRouter balance and caches failed probes", async () => {
|
|
541
|
+
const cache = new BalanceProbeCache();
|
|
542
|
+
let calls = 0;
|
|
543
|
+
const openRouter = await probeUpstreamBalance({
|
|
544
|
+
upstreamUrl: "https://openrouter.ai/api",
|
|
545
|
+
upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
|
|
546
|
+
upstreamApiKey: "fixture-key"
|
|
547
|
+
}, {
|
|
548
|
+
now: () => 1000,
|
|
549
|
+
cache,
|
|
550
|
+
fetch: async (url, init) => {
|
|
551
|
+
calls += 1;
|
|
552
|
+
expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
|
|
553
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
|
|
554
|
+
return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(openRouter).toMatchObject({
|
|
559
|
+
source: "openrouter",
|
|
560
|
+
rawAmount: 47.95,
|
|
561
|
+
amountUsdMicros: 47950000,
|
|
562
|
+
currency: "USD"
|
|
563
|
+
});
|
|
564
|
+
expect(calls).toBe(1);
|
|
565
|
+
|
|
566
|
+
const firstFailure = await probeUpstreamBalance({
|
|
567
|
+
upstreamUrl: "https://custom-upstream.example/v1",
|
|
568
|
+
upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
|
|
569
|
+
upstreamApiKey: "fixture-key"
|
|
570
|
+
}, {
|
|
571
|
+
now: () => 2000,
|
|
572
|
+
cache,
|
|
573
|
+
fetch: async () => {
|
|
574
|
+
calls += 1;
|
|
575
|
+
return jsonResponse({ success: false });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
const cachedFailure = await probeUpstreamBalance({
|
|
579
|
+
upstreamUrl: "https://custom-upstream.example/v1",
|
|
580
|
+
upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
|
|
581
|
+
upstreamApiKey: "fixture-key"
|
|
582
|
+
}, {
|
|
583
|
+
now: () => 3000,
|
|
584
|
+
cache,
|
|
585
|
+
fetch: async () => {
|
|
586
|
+
throw new Error("cache miss");
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(firstFailure.error?.message).toBe("missing upstreamUserId for newapi upstream");
|
|
591
|
+
expect(cachedFailure).toBe(firstFailure);
|
|
592
|
+
expect(calls).toBe(1);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("probeUpstreamBalance sends New-Api-User for generic newapi balances", async () => {
|
|
596
|
+
const snapshot = await probeUpstreamBalance({
|
|
597
|
+
upstreamUrl: "https://custom-upstream.example/v1",
|
|
598
|
+
upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
|
|
599
|
+
upstreamApiKey: "fixture-key",
|
|
600
|
+
upstreamUserId: "12345"
|
|
601
|
+
}, {
|
|
602
|
+
now: () => 4000,
|
|
603
|
+
fetch: async (url, init) => {
|
|
604
|
+
expect(String(url)).toBe("https://custom-upstream.example/api/user/quota");
|
|
605
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
|
|
606
|
+
expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBe("12345");
|
|
607
|
+
return jsonResponse({ success: true, data: { quota: 50000000, used_quota: 1250000 } });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(snapshot).toMatchObject({
|
|
612
|
+
source: "newapi_generic",
|
|
613
|
+
rawAmount: 97.5,
|
|
614
|
+
amountUsdMicros: 97500000,
|
|
615
|
+
currency: "USD"
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("probeUpstreamBalance parses generic /v1/usage balances without user id", async () => {
|
|
620
|
+
const snapshot = await probeUpstreamBalance({
|
|
621
|
+
upstreamUrl: "https://code.shoestravel.xin",
|
|
622
|
+
upstreamApiKey: "fixture-key"
|
|
623
|
+
}, {
|
|
624
|
+
now: () => 5000,
|
|
625
|
+
fetch: async (url, init) => {
|
|
626
|
+
expect(String(url)).toBe("https://code.shoestravel.xin/v1/usage");
|
|
627
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
|
|
628
|
+
expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBeUndefined();
|
|
629
|
+
return jsonResponse({
|
|
630
|
+
balance: 37.96221984,
|
|
631
|
+
isValid: true,
|
|
632
|
+
remaining: 37.96221984,
|
|
633
|
+
unit: "USD"
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
expect(snapshot).toMatchObject({
|
|
639
|
+
source: "usage_generic",
|
|
640
|
+
rawAmount: 37.96221984,
|
|
641
|
+
amountUsdMicros: 37962220,
|
|
642
|
+
currency: "USD"
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("probeUpstreamBalance honors explicit balance probe templates", async () => {
|
|
647
|
+
const newApi = await probeUpstreamBalance({
|
|
648
|
+
upstreamUrl: "https://custom-upstream.example/v1",
|
|
649
|
+
upstreamApiKey: "fixture-key",
|
|
650
|
+
upstreamBalanceProbe: {
|
|
651
|
+
template: "newapi_generic",
|
|
652
|
+
url: "https://custom-upstream.example/api/user/self",
|
|
653
|
+
userId: "67890"
|
|
654
|
+
}
|
|
655
|
+
}, {
|
|
656
|
+
now: () => 7000,
|
|
657
|
+
fetch: async (url, init) => {
|
|
658
|
+
expect(String(url)).toBe("https://custom-upstream.example/api/user/self");
|
|
659
|
+
expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBe("67890");
|
|
660
|
+
return jsonResponse({ success: true, data: { quota: 1000000, used_quota: 500000 } });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
expect(newApi).toMatchObject({
|
|
665
|
+
source: "newapi_generic",
|
|
666
|
+
rawAmount: 1,
|
|
667
|
+
amountUsdMicros: 1000000,
|
|
668
|
+
currency: "USD"
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const usage = await probeUpstreamBalance({
|
|
672
|
+
upstreamUrl: "https://code.shoestravel.xin/v1",
|
|
673
|
+
upstreamApiKey: "fixture-key",
|
|
674
|
+
upstreamBalanceProbe: {
|
|
675
|
+
template: "usage_generic"
|
|
676
|
+
}
|
|
677
|
+
}, {
|
|
678
|
+
now: () => 8000,
|
|
679
|
+
fetch: async (url) => {
|
|
680
|
+
expect(String(url)).toBe("https://code.shoestravel.xin/v1/usage");
|
|
681
|
+
return jsonResponse({ remaining: 37.96221984, unit: "USD", isValid: true });
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
expect(usage).toMatchObject({
|
|
686
|
+
source: "usage_generic",
|
|
687
|
+
rawAmount: 37.96221984,
|
|
688
|
+
amountUsdMicros: 37962220,
|
|
689
|
+
currency: "USD"
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("probeUpstreamBalance reports inactive generic /v1/usage keys", async () => {
|
|
694
|
+
const snapshot = await probeUpstreamBalance({
|
|
695
|
+
upstreamUrl: "https://custom-upstream.example/v1",
|
|
696
|
+
upstreamBalanceUrl: "https://custom-upstream.example/v1/usage",
|
|
697
|
+
upstreamApiKey: "fixture-key"
|
|
698
|
+
}, {
|
|
699
|
+
now: () => 6000,
|
|
700
|
+
fetch: async () => jsonResponse({
|
|
701
|
+
remaining: 12.5,
|
|
702
|
+
unit: "USD",
|
|
703
|
+
isValid: false
|
|
704
|
+
})
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
expect(snapshot).toMatchObject({
|
|
708
|
+
source: "usage_generic",
|
|
709
|
+
rawAmount: 12.5,
|
|
710
|
+
amountUsdMicros: 12500000,
|
|
711
|
+
currency: "USD",
|
|
712
|
+
error: {
|
|
713
|
+
httpStatus: 200,
|
|
714
|
+
message: "upstream key is not active"
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("UiActions create validates, creates, then applies initial seller config", async () => {
|
|
720
|
+
const fixture = await startFixtureAdminServer();
|
|
721
|
+
try {
|
|
722
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
723
|
+
mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
|
|
724
|
+
mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
|
|
725
|
+
const calls: string[][] = [];
|
|
726
|
+
const actions = new UiActions({
|
|
727
|
+
configManager: mgr,
|
|
728
|
+
configPath: TEMP_CONF_PATH,
|
|
729
|
+
profile: "bootstrap",
|
|
730
|
+
fetchJson: async (url) => createSellerFetchJson(url),
|
|
731
|
+
commandRunner: async (args): Promise<UiActionResult> => {
|
|
732
|
+
if (isBootstrapSellersValidate(args)) {
|
|
733
|
+
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");
|
|
736
|
+
}
|
|
737
|
+
calls.push(args);
|
|
738
|
+
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const response = await actions.createSeller({
|
|
743
|
+
sellerName: "tbs-nrt-07",
|
|
744
|
+
app: "tbs-nrt-07",
|
|
745
|
+
region: "nrt",
|
|
746
|
+
image: "registry.fly.io/tb-seller:latest",
|
|
747
|
+
upstreamWebsite: "https://openrouter.ai",
|
|
748
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
749
|
+
upstreamApiKey: "fixture-upstream-key",
|
|
750
|
+
upstreamBalanceProbeTemplate: "usage_generic",
|
|
751
|
+
upstreamBalanceProbeUrl: "https://code.shoestravel.xin/v1/usage",
|
|
752
|
+
upstreamBalanceProbeRechargeUrl: "https://code.shoestravel.xin/topup",
|
|
753
|
+
maxConnections: 8,
|
|
754
|
+
maxQueueDepth: 4,
|
|
755
|
+
markupRatio: 1.2,
|
|
756
|
+
discountRatio: 1,
|
|
757
|
+
paymentMethods: ["clawtip", "mock"],
|
|
758
|
+
clawtipPayTo: "pay-to-seller",
|
|
759
|
+
clawtipSm4KeyBase64: "0123456789abcdef012345==",
|
|
760
|
+
clawtipSkillSlug: "tokenbuddy-seller",
|
|
761
|
+
clawtipSkillId: "si-tokenbuddy-seller",
|
|
762
|
+
clawtipDescription: "TokenBuddy Seller",
|
|
763
|
+
clawtipResourceUrl: "https://tbs-nrt-07.fly.dev",
|
|
764
|
+
clawtipActivationFeeFen: 1,
|
|
765
|
+
clawtipMicrosPerFen: 10000,
|
|
766
|
+
flyConfig: "deploy/fly.io/fly.tb-seller.toml"
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
expect(response.result.ok).toBe(true);
|
|
770
|
+
expect(response.configPut?.ok).toBe(true);
|
|
771
|
+
expect(response.modelsRefresh?.ok).toBe(true);
|
|
772
|
+
expect(response.registryPublish?.ok).toBe(true);
|
|
773
|
+
expect(response.publishRegistry).toBe("completed");
|
|
774
|
+
expect(response.configPreview.upstreamUrl).toBe("https://openrouter.ai/api");
|
|
775
|
+
expect(response.configPreview.upstreamBalanceProbe).toEqual({
|
|
776
|
+
template: "usage_generic",
|
|
777
|
+
url: "https://code.shoestravel.xin/v1/usage",
|
|
778
|
+
userId: undefined,
|
|
779
|
+
rechargeUrl: "https://code.shoestravel.xin/topup"
|
|
780
|
+
});
|
|
781
|
+
expect(response.configPreview.upstreamBalanceUrl).toBe("https://code.shoestravel.xin/v1/usage");
|
|
782
|
+
expect(response.configPreview.upstreamRechargeUrl).toBe("https://code.shoestravel.xin/topup");
|
|
783
|
+
expect(response.configPreview.allowMock).toBe(true);
|
|
784
|
+
expect(response.configPreview.clawtip).toEqual({
|
|
785
|
+
payTo: "pay-to-seller",
|
|
786
|
+
sm4KeyBase64: "********",
|
|
787
|
+
skillSlug: "tokenbuddy-seller",
|
|
788
|
+
skillId: "si-tokenbuddy-seller",
|
|
789
|
+
description: "TokenBuddy Seller",
|
|
790
|
+
resourceUrl: "https://tbs-nrt-07.fly.dev",
|
|
791
|
+
activationFeeFen: 1,
|
|
792
|
+
microsPerFen: 10000
|
|
793
|
+
});
|
|
794
|
+
const commandLines = calls.map((args) => args.join(" "));
|
|
795
|
+
expect(commandLines).toEqual([
|
|
796
|
+
expect.stringContaining("seller-config validate --file"),
|
|
797
|
+
expect.stringContaining("seller create tbs-nrt-07"),
|
|
798
|
+
expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret status"),
|
|
799
|
+
expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret seller-config put --file"),
|
|
800
|
+
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")
|
|
803
|
+
]);
|
|
804
|
+
expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
|
|
805
|
+
} finally {
|
|
806
|
+
await fixture.close();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("UiActions create supports mock-only payment without ClawTip parameters", async () => {
|
|
811
|
+
const fixture = await startFixtureAdminServer();
|
|
812
|
+
try {
|
|
813
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
814
|
+
mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
|
|
815
|
+
mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
|
|
816
|
+
const actions = new UiActions({
|
|
817
|
+
configManager: mgr,
|
|
818
|
+
profile: "bootstrap",
|
|
819
|
+
commandRunner: async (args): Promise<UiActionResult> => {
|
|
820
|
+
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const response = await actions.createSeller({
|
|
825
|
+
sellerName: "tbs-nrt-08",
|
|
826
|
+
app: "tbs-nrt-08",
|
|
827
|
+
region: "nrt",
|
|
828
|
+
image: "registry.fly.io/tb-seller:latest",
|
|
829
|
+
upstreamWebsite: "https://openrouter.ai",
|
|
830
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
831
|
+
upstreamApiKey: "fixture-upstream-key",
|
|
832
|
+
upstreamBalanceProbeTemplate: "none",
|
|
833
|
+
maxConnections: 8,
|
|
834
|
+
maxQueueDepth: 4,
|
|
835
|
+
markupRatio: 1.2,
|
|
836
|
+
discountRatio: 1,
|
|
837
|
+
paymentMethods: ["mock"],
|
|
838
|
+
flyConfig: "deploy/fly.io/fly.tb-seller.toml",
|
|
839
|
+
dryRun: true
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
expect(response.result.ok).toBe(true);
|
|
843
|
+
expect(response.readiness).toBeUndefined();
|
|
844
|
+
expect(response.configPut).toBeUndefined();
|
|
845
|
+
expect(response.configPreview.allowMock).toBe(true);
|
|
846
|
+
expect(response.configPreview.clawtip).toBeUndefined();
|
|
847
|
+
} finally {
|
|
848
|
+
await fixture.close();
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("UiActions create requires ClawTip parameters for ClawTip payment", async () => {
|
|
853
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
854
|
+
const actions = new UiActions({
|
|
855
|
+
configManager: mgr,
|
|
856
|
+
profile: "bootstrap",
|
|
857
|
+
commandRunner: async (): Promise<UiActionResult> => {
|
|
858
|
+
throw new Error("create should fail before running commands");
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
await expect(actions.createSeller({
|
|
863
|
+
sellerName: "tbs-nrt-09",
|
|
864
|
+
app: "tbs-nrt-09",
|
|
865
|
+
region: "nrt",
|
|
866
|
+
image: "registry.fly.io/tb-seller:latest",
|
|
867
|
+
upstreamWebsite: "https://openrouter.ai",
|
|
868
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
869
|
+
upstreamApiKey: "fixture-upstream-key",
|
|
870
|
+
upstreamBalanceProbeTemplate: "none",
|
|
871
|
+
maxConnections: 8,
|
|
872
|
+
maxQueueDepth: 4,
|
|
873
|
+
markupRatio: 1.2,
|
|
874
|
+
discountRatio: 1,
|
|
875
|
+
paymentMethods: ["clawtip"],
|
|
876
|
+
flyConfig: "deploy/fly.io/fly.tb-seller.toml"
|
|
877
|
+
})).rejects.toThrow("clawtipPayTo is required");
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("UiActions subprocess runner uses the tb-admin bin entrypoint", async () => {
|
|
881
|
+
const previousArgv = process.argv[1];
|
|
882
|
+
process.argv[1] = path.resolve(__dirname, "../dist/src/index.js");
|
|
883
|
+
const { runTbAdmin } = await import("../src/ui-actions.js");
|
|
884
|
+
try {
|
|
885
|
+
const result = await runTbAdmin(["--help"], 30000);
|
|
886
|
+
expect(result.ok).toBe(true);
|
|
887
|
+
expect(result.command[1]).toMatch(/packages\/admin-cli\/bin\/tb-admin\.js$/);
|
|
888
|
+
expect(result.stdout).toContain("Remote admin CLI");
|
|
889
|
+
} finally {
|
|
890
|
+
process.argv[1] = previousArgv;
|
|
891
|
+
}
|
|
892
|
+
});
|
|
161
893
|
});
|
|
894
|
+
|
|
895
|
+
async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
|
896
|
+
const server = http.createServer((req, res) => {
|
|
897
|
+
const pathName = new URL(req.url || "/", "http://127.0.0.1").pathname;
|
|
898
|
+
if (pathName === "/registry/sellers") {
|
|
899
|
+
sendJson(res, {
|
|
900
|
+
version: 7,
|
|
901
|
+
updatedAt: "2026-06-05T00:00:00.000Z",
|
|
902
|
+
defaultSeller: "tbs-sin-06",
|
|
903
|
+
sellers: [{
|
|
904
|
+
id: "tbs-sin-06",
|
|
905
|
+
name: "tbs-sin-06",
|
|
906
|
+
profile: "seller-sin",
|
|
907
|
+
app: "tbs-sin-06",
|
|
908
|
+
url: "https://seller.example.test",
|
|
909
|
+
status: "active",
|
|
910
|
+
region: "sin",
|
|
911
|
+
modelsCount: 1,
|
|
912
|
+
sampleModels: ["openai/gpt-5.4"],
|
|
913
|
+
supportedProtocols: ["responses"],
|
|
914
|
+
paymentMethods: ["clawtip"]
|
|
915
|
+
}]
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (pathName === "/operator/status") {
|
|
920
|
+
sendJson(res, {
|
|
921
|
+
status: "healthy",
|
|
922
|
+
capacity: { activeConnections: 3, maxConnections: 8, queueDepth: 0, maxQueueDepth: 4 },
|
|
923
|
+
upstream: { status: "healthy" },
|
|
924
|
+
latency: { ttftMs: 321, avgInferenceMs: 640, lastInferenceMs: 700, sampleCount: 2 }
|
|
925
|
+
});
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (pathName === "/operator/admin/service") {
|
|
929
|
+
sendJson(res, {
|
|
930
|
+
sellerId: "tbs-sin-06",
|
|
931
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
932
|
+
modelsCount: 1,
|
|
933
|
+
capacity: { activeConnections: 3, maxConnections: 8, queueDepth: 0, maxQueueDepth: 4 }
|
|
934
|
+
});
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (pathName === "/operator/admin/upstreams") {
|
|
938
|
+
sendJson(res, {
|
|
939
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
940
|
+
upstreamApiKey: "****27f9",
|
|
941
|
+
markupRatio: 1.2,
|
|
942
|
+
discountRatio: 1,
|
|
943
|
+
modelAliases: { "openai/gpt-5.4": "gpt-5.4" },
|
|
944
|
+
models: [{
|
|
945
|
+
id: "openai/gpt-5.4",
|
|
946
|
+
inputPriceMicrosPer1m: 1000000,
|
|
947
|
+
outputPriceMicrosPer1m: 3000000
|
|
948
|
+
}]
|
|
949
|
+
});
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (pathName === "/operator/admin/config") {
|
|
953
|
+
sendJson(res, {
|
|
954
|
+
config: {
|
|
955
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
956
|
+
upstreamApiKey: "fixture-live-key-27f9",
|
|
957
|
+
upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
|
|
958
|
+
upstreamRechargeUrl: "https://openrouter.ai/settings/credits",
|
|
959
|
+
upstreamBalanceProbe: {
|
|
960
|
+
template: "openrouter",
|
|
961
|
+
url: "https://openrouter.ai/api/v1/credits",
|
|
962
|
+
rechargeUrl: "https://openrouter.ai/settings/credits"
|
|
963
|
+
},
|
|
964
|
+
markupRatio: 1.2,
|
|
965
|
+
discountRatio: 1,
|
|
966
|
+
maxConnections: 8,
|
|
967
|
+
maxQueueDepth: 4,
|
|
968
|
+
modelAliases: { "openai/gpt-5.4": "gpt-5.4" },
|
|
969
|
+
models: [{ id: "openai/gpt-5.4" }]
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
res.statusCode = 404;
|
|
975
|
+
res.end();
|
|
976
|
+
});
|
|
977
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
978
|
+
const address = server.address();
|
|
979
|
+
if (!address || typeof address === "string") {
|
|
980
|
+
throw new Error("fixture server did not bind a TCP port");
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
984
|
+
close: () => new Promise<void>((resolve) => server.close(() => resolve()))
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function sendJson(res: http.ServerResponse, body: unknown): void {
|
|
989
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
990
|
+
res.end(JSON.stringify(body));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function createSellerFetchJson(url: string): Promise<unknown> {
|
|
994
|
+
const pathName = new URL(url).pathname;
|
|
995
|
+
if (pathName === "/registry/sellers") {
|
|
996
|
+
return {
|
|
997
|
+
version: 7,
|
|
998
|
+
sellers: []
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
if (pathName === "/operator/admin/upstreams") {
|
|
1002
|
+
return {
|
|
1003
|
+
upstreamUrl: "https://openrouter.ai/api",
|
|
1004
|
+
models: [
|
|
1005
|
+
{ id: "openai/gpt-5.4" },
|
|
1006
|
+
{ id: "openai/gpt-5.4-mini" }
|
|
1007
|
+
],
|
|
1008
|
+
supportedProtocols: ["chat_completions"]
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
if (pathName === "/operator/admin/service") {
|
|
1012
|
+
return {
|
|
1013
|
+
sellerId: "tbs-nrt-07",
|
|
1014
|
+
modelsCount: 2,
|
|
1015
|
+
supportedProtocols: ["chat_completions"]
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
throw new Error(`unexpected fetch url ${url}`);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function isBootstrapSellersValidate(args: string[]): boolean {
|
|
1022
|
+
const validateIndex = args.indexOf("validate");
|
|
1023
|
+
return validateIndex >= 2 && args[validateIndex - 2] === "bootstrap" && args[validateIndex - 1] === "sellers";
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
1027
|
+
return new Response(JSON.stringify(body), {
|
|
1028
|
+
status,
|
|
1029
|
+
headers: { "Content-Type": "application/json" }
|
|
1030
|
+
});
|
|
1031
|
+
}
|