turbollm 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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: [] };
@@ -1966,6 +1972,20 @@ function quantFromName(filename) {
1966
1972
  // src/models/scanner.ts
1967
1973
  var CACHE_VERSION = 2;
1968
1974
  var SPLIT_RE = /^(.*)-(\d{5})-of-(\d{5})\.gguf$/i;
1975
+ var EMBED_ARCHS = /* @__PURE__ */ new Set([
1976
+ "bert",
1977
+ "nomic-bert",
1978
+ "jina-bert-v3-base",
1979
+ "jina-bert",
1980
+ "distilbert",
1981
+ "roberta",
1982
+ "xlm-roberta",
1983
+ "electra"
1984
+ ]);
1985
+ var EMBED_FILE_RE = /\b(bge[-_]|nomic[-_]embed|all[-_]minilm|e5[-_]|gte[-_]|stella[-_]embed|jina[-_]embed|mxbai[-_]embed)\b/i;
1986
+ function isEmbeddingModel(arch2, name) {
1987
+ return EMBED_ARCHS.has(arch2.toLowerCase()) || EMBED_FILE_RE.test(name);
1988
+ }
1969
1989
  var ScannerError = class extends Error {
1970
1990
  constructor(code, message) {
1971
1991
  super(message);
@@ -2111,6 +2131,7 @@ var Scanner = class {
2111
2131
  const quant = meta?.quant || quantFromName(fileName);
2112
2132
  const name = meta?.name || cleanName(fileName);
2113
2133
  const vision = mmprojPath !== null;
2134
+ const arch2 = meta?.arch ?? "unknown";
2114
2135
  return {
2115
2136
  key: `${name.toLowerCase()}|${quant}|${sizeBytes}`,
2116
2137
  name,
@@ -2119,7 +2140,7 @@ var Scanner = class {
2119
2140
  format: "gguf",
2120
2141
  sizeBytes,
2121
2142
  sizeLabel: meta?.sizeLabel ?? "",
2122
- arch: meta?.arch ?? "unknown",
2143
+ arch: arch2,
2123
2144
  quant,
2124
2145
  nativeCtx: meta?.nativeCtx ?? 0,
2125
2146
  blockCount: meta?.blockCount ?? 0,
@@ -2130,6 +2151,7 @@ var Scanner = class {
2130
2151
  vision,
2131
2152
  mmprojPath: vision ? mmprojPath : null,
2132
2153
  hasChatTemplate: meta?.hasChatTemplate ?? false,
2154
+ embedding: isEmbeddingModel(arch2, fileName),
2133
2155
  incomplete,
2134
2156
  parseError,
2135
2157
  loaded: false,
@@ -2240,6 +2262,7 @@ function mlxEntryFor(dir) {
2240
2262
  vision: false,
2241
2263
  mmprojPath: null,
2242
2264
  hasChatTemplate,
2265
+ embedding: isEmbeddingModel(arch2, basename(dir)),
2243
2266
  incomplete: false,
2244
2267
  parseError,
2245
2268
  loaded: false,
@@ -2342,7 +2365,7 @@ function kvBytesPerElem(t) {
2342
2365
  }
2343
2366
  }
2344
2367
  function defaultSampling() {
2345
- return { temp: 0.8, topP: 0.95, topK: 40, minP: 0.05, repeatPenalty: 1, presencePenalty: 0 };
2368
+ return { temp: 0.8, topP: 0.95, topK: 40, minP: 0.05, repeatPenalty: 1, presencePenalty: 0, frequencyPenalty: 0, stop: [] };
2346
2369
  }
2347
2370
  function gpuBudgetMb(sys, p) {
2348
2371
  if (sys.gpus.length === 0) return 0;
@@ -2398,7 +2421,13 @@ function deriveDefault(m, sys) {
2398
2421
  mtpHeadPath: "",
2399
2422
  draftModelPath: "",
2400
2423
  sampling: defaultSampling(),
2424
+ contextOverflow: "shift",
2425
+ nKeep: 0,
2426
+ ropeScalingType: "none",
2427
+ ropeFreqBase: 0,
2428
+ ropeFreqScale: 0,
2401
2429
  gpu: defaultGpu(),
2430
+ grammar: "",
2402
2431
  extraArgs: []
2403
2432
  };
2404
2433
  if (m.moe && hasGpu && m.blockCount > 0) {
@@ -2475,6 +2504,21 @@ function profileToArgs(p, m, caps, cores = 0) {
2475
2504
  if (specType) a.push("--spec-type", "draft");
2476
2505
  a.push("--model-draft", p.draftModelPath, "--draft-max", "16", "--draft-min", "1");
2477
2506
  }
2507
+ if (p.sampling.temp !== 0.8 && has("--temp")) a.push("--temp", String(p.sampling.temp));
2508
+ if (p.sampling.topP !== 0.95 && has("--top-p")) a.push("--top-p", String(p.sampling.topP));
2509
+ if (p.sampling.topK !== 40 && has("--top-k")) a.push("--top-k", String(p.sampling.topK));
2510
+ if (p.sampling.minP !== 0.05 && has("--min-p")) a.push("--min-p", String(p.sampling.minP));
2511
+ if (p.sampling.repeatPenalty !== 1 && has("--repeat-penalty")) a.push("--repeat-penalty", String(p.sampling.repeatPenalty));
2512
+ if (p.sampling.presencePenalty !== 0 && has("--presence-penalty")) a.push("--presence-penalty", String(p.sampling.presencePenalty));
2513
+ if (p.sampling.frequencyPenalty !== 0 && has("--frequency-penalty")) a.push("--frequency-penalty", String(p.sampling.frequencyPenalty));
2514
+ if (p.contextOverflow === "keep" && p.nKeep > 0 && has("--n-keep")) a.push("--n-keep", String(p.nKeep));
2515
+ if (p.ropeScalingType !== "none" && has("--rope-scaling")) {
2516
+ a.push("--rope-scaling", p.ropeScalingType);
2517
+ if (p.ropeFreqBase > 0 && has("--rope-freq-base")) a.push("--rope-freq-base", String(p.ropeFreqBase));
2518
+ if (p.ropeFreqScale > 0 && has("--rope-freq-scale")) a.push("--rope-freq-scale", String(p.ropeFreqScale));
2519
+ }
2520
+ if (m.embedding && has("--embeddings")) a.push("--embeddings");
2521
+ if (p.grammar && has("--grammar")) a.push("--grammar", p.grammar);
2478
2522
  a.push(...p.extraArgs);
2479
2523
  return a;
2480
2524
  }
@@ -2685,10 +2729,15 @@ var HfClient = class {
2685
2729
  tokenFn;
2686
2730
  version;
2687
2731
  cache = /* @__PURE__ */ new Map();
2688
- /** Search GGUF repos (spec 10 §2). Returns up to 30 rows sorted by downloads. */
2689
- async searchModels(query) {
2732
+ /** Search repos (spec 10 §2). Returns up to 30 rows sorted by downloads.
2733
+ * Format filter adapts to the active engine kind:
2734
+ * - llama-server / TurboQuant → filter=gguf
2735
+ * - mlx → filter=mlx (HF library tag)
2736
+ * - vllm → no format filter (searches all HF repos) */
2737
+ async searchModels(query, engineKind) {
2690
2738
  const q = query.trim();
2691
- const url = `${BASE}/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=downloads&direction=-1&limit=30&full=false`;
2739
+ const formatFilter = engineKind === "mlx" ? "&filter=mlx" : engineKind === "vllm" ? "" : "&filter=gguf";
2740
+ const url = `${BASE}/api/models?search=${encodeURIComponent(q)}${formatFilter}&sort=downloads&direction=-1&limit=30&full=false`;
2692
2741
  const raw = await this.getJson(url);
2693
2742
  return raw.map((m) => ({
2694
2743
  repo: m.id ?? m.modelId ?? "",
@@ -2704,8 +2753,30 @@ var HfClient = class {
2704
2753
  async getRepo(repo) {
2705
2754
  const info = await this.getJson(`${BASE}/api/models/${repo}`);
2706
2755
  const tree = await this.getJson(`${BASE}/api/models/${repo}/tree/main?recursive=true`);
2707
- const gguf = tree.filter((e) => e.type === "file" && /\.gguf$/i.test(e.path));
2708
- const files = groupFiles(repo, gguf);
2756
+ const ggufEntries = tree.filter((e) => e.type === "file" && /\.gguf$/i.test(e.path));
2757
+ const safetensorsEntries = tree.filter((e) => e.type === "file" && /\.safetensors$/i.test(e.path));
2758
+ const isSafetensors = ggufEntries.length === 0 && safetensorsEntries.length > 0;
2759
+ let files;
2760
+ let safetensors;
2761
+ if (isSafetensors) {
2762
+ safetensors = true;
2763
+ const components = tree.filter(
2764
+ (e) => e.type === "file" && (/\.safetensors$/i.test(e.path) || /\.json$/i.test(e.path)) && !e.path.includes("/")
2765
+ // root-level only — no nested model card assets
2766
+ );
2767
+ files = components.map((e) => ({
2768
+ name: e.path,
2769
+ quant: "mlx",
2770
+ sizeBytes: e.lfs?.size ?? e.size ?? 0,
2771
+ parts: 1,
2772
+ mmproj: false,
2773
+ safetensors: true,
2774
+ sha256: e.lfs?.oid,
2775
+ url: this.fileUrl(repo, e.path)
2776
+ }));
2777
+ } else {
2778
+ files = groupFiles(repo, ggufEntries);
2779
+ }
2709
2780
  const gated = info.gated === true || info.gated === "auto" || info.gated === "manual";
2710
2781
  const license = info.cardData?.license ?? (info.tags?.find((t) => t.startsWith("license:"))?.slice("license:".length) || "");
2711
2782
  return {
@@ -2715,7 +2786,8 @@ var HfClient = class {
2715
2786
  downloads: info.downloads ?? 0,
2716
2787
  likes: info.likes ?? 0,
2717
2788
  card: await this.getCard(repo),
2718
- files
2789
+ files,
2790
+ ...safetensors ? { safetensors } : {}
2719
2791
  };
2720
2792
  }
2721
2793
  /** Fetch the repo README (the model card), strip its YAML frontmatter, and cap the
@@ -2910,27 +2982,30 @@ var DownloadManager = class {
2910
2982
  const dir = this.primaryDir();
2911
2983
  if (!dir) throw new DownloadError("no_model_dir", "Add a model folder in Settings before downloading.");
2912
2984
  let repo = (input.repo ?? "").trim();
2985
+ const subdir = (input.subdir ?? "").trim();
2913
2986
  let url;
2914
2987
  let filename;
2915
2988
  if (input.url) {
2916
2989
  const u = input.url.trim();
2917
2990
  if (!/^https?:\/\//i.test(u)) throw new DownloadError("invalid_url", "URL must start with http:// or https://.");
2918
2991
  const path = safePathname(u);
2919
- if (!/\.gguf$/i.test(path) && !HF_BLOB_RE.test(u)) {
2992
+ if (!subdir && !/\.gguf$/i.test(path) && !HF_BLOB_RE.test(u)) {
2920
2993
  throw new DownloadError("invalid_url", "URL must point to a .gguf file.");
2921
2994
  }
2922
2995
  filename = basename2(path);
2923
- if (!/\.gguf$/i.test(filename)) throw new DownloadError("invalid_url", "Could not derive a .gguf filename from that URL.");
2996
+ if (!subdir && !/\.gguf$/i.test(filename)) throw new DownloadError("invalid_url", "Could not derive a .gguf filename from that URL.");
2924
2997
  url = u;
2925
2998
  repo = "";
2926
2999
  } else {
2927
3000
  const rfilename = (input.rfilename ?? "").trim();
2928
3001
  if (!repo || !rfilename) throw new DownloadError("invalid_request", "repo and rfilename are required.");
2929
- if (!/\.gguf$/i.test(rfilename)) throw new DownloadError("invalid_url", "The file must be a .gguf.");
3002
+ if (!subdir && !/\.gguf$/i.test(rfilename)) throw new DownloadError("invalid_url", "The file must be a .gguf.");
2930
3003
  filename = basename2(rfilename);
2931
3004
  url = `https://huggingface.co/${repo}/resolve/main/${rfilename}`;
2932
3005
  }
2933
3006
  const total = input.size ?? 0;
3007
+ const destDir = subdir ? join10(dir, subdir) : dir;
3008
+ if (subdir) mkdirSync6(destDir, { recursive: true });
2934
3009
  if (total > 0) this.assertDisk(dir, total);
2935
3010
  const id = `dl-${Date.now().toString(36)}-${(this.nextSeq++).toString(36)}`;
2936
3011
  const rec = {
@@ -2938,7 +3013,7 @@ var DownloadManager = class {
2938
3013
  name: filename,
2939
3014
  repo,
2940
3015
  url,
2941
- dest: join10(dir, filename),
3016
+ dest: join10(destDir, filename),
2942
3017
  total,
2943
3018
  received: 0,
2944
3019
  status: "queued",
@@ -3508,6 +3583,219 @@ function sleep2(ms) {
3508
3583
  return new Promise((r) => setTimeout(r, ms));
3509
3584
  }
3510
3585
 
3586
+ // src/gateway/model-router.ts
3587
+ var ModelRouter = class {
3588
+ constructor(store2, registry2, manager2, scanner2, comfy2) {
3589
+ this.store = store2;
3590
+ this.registry = registry2;
3591
+ this.manager = manager2;
3592
+ this.scanner = scanner2;
3593
+ this.comfy = comfy2;
3594
+ }
3595
+ store;
3596
+ registry;
3597
+ manager;
3598
+ scanner;
3599
+ comfy;
3600
+ /** Extra pool slots beyond the primary manager. Only populated when keepN > 1. */
3601
+ extraSlots = /* @__PURE__ */ new Map();
3602
+ /** Last-used timestamp for the primary manager slot (for LRU eviction). */
3603
+ primaryLastUsed = 0;
3604
+ /** Promise chain that serialises swap operations so concurrent requests for
3605
+ * different models queue rather than race. */
3606
+ swapChain = Promise.resolve();
3607
+ /** Route a request to the correct model target URL.
3608
+ * - If autoSwap is off: returns whatever the primary manager has loaded.
3609
+ * - If the requested model is already loaded: returns its target immediately.
3610
+ * - Otherwise: loads the model (swapping / evicting LRU as needed) and waits. */
3611
+ async route(requestedModel) {
3612
+ const cfg2 = this.store.snapshot();
3613
+ if (!cfg2.gateway.autoSwap || !requestedModel.trim()) {
3614
+ const t = this.manager.target();
3615
+ return t ? { target: t } : { status: 503, message: "No model loaded. Load one in TurboLLM." };
3616
+ }
3617
+ const entry = this.resolveEntry(requestedModel);
3618
+ if (!entry) {
3619
+ const t = this.manager.target();
3620
+ return t ? { target: t } : { status: 503, message: `No model matching '${requestedModel}' found. Add one in TurboLLM.` };
3621
+ }
3622
+ {
3623
+ const ms = this.manager.status();
3624
+ if (ms.state === "running" && ms.model && this.keysMatch(ms.model.key, entry)) {
3625
+ this.primaryLastUsed = Date.now();
3626
+ this.manager.touch();
3627
+ return { target: this.manager.target() };
3628
+ }
3629
+ }
3630
+ const slot = this.extraSlots.get(entry.key);
3631
+ if (slot) {
3632
+ const ss = slot.manager.status();
3633
+ if (ss.state === "running") {
3634
+ slot.lastUsedMs = Date.now();
3635
+ slot.manager.touch();
3636
+ return { target: slot.manager.target() };
3637
+ }
3638
+ this.extraSlots.delete(entry.key);
3639
+ }
3640
+ let unlock;
3641
+ const prev = this.swapChain;
3642
+ this.swapChain = new Promise((r) => {
3643
+ unlock = r;
3644
+ });
3645
+ try {
3646
+ await prev;
3647
+ return await this.doLoad(entry);
3648
+ } finally {
3649
+ unlock();
3650
+ }
3651
+ }
3652
+ // ── internal ──────────────────────────────────────────────────────────────
3653
+ async doLoad(entry) {
3654
+ {
3655
+ const ms = this.manager.status();
3656
+ if (ms.state === "running" && ms.model && this.keysMatch(ms.model.key, entry)) {
3657
+ this.primaryLastUsed = Date.now();
3658
+ this.manager.touch();
3659
+ return { target: this.manager.target() };
3660
+ }
3661
+ const slot = this.extraSlots.get(entry.key);
3662
+ if (slot && slot.manager.status().state === "running") {
3663
+ slot.lastUsedMs = Date.now();
3664
+ slot.manager.touch();
3665
+ return { target: slot.manager.target() };
3666
+ }
3667
+ }
3668
+ if (this.comfy?.isBlocked()) {
3669
+ return { status: 503, message: "ComfyUI is rendering \u2014 model swap paused until its queue finishes." };
3670
+ }
3671
+ const active = this.registry.active();
3672
+ if (!active) return { status: 503, message: "No active engine. Set one up in TurboLLM." };
3673
+ if (!engineAcceptsFormat(active.kind, entry.format)) {
3674
+ return { status: 503, message: `Active engine cannot load model format '${entry.format}'.` };
3675
+ }
3676
+ const opts = this.buildOpts(entry, active);
3677
+ if (!opts) return { status: 503, message: "Model is incomplete or unreadable." };
3678
+ const keepN = Math.max(1, this.store.snapshot().gateway.keepN);
3679
+ const needsNewSlot = entry.embedding || this.chatSlotCount() < keepN;
3680
+ const targetManager = needsNewSlot ? this.manager.status().state === "stopped" || this.manager.status().state === "error" ? this.manager : new Manager(this.store) : this.evictChatLru();
3681
+ await targetManager.stopAndWait();
3682
+ await this.comfy?.freeComfyUIBeforeLoad();
3683
+ try {
3684
+ await targetManager.start(opts);
3685
+ } catch (e) {
3686
+ return { status: 503, message: `Engine start failed: ${e.message}` };
3687
+ }
3688
+ const ready = await this.waitReady(targetManager, active.kind);
3689
+ if (!ready) {
3690
+ const s = targetManager.status();
3691
+ return { status: 503, message: s.err?.message ?? "Model failed to become ready." };
3692
+ }
3693
+ const target = targetManager.target();
3694
+ if (!target) return { status: 503, message: "Model loaded but target URL unavailable." };
3695
+ if (targetManager === this.manager) {
3696
+ this.primaryLastUsed = Date.now();
3697
+ } else {
3698
+ this.extraSlots.set(entry.key, { manager: targetManager, modelKey: entry.key, lastUsedMs: Date.now() });
3699
+ }
3700
+ this.store.update((x) => {
3701
+ x.lastLoaded = { modelKey: entry.key, engineId: active.id };
3702
+ });
3703
+ return { target };
3704
+ }
3705
+ /** Count of alive chat (non-embedding) slots. Embedding models don't consume
3706
+ * a keepN slot so chat models and embedding models can coexist independently. */
3707
+ chatSlotCount() {
3708
+ const isAlive = (s) => s === "running" || s === "starting";
3709
+ const ms = this.manager.status();
3710
+ const primaryAlive = isAlive(ms.state);
3711
+ const primaryEmbed = primaryAlive && !!ms.model && (this.scanner.get(ms.model.key)?.embedding ?? false);
3712
+ const extraChat = [...this.extraSlots.values()].filter(
3713
+ (s) => isAlive(s.manager.status().state) && !(this.scanner.get(s.modelKey)?.embedding ?? false)
3714
+ ).length;
3715
+ return (primaryAlive && !primaryEmbed ? 1 : 0) + extraChat;
3716
+ }
3717
+ /** Evict the least-recently-used chat (non-embedding) slot. Embedding slots are
3718
+ * skipped; if every alive slot is an embedding model the true LRU is used as
3719
+ * a fallback so we never deadlock. */
3720
+ evictChatLru() {
3721
+ const isAlive = (s) => s === "running" || s === "starting";
3722
+ const ms = this.manager.status();
3723
+ const primaryAlive = isAlive(ms.state);
3724
+ const primaryEmbed = primaryAlive && !!ms.model && (this.scanner.get(ms.model.key)?.embedding ?? false);
3725
+ let lruManager = this.manager;
3726
+ let lruTime = primaryAlive && !primaryEmbed ? this.primaryLastUsed : Infinity;
3727
+ let lruKey = null;
3728
+ for (const slot of this.extraSlots.values()) {
3729
+ const slotEmbed = this.scanner.get(slot.modelKey)?.embedding ?? false;
3730
+ if (isAlive(slot.manager.status().state) && !slotEmbed && slot.lastUsedMs < lruTime) {
3731
+ lruTime = slot.lastUsedMs;
3732
+ lruManager = slot.manager;
3733
+ lruKey = slot.modelKey;
3734
+ }
3735
+ }
3736
+ if (lruTime === Infinity) {
3737
+ lruTime = primaryAlive ? this.primaryLastUsed : Infinity;
3738
+ lruManager = this.manager;
3739
+ lruKey = null;
3740
+ for (const slot of this.extraSlots.values()) {
3741
+ if (isAlive(slot.manager.status().state) && slot.lastUsedMs < lruTime) {
3742
+ lruTime = slot.lastUsedMs;
3743
+ lruManager = slot.manager;
3744
+ lruKey = slot.modelKey;
3745
+ }
3746
+ }
3747
+ }
3748
+ if (lruKey !== null) this.extraSlots.delete(lruKey);
3749
+ return lruManager;
3750
+ }
3751
+ resolveEntry(requested) {
3752
+ const models = this.scanner.list().models;
3753
+ 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()));
3754
+ }
3755
+ keysMatch(loadedKey, entry) {
3756
+ return loadedKey === entry.key || loadedKey === entry.path;
3757
+ }
3758
+ buildOpts(entry, engine) {
3759
+ if (entry.incomplete || entry.parseError) return null;
3760
+ const cfg2 = this.store.snapshot();
3761
+ const sys = getSysInfo();
3762
+ if (entry.format !== "gguf") {
3763
+ const savedGpu = cfg2.modelProfiles[entry.key]?.gpu;
3764
+ return {
3765
+ engine,
3766
+ model: { key: entry.key, name: entry.name, quant: entry.quant, ctx: entry.nativeCtx, vision: false },
3767
+ modelPath: entry.path,
3768
+ extraArgs: [],
3769
+ tensorParallelSize: savedGpu?.tensorParallelSize
3770
+ };
3771
+ }
3772
+ const saved = cfg2.modelProfiles[entry.key];
3773
+ const profile = resolveProfile(entry, sys, saved, void 0, cfg2.modelDefaults);
3774
+ return {
3775
+ engine,
3776
+ model: { key: entry.key, name: entry.name, quant: entry.quant, ctx: profile.ctx, vision: entry.vision },
3777
+ modelPath: entry.path,
3778
+ extraArgs: profileToArgs(profile, entry, engine.capabilities, sys.cores)
3779
+ };
3780
+ }
3781
+ /** Poll until the manager's engine process becomes ready or fails.
3782
+ * Mirrors the Manager's internal readiness timeout by engine kind. */
3783
+ async waitReady(manager2, engineKind) {
3784
+ const timeoutMs = engineKind === "vllm" ? 6e5 : 12e4;
3785
+ const deadline = Date.now() + timeoutMs;
3786
+ while (Date.now() < deadline) {
3787
+ const s = manager2.status();
3788
+ if (s.state === "running") return true;
3789
+ if (s.state === "error" || s.state === "stopped") return false;
3790
+ await sleep3(250);
3791
+ }
3792
+ return false;
3793
+ }
3794
+ };
3795
+ function sleep3(ms) {
3796
+ return new Promise((r) => setTimeout(r, ms));
3797
+ }
3798
+
3511
3799
  // src/cli-launch.ts
3512
3800
  import { spawn as spawn2 } from "child_process";
3513
3801
  var SUPPORTED = {
@@ -3591,7 +3879,7 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as re
3591
3879
  import { basename as basename3, dirname as dirname5, join as join12, resolve, sep } from "path";
3592
3880
 
3593
3881
  // src/comfyui/gate-template.ts
3594
- var GATE_VERSION = 1;
3882
+ var GATE_VERSION = 2;
3595
3883
  function gateNodeSource(baseUrl) {
3596
3884
  const base2 = baseUrl.replace(/\/+$/, "");
3597
3885
  const baseLit = JSON.stringify(base2);
@@ -3648,6 +3936,19 @@ def _acquire_blocking() -> None:
3648
3936
  _post("/api/v1/comfyui/acquire", ACQUIRE_TIMEOUT_SEC)
3649
3937
 
3650
3938
 
3939
+ def _free_self() -> None:
3940
+ """Unload ComfyUI's own models and clear the CUDA cache so VRAM is available
3941
+ for TurboLLM to reload its model. Called on the release edge (queue drained),
3942
+ before notifying TurboLLM. In-process \u2014 no HTTP roundtrip."""
3943
+ try:
3944
+ import comfy.model_management as mm # type: ignore[import]
3945
+ mm.unload_all_models()
3946
+ mm.soft_empty_cache()
3947
+ _log("freed ComfyUI model cache.")
3948
+ except Exception as e: # noqa: BLE001
3949
+ _log(f"could not free ComfyUI models ({e}); continuing.")
3950
+
3951
+
3651
3952
  def _release() -> None:
3652
3953
  _post("/api/v1/comfyui/release", RELEASE_TIMEOUT_SEC)
3653
3954
 
@@ -3697,6 +3998,7 @@ def _install_hooks() -> None:
3697
3998
  _busy = False
3698
3999
  do_release = True
3699
4000
  if do_release:
4001
+ _free_self()
3700
4002
  _release()
3701
4003
  return result
3702
4004
 
@@ -3802,6 +4104,7 @@ function registerApi(app2, d) {
3802
4104
  const engine = {
3803
4105
  id: active?.id ?? "",
3804
4106
  name: active?.name ?? "",
4107
+ kind: active?.kind ?? "",
3805
4108
  state: ms.state,
3806
4109
  port: ms.port,
3807
4110
  pid: ms.pid
@@ -3822,7 +4125,22 @@ function registerApi(app2, d) {
3822
4125
  downloads: { active: d.downloads.activeCount() },
3823
4126
  engineProvision: d.provision.get(),
3824
4127
  // ComfyUI GPU coordination: lets the UI explain a paused/unloaded engine.
3825
- comfyui: d.comfy?.snapshot() ?? null,
4128
+ // Also expose the installed gate node version so the UI can prompt an upgrade.
4129
+ comfyui: (() => {
4130
+ const snap = d.comfy?.snapshot() ?? null;
4131
+ if (!snap) return null;
4132
+ const gatePath = d.store.snapshot().comfyui.gatePath;
4133
+ let installedVersion = null;
4134
+ if (gatePath) {
4135
+ try {
4136
+ const src = readFileSync6(join12(gatePath, "__init__.py"), "utf-8");
4137
+ const m = src.match(/^GATE_VERSION\s*=\s*(\d+)/m);
4138
+ if (m) installedVersion = Number(m[1]);
4139
+ } catch {
4140
+ }
4141
+ }
4142
+ return { ...snap, installedVersion, currentVersion: GATE_VERSION };
4143
+ })(),
3826
4144
  telemetryLevel: d.store.snapshot().telemetry.level,
3827
4145
  uptimeSec: Math.floor((Date.now() - d.startedAt) / 1e3)
3828
4146
  });
@@ -4382,11 +4700,19 @@ function registerApi(app2, d) {
4382
4700
  }
4383
4701
  cuUpdates.url = u;
4384
4702
  }
4703
+ const gwUpdates = {};
4704
+ if (b.gateway?.autoSwap !== void 0) gwUpdates.autoSwap = !!b.gateway.autoSwap;
4705
+ if (b.gateway?.keepN !== void 0) {
4706
+ const v = Number(b.gateway.keepN);
4707
+ if (!Number.isInteger(v) || v < 1 || v > 4) return err(c, 400, "invalid_config_value", "gateway.keepN must be 1\u20134.");
4708
+ gwUpdates.keepN = v;
4709
+ }
4385
4710
  const before = d.store.snapshot().daemon;
4386
4711
  d.store.update((cfg2) => {
4387
4712
  Object.assign(cfg2.daemon, updates);
4388
4713
  Object.assign(cfg2.modelDefaults, mdUpdates);
4389
4714
  Object.assign(cfg2.comfyui, cuUpdates);
4715
+ Object.assign(cfg2.gateway, gwUpdates);
4390
4716
  if (b.autoLoadOnStart !== void 0) cfg2.autoLoadOnStart = !!b.autoLoadOnStart;
4391
4717
  if (telemetryLevel !== void 0) cfg2.telemetry.level = telemetryLevel;
4392
4718
  if (b.hfToken !== void 0) cfg2.hf.token = String(b.hfToken).trim();
@@ -4455,7 +4781,7 @@ function registerApi(app2, d) {
4455
4781
  const q = (c.req.query("q") ?? "").trim();
4456
4782
  if (!q) return c.json({ results: [] });
4457
4783
  try {
4458
- const results = await d.hf.searchModels(q);
4784
+ const results = await d.hf.searchModels(q, d.registry.active()?.kind);
4459
4785
  const withLocal = results.map((r) => ({ ...r, localCount: localCountFor(d, r.repo) }));
4460
4786
  return c.json({ results: withLocal });
4461
4787
  } catch (e) {
@@ -4651,6 +4977,7 @@ function settingsPayload(d) {
4651
4977
  telemetryLevel,
4652
4978
  modelDefaults: cfg2.modelDefaults,
4653
4979
  comfyui: cfg2.comfyui,
4980
+ gateway: cfg2.gateway,
4654
4981
  // The HF token is write-only over the wire (spec 10 §4): we never echo it back,
4655
4982
  // only whether one is set, so the UI can show "configured" without leaking it.
4656
4983
  hfTokenSet: cfg2.hf.token.length > 0
@@ -5075,17 +5402,33 @@ ${content}` : b.docContext : content;
5075
5402
  async function runGeneration(d, stream, ctx) {
5076
5403
  const { db: db2 } = d;
5077
5404
  const { convId, conv, engineMessages, assistantMsg, ms, target, ac, disableThinking } = ctx;
5078
- const modelSampling = {};
5079
- const convSampling = conv.sampling ?? {};
5080
- const merged = { ...modelSampling, ...convSampling };
5405
+ const convS = conv.sampling ?? {};
5406
+ const SAMPLING_KEYS = {
5407
+ temp: "temperature",
5408
+ topP: "top_p",
5409
+ topK: "top_k",
5410
+ minP: "min_p",
5411
+ repeatPenalty: "repeat_penalty",
5412
+ presencePenalty: "presence_penalty",
5413
+ frequencyPenalty: "frequency_penalty"
5414
+ };
5415
+ const samplingOverride = {};
5416
+ for (const [camel, snake] of Object.entries(SAMPLING_KEYS)) {
5417
+ if (camel in convS) samplingOverride[snake] = convS[camel];
5418
+ }
5419
+ for (const [k, v] of Object.entries(convS)) {
5420
+ if (!(k in SAMPLING_KEYS) && k !== "stop") samplingOverride[k] = v;
5421
+ }
5081
5422
  const reqBody = {
5082
5423
  model: ms.model.key,
5083
5424
  messages: engineMessages,
5084
5425
  stream: true,
5085
5426
  stream_options: { include_usage: true },
5086
5427
  return_progress: true,
5087
- ...merged
5428
+ ...samplingOverride
5088
5429
  };
5430
+ const stopStrings = convS.stop;
5431
+ if (stopStrings?.length) reqBody.stop = stopStrings;
5089
5432
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5090
5433
  const cappedMax = clampMaxTokens(reqBody.max_tokens, maxLimit);
5091
5434
  if (cappedMax != null) reqBody.max_tokens = cappedMax;
@@ -5123,6 +5466,12 @@ async function runGeneration(d, stream, ctx) {
5123
5466
  const reader = res.body.getReader();
5124
5467
  const decoder = new TextDecoder();
5125
5468
  let buf = "";
5469
+ const cancelReader = () => void reader.cancel();
5470
+ if (ac.signal.aborted) {
5471
+ cancelReader();
5472
+ } else {
5473
+ ac.signal.addEventListener("abort", cancelReader, { once: true });
5474
+ }
5126
5475
  outer: while (true) {
5127
5476
  const { done, value } = await reader.read();
5128
5477
  if (done) break;
@@ -5234,6 +5583,7 @@ async function runGeneration(d, stream, ctx) {
5234
5583
  }
5235
5584
  }
5236
5585
  }
5586
+ ac.signal.removeEventListener("abort", cancelReader);
5237
5587
  if (pendingThinkBuf && inThink) {
5238
5588
  fullReasoning += pendingThinkBuf;
5239
5589
  await stream.writeSSE({ event: "reasoning", data: JSON.stringify({ delta: pendingThinkBuf }) });
@@ -5643,13 +5993,6 @@ function cbStop(index) {
5643
5993
  // src/gateway/gateway.ts
5644
5994
  function registerGateway(app2, d) {
5645
5995
  app2.post("/v1/messages", async (c) => {
5646
- const target = d.manager.target();
5647
- if (!target) {
5648
- return c.json(
5649
- { type: "error", error: { type: "api_error", message: "No model loaded. Load one in TurboLLM." } },
5650
- 503
5651
- );
5652
- }
5653
5996
  let req;
5654
5997
  try {
5655
5998
  req = await c.req.json();
@@ -5667,7 +6010,14 @@ function registerGateway(app2, d) {
5667
6010
  }
5668
6011
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5669
6012
  req.max_tokens = clampMaxTokens(req.max_tokens, maxLimit) ?? req.max_tokens;
5670
- d.manager.touch();
6013
+ const routeResult = await d.modelRouter.route(req.model ?? "");
6014
+ if ("status" in routeResult) {
6015
+ return c.json(
6016
+ { type: "error", error: { type: "api_error", message: routeResult.message } },
6017
+ routeResult.status
6018
+ );
6019
+ }
6020
+ const target = routeResult.target;
5671
6021
  const status = d.manager.status();
5672
6022
  const modelName = status.state === "running" ? status.model?.name ?? req.model ?? "local" : req.model ?? "local";
5673
6023
  const oaiBody = mapToOpenAI(req);
@@ -5762,46 +6112,40 @@ function registerGateway(app2, d) {
5762
6112
  return c.json({ input_tokens: estimate });
5763
6113
  });
5764
6114
  app2.all("/v1/*", async (c) => {
5765
- const target = d.manager.target();
5766
- if (!target) {
5767
- if (c.req.method === "GET" && c.req.path === "/v1/models") {
6115
+ const url = new URL(c.req.url);
6116
+ const isChat = c.req.method === "POST" && url.pathname === "/v1/chat/completions";
6117
+ let parsedBody = null;
6118
+ if (isChat) {
6119
+ try {
6120
+ parsedBody = await c.req.json();
6121
+ } catch {
6122
+ parsedBody = null;
6123
+ }
6124
+ }
6125
+ const requestedModel = isChat ? parsedBody?.model ?? "" : "";
6126
+ const routeResult = await d.modelRouter.route(requestedModel);
6127
+ if ("status" in routeResult) {
6128
+ if (c.req.method === "GET" && url.pathname === "/v1/models") {
5768
6129
  return c.json({ object: "list", data: [] });
5769
6130
  }
5770
6131
  return c.json(
5771
- {
5772
- error: {
5773
- message: "No model loaded. Load one in TurboLLM.",
5774
- type: "model_not_loaded",
5775
- code: "model_not_loaded"
5776
- }
5777
- },
6132
+ { error: { message: routeResult.message, type: "model_not_loaded", code: "model_not_loaded" } },
5778
6133
  503
5779
6134
  );
5780
6135
  }
5781
- d.manager.touch();
5782
- const url = new URL(c.req.url);
6136
+ const target = routeResult.target;
5783
6137
  const upstream = target + url.pathname + url.search;
5784
6138
  const headers = new Headers(c.req.raw.headers);
5785
6139
  headers.delete("host");
5786
- const isChat = c.req.method === "POST" && url.pathname === "/v1/chat/completions";
5787
6140
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5788
6141
  const init = { method: c.req.method, headers };
5789
6142
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
5790
- if (isChat && maxLimit > 0) {
5791
- let parsed = null;
5792
- try {
5793
- parsed = await c.req.json();
5794
- } catch {
5795
- parsed = null;
5796
- }
5797
- if (parsed) {
5798
- parsed.max_tokens = clampMaxTokens(parsed.max_tokens, maxLimit);
5799
- headers.delete("content-length");
5800
- init.body = JSON.stringify(parsed);
5801
- } else {
5802
- init.body = c.req.raw.body;
5803
- init.duplex = "half";
6143
+ if (isChat) {
6144
+ if (parsedBody && maxLimit > 0) {
6145
+ parsedBody.max_tokens = clampMaxTokens(parsedBody.max_tokens, maxLimit);
5804
6146
  }
6147
+ headers.delete("content-length");
6148
+ init.body = parsedBody ? JSON.stringify(parsedBody) : "";
5805
6149
  } else {
5806
6150
  init.body = c.req.raw.body;
5807
6151
  init.duplex = "half";
@@ -6085,8 +6429,9 @@ var hf = new HfClient(() => store.snapshot().hf.token, version);
6085
6429
  var downloads = new DownloadManager(store, () => void scanner.rescan(), () => hf.authHeaders());
6086
6430
  var bench = new BenchRunner(manager, store, scanner, registry, version);
6087
6431
  var comfy = new ComfyGuard(store, manager);
6432
+ var modelRouter = new ModelRouter(store, registry, manager, scanner, comfy);
6088
6433
  var startedAt = Date.now();
6089
- var deps = { store, registry, manager, scanner, hashes, db, provision, hf, downloads, bench, comfy, version, startedAt };
6434
+ var deps = { store, registry, manager, scanner, hashes, db, provision, hf, downloads, bench, modelRouter, comfy, version, startedAt };
6090
6435
  var app = createApp(deps);
6091
6436
  var cfg = store.snapshot();
6092
6437
  var defaultHost = cfg.daemon.lanBind ? "0.0.0.0" : cfg.daemon.host || "127.0.0.1";