turbollm 0.7.1 → 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.
Files changed (2) hide show
  1. package/dist/cli.js +359 -195
  2. package/package.json +1 -1
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));
@@ -2368,7 +2529,7 @@ function tick() {
2368
2529
 
2369
2530
  // src/models/hashes.ts
2370
2531
  import { createHash as createHash2 } from "crypto";
2371
- 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";
2372
2533
  import { join as join8 } from "path";
2373
2534
  var HashStore = class {
2374
2535
  cache = /* @__PURE__ */ new Map();
@@ -2419,7 +2580,7 @@ var HashStore = class {
2419
2580
  const entries = {};
2420
2581
  for (const [k, v] of this.cache) entries[k] = v;
2421
2582
  try {
2422
- writeFileSync3(this.path, JSON.stringify({ version: 1, entries }));
2583
+ writeFileSync4(this.path, JSON.stringify({ version: 1, entries }));
2423
2584
  } catch {
2424
2585
  }
2425
2586
  }
@@ -3026,10 +3187,10 @@ import {
3026
3187
  mkdirSync as mkdirSync6,
3027
3188
  readFileSync as readFileSync5,
3028
3189
  renameSync as renameSync2,
3029
- rmSync as rmSync5,
3190
+ rmSync as rmSync6,
3030
3191
  statfsSync,
3031
3192
  statSync as statSync3,
3032
- writeFileSync as writeFileSync4
3193
+ writeFileSync as writeFileSync5
3033
3194
  } from "fs";
3034
3195
  import { basename as basename2, join as join10 } from "path";
3035
3196
  import { Readable as Readable2 } from "stream";
@@ -3140,7 +3301,7 @@ var DownloadManager = class {
3140
3301
  this.controllers.get(id)?.abort();
3141
3302
  this.controllers.delete(id);
3142
3303
  if (rec.status !== "done") {
3143
- rmSync5(`${rec.dest}.part`, { force: true });
3304
+ rmSync6(`${rec.dest}.part`, { force: true });
3144
3305
  rec.status = "cancelled";
3145
3306
  rec.bytesPerSec = 0;
3146
3307
  }
@@ -3154,7 +3315,7 @@ var DownloadManager = class {
3154
3315
  if (!rec) return false;
3155
3316
  this.controllers.get(id)?.abort();
3156
3317
  this.controllers.delete(id);
3157
- if (rec.status !== "done") rmSync5(`${rec.dest}.part`, { force: true });
3318
+ if (rec.status !== "done") rmSync6(`${rec.dest}.part`, { force: true });
3158
3319
  this.records.delete(id);
3159
3320
  this.persist();
3160
3321
  this.pump();
@@ -3219,7 +3380,7 @@ var DownloadManager = class {
3219
3380
  if (!res.body) throw new DownloadError("download_failed", "Empty response body.");
3220
3381
  const resuming = res.status === 206;
3221
3382
  if (!resuming && startAt > 0) {
3222
- rmSync5(part, { force: true });
3383
+ rmSync6(part, { force: true });
3223
3384
  startAt = 0;
3224
3385
  rec.received = 0;
3225
3386
  }
@@ -3244,13 +3405,13 @@ var DownloadManager = class {
3244
3405
  const out = createWriteStream3(part, startAt > 0 ? { flags: "a" } : { flags: "w" });
3245
3406
  await pipeline2(body3, out, { signal: ac.signal });
3246
3407
  if (rec.total > 0 && rec.received !== rec.total) {
3247
- rmSync5(part, { force: true });
3408
+ rmSync6(part, { force: true });
3248
3409
  throw new DownloadError("size_mismatch", "Download corrupt \u2014 size did not match. Removed the partial file.");
3249
3410
  }
3250
3411
  if (verifyHash && rec.sha256) {
3251
3412
  const got = hash.digest("hex");
3252
3413
  if (got !== rec.sha256) {
3253
- rmSync5(part, { force: true });
3414
+ rmSync6(part, { force: true });
3254
3415
  throw new DownloadError("checksum_failed", "Checksum failed \u2014 the downloaded file was corrupt.");
3255
3416
  }
3256
3417
  }
@@ -3303,7 +3464,7 @@ var DownloadManager = class {
3303
3464
  }
3304
3465
  saveProvenance() {
3305
3466
  try {
3306
- 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));
3307
3468
  } catch {
3308
3469
  }
3309
3470
  }
@@ -3325,7 +3486,7 @@ var DownloadManager = class {
3325
3486
  });
3326
3487
  }
3327
3488
  try {
3328
- writeFileSync4(this.manifestPath, JSON.stringify({ version: 1, entries }, null, 2));
3489
+ writeFileSync5(this.manifestPath, JSON.stringify({ version: 1, entries }, null, 2));
3329
3490
  } catch {
3330
3491
  }
3331
3492
  }
@@ -3375,7 +3536,7 @@ function safePathname(u) {
3375
3536
 
3376
3537
  // src/bench/bench.ts
3377
3538
  import { execFile as execFile6 } from "child_process";
3378
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5 } from "fs";
3539
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6 } from "fs";
3379
3540
  import { join as join11 } from "path";
