@tokenbuddy/tb-admin 1.0.32 → 1.0.34
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/cli.d.ts.map +1 -1
- package/dist/src/cli.js +29 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.js +3 -3
- package/dist/src/client.js.map +1 -1
- package/dist/src/seller.d.ts +40 -1
- package/dist/src/seller.d.ts.map +1 -1
- package/dist/src/seller.js +132 -2
- package/dist/src/seller.js.map +1 -1
- package/dist/src/ui-actions.d.ts +2 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +8 -6
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-command.d.ts +1 -0
- package/dist/src/ui-command.d.ts.map +1 -1
- package/dist/src/ui-command.js +7 -2
- package/dist/src/ui-command.js.map +1 -1
- package/dist/src/ui-server.d.ts.map +1 -1
- package/dist/src/ui-server.js +29 -8
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +29 -0
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +455 -111
- package/dist/src/ui-state.js.map +1 -1
- package/dist/src/ui-static.d.ts.map +1 -1
- package/dist/src/ui-static.js +262 -143
- package/dist/src/ui-static.js.map +1 -1
- package/dist/src/upstream-balance-probe.d.ts +2 -40
- package/dist/src/upstream-balance-probe.d.ts.map +1 -1
- package/dist/src/upstream-balance-probe.js +1 -378
- package/dist/src/upstream-balance-probe.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +32 -1
- package/src/client.ts +3 -4
- package/src/seller.ts +179 -3
- package/src/ui-actions.ts +10 -6
- package/src/ui-command.ts +7 -2
- package/src/ui-server.ts +30 -8
- package/src/ui-state.ts +533 -111
- package/src/ui-static.ts +262 -143
- package/src/upstream-balance-probe.ts +13 -505
- package/tests/admin.test.ts +472 -36
- package/tests/seller.test.ts +84 -3
- package/tests/ui-state-fleet.test.ts +272 -3
- package/tests/ui-static-row.test.ts +273 -8
- package/tests/vendor-cli.test.ts +45 -1
package/tests/seller.test.ts
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { execFileSync } from "node:child_process";
|
|
23
|
-
import { existsSync } from "node:fs";
|
|
23
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
24
25
|
import { join } from "node:path";
|
|
25
26
|
import { ConfigManager } from "../src/config.js";
|
|
26
27
|
import { SellerCommandRunner, parseFlyListJson, parseFlyStatusJson } from "../src/seller.js";
|
|
@@ -28,16 +29,28 @@ import { FlyProvider, type FlyProviderRuntime } from "../src/server-cmd.js";
|
|
|
28
29
|
|
|
29
30
|
const TB_ADMIN_BIN = join(__dirname, "..", "bin", "tb-admin.js");
|
|
30
31
|
const FLYCTL = "flyctl";
|
|
32
|
+
const RUN_LIVE_FLY_TESTS = process.env.TOKENBUDDY_ADMIN_LIVE_FLY_TESTS === "1";
|
|
31
33
|
|
|
32
34
|
function flyctlInstalled(): boolean {
|
|
33
35
|
try {
|
|
34
|
-
execFileSync(
|
|
36
|
+
execFileSync("which", [FLYCTL], { stdio: "ignore" });
|
|
35
37
|
return true;
|
|
36
38
|
} catch {
|
|
37
39
|
return false;
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
function makeEmptyConfigManager(): ConfigManager {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "tb-admin-test-"));
|
|
45
|
+
return new ConfigManager(join(dir, "admin.toml"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeMissingFlyctlConfigManager(): ConfigManager {
|
|
49
|
+
const manager = makeEmptyConfigManager();
|
|
50
|
+
manager.setSellerProvider("fly", { flyctl_path: "/tmp/tokenbuddy-missing-flyctl" });
|
|
51
|
+
return manager;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
function tbAdminBinInstalled(): boolean {
|
|
42
55
|
return existsSync(TB_ADMIN_BIN);
|
|
43
56
|
}
|
|
@@ -98,6 +111,10 @@ describe("tb-admin seller CLI real spawn (no mock)", () => {
|
|
|
98
111
|
// 每个 test 进来时现场探测.
|
|
99
112
|
const itRequires = (name: string, fn: () => void | Promise<void>) => {
|
|
100
113
|
test(name, async () => {
|
|
114
|
+
if (!RUN_LIVE_FLY_TESTS) {
|
|
115
|
+
console.warn(`[skipped] ${name}: set TOKENBUDDY_ADMIN_LIVE_FLY_TESTS=1 to run live Fly.io checks`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
101
118
|
const hasFlyctl = flyctlInstalled();
|
|
102
119
|
const hasBin = tbAdminBinInstalled();
|
|
103
120
|
if (!hasFlyctl || !hasBin) {
|
|
@@ -205,6 +222,57 @@ describe("tb-admin seller CLI real spawn (no mock)", () => {
|
|
|
205
222
|
});
|
|
206
223
|
expect(parsed.commands[0]).toContain("apps destroy tbs-86d81e");
|
|
207
224
|
});
|
|
225
|
+
|
|
226
|
+
itRequires("roll --dry-run text path lists all live tbs-* candidates from fly apps list", () => {
|
|
227
|
+
const result = runTbAdminReal([
|
|
228
|
+
"seller", "roll",
|
|
229
|
+
"--image", "registry.fly.io/tb-seller:1.0.33",
|
|
230
|
+
"--dry-run"
|
|
231
|
+
]);
|
|
232
|
+
expect(result.ok).toBe(true);
|
|
233
|
+
expect(result.stdout).toMatch(/\[Fly\.io\] roll candidates \(tbs-\*, excludes applied\): \d+/);
|
|
234
|
+
// 至少要有 tbs-86d81e (live 1.0.31 已知)
|
|
235
|
+
expect(result.stdout).toMatch(/tbs-86d81e/);
|
|
236
|
+
// dry-run 模式: 每台都是 [DRY-RUN] 而非真 flyctl
|
|
237
|
+
expect(result.stdout).toMatch(/\[DRY-RUN\] would update tbs-86d81e image to/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
itRequires("roll --dry-run --json returns structured candidates/excluded/attempts", () => {
|
|
241
|
+
const result = runTbAdminReal([
|
|
242
|
+
"seller", "roll",
|
|
243
|
+
"--image", "registry.fly.io/tb-seller:1.0.33",
|
|
244
|
+
"--exclude", "tbs-anpin-ai-0d7517,tbs-openrouter-ai-06vry",
|
|
245
|
+
"--dry-run", "--json"
|
|
246
|
+
]);
|
|
247
|
+
expect(result.ok).toBe(true);
|
|
248
|
+
const parsed = JSON.parse(result.stdout);
|
|
249
|
+
expect(parsed).toMatchObject({
|
|
250
|
+
ok: true,
|
|
251
|
+
provider: "fly",
|
|
252
|
+
action: "roll",
|
|
253
|
+
image: "registry.fly.io/tb-seller:1.0.33",
|
|
254
|
+
dryRun: true,
|
|
255
|
+
completed: true
|
|
256
|
+
});
|
|
257
|
+
expect(Array.isArray(parsed.candidates)).toBe(true);
|
|
258
|
+
expect(parsed.candidates).toContain("tbs-86d81e");
|
|
259
|
+
expect(parsed.candidates).not.toContain("tbs-anpin-ai-0d7517");
|
|
260
|
+
expect(parsed.excluded).toEqual(expect.arrayContaining(["tbs-anpin-ai-0d7517", "tbs-openrouter-ai-06vry"]));
|
|
261
|
+
// attempts 顺序应该跟 candidates 顺序一致
|
|
262
|
+
expect(parsed.attempts.length).toBe(parsed.candidates.length);
|
|
263
|
+
for (let i = 0; i < parsed.attempts.length; i++) {
|
|
264
|
+
expect(parsed.attempts[i].app).toBe(parsed.candidates[i]);
|
|
265
|
+
expect(parsed.attempts[i].ok).toBe(true);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
itRequires("roll rejects empty image and returns 1 (with usage error)", () => {
|
|
270
|
+
const result = runTbAdminReal([
|
|
271
|
+
"seller", "roll", "--dry-run", "--json"
|
|
272
|
+
]);
|
|
273
|
+
// commander requiredOption 失败时, 进程退出非 0
|
|
274
|
+
expect(result.ok).toBe(false);
|
|
275
|
+
});
|
|
208
276
|
});
|
|
209
277
|
|
|
210
278
|
describe("SellerCommandRunner integration with real ConfigManager (no mock)", () => {
|
|
@@ -219,10 +287,15 @@ describe("SellerCommandRunner integration with real ConfigManager (no mock)", ()
|
|
|
219
287
|
}
|
|
220
288
|
|
|
221
289
|
test("ls(false) returns flyctl stdout string via real FlyProvider (no mock)", () => {
|
|
290
|
+
if (!RUN_LIVE_FLY_TESTS) {
|
|
291
|
+
const runner = new SellerCommandRunner(makeMissingFlyctlConfigManager());
|
|
292
|
+
expect(() => runner.ls(false)).toThrow(/flyctl|not installed|seller_providers/);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
222
295
|
const mgr = tryRealConfigManager();
|
|
223
296
|
if (!mgr) {
|
|
224
297
|
// 环境无 admin profile, 用空 ConfigManager 走 fallback
|
|
225
|
-
const tmp =
|
|
298
|
+
const tmp = makeEmptyConfigManager();
|
|
226
299
|
const runner = new SellerCommandRunner(tmp);
|
|
227
300
|
// 没有 provider config -> 走 FlyProvider -> flyctl not installed (test env) -> throw
|
|
228
301
|
try {
|
|
@@ -245,6 +318,14 @@ describe("FlyProvider dry-run paths (no mock, real flyctl required)", () => {
|
|
|
245
318
|
// 只输出 plan. 这是 FlyProvider 的核心契约.
|
|
246
319
|
|
|
247
320
|
function makeRealProvider(): FlyProvider | null {
|
|
321
|
+
if (!RUN_LIVE_FLY_TESTS) {
|
|
322
|
+
return new FlyProvider({
|
|
323
|
+
default_region: "sin",
|
|
324
|
+
operator_secret: "test-secret",
|
|
325
|
+
volume_name: "tb_seller_data",
|
|
326
|
+
volume_size_gb: 1
|
|
327
|
+
});
|
|
328
|
+
}
|
|
248
329
|
const home = process.env.HOME || "";
|
|
249
330
|
const adminToml = join(home, ".config", "tokenbuddy", "admin.toml");
|
|
250
331
|
if (!existsSync(adminToml)) {
|
|
@@ -40,22 +40,44 @@ interface FakeServerHandle {
|
|
|
40
40
|
url: string;
|
|
41
41
|
setManifest: (sellerId: string, resp: FakeManifestResponse) => void;
|
|
42
42
|
setManifestPath: (path: string, resp: FakeManifestResponse) => void;
|
|
43
|
+
setOperatorJson: (path: string, resp: unknown) => void;
|
|
43
44
|
setRegistry: (resp: { sellers: any[]; version: number }) => void;
|
|
45
|
+
setManagedSellers: (sellers: any[]) => void;
|
|
46
|
+
requests: () => Array<{ path: string; authorization?: string }>;
|
|
44
47
|
close: () => Promise<void>;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
function startFakeRegistry(): Promise<FakeServerHandle> {
|
|
48
51
|
let registryDoc: { sellers: any[]; version: number } = { sellers: [], version: 0 };
|
|
52
|
+
let managedSellers: any[] | undefined;
|
|
49
53
|
const manifests = new Map<string, FakeManifestResponse>();
|
|
54
|
+
const operatorJson = new Map<string, unknown>();
|
|
55
|
+
const requests: Array<{ path: string; authorization?: string }> = [];
|
|
50
56
|
|
|
51
57
|
return new Promise((resolve) => {
|
|
52
58
|
const server = createServer((req, res) => {
|
|
53
59
|
const url = new URL(req.url || "/", "http://localhost");
|
|
60
|
+
requests.push({ path: url.pathname, authorization: req.headers.authorization });
|
|
61
|
+
if (url.pathname === "/platform/sellers") {
|
|
62
|
+
if (req.headers.authorization !== "Bearer fake-vendor-token") {
|
|
63
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
64
|
+
res.end(JSON.stringify({ error: "vendor_auth_required" }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
68
|
+
res.end(JSON.stringify({ sellers: managedSellers ?? registryDoc.sellers }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
54
71
|
if (url.pathname === "/registry/sellers") {
|
|
55
72
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
56
73
|
res.end(JSON.stringify(registryDoc));
|
|
57
74
|
return;
|
|
58
75
|
}
|
|
76
|
+
if (operatorJson.has(url.pathname)) {
|
|
77
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
78
|
+
res.end(JSON.stringify(operatorJson.get(url.pathname)));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
59
81
|
// 1) 按 path 查 (setManifestPath 注册的)
|
|
60
82
|
if (manifests.has(url.pathname)) {
|
|
61
83
|
const resp = manifests.get(url.pathname)!;
|
|
@@ -85,9 +107,16 @@ function startFakeRegistry(): Promise<FakeServerHandle> {
|
|
|
85
107
|
setManifestPath: (path, resp) => {
|
|
86
108
|
manifests.set(path, resp);
|
|
87
109
|
},
|
|
110
|
+
setOperatorJson: (path, resp) => {
|
|
111
|
+
operatorJson.set(path, resp);
|
|
112
|
+
},
|
|
88
113
|
setRegistry: (doc) => {
|
|
89
114
|
registryDoc = doc;
|
|
90
115
|
},
|
|
116
|
+
setManagedSellers: (sellers) => {
|
|
117
|
+
managedSellers = sellers;
|
|
118
|
+
},
|
|
119
|
+
requests: () => requests.slice(),
|
|
91
120
|
close: () => new Promise<void>((r) => server.close(() => r()))
|
|
92
121
|
};
|
|
93
122
|
resolve(handle);
|
|
@@ -100,7 +129,8 @@ let tmpConfigPath: string;
|
|
|
100
129
|
|
|
101
130
|
function makeState(
|
|
102
131
|
registryUrl: string,
|
|
103
|
-
flyApps: Omit<SellerAppJson, "raw">[]
|
|
132
|
+
flyApps: Omit<SellerAppJson, "raw">[],
|
|
133
|
+
token = "fake-vendor-token"
|
|
104
134
|
): AdminUiState {
|
|
105
135
|
if (!tmpDir) {
|
|
106
136
|
tmpDir = mkdtempSync(join(tmpdir(), "ui-fleet-test-"));
|
|
@@ -110,7 +140,7 @@ function makeState(
|
|
|
110
140
|
tmpConfigPath,
|
|
111
141
|
`[profiles.default]
|
|
112
142
|
url = "${registryUrl}"
|
|
113
|
-
token = "
|
|
143
|
+
token = "${token}"
|
|
114
144
|
`,
|
|
115
145
|
"utf8"
|
|
116
146
|
);
|
|
@@ -119,10 +149,13 @@ token = "fake-vendor-token"
|
|
|
119
149
|
configManager,
|
|
120
150
|
profile: "default",
|
|
121
151
|
url: registryUrl,
|
|
122
|
-
token
|
|
152
|
+
token,
|
|
123
153
|
flyApps: async () => flyApps.map((a) => ({ ...a, raw: {} })),
|
|
124
154
|
fetchJson: (async (target: string, init?: RequestInit) => {
|
|
125
155
|
const res = await fetch(target, init);
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
throw new Error(`HTTP Error ${res.status}: ${await res.text()}`);
|
|
158
|
+
}
|
|
126
159
|
return await res.json();
|
|
127
160
|
}) as any
|
|
128
161
|
});
|
|
@@ -174,6 +207,242 @@ describe("AdminUiState 双源 seller list (v1.1 spec)", () => {
|
|
|
174
207
|
expect(rows[0].registryAlert).toBeFalsy();
|
|
175
208
|
// 绿点: /manifest 200 → nodeStatus=active (manifest ok 映射 active)
|
|
176
209
|
expect(rows[0].nodeStatus).toBe("active");
|
|
210
|
+
expect(fake.requests()).toContainEqual({
|
|
211
|
+
path: "/platform/sellers",
|
|
212
|
+
authorization: "Bearer fake-vendor-token"
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("sellerRegistryRows returns registry publish-checking rows before Fly inventory", async () => {
|
|
217
|
+
fake.setRegistry({
|
|
218
|
+
version: 7,
|
|
219
|
+
sellers: [
|
|
220
|
+
{
|
|
221
|
+
id: "alpha",
|
|
222
|
+
name: "Alpha Seller",
|
|
223
|
+
app: "tb-seller-alpha",
|
|
224
|
+
url: `${fake.url}/alpha-base`,
|
|
225
|
+
status: "active",
|
|
226
|
+
supportedProtocols: ["chat_completions"],
|
|
227
|
+
paymentMethods: ["clawtip"],
|
|
228
|
+
models: ["gpt-5.4"]
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
});
|
|
232
|
+
const state = makeState(fake.url, [
|
|
233
|
+
{ name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const rows = await state.sellerRegistryRows();
|
|
237
|
+
expect(rows).toHaveLength(1);
|
|
238
|
+
expect(rows[0]).toMatchObject({
|
|
239
|
+
id: "alpha",
|
|
240
|
+
publishStatus: "checking",
|
|
241
|
+
detailStatus: "pending",
|
|
242
|
+
nodeStatus: "unknown"
|
|
243
|
+
});
|
|
244
|
+
expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("sellerInventory merges registry + Fly without probing seller details", async () => {
|
|
248
|
+
fake.setRegistry({
|
|
249
|
+
version: 7,
|
|
250
|
+
sellers: [
|
|
251
|
+
{
|
|
252
|
+
id: "alpha",
|
|
253
|
+
name: "Alpha Seller",
|
|
254
|
+
app: "tb-seller-alpha",
|
|
255
|
+
url: `${fake.url}/alpha-base`,
|
|
256
|
+
status: "active",
|
|
257
|
+
supportedProtocols: ["chat_completions"],
|
|
258
|
+
paymentMethods: ["clawtip"],
|
|
259
|
+
models: ["gpt-5.4"]
|
|
260
|
+
}
|
|
261
|
+
]
|
|
262
|
+
});
|
|
263
|
+
fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
|
|
264
|
+
const state = makeState(fake.url, [
|
|
265
|
+
{ name: "tb-seller-alpha", status: "running", owner: "vendor-a" },
|
|
266
|
+
{ name: "tb-seller-flyonly", status: "running", owner: "vendor-a" }
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
const rows = await state.sellerInventory();
|
|
270
|
+
expect(rows).toHaveLength(2);
|
|
271
|
+
expect(rows.find((row) => row.id === "alpha")).toMatchObject({
|
|
272
|
+
dataSource: "both",
|
|
273
|
+
publishStatus: "published",
|
|
274
|
+
detailStatus: "pending",
|
|
275
|
+
nodeStatus: "unknown"
|
|
276
|
+
});
|
|
277
|
+
expect(rows.find((row) => row.id === "tb-seller-flyonly")).toMatchObject({
|
|
278
|
+
dataSource: "fly",
|
|
279
|
+
publishStatus: "unpublished",
|
|
280
|
+
detailStatus: "pending"
|
|
281
|
+
});
|
|
282
|
+
expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("sellerInventory filters non-seller Fly apps", async () => {
|
|
286
|
+
fake.setRegistry({ version: 7, sellers: [] });
|
|
287
|
+
const state = makeState(fake.url, [
|
|
288
|
+
{ name: "tb-registry", status: "running", owner: "vendor-a" },
|
|
289
|
+
{ name: "tb-seller", status: "running", owner: "vendor-a" },
|
|
290
|
+
{ name: "tbs-valid", status: "running", owner: "vendor-a" }
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
const rows = await state.sellerInventory();
|
|
294
|
+
expect(rows.map((row) => row.app)).toEqual(["tbs-valid"]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("refreshSellerRows hydrates upstream domain from seller config", async () => {
|
|
298
|
+
fake.setManifestPath("/manifest", { status: 200, body: { ok: true } });
|
|
299
|
+
fake.setOperatorJson("/operator/status", {
|
|
300
|
+
status: "healthy",
|
|
301
|
+
upstream: { status: "healthy" },
|
|
302
|
+
capacity: { activeConnections: 2, maxConnections: 8 },
|
|
303
|
+
runtime: { cpuPercent: 12.5, memoryPercent: 48, memoryRssMb: 246, memoryLimitMb: 512 }
|
|
304
|
+
});
|
|
305
|
+
fake.setOperatorJson("/operator/admin/upstreams", {
|
|
306
|
+
upstreams: [{
|
|
307
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
308
|
+
discountRatio: 1,
|
|
309
|
+
models: [{ id: "openai/gpt-5.4" }]
|
|
310
|
+
}]
|
|
311
|
+
});
|
|
312
|
+
fake.setOperatorJson("/operator/admin/config", {
|
|
313
|
+
config: {
|
|
314
|
+
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
315
|
+
discountRatio: 1
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
const state = makeState(fake.url, []);
|
|
319
|
+
|
|
320
|
+
const rows = await state.refreshSellerRows([{
|
|
321
|
+
id: "tbs-openrouter",
|
|
322
|
+
name: "tbs-openrouter",
|
|
323
|
+
app: "tbs-openrouter",
|
|
324
|
+
url: fake.url,
|
|
325
|
+
registryStatus: "active",
|
|
326
|
+
nodeStatus: "unknown",
|
|
327
|
+
upstreamDomain: "tbs-openrouter.fly.dev",
|
|
328
|
+
upstreamStatus: "unknown",
|
|
329
|
+
dataSource: "both",
|
|
330
|
+
publishStatus: "published",
|
|
331
|
+
detailStatus: "queued"
|
|
332
|
+
}]);
|
|
333
|
+
|
|
334
|
+
expect(rows[0]).toMatchObject({
|
|
335
|
+
upstreamDomain: "openrouter.ai",
|
|
336
|
+
upstreamStatus: "healthy",
|
|
337
|
+
capacityUsed: 2,
|
|
338
|
+
capacityLimit: 8,
|
|
339
|
+
resourceCpuPercent: 12.5,
|
|
340
|
+
resourceMemoryPercent: 48,
|
|
341
|
+
modelsCount: 1,
|
|
342
|
+
detailStatus: "fresh"
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("case 1b: empty vendor-managed list keeps public registry publish state", async () => {
|
|
347
|
+
fake.setRegistry({
|
|
348
|
+
version: 9,
|
|
349
|
+
sellers: [
|
|
350
|
+
{
|
|
351
|
+
id: "public-alpha",
|
|
352
|
+
name: "Public Alpha",
|
|
353
|
+
app: "tb-seller-alpha",
|
|
354
|
+
url: `${fake.url}/alpha-base`,
|
|
355
|
+
status: "active",
|
|
356
|
+
supportedProtocols: ["chat_completions"],
|
|
357
|
+
paymentMethods: ["clawtip"],
|
|
358
|
+
models: ["gpt-5.4"]
|
|
359
|
+
}
|
|
360
|
+
]
|
|
361
|
+
});
|
|
362
|
+
fake.setManagedSellers([]);
|
|
363
|
+
fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
|
|
364
|
+
const state = makeState(fake.url, [
|
|
365
|
+
{ name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
const rows = await state.sellers();
|
|
369
|
+
expect(rows).toHaveLength(1);
|
|
370
|
+
expect(rows[0]).toMatchObject({
|
|
371
|
+
id: "public-alpha",
|
|
372
|
+
dataSource: "both",
|
|
373
|
+
registryStatus: "active",
|
|
374
|
+
nodeStatus: "active"
|
|
375
|
+
});
|
|
376
|
+
expect(rows[0].publishHint).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("case 1c: vendor-managed auth failure falls back to public registry", async () => {
|
|
380
|
+
fake.setRegistry({
|
|
381
|
+
version: 9,
|
|
382
|
+
sellers: [
|
|
383
|
+
{
|
|
384
|
+
id: "public-alpha",
|
|
385
|
+
name: "Public Alpha",
|
|
386
|
+
app: "tb-seller-alpha",
|
|
387
|
+
url: `${fake.url}/alpha-base`,
|
|
388
|
+
status: "active",
|
|
389
|
+
supportedProtocols: ["chat_completions"],
|
|
390
|
+
paymentMethods: ["clawtip"],
|
|
391
|
+
models: ["gpt-5.4"]
|
|
392
|
+
}
|
|
393
|
+
]
|
|
394
|
+
});
|
|
395
|
+
fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
|
|
396
|
+
const state = makeState(fake.url, [
|
|
397
|
+
{ name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
|
|
398
|
+
], "wrong-vendor-token");
|
|
399
|
+
|
|
400
|
+
const rows = await state.sellers();
|
|
401
|
+
expect(rows).toHaveLength(1);
|
|
402
|
+
expect(rows[0]).toMatchObject({
|
|
403
|
+
id: "public-alpha",
|
|
404
|
+
dataSource: "both",
|
|
405
|
+
registryStatus: "active",
|
|
406
|
+
nodeStatus: "active"
|
|
407
|
+
});
|
|
408
|
+
expect(fake.requests()).toContainEqual({
|
|
409
|
+
path: "/platform/sellers",
|
|
410
|
+
authorization: "Bearer wrong-vendor-token"
|
|
411
|
+
});
|
|
412
|
+
expect(fake.requests()).toContainEqual({
|
|
413
|
+
path: "/registry/sellers",
|
|
414
|
+
authorization: undefined
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("case 1d: registry entry without app matches Fly app by id", async () => {
|
|
419
|
+
fake.setRegistry({
|
|
420
|
+
version: 9,
|
|
421
|
+
sellers: [
|
|
422
|
+
{
|
|
423
|
+
id: "tb-seller-alpha",
|
|
424
|
+
name: "Alpha Seller",
|
|
425
|
+
url: `${fake.url}/alpha-base`,
|
|
426
|
+
status: "active",
|
|
427
|
+
supportedProtocols: ["chat_completions"],
|
|
428
|
+
paymentMethods: ["clawtip"],
|
|
429
|
+
models: ["gpt-5.4"]
|
|
430
|
+
}
|
|
431
|
+
]
|
|
432
|
+
});
|
|
433
|
+
fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
|
|
434
|
+
const state = makeState(fake.url, [
|
|
435
|
+
{ name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
|
|
436
|
+
]);
|
|
437
|
+
|
|
438
|
+
const rows = await state.sellers();
|
|
439
|
+
expect(rows).toHaveLength(1);
|
|
440
|
+
expect(rows[0]).toMatchObject({
|
|
441
|
+
id: "tb-seller-alpha",
|
|
442
|
+
dataSource: "both",
|
|
443
|
+
registryStatus: "active",
|
|
444
|
+
nodeStatus: "active"
|
|
445
|
+
});
|
|
177
446
|
});
|
|
178
447
|
|
|
179
448
|
test("case 2: fly only → dataSource=fly, 灰点, registryAlert=undefined", async () => {
|