@fusionkit/cli 0.1.3 → 0.1.4
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/commands/fusion.js +17 -2
- package/dist/commands/plane.js +10 -2
- package/dist/fusion-config.d.ts +1 -0
- package/dist/fusion-config.js +5 -0
- package/dist/fusion-quickstart.d.ts +33 -34
- package/dist/fusion-quickstart.js +324 -278
- package/dist/shared/portless.d.ts +97 -0
- package/dist/shared/portless.js +253 -0
- package/dist/test/portless.test.d.ts +1 -0
- package/dist/test/portless.test.js +65 -0
- package/package.json +12 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +8 -8
- package/scope/.next/app-path-routes-manifest.json +3 -3
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +9 -9
- package/scope/.next/required-server-files.json +4 -0
- package/scope/.next/server/app/_not-found.html +1 -1
- package/scope/.next/server/app/_not-found.rsc +1 -1
- package/scope/.next/server/app/environments.html +1 -1
- package/scope/.next/server/app/environments.rsc +1 -1
- package/scope/.next/server/app/index.html +1 -1
- package/scope/.next/server/app/index.rsc +1 -1
- package/scope/.next/server/app/models.html +1 -1
- package/scope/.next/server/app/models.rsc +1 -1
- package/scope/.next/server/app-paths-manifest.json +3 -3
- package/scope/.next/server/functions-config-manifest.json +3 -3
- package/scope/.next/server/pages/404.html +1 -1
- package/scope/.next/server/pages/500.html +1 -1
- package/scope/.next/server/server-reference-manifest.json +1 -1
- package/scope/package.json +3 -1
- package/scope/server.js +1 -1
- /package/scope/.next/static/{8b1kwXDxrKvteRVkOC_Z2 → 5tnFLuvnSbNZNtqRgoot8}/_buildManifest.js +0 -0
- /package/scope/.next/static/{8b1kwXDxrKvteRVkOC_Z2 → 5tnFLuvnSbNZNtqRgoot8}/_ssgManifest.js +0 -0
|
@@ -20,12 +20,13 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
import { MlxBackend, startGateway } from "@fusionkit/model-gateway";
|
|
21
21
|
import { gatewaySetupSnippets, setGatewayChatter, startFusionStepGateway } from "./gateway.js";
|
|
22
22
|
import { claudeEnv, codexConfigToml } from "./local.js";
|
|
23
|
+
import { createPortlessSession } from "./shared/portless.js";
|
|
23
24
|
import { runPreflight } from "./shared/preflight.js";
|
|
24
25
|
import { createBootView } from "./ui/boot.js";
|
|
25
26
|
import { confirm, select } from "./ui/prompt.js";
|
|
26
27
|
import { canPromptInteractively, isInteractive, uiStream } from "./ui/runtime.js";
|
|
27
28
|
import { bold, brandHeader, dim, glyph, gray, green } from "./ui/theme.js";
|
|
28
|
-
import { freePort,
|
|
29
|
+
import { freePort, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "./shared/proc.js";
|
|
29
30
|
export const FUSION_TOOLS = ["codex", "claude", "cursor", "serve"];
|
|
30
31
|
/** The model label the launched tool uses; the gateway ignores it for routing. */
|
|
31
32
|
const FUSION_MODEL_LABEL = "fusion-panel";
|
|
@@ -34,7 +35,7 @@ const FUSION_MODEL_LABEL = "fusion-panel";
|
|
|
34
35
|
* synthesizer (`fusionkit serve`) and the single-model OpenAI shim
|
|
35
36
|
* (`fusionkit serve-endpoint`). Pinned so `uvx` resolves a reproducible build.
|
|
36
37
|
*/
|
|
37
|
-
export const FUSIONKIT_PYPI_VERSION = "0.
|
|
38
|
+
export const FUSIONKIT_PYPI_VERSION = "0.2.0";
|
|
38
39
|
/**
|
|
39
40
|
* Default cloud panel — works cross-platform with only `OPENAI_API_KEY` and
|
|
40
41
|
* `ANTHROPIC_API_KEY` set. The judge defaults to the first entry.
|
|
@@ -199,6 +200,26 @@ export function bundledScopeServer() {
|
|
|
199
200
|
const serverJs = join(dirname(fileURLToPath(import.meta.url)), "..", "scope", "server.js");
|
|
200
201
|
return existsSync(serverJs) ? serverJs : undefined;
|
|
201
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Inject the portless CA so spawned Node children (dashboard, cursor bridge,
|
|
205
|
+
* launched agents) trust the proxy's HTTPS routes. Only `NODE_EXTRA_CA_CERTS` is
|
|
206
|
+
* set: it *extends* Node's trust store. We deliberately do NOT set Python's
|
|
207
|
+
* `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`, because those *replace* the bundle — and
|
|
208
|
+
* pointing them at the portless CA alone breaks the router's outbound HTTPS to
|
|
209
|
+
* real providers (api.openai.com, etc.). The router never calls a portless HTTPS
|
|
210
|
+
* URL (providers go direct; MLX is loopback), so it needs no portless CA. If a
|
|
211
|
+
* Python process ever must reach a portless HTTPS URL, build a combined
|
|
212
|
+
* certifi+portless bundle rather than replacing the bundle here. A no-op when
|
|
213
|
+
* portless is off (no CA path).
|
|
214
|
+
*/
|
|
215
|
+
function withCaEnv(env, caCertPath) {
|
|
216
|
+
if (caCertPath === undefined)
|
|
217
|
+
return env;
|
|
218
|
+
return {
|
|
219
|
+
...env,
|
|
220
|
+
NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS ?? caCertPath
|
|
221
|
+
};
|
|
222
|
+
}
|
|
202
223
|
/** Best-effort: open a URL in the default browser (no-op on failure). */
|
|
203
224
|
function openUrl(url) {
|
|
204
225
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
@@ -271,62 +292,94 @@ function startDevDashboard(input) {
|
|
|
271
292
|
* prebuilt bundle shipped inside the npm package; falls back to building the
|
|
272
293
|
* app from source in a monorepo dev checkout.
|
|
273
294
|
*/
|
|
295
|
+
/** Identity token of a reusable scope dashboard (any healthy instance qualifies). */
|
|
296
|
+
const SCOPE_IDENTITY = "scope-dashboard";
|
|
274
297
|
export async function startObservability(input) {
|
|
275
298
|
const traceDir = mkdtempSync(join(tmpdir(), "fusion-trace-"));
|
|
276
299
|
const dbPath = join(traceDir, "scope.db");
|
|
277
300
|
// The dashboard server loads node:sqlite; keep its experimental warnings out
|
|
278
301
|
// of the log just like the parent CLI. The per-run db/trace dir isolate state.
|
|
279
|
-
const childEnv = {
|
|
302
|
+
const childEnv = withCaEnv({
|
|
280
303
|
...process.env,
|
|
281
304
|
NODE_OPTIONS: [process.env.NODE_OPTIONS, "--disable-warning=ExperimentalWarning"].filter(Boolean).join(" "),
|
|
282
305
|
SCOPEKIT_DB: dbPath,
|
|
283
306
|
FUSION_TRACE_DIR: traceDir
|
|
307
|
+
}, input.portless.caCertPath);
|
|
308
|
+
const spawnDashboard = async () => {
|
|
309
|
+
const bundled = bundledScopeServer();
|
|
310
|
+
if (input.report)
|
|
311
|
+
input.report({ kind: "dashboard.start" });
|
|
312
|
+
else if (bundled !== undefined)
|
|
313
|
+
input.log("fusion: starting observability dashboard...");
|
|
314
|
+
else
|
|
315
|
+
input.log("fusion: building observability dashboard (one-time)...");
|
|
316
|
+
let proc;
|
|
317
|
+
try {
|
|
318
|
+
proc =
|
|
319
|
+
bundled !== undefined
|
|
320
|
+
? startBundledDashboard({
|
|
321
|
+
serverJs: bundled,
|
|
322
|
+
env: childEnv,
|
|
323
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {})
|
|
324
|
+
})
|
|
325
|
+
: startDevDashboard({
|
|
326
|
+
env: childEnv,
|
|
327
|
+
traceDir,
|
|
328
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {})
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await waitForHttp(`http://127.0.0.1:${SCOPE_DASHBOARD_PORT}`, proc, {
|
|
336
|
+
timeoutMs: 60_000,
|
|
337
|
+
label: "dashboard"
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
terminate(proc.child);
|
|
342
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
port: SCOPE_DASHBOARD_PORT,
|
|
346
|
+
...(proc.child.pid !== undefined ? { pid: proc.child.pid } : {}),
|
|
347
|
+
close: () => terminate(proc.child)
|
|
348
|
+
};
|
|
284
349
|
};
|
|
285
|
-
|
|
286
|
-
if (input.report)
|
|
287
|
-
input.report({ kind: "dashboard.start" });
|
|
288
|
-
else if (bundled !== undefined)
|
|
289
|
-
input.log("fusion: starting observability dashboard...");
|
|
290
|
-
else
|
|
291
|
-
input.log("fusion: building observability dashboard (one-time)...");
|
|
292
|
-
let proc;
|
|
350
|
+
let resolved;
|
|
293
351
|
try {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
rmSync(traceDir, { recursive: true, force: true });
|
|
309
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
310
|
-
}
|
|
311
|
-
const url = `http://127.0.0.1:${SCOPE_DASHBOARD_PORT}`;
|
|
312
|
-
try {
|
|
313
|
-
await waitForHttp(url, proc, { timeoutMs: 60_000, label: "dashboard" });
|
|
352
|
+
resolved = await input.portless.discoverOrSpawn({
|
|
353
|
+
name: "scope",
|
|
354
|
+
identity: SCOPE_IDENTITY,
|
|
355
|
+
healthCheck: async (loopbackUrl) => {
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetch(loopbackUrl, { signal: AbortSignal.timeout(2000) });
|
|
358
|
+
return response.ok ? SCOPE_IDENTITY : undefined;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
spawn: spawnDashboard
|
|
365
|
+
});
|
|
314
366
|
}
|
|
315
367
|
catch (error) {
|
|
316
|
-
terminate(proc.child);
|
|
317
368
|
rmSync(traceDir, { recursive: true, force: true });
|
|
318
369
|
throw error instanceof Error ? error : new Error(String(error));
|
|
319
370
|
}
|
|
320
371
|
if (input.report)
|
|
321
|
-
input.report({ kind: "dashboard.ready", detail: url });
|
|
372
|
+
input.report({ kind: "dashboard.ready", detail: resolved.url });
|
|
322
373
|
else
|
|
323
|
-
input.log(`fusion: observability dashboard ready on ${url}`);
|
|
374
|
+
input.log(`fusion: observability dashboard ready on ${resolved.url}`);
|
|
324
375
|
return {
|
|
325
|
-
url,
|
|
326
|
-
|
|
376
|
+
url: resolved.url,
|
|
377
|
+
// Trace events post over loopback (the in-process emitters do not carry the
|
|
378
|
+
// portless CA), so ingest uses the raw port; the named URL is for humans.
|
|
379
|
+
ingestUrl: `${resolved.loopbackUrl}/api/ingest`,
|
|
327
380
|
traceDir,
|
|
328
381
|
close: async () => {
|
|
329
|
-
|
|
382
|
+
await resolved.close();
|
|
330
383
|
rmSync(traceDir, { recursive: true, force: true });
|
|
331
384
|
}
|
|
332
385
|
};
|
|
@@ -346,277 +399,252 @@ export async function startObservability(input) {
|
|
|
346
399
|
function looksPermanentFailure(log) {
|
|
347
400
|
return /401|403|invalid[ _-]?api[ _-]?key|unauthorized|forbidden|authentication|permission|model[^\n]*(not found|does not exist)|no such model|model_not_found/i.test(log);
|
|
348
401
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
"
|
|
361
|
-
|
|
362
|
-
"
|
|
363
|
-
|
|
364
|
-
"
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
"--port",
|
|
369
|
-
String(port),
|
|
370
|
-
...(input.spec.baseUrl !== undefined ? ["--base-url", input.spec.baseUrl] : []),
|
|
371
|
-
...(keyEnv !== undefined ? ["--api-key-env", keyEnv] : [])
|
|
372
|
-
];
|
|
373
|
-
input.log(attempt === 1
|
|
374
|
-
? `fusion: starting ${label}...`
|
|
375
|
-
: `fusion: retrying ${label} (attempt ${attempt}/${maxAttempts})...`);
|
|
376
|
-
const proc = spawnLogged(runner.command, args, {
|
|
377
|
-
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
378
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
379
|
-
env: input.env
|
|
380
|
-
});
|
|
381
|
-
const url = `http://127.0.0.1:${port}`;
|
|
382
|
-
try {
|
|
383
|
-
await waitForHttp(`${url}/v1/models`, proc, {
|
|
384
|
-
timeoutMs: 30_000,
|
|
385
|
-
label: `${input.spec.id} server`,
|
|
386
|
-
requireOk: true
|
|
387
|
-
});
|
|
388
|
-
input.log(`fusion: ${input.spec.id} ready on ${url}`);
|
|
389
|
-
return { url, child: proc.child };
|
|
402
|
+
/**
|
|
403
|
+
* Default provider base URL when a cloud spec carries no explicit `baseUrl`.
|
|
404
|
+
* fusionkit's `ModelEndpoint` requires `base_url`, so the router config must
|
|
405
|
+
* always set it (the `serve-endpoint` shim filled this in for us before). Mirrors
|
|
406
|
+
* `PROVIDER_DEFAULT_BASE_URL` in fusionkit's openai_endpoint.py.
|
|
407
|
+
*/
|
|
408
|
+
function providerDefaultBaseUrl(provider) {
|
|
409
|
+
switch (provider) {
|
|
410
|
+
case "openai":
|
|
411
|
+
return "https://api.openai.com";
|
|
412
|
+
case "anthropic":
|
|
413
|
+
return "https://api.anthropic.com";
|
|
414
|
+
case "google":
|
|
415
|
+
return "https://generativelanguage.googleapis.com";
|
|
416
|
+
case "openai-compatible":
|
|
417
|
+
return "http://127.0.0.1";
|
|
418
|
+
default: {
|
|
419
|
+
const exhaustive = provider;
|
|
420
|
+
throw new Error(`unknown provider ${String(exhaustive)}`);
|
|
390
421
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Pick the panel spec that backs the judge (by model name), else the first. */
|
|
425
|
+
function judgeSpecFor(specs, judgeModel) {
|
|
426
|
+
const first = specs[0];
|
|
427
|
+
if (first === undefined)
|
|
428
|
+
throw new Error("at least one panel model is required");
|
|
429
|
+
if (judgeModel === undefined)
|
|
430
|
+
return first;
|
|
431
|
+
return specs.find((spec) => spec.model === judgeModel) ?? first;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Build the `fusionkit serve` config (YAML) for the consolidated router: one
|
|
435
|
+
* endpoint per panel model. Cloud models call their provider directly (keyed by
|
|
436
|
+
* `api_key_env`); MLX models are fronted as `openai-compatible` endpoints
|
|
437
|
+
* pointing at their in-process gateway loopback URL. The judge endpoint doubles
|
|
438
|
+
* as the synthesizer. Values are JSON-quoted (valid YAML flow scalars).
|
|
439
|
+
*/
|
|
440
|
+
function routerConfigYaml(input) {
|
|
441
|
+
const lines = ["endpoints:"];
|
|
442
|
+
for (const spec of input.specs) {
|
|
443
|
+
const provider = spec.provider ?? "mlx";
|
|
444
|
+
lines.push(` - id: ${JSON.stringify(spec.id)}`);
|
|
445
|
+
lines.push(` model: ${JSON.stringify(spec.model)}`);
|
|
446
|
+
if (provider === "mlx") {
|
|
447
|
+
lines.push(" provider: openai-compatible");
|
|
448
|
+
lines.push(` base_url: ${JSON.stringify(input.mlxUrls[spec.id] ?? "")}`);
|
|
449
|
+
lines.push(" api_key: not-needed");
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// `base_url` is required by fusionkit's ModelEndpoint, so always emit one
|
|
453
|
+
// (the spec's, or the provider default).
|
|
454
|
+
const baseUrl = spec.baseUrl ?? providerDefaultBaseUrl(provider);
|
|
455
|
+
lines.push(` provider: ${provider}`);
|
|
456
|
+
lines.push(` base_url: ${JSON.stringify(baseUrl)}`);
|
|
457
|
+
const keyEnv = spec.keyEnv ?? defaultKeyEnv(provider);
|
|
458
|
+
if (keyEnv !== undefined)
|
|
459
|
+
lines.push(` api_key_env: ${JSON.stringify(keyEnv)}`);
|
|
405
460
|
}
|
|
406
461
|
}
|
|
407
|
-
|
|
462
|
+
lines.push(`default_model: ${JSON.stringify(input.judgeId)}`);
|
|
463
|
+
lines.push(`judge_model: ${JSON.stringify(input.judgeId)}`);
|
|
464
|
+
lines.push(`synthesizer_model: ${JSON.stringify(input.judgeId)}`);
|
|
465
|
+
// Generous budget: reasoning models (gpt-5.x) spend tokens on reasoning before
|
|
466
|
+
// producing content, so a small cap can yield an empty answer.
|
|
467
|
+
lines.push("sampling: {temperature: 0.2, top_p: 0.9, max_tokens: 8192}");
|
|
468
|
+
lines.push("");
|
|
469
|
+
return lines.join("\n");
|
|
408
470
|
}
|
|
409
471
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
472
|
+
* Spawn the single `fusionkit serve` router fronting every panel model + the
|
|
473
|
+
* synthesizer. MLX specs first get an in-process OpenAI-compatible gateway
|
|
474
|
+
* (loopback) that the router proxies to; cloud specs call their provider
|
|
475
|
+
* directly. Returns the router URL, an id->routerUrl endpoint map, and a close
|
|
476
|
+
* that tears down the router process and any MLX gateways it fronts.
|
|
414
477
|
*/
|
|
415
|
-
export async function
|
|
478
|
+
export async function startRouter(options) {
|
|
416
479
|
const { specs, report } = options;
|
|
417
|
-
const
|
|
418
|
-
if (judge === undefined)
|
|
419
|
-
throw new Error("at least one panel model is required");
|
|
480
|
+
const judgeSpec = judgeSpecFor(specs, options.judgeModel);
|
|
420
481
|
const models = specs.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const announceStart = (id, label) => {
|
|
482
|
+
const identity = specs.map((spec) => spec.id).sort().join(",");
|
|
483
|
+
const announceStart = (label) => {
|
|
424
484
|
if (report)
|
|
425
|
-
report({ kind: "server.start", id, label });
|
|
485
|
+
report({ kind: "server.start", id: "router", label });
|
|
426
486
|
else
|
|
427
487
|
options.log(`fusion: starting ${label}...`);
|
|
428
488
|
};
|
|
429
|
-
const announceReady = (
|
|
489
|
+
const announceReady = (detail) => {
|
|
430
490
|
if (report)
|
|
431
|
-
report({ kind: "server.ready", id, detail });
|
|
491
|
+
report({ kind: "server.ready", id: "router", detail });
|
|
432
492
|
else
|
|
433
|
-
options.log(`fusion:
|
|
493
|
+
options.log(`fusion: router ready on ${detail}`);
|
|
434
494
|
};
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
// Cloud servers inherit the parent env plus the FusionKit checkout's `.env`
|
|
445
|
-
// (so OPENAI_API_KEY / ANTHROPIC_API_KEY load seamlessly), without overriding
|
|
446
|
-
// anything already exported.
|
|
447
|
-
const cloudEnv = { ...process.env };
|
|
495
|
+
announceStart(`router · ${specs.map((spec) => spec.id).join(", ")}`);
|
|
496
|
+
// The router inherits the parent env plus the FusionKit checkout's `.env` (so
|
|
497
|
+
// provider keys load seamlessly), without overriding anything already exported.
|
|
498
|
+
// It calls providers directly and MLX over loopback (never a portless HTTPS
|
|
499
|
+
// URL), so it needs no portless CA — and must keep its default certifi bundle
|
|
500
|
+
// intact to verify real provider certificates.
|
|
501
|
+
const env = { ...process.env };
|
|
448
502
|
if (options.fusionkitDir !== undefined) {
|
|
449
|
-
loadEnvFileInto(join(options.fusionkitDir, ".env"),
|
|
503
|
+
loadEnvFileInto(join(options.fusionkitDir, ".env"), env);
|
|
450
504
|
}
|
|
451
|
-
const gateways = [];
|
|
452
505
|
const backends = [];
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
const
|
|
456
|
-
for (const child of children)
|
|
457
|
-
terminate(child);
|
|
506
|
+
const gateways = [];
|
|
507
|
+
const mlxUrls = {};
|
|
508
|
+
const closeBackends = async () => {
|
|
458
509
|
await Promise.allSettled(gateways.map((gateway) => gateway.close()));
|
|
459
510
|
await Promise.allSettled(backends.map((backend) => backend.stop()));
|
|
460
511
|
};
|
|
461
|
-
const logFileFor = (id) => options.logsDir !== undefined ? join(options.logsDir, `${id}.log`) : undefined;
|
|
462
|
-
// MLX backends are memory-heavy (each loads a model into RAM), so they start
|
|
463
|
-
// sequentially. Cloud `serve-endpoint` servers are cheap and network-bound, so
|
|
464
|
-
// they start concurrently to cut perceived boot time.
|
|
465
|
-
const mlxSpecs = specs.filter((spec) => (spec.provider ?? "mlx") === "mlx");
|
|
466
|
-
const cloudSpecs = specs.filter((spec) => (spec.provider ?? "mlx") !== "mlx");
|
|
467
512
|
try {
|
|
468
|
-
|
|
469
|
-
|
|
513
|
+
// MLX backends are memory-heavy (each loads a model into RAM), so they start
|
|
514
|
+
// sequentially before the router that fronts them.
|
|
515
|
+
for (const spec of specs) {
|
|
516
|
+
if ((spec.provider ?? "mlx") !== "mlx")
|
|
517
|
+
continue;
|
|
470
518
|
const backend = new MlxBackend({ model: spec.model });
|
|
471
519
|
await backend.start();
|
|
472
520
|
const gateway = await startGateway({ backend });
|
|
473
521
|
backends.push(backend);
|
|
474
522
|
gateways.push(gateway);
|
|
475
|
-
|
|
476
|
-
announceReady(spec.id, gateway.url());
|
|
477
|
-
}
|
|
478
|
-
for (const spec of cloudSpecs) {
|
|
479
|
-
announceStart(spec.id, `${spec.id} (${spec.provider}:${spec.model})`);
|
|
523
|
+
mlxUrls[spec.id] = gateway.url();
|
|
480
524
|
}
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
else {
|
|
501
|
-
const detail = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
502
|
-
if (report)
|
|
503
|
-
report({ kind: "server.fail", id: spec.id, detail });
|
|
504
|
-
failures.push(`${spec.id}: ${detail}`);
|
|
505
|
-
}
|
|
525
|
+
const config = routerConfigYaml({ specs, mlxUrls, judgeId: judgeSpec.id });
|
|
526
|
+
const configDir = mkdtempSync(join(tmpdir(), "fusion-router-"));
|
|
527
|
+
const configPath = join(configDir, "router.yaml");
|
|
528
|
+
writeFileSync(configPath, config);
|
|
529
|
+
const runner = fusionkitPyCommand(options.fusionkitDir);
|
|
530
|
+
const port = await freePort();
|
|
531
|
+
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
532
|
+
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
533
|
+
...(options.logsDir !== undefined ? { logFile: join(options.logsDir, "router.log") } : {}),
|
|
534
|
+
env
|
|
535
|
+
});
|
|
536
|
+
proc.child.once("exit", () => rmSync(configDir, { recursive: true, force: true }));
|
|
537
|
+
const url = `http://127.0.0.1:${port}`;
|
|
538
|
+
try {
|
|
539
|
+
await waitForHttp(`${url}/v1/models`, proc, {
|
|
540
|
+
timeoutMs: 60_000,
|
|
541
|
+
label: "fusion router",
|
|
542
|
+
requireOk: true
|
|
543
|
+
});
|
|
506
544
|
}
|
|
507
|
-
|
|
508
|
-
|
|
545
|
+
catch (error) {
|
|
546
|
+
terminate(proc.child);
|
|
547
|
+
// A provider-side rejection (bad key / model) will not be fixed by a
|
|
548
|
+
// retry, so surface the distilled cause with guidance.
|
|
549
|
+
const hint = looksPermanentFailure(proc.log()) ? " (check model names and provider API keys)" : "";
|
|
550
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${hint}`);
|
|
509
551
|
}
|
|
552
|
+
announceReady(url);
|
|
553
|
+
const endpoints = Object.fromEntries(specs.map((spec) => [spec.id, url]));
|
|
554
|
+
return {
|
|
555
|
+
url,
|
|
556
|
+
port,
|
|
557
|
+
...(proc.child.pid !== undefined ? { pid: proc.child.pid } : {}),
|
|
558
|
+
endpoints,
|
|
559
|
+
models,
|
|
560
|
+
judgeModel: judgeSpec.id,
|
|
561
|
+
identity,
|
|
562
|
+
close: async () => {
|
|
563
|
+
terminate(proc.child);
|
|
564
|
+
await closeBackends();
|
|
565
|
+
}
|
|
566
|
+
};
|
|
510
567
|
}
|
|
511
568
|
catch (error) {
|
|
512
|
-
await
|
|
569
|
+
await closeBackends();
|
|
513
570
|
throw error;
|
|
514
571
|
}
|
|
515
|
-
return {
|
|
516
|
-
endpoints,
|
|
517
|
-
judgeUrl: endpoints[judge.id] ?? Object.values(endpoints)[0] ?? "",
|
|
518
|
-
judgeModel: judge.model,
|
|
519
|
-
models,
|
|
520
|
-
close: closeAll
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
/**
|
|
524
|
-
* Spawn a `fusionkit serve` as the trajectory-synthesis backend, configured
|
|
525
|
-
* with the judge model. FusionKit owns synthesis, so the agent harness fuses
|
|
526
|
-
* its trajectories through this server's `/v1/fusion/trajectories:fuse`.
|
|
527
|
-
*/
|
|
528
|
-
export async function startSynthesisServer(input) {
|
|
529
|
-
const port = await freePort();
|
|
530
|
-
const config = [
|
|
531
|
-
"endpoints:",
|
|
532
|
-
" - id: judge",
|
|
533
|
-
" provider: openai-compatible",
|
|
534
|
-
` model: ${JSON.stringify(input.judgeModel)}`,
|
|
535
|
-
` base_url: ${JSON.stringify(input.judgeBaseUrl)}`,
|
|
536
|
-
" api_key: not-needed",
|
|
537
|
-
"default_model: judge",
|
|
538
|
-
"judge_model: judge",
|
|
539
|
-
"synthesizer_model: judge",
|
|
540
|
-
// Generous budget: reasoning models (gpt-5.x) spend tokens on reasoning
|
|
541
|
-
// before producing content, so a small cap can yield an empty answer.
|
|
542
|
-
"sampling: {temperature: 0.2, top_p: 0.9, max_tokens: 8192}",
|
|
543
|
-
""
|
|
544
|
-
].join("\n");
|
|
545
|
-
const configDir = mkdtempSync(join(tmpdir(), "fusion-synth-"));
|
|
546
|
-
const configPath = join(configDir, "synthesis.yaml");
|
|
547
|
-
writeFileSync(configPath, config);
|
|
548
|
-
input.log("fusion: starting synthesis backend (fusionkit serve)...");
|
|
549
|
-
const runner = fusionkitPyCommand(input.fusionkitDir);
|
|
550
|
-
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
551
|
-
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
552
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
553
|
-
env: input.env
|
|
554
|
-
});
|
|
555
|
-
// The temp config is only read at startup; drop it once the server exits.
|
|
556
|
-
proc.child.once("exit", () => rmSync(configDir, { recursive: true, force: true }));
|
|
557
|
-
const url = `http://127.0.0.1:${port}`;
|
|
558
|
-
try {
|
|
559
|
-
await waitForHttp(`${url}/v1/models`, proc, {
|
|
560
|
-
timeoutMs: 60_000,
|
|
561
|
-
label: "synthesis backend",
|
|
562
|
-
requireOk: true
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
catch (error) {
|
|
566
|
-
terminate(proc.child);
|
|
567
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
568
|
-
}
|
|
569
|
-
input.log(`fusion: synthesis backend ready on ${url}`);
|
|
570
|
-
return { child: proc.child, url };
|
|
571
572
|
}
|
|
572
573
|
export async function startFusionStack(options) {
|
|
573
574
|
const report = options.report;
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
let
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
575
|
+
const portless = options.portless ?? (await createPortlessSession({ enabled: false }));
|
|
576
|
+
// Full override (pre-running per-model endpoints + a pre-running synthesis
|
|
577
|
+
// URL, e.g. tests): use them verbatim and spawn no router.
|
|
578
|
+
const override = options.endpoints !== undefined && options.synthesisUrl !== undefined;
|
|
579
|
+
const judgeModelName = options.judgeModel ?? options.models[0]?.model ?? "";
|
|
580
|
+
const models = options.models.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
581
|
+
let modelEndpoints;
|
|
582
|
+
let fusionBackendUrl;
|
|
583
|
+
let routerClose = () => { };
|
|
584
|
+
if (override) {
|
|
585
|
+
modelEndpoints = options.endpoints;
|
|
586
|
+
fusionBackendUrl = options.synthesisUrl;
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
// Discover-or-spawn the single router (models + synthesis), reusing a
|
|
590
|
+
// compatible running instance (same endpoint id set) across runs.
|
|
591
|
+
const expectedIdentity = options.models.map((spec) => spec.id).sort().join(",");
|
|
592
|
+
const resolved = await portless.discoverOrSpawn({
|
|
593
|
+
name: "router",
|
|
594
|
+
identity: expectedIdentity,
|
|
595
|
+
healthCheck: async (loopbackUrl) => {
|
|
596
|
+
try {
|
|
597
|
+
const response = await fetch(`${loopbackUrl}/v1/models`, { signal: AbortSignal.timeout(2000) });
|
|
598
|
+
if (!response.ok)
|
|
599
|
+
return undefined;
|
|
600
|
+
const body = (await response.json());
|
|
601
|
+
return (body.data ?? [])
|
|
602
|
+
.map((entry) => entry.id)
|
|
603
|
+
.filter((id) => typeof id === "string" && id !== "fusionkit/router")
|
|
604
|
+
.sort()
|
|
605
|
+
.join(",");
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
spawn: async () => {
|
|
612
|
+
if (report)
|
|
613
|
+
report({ kind: "server.start", id: "router", label: "router" });
|
|
614
|
+
const router = await startRouter({
|
|
615
|
+
specs: options.models,
|
|
616
|
+
...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
|
|
617
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
618
|
+
...(options.logsDir !== undefined ? { logsDir: options.logsDir } : {}),
|
|
619
|
+
...(report !== undefined ? { report } : {}),
|
|
620
|
+
log: options.log
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
port: router.port,
|
|
624
|
+
...(router.pid !== undefined ? { pid: router.pid } : {}),
|
|
625
|
+
close: router.close
|
|
626
|
+
};
|
|
592
627
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
log: report ? () => { } : options.log
|
|
602
|
-
});
|
|
603
|
-
synthesisChild = synthesis.child;
|
|
604
|
-
synthesisUrl = synthesis.url;
|
|
605
|
-
if (report)
|
|
606
|
-
report({ kind: "synth.ready", detail: synthesis.url });
|
|
607
|
-
}
|
|
628
|
+
});
|
|
629
|
+
// The harness + the in-process step call reach the router over loopback (the
|
|
630
|
+
// portless name is for humans); see the CA-at-startup note in portless.ts.
|
|
631
|
+
modelEndpoints = Object.fromEntries(options.models.map((spec) => [spec.id, resolved.loopbackUrl]));
|
|
632
|
+
fusionBackendUrl = resolved.loopbackUrl;
|
|
633
|
+
routerClose = resolved.close;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
608
636
|
if (report)
|
|
609
637
|
report({ kind: "gateway.start" });
|
|
610
638
|
// The judge-streamed-trajectory front door: each panel model produces a
|
|
611
639
|
// trajectory and the judge emits the trajectory the user's tool executes.
|
|
612
640
|
const gatewayConfig = {
|
|
613
|
-
fusionBackendUrl
|
|
641
|
+
fusionBackendUrl,
|
|
614
642
|
repo: options.repo,
|
|
615
643
|
outputRoot: options.outputRoot,
|
|
616
644
|
harnesses: ["agent"],
|
|
617
|
-
models
|
|
618
|
-
judgeModel:
|
|
619
|
-
modelEndpoints
|
|
645
|
+
models,
|
|
646
|
+
judgeModel: judgeModelName,
|
|
647
|
+
modelEndpoints,
|
|
620
648
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
621
649
|
};
|
|
622
650
|
const gateway = await startFusionStepGateway({
|
|
@@ -625,23 +653,23 @@ export async function startFusionStack(options) {
|
|
|
625
653
|
port: options.port ?? 0,
|
|
626
654
|
...(options.authToken !== undefined ? { authToken: options.authToken } : {})
|
|
627
655
|
});
|
|
656
|
+
// The gateway runs in-process (dies with this CLI), so it gets a per-run
|
|
657
|
+
// portless name rather than being a cross-run singleton.
|
|
658
|
+
const fusionUrl = portless.register("gateway", gateway.port());
|
|
628
659
|
if (report)
|
|
629
|
-
report({ kind: "gateway.ready", detail:
|
|
660
|
+
report({ kind: "gateway.ready", detail: fusionUrl });
|
|
630
661
|
return {
|
|
631
|
-
fusionUrl
|
|
632
|
-
endpoints:
|
|
662
|
+
fusionUrl,
|
|
663
|
+
endpoints: modelEndpoints,
|
|
633
664
|
close: async () => {
|
|
634
665
|
await gateway.close();
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
await servers.close();
|
|
666
|
+
portless.unregister("gateway");
|
|
667
|
+
await routerClose();
|
|
638
668
|
}
|
|
639
669
|
};
|
|
640
670
|
}
|
|
641
671
|
catch (error) {
|
|
642
|
-
|
|
643
|
-
terminate(synthesisChild);
|
|
644
|
-
await servers.close();
|
|
672
|
+
await routerClose();
|
|
645
673
|
throw error;
|
|
646
674
|
}
|
|
647
675
|
}
|
|
@@ -664,7 +692,7 @@ function scrubbedBridgeEnv() {
|
|
|
664
692
|
export async function startCursorBridge(input) {
|
|
665
693
|
const port = await freePort();
|
|
666
694
|
const env = {
|
|
667
|
-
...scrubbedBridgeEnv(),
|
|
695
|
+
...withCaEnv(scrubbedBridgeEnv(), input.caCertPath),
|
|
668
696
|
BRIDGE_PORT: String(port),
|
|
669
697
|
BRIDGE_ROUTE_INVENTORY: "true",
|
|
670
698
|
CURSOR_UPSTREAM_BASE_URL: "https://api2.cursor.sh",
|
|
@@ -689,6 +717,12 @@ export async function startCursorBridge(input) {
|
|
|
689
717
|
input.log(`fusion: Cursorkit bridge listening on http://127.0.0.1:${port}`);
|
|
690
718
|
return { child: proc.child, port };
|
|
691
719
|
}
|
|
720
|
+
/** Whether portless is enabled: explicit flag/config wins, else on unless PORTLESS=0. */
|
|
721
|
+
export function portlessEnabled(options) {
|
|
722
|
+
if (options.portless !== undefined)
|
|
723
|
+
return options.portless;
|
|
724
|
+
return process.env.PORTLESS !== "0";
|
|
725
|
+
}
|
|
692
726
|
export async function runFusion(tool, toolArgs, options = {}) {
|
|
693
727
|
const log = options.log ?? ((line) => console.error(line));
|
|
694
728
|
const root = mkdtempSync(join(tmpdir(), "fusionkit-fusion-"));
|
|
@@ -716,6 +750,11 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
716
750
|
const models = options.models ?? (options.local === true ? [...DEFAULT_TRIO] : [...DEFAULT_CLOUD_PANEL]);
|
|
717
751
|
// Fail fast on missing prerequisites before we start spawning a stack.
|
|
718
752
|
runPreflight(preflightRequirements(tool, models, options));
|
|
753
|
+
// Bring up the portless session (programmatic RouteStore). Portless is a
|
|
754
|
+
// polish layer, never a hard requirement: when it is off, unavailable (Node <
|
|
755
|
+
// 24), or its proxy isn't running, the session degrades to loopback URLs and
|
|
756
|
+
// logs a one-line hint, so a fresh install always runs out of the box.
|
|
757
|
+
const portless = await createPortlessSession({ enabled: portlessEnabled(options), log });
|
|
719
758
|
const judgeLabel = options.judgeModel ?? models[0]?.model ?? "(first panel model)";
|
|
720
759
|
// The live boot checklist only renders on an interactive TTY when the caller
|
|
721
760
|
// did not supply its own log sink (tests/programmatic callers stay on the
|
|
@@ -776,13 +815,15 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
776
815
|
return 130;
|
|
777
816
|
}
|
|
778
817
|
}
|
|
779
|
-
// The live boot checklist, driven by structured stack events.
|
|
818
|
+
// The live boot checklist, driven by structured stack events. The panel now
|
|
819
|
+
// runs behind a single `fusionkit serve` router (models + synthesis), so the
|
|
820
|
+
// checklist shows one router row instead of one per model; the override path
|
|
821
|
+
// (pre-running endpoints + synthesis) spawns nothing.
|
|
822
|
+
const spawnsRouter = !(options.endpoints !== undefined && options.synthesisUrl !== undefined);
|
|
780
823
|
const boot = useBootView
|
|
781
824
|
? createBootView({
|
|
782
|
-
servers:
|
|
783
|
-
|
|
784
|
-
: [],
|
|
785
|
-
includeSynth: options.synthesisUrl === undefined,
|
|
825
|
+
servers: spawnsRouter ? [{ id: "router", label: `router · ${models.map((model) => model.id).join(", ")}` }] : [],
|
|
826
|
+
includeSynth: false,
|
|
786
827
|
includeDashboard: options.observe === true,
|
|
787
828
|
title: dim("booting the fusion stack")
|
|
788
829
|
})
|
|
@@ -806,6 +847,7 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
806
847
|
observability = await startObservability({
|
|
807
848
|
log,
|
|
808
849
|
logFile: join(logsDir, "dashboard.log"),
|
|
850
|
+
portless,
|
|
809
851
|
...(report !== undefined ? { report } : {})
|
|
810
852
|
});
|
|
811
853
|
disposers.push(() => observability?.close() ?? Promise.resolve());
|
|
@@ -831,6 +873,7 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
831
873
|
outputRoot: join(root, "runs"),
|
|
832
874
|
models,
|
|
833
875
|
logsDir,
|
|
876
|
+
portless,
|
|
834
877
|
...(report !== undefined ? { report } : {}),
|
|
835
878
|
...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
|
|
836
879
|
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
@@ -912,16 +955,19 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
912
955
|
cursorKitDir,
|
|
913
956
|
fusionUrl: stack.fusionUrl,
|
|
914
957
|
logFile: join(logsDir, "cursor-bridge.log"),
|
|
958
|
+
...(portless.caCertPath !== undefined ? { caCertPath: portless.caCertPath } : {}),
|
|
915
959
|
log
|
|
916
960
|
});
|
|
917
961
|
bridge = started.child;
|
|
962
|
+
const bridgeUrl = portless.register("cursor", started.port);
|
|
918
963
|
disposers.push(() => {
|
|
964
|
+
portless.unregister("cursor");
|
|
919
965
|
if (bridge !== undefined)
|
|
920
966
|
terminate(bridge);
|
|
921
967
|
});
|
|
922
968
|
prepareForPassthrough();
|
|
923
969
|
log("fusion: launching cursor-agent...");
|
|
924
|
-
return await spawnTool("cursor-agent", ["--endpoint",
|
|
970
|
+
return await spawnTool("cursor-agent", ["--endpoint", bridgeUrl, "--model", FUSION_MODEL_LABEL, ...toolArgs], {}, repo);
|
|
925
971
|
}
|
|
926
972
|
default: {
|
|
927
973
|
const unreachable = tool;
|