3380
3541
  import { randomUUID as randomUUID4 } from "crypto";
3381
3542
  var READY_TIMEOUT_MS = 12e4;
@@ -3646,7 +3807,7 @@ var BenchRunner = class {
3646
3807
  };
3647
3808
  const queueDir = join11(this.store.dir(), "telemetry", "queue");
3648
3809
  mkdirSync7(queueDir, { recursive: true });
3649
- writeFileSync5(join11(queueDir, `${randomUUID4()}.json`), JSON.stringify(event));
3810
+ writeFileSync6(join11(queueDir, `${randomUUID4()}.json`), JSON.stringify(event));
3650
3811
  } catch {
3651
3812
  }
3652
3813
  }
@@ -3783,16 +3944,15 @@ var ModelRouter = class {
3783
3944
  const keepN = Math.max(1, this.store.snapshot().gateway.keepN);
3784
3945
  const needsNewSlot = entry.embedding || this.chatSlotCount() < keepN;
3785
3946
  const targetManager = needsNewSlot ? this.manager.status().state === "stopped" || this.manager.status().state === "error" ? this.manager : new Manager(this.store) : this.evictChatLru();
3786
- await targetManager.stopAndWait();
3787
- await this.comfy?.freeComfyUIBeforeLoad();
3788
3947
  try {
3789
- await targetManager.start(opts);
3948
+ await targetManager.load(opts, {
3949
+ beforeStart: () => this.comfy?.freeComfyUIBeforeLoad() ?? Promise.resolve()
3950
+ });
3790
3951
  } catch (e) {
3791
3952
  return { status: 503, message: `Engine start failed: ${e.message}` };
3792
3953
  }
3793
- const ready = await this.waitReady(targetManager, active.kind);
3794
- if (!ready) {
3795
- const s = targetManager.status();
3954
+ const s = targetManager.status();
3955
+ if (s.state !== "running") {
3796
3956
  return { status: 503, message: s.err?.message ?? "Model failed to become ready." };
3797
3957
  }
3798
3958
  const target = targetManager.target();
@@ -3884,23 +4044,7 @@ var ModelRouter = class {
3884
4044
  extraArgs: profileToArgs(profile, entry, engine.capabilities, sys.cores)
3885
4045
  };
3886
4046
  }
3887
- /** Poll until the manager's engine process becomes ready or fails.
3888
- * Mirrors the Manager's internal readiness timeout by engine kind. */
3889
- async waitReady(manager2, engineKind) {
3890
- const timeoutMs = engineKind === "vllm" ? 6e5 : 12e4;
3891
- const deadline = Date.now() + timeoutMs;
3892
- while (Date.now() < deadline) {
3893
- const s = manager2.status();
3894
- if (s.state === "running") return true;
3895
- if (s.state === "error" || s.state === "stopped") return false;
3896
- await sleep3(250);
3897
- }
3898
- return false;
3899
- }
3900
4047
  };
