@fusionkit/cli 0.1.2 → 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.
Files changed (46) hide show
  1. package/dist/commands/fusion.js +17 -2
  2. package/dist/commands/plane.js +10 -2
  3. package/dist/fusion-config.d.ts +1 -0
  4. package/dist/fusion-config.js +5 -0
  5. package/dist/fusion-quickstart.d.ts +33 -34
  6. package/dist/fusion-quickstart.js +324 -278
  7. package/dist/shared/portless.d.ts +97 -0
  8. package/dist/shared/portless.js +253 -0
  9. package/dist/test/portless.test.d.ts +1 -0
  10. package/dist/test/portless.test.js +65 -0
  11. package/package.json +12 -9
  12. package/scope/.next/BUILD_ID +1 -1
  13. package/scope/.next/app-build-manifest.json +14 -14
  14. package/scope/.next/app-path-routes-manifest.json +4 -4
  15. package/scope/.next/build-manifest.json +2 -2
  16. package/scope/.next/prerender-manifest.json +13 -13
  17. package/scope/.next/required-server-files.json +4 -0
  18. package/scope/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  19. package/scope/.next/server/app/_not-found.html +1 -1
  20. package/scope/.next/server/app/_not-found.rsc +1 -1
  21. package/scope/.next/server/app/api/environments/route_client-reference-manifest.js +1 -1
  22. package/scope/.next/server/app/api/ingest/route_client-reference-manifest.js +1 -1
  23. package/scope/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
  24. package/scope/.next/server/app/api/replay/route_client-reference-manifest.js +1 -1
  25. package/scope/.next/server/app/api/sessions/[traceId]/route_client-reference-manifest.js +1 -1
  26. package/scope/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
  27. package/scope/.next/server/app/api/stream/route_client-reference-manifest.js +1 -1
  28. package/scope/.next/server/app/environments/page_client-reference-manifest.js +1 -1
  29. package/scope/.next/server/app/environments.html +1 -1
  30. package/scope/.next/server/app/environments.rsc +1 -1
  31. package/scope/.next/server/app/index.html +1 -1
  32. package/scope/.next/server/app/index.rsc +1 -1
  33. package/scope/.next/server/app/models/page_client-reference-manifest.js +1 -1
  34. package/scope/.next/server/app/models.html +1 -1
  35. package/scope/.next/server/app/models.rsc +1 -1
  36. package/scope/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/scope/.next/server/app/sessions/[traceId]/page_client-reference-manifest.js +1 -1
  38. package/scope/.next/server/app-paths-manifest.json +4 -4
  39. package/scope/.next/server/functions-config-manifest.json +2 -2
  40. package/scope/.next/server/pages/404.html +1 -1
  41. package/scope/.next/server/pages/500.html +1 -1
  42. package/scope/.next/server/server-reference-manifest.json +1 -1
  43. package/scope/package.json +3 -1
  44. package/scope/server.js +1 -1
  45. /package/scope/.next/static/{hyFZFZhpE5yuTp_t6QAIZ → 5tnFLuvnSbNZNtqRgoot8}/_buildManifest.js +0 -0
  46. /package/scope/.next/static/{hyFZFZhpE5yuTp_t6QAIZ → 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, sleep, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "./shared/proc.js";
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.1.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
- const bundled = bundledScopeServer();
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
- proc =
295
- bundled !== undefined
296
- ? startBundledDashboard({
297
- serverJs: bundled,
298
- env: childEnv,
299
- ...(input.logFile !== undefined ? { logFile: input.logFile } : {})
300
- })
301
- : startDevDashboard({
302
- env: childEnv,
303
- traceDir,
304
- ...(input.logFile !== undefined ? { logFile: input.logFile } : {})
305
- });
306
- }
307
- catch (error) {
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
- ingestUrl: `${url}/api/ingest`,
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
- terminate(proc.child);
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
- async function spawnCloudServer(input) {
350
- const keyEnv = input.spec.keyEnv ?? defaultKeyEnv(input.provider);
351
- const runner = fusionkitPyCommand(input.fusionkitDir);
352
- const label = `${input.spec.id} (${input.provider}:${input.spec.model})`;
353
- const maxAttempts = 3;
354
- let lastError;
355
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
356
- const port = await freePort();
357
- const args = [
358
- ...runner.prefix,
359
- "serve-endpoint",
360
- "--id",
361
- input.spec.id,
362
- "--model",
363
- input.spec.model,
364
- "--provider",
365
- input.provider,
366
- "--host",
367
- "127.0.0.1",
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
- catch (error) {
392
- terminate(proc.child);
393
- lastError = error instanceof Error ? error : new Error(String(error));
394
- // A missing runner binary or a provider-side rejection (bad key / model)
395
- // will not be fixed by retrying — surface it immediately with guidance.
396
- const permanent = proc.spawnError() !== undefined || looksPermanentFailure(proc.log());
397
- if (permanent || attempt === maxAttempts) {
398
- const keyHint = keyEnv !== undefined ? ` and that ${keyEnv} grants access` : "";
399
- throw new Error(`panel model "${input.spec.id}" (${input.provider}:${input.spec.model}) could not start.\n` +
400
- ` Check the model name${keyHint}.\n` +
401
- ` ${lastError.message}`);
402
- }
403
- // Transient: brief backoff, then retry on a fresh port.
404
- await sleep(500 * attempt);
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
- throw lastError ?? new Error(`panel model "${input.spec.id}" could not start`);
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
- * Bring up one real model server per panel model and return an id -> base URL
411
- * map. `mlx` specs run locally; cloud specs are fronted by FusionKit. When
412
- * `endpoints` is supplied (pre-running servers or tests), those are used
413
- * verbatim and nothing is spawned.
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 startModelServers(options) {
478
+ export async function startRouter(options) {
416
479
  const { specs, report } = options;
417
- const judge = specs[0];
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
- // Prefer structured events when a reporter is present; otherwise keep the
422
- // plain line logs that non-interactive callers and tests rely on.
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 = (id, detail) => {
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: ${id} ready on ${detail}`);
493
+ options.log(`fusion: router ready on ${detail}`);
434
494
  };
435
- if (options.endpoints !== undefined) {
436
- return {
437
- endpoints: options.endpoints,
438
- judgeUrl: options.endpoints[judge.id] ?? Object.values(options.endpoints)[0] ?? "",
439
- judgeModel: judge.model,
440
- models,
441
- close: async () => { }
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"), cloudEnv);
503
+ loadEnvFileInto(join(options.fusionkitDir, ".env"), env);
450
504
  }
451
- const gateways = [];
452
505
  const backends = [];
453
- const children = [];
454
- const endpoints = {};
455
- const closeAll = async () => {
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
- for (const spec of mlxSpecs) {
469
- announceStart(spec.id, `${spec.id} (${spec.model})`);
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
- endpoints[spec.id] = gateway.url();
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 cloudResults = await Promise.allSettled(cloudSpecs.map((spec) => spawnCloudServer({
482
- spec,
483
- provider: (spec.provider ?? "mlx"),
484
- ...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
485
- ...(logFileFor(spec.id) !== undefined ? { logFile: logFileFor(spec.id) } : {}),
486
- env: cloudEnv,
487
- log: () => { }
488
- }).then((started) => ({ id: spec.id, started }))));
489
- const failures = [];
490
- for (let index = 0; index < cloudResults.length; index++) {
491
- const result = cloudResults[index];
492
- const spec = cloudSpecs[index];
493
- if (result === undefined || spec === undefined)
494
- continue;
495
- if (result.status === "fulfilled") {
496
- children.push(result.value.started.child);
497
- endpoints[result.value.id] = result.value.started.url;
498
- announceReady(result.value.id, result.value.started.url);
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
- if (failures.length > 0) {
508
- throw new Error(`panel server(s) failed to start:\n${failures.join("\n")}`);
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 closeAll();
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 servers = await startModelServers({
575
- specs: options.models,
576
- ...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
577
- ...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
578
- ...(options.logsDir !== undefined ? { logsDir: options.logsDir } : {}),
579
- ...(report !== undefined ? { report } : {}),
580
- log: options.log
581
- });
582
- let synthesisChild;
583
- let synthesisUrl = options.synthesisUrl ?? servers.judgeUrl;
584
- try {
585
- // Trajectory fusion needs a FusionKit synthesizer; spawn one (via `uvx
586
- // fusionkit serve`, or a local checkout if --fusionkit-dir is given) unless
587
- // the caller supplied a pre-running server via --synthesis-url.
588
- if (options.synthesisUrl === undefined) {
589
- const cloudEnv = { ...process.env };
590
- if (options.fusionkitDir !== undefined) {
591
- loadEnvFileInto(join(options.fusionkitDir, ".env"), cloudEnv);
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
- if (report)
594
- report({ kind: "synth.start" });
595
- const synthesis = await startSynthesisServer({
596
- ...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
597
- ...(options.logsDir !== undefined ? { logFile: join(options.logsDir, "synthesis.log") } : {}),
598
- judgeModel: options.judgeModel ?? servers.judgeModel,
599
- judgeBaseUrl: servers.judgeUrl,
600
- env: cloudEnv,
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: synthesisUrl,
641
+ fusionBackendUrl,
614
642
  repo: options.repo,
615
643
  outputRoot: options.outputRoot,
616
644
  harnesses: ["agent"],
617
- models: servers.models,
618
- judgeModel: options.judgeModel ?? servers.judgeModel,
619
- modelEndpoints: servers.endpoints,
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: gateway.url() });
660
+ report({ kind: "gateway.ready", detail: fusionUrl });
630
661
  return {
631
- fusionUrl: gateway.url(),
632
- endpoints: servers.endpoints,
662
+ fusionUrl,
663
+ endpoints: modelEndpoints,
633
664
  close: async () => {
634
665
  await gateway.close();
635
- if (synthesisChild !== undefined)
636
- terminate(synthesisChild);
637
- await servers.close();
666
+ portless.unregister("gateway");
667
+ await routerClose();
638
668
  }
639
669
  };
640
670
  }
641
671
  catch (error) {
642
- if (synthesisChild !== undefined)
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: options.endpoints === undefined
783
- ? models.map((model) => ({ id: model.id, label: `${model.id} · ${model.model}` }))
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", `http://127.0.0.1:${started.port}`, "--model", FUSION_MODEL_LABEL, ...toolArgs], {}, repo);
970
+ return await spawnTool("cursor-agent", ["--endpoint", bridgeUrl, "--model", FUSION_MODEL_LABEL, ...toolArgs], {}, repo);
925
971
  }
926
972
  default: {
927
973
  const unreachable = tool;