turbollm 0.5.0 → 0.6.1

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
@@ -87,7 +87,8 @@ 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: "", url: "", reverseGate: false, cachePersist: false }
90
+ comfyui: { enabled: false, gatePath: "", url: "", reverseGate: false, cachePersist: false },
91
+ gateway: { autoSwap: true, keepN: 1 }
91
92
  };
92
93
  }
93
94
  var ConfigStore = class _ConfigStore {
@@ -228,6 +229,11 @@ function normalize(c) {
228
229
  // Reseated like the other known fields so it isn't dropped on every load.
229
230
  cachePersist: !!cu.cachePersist
230
231
  };
232
+ const gw = c.gateway ?? {};
233
+ c.gateway = {
234
+ autoSwap: gw.autoSwap !== false,
235
+ keepN: typeof gw.keepN === "number" && gw.keepN >= 1 ? Math.min(Math.floor(gw.keepN), 4) : 1
236
+ };
231
237
  c.telemetry.level = normalizeTelemetryLevel(c.telemetry.level);
232
238
  for (const e of c.engines) {
233
239
  e.capabilities ??= { kvTypes: [], flags: [] };
@@ -699,6 +705,11 @@ function vllmServerCommand(python, model, port2, host2, tensorParallelSize = 1)
699
705
  "vllm.entrypoints.openai.api_server",
700
706
  "--model",
701
707
  model,
708
+ // Serve under a fixed alias so requests can address the model by a stable name
709
+ // (TurboLLM's internal key is a display string with spaces). Mirrors mlx-lm's
710
+ // built-in `default_model` alias; see engineModelAlias() in compat.ts.
711
+ "--served-model-name",
712
+ "default_model",
702
713
  "--host",
703
714
  host2,
704
715
  "--port",
@@ -775,7 +786,7 @@ var Manager = class {
775
786
  }
776
787
  }
777
788
  const { cmd, args } = engineCommand(opts, port2, slotSavePath);
778
- const child = spawn(cmd, args, { cwd: dirname2(cmd), windowsHide: true });
789
+ const child = spawn(cmd, args, { cwd: dirname2(cmd), windowsHide: true, env: pyEngineEnv(opts.engine.kind, this.store.dir()) });
779
790
  child.stdout?.pipe(logStream, { end: false });
780
791
  child.stderr?.pipe(logStream, { end: false });
781
792
  this.state = "starting";
@@ -947,10 +958,22 @@ var Manager = class {
947
958
  this.resolveExited?.();
948
959
  }
949
960
  async readiness(child, port2) {
950
- const deadline = Date.now() + readinessTimeoutMs(this.opts?.engine.kind ?? "llama-server");
961
+ const kind = this.opts?.engine.kind ?? "llama-server";
962
+ const deadline = Date.now() + readinessTimeoutMs(kind);
951
963
  for (; ; ) {
952
964
  await sleep(500);
953
965
  if (this.child !== child || this.state !== "starting") return;
966
+ if (kind === "mlx" || kind === "vllm") {
967
+ const loadErr = detectPyLoadFailure(readTail(this.logPathStr, 200));
968
+ if (loadErr) {
969
+ if (this.child === child && this.state === "starting") {
970
+ this.state = "error";
971
+ this.errInfo = { code: "model_load_failed", message: loadErr, exitCode: -1, logTail: readTail(this.logPathStr, 20) };
972
+ child.kill("SIGKILL");
973
+ }
974
+ return;
975
+ }
976
+ }
954
977
  if (await probeReady(port2)) {
955
978
  if (this.child === child && this.state === "starting") {
956
979
  this.state = "running";
@@ -992,6 +1015,27 @@ function engineCommand(opts, port2, slotSavePath) {
992
1015
  function readinessTimeoutMs(kind) {
993
1016
  return kind === "vllm" ? 6e5 : 12e4;
994
1017
  }
1018
+ function pyEngineEnv(kind, dataDir) {
1019
+ if (kind !== "mlx" && kind !== "vllm") return void 0;
1020
+ const hfHome = join6(dataDir, "hf-cache");
1021
+ const hubCache = join6(hfHome, "hub");
1022
+ mkdirSync4(hubCache, { recursive: true });
1023
+ return {
1024
+ ...process.env,
1025
+ HF_HUB_OFFLINE: "1",
1026
+ TRANSFORMERS_OFFLINE: "1",
1027
+ HF_HOME: hfHome,
1028
+ HF_HUB_CACHE: hubCache
1029
+ };
1030
+ }
1031
+ function detectPyLoadFailure(lines) {
1032
+ const text = lines.join("\n");
1033
+ const isLoadCrash = /Exception in thread[^\n]*_generate/.test(text) || /in load_default\b/.test(text) || /in load_model\b/.test(text) || /load_weights/.test(text);
1034
+ if (!isLoadCrash) return null;
1035
+ const errLine = [...lines].reverse().find((l) => /^[A-Za-z_][\w.]*(Error|Exception):/.test(l.trim()));
1036
+ const detail = (errLine ? errLine.trim() : "the model failed to load").slice(0, 200);
1037
+ return `MLX could not load this model \u2014 ${detail} This usually means the installed mlx-lm version does not support this model's architecture or quantization.`;
1038
+ }
995
1039
  function buildArgs(opts, port2, slotSavePath) {
996
1040
  const args = ["-m", opts.modelPath, "--host", "127.0.0.1", "--port", String(port2)];
997
1041
  const flags = opts.engine.capabilities.flags;
@@ -1709,6 +1753,10 @@ function engineAcceptsFormat(engineKind, format) {
1709
1753
  if (engineKind === "vllm") return format === "mlx";
1710
1754
  return format === "gguf";
1711
1755
  }
1756
+ var ENGINE_MODEL_ALIAS = "default_model";
1757
+ function engineModelAlias(engineKind) {
1758
+ return engineKind === "mlx" || engineKind === "vllm" ? ENGINE_MODEL_ALIAS : null;
1759
+ }
1712
1760
 
1713
1761
  // src/models/scanner.ts
1714
1762
  import { existsSync as existsSync9, lstatSync, readdirSync as readdirSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
@@ -1966,6 +2014,20 @@ function quantFromName(filename) {
1966
2014
  // src/models/scanner.ts
1967
2015
  var CACHE_VERSION = 2;
1968
2016
  var SPLIT_RE = /^(.*)-(\d{5})-of-(\d{5})\.gguf$/i;
2017
+ var EMBED_ARCHS = /* @__PURE__ */ new Set([
2018
+ "bert",
2019
+ "nomic-bert",
2020
+ "jina-bert-v3-base",
2021
+ "jina-bert",
2022
+ "distilbert",
2023
+ "roberta",
2024
+ "xlm-roberta",
2025
+ "electra"
2026
+ ]);
2027
+ var EMBED_FILE_RE = /\b(bge[-_]|nomic[-_]embed|all[-_]minilm|e5[-_]|gte[-_]|stella[-_]embed|jina[-_]embed|mxbai[-_]embed)\b/i;
2028
+ function isEmbeddingModel(arch2, name) {
2029
+ return EMBED_ARCHS.has(arch2.toLowerCase()) || EMBED_FILE_RE.test(name);
2030
+ }
1969
2031
  var ScannerError = class extends Error {
1970
2032
  constructor(code, message) {
1971
2033
  super(message);
@@ -2111,6 +2173,7 @@ var Scanner = class {
2111
2173
  const quant = meta?.quant || quantFromName(fileName);
2112
2174
  const name = meta?.name || cleanName(fileName);
2113
2175
  const vision = mmprojPath !== null;
2176
+ const arch2 = meta?.arch ?? "unknown";
2114
2177
  return {
2115
2178
  key: `${name.toLowerCase()}|${quant}|${sizeBytes}`,
2116
2179
  name,
@@ -2119,7 +2182,7 @@ var Scanner = class {
2119
2182
  format: "gguf",
2120
2183
  sizeBytes,
2121
2184
  sizeLabel: meta?.sizeLabel ?? "",
2122
- arch: meta?.arch ?? "unknown",
2185
+ arch: arch2,
2123
2186
  quant,
2124
2187
  nativeCtx: meta?.nativeCtx ?? 0,
2125
2188
  blockCount: meta?.blockCount ?? 0,
@@ -2130,6 +2193,7 @@ var Scanner = class {
2130
2193
  vision,
2131
2194
  mmprojPath: vision ? mmprojPath : null,
2132
2195
  hasChatTemplate: meta?.hasChatTemplate ?? false,
2196
+ embedding: isEmbeddingModel(arch2, fileName),
2133
2197
  incomplete,
2134
2198
  parseError,
2135
2199
  loaded: false,
@@ -2240,6 +2304,7 @@ function mlxEntryFor(dir) {
2240
2304
  vision: false,
2241
2305
  mmprojPath: null,
2242
2306
  hasChatTemplate,
2307
+ embedding: isEmbeddingModel(arch2, basename(dir)),
2243
2308
  incomplete: false,
2244
2309
  parseError,
2245
2310
  loaded: false,
@@ -2404,6 +2469,7 @@ function deriveDefault(m, sys) {
2404
2469
  ropeFreqBase: 0,
2405
2470
  ropeFreqScale: 0,
2406
2471
  gpu: defaultGpu(),
2472
+ grammar: "",
2407
2473
  extraArgs: []
2408
2474
  };
2409
2475
  if (m.moe && hasGpu && m.blockCount > 0) {
@@ -2493,6 +2559,8 @@ function profileToArgs(p, m, caps, cores = 0) {
2493
2559
  if (p.ropeFreqBase > 0 && has("--rope-freq-base")) a.push("--rope-freq-base", String(p.ropeFreqBase));
2494
2560
  if (p.ropeFreqScale > 0 && has("--rope-freq-scale")) a.push("--rope-freq-scale", String(p.ropeFreqScale));
2495
2561
  }
2562
+ if (m.embedding && has("--embeddings")) a.push("--embeddings");
2563
+ if (p.grammar && has("--grammar")) a.push("--grammar", p.grammar);
2496
2564
  a.push(...p.extraArgs);
2497
2565
  return a;
2498
2566
  }
@@ -2703,10 +2771,15 @@ var HfClient = class {
2703
2771
  tokenFn;
2704
2772
  version;
2705
2773
  cache = /* @__PURE__ */ new Map();
2706
- /** Search GGUF repos (spec 10 §2). Returns up to 30 rows sorted by downloads. */
2707
- async searchModels(query) {
2774
+ /** Search repos (spec 10 §2). Returns up to 30 rows sorted by downloads.
2775
+ * Format filter adapts to the active engine kind:
2776
+ * - llama-server / TurboQuant → filter=gguf
2777
+ * - mlx → filter=mlx (HF library tag)
2778
+ * - vllm → no format filter (searches all HF repos) */
2779
+ async searchModels(query, engineKind) {
2708
2780
  const q = query.trim();
2709
- const url = `${BASE}/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=downloads&direction=-1&limit=30&full=false`;
2781
+ const formatFilter = engineKind === "mlx" ? "&filter=mlx" : engineKind === "vllm" ? "" : "&filter=gguf";
2782
+ const url = `${BASE}/api/models?search=${encodeURIComponent(q)}${formatFilter}&sort=downloads&direction=-1&limit=30&full=false`;
2710
2783
  const raw = await this.getJson(url);
2711
2784
  return raw.map((m) => ({
2712
2785
  repo: m.id ?? m.modelId ?? "",
@@ -2722,8 +2795,30 @@ var HfClient = class {
2722
2795
  async getRepo(repo) {
2723
2796
  const info = await this.getJson(`${BASE}/api/models/${repo}`);
2724
2797
  const tree = await this.getJson(`${BASE}/api/models/${repo}/tree/main?recursive=true`);
2725
- const gguf = tree.filter((e) => e.type === "file" && /\.gguf$/i.test(e.path));
2726
- const files = groupFiles(repo, gguf);
2798
+ const ggufEntries = tree.filter((e) => e.type === "file" && /\.gguf$/i.test(e.path));
2799
+ const safetensorsEntries = tree.filter((e) => e.type === "file" && /\.safetensors$/i.test(e.path));
2800
+ const isSafetensors = ggufEntries.length === 0 && safetensorsEntries.length > 0;
2801
+ let files;
2802
+ let safetensors;
2803
+ if (isSafetensors) {
2804
+ safetensors = true;
2805
+ const components = tree.filter(
2806
+ (e) => e.type === "file" && (/\.safetensors$/i.test(e.path) || /\.json$/i.test(e.path)) && !e.path.includes("/")
2807
+ // root-level only — no nested model card assets
2808
+ );
2809
+ files = components.map((e) => ({
2810
+ name: e.path,
2811
+ quant: "mlx",
2812
+ sizeBytes: e.lfs?.size ?? e.size ?? 0,
2813
+ parts: 1,
2814
+ mmproj: false,
2815
+ safetensors: true,
2816
+ sha256: e.lfs?.oid,
2817
+ url: this.fileUrl(repo, e.path)
2818
+ }));
2819
+ } else {
2820
+ files = groupFiles(repo, ggufEntries);
2821
+ }
2727
2822
  const gated = info.gated === true || info.gated === "auto" || info.gated === "manual";
2728
2823
  const license = info.cardData?.license ?? (info.tags?.find((t) => t.startsWith("license:"))?.slice("license:".length) || "");
2729
2824
  return {
@@ -2733,7 +2828,8 @@ var HfClient = class {
2733
2828
  downloads: info.downloads ?? 0,
2734
2829
  likes: info.likes ?? 0,
2735
2830
  card: await this.getCard(repo),
2736
- files
2831
+ files,
2832
+ ...safetensors ? { safetensors } : {}
2737
2833
  };
2738
2834
  }
2739
2835
  /** Fetch the repo README (the model card), strip its YAML frontmatter, and cap the
@@ -2928,27 +3024,30 @@ var DownloadManager = class {
2928
3024
  const dir = this.primaryDir();
2929
3025
  if (!dir) throw new DownloadError("no_model_dir", "Add a model folder in Settings before downloading.");
2930
3026
  let repo = (input.repo ?? "").trim();
3027
+ const subdir = (input.subdir ?? "").trim();
2931
3028
  let url;
2932
3029
  let filename;
2933
3030
  if (input.url) {
2934
3031
  const u = input.url.trim();
2935
3032
  if (!/^https?:\/\//i.test(u)) throw new DownloadError("invalid_url", "URL must start with http:// or https://.");
2936
3033
  const path = safePathname(u);
2937
- if (!/\.gguf$/i.test(path) && !HF_BLOB_RE.test(u)) {
3034
+ if (!subdir && !/\.gguf$/i.test(path) && !HF_BLOB_RE.test(u)) {
2938
3035
  throw new DownloadError("invalid_url", "URL must point to a .gguf file.");
2939
3036
  }
2940
3037
  filename = basename2(path);
2941
- if (!/\.gguf$/i.test(filename)) throw new DownloadError("invalid_url", "Could not derive a .gguf filename from that URL.");
3038
+ if (!subdir && !/\.gguf$/i.test(filename)) throw new DownloadError("invalid_url", "Could not derive a .gguf filename from that URL.");
2942
3039
  url = u;
2943
3040
  repo = "";
2944
3041
  } else {
2945
3042
  const rfilename = (input.rfilename ?? "").trim();
2946
3043
  if (!repo || !rfilename) throw new DownloadError("invalid_request", "repo and rfilename are required.");
2947
- if (!/\.gguf$/i.test(rfilename)) throw new DownloadError("invalid_url", "The file must be a .gguf.");
3044
+ if (!subdir && !/\.gguf$/i.test(rfilename)) throw new DownloadError("invalid_url", "The file must be a .gguf.");
2948
3045
  filename = basename2(rfilename);
2949
3046
  url = `https://huggingface.co/${repo}/resolve/main/${rfilename}`;
2950
3047
  }
2951
3048
  const total = input.size ?? 0;
3049
+ const destDir = subdir ? join10(dir, subdir) : dir;
3050
+ if (subdir) mkdirSync6(destDir, { recursive: true });
2952
3051
  if (total > 0) this.assertDisk(dir, total);
2953
3052
  const id = `dl-${Date.now().toString(36)}-${(this.nextSeq++).toString(36)}`;
2954
3053
  const rec = {
@@ -2956,7 +3055,7 @@ var DownloadManager = class {
2956
3055
  name: filename,
2957
3056
  repo,
2958
3057
  url,
2959
- dest: join10(dir, filename),
3058
+ dest: join10(destDir, filename),
2960
3059
  total,
2961
3060
  received: 0,
2962
3061
  status: "queued",
@@ -3526,6 +3625,219 @@ function sleep2(ms) {
3526
3625
  return new Promise((r) => setTimeout(r, ms));
3527
3626
  }
3528
3627
 
3628
+ // src/gateway/model-router.ts
3629
+ var ModelRouter = class {
3630
+ constructor(store2, registry2, manager2, scanner2, comfy2) {
3631
+ this.store = store2;
3632
+ this.registry = registry2;
3633
+ this.manager = manager2;
3634
+ this.scanner = scanner2;
3635
+ this.comfy = comfy2;
3636
+ }
3637
+ store;
3638
+ registry;
3639
+ manager;
3640
+ scanner;
3641
+ comfy;
3642
+ /** Extra pool slots beyond the primary manager. Only populated when keepN > 1. */
3643
+ extraSlots = /* @__PURE__ */ new Map();
3644
+ /** Last-used timestamp for the primary manager slot (for LRU eviction). */
3645
+ primaryLastUsed = 0;
3646
+ /** Promise chain that serialises swap operations so concurrent requests for
3647
+ * different models queue rather than race. */
3648
+ swapChain = Promise.resolve();
3649
+ /** Route a request to the correct model target URL.
3650
+ * - If autoSwap is off: returns whatever the primary manager has loaded.
3651
+ * - If the requested model is already loaded: returns its target immediately.
3652
+ * - Otherwise: loads the model (swapping / evicting LRU as needed) and waits. */
3653
+ async route(requestedModel) {
3654
+ const cfg2 = this.store.snapshot();
3655
+ if (!cfg2.gateway.autoSwap || !requestedModel.trim()) {
3656
+ const t = this.manager.target();
3657
+ return t ? { target: t } : { status: 503, message: "No model loaded. Load one in TurboLLM." };
3658
+ }
3659
+ const entry = this.resolveEntry(requestedModel);
3660
+ if (!entry) {
3661
+ const t = this.manager.target();
3662
+ return t ? { target: t } : { status: 503, message: `No model matching '${requestedModel}' found. Add one in TurboLLM.` };
3663
+ }
3664
+ {
3665
+ const ms = this.manager.status();
3666
+ if (ms.state === "running" && ms.model && this.keysMatch(ms.model.key, entry)) {
3667
+ this.primaryLastUsed = Date.now();
3668
+ this.manager.touch();
3669
+ return { target: this.manager.target() };
3670
+ }
3671
+ }
3672
+ const slot = this.extraSlots.get(entry.key);
3673
+ if (slot) {
3674
+ const ss = slot.manager.status();
3675
+ if (ss.state === "running") {
3676
+ slot.lastUsedMs = Date.now();
3677
+ slot.manager.touch();
3678
+ return { target: slot.manager.target() };
3679
+ }
3680
+ this.extraSlots.delete(entry.key);
3681
+ }
3682
+ let unlock;
3683
+ const prev = this.swapChain;
3684
+ this.swapChain = new Promise((r) => {
3685
+ unlock = r;
3686
+ });
3687
+ try {
3688
+ await prev;
3689
+ return await this.doLoad(entry);
3690
+ } finally {
3691
+ unlock();
3692
+ }
3693
+ }
3694
+ // ── internal ──────────────────────────────────────────────────────────────
3695
+ async doLoad(entry) {
3696
+ {
3697
+ const ms = this.manager.status();
3698
+ if (ms.state === "running" && ms.model && this.keysMatch(ms.model.key, entry)) {
3699
+ this.primaryLastUsed = Date.now();
3700
+ this.manager.touch();
3701
+ return { target: this.manager.target() };
3702
+ }
3703
+ const slot = this.extraSlots.get(entry.key);
3704
+ if (slot && slot.manager.status().state === "running") {
3705
+ slot.lastUsedMs = Date.now();
3706
+ slot.manager.touch();
3707
+ return { target: slot.manager.target() };
3708
+ }
3709
+ }
3710
+ if (this.comfy?.isBlocked()) {
3711
+ return { status: 503, message: "ComfyUI is rendering \u2014 model swap paused until its queue finishes." };
3712
+ }
3713
+ const active = this.registry.active();
3714
+ if (!active) return { status: 503, message: "No active engine. Set one up in TurboLLM." };
3715
+ if (!engineAcceptsFormat(active.kind, entry.format)) {
3716
+ return { status: 503, message: `Active engine cannot load model format '${entry.format}'.` };
3717
+ }
3718
+ const opts = this.buildOpts(entry, active);
3719
+ if (!opts) return { status: 503, message: "Model is incomplete or unreadable." };
3720
+ const keepN = Math.max(1, this.store.snapshot().gateway.keepN);
3721
+ const needsNewSlot = entry.embedding || this.chatSlotCount() < keepN;
3722
+ const targetManager = needsNewSlot ? this.manager.status().state === "stopped" || this.manager.status().state === "error" ? this.manager : new Manager(this.store) : this.evictChatLru();
3723
+ await targetManager.stopAndWait();
3724
+ await this.comfy?.freeComfyUIBeforeLoad();
3725
+ try {
3726
+ await targetManager.start(opts);
3727
+ } catch (e) {
3728
+ return { status: 503, message: `Engine start failed: ${e.message}` };
3729
+ }
3730
+ const ready = await this.waitReady(targetManager, active.kind);
3731
+ if (!ready) {
3732
+ const s = targetManager.status();
3733
+ return { status: 503, message: s.err?.message ?? "Model failed to become ready." };
3734
+ }
3735
+ const target = targetManager.target();
3736
+ if (!target) return { status: 503, message: "Model loaded but target URL unavailable." };
3737
+ if (targetManager === this.manager) {
3738
+ this.primaryLastUsed = Date.now();
3739
+ } else {
3740
+ this.extraSlots.set(entry.key, { manager: targetManager, modelKey: entry.key, lastUsedMs: Date.now() });
3741
+ }
3742
+ this.store.update((x) => {
3743
+ x.lastLoaded = { modelKey: entry.key, engineId: active.id };
3744
+ });
3745
+ return { target };
3746
+ }
3747
+ /** Count of alive chat (non-embedding) slots. Embedding models don't consume
3748
+ * a keepN slot so chat models and embedding models can coexist independently. */
3749
+ chatSlotCount() {
3750
+ const isAlive = (s) => s === "running" || s === "starting";
3751
+ const ms = this.manager.status();
3752
+ const primaryAlive = isAlive(ms.state);
3753
+ const primaryEmbed = primaryAlive && !!ms.model && (this.scanner.get(ms.model.key)?.embedding ?? false);
3754
+ const extraChat = [...this.extraSlots.values()].filter(
3755
+ (s) => isAlive(s.manager.status().state) && !(this.scanner.get(s.modelKey)?.embedding ?? false)
3756
+ ).length;
3757
+ return (primaryAlive && !primaryEmbed ? 1 : 0) + extraChat;
3758
+ }
3759
+ /** Evict the least-recently-used chat (non-embedding) slot. Embedding slots are
3760
+ * skipped; if every alive slot is an embedding model the true LRU is used as
3761
+ * a fallback so we never deadlock. */
3762
+ evictChatLru() {
3763
+ const isAlive = (s) => s === "running" || s === "starting";
3764
+ const ms = this.manager.status();
3765
+ const primaryAlive = isAlive(ms.state);
3766
+ const primaryEmbed = primaryAlive && !!ms.model && (this.scanner.get(ms.model.key)?.embedding ?? false);
3767
+ let lruManager = this.manager;
3768
+ let lruTime = primaryAlive && !primaryEmbed ? this.primaryLastUsed : Infinity;
3769
+ let lruKey = null;
3770
+ for (const slot of this.extraSlots.values()) {
3771
+ const slotEmbed = this.scanner.get(slot.modelKey)?.embedding ?? false;
3772
+ if (isAlive(slot.manager.status().state) && !slotEmbed && slot.lastUsedMs < lruTime) {
3773
+ lruTime = slot.lastUsedMs;
3774
+ lruManager = slot.manager;
3775
+ lruKey = slot.modelKey;
3776
+ }
3777
+ }
3778
+ if (lruTime === Infinity) {
3779
+ lruTime = primaryAlive ? this.primaryLastUsed : Infinity;
3780
+ lruManager = this.manager;
3781
+ lruKey = null;
3782
+ for (const slot of this.extraSlots.values()) {
3783
+ if (isAlive(slot.manager.status().state) && slot.lastUsedMs < lruTime) {
3784
+ lruTime = slot.lastUsedMs;
3785
+ lruManager = slot.manager;
3786
+ lruKey = slot.modelKey;
3787
+ }
3788
+ }
3789
+ }
3790
+ if (lruKey !== null) this.extraSlots.delete(lruKey);
3791
+ return lruManager;
3792
+ }
3793
+ resolveEntry(requested) {
3794
+ const models = this.scanner.list().models;
3795
+ return models.find((e) => e.key === requested) ?? models.find((e) => e.name === requested) ?? models.find((e) => e.name.toLowerCase() === requested.toLowerCase()) ?? models.find((e) => e.name.toLowerCase().includes(requested.toLowerCase()));
3796
+ }
3797
+ keysMatch(loadedKey, entry) {
3798
+ return loadedKey === entry.key || loadedKey === entry.path;
3799
+ }
3800
+ buildOpts(entry, engine) {
3801
+ if (entry.incomplete || entry.parseError) return null;
3802
+ const cfg2 = this.store.snapshot();
3803
+ const sys = getSysInfo();
3804
+ if (entry.format !== "gguf") {
3805
+ const savedGpu = cfg2.modelProfiles[entry.key]?.gpu;
3806
+ return {
3807
+ engine,
3808
+ model: { key: entry.key, name: entry.name, quant: entry.quant, ctx: entry.nativeCtx, vision: false },
3809
+ modelPath: entry.path,
3810
+ extraArgs: [],
3811
+ tensorParallelSize: savedGpu?.tensorParallelSize
3812
+ };
3813
+ }
3814
+ const saved = cfg2.modelProfiles[entry.key];
3815
+ const profile = resolveProfile(entry, sys, saved, void 0, cfg2.modelDefaults);
3816
+ return {
3817
+ engine,
3818
+ model: { key: entry.key, name: entry.name, quant: entry.quant, ctx: profile.ctx, vision: entry.vision },
3819
+ modelPath: entry.path,
3820
+ extraArgs: profileToArgs(profile, entry, engine.capabilities, sys.cores)
3821
+ };
3822
+ }
3823
+ /** Poll until the manager's engine process becomes ready or fails.
3824
+ * Mirrors the Manager's internal readiness timeout by engine kind. */
3825
+ async waitReady(manager2, engineKind) {
3826
+ const timeoutMs = engineKind === "vllm" ? 6e5 : 12e4;
3827
+ const deadline = Date.now() + timeoutMs;
3828
+ while (Date.now() < deadline) {
3829
+ const s = manager2.status();
3830
+ if (s.state === "running") return true;
3831
+ if (s.state === "error" || s.state === "stopped") return false;
3832
+ await sleep3(250);
3833
+ }
3834
+ return false;
3835
+ }
3836
+ };
3837
+ function sleep3(ms) {
3838
+ return new Promise((r) => setTimeout(r, ms));
3839
+ }
3840
+
3529
3841
  // src/cli-launch.ts
3530
3842
  import { spawn as spawn2 } from "child_process";
3531
3843
  var SUPPORTED = {
@@ -3834,6 +4146,7 @@ function registerApi(app2, d) {
3834
4146
  const engine = {
3835
4147
  id: active?.id ?? "",
3836
4148
  name: active?.name ?? "",
4149
+ kind: active?.kind ?? "",
3837
4150
  state: ms.state,
3838
4151
  port: ms.port,
3839
4152
  pid: ms.pid
@@ -4429,11 +4742,19 @@ function registerApi(app2, d) {
4429
4742
  }
4430
4743
  cuUpdates.url = u;
4431
4744
  }
4745
+ const gwUpdates = {};
4746
+ if (b.gateway?.autoSwap !== void 0) gwUpdates.autoSwap = !!b.gateway.autoSwap;
4747
+ if (b.gateway?.keepN !== void 0) {
4748
+ const v = Number(b.gateway.keepN);
4749
+ if (!Number.isInteger(v) || v < 1 || v > 4) return err(c, 400, "invalid_config_value", "gateway.keepN must be 1\u20134.");
4750
+ gwUpdates.keepN = v;
4751
+ }
4432
4752
  const before = d.store.snapshot().daemon;
4433
4753
  d.store.update((cfg2) => {
4434
4754
  Object.assign(cfg2.daemon, updates);
4435
4755
  Object.assign(cfg2.modelDefaults, mdUpdates);
4436
4756
  Object.assign(cfg2.comfyui, cuUpdates);
4757
+ Object.assign(cfg2.gateway, gwUpdates);
4437
4758
  if (b.autoLoadOnStart !== void 0) cfg2.autoLoadOnStart = !!b.autoLoadOnStart;
4438
4759
  if (telemetryLevel !== void 0) cfg2.telemetry.level = telemetryLevel;
4439
4760
  if (b.hfToken !== void 0) cfg2.hf.token = String(b.hfToken).trim();
@@ -4502,7 +4823,7 @@ function registerApi(app2, d) {
4502
4823
  const q = (c.req.query("q") ?? "").trim();
4503
4824
  if (!q) return c.json({ results: [] });
4504
4825
  try {
4505
- const results = await d.hf.searchModels(q);
4826
+ const results = await d.hf.searchModels(q, d.registry.active()?.kind);
4506
4827
  const withLocal = results.map((r) => ({ ...r, localCount: localCountFor(d, r.repo) }));
4507
4828
  return c.json({ results: withLocal });
4508
4829
  } catch (e) {
@@ -4698,6 +5019,7 @@ function settingsPayload(d) {
4698
5019
  telemetryLevel,
4699
5020
  modelDefaults: cfg2.modelDefaults,
4700
5021
  comfyui: cfg2.comfyui,
5022
+ gateway: cfg2.gateway,
4701
5023
  // The HF token is write-only over the wire (spec 10 §4): we never echo it back,
4702
5024
  // only whether one is set, so the UI can show "configured" without leaking it.
4703
5025
  hfTokenSet: cfg2.hf.token.length > 0
@@ -5140,7 +5462,9 @@ async function runGeneration(d, stream, ctx) {
5140
5462
  if (!(k in SAMPLING_KEYS) && k !== "stop") samplingOverride[k] = v;
5141
5463
  }
5142
5464
  const reqBody = {
5143
- model: ms.model.key,
5465
+ // mlx-lm / vLLM serve under a fixed alias and 404 on TurboLLM's internal key;
5466
+ // llama.cpp ignores the field. engineModelAlias() returns the right value per kind.
5467
+ model: engineModelAlias(d.registry.active()?.kind ?? "") ?? ms.model.key,
5144
5468
  messages: engineMessages,
5145
5469
  stream: true,
5146
5470
  stream_options: { include_usage: true },
@@ -5388,7 +5712,7 @@ async function autoTitle(d, convId, prevMessages, assistantReply, target) {
5388
5712
  method: "POST",
5389
5713
  headers: { "Content-Type": "application/json" },
5390
5714
  body: JSON.stringify({
5391
- model: ms.model?.key,
5715
+ model: engineModelAlias(d.registry.active()?.kind ?? "") ?? ms.model?.key,
5392
5716
  messages: titleMessages,
5393
5717
  stream: false,
5394
5718
  temperature: 0.3,
@@ -5713,13 +6037,6 @@ function cbStop(index) {
5713
6037
  // src/gateway/gateway.ts
5714
6038
  function registerGateway(app2, d) {
5715
6039
  app2.post("/v1/messages", async (c) => {
5716
- const target = d.manager.target();
5717
- if (!target) {
5718
- return c.json(
5719
- { type: "error", error: { type: "api_error", message: "No model loaded. Load one in TurboLLM." } },
5720
- 503
5721
- );
5722
- }
5723
6040
  let req;
5724
6041
  try {
5725
6042
  req = await c.req.json();
@@ -5737,10 +6054,19 @@ function registerGateway(app2, d) {
5737
6054
  }
5738
6055
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5739
6056
  req.max_tokens = clampMaxTokens(req.max_tokens, maxLimit) ?? req.max_tokens;
5740
- d.manager.touch();
6057
+ const routeResult = await d.modelRouter.route(req.model ?? "");
6058
+ if ("status" in routeResult) {
6059
+ return c.json(
6060
+ { type: "error", error: { type: "api_error", message: routeResult.message } },
6061
+ routeResult.status
6062
+ );
6063
+ }
6064
+ const target = routeResult.target;
5741
6065
  const status = d.manager.status();
5742
6066
  const modelName = status.state === "running" ? status.model?.name ?? req.model ?? "local" : req.model ?? "local";
5743
6067
  const oaiBody = mapToOpenAI(req);
6068
+ const oaiAlias = engineModelAlias(d.registry.active()?.kind ?? "");
6069
+ if (oaiAlias) oaiBody.model = oaiAlias;
5744
6070
  d.manager.generationStart();
5745
6071
  let res;
5746
6072
  try {
@@ -5832,46 +6158,44 @@ function registerGateway(app2, d) {
5832
6158
  return c.json({ input_tokens: estimate });
5833
6159
  });
5834
6160
  app2.all("/v1/*", async (c) => {
5835
- const target = d.manager.target();
5836
- if (!target) {
5837
- if (c.req.method === "GET" && c.req.path === "/v1/models") {
6161
+ const url = new URL(c.req.url);
6162
+ const isChat = c.req.method === "POST" && url.pathname === "/v1/chat/completions";
6163
+ let parsedBody = null;
6164
+ if (isChat) {
6165
+ try {
6166
+ parsedBody = await c.req.json();
6167
+ } catch {
6168
+ parsedBody = null;
6169
+ }
6170
+ }
6171
+ const requestedModel = isChat ? parsedBody?.model ?? "" : "";
6172
+ const routeResult = await d.modelRouter.route(requestedModel);
6173
+ if ("status" in routeResult) {
6174
+ if (c.req.method === "GET" && url.pathname === "/v1/models") {
5838
6175
  return c.json({ object: "list", data: [] });
5839
6176
  }
5840
6177
  return c.json(
5841
- {
5842
- error: {
5843
- message: "No model loaded. Load one in TurboLLM.",
5844
- type: "model_not_loaded",
5845
- code: "model_not_loaded"
5846
- }
5847
- },
6178
+ { error: { message: routeResult.message, type: "model_not_loaded", code: "model_not_loaded" } },
5848
6179
  503
5849
6180
  );
5850
6181
  }
5851
- d.manager.touch();
5852
- const url = new URL(c.req.url);
6182
+ const target = routeResult.target;
5853
6183
  const upstream = target + url.pathname + url.search;
5854
6184
  const headers = new Headers(c.req.raw.headers);
5855
6185
  headers.delete("host");
5856
- const isChat = c.req.method === "POST" && url.pathname === "/v1/chat/completions";
5857
6186
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5858
6187
  const init = { method: c.req.method, headers };
5859
6188
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
5860
- if (isChat && maxLimit > 0) {
5861
- let parsed = null;
5862
- try {
5863
- parsed = await c.req.json();
5864
- } catch {
5865
- parsed = null;
6189
+ if (isChat) {
6190
+ if (parsedBody && maxLimit > 0) {
6191
+ parsedBody.max_tokens = clampMaxTokens(parsedBody.max_tokens, maxLimit);
5866
6192
  }
5867
- if (parsed) {
5868
- parsed.max_tokens = clampMaxTokens(parsed.max_tokens, maxLimit);
5869
- headers.delete("content-length");
5870
- init.body = JSON.stringify(parsed);
5871
- } else {
5872
- init.body = c.req.raw.body;
5873
- init.duplex = "half";
6193
+ if (parsedBody) {
6194
+ const alias = engineModelAlias(d.registry.active()?.kind ?? "");
6195
+ if (alias) parsedBody.model = alias;
5874
6196
  }
6197
+ headers.delete("content-length");
6198
+ init.body = parsedBody ? JSON.stringify(parsedBody) : "";
5875
6199
  } else {
5876
6200
  init.body = c.req.raw.body;
5877
6201
  init.duplex = "half";
@@ -6155,8 +6479,9 @@ var hf = new HfClient(() => store.snapshot().hf.token, version);
6155
6479
  var downloads = new DownloadManager(store, () => void scanner.rescan(), () => hf.authHeaders());
6156
6480
  var bench = new BenchRunner(manager, store, scanner, registry, version);
6157
6481
  var comfy = new ComfyGuard(store, manager);
6482
+ var modelRouter = new ModelRouter(store, registry, manager, scanner, comfy);
6158
6483
  var startedAt = Date.now();
6159
- var deps = { store, registry, manager, scanner, hashes, db, provision, hf, downloads, bench, comfy, version, startedAt };
6484
+ var deps = { store, registry, manager, scanner, hashes, db, provision, hf, downloads, bench, modelRouter, comfy, version, startedAt };
6160
6485
  var app = createApp(deps);
6161
6486
  var cfg = store.snapshot();
6162
6487
  var defaultHost = cfg.daemon.lanBind ? "0.0.0.0" : cfg.daemon.host || "127.0.0.1";