3901
- function sleep3(ms) {
3902
- return new Promise((r) => setTimeout(r, ms));
3903
- }
3904
4048
 
3905
4049
  // src/tools/builtin.ts
3906
4050
  import { runInNewContext } from "vm";
@@ -4470,7 +4614,7 @@ import { Agent, setGlobalDispatcher } from "undici";
4470
4614
 
4471
4615
  // src/api/routes.ts
4472
4616
  import { streamSSE } from "hono/streaming";
4473
- 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";
4474
4618
  import { basename as basename3, dirname as dirname5, join as join12, resolve, sep } from "path";
4475
4619
 
4476
4620
  // src/comfyui/gate-template.ts
@@ -4681,6 +4825,23 @@ function catalogEngine(id) {
4681
4825
  return ALL.find((e) => e.id === id);
4682
4826
  }
4683
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
+
4684
4845
  // src/api/routes.ts
4685
4846
  function err(c, status, code, message) {
4686
4847
  return c.json({ error: { code, message } }, status);
@@ -4963,7 +5124,7 @@ function registerApi(app2, d) {
4963
5124
  }
4964
5125
  let entries;
4965
5126
  try {
4966
- 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) => {
4967
5128
  let isDir = d2.isDirectory();
4968
5129
  if (d2.isSymbolicLink()) {
4969
5130
  try {
@@ -5020,13 +5181,7 @@ function registerApi(app2, d) {
5020
5181
  extraArgs: profileToArgs(profile, entry, active.capabilities, sys.cores)
5021
5182
  };
5022
5183
  }
5023
- await d.manager.stopAndWait();
5024
- await d.comfy?.freeComfyUIBeforeLoad();
5025
- try {
5026
- await d.manager.start(opts2);
5027
- } catch (e) {
5028
- return startError(c, e);
5029
- }
5184
+ void d.manager.load(opts2, { beforeStart: () => d.comfy?.freeComfyUIBeforeLoad() ?? Promise.resolve() }).catch((e) => console.warn(`engine load failed: ${e}`));
5030
5185
  d.store.update((x) => {
5031
5186
  x.lastLoaded = { modelKey: entry.key, engineId: active.id };
5032
5187
  });
@@ -5042,13 +5197,7 @@ function registerApi(app2, d) {
5042
5197
  }
5043
5198
  if (!modelPath) return err(c, 409, "no_such_model", "No model specified. Pick one from the Models screen.");
5044
5199
  const opts = { engine: active, model: deriveModel(modelPath, name, extra), modelPath, extraArgs: extra };
5045
- await d.manager.stopAndWait();
5046
- await d.comfy?.freeComfyUIBeforeLoad();
5047
- try {
5048
- await d.manager.start(opts);
5049
- } catch (e) {
5050
- return startError(c, e);
5051
- }
5200
+ void d.manager.load(opts, { beforeStart: () => d.comfy?.freeComfyUIBeforeLoad() ?? Promise.resolve() }).catch((e) => console.warn(`engine load failed: ${e}`));
5052
5201
  return c.json({ ok: true }, 202);
5053
5202
  });
5054
5203
  app2.post("/api/v1/engine/stop", (c) => {
@@ -5089,7 +5238,7 @@ function registerApi(app2, d) {
5089
5238
  const gateDir = join12(customNodes, "turbollm_gate");
5090
5239
  try {
5091
5240
  mkdirSync8(gateDir, { recursive: true });
5092
- writeFileSync6(join12(gateDir, "__init__.py"), gateNodeSource(base2));
5241
+ writeFileSync7(join12(gateDir, "__init__.py"), gateNodeSource(base2));
5093
5242
  } catch (e) {
5094
5243
  return err(c, 500, "fs_write_failed", `Could not write the gate node: ${e instanceof Error ? e.message : e}`);
5095
5244
  }
@@ -5102,7 +5251,7 @@ function registerApi(app2, d) {
5102
5251
  const dir = d.store.snapshot().comfyui.gatePath;
5103
5252
  if (dir && existsSync11(dir)) {
5104
5253
  try {
5105
- rmSync6(dir, { recursive: true, force: true });
5254
+ rmSync7(dir, { recursive: true, force: true });
5106
5255
  } catch (e) {
5107
5256
  return err(c, 500, "fs_write_failed", `Could not remove the gate node: ${e instanceof Error ? e.message : e}`);
5108
5257
  }
@@ -5591,21 +5740,6 @@ function overlayModel(e, d, lastTpsMap) {
5591
5740
  const sourceRepo = provRepo ?? inferRepoFromPath(e.path, snap.modelDirs);
5592
5741
  return { ...e, loaded, hasProfile: e.key in profiles, lastTps, liveTps, benchTps, compatibleWithActiveEngine, sourceRepo };
5593
5742
  }
5594
- function inferRepoFromPath(filePath, modelDirs) {
5595
- const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
5596
- const fp = norm(filePath);
5597
- const seg = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
5598
- for (const dir of modelDirs) {
5599
- const root = norm(dir);
5600
- if (!fp.toLowerCase().startsWith(root.toLowerCase() + "/")) continue;
5601
- const parts = fp.slice(root.length + 1).split("/");
5602
- if (parts.length >= 2 && seg.test(parts[0]) && seg.test(parts[1])) {
5603
- return `${parts[0]}/${parts[1]}`;
5604
- }
5605
- return null;
5606
- }
5607
- return null;
5608
- }
5609
5743
  function formatMismatchMessage(engineKind, format) {
5610
5744
  if (engineKind === "mlx")
5611
5745
  return "The active engine is MLX \u2014 pick a safetensors model, or switch to a llama.cpp engine for GGUF.";
@@ -5725,11 +5859,6 @@ function regErr(c, e) {
5725
5859
  if (e instanceof ValueError) return err(c, 400, "invalid_config_value", e.message);
5726
5860
  return err(c, 500, "internal", e.message);
5727
5861
  }
5728
- function startError(c, e) {
5729
- if (e instanceof BusyError) return err(c, 409, "engine_already_running", "An engine is already running.");
5730
- if (e.message === "no_free_port") return err(c, 409, "no_free_port", "No free port for the engine (8081\u20138181 all in use).");
5731
- return err(c, 500, "engine_start_failed", e.message);
5732
- }
5733
5862
  function deriveModel(modelPath, name, extraArgs) {
5734
5863
  let ctx = 0;
5735
5864
  for (let i = 0; i + 1 < extraArgs.length; i++) {
@@ -5878,12 +6007,95 @@ function buildConnectSnippets(cli, base2, apiKey, modelName) {
5878
6007
 
5879
6008
  // src/chat/chat-routes.ts
5880
6009
  import { streamSSE as streamSSE2 } from "hono/streaming";
5881
- var inflight = /* @__PURE__ */ new Map();
6010
+
6011
+ // src/chat/parser.ts
5882
6012
  var THINK_OPEN = "<think>";
5883
6013
  var THINK_CLOSE = "</think>";
5884
6014
  var CHAN_ANALYSIS_OPEN = "<|channel|>analysis<|message|>";
5885
6015
  var CHAN_CLOSE = "<|end|>";
5886
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
6098
+ var inflight = /* @__PURE__ */ new Map();
5887
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.
5888
6100
 
5889
6101
  Your job is to help the user get the most out of TurboLLM:
@@ -6146,9 +6358,7 @@ async function runGeneration(d, stream, ctx) {
6146
6358
  ac.signal.addEventListener("abort", cancelReader, { once: true });
6147
6359
  }
6148
6360
  let roundContent = "";
6149
- let parsePhase = "initial";
6150
- let parseIsChannel = false;
6151
- let parseBuf = "";
6361
+ let parseState = initParseState();
6152
6362
  let finishReason = "";
6153
6363
  const pendingToolCalls = /* @__PURE__ */ new Map();
6154
6364
  roundLoop: while (true) {
@@ -6202,108 +6412,33 @@ async function runGeneration(d, stream, ctx) {
6202
6412
  }
6203
6413
  const raw_content = delta.content ?? "";
6204
6414
  if (!raw_content) continue;
6205
- parseBuf += raw_content;
6206
- while (parseBuf.length > 0) {
6207
- if (parsePhase === "initial") {
6208
- const thinkIdx = parseBuf.indexOf(THINK_OPEN);
6209
- const chanIdx = parseBuf.indexOf(CHAN_ANALYSIS_OPEN);
6210
- const hasThink = thinkIdx >= 0;
6211
- const hasChan = chanIdx >= 0;
6212
- const useThink = hasThink && (!hasChan || thinkIdx <= chanIdx);
6213
- const openIdx = useThink ? thinkIdx : hasChan ? chanIdx : -1;
6214
- const openTag = useThink ? THINK_OPEN : CHAN_ANALYSIS_OPEN;
6215
- if (openIdx === 0) {
6216
- parseIsChannel = !useThink;
6217
- parsePhase = "reasoning";
6218
- if (!thinkStart) thinkStart = Date.now();
6219
- parseBuf = parseBuf.slice(openTag.length);
6220
- } else if (openIdx > 0) {
6221
- const before = parseBuf.slice(0, openIdx);
6222
- fullContent += before;
6223
- roundContent += before;
6224
- if (!ttftMs) ttftMs = Date.now() - requestStart;
6225
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: before }) });
6226
- parseIsChannel = !useThink;
6227
- parsePhase = "reasoning";
6228
- if (!thinkStart) thinkStart = Date.now();
6229
- parseBuf = parseBuf.slice(openIdx + openTag.length);
6230
- } else {
6231
- const safeLen = parseBuf.length - (CHAN_ANALYSIS_OPEN.length - 1);
6232
- if (safeLen > 0) {
6233
- const flush = parseBuf.slice(0, safeLen);
6234
- parseBuf = parseBuf.slice(safeLen);
6235
- fullContent += flush;
6236
- roundContent += flush;
6237
- if (!ttftMs) ttftMs = Date.now() - requestStart;
6238
- d.manager.setLiveGen({ phase: "gen", pct: 0, outputTokens: ++liveOut });
6239
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: flush }) });
6240
- parsePhase = "content";
6241
- } else {
6242
- break;
6243
- }
6244
- }
6245
- } else if (parsePhase === "reasoning") {
6246
- const closeTag = parseIsChannel ? CHAN_CLOSE : THINK_CLOSE;
6247
- const closeIdx = parseBuf.indexOf(closeTag);
6248
- if (closeIdx >= 0) {
6249
- if (closeIdx > 0) {
6250
- const chunk2 = parseBuf.slice(0, closeIdx);
6251
- fullReasoning += chunk2;
6252
- await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: chunk2 }) });
6253
- }
6254
- thinkEnd = Date.now();
6255
- const wasChannel = parseIsChannel;
6256
- parseBuf = parseBuf.slice(closeIdx + closeTag.length);
6257
- parsePhase = wasChannel ? "skipFinal" : "content";
6258
- if (!wasChannel && parseBuf) {
6259
- fullContent += parseBuf;
6260
- roundContent += parseBuf;
6261
- if (!ttftMs) ttftMs = Date.now() - requestStart;
6262
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: parseBuf }) });
6263
- parseBuf = "";
6264
- }
6265
- } else if (parseBuf.length >= closeTag.length) {
6266
- const safe = parseBuf.length - (closeTag.length - 1);
6267
- const chunk2 = parseBuf.slice(0, safe);
6268
- parseBuf = parseBuf.slice(safe);
6269
- thinkEnd = Date.now();
6270
- fullReasoning += chunk2;
6271
- await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: chunk2 }) });
6272
- } else {
6273
- break;
6274
- }
6275
- } else if (parsePhase === "skipFinal") {
6276
- if (parseBuf.startsWith(CHAN_FINAL_SKIP)) {
6277
- parseBuf = parseBuf.slice(CHAN_FINAL_SKIP.length);
6278
- parsePhase = "content";
6279
- } else if (CHAN_FINAL_SKIP.startsWith(parseBuf) && parseBuf.length < CHAN_FINAL_SKIP.length) {
6280
- break;
6281
- } else {
6282
- const skipIdx = parseBuf.indexOf(CHAN_FINAL_SKIP);
6283
- parseBuf = skipIdx >= 0 ? parseBuf.slice(skipIdx + CHAN_FINAL_SKIP.length) : "";
6284
- parsePhase = "content";
6285
- }
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 }) });
6286
6423
  } else {
6424
+ fullContent += ev.text;
6425
+ roundContent += ev.text;
6287
6426
  if (!ttftMs) ttftMs = Date.now() - requestStart;
6288
- fullContent += parseBuf;
6289
- roundContent += parseBuf;
6290
6427
  d.manager.setLiveGen({ phase: "gen", pct: 0, outputTokens: ++liveOut });
6291
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: parseBuf }) });
6292
- parseBuf = "";
6293
- break;
6428
+ await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: ev.text }) });
6294
6429
  }
