turbollm 0.2.0 → 0.3.0

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/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { spawn as spawn3 } from "child_process";
5
5
  import { openSync as openSync3, readFileSync as readFileSync8 } from "fs";
6
6
  import { serve } from "@hono/node-server";
7
- import { dirname as dirname7, join as join13 } from "path";
7
+ import { dirname as dirname7, join as join14 } from "path";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
9
 
10
10
  // src/config/config.ts
@@ -87,7 +87,7 @@ function defaultConfig() {
87
87
  hf: { token: "" },
88
88
  modelDefaults: { ctx: 8192, ngl: 99, imageMaxTokens: 0, maxTokens: 0 },
89
89
  featuredOverrideUrl: "",
90
- comfyui: { enabled: false, gatePath: "" }
90
+ comfyui: { enabled: false, gatePath: "", url: "", reverseGate: false, cachePersist: false }
91
91
  };
92
92
  }
93
93
  var ConfigStore = class _ConfigStore {
@@ -217,7 +217,17 @@ function normalize(c) {
217
217
  c.autoLoadOnStart ??= false;
218
218
  c.featuredOverrideUrl ??= "";
219
219
  const cu = c.comfyui ?? {};
220
- c.comfyui = { enabled: !!cu.enabled, gatePath: typeof cu.gatePath === "string" ? cu.gatePath : "" };
220
+ c.comfyui = {
221
+ enabled: !!cu.enabled,
222
+ gatePath: typeof cu.gatePath === "string" ? cu.gatePath : "",
223
+ // Reverse gate (F-011): ComfyUI origin + opt-in toggle. Absent in pre-F-011 configs
224
+ // → '' / false. Reseated here (like the other known fields) so they aren't dropped.
225
+ url: typeof cu.url === "string" ? cu.url : "",
226
+ reverseGate: !!cu.reverseGate,
227
+ // KV prompt-cache persistence (F-014): opt-in. Absent in pre-F-014 configs → false.
228
+ // Reseated like the other known fields so it isn't dropped on every load.
229
+ cachePersist: !!cu.cachePersist
230
+ };
221
231
  c.telemetry.level = normalizeTelemetryLevel(c.telemetry.level);
222
232
  for (const e of c.engines) {
223
233
  e.capabilities ??= { kvTypes: [], flags: [] };
@@ -244,6 +254,9 @@ function validate(c) {
244
254
  if (c.activeEngineId && !c.engines.some((e) => e.id === c.activeEngineId)) {
245
255
  throw new ValueError("activeEngineId", "unknown engine id");
246
256
  }
257
+ if (c.comfyui.url && !/^https?:\/\//i.test(c.comfyui.url)) {
258
+ throw new ValueError("comfyui.url", "must be an http(s):// origin (e.g. http://127.0.0.1:8188)");
259
+ }
247
260
  }
248
261
  function isAbsolutePath(p) {
249
262
  return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
@@ -259,9 +272,9 @@ function clampMaxTokens(requested, limit) {
259
272
 
260
273
  // src/engines/manager.ts
261
274
  import { execFile as execFile4, spawn } from "child_process";
262
- import { createWriteStream as createWriteStream2, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync2 } from "fs";
275
+ import { createWriteStream as createWriteStream2, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2 } from "fs";
263
276
  import { createServer } from "net";
264
- import { dirname as dirname2, join as join5 } from "path";
277
+ import { dirname as dirname2, join as join6 } from "path";
265
278
 
266
279
  // src/engines/mlx.ts
267
280
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, rmSync as rmSync2 } from "fs";
@@ -557,21 +570,110 @@ function mlxServerCommand(python, model, port2, host2) {
557
570
  };
558
571
  }
559
572
 
573
+ // src/engines/slot-cache.ts
574
+ import { createHash } from "crypto";
575
+ import { existsSync as existsSync4, readdirSync as readdirSync2, rmSync as rmSync3, statSync } from "fs";
576
+ import { join as join4 } from "path";
577
+ var SAVE_CAP_MS = (() => {
578
+ const v = Number(process.env.TURBOLLM_SLOT_CACHE_SAVE_CAP_MS);
579
+ return Number.isFinite(v) && v > 0 ? v : 2500;
580
+ })();
581
+ var TTL_MS = (() => {
582
+ const v = Number(process.env.TURBOLLM_SLOT_CACHE_TTL_MIN);
583
+ return Number.isFinite(v) && v > 0 ? v * 6e4 : 60 * 6e4;
584
+ })();
585
+ function slotCacheDir(dataDir) {
586
+ return join4(dataDir, "slot-cache");
587
+ }
588
+ function slotCacheKey(opts) {
589
+ const material = `${opts.modelPath}\0${opts.extraArgs.join(" ")}\0${opts.engine.version}`;
590
+ const hash = createHash("sha256").update(material).digest("hex").slice(0, 16);
591
+ return `slot-${hash}.bin`;
592
+ }
593
+ function parallelIsOne(extraArgs) {
594
+ const i = extraArgs.indexOf("--parallel");
595
+ if (i === -1) return true;
596
+ return extraArgs[i + 1] === "1";
597
+ }
598
+ function cacheEligible(opts, cfg2) {
599
+ return cfg2.enabled && cfg2.cachePersist && opts.engine.kind === "llama-server" && !opts.model.vision && parallelIsOne(opts.extraArgs);
600
+ }
601
+ function sweepExpired(dir, ttlMs, now) {
602
+ if (!existsSync4(dir)) return;
603
+ let names;
604
+ try {
605
+ names = readdirSync2(dir);
606
+ } catch {
607
+ return;
608
+ }
609
+ for (const name of names) {
610
+ if (!/^slot-.*\.bin$/.test(name)) continue;
611
+ const full = join4(dir, name);
612
+ try {
613
+ if (now - statSync(full).mtimeMs > ttlMs) rmSync3(full, { force: true });
614
+ } catch {
615
+ }
616
+ }
617
+ }
618
+ async function saveSlot(p) {
619
+ sweepExpired(p.dir, p.ttlMs, p.now);
620
+ try {
621
+ console.log(`[slot-cache] saving the prompt cache before ComfyUI takes the GPU (cap ${p.capMs}ms).`);
622
+ const res = await p.http(`${p.base}/slots/0?action=save`, {
623
+ method: "POST",
624
+ headers: { "content-type": "application/json" },
625
+ body: JSON.stringify({ filename: p.filename }),
626
+ signal: AbortSignal.timeout(p.capMs)
627
+ });
628
+ if (!res.ok) {
629
+ console.warn(`[slot-cache] save returned ${res.status} \u2014 skipping the cache this cycle.`);
630
+ return false;
631
+ }
632
+ return true;
633
+ } catch (e) {
634
+ console.warn(`[slot-cache] could not save the prompt cache (${e instanceof Error ? e.message : e}) \u2014 skipping it this cycle.`);
635
+ return false;
636
+ }
637
+ }
638
+ async function restoreSlot(p) {
639
+ try {
640
+ console.log("[slot-cache] restoring the prompt cache after the ComfyUI reload (skipping the re-prefill).");
641
+ const res = await p.http(`${p.base}/slots/0?action=restore`, {
642
+ method: "POST",
643
+ headers: { "content-type": "application/json" },
644
+ body: JSON.stringify({ filename: p.filename }),
645
+ signal: AbortSignal.timeout(3e4)
646
+ });
647
+ if (!res.ok) {
648
+ console.warn(`[slot-cache] restore returned ${res.status} \u2014 the prompt will be re-prefilled normally.`);
649
+ return false;
650
+ }
651
+ try {
652
+ rmSync3(join4(p.dir, p.filename), { force: true });
653
+ } catch {
654
+ }
655
+ return true;
656
+ } catch (e) {
657
+ console.warn(`[slot-cache] could not restore the prompt cache (${e instanceof Error ? e.message : e}) \u2014 re-prefilling normally.`);
658
+ return false;
659
+ }
660
+ }
661
+
560
662
  // src/engines/vllm.ts
561
- import { existsSync as existsSync4 } from "fs";
663
+ import { existsSync as existsSync5 } from "fs";
562
664
  import { execFile as execFile3 } from "child_process";
563
- import { join as join4 } from "path";
665
+ import { join as join5 } from "path";
564
666
  import { promisify as promisify3 } from "util";
565
667
  var execFileP3 = promisify3(execFile3);
566
668
  var VLLM_PYTHON = "3.12";
567
669
  function venvPython2(envDir) {
568
- return process.platform === "win32" ? join4(envDir, "Scripts", "python.exe") : join4(envDir, "bin", "python");
670
+ return process.platform === "win32" ? join5(envDir, "Scripts", "python.exe") : join5(envDir, "bin", "python");
569
671
  }
570
672
  async function ensureVllmEnv(root, onProgress) {
571
673
  const uv = await ensureUv(root, onProgress);
572
- const envDir = join4(root, "vllm", "venv");
674
+ const envDir = join5(root, "vllm", "venv");
573
675
  const py = venvPython2(envDir);
574
- if (!existsSync4(py)) {
676
+ if (!existsSync5(py)) {
575
677
  onProgress?.({ phase: "extracting", pct: -1 });
576
678
  await execFileP3(uv, ["venv", "--python", VLLM_PYTHON, envDir], { cwd: root });
577
679
  }
@@ -591,20 +693,19 @@ async function probeVllm(python) {
591
693
  );
592
694
  return `vllm ${stdout.trim()}`;
593
695
  }
594
- function vllmServerCommand(python, model, port2, host2) {
595
- return {
596
- cmd: python,
597
- args: [
598
- "-m",
599
- "vllm.entrypoints.openai.api_server",
600
- "--model",
601
- model,
602
- "--host",
603
- host2,
604
- "--port",
605
- String(port2)
606
- ]
607
- };
696
+ function vllmServerCommand(python, model, port2, host2, tensorParallelSize = 1) {
697
+ const args = [
698
+ "-m",
699
+ "vllm.entrypoints.openai.api_server",
700
+ "--model",
701
+ model,
702
+ "--host",
703
+ host2,
704
+ "--port",
705
+ String(port2)
706
+ ];
707
+ if (tensorParallelSize > 1) args.push("--tensor-parallel-size", String(tensorParallelSize));
708
+ return { cmd: python, args };
608
709
  }
609
710
 
610
711
  // src/engines/manager.ts
@@ -657,14 +758,23 @@ var Manager = class {
657
758
  if (!opts.engine.binPath) throw new Error("no_active_engine");
658
759
  if (!opts.modelPath) throw new Error("no_such_model");
659
760
  const port2 = await allocPort();
660
- const logPath = join5(this.store.dir(), "logs", `engine-${opts.engine.id}.log`);
761
+ const logPath = join6(this.store.dir(), "logs", `engine-${opts.engine.id}.log`);
661
762
  mkdirSync4(dirname2(logPath), { recursive: true });
662
763
  const logStream = createWriteStream2(logPath);
663
764
  logStream.write(
664
765
  `[turbollm] starting engine "${opts.engine.name}" on internal port ${port2} (127.0.0.1 only \u2014 the engine's own port, NOT the TurboLLM app/UI port).
665
766
  `
666
767
  );
667
- const { cmd, args } = engineCommand(opts, port2);
768
+ const cfg2 = this.store.snapshot();
769
+ let slotSavePath;
770
+ if (cfg2.comfyui.enabled && cfg2.comfyui.cachePersist && opts.engine.kind === "llama-server") {
771
+ const flags = opts.engine.capabilities.flags;
772
+ if (flags.length === 0 || flags.includes("--slot-save-path")) {
773
+ slotSavePath = slotCacheDir(this.store.dir());
774
+ mkdirSync4(slotSavePath, { recursive: true });
775
+ }
776
+ }
777
+ const { cmd, args } = engineCommand(opts, port2, slotSavePath);
668
778
  const child = spawn(cmd, args, { cwd: dirname2(cmd), windowsHide: true });
669
779
  child.stdout?.pipe(logStream, { end: false });
670
780
  child.stderr?.pipe(logStream, { end: false });
@@ -870,23 +980,24 @@ var Manager = class {
870
980
  if (idle) this.stop();
871
981
  }
872
982
  };
873
- function engineCommand(opts, port2) {
983
+ function engineCommand(opts, port2, slotSavePath) {
874
984
  if (opts.engine.kind === "mlx") {
875
985
  return mlxServerCommand(opts.engine.binPath, opts.modelPath, port2, "127.0.0.1");
876
986
  }
877
987
  if (opts.engine.kind === "vllm") {
878
- return vllmServerCommand(opts.engine.binPath, opts.modelPath, port2, "127.0.0.1");
988
+ return vllmServerCommand(opts.engine.binPath, opts.modelPath, port2, "127.0.0.1", opts.tensorParallelSize);
879
989
  }
880
- return { cmd: opts.engine.binPath, args: buildArgs(opts, port2) };
990
+ return { cmd: opts.engine.binPath, args: buildArgs(opts, port2, slotSavePath) };
881
991
  }
882
992
  function readinessTimeoutMs(kind) {
883
993
  return kind === "vllm" ? 6e5 : 12e4;
884
994
  }
885
- function buildArgs(opts, port2) {
995
+ function buildArgs(opts, port2, slotSavePath) {
886
996
  const args = ["-m", opts.modelPath, "--host", "127.0.0.1", "--port", String(port2)];
887
997
  const flags = opts.engine.capabilities.flags;
888
998
  if (flags.length === 0 || flags.includes("--metrics")) args.push("--metrics");
889
999
  if (flags.includes("--no-webui")) args.push("--no-webui");
1000
+ if (slotSavePath) args.push("--slot-save-path", slotSavePath);
890
1001
  args.push(...opts.extraArgs);
891
1002
  return args;
892
1003
  }
@@ -945,7 +1056,7 @@ function forceKill(child) {
945
1056
  }
946
1057
  }
947
1058
  function readTail(path, n) {
948
- if (!path || !existsSync5(path)) return [];
1059
+ if (!path || !existsSync6(path)) return [];
949
1060
  try {
950
1061
  const lines = readFileSync2(path, "utf8").replace(/[\r\n]+$/, "").split("\n").map((l) => l.replace(/\r$/, ""));
951
1062
  return lines.length > n ? lines.slice(-n) : lines;
@@ -958,15 +1069,17 @@ function sleep(ms) {
958
1069
  }
959
1070
 
960
1071
  // src/engines/comfy-guard.ts
1072
+ var FREE_TIMEOUT_MS = 1e4;
961
1073
  var LEASE_MINUTES = (() => {
962
1074
  const v = Number(process.env.TURBOLLM_COMFY_LEASE_MIN);
963
1075
  return Number.isFinite(v) && v > 0 ? v : 30;
964
1076
  })();
965
1077
  var ComfyGuard = class {
966
- constructor(store2, manager2, leaseMinutes = LEASE_MINUTES) {
1078
+ constructor(store2, manager2, leaseMinutes = LEASE_MINUTES, fetchImpl = globalThis.fetch) {
967
1079
  this.store = store2;
968
1080
  this.manager = manager2;
969
1081
  this.leaseMs = Math.max(1, leaseMinutes * 6e4);
1082
+ this.fetchImpl = fetchImpl;
970
1083
  }
971
1084
  store;
972
1085
  manager;
@@ -977,7 +1090,15 @@ var ComfyGuard = class {
977
1090
  lastSignalAt = 0;
978
1091
  // Serialize concurrent acquire() calls (rapid-fire enqueues) onto one unload.
979
1092
  acquiring = null;
1093
+ // Serialize concurrent reverse free-calls (rapid LLM↔image alternation) onto one
1094
+ // POST so we never double-free or race two /free requests at ComfyUI.
1095
+ freeing = null;
1096
+ // KV prompt-cache persistence (F-014): the slot filename we successfully saved THIS
1097
+ // cycle (or null if the save was skipped/failed/ineligible). Gates the restore in
1098
+ // release() — we only restore a cache we actually wrote this acquire.
1099
+ cachedFile = null;
980
1100
  leaseMs;
1101
+ fetchImpl;
981
1102
  enabled() {
982
1103
  return this.store.snapshot().comfyui.enabled;
983
1104
  }
@@ -997,9 +1118,29 @@ var ComfyGuard = class {
997
1118
  if (this.acquiring) return this.acquiring;
998
1119
  this.acquiring = (async () => {
999
1120
  const st = this.manager.status();
1121
+ this.cachedFile = null;
1000
1122
  if (st.state === "running" || st.state === "starting") {
1001
1123
  const opts = this.manager.currentOpts();
1002
1124
  if (opts && !this.suspended) this.suspended = opts;
1125
+ if (st.state === "running" && opts) {
1126
+ const cfg2 = this.store.snapshot().comfyui;
1127
+ if (cacheEligible(opts, cfg2)) {
1128
+ const base2 = this.manager.target();
1129
+ if (base2) {
1130
+ const filename = slotCacheKey(opts);
1131
+ const ok = await saveSlot({
1132
+ http: this.fetchImpl,
1133
+ base: base2,
1134
+ dir: slotCacheDir(this.store.dir()),
1135
+ filename,
1136
+ capMs: SAVE_CAP_MS,
1137
+ ttlMs: TTL_MS,
1138
+ now: Date.now()
1139
+ });
1140
+ this.cachedFile = ok ? filename : null;
1141
+ }
1142
+ }
1143
+ }
1003
1144
  console.log("[comfy-guard] ComfyUI acquired the GPU \u2014 force-unloading the model.");
1004
1145
  await this.manager.stopAndWait({ force: true });
1005
1146
  }
@@ -1023,10 +1164,74 @@ var ComfyGuard = class {
1023
1164
  console.log("[comfy-guard] ComfyUI released the GPU \u2014 reloading the previous model.");
1024
1165
  try {
1025
1166
  await this.manager.start(opts);
1167
+ if (this.cachedFile) {
1168
+ await this.restoreAfterReady(this.cachedFile);
1169
+ }
1026
1170
  } catch (e) {
1027
1171
  console.warn(`[comfy-guard] reload after ComfyUI failed: ${e instanceof Error ? e.message : e}`);
1028
1172
  }
1029
1173
  }
1174
+ this.cachedFile = null;
1175
+ }
1176
+ /** Wait for the just-reloaded engine to reach 'running', then restore the saved KV cache
1177
+ * (F-014). Bounded poll: bails on 'error' or after ~130s (just over the manager's 120s
1178
+ * readiness window). The restore itself is non-fatal — any failure leaves the prefix to
1179
+ * re-prefill normally. */
1180
+ async restoreAfterReady(filename) {
1181
+ const deadline = Date.now() + 13e4;
1182
+ for (; ; ) {
1183
+ const state = this.manager.status().state;
1184
+ if (state === "running") break;
1185
+ if (state === "error" || Date.now() > deadline) return;
1186
+ await this.sleep(500);
1187
+ }
1188
+ const base2 = this.manager.target();
1189
+ if (!base2) return;
1190
+ try {
1191
+ await restoreSlot({ http: this.fetchImpl, base: base2, dir: slotCacheDir(this.store.dir()), filename });
1192
+ } catch {
1193
+ }
1194
+ }
1195
+ sleep(ms) {
1196
+ return new Promise((r) => setTimeout(r, ms));
1197
+ }
1198
+ /** REVERSE gate (F-011): TurboLLM is about to load a model — ask ComfyUI to drop its
1199
+ * VRAM first by calling its native `POST {url}/free`. Symmetric counterpart of the
1200
+ * forward acquire/release gate. Every model-load entry point (the HTTP load route,
1201
+ * the bench load, the startup auto-load) awaits this before `manager.start(...)`.
1202
+ *
1203
+ * Fires ONLY when ComfyUI is idle from our side (`!held`): if WE currently hold the
1204
+ * GPU for ComfyUI a render is in flight, and the forward block already kept this load
1205
+ * out — never interrupt that render. Any failure (ComfyUI down, connection refused,
1206
+ * timeout, non-2xx) is NON-FATAL: we log a warning and return so the load proceeds,
1207
+ * exactly as the gate node treats an unreachable TurboLLM. Concurrent calls (rapid
1208
+ * LLM↔image alternation) collapse onto one in-flight POST so we never double-free. */
1209
+ async freeComfyUIBeforeLoad() {
1210
+ const cfg2 = this.store.snapshot().comfyui;
1211
+ if (!cfg2.enabled || !cfg2.reverseGate || !cfg2.url || this.held) return;
1212
+ if (this.freeing) return this.freeing;
1213
+ this.freeing = (async () => {
1214
+ const url = `${cfg2.url.replace(/\/+$/, "")}/free`;
1215
+ try {
1216
+ console.log("[comfy-guard] TurboLLM is loading a model \u2014 asking ComfyUI to free its VRAM first.");
1217
+ const res = await this.fetchImpl(url, {
1218
+ method: "POST",
1219
+ headers: { "content-type": "application/json" },
1220
+ body: JSON.stringify({ unload_models: true, free_memory: true }),
1221
+ signal: AbortSignal.timeout(FREE_TIMEOUT_MS)
1222
+ });
1223
+ if (!res.ok) {
1224
+ console.warn(`[comfy-guard] ComfyUI /free returned ${res.status} \u2014 loading anyway.`);
1225
+ }
1226
+ } catch (e) {
1227
+ console.warn(`[comfy-guard] could not reach ComfyUI /free (${e instanceof Error ? e.message : e}) \u2014 loading anyway.`);
1228
+ }
1229
+ })();
1230
+ try {
1231
+ await this.freeing;
1232
+ } finally {
1233
+ this.freeing = null;
1234
+ }
1030
1235
  }
1031
1236
  snapshot() {
1032
1237
  const cfg2 = this.store.snapshot().comfyui;
@@ -1058,12 +1263,12 @@ var ComfyGuard = class {
1058
1263
  };
1059
1264
 
1060
1265
  // src/engines/registry.ts
1061
- import { existsSync as existsSync7 } from "fs";
1266
+ import { existsSync as existsSync8 } from "fs";
1062
1267
  import { randomUUID as randomUUID2 } from "crypto";
1063
1268
 
1064
1269
  // src/engines/probe.ts
1065
1270
  import { execFile as execFile5 } from "child_process";
1066
- import { closeSync, existsSync as existsSync6, openSync, readSync, statSync } from "fs";
1271
+ import { closeSync, existsSync as existsSync7, openSync, readSync, statSync as statSync2 } from "fs";
1067
1272
  import { dirname as dirname3 } from "path";
1068
1273
  var ProbeError = class extends Error {
1069
1274
  constructor(code, msg) {
@@ -1093,7 +1298,7 @@ function runCaptured(bin, arg) {
1093
1298
  });
1094
1299
  }
1095
1300
  async function probe(bin) {
1096
- if (!existsSync6(bin) || statSync(bin).isDirectory()) {
1301
+ if (!existsSync7(bin) || statSync2(bin).isDirectory()) {
1097
1302
  throw new ProbeError("binary_not_found", "Binary not found at that path.");
1098
1303
  }
1099
1304
  const fmt = detectFormat(bin);
@@ -1323,7 +1528,7 @@ var Registry = class {
1323
1528
  pruneDeadManagedBuilds() {
1324
1529
  let removed = 0;
1325
1530
  for (const e of this.list().engines) {
1326
- if (isManagedBuild(e.binPath) && !existsSync7(e.binPath)) {
1531
+ if (isManagedBuild(e.binPath) && !existsSync8(e.binPath)) {
1327
1532
  try {
1328
1533
  this.remove(e.id);
1329
1534
  removed++;
@@ -1506,8 +1711,8 @@ function engineAcceptsFormat(engineKind, format) {
1506
1711
  }
1507
1712
 
1508
1713
  // src/models/scanner.ts
1509
- import { existsSync as existsSync8, lstatSync, readdirSync as readdirSync2, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "fs";
1510
- import { basename, dirname as dirname4, join as join6 } from "path";
1714
+ import { existsSync as existsSync9, lstatSync, readdirSync as readdirSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
1715
+ import { basename, dirname as dirname4, join as join7 } from "path";
1511
1716
 
1512
1717
  // src/gguf/gguf.ts
1513
1718
  import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2 } from "fs";
@@ -1772,7 +1977,7 @@ var ScannerError = class extends Error {
1772
1977
  var Scanner = class {
1773
1978
  constructor(store2) {
1774
1979
  this.store = store2;
1775
- this.cachePath = join6(store2.dir(), "models-cache.json");
1980
+ this.cachePath = join7(store2.dir(), "models-cache.json");
1776
1981
  this.loadCache();
1777
1982
  }
1778
1983
  store;
@@ -1801,14 +2006,14 @@ var Scanner = class {
1801
2006
  const total = m[3];
1802
2007
  let names;
1803
2008
  try {
1804
- names = readdirSync2(e.dir);
2009
+ names = readdirSync3(e.dir);
1805
2010
  } catch {
1806
2011
  return [e.path];
1807
2012
  }
1808
2013
  const shards = [];
1809
2014
  for (const name of names) {
1810
2015
  const sm = name.match(SPLIT_RE);
1811
- if (sm && sm[1] === prefix && sm[3] === total) shards.push(join6(e.dir, name));
2016
+ if (sm && sm[1] === prefix && sm[3] === total) shards.push(join7(e.dir, name));
1812
2017
  }
1813
2018
  return shards.length > 0 ? shards.sort() : [e.path];
1814
2019
  }
@@ -1820,9 +2025,9 @@ var Scanner = class {
1820
2025
  if (!e) throw new ScannerError("no_such_model", "No model with that key.");
1821
2026
  const paths = this.filesFor(key);
1822
2027
  if (e.format === "mlx") {
1823
- rmSync3(e.path, { recursive: true, force: true });
2028
+ rmSync4(e.path, { recursive: true, force: true });
1824
2029
  } else {
1825
- for (const p of paths) rmSync3(p, { force: true });
2030
+ for (const p of paths) rmSync4(p, { force: true });
1826
2031
  this.cache.delete(e.path);
1827
2032
  }
1828
2033
  await this.rescan();
@@ -1836,7 +2041,7 @@ var Scanner = class {
1836
2041
  const dirs = this.store.snapshot().modelDirs;
1837
2042
  const scan = { ggufs: [], mlxDirs: [] };
1838
2043
  for (const d of dirs) {
1839
- if (existsSync8(d)) walk(d, scan);
2044
+ if (existsSync9(d)) walk(d, scan);
1840
2045
  await tick();
1841
2046
  }
1842
2047
  const gguf = await this.build(scan.ggufs);
@@ -1962,7 +2167,7 @@ function isMlxModelDir(names) {
1962
2167
  function walk(dir, out) {
1963
2168
  let names;
1964
2169
  try {
1965
- names = readdirSync2(dir);
2170
+ names = readdirSync3(dir);
1966
2171
  } catch {
1967
2172
  return;
1968
2173
  }
@@ -1972,7 +2177,7 @@ function walk(dir, out) {
1972
2177
  }
1973
2178
  for (const name of names) {
1974
2179
  if (name === ".git" || name === "node_modules") continue;
1975
- const full = join6(dir, name);
2180
+ const full = join7(dir, name);
1976
2181
  let st;
1977
2182
  try {
1978
2183
  st = lstatSync(full);
@@ -1990,7 +2195,7 @@ function mlxEntryFor(dir) {
1990
2195
  let cfg2 = {};
1991
2196
  let parseError = null;
1992
2197
  try {
1993
- const raw = readFileSync3(join6(dir, "config.json"), "utf8").replace(/^/, "");
2198
+ const raw = readFileSync3(join7(dir, "config.json"), "utf8").replace(/^/, "");
1994
2199
  cfg2 = JSON.parse(raw);
1995
2200
  } catch (e) {
1996
2201
  parseError = `Could not read config.json: ${e.message}`;
@@ -1999,16 +2204,16 @@ function mlxEntryFor(dir) {
1999
2204
  let mtimeMs = 0;
2000
2205
  let hasChatTemplate = false;
2001
2206
  try {
2002
- for (const n of readdirSync2(dir)) {
2207
+ for (const n of readdirSync3(dir)) {
2003
2208
  const lower = n.toLowerCase();
2004
2209
  if (lower.endsWith(".safetensors")) {
2005
- const st = lstatSync(join6(dir, n));
2210
+ const st = lstatSync(join7(dir, n));
2006
2211
  sizeBytes += st.size;
2007
2212
  mtimeMs = Math.max(mtimeMs, st.mtimeMs);
2008
2213
  }
2009
2214
  }
2010
- const tc = join6(dir, "tokenizer_config.json");
2011
- if (existsSync8(tc)) hasChatTemplate = readFileSync3(tc, "utf8").includes("chat_template");
2215
+ const tc = join7(dir, "tokenizer_config.json");
2216
+ if (existsSync9(tc)) hasChatTemplate = readFileSync3(tc, "utf8").includes("chat_template");
2012
2217
  } catch {
2013
2218
  }
2014
2219
  const expertCount = cfg2.num_local_experts ?? cfg2.num_experts ?? 0;
@@ -2051,16 +2256,16 @@ function tick() {
2051
2256
  }
2052
2257
 
2053
2258
  // src/models/hashes.ts
2054
- import { createHash } from "crypto";
2259
+ import { createHash as createHash2 } from "crypto";
2055
2260
  import { createReadStream, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2056
- import { join as join7 } from "path";
2261
+ import { join as join8 } from "path";
2057
2262
  var HashStore = class {
2058
2263
  cache = /* @__PURE__ */ new Map();
2059
2264
  pending = /* @__PURE__ */ new Set();
2060
2265
  path;
2061
2266
  constructor(dataDir) {
2062
2267
  mkdirSync5(dataDir, { recursive: true });
2063
- this.path = join7(dataDir, "model-hashes.json");
2268
+ this.path = join8(dataDir, "model-hashes.json");
2064
2269
  this.load();
2065
2270
  }
2066
2271
  /** Cached sha256 for a file, only when the cached row still matches size+mtime
@@ -2078,7 +2283,7 @@ var HashStore = class {
2078
2283
  }
2079
2284
  compute(path, size, mtime) {
2080
2285
  return new Promise((resolve2) => {
2081
- const hash = createHash("sha256");
2286
+ const hash = createHash2("sha256");
2082
2287
  const rs = createReadStream(path);
2083
2288
  rs.on("error", () => resolve2());
2084
2289
  rs.on("data", (c) => hash.update(c));
@@ -2110,6 +2315,9 @@ var HashStore = class {
2110
2315
  };
2111
2316
 
2112
2317
  // src/models/profile.ts
2318
+ function defaultGpu() {
2319
+ return { splitMode: "layer", tensorSplit: [], mainGpu: -1, tensorParallelSize: 1 };
2320
+ }
2113
2321
  var HEAD_DIM = 128;
2114
2322
  function kvBytesPerElem(t) {
2115
2323
  switch (t) {
@@ -2136,8 +2344,16 @@ function kvBytesPerElem(t) {
2136
2344
  function defaultSampling() {
2137
2345
  return { temp: 0.8, topP: 0.95, topK: 40, minP: 0.05, repeatPenalty: 1, presencePenalty: 0 };
2138
2346
  }
2347
+ function gpuBudgetMb(sys, p) {
2348
+ if (sys.gpus.length === 0) return 0;
2349
+ if (p?.gpu?.splitMode === "none") {
2350
+ const idx = p.gpu.mainGpu >= 0 ? p.gpu.mainGpu : 0;
2351
+ return sys.gpus[idx]?.vramMb ?? sys.gpus[0]?.vramMb ?? 0;
2352
+ }
2353
+ return sys.gpus.reduce((sum, g) => sum + (g.vramMb || 0), 0);
2354
+ }
2139
2355
  function estimateVram(p, m, sys) {
2140
- const totalVramMb = sys.gpus[0]?.vramMb ?? 0;
2356
+ const totalVramMb = gpuBudgetMb(sys, p);
2141
2357
  if (totalVramMb === 0) return { estMb: 0, totalVramMb: 0, pct: 0, verdict: "cpu" };
2142
2358
  const sizeMb = m.sizeBytes / 1e6;
2143
2359
  const blocks = m.blockCount || 1;
@@ -2182,10 +2398,11 @@ function deriveDefault(m, sys) {
2182
2398
  mtpHeadPath: "",
2183
2399
  draftModelPath: "",
2184
2400
  sampling: defaultSampling(),
2401
+ gpu: defaultGpu(),
2185
2402
  extraArgs: []
2186
2403
  };
2187
2404
  if (m.moe && hasGpu && m.blockCount > 0) {
2188
- const budget = (sys.gpus[0].vramMb || 0) * 0.85;
2405
+ const budget = gpuBudgetMb(sys, base2) * 0.85;
2189
2406
  base2.nCpuMoe = m.blockCount;
2190
2407
  for (let n = 0; n <= m.blockCount; n += 2) {
2191
2408
  if (estimateVram({ ...base2, nCpuMoe: n }, m, sys).estMb <= budget) {
@@ -2213,13 +2430,24 @@ function resolveProfile(m, sys, saved, overrides, defaults) {
2213
2430
  ...base2,
2214
2431
  ...saved ?? {},
2215
2432
  ...overrides ?? {},
2216
- sampling: { ...base2.sampling, ...saved?.sampling ?? {}, ...overrides?.sampling ?? {} }
2433
+ sampling: { ...base2.sampling, ...saved?.sampling ?? {}, ...overrides?.sampling ?? {} },
2434
+ // gpu is deep-merged like sampling so a partial override (or an old saved profile
2435
+ // missing some fields) keeps the rest of the defaults instead of going undefined.
2436
+ gpu: { ...base2.gpu, ...saved?.gpu ?? {}, ...overrides?.gpu ?? {} }
2217
2437
  };
2218
2438
  }
2219
2439
  function profileToArgs(p, m, caps, cores = 0) {
2220
2440
  const has = (flag) => caps.flags.length === 0 || caps.flags.includes(flag);
2221
2441
  const a = ["-c", String(p.ctx)];
2222
2442
  if (p.ngl > 0) a.push("-ngl", String(p.ngl));
2443
+ const g = p.gpu;
2444
+ if (g) {
2445
+ if (g.splitMode !== "layer" && has("--split-mode")) a.push("--split-mode", g.splitMode);
2446
+ if (g.splitMode !== "none" && g.tensorSplit.length > 0 && has("--tensor-split")) {
2447
+ a.push("--tensor-split", g.tensorSplit.join(","));
2448
+ }
2449
+ if (g.mainGpu >= 0 && has("--main-gpu")) a.push("--main-gpu", String(g.mainGpu));
2450
+ }
2223
2451
  if (has("--parallel")) a.push("--parallel", String(p.parallel));
2224
2452
  if (p.parallel > 1 && p.kvUnified && has("--kv-unified")) a.push("--kv-unified");
2225
2453
  if (m.moe && p.nCpuMoe > 0 && has("--n-cpu-moe")) a.push("--n-cpu-moe", String(p.nCpuMoe));
@@ -2253,7 +2481,7 @@ function profileToArgs(p, m, caps, cores = 0) {
2253
2481
 
2254
2482
  // src/chat/db.ts
2255
2483
  import { DatabaseSync } from "node:sqlite";
2256
- import { join as join8 } from "path";
2484
+ import { join as join9 } from "path";
2257
2485
  import { randomUUID as randomUUID3 } from "crypto";
2258
2486
  function safeJson(s) {
2259
2487
  try {
@@ -2271,7 +2499,7 @@ function rowToMsg(r) {
2271
2499
  var ConversationStore = class {
2272
2500
  db;
2273
2501
  constructor(dataDir) {
2274
- this.db = new DatabaseSync(join8(dataDir, "turbollm.db"));
2502
+ this.db = new DatabaseSync(join9(dataDir, "turbollm.db"));
2275
2503
  this.migrate();
2276
2504
  }
2277
2505
  migrate() {
@@ -2614,19 +2842,19 @@ function base(p) {
2614
2842
  }
2615
2843
 
2616
2844
  // src/downloads/downloads.ts
2617
- import { createHash as createHash2 } from "crypto";
2845
+ import { createHash as createHash3 } from "crypto";
2618
2846
  import {
2619
2847
  createWriteStream as createWriteStream3,
2620
- existsSync as existsSync9,
2848
+ existsSync as existsSync10,
2621
2849
  mkdirSync as mkdirSync6,
2622
2850
  readFileSync as readFileSync5,
2623
2851
  renameSync as renameSync2,
2624
- rmSync as rmSync4,
2852
+ rmSync as rmSync5,
2625
2853
  statfsSync,
2626
- statSync as statSync2,
2854
+ statSync as statSync3,
2627
2855
  writeFileSync as writeFileSync4
2628
2856
  } from "fs";
2629
- import { basename as basename2, join as join9 } from "path";
2857
+ import { basename as basename2, join as join10 } from "path";
2630
2858
  import { Readable as Readable2 } from "stream";
2631
2859
  import { pipeline as pipeline2 } from "stream/promises";
2632
2860
  var MAX_CONCURRENT = 2;
@@ -2644,10 +2872,10 @@ var DownloadManager = class {
2644
2872
  this.store = store2;
2645
2873
  this.onComplete = onComplete;
2646
2874
  this.authHeaders = authHeaders;
2647
- const dir = join9(store2.dir(), "downloads");
2875
+ const dir = join10(store2.dir(), "downloads");
2648
2876
  mkdirSync6(dir, { recursive: true });
2649
- this.manifestPath = join9(dir, "manifest.json");
2650
- this.provenancePath = join9(dir, "provenance.json");
2877
+ this.manifestPath = join10(dir, "manifest.json");
2878
+ this.provenancePath = join10(dir, "provenance.json");
2651
2879
  this.restore();
2652
2880
  this.loadProvenance();
2653
2881
  }
@@ -2710,7 +2938,7 @@ var DownloadManager = class {
2710
2938
  name: filename,
2711
2939
  repo,
2712
2940
  url,
2713
- dest: join9(dir, filename),
2941
+ dest: join10(dir, filename),
2714
2942
  total,
2715
2943
  received: 0,
2716
2944
  status: "queued",
@@ -2732,7 +2960,7 @@ var DownloadManager = class {
2732
2960
  this.controllers.get(id)?.abort();
2733
2961
  this.controllers.delete(id);
2734
2962
  if (rec.status !== "done") {
2735
- rmSync4(`${rec.dest}.part`, { force: true });
2963
+ rmSync5(`${rec.dest}.part`, { force: true });
2736
2964
  rec.status = "cancelled";
2737
2965
  rec.bytesPerSec = 0;
2738
2966
  }
@@ -2746,7 +2974,7 @@ var DownloadManager = class {
2746
2974
  if (!rec) return false;
2747
2975
  this.controllers.get(id)?.abort();
2748
2976
  this.controllers.delete(id);
2749
- if (rec.status !== "done") rmSync4(`${rec.dest}.part`, { force: true });
2977
+ if (rec.status !== "done") rmSync5(`${rec.dest}.part`, { force: true });
2750
2978
  this.records.delete(id);
2751
2979
  this.persist();
2752
2980
  this.pump();
@@ -2794,9 +3022,9 @@ var DownloadManager = class {
2794
3022
  const part = `${rec.dest}.part`;
2795
3023
  try {
2796
3024
  let startAt = 0;
2797
- if (existsSync9(part)) {
3025
+ if (existsSync10(part)) {
2798
3026
  try {
2799
- startAt = statSync2(part).size;
3027
+ startAt = statSync3(part).size;
2800
3028
  } catch {
2801
3029
  startAt = 0;
2802
3030
  }
@@ -2811,13 +3039,13 @@ var DownloadManager = class {
2811
3039
  if (!res.body) throw new DownloadError("download_failed", "Empty response body.");
2812
3040
  const resuming = res.status === 206;
2813
3041
  if (!resuming && startAt > 0) {
2814
- rmSync4(part, { force: true });
3042
+ rmSync5(part, { force: true });
2815
3043
  startAt = 0;
2816
3044
  rec.received = 0;
2817
3045
  }
2818
3046
  const clen = Number(res.headers.get("content-length") ?? 0);
2819
3047
  if (clen > 0) rec.total = resuming ? startAt + clen : clen;
2820
- const hash = rec.sha256 ? createHash2("sha256") : null;
3048
+ const hash = rec.sha256 ? createHash3("sha256") : null;
2821
3049
  const verifyHash = hash !== null && startAt === 0;
2822
3050
  let lastTick = Date.now();
2823
3051
  let lastBytes = startAt;
@@ -2836,13 +3064,13 @@ var DownloadManager = class {
2836
3064
  const out = createWriteStream3(part, startAt > 0 ? { flags: "a" } : { flags: "w" });
2837
3065
  await pipeline2(body3, out, { signal: ac.signal });
2838
3066
  if (rec.total > 0 && rec.received !== rec.total) {
2839
- rmSync4(part, { force: true });
3067
+ rmSync5(part, { force: true });
2840
3068
  throw new DownloadError("size_mismatch", "Download corrupt \u2014 size did not match. Removed the partial file.");
2841
3069
  }
2842
3070
  if (verifyHash && rec.sha256) {
2843
3071
  const got = hash.digest("hex");
2844
3072
  if (got !== rec.sha256) {
2845
- rmSync4(part, { force: true });
3073
+ rmSync5(part, { force: true });
2846
3074
  throw new DownloadError("checksum_failed", "Checksum failed \u2014 the downloaded file was corrupt.");
2847
3075
  }
2848
3076
  }
@@ -2934,7 +3162,7 @@ var DownloadManager = class {
2934
3162
  let received = 0;
2935
3163
  try {
2936
3164
  const p = `${e.dest}.part`;
2937
- if (existsSync9(p)) received = statSync2(p).size;
3165
+ if (existsSync10(p)) received = statSync3(p).size;
2938
3166
  } catch {
2939
3167
  }
2940
3168
  this.records.set(e.id, {
@@ -2968,7 +3196,7 @@ function safePathname(u) {
2968
3196
  // src/bench/bench.ts
2969
3197
  import { execFile as execFile6 } from "child_process";
2970
3198
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5 } from "fs";
2971
- import { join as join10 } from "path";
3199
+ import { join as join11 } from "path";
2972
3200
  import { randomUUID as randomUUID4 } from "crypto";
2973
3201
  var READY_TIMEOUT_MS = 12e4;
2974
3202
  var TOTAL_BUDGET_MS = 10 * 6e4;
@@ -3236,9 +3464,9 @@ var BenchRunner = class {
3236
3464
  result: { tps: record.tps, ttftMs: record.ttftMs, vramMb: record.vramMb, outcome: "ok" }
3237
3465
  }
3238
3466
  };
3239
- const queueDir = join10(this.store.dir(), "telemetry", "queue");
3467
+ const queueDir = join11(this.store.dir(), "telemetry", "queue");
3240
3468
  mkdirSync7(queueDir, { recursive: true });
3241
- writeFileSync5(join10(queueDir, `${randomUUID4()}.json`), JSON.stringify(event));
3469
+ writeFileSync5(join11(queueDir, `${randomUUID4()}.json`), JSON.stringify(event));
3242
3470
  } catch {
3243
3471
  }
3244
3472
  }
@@ -3352,15 +3580,15 @@ Install it: ${spec.install}
3352
3580
  // src/server.ts
3353
3581
  import { Hono } from "hono";
3354
3582
  import { cors } from "hono/cors";
3355
- import { existsSync as existsSync11, readFileSync as readFileSync7, statSync as statSync4 } from "fs";
3583
+ import { existsSync as existsSync12, readFileSync as readFileSync7, statSync as statSync5 } from "fs";
3356
3584
  import { fileURLToPath } from "url";
3357
- import { dirname as dirname6, join as join12, normalize as normalize2 } from "path";
3585
+ import { dirname as dirname6, join as join13, normalize as normalize2 } from "path";
3358
3586
  import { Agent, setGlobalDispatcher } from "undici";
3359
3587
 
3360
3588
  // src/api/routes.ts
3361
3589
  import { streamSSE } from "hono/streaming";
3362
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync6, readdirSync as readdirSync3, realpathSync, rmSync as rmSync5, statSync as statSync3, writeFileSync as writeFileSync6 } from "fs";
3363
- import { basename as basename3, dirname as dirname5, join as join11, resolve, sep } from "path";
3590
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync6, readdirSync as readdirSync4, realpathSync, rmSync as rmSync6, statSync as statSync4, writeFileSync as writeFileSync6 } from "fs";
3591
+ import { basename as basename3, dirname as dirname5, join as join12, resolve, sep } from "path";
3364
3592
 
3365
3593
  // src/comfyui/gate-template.ts
3366
3594
  var GATE_VERSION = 1;
@@ -3485,7 +3713,7 @@ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
3485
3713
  }
3486
3714
 
3487
3715
  // src/api/routes.ts
3488
- import { createHash as createHash3, randomBytes, randomUUID as randomUUID5 } from "crypto";
3716
+ import { createHash as createHash4, randomBytes, randomUUID as randomUUID5 } from "crypto";
3489
3717
  import { homedir as homedir2, networkInterfaces } from "os";
3490
3718
 
3491
3719
  // src/engines/catalog.ts
@@ -3608,7 +3836,7 @@ function registerApi(app2, d) {
3608
3836
  const sys = getSysInfo();
3609
3837
  const vendor = primaryVendor(sys);
3610
3838
  const recommended = recommendBackendId(vendor, sys.gpus.length > 0);
3611
- const root = join11(d.store.dir(), "engines");
3839
+ const root = join12(d.store.dir(), "engines");
3612
3840
  const active = d.registry.active();
3613
3841
  const regEngines = d.registry.list().engines;
3614
3842
  const backends = availableBackends().map((b) => {
@@ -3637,7 +3865,7 @@ function registerApi(app2, d) {
3637
3865
  const def = availableBackends().find((x) => x.id === b.backend);
3638
3866
  if (!def) return err(c, 400, "invalid_config_value", "Unknown backend for this platform.");
3639
3867
  if (d.provision.get().active) return err(c, 409, "engine_already_running", "Another engine download is already in progress.");
3640
- const root = join11(d.store.dir(), "engines");
3868
+ const root = join12(d.store.dir(), "engines");
3641
3869
  const ac = new AbortController();
3642
3870
  provisionAbort = ac;
3643
3871
  void (async () => {
@@ -3673,7 +3901,7 @@ function registerApi(app2, d) {
3673
3901
  app2.delete("/api/v1/engines/backends/:id", async (c) => {
3674
3902
  const def = availableBackends().find((x) => x.id === c.req.param("id"));
3675
3903
  if (!def) return err(c, 400, "invalid_config_value", "Unknown backend for this platform.");
3676
- const root = join11(d.store.dir(), "engines");
3904
+ const root = join12(d.store.dir(), "engines");
3677
3905
  const bin = installedBackendServer(root, def.id);
3678
3906
  const eng = bin ? d.registry.list().engines.find((e) => e.binPath === bin) : void 0;
3679
3907
  if (eng && d.registry.active()?.id === eng.id) await d.manager.stopAndWait();
@@ -3686,7 +3914,7 @@ function registerApi(app2, d) {
3686
3914
  return err(c, 409, "unsupported_platform", "MLX is only available on macOS (Apple Silicon).");
3687
3915
  }
3688
3916
  if (d.provision.get().active) return err(c, 409, "engine_already_running", "Another engine download is already in progress.");
3689
- const root = join11(d.store.dir(), "engines");
3917
+ const root = join12(d.store.dir(), "engines");
3690
3918
  void (async () => {
3691
3919
  try {
3692
3920
  d.provision.start("mlx");
@@ -3715,7 +3943,7 @@ function registerApi(app2, d) {
3715
3943
  });
3716
3944
  app2.post("/api/v1/engines/vllm", (c) => {
3717
3945
  if (d.provision.get().active) return err(c, 409, "engine_already_running", "Another engine download is already in progress.");
3718
- const root = join11(d.store.dir(), "engines");
3946
+ const root = join12(d.store.dir(), "engines");
3719
3947
  void (async () => {
3720
3948
  try {
3721
3949
  d.provision.start("vllm");
@@ -3736,7 +3964,7 @@ function registerApi(app2, d) {
3736
3964
  return err(c, 409, "unsupported_platform", "TurboQuant has no prebuilt binary for this operating system yet.");
3737
3965
  }
3738
3966
  if (d.provision.get().active) return err(c, 409, "engine_already_running", "Another engine download is already in progress.");
3739
- const root = join11(d.store.dir(), "engines");
3967
+ const root = join12(d.store.dir(), "engines");
3740
3968
  void (async () => {
3741
3969
  try {
3742
3970
  d.provision.start("turboquant");
@@ -3822,16 +4050,16 @@ function registerApi(app2, d) {
3822
4050
  }
3823
4051
  let entries;
3824
4052
  try {
3825
- entries = readdirSync3(real, { withFileTypes: true }).filter((d2) => !d2.name.startsWith(".")).map((d2) => {
4053
+ entries = readdirSync4(real, { withFileTypes: true }).filter((d2) => !d2.name.startsWith(".")).map((d2) => {
3826
4054
  let isDir = d2.isDirectory();
3827
4055
  if (d2.isSymbolicLink()) {
3828
4056
  try {
3829
- isDir = statSync3(join11(real, d2.name)).isDirectory();
4057
+ isDir = statSync4(join12(real, d2.name)).isDirectory();
3830
4058
  } catch {
3831
4059
  isDir = false;
3832
4060
  }
3833
4061
  }
3834
- return { name: d2.name, path: join11(real, d2.name), isDir };
4062
+ return { name: d2.name, path: join12(real, d2.name), isDir };
3835
4063
  }).sort((a, b) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1);
3836
4064
  } catch {
3837
4065
  return err(c, 400, "fs_read_failed", "Could not read that folder (permission denied or not a directory).");
@@ -3859,11 +4087,13 @@ function registerApi(app2, d) {
3859
4087
  }
3860
4088
  let opts2;
3861
4089
  if (entry.format !== "gguf") {
4090
+ const savedGpu = cfg2.modelProfiles[entry.key]?.gpu;
3862
4091
  opts2 = {
3863
4092
  engine: active,
3864
4093
  model: { key: entry.key, name: entry.name, quant: entry.quant, ctx: entry.nativeCtx, vision: false },
3865
4094
  modelPath: entry.path,
3866
- extraArgs: []
4095
+ extraArgs: [],
4096
+ tensorParallelSize: savedGpu?.tensorParallelSize
3867
4097
  };
3868
4098
  } else {
3869
4099
  const saved = cfg2.modelProfiles[entry.key];
@@ -3876,6 +4106,7 @@ function registerApi(app2, d) {
3876
4106
  };
3877
4107
  }
3878
4108
  await d.manager.stopAndWait();
4109
+ await d.comfy?.freeComfyUIBeforeLoad();
3879
4110
  try {
3880
4111
  await d.manager.start(opts2);
3881
4112
  } catch (e) {
@@ -3897,6 +4128,7 @@ function registerApi(app2, d) {
3897
4128
  if (!modelPath) return err(c, 409, "no_such_model", "No model specified. Pick one from the Models screen.");
3898
4129
  const opts = { engine: active, model: deriveModel(modelPath, name, extra), modelPath, extraArgs: extra };
3899
4130
  await d.manager.stopAndWait();
4131
+ await d.comfy?.freeComfyUIBeforeLoad();
3900
4132
  try {
3901
4133
  await d.manager.start(opts);
3902
4134
  } catch (e) {
@@ -3927,22 +4159,22 @@ function registerApi(app2, d) {
3927
4159
  const raw = (b.path ?? "").trim();
3928
4160
  if (!raw) return err(c, 400, "invalid_config_value", "Enter the path to your ComfyUI folder.");
3929
4161
  const root = resolve(raw);
3930
- if (!existsSync10(root) || !statSync3(root).isDirectory()) {
4162
+ if (!existsSync11(root) || !statSync4(root).isDirectory()) {
3931
4163
  return err(c, 400, "invalid_config_value", "That folder does not exist.");
3932
4164
  }
3933
4165
  let customNodes;
3934
4166
  if (basename3(root).toLowerCase() === "custom_nodes") customNodes = root;
3935
- else if (existsSync10(join11(root, "custom_nodes"))) customNodes = join11(root, "custom_nodes");
4167
+ else if (existsSync11(join12(root, "custom_nodes"))) customNodes = join12(root, "custom_nodes");
3936
4168
  else {
3937
4169
  return err(c, 400, "invalid_config_value", "No 'custom_nodes' folder here. Point me at your ComfyUI folder or its custom_nodes folder.");
3938
4170
  }
3939
4171
  const reqUrl = new URL(c.req.url);
3940
4172
  const port2 = reqUrl.port || (reqUrl.protocol === "https:" ? "443" : "80");
3941
4173
  const base2 = `http://127.0.0.1:${port2}`;
3942
- const gateDir = join11(customNodes, "turbollm_gate");
4174
+ const gateDir = join12(customNodes, "turbollm_gate");
3943
4175
  try {
3944
4176
  mkdirSync8(gateDir, { recursive: true });
3945
- writeFileSync6(join11(gateDir, "__init__.py"), gateNodeSource(base2));
4177
+ writeFileSync6(join12(gateDir, "__init__.py"), gateNodeSource(base2));
3946
4178
  } catch (e) {
3947
4179
  return err(c, 500, "fs_write_failed", `Could not write the gate node: ${e instanceof Error ? e.message : e}`);
3948
4180
  }
@@ -3953,9 +4185,9 @@ function registerApi(app2, d) {
3953
4185
  });
3954
4186
  app2.post("/api/v1/comfyui/uninstall", (c) => {
3955
4187
  const dir = d.store.snapshot().comfyui.gatePath;
3956
- if (dir && existsSync10(dir)) {
4188
+ if (dir && existsSync11(dir)) {
3957
4189
  try {
3958
- rmSync5(dir, { recursive: true, force: true });
4190
+ rmSync6(dir, { recursive: true, force: true });
3959
4191
  } catch (e) {
3960
4192
  return err(c, 500, "fs_write_failed", `Could not remove the gate node: ${e instanceof Error ? e.message : e}`);
3961
4193
  }
@@ -3988,7 +4220,7 @@ function registerApi(app2, d) {
3988
4220
  });
3989
4221
  while (!aborted) {
3990
4222
  const path = d.manager.logPath();
3991
- if (path && existsSync10(path)) {
4223
+ if (path && existsSync11(path)) {
3992
4224
  const lines = readFileSync6(path, "utf8").split("\n");
3993
4225
  for (; sent < lines.length - 1; sent++) {
3994
4226
  await stream.writeSSE({ event: "line", data: JSON.stringify({ line: lines[sent].replace(/\r$/, "") }) });
@@ -4004,6 +4236,7 @@ function registerApi(app2, d) {
4004
4236
  const key = (b.modelKey ?? "").trim();
4005
4237
  if (!key) return err(c, 400, "invalid_config_value", "modelKey is required.");
4006
4238
  if (d.comfy?.isBlocked()) return err(c, 409, "comfyui_busy", "ComfyUI is rendering \u2014 benchmarking is paused until its queue finishes.");
4239
+ await d.comfy?.freeComfyUIBeforeLoad();
4007
4240
  try {
4008
4241
  d.bench.start(key, b.base && typeof b.base === "object" ? b.base : void 0);
4009
4242
  return c.json({ accepted: true }, 202);
@@ -4048,7 +4281,7 @@ function registerApi(app2, d) {
4048
4281
  const snap = d.store.snapshot();
4049
4282
  const saved = snap.modelProfiles[e.key];
4050
4283
  const profile = resolveProfile(e, sys, saved, void 0, snap.modelDefaults);
4051
- return c.json({ ...overlayModel(e, d), profile, vramFit: estimateVram(profile, e, sys), gpu: sys.gpus[0] ?? null, cores: sys.cores });
4284
+ return c.json({ ...overlayModel(e, d), profile, vramFit: estimateVram(profile, e, sys), gpu: sys.gpus[0] ?? null, gpus: sys.gpus, cores: sys.cores });
4052
4285
  });
4053
4286
  app2.put("/api/v1/models/:key/profile", async (c) => {
4054
4287
  const key = decodeURIComponent(c.req.param("key"));
@@ -4058,6 +4291,21 @@ function registerApi(app2, d) {
4058
4291
  if (!p || typeof p.ctx !== "number" || p.ctx < 256) {
4059
4292
  return err(c, 400, "invalid_profile_value", "ctx must be at least 256.");
4060
4293
  }
4294
+ if (p.gpu) {
4295
+ const g = p.gpu;
4296
+ if (!["layer", "row", "none"].includes(g.splitMode)) {
4297
+ return err(c, 400, "invalid_profile_value", "gpu.splitMode must be layer, row, or none.");
4298
+ }
4299
+ if (!Array.isArray(g.tensorSplit) || g.tensorSplit.some((n) => typeof n !== "number" || !(n >= 0))) {
4300
+ return err(c, 400, "invalid_profile_value", "gpu.tensorSplit must be an array of non-negative numbers.");
4301
+ }
4302
+ if (!Number.isInteger(g.mainGpu) || g.mainGpu < -1) {
4303
+ return err(c, 400, "invalid_profile_value", "gpu.mainGpu must be an integer \u2265 -1.");
4304
+ }
4305
+ if (!Number.isInteger(g.tensorParallelSize) || g.tensorParallelSize < 1) {
4306
+ return err(c, 400, "invalid_profile_value", "gpu.tensorParallelSize must be an integer \u2265 1.");
4307
+ }
4308
+ }
4061
4309
  d.store.update((cfg2) => {
4062
4310
  cfg2.modelProfiles[key] = p;
4063
4311
  });
@@ -4126,6 +4374,14 @@ function registerApi(app2, d) {
4126
4374
  }
4127
4375
  const cuUpdates = {};
4128
4376
  if (b.comfyui?.enabled !== void 0) cuUpdates.enabled = !!b.comfyui.enabled;
4377
+ if (b.comfyui?.reverseGate !== void 0) cuUpdates.reverseGate = !!b.comfyui.reverseGate;
4378
+ if (b.comfyui?.url !== void 0) {
4379
+ const u = b.comfyui.url.trim();
4380
+ if (u && !/^https?:\/\//i.test(u)) {
4381
+ return err(c, 400, "invalid_config_value", "comfyui.url must be an http(s):// origin (e.g. http://127.0.0.1:8188).");
4382
+ }
4383
+ cuUpdates.url = u;
4384
+ }
4129
4385
  const before = d.store.snapshot().daemon;
4130
4386
  d.store.update((cfg2) => {
4131
4387
  Object.assign(cfg2.daemon, updates);
@@ -4164,7 +4420,7 @@ function registerApi(app2, d) {
4164
4420
  const b = await body(c);
4165
4421
  const dir = (b.dir ?? "").trim();
4166
4422
  if (!dir || !/^([a-zA-Z]:[\\/]|[\\/])/.test(dir)) return err(c, 400, "invalid_config_value", "Path must be absolute.");
4167
- if (!existsSync10(dir)) return err(c, 400, "invalid_config_value", "That folder does not exist.");
4423
+ if (!existsSync11(dir)) return err(c, 400, "invalid_config_value", "That folder does not exist.");
4168
4424
  try {
4169
4425
  d.store.update((cfg2) => {
4170
4426
  if (!cfg2.modelDirs.includes(dir)) cfg2.modelDirs.push(dir);
@@ -4498,7 +4754,7 @@ function cleanModelName(p) {
4498
4754
  return basename3(p).replace(/\.gguf$/i, "");
4499
4755
  }
4500
4756
  function readTail2(path, n) {
4501
- if (!path || !existsSync10(path)) return [];
4757
+ if (!path || !existsSync11(path)) return [];
4502
4758
  try {
4503
4759
  const lines = readFileSync6(path, "utf8").replace(/[\r\n]+$/, "").split("\n").map((l) => l.replace(/\r$/, ""));
4504
4760
  return lines.length > n ? lines.slice(-n) : lines;
@@ -4512,7 +4768,7 @@ function generateApiKey() {
4512
4768
  let key = "";
4513
4769
  for (let i = 0; i < 40; i++) key += charset[buf[i] % 62];
4514
4770
  const full = `tllm-${key}`;
4515
- const hash = createHash3("sha256").update(full).digest("hex");
4771
+ const hash = createHash4("sha256").update(full).digest("hex");
4516
4772
  return { full, hash, prefix: full.slice(0, 12) };
4517
4773
  }
4518
4774
  function realHome() {
@@ -5629,11 +5885,11 @@ async function recordOpenAiStreamUsage(d, body3) {
5629
5885
  }
5630
5886
 
5631
5887
  // src/auth.ts
5632
- import { createHash as createHash4 } from "crypto";
5888
+ import { createHash as createHash5 } from "crypto";
5633
5889
  import { getConnInfo } from "@hono/node-server/conninfo";
5634
5890
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
5635
5891
  function hashKey(raw) {
5636
- return createHash4("sha256").update(raw).digest("hex");
5892
+ return createHash5("sha256").update(raw).digest("hex");
5637
5893
  }
5638
5894
  function presentedKey(c) {
5639
5895
  const direct = c.req.header("X-TurboLLM-Auth") ?? c.req.header("x-api-key");
@@ -5691,7 +5947,7 @@ function lanAuth(d) {
5691
5947
 
5692
5948
  // src/server.ts
5693
5949
  setGlobalDispatcher(new Agent({ keepAliveMaxTimeout: 6e4, connections: 10 }));
5694
- var WEB_ROOT = join12(dirname6(fileURLToPath(import.meta.url)), "webdist");
5950
+ var WEB_ROOT = join13(dirname6(fileURLToPath(import.meta.url)), "webdist");
5695
5951
  function createApp(d) {
5696
5952
  const app2 = new Hono();
5697
5953
  app2.use(
@@ -5716,11 +5972,11 @@ function createApp(d) {
5716
5972
  if (path.startsWith("api/") || path.startsWith("v1/")) {
5717
5973
  return c.json({ error: { code: "not_found", message: "Unknown endpoint." } }, 404);
5718
5974
  }
5719
- let file = normalize2(join12(WEB_ROOT, path || "index.html"));
5720
- if (!file.startsWith(WEB_ROOT) || !existsSync11(file) || statSync4(file).isDirectory()) {
5721
- file = join12(WEB_ROOT, "index.html");
5975
+ let file = normalize2(join13(WEB_ROOT, path || "index.html"));
5976
+ if (!file.startsWith(WEB_ROOT) || !existsSync12(file) || statSync5(file).isDirectory()) {
5977
+ file = join13(WEB_ROOT, "index.html");
5722
5978
  }
5723
- if (!existsSync11(file)) return c.text("web ui not built \u2014 run `npm run build:web`", 500);
5979
+ if (!existsSync12(file)) return c.text("web ui not built \u2014 run `npm run build:web`", 500);
5724
5980
  return new Response(readFileSync7(file), { status: 200, headers: { "Content-Type": contentType(file) } });
5725
5981
  });
5726
5982
  return app2;
@@ -5748,7 +6004,7 @@ function contentType(file) {
5748
6004
  // src/cli.ts
5749
6005
  var version = "0.1.1";
5750
6006
  try {
5751
- const pkgPath = join13(dirname7(fileURLToPath2(import.meta.url)), "..", "package.json");
6007
+ const pkgPath = join14(dirname7(fileURLToPath2(import.meta.url)), "..", "package.json");
5752
6008
  version = JSON.parse(readFileSync8(pkgPath, "utf8")).version ?? version;
5753
6009
  } catch {
5754
6010
  }
@@ -5818,7 +6074,7 @@ var registry = new Registry(store);
5818
6074
  var pruned = registry.pruneDeadManagedBuilds();
5819
6075
  if (pruned > 0) console.log(`pruned ${pruned} dangling engine build(s)`);
5820
6076
  var provision = new ProvisionState();
5821
- var enginesDir = join13(store.dir(), "engines");
6077
+ var enginesDir = join14(store.dir(), "engines");
5822
6078
  void seedDefaultEngines(registry, enginesDir, provision).then(() => registry.ensureProbed());
5823
6079
  var manager = new Manager(store);
5824
6080
  var scanner = new Scanner(store);
@@ -5944,7 +6200,7 @@ var restarting = false;
5944
6200
  function spawnReplacement() {
5945
6201
  let out = "ignore";
5946
6202
  try {
5947
- out = openSync3(join13(store.dir(), "restart.log"), "a");
6203
+ out = openSync3(join14(store.dir(), "restart.log"), "a");
5948
6204
  } catch {
5949
6205
  out = "ignore";
5950
6206
  }
@@ -6029,7 +6285,10 @@ void (async () => {
6029
6285
  extraArgs: cfg.devModel.extraArgs
6030
6286
  };
6031
6287
  }
6032
- if (opts) manager.start(opts).catch((e) => console.warn(`auto-load failed: ${e}`));
6288
+ if (opts) {
6289
+ await comfy.freeComfyUIBeforeLoad();
6290
+ manager.start(opts).catch((e) => console.warn(`auto-load failed: ${e}`));
6291
+ }
6033
6292
  })();
6034
6293
  var shuttingDown = false;
6035
6294
  for (const sig of ["SIGINT", "SIGTERM"]) {