turbollm 0.7.0 → 0.7.2

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
@@ -288,9 +288,9 @@ function clampMaxTokens(requested, limit) {
288
288
  }
289
289
 
290
290
  // src/engines/manager.ts
291
- import { execFile as execFile4, spawn } from "child_process";
292
- import { createWriteStream as createWriteStream2, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync2 } from "fs";
293
- import { createServer } from "net";
291
+ import { execFile as execFile4, spawn, spawnSync } from "child_process";
292
+ import { createWriteStream as createWriteStream2, existsSync as existsSync6, mkdirSync as mkdirSync4, readdirSync as readdirSync3, readFileSync as readFileSync2, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
293
+ import { createConnection, createServer } from "net";
294
294
  import { dirname as dirname2, join as join6 } from "path";
295
295
 
296
296
  // src/engines/mlx.ts
@@ -773,12 +773,35 @@ var BusyError = class extends Error {
773
773
  this.name = "BusyError";
774
774
  }
775
775
  };
776
- var Manager = class {
776
+ var Manager = class _Manager {
777
777
  constructor(store2) {
778
778
  this.store = store2;
779
779
  setInterval(() => this.watchdogTick(), 6e4).unref();
780
780
  }
781
781
  store;
782
+ /** Global single-load lock (rules 1 & 2). Shared across EVERY Manager instance —
783
+ * including the gateway keep-N pool's extra slots — so at most ONE model load /
784
+ * reload is ever in flight at a time, no matter who requests it. Holding it
785
+ * through readiness (not just spawn) guarantees two engines never spin up at
786
+ * once and double-allocate VRAM. All load paths funnel through start()/load(),
787
+ * the only entry points that touch the engine — nothing spawns an engine without
788
+ * passing this gate (rule 3). */
789
+ static loadGate = Promise.resolve();
790
+ /** Acquire the global load gate, run `fn` exclusively, then release it. Queued
791
+ * callers run in FIFO order; a thrown fn still releases the gate. */
792
+ static async runExclusive(fn) {
793
+ const prev = _Manager.loadGate;
794
+ let release;
795
+ _Manager.loadGate = new Promise((r) => {
796
+ release = r;
797
+ });
798
+ await prev;
799
+ try {
800
+ return await fn();
801
+ } finally {
802
+ release();
803
+ }
804
+ }
782
805
  state = "stopped";
783
806
  opts = null;
784
807
  port = 0;
@@ -793,7 +816,41 @@ var Manager = class {
793
816
  generation = 0;
794
817
  liveGen = null;
795
818
  session = freshSession();
819
+ /** Load a model, freeing any currently-loaded one first — the single atomic
820
+ * swap entry point (rules 1–3). Runs under the global load gate so the stop +
821
+ * optional pre-start hook (e.g. the ComfyUI VRAM free) + spawn + readiness wait
822
+ * are one indivisible operation; no other load can interleave. Never throws on a
823
+ * model that simply fails to load — callers read status() for the running/error
824
+ * outcome. */
825
+ async load(opts, hooks) {
826
+ await _Manager.runExclusive(async () => {
827
+ if (this.state === "running" || this.state === "starting" || this.state === "stopping") {
828
+ await this.stopAndWait();
829
+ }
830
+ if (hooks?.beforeStart) await hooks.beforeStart();
831
+ await this.startInternal(opts);
832
+ await this.awaitNotStarting();
833
+ });
834
+ }
835
+ /** Start a model assuming nothing is loaded (throws BusyError otherwise). Held by
836
+ * the global load gate through readiness so concurrent loads can't spin up two
837
+ * engines at once. Most callers want load() (which stops first); this exists for
838
+ * paths that have already ensured the engine is free (bench, ComfyUI reload). */
796
839
  async start(opts) {
840
+ await _Manager.runExclusive(async () => {
841
+ await this.startInternal(opts);
842
+ await this.awaitNotStarting();
843
+ });
844
+ }
845
+ /** Wait until the engine leaves the 'starting' state (→ running or error/stopped),
846
+ * bounded by the engine kind's readiness window plus a small grace. The internal
847
+ * readiness loop flips the state and surfaces errors; this just keeps the load
848
+ * gate held until that resolves. */
849
+ async awaitNotStarting() {
850
+ const deadline = Date.now() + readinessTimeoutMs(this.opts?.engine.kind ?? "llama-server") + 5e3;
851
+ while (this.state === "starting" && Date.now() < deadline) await sleep(200);
852
+ }
853
+ async startInternal(opts) {
797
854
  if (this.state === "starting" || this.state === "running" || this.state === "stopping") {
798
855
  throw new BusyError();
799
856
  }
@@ -825,6 +882,7 @@ var Manager = class {
825
882
  this.opts = opts;
826
883
  this.port = port2;
827
884
  this.pid = child.pid ?? 0;
885
+ if (this.pid) writeEnginePid(this.store.dir(), this.pid, port2);
828
886
  this.child = child;
829
887
  this.startedAt = Date.now();
830
888
  this.errInfo = null;
@@ -870,14 +928,8 @@ var Manager = class {
870
928
  }
871
929
  async restart() {
872
930
  const opts = this.opts;
873
- const running = this.state === "running" || this.state === "starting";
874
- const exited = this.exited;
875
931
  if (!opts?.modelPath) throw new Error("no_such_model");
876
- if (running) {
877
- this.stop();
878
- await Promise.race([exited, sleep(1e4)]);
879
- }
880
- await this.start(opts);
932
+ await this.load(opts);
881
933
  }
882
934
  status() {
883
935
  const st = { state: this.state, err: this.errInfo, port: this.port, pid: this.pid, model: null, loadElapsedMs: 0 };
@@ -960,6 +1012,7 @@ var Manager = class {
960
1012
  // ---- internal ----------------------------------------------------------
961
1013
  onTerminated(child, code, logStream, errMsg) {
962
1014
  if (this.child !== child) return;
1015
+ if (child.pid) clearEnginePid(this.store.dir(), child.pid);
963
1016
  const cleanStop = this.state === "stopping" || this.state === "stopped";
964
1017
  try {
965
1018
  logStream.write(
@@ -1130,6 +1183,114 @@ function forceKill(child) {
1130
1183
  child.kill("SIGKILL");
1131
1184
  }
1132
1185
  }
1186
+ function enginePidDir(dataDir) {
1187
+ return join6(dataDir, "run");
1188
+ }
1189
+ function writeEnginePid(dataDir, pid, port2) {
1190
+ try {
1191
+ const dir = enginePidDir(dataDir);
1192
+ mkdirSync4(dir, { recursive: true });
1193
+ writeFileSync2(join6(dir, `engine-${pid}.pid`), JSON.stringify({ pid, port: port2, owner: process.pid }));
1194
+ } catch {
1195
+ }
1196
+ }
1197
+ function clearEnginePid(dataDir, pid) {
1198
+ try {
1199
+ rmSync4(join6(enginePidDir(dataDir), `engine-${pid}.pid`), { force: true });
1200
+ } catch {
1201
+ }
1202
+ }
1203
+ function pidAlive(pid) {
1204
+ if (!pid) return false;
1205
+ try {
1206
+ process.kill(pid, 0);
1207
+ return true;
1208
+ } catch (e) {
1209
+ return e.code === "EPERM";
1210
+ }
1211
+ }
1212
+ function readEnginePidFiles(dataDir) {
1213
+ const dir = enginePidDir(dataDir);
1214
+ let names;
1215
+ try {
1216
+ names = readdirSync3(dir).filter((n) => /^engine-\d+\.pid$/.test(n));
1217
+ } catch {
1218
+ return [];
1219
+ }
1220
+ const out = [];
1221
+ for (const name of names) {
1222
+ const file = join6(dir, name);
1223
+ try {
1224
+ const { pid, port: port2, owner } = JSON.parse(readFileSync2(file, "utf8"));
1225
+ if (typeof pid === "number" && pid > 0) {
1226
+ out.push({ pid, port: typeof port2 === "number" ? port2 : 0, owner: typeof owner === "number" ? owner : 0, file });
1227
+ } else rmSync4(file, { force: true });
1228
+ } catch {
1229
+ try {
1230
+ rmSync4(file, { force: true });
1231
+ } catch {
1232
+ }
1233
+ }
1234
+ }
1235
+ return out;
1236
+ }
1237
+ function portAlive(port2, timeoutMs = 600) {
1238
+ if (!port2) return Promise.resolve(false);
1239
+ return new Promise((resolve2) => {
1240
+ const sock = createConnection({ host: "127.0.0.1", port: port2 });
1241
+ const done = (alive) => {
1242
+ sock.destroy();
1243
+ resolve2(alive);
1244
+ };
1245
+ sock.setTimeout(timeoutMs);
1246
+ sock.once("connect", () => done(true));
1247
+ sock.once("timeout", () => done(false));
1248
+ sock.once("error", () => done(false));
1249
+ });
1250
+ }
1251
+ async function reapStaleEngines(dataDir) {
1252
+ let killed = 0;
1253
+ for (const { pid, port: port2, owner, file } of readEnginePidFiles(dataDir)) {
1254
+ if (owner && pidAlive(owner)) continue;
1255
+ if (await portAlive(port2)) {
1256
+ killPidTree(pid);
1257
+ killed++;
1258
+ }
1259
+ try {
1260
+ rmSync4(file, { force: true });
1261
+ } catch {
1262
+ }
1263
+ }
1264
+ return killed;
1265
+ }
1266
+ function killTrackedEnginesSync(dataDir) {
1267
+ for (const { pid, owner, file } of readEnginePidFiles(dataDir)) {
1268
+ if (owner !== process.pid) continue;
1269
+ try {
1270
+ if (process.platform === "win32") {
1271
+ spawnSync("taskkill", ["/PID", String(pid), "/F", "/T"]);
1272
+ } else {
1273
+ process.kill(pid, "SIGKILL");
1274
+ }
1275
+ } catch {
1276
+ }
1277
+ try {
1278
+ rmSync4(file, { force: true });
1279
+ } catch {
1280
+ }
1281
+ }
1282
+ }
1283
+ function killPidTree(pid) {
1284
+ if (process.platform === "win32") {
1285
+ execFile4("taskkill", ["/PID", String(pid), "/F", "/T"], () => {
1286
+ });
1287
+ } else {
1288
+ try {
1289
+ process.kill(pid, "SIGKILL");
1290
+ } catch {
1291
+ }
1292
+ }
1293
+ }
1133
1294
  function readTail(path, n) {
1134
1295
  if (!path || !existsSync6(path)) return [];
1135
1296
  try {
@@ -1790,7 +1951,7 @@ function engineModelAlias(engineKind) {
1790
1951
  }
1791
1952
 
1792
1953
  // src/models/scanner.ts
1793
- import { existsSync as existsSync9, lstatSync, readdirSync as readdirSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
1954
+ import { existsSync as existsSync9, lstatSync, readdirSync as readdirSync4, readFileSync as readFileSync3, rmSync as rmSync5, writeFileSync as writeFileSync3 } from "fs";
1794
1955
  import { basename, dirname as dirname4, join as join7 } from "path";
1795
1956
 
1796
1957
  // src/gguf/gguf.ts
@@ -2099,7 +2260,7 @@ var Scanner = class {
2099
2260
  const total = m[3];
2100
2261
  let names;
2101
2262
  try {
2102
- names = readdirSync3(e.dir);
2263
+ names = readdirSync4(e.dir);
2103
2264
  } catch {
2104
2265
  return [e.path];
2105
2266
  }
@@ -2118,9 +2279,9 @@ var Scanner = class {
2118
2279
  if (!e) throw new ScannerError("no_such_model", "No model with that key.");
2119
2280
  const paths = this.filesFor(key);
2120
2281
  if (e.format === "mlx") {
2121
- rmSync4(e.path, { recursive: true, force: true });
2282
+ rmSync5(e.path, { recursive: true, force: true });
2122
2283
  } else {
2123
- for (const p of paths) rmSync4(p, { force: true });
2284
+ for (const p of paths) rmSync5(p, { force: true });
2124
2285
  this.cache.delete(e.path);
2125
2286
  }
2126
2287
  await this.rescan();
@@ -2247,7 +2408,7 @@ var Scanner = class {
2247
2408
  const entries = {};
2248
2409
  for (const [k, v] of this.cache) entries[k] = v;
2249
2410
  try {
2250
- writeFileSync2(this.cachePath, JSON.stringify({ version: CACHE_VERSION, entries }));
2411
+ writeFileSync3(this.cachePath, JSON.stringify({ version: CACHE_VERSION, entries }));
2251
2412
  } catch {
2252
2413
  }
2253
2414
  }
@@ -2262,7 +2423,7 @@ function isMlxModelDir(names) {
2262
2423
  function walk(dir, out) {
2263
2424
  let names;
2264
2425
  try {
2265
- names = readdirSync3(dir);
2426
+ names = readdirSync4(dir);
2266
2427
  } catch {
2267
2428
  return;
2268
2429
  }
@@ -2299,7 +2460,7 @@ function mlxEntryFor(dir) {
2299
2460
  let mtimeMs = 0;
2300
2461
  let hasChatTemplate = false;
2301
2462
  try {
2302
- for (const n of readdirSync3(dir)) {
2463
+ for (const n of readdirSync4(dir)) {
2303
2464
  const lower = n.toLowerCase();
2304
2465
  if (lower.endsWith(".safetensors")) {
2305
2466
  const st = lstatSync(join7(dir, n));
@@ -2311,6 +2472,21 @@ function mlxEntryFor(dir) {
2311
2472
  if (existsSync9(tc)) hasChatTemplate = readFileSync3(tc, "utf8").includes("chat_template");
2312
2473
  } catch {
2313
2474
  }
2475
+ let incomplete = false;
2476
+ try {
2477
+ const indexPath = join7(dir, "model.safetensors.index.json");
2478
+ if (existsSync9(indexPath)) {
2479
+ const index = JSON.parse(readFileSync3(indexPath, "utf8"));
2480
+ const shards = new Set(Object.values(index.weight_map ?? {}));
2481
+ for (const shard of shards) {
2482
+ if (!existsSync9(join7(dir, shard))) {
2483
+ incomplete = true;
2484
+ break;
2485
+ }
2486
+ }
2487
+ }
2488
+ } catch {
2489
+ }
2314
2490
  const expertCount = cfg2.num_local_experts ?? cfg2.num_experts ?? 0;
2315
2491
  const bits = cfg2.quantization?.bits;
2316
2492
  const quant = bits ? `${bits}bit` : "fp16";
@@ -2336,7 +2512,7 @@ function mlxEntryFor(dir) {
2336
2512
  mmprojPath: null,
2337
2513
  hasChatTemplate,
2338
2514
  embedding: isEmbeddingModel(arch2, basename(dir)),
2339
- incomplete: false,
2515
+ incomplete,
2340
2516
  parseError,
2341
2517
  loaded: false,
2342
2518
  hasProfile: false,
@@ -2353,7 +2529,7 @@ function tick() {
2353
2529
 
2354
2530
  // src/models/hashes.ts
2355
2531
  import { createHash as createHash2 } from "crypto";
2356
- import { createReadStream, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2532
+ import { createReadStream, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
2357
2533
  import { join as join8 } from "path";
2358
2534
  var HashStore = class {
2359
2535
  cache = /* @__PURE__ */ new Map();
@@ -2404,7 +2580,7 @@ var HashStore = class {
2404
2580
  const entries = {};
2405
2581
  for (const [k, v] of this.cache) entries[k] = v;
2406
2582
  try {
2407
- writeFileSync3(this.path, JSON.stringify({ version: 1, entries }));
2583
+ writeFileSync4(this.path, JSON.stringify({ version: 1, entries }));
2408
2584
  } catch {
2409
2585
  }
2410
2586
  }
@@ -3011,10 +3187,10 @@ import {
3011
3187
  mkdirSync as mkdirSync6,
3012
3188
  readFileSync as readFileSync5,
3013
3189
  renameSync as renameSync2,
3014
- rmSync as rmSync5,
3190
+ rmSync as rmSync6,
3015
3191
  statfsSync,
3016
3192
  statSync as statSync3,
3017
- writeFileSync as writeFileSync4
3193
+ writeFileSync as writeFileSync5
3018
3194
  } from "fs";
3019
3195
  import { basename as basename2, join as join10 } from "path";
3020
3196
  import { Readable as Readable2 } from "stream";
@@ -3125,7 +3301,7 @@ var DownloadManager = class {
3125
3301
  this.controllers.get(id)?.abort();
3126
3302
  this.controllers.delete(id);
3127
3303
  if (rec.status !== "done") {
3128
- rmSync5(`${rec.dest}.part`, { force: true });
3304
+ rmSync6(`${rec.dest}.part`, { force: true });
3129
3305
  rec.status = "cancelled";
3130
3306
  rec.bytesPerSec = 0;
3131
3307
  }
@@ -3139,7 +3315,7 @@ var DownloadManager = class {
3139
3315
  if (!rec) return false;
3140
3316
  this.controllers.get(id)?.abort();
3141
3317
  this.controllers.delete(id);
3142
- if (rec.status !== "done") rmSync5(`${rec.dest}.part`, { force: true });
3318
+ if (rec.status !== "done") rmSync6(`${rec.dest}.part`, { force: true });
3143
3319
  this.records.delete(id);
3144
3320
  this.persist();
3145
3321
  this.pump();
@@ -3204,7 +3380,7 @@ var DownloadManager = class {
3204
3380
  if (!res.body) throw new DownloadError("download_failed", "Empty response body.");
3205
3381
  const resuming = res.status === 206;
3206
3382
  if (!resuming && startAt > 0) {
3207
- rmSync5(part, { force: true });
3383
+ rmSync6(part, { force: true });
3208
3384
  startAt = 0;
3209
3385
  rec.received = 0;
3210
3386
  }
@@ -3229,13 +3405,13 @@ var DownloadManager = class {
3229
3405
  const out = createWriteStream3(part, startAt > 0 ? { flags: "a" } : { flags: "w" });
3230
3406
  await pipeline2(body3, out, { signal: ac.signal });
3231
3407
  if (rec.total > 0 && rec.received !== rec.total) {
3232
- rmSync5(part, { force: true });
3408
+ rmSync6(part, { force: true });
3233
3409
  throw new DownloadError("size_mismatch", "Download corrupt \u2014 size did not match. Removed the partial file.");
3234
3410
  }
3235
3411
  if (verifyHash && rec.sha256) {
3236
3412
  const got = hash.digest("hex");
3237
3413
  if (got !== rec.sha256) {
3238
- rmSync5(part, { force: true });
3414
+ rmSync6(part, { force: true });
3239
3415
  throw new DownloadError("checksum_failed", "Checksum failed \u2014 the downloaded file was corrupt.");
3240
3416
  }
3241
3417
  }
@@ -3288,7 +3464,7 @@ var DownloadManager = class {
3288
3464
  }
3289
3465
  saveProvenance() {
3290
3466
  try {
3291
- writeFileSync4(this.provenancePath, JSON.stringify({ version: 1, entries: this.provenanceList }, null, 2));
3467
+ writeFileSync5(this.provenancePath, JSON.stringify({ version: 1, entries: this.provenanceList }, null, 2));
3292
3468
  } catch {
3293
3469
  }
3294
3470
  }
@@ -3310,7 +3486,7 @@ var DownloadManager = class {
3310
3486
  });
3311
3487
  }
3312
3488
  try {
3313
- writeFileSync4(this.manifestPath, JSON.stringify({ version: 1, entries }, null, 2));
3489
+ writeFileSync5(this.manifestPath, JSON.stringify({ version: 1, entries }, null, 2));
3314
3490
  } catch {
3315
3491
  }
3316
3492
  }
@@ -3360,7 +3536,7 @@ function safePathname(u) {
3360
3536
 
3361
3537
  // src/bench/bench.ts
3362
3538
  import { execFile as execFile6 } from "child_process";
3363
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5 } from "fs";
3539
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
3364
3540
  import { join as join11 } from "path";
3365
3541
  import { randomUUID as randomUUID4 } from "crypto";
3366
3542
  var READY_TIMEOUT_MS = 12e4;
@@ -3631,7 +3807,7 @@ var BenchRunner = class {
3631
3807
  };
3632
3808
  const queueDir = join11(this.store.dir(), "telemetry", "queue");
3633
3809
  mkdirSync7(queueDir, { recursive: true });
3634
- writeFileSync5(join11(queueDir, `${randomUUID4()}.json`), JSON.stringify(event));
3810
+ writeFileSync6(join11(queueDir, `${randomUUID4()}.json`), JSON.stringify(event));
3635
3811
  } catch {
3636
3812
  }
3637
3813
  }
@@ -3768,16 +3944,15 @@ var ModelRouter = class {
3768
3944
  const keepN = Math.max(1, this.store.snapshot().gateway.keepN);
3769
3945
  const needsNewSlot = entry.embedding || this.chatSlotCount() < keepN;
3770
3946
  const targetManager = needsNewSlot ? this.manager.status().state === "stopped" || this.manager.status().state === "error" ? this.manager : new Manager(this.store) : this.evictChatLru();
3771
- await targetManager.stopAndWait();
3772
- await this.comfy?.freeComfyUIBeforeLoad();
3773
3947
  try {
3774
- await targetManager.start(opts);
3948
+ await targetManager.load(opts, {
3949
+ beforeStart: () => this.comfy?.freeComfyUIBeforeLoad() ?? Promise.resolve()
3950
+ });
3775
3951
  } catch (e) {
3776
3952
  return { status: 503, message: `Engine start failed: ${e.message}` };
3777
3953
  }
3778
- const ready = await this.waitReady(targetManager, active.kind);
3779
- if (!ready) {
3780
- const s = targetManager.status();
3954
+ const s = targetManager.status();
3955
+ if (s.state !== "running") {
3781
3956
  return { status: 503, message: s.err?.message ?? "Model failed to become ready." };
3782
3957
  }
3783
3958
  const target = targetManager.target();
@@ -3869,23 +4044,7 @@ var ModelRouter = class {
3869
4044
  extraArgs: profileToArgs(profile, entry, engine.capabilities, sys.cores)
3870
4045
  };
3871
4046
  }
3872
- /** Poll until the manager's engine process becomes ready or fails.
3873
- * Mirrors the Manager's internal readiness timeout by engine kind. */
3874
- async waitReady(manager2, engineKind) {
3875
- const timeoutMs = engineKind === "vllm" ? 6e5 : 12e4;
3876
- const deadline = Date.now() + timeoutMs;
3877
- while (Date.now() < deadline) {
3878
- const s = manager2.status();
3879
- if (s.state === "running") return true;
3880
- if (s.state === "error" || s.state === "stopped") return false;
3881
- await sleep3(250);
3882
- }
3883
- return false;
3884
- }
3885
4047
  };
3886
- function sleep3(ms) {
3887
- return new Promise((r) => setTimeout(r, ms));
3888
- }
3889
4048
 
3890
4049
  // src/tools/builtin.ts
3891
4050
  import { runInNewContext } from "vm";
@@ -4455,7 +4614,7 @@ import { Agent, setGlobalDispatcher } from "undici";
4455
4614
 
4456
4615
  // src/api/routes.ts
4457
4616
  import { streamSSE } from "hono/streaming";
4458
- 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";
4617
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync6, readdirSync as readdirSync5, realpathSync, rmSync as rmSync7, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
4459
4618
  import { basename as basename3, dirname as dirname5, join as join12, resolve, sep } from "path";
4460
4619
 
4461
4620
  // src/comfyui/gate-template.ts
@@ -4666,6 +4825,23 @@ function catalogEngine(id) {
4666
4825
  return ALL.find((e) => e.id === id);
4667
4826
  }
4668
4827
 
4828
+ // src/api/path-utils.ts
4829
+ function inferRepoFromPath(filePath, modelDirs) {
4830
+ const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
4831
+ const fp = norm(filePath);
4832
+ const seg = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
4833
+ for (const dir of modelDirs) {
4834
+ const root = norm(dir);
4835
+ if (!fp.toLowerCase().startsWith(root.toLowerCase() + "/")) continue;
4836
+ const parts = fp.slice(root.length + 1).split("/");
4837
+ if (parts.length >= 2 && seg.test(parts[0]) && seg.test(parts[1])) {
4838
+ return `${parts[0]}/${parts[1]}`;
4839
+ }
4840
+ return null;
4841
+ }
4842
+ return null;
4843
+ }
4844
+
4669
4845
  // src/api/routes.ts
4670
4846
  function err(c, status, code, message) {
4671
4847
  return c.json({ error: { code, message } }, status);
@@ -4948,7 +5124,7 @@ function registerApi(app2, d) {
4948
5124
  }
4949
5125
  let entries;
4950
5126
  try {
4951
- entries = readdirSync4(real, { withFileTypes: true }).filter((d2) => !d2.name.startsWith(".")).map((d2) => {
5127
+ entries = readdirSync5(real, { withFileTypes: true }).filter((d2) => !d2.name.startsWith(".")).map((d2) => {
4952
5128
  let isDir = d2.isDirectory();
4953
5129
  if (d2.isSymbolicLink()) {
4954
5130
  try {
@@ -5005,13 +5181,7 @@ function registerApi(app2, d) {
5005
5181
  extraArgs: profileToArgs(profile, entry, active.capabilities, sys.cores)
5006
5182
  };
5007
5183
  }
5008
- await d.manager.stopAndWait();
5009
- await d.comfy?.freeComfyUIBeforeLoad();
5010
- try {
5011
- await d.manager.start(opts2);
5012
- } catch (e) {
5013
- return startError(c, e);
5014
- }
5184
+ void d.manager.load(opts2, { beforeStart: () => d.comfy?.freeComfyUIBeforeLoad() ?? Promise.resolve() }).catch((e) => console.warn(`engine load failed: ${e}`));
5015
5185
  d.store.update((x) => {
5016
5186
  x.lastLoaded = { modelKey: entry.key, engineId: active.id };
5017
5187
  });
@@ -5027,13 +5197,7 @@ function registerApi(app2, d) {
5027
5197
  }
5028
5198
  if (!modelPath) return err(c, 409, "no_such_model", "No model specified. Pick one from the Models screen.");
5029
5199
  const opts = { engine: active, model: deriveModel(modelPath, name, extra), modelPath, extraArgs: extra };
5030
- await d.manager.stopAndWait();
5031
- await d.comfy?.freeComfyUIBeforeLoad();
5032
- try {
5033
- await d.manager.start(opts);
5034
- } catch (e) {
5035
- return startError(c, e);
5036
- }
5200
+ void d.manager.load(opts, { beforeStart: () => d.comfy?.freeComfyUIBeforeLoad() ?? Promise.resolve() }).catch((e) => console.warn(`engine load failed: ${e}`));
5037
5201
  return c.json({ ok: true }, 202);
5038
5202
  });
5039
5203
  app2.post("/api/v1/engine/stop", (c) => {
@@ -5074,7 +5238,7 @@ function registerApi(app2, d) {
5074
5238
  const gateDir = join12(customNodes, "turbollm_gate");
5075
5239
  try {
5076
5240
  mkdirSync8(gateDir, { recursive: true });
5077
- writeFileSync6(join12(gateDir, "__init__.py"), gateNodeSource(base2));
5241
+ writeFileSync7(join12(gateDir, "__init__.py"), gateNodeSource(base2));
5078
5242
  } catch (e) {
5079
5243
  return err(c, 500, "fs_write_failed", `Could not write the gate node: ${e instanceof Error ? e.message : e}`);
5080
5244
  }
@@ -5087,7 +5251,7 @@ function registerApi(app2, d) {
5087
5251
  const dir = d.store.snapshot().comfyui.gatePath;
5088
5252
  if (dir && existsSync11(dir)) {
5089
5253
  try {
5090
- rmSync6(dir, { recursive: true, force: true });
5254
+ rmSync7(dir, { recursive: true, force: true });
5091
5255
  } catch (e) {
5092
5256
  return err(c, 500, "fs_write_failed", `Could not remove the gate node: ${e instanceof Error ? e.message : e}`);
5093
5257
  }
@@ -5576,21 +5740,6 @@ function overlayModel(e, d, lastTpsMap) {
5576
5740
  const sourceRepo = provRepo ?? inferRepoFromPath(e.path, snap.modelDirs);
5577
5741
  return { ...e, loaded, hasProfile: e.key in profiles, lastTps, liveTps, benchTps, compatibleWithActiveEngine, sourceRepo };
5578
5742
  }
5579
- function inferRepoFromPath(filePath, modelDirs) {
5580
- const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
5581
- const fp = norm(filePath);
5582
- const seg = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
5583
- for (const dir of modelDirs) {
5584
- const root = norm(dir);
5585
- if (!fp.toLowerCase().startsWith(root.toLowerCase() + "/")) continue;
5586
- const parts = fp.slice(root.length + 1).split("/");
5587
- if (parts.length >= 3 && seg.test(parts[0]) && seg.test(parts[1])) {
5588
- return `${parts[0]}/${parts[1]}`;
5589
- }
5590
- return null;
5591
- }
5592
- return null;
5593
- }
5594
5743
  function formatMismatchMessage(engineKind, format) {
5595
5744
  if (engineKind === "mlx")
5596
5745
  return "The active engine is MLX \u2014 pick a safetensors model, or switch to a llama.cpp engine for GGUF.";
@@ -5710,11 +5859,6 @@ function regErr(c, e) {
5710
5859
  if (e instanceof ValueError) return err(c, 400, "invalid_config_value", e.message);
5711
5860
  return err(c, 500, "internal", e.message);
5712
5861
  }
5713
- function startError(c, e) {
5714
- if (e instanceof BusyError) return err(c, 409, "engine_already_running", "An engine is already running.");
5715
- if (e.message === "no_free_port") return err(c, 409, "no_free_port", "No free port for the engine (8081\u20138181 all in use).");
5716
- return err(c, 500, "engine_start_failed", e.message);
5717
- }
5718
5862
  function deriveModel(modelPath, name, extraArgs) {
5719
5863
  let ctx = 0;
5720
5864
  for (let i = 0; i + 1 < extraArgs.length; i++) {
@@ -5863,6 +6007,94 @@ function buildConnectSnippets(cli, base2, apiKey, modelName) {
5863
6007
 
5864
6008
  // src/chat/chat-routes.ts
5865
6009
  import { streamSSE as streamSSE2 } from "hono/streaming";
6010
+
6011
+ // src/chat/parser.ts
6012
+ var THINK_OPEN = "<think>";
6013
+ var THINK_CLOSE = "</think>";
6014
+ var CHAN_ANALYSIS_OPEN = "<|channel|>analysis<|message|>";
6015
+ var CHAN_CLOSE = "<|end|>";
6016
+ var CHAN_FINAL_SKIP = "<|start|>assistant<|channel|>final<|message|>";
6017
+ function initParseState() {
6018
+ return { phase: "initial", isChannel: false, buf: "" };
6019
+ }
6020
+ function feedChunk(state, raw) {
6021
+ const events = [];
6022
+ let { phase, isChannel, buf } = state;
6023
+ buf += raw;
6024
+ while (buf.length > 0) {
6025
+ if (phase === "initial") {
6026
+ const thinkIdx = buf.indexOf(THINK_OPEN);
6027
+ const chanIdx = buf.indexOf(CHAN_ANALYSIS_OPEN);
6028
+ const hasThink = thinkIdx >= 0;
6029
+ const hasChan = chanIdx >= 0;
6030
+ const useThink = hasThink && (!hasChan || thinkIdx <= chanIdx);
6031
+ const openIdx = useThink ? thinkIdx : hasChan ? chanIdx : -1;
6032
+ const openTag = useThink ? THINK_OPEN : CHAN_ANALYSIS_OPEN;
6033
+ if (openIdx === 0) {
6034
+ isChannel = !useThink;
6035
+ phase = "reasoning";
6036
+ buf = buf.slice(openTag.length);
6037
+ } else if (openIdx > 0) {
6038
+ events.push({ type: "delta", text: buf.slice(0, openIdx) });
6039
+ isChannel = !useThink;
6040
+ phase = "reasoning";
6041
+ buf = buf.slice(openIdx + openTag.length);
6042
+ } else {
6043
+ const safeLen = buf.length - (CHAN_ANALYSIS_OPEN.length - 1);
6044
+ if (safeLen > 0) {
6045
+ events.push({ type: "delta", text: buf.slice(0, safeLen) });
6046
+ buf = buf.slice(safeLen);
6047
+ phase = "content";
6048
+ } else {
6049
+ break;
6050
+ }
6051
+ }
6052
+ } else if (phase === "reasoning") {
6053
+ const closeTag = isChannel ? CHAN_CLOSE : THINK_CLOSE;
6054
+ const closeIdx = buf.indexOf(closeTag);
6055
+ if (closeIdx >= 0) {
6056
+ if (closeIdx > 0) {
6057
+ events.push({ type: "reasoning", text: buf.slice(0, closeIdx) });
6058
+ }
6059
+ const wasChannel = isChannel;
6060
+ buf = buf.slice(closeIdx + closeTag.length);
6061
+ phase = wasChannel ? "skipFinal" : "content";
6062
+ if (!wasChannel && buf) {
6063
+ events.push({ type: "delta", text: buf });
6064
+ buf = "";
6065
+ }
6066
+ } else if (buf.length >= closeTag.length) {
6067
+ const safe = buf.length - (closeTag.length - 1);
6068
+ events.push({ type: "reasoning", text: buf.slice(0, safe) });
6069
+ buf = buf.slice(safe);
6070
+ } else {
6071
+ break;
6072
+ }
6073
+ } else if (phase === "skipFinal") {
6074
+ if (buf.startsWith(CHAN_FINAL_SKIP)) {
6075
+ buf = buf.slice(CHAN_FINAL_SKIP.length);
6076
+ phase = "content";
6077
+ } else if (CHAN_FINAL_SKIP.startsWith(buf) && buf.length < CHAN_FINAL_SKIP.length) {
6078
+ break;
6079
+ } else {
6080
+ const skipIdx = buf.indexOf(CHAN_FINAL_SKIP);
6081
+ buf = skipIdx >= 0 ? buf.slice(skipIdx + CHAN_FINAL_SKIP.length) : "";
6082
+ phase = "content";
6083
+ }
6084
+ } else {
6085
+ events.push({ type: "delta", text: buf });
6086
+ buf = "";
6087
+ break;
6088
+ }
6089
+ }
6090
+ return { state: { phase, isChannel, buf }, events };
6091
+ }
6092
+ function flushState(state) {
6093
+ if (!state.buf) return [];
6094
+ return state.phase === "reasoning" ? [{ type: "reasoning", text: state.buf }] : [{ type: "delta", text: state.buf }];
6095
+ }
6096
+
6097
+ // src/chat/chat-routes.ts
5866
6098
  var inflight = /* @__PURE__ */ new Map();
5867
6099
  var EXPERT_SYSTEM_PROMPT = `You are the TurboLLM in-app expert assistant \u2014 a knowledgeable, friendly guide built into TurboLLM, a local-first desktop app for running large language models on the user's own machine.
5868
6100
 
@@ -6126,9 +6358,7 @@ async function runGeneration(d, stream, ctx) {
6126
6358
  ac.signal.addEventListener("abort", cancelReader, { once: true });
6127
6359
  }
6128
6360
  let roundContent = "";
6129
- let inThink = false;
6130
- let pendingThinkBuf = "";
6131
- let firstDelta = false;
6361
+ let parseState = initParseState();
6132
6362
  let finishReason = "";
6133
6363
  const pendingToolCalls = /* @__PURE__ */ new Map();
6134
6364
  roundLoop: while (true) {
@@ -6172,8 +6402,8 @@ async function runGeneration(d, stream, ctx) {
6172
6402
  }
6173
6403
  continue;
6174
6404
  }
6175
- if (delta.reasoning_content) {
6176
- const rc = delta.reasoning_content;
6405
+ const rc = delta.reasoning_content ?? delta.reasoning;
6406
+ if (rc) {
6177
6407
  if (!thinkStart) thinkStart = Date.now();
6178
6408
  thinkEnd = Date.now();
6179
6409
  fullReasoning += rc;
@@ -6182,88 +6412,34 @@ async function runGeneration(d, stream, ctx) {
6182
6412
  }
6183
6413
  const raw_content = delta.content ?? "";
6184
6414
  if (!raw_content) continue;
6185
- let toProcess = raw_content;
6186
- while (toProcess.length > 0) {
6187
- if (!inThink && !firstDelta) {
6188
- pendingThinkBuf += toProcess;
6189
- const openIdx = pendingThinkBuf.indexOf("<think>");
6190
- if (openIdx === 0) {
6191
- inThink = true;
6192
- if (!thinkStart) thinkStart = Date.now();
6193
- pendingThinkBuf = pendingThinkBuf.slice(7);
6194
- toProcess = "";
6195
- } else if (openIdx > 0) {
6196
- const before = pendingThinkBuf.slice(0, openIdx);
6197
- fullContent += before;
6198
- roundContent += before;
6199
- firstDelta = true;
6200
- if (!ttftMs) ttftMs = Date.now() - requestStart;
6201
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: before }) });
6202
- inThink = true;
6203
- if (!thinkStart) thinkStart = Date.now();
6204
- pendingThinkBuf = pendingThinkBuf.slice(openIdx + 7);
6205
- toProcess = "";
6206
- } else if (pendingThinkBuf.length > 20) {
6207
- const flush = pendingThinkBuf;
6208
- pendingThinkBuf = "";
6209
- fullContent += flush;
6210
- roundContent += flush;
6211
- firstDelta = true;
6212
- if (!ttftMs) ttftMs = Date.now() - requestStart;
6213
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: flush }) });
6214
- toProcess = "";
6215
- } else {
6216
- toProcess = "";
6217
- }
6218
- } else if (inThink) {
6219
- pendingThinkBuf += toProcess;
6220
- const closeIdx = pendingThinkBuf.indexOf("</think>");
6221
- if (closeIdx >= 0) {
6222
- const thinkChunk = pendingThinkBuf.slice(0, closeIdx);
6223
- if (thinkChunk) {
6224
- thinkEnd = Date.now();
6225
- fullReasoning += thinkChunk;
6226
- await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: thinkChunk }) });
6227
- }
6228
- inThink = false;
6229
- thinkEnd = Date.now();
6230
- pendingThinkBuf = pendingThinkBuf.slice(closeIdx + 8);
6231
- toProcess = "";
6232
- if (pendingThinkBuf) {
6233
- fullContent += pendingThinkBuf;
6234
- roundContent += pendingThinkBuf;
6235
- firstDelta = true;
6236
- if (!ttftMs) ttftMs = Date.now() - requestStart;
6237
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: pendingThinkBuf }) });
6238
- pendingThinkBuf = "";
6239
- }
6240
- } else {
6241
- thinkEnd = Date.now();
6242
- fullReasoning += pendingThinkBuf;
6243
- await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: pendingThinkBuf }) });
6244
- pendingThinkBuf = "";
6245
- toProcess = "";
6246
- }
6415
+ const { state: nextState, events: parseEvents } = feedChunk(parseState, raw_content);
6416
+ parseState = nextState;
6417
+ for (const ev of parseEvents) {
6418
+ if (ev.type === "reasoning") {
6419
+ if (!thinkStart) thinkStart = Date.now();
6420
+ thinkEnd = Date.now();
6421
+ fullReasoning += ev.text;
6422
+ await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: ev.text }) });
6247
6423
  } else {
6424
+ fullContent += ev.text;
6425
+ roundContent += ev.text;
6248
6426
  if (!ttftMs) ttftMs = Date.now() - requestStart;
6249
- firstDelta = true;
6250
- fullContent += toProcess;
6251
- roundContent += toProcess;
6252
6427
  d.manager.setLiveGen({ phase: "gen", pct: 0, outputTokens: ++liveOut });
6253
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: toProcess }) });
6254
- toProcess = "";
6428
+ await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: ev.text }) });
6255
6429
  }
6256
6430
  }
6257
6431
  }
6258
6432
  }
6259
6433
  ac.signal.removeEventListener("abort", cancelReader);
6260
- if (pendingThinkBuf && inThink) {
6261
- fullReasoning += pendingThinkBuf;
6262
- await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: pendingThinkBuf }) });
6263
- } else if (pendingThinkBuf) {
6264
- fullContent += pendingThinkBuf;
6265
- roundContent += pendingThinkBuf;
6266
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: pendingThinkBuf }) });
6434
+ for (const ev of flushState(parseState)) {
6435
+ if (ev.type === "reasoning") {
6436
+ fullReasoning += ev.text;
6437
+ await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: ev.text }) });
6438
+ } else {
6439
+ fullContent += ev.text;
6440
+ roundContent += ev.text;
6441
+ await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: ev.text }) });
6442
+ }
6267
6443
  }
6268
6444
  if ((finishReason === "tool_calls" || pendingToolCalls.size > 0) && d.tools && toolIter <= MAX_TOOL_ITER) {
6269
6445
  const roundToolCalls = Array.from(pendingToolCalls.values());
@@ -6358,7 +6534,10 @@ async function runGeneration(d, stream, ctx) {
6358
6534
  } catch {
6359
6535
  }
6360
6536
  const finalMsg = db2.getMessage(assistantMsg.id);
6361
- await stream.writeSSE({ event: "done", data: JSON.stringify({ message: finalMsg }) });
6537
+ try {
6538
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ message: finalMsg }) });
6539
+ } catch {
6540
+ }
6362
6541
  if (!aborted && conv.title === "New chat" && d.store.snapshot().daemon.autoGenerateTitles) {
6363
6542
  setTimeout(() => {
6364
6543
  void autoTitle(d, convId, ctx.engineMessages, fullContent, target);
@@ -6655,7 +6834,8 @@ async function* streamToAnthropic(oaiStream, modelName, msgId, onUsage, onLive)
6655
6834
  failed = true;
6656
6835
  yield sse("error", { error: { type: "api_error", message: "engine stopped" } });
6657
6836
  } finally {
6658
- reader.releaseLock();
6837
+ await reader.cancel().catch(() => {
6838
+ });
6659
6839
  }
6660
6840
  if (!failed) {
6661
6841
  if (inThinking) {
@@ -6706,6 +6886,15 @@ function cbStop(index) {
6706
6886
  }
6707
6887
 
6708
6888
  // src/gateway/gateway.ts
6889
+ function clientAbort(c) {
6890
+ const ac = new AbortController();
6891
+ const sig = c.req.raw.signal;
6892
+ if (sig) {
6893
+ if (sig.aborted) ac.abort();
6894
+ else sig.addEventListener("abort", () => ac.abort(), { once: true });
6895
+ }
6896
+ return ac;
6897
+ }
6709
6898
  function registerGateway(app2, d) {
6710
6899
  app2.post("/v1/messages", async (c) => {
6711
6900
  let req;
@@ -6739,12 +6928,14 @@ function registerGateway(app2, d) {
6739
6928
  const oaiAlias = engineModelAlias(d.registry.active()?.kind ?? "");
6740
6929
  if (oaiAlias) oaiBody.model = oaiAlias;
6741
6930
  d.manager.generationStart();
6931
+ const ac = clientAbort(c);
6742
6932
  let res;
6743
6933
  try {
6744
6934
  res = await fetch(`${target}/v1/chat/completions`, {
6745
6935
  method: "POST",
6746
6936
  headers: { "Content-Type": "application/json" },
6747
- body: JSON.stringify(oaiBody)
6937
+ body: JSON.stringify(oaiBody),
6938
+ signal: ac.signal
6748
6939
  });
6749
6940
  } catch (e) {
6750
6941
  d.manager.generationEnd();
@@ -6783,11 +6974,13 @@ function registerGateway(app2, d) {
6783
6974
  }
6784
6975
  );
6785
6976
  return streamSSE3(c, async (stream) => {
6977
+ stream.onAbort(() => ac.abort());
6786
6978
  try {
6787
6979
  for await (const evt of gen) {
6788
6980
  await stream.writeSSE({ event: evt.event, data: evt.data });
6789
6981
  }
6790
6982
  } finally {
6983
+ ac.abort();
6791
6984
  d.manager.generationEnd();
6792
6985
  }
6793
6986
  });
@@ -6855,7 +7048,8 @@ function registerGateway(app2, d) {
6855
7048
  const headers = new Headers(c.req.raw.headers);
6856
7049
  headers.delete("host");
6857
7050
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
6858
- const init = { method: c.req.method, headers };
7051
+ const ac = clientAbort(c);
7052
+ const init = { method: c.req.method, headers, signal: ac.signal };
6859
7053
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
6860
7054
  if (isChat) {
6861
7055
  if (parsedBody && maxLimit > 0) {
@@ -7083,6 +7277,10 @@ Please upgrade: https://nodejs.org
7083
7277
  );
7084
7278
  process.exit(1);
7085
7279
  }
7280
+ process.on("unhandledRejection", (reason) => {
7281
+ if (reason?.name === "AbortError") return;
7282
+ console.warn("unhandledRejection (continuing):", reason);
7283
+ });
7086
7284
  var argv = process.argv.slice(2);
7087
7285
  function hasFlag(...names) {
7088
7286
  return names.some((n) => argv.includes(n));
@@ -7135,6 +7333,8 @@ var store = ConfigStore.load(argValue("--config", defaultConfigPath()));
7135
7333
  if (store.brokenBackup()) {
7136
7334
  console.warn(`config was reset; previous file backed up at ${store.brokenBackup()}`);
7137
7335
  }
7336
+ var reaped = await reapStaleEngines(store.dir()).catch(() => 0);
7337
+ if (reaped > 0) console.log(`reaped ${reaped} orphaned engine process(es) from a previous run`);
7138
7338
  var registry = new Registry(store);
7139
7339
  var pruned = registry.pruneDeadManagedBuilds();
7140
7340
  if (pruned > 0) console.log(`pruned ${pruned} dangling engine build(s)`);
@@ -7357,12 +7557,11 @@ void (async () => {
7357
7557
  };
7358
7558
  }
7359
7559
  if (opts) {
7360
- await comfy.freeComfyUIBeforeLoad();
7361
- manager.start(opts).catch((e) => console.warn(`auto-load failed: ${e}`));
7560
+ manager.load(opts, { beforeStart: () => comfy.freeComfyUIBeforeLoad() }).catch((e) => console.warn(`auto-load failed: ${e}`));
7362
7561
  }
7363
7562
  })();
7364
7563
  var shuttingDown = false;
7365
- for (const sig of ["SIGINT", "SIGTERM"]) {
7564
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
7366
7565
  process.on(sig, () => {
7367
7566
  if (shuttingDown) return;
7368
7567
  shuttingDown = true;
@@ -7376,3 +7575,9 @@ for (const sig of ["SIGINT", "SIGTERM"]) {
7376
7575
  setTimeout(() => process.exit(0), 12e3).unref();
7377
7576
  });
7378
7577
  }
7578
+ process.on("exit", () => {
7579
+ try {
7580
+ killTrackedEnginesSync(store.dir());
7581
+ } catch {
7582
+ }
7583
+ });