6295
6430
  }
6296
6431
  }
6297
6432
  }
6298
6433
  ac.signal.removeEventListener("abort", cancelReader);
6299
- if (parseBuf) {
6300
- if (parsePhase === "reasoning") {
6301
- fullReasoning += parseBuf;
6302
- await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: parseBuf }) });
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 }) });
6303
6438
  } else {
6304
- fullContent += parseBuf;
6305
- roundContent += parseBuf;
6306
- await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: parseBuf }) });
6439
+ fullContent += ev.text;
6440
+ roundContent += ev.text;
6441
+ await stream.writeSSE({ event: "delta", data: JSON.stringify({ delta: ev.text }) });
6307
6442
  }
6308
6443
  }
6309
6444
  if ((finishReason === "tool_calls" || pendingToolCalls.size > 0) && d.tools && toolIter <= MAX_TOOL_ITER) {
@@ -6399,7 +6534,10 @@ async function runGeneration(d, stream, ctx) {
6399
6534
  } catch {
6400
6535
  }
6401
6536
  const finalMsg = db2.getMessage(assistantMsg.id);
6402
- 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
+ }
6403
6541
  if (!aborted && conv.title === "New chat" && d.store.snapshot().daemon.autoGenerateTitles) {
6404
6542
  setTimeout(() => {
6405
6543
  void autoTitle(d, convId, ctx.engineMessages, fullContent, target);
@@ -6696,7 +6834,8 @@ async function* streamToAnthropic(oaiStream, modelName, msgId, onUsage, onLive)
6696
6834
  failed = true;
6697
6835
  yield sse("error", { error: { type: "api_error", message: "engine stopped" } });
6698
6836
  } finally {
6699
- reader.releaseLock();
6837
+ await reader.cancel().catch(() => {
6838
+ });
6700
6839
  }
6701
6840
  if (!failed) {
6702
6841
  if (inThinking) {
@@ -6747,6 +6886,15 @@ function cbStop(index) {
6747
6886
  }
6748
6887
 
6749
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
+ }
6750
6898
  function registerGateway(app2, d) {
6751
6899
  app2.post("/v1/messages", async (c) => {
6752
6900
  let req;
@@ -6780,12 +6928,14 @@ function registerGateway(app2, d) {
6780
6928
  const oaiAlias = engineModelAlias(d.registry.active()?.kind ?? "");
6781
6929
  if (oaiAlias) oaiBody.model = oaiAlias;
6782
6930
  d.manager.generationStart();
6931
+ const ac = clientAbort(c);
6783
6932
  let res;
6784
6933
  try {
6785
6934
  res = await fetch(`${target}/v1/chat/completions`, {
6786
6935
  method: "POST",
6787
6936
  headers: { "Content-Type": "application/json" },
6788
- body: JSON.stringify(oaiBody)
6937
+ body: JSON.stringify(oaiBody),
6938
+ signal: ac.signal
6789
6939
  });
6790
6940
  } catch (e) {
6791
6941
  d.manager.generationEnd();
@@ -6824,11 +6974,13 @@ function registerGateway(app2, d) {
6824
6974
  }
6825
6975
  );
6826
6976
  return streamSSE3(c, async (stream) => {
6977
+ stream.onAbort(() => ac.abort());
6827
6978
  try {
6828
6979
  for await (const evt of gen) {
6829
6980
  await stream.writeSSE({ event: evt.event, data: evt.data });
6830
6981
  }
6831
6982
  } finally {
6983
+ ac.abort();
6832
6984
  d.manager.generationEnd();
6833
6985
  }
6834
6986
  });
@@ -6896,7 +7048,8 @@ function registerGateway(app2, d) {
6896
7048
  const headers = new Headers(c.req.raw.headers);
6897
7049
  headers.delete("host");
6898
7050
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
6899
- const init = { method: c.req.method, headers };
7051
+ const ac = clientAbort(c);
7052
+ const init = { method: c.req.method, headers, signal: ac.signal };
6900
7053
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
6901
7054
  if (isChat) {
6902
7055
  if (parsedBody && maxLimit > 0) {
@@ -7124,6 +7277,10 @@ Please upgrade: https://nodejs.org
7124
7277
  );
7125
7278
  process.exit(1);
7126
7279
  }
7280
+ process.on("unhandledRejection", (reason) => {
7281
+ if (reason?.name === "AbortError") return;
7282
+ console.warn("unhandledRejection (continuing):", reason);
7283
+ });
7127
7284
  var argv = process.argv.slice(2);
7128
7285
  function hasFlag(...names) {
7129
7286
  return names.some((n) => argv.includes(n));
@@ -7176,6 +7333,8 @@ var store = ConfigStore.load(argValue("--config", defaultConfigPath()));
7176
7333
  if (store.brokenBackup()) {
7177
7334
  console.warn(`config was reset; previous file backed up at ${store.brokenBackup()}`);
7178
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`);
7179
7338
  var registry = new Registry(store);
7180
7339
  var pruned = registry.pruneDeadManagedBuilds();
7181
7340
  if (pruned > 0) console.log(`pruned ${pruned} dangling engine build(s)`);
@@ -7398,12 +7557,11 @@ void (async () => {
7398
7557
  };
7399
7558
  }
7400
7559
  if (opts) {
7401
- await comfy.freeComfyUIBeforeLoad();
7402
- 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}`));
7403
7561
  }
7404
7562
  })();
7405
7563
  var shuttingDown = false;
7406
- for (const sig of ["SIGINT", "SIGTERM"]) {
7564
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
7407
7565
  process.on(sig, () => {
7408
7566
  if (shuttingDown) return;
7409
7567
  shuttingDown = true;
@@ -7417,3 +7575,9 @@ for (const sig of ["SIGINT", "SIGTERM"]) {
7417
7575
  setTimeout(() => process.exit(0), 12e3).unref();
7418
7576
  });
7419
7577
  }
7578
+ process.on("exit", () => {
7579
+ try {
7580
+ killTrackedEnginesSync(store.dir());
7581
+ } catch {
7582
+ }
7583
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbollm",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "TurboLLM — local LLM platform: run any inference engine auto-tuned to your GPU, with a web UI and OpenAI/Anthropic-compatible API. Point Claude Code at your own machine in one command.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "author": "Mohit Soni",