turbollm 0.5.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,
@@ -2404,6 +2427,7 @@ function deriveDefault(m, sys) {
2404
2427
  ropeFreqBase: 0,
2405
2428
  ropeFreqScale: 0,
2406
2429
  gpu: defaultGpu(),
2430
+ grammar: "",
2407
2431
  extraArgs: []
2408
2432
  };
2409
2433
  if (m.moe && hasGpu && m.blockCount > 0) {
@@ -2493,6 +2517,8 @@ function profileToArgs(p, m, caps, cores = 0) {
2493
2517
  if (p.ropeFreqBase > 0 && has("--rope-freq-base")) a.push("--rope-freq-base", String(p.ropeFreqBase));
2494
2518
  if (p.ropeFreqScale > 0 && has("--rope-freq-scale")) a.push("--rope-freq-scale", String(p.ropeFreqScale));
2495
2519
  }
2520
+ if (m.embedding && has("--embeddings")) a.push("--embeddings");
2521
+ if (p.grammar && has("--grammar")) a.push("--grammar", p.grammar);
2496
2522
  a.push(...p.extraArgs);
2497
2523
  return a;
2498
2524
  }
@@ -2703,10 +2729,15 @@ var HfClient = class {
2703
2729
  tokenFn;
2704
2730
  version;
2705
2731
  cache = /* @__PURE__ */ new Map();
2706
- /** Search GGUF repos (spec 10 §2). Returns up to 30 rows sorted by downloads. */
2707
- 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) {
2708
2738
  const q = query.trim();
2709
- 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`;
2710
2741
  const raw = await this.getJson(url);
2711
2742
  return raw.map((m) => ({
2712
2743
  repo: m.id ?? m.modelId ?? "",
@@ -2722,8 +2753,30 @@ var HfClient = class {
2722
2753
  async getRepo(repo) {
2723
2754
  const info = await this.getJson(`${BASE}/api/models/${repo}`);
2724
2755
  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);
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
+ }
2727
2780
  const gated = info.gated === true || info.gated === "auto" || info.gated === "manual";
2728
2781
  const license = info.cardData?.license ?? (info.tags?.find((t) => t.startsWith("license:"))?.slice("license:".length) || "");
2729
2782
  return {
@@ -2733,7 +2786,8 @@ var HfClient = class {
2733
2786
  downloads: info.downloads ?? 0,
2734
2787
  likes: info.likes ?? 0,
2735
2788
  card: await this.getCard(repo),
2736
- files
2789
+ files,
2790
+ ...safetensors ? { safetensors } : {}
2737
2791
  };
2738
2792
  }
2739
2793
  /** Fetch the repo README (the model card), strip its YAML frontmatter, and cap the
@@ -2928,27 +2982,30 @@ var DownloadManager = class {
2928
2982
  const dir = this.primaryDir();
2929
2983
  if (!dir) throw new DownloadError("no_model_dir", "Add a model folder in Settings before downloading.");
2930
2984
  let repo = (input.repo ?? "").trim();
2985
+ const subdir = (input.subdir ?? "").trim();
2931
2986
  let url;
2932
2987
  let filename;
2933
2988
  if (input.url) {
2934
2989
  const u = input.url.trim();
2935
2990
  if (!/^https?:\/\//i.test(u)) throw new DownloadError("invalid_url", "URL must start with http:// or https://.");
2936
2991
  const path = safePathname(u);
2937
- if (!/\.gguf$/i.test(path) && !HF_BLOB_RE.test(u)) {
2992
+ if (!subdir && !/\.gguf$/i.test(path) && !HF_BLOB_RE.test(u)) {
2938
2993
  throw new DownloadError("invalid_url", "URL must point to a .gguf file.");
2939
2994
  }
2940
2995
  filename = basename2(path);
2941
- 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.");
2942
2997
  url = u;
2943
2998
  repo = "";
2944
2999
  } else {
2945
3000
  const rfilename = (input.rfilename ?? "").trim();
2946
3001
  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.");
3002
+ if (!subdir && !/\.gguf$/i.test(rfilename)) throw new DownloadError("invalid_url", "The file must be a .gguf.");
2948
3003
  filename = basename2(rfilename);
2949
3004
  url = `https://huggingface.co/${repo}/resolve/main/${rfilename}`;
2950
3005
  }
2951
3006
  const total = input.size ?? 0;
3007
+ const destDir = subdir ? join10(dir, subdir) : dir;
3008
+ if (subdir) mkdirSync6(destDir, { recursive: true });
2952
3009
  if (total > 0) this.assertDisk(dir, total);
2953
3010
  const id = `dl-${Date.now().toString(36)}-${(this.nextSeq++).toString(36)}`;
2954
3011
  const rec = {
@@ -2956,7 +3013,7 @@ var DownloadManager = class {
2956
3013
  name: filename,
2957
3014
  repo,
2958
3015
  url,
2959
- dest: join10(dir, filename),
3016
+ dest: join10(destDir, filename),
2960
3017
  total,
2961
3018
  received: 0,
2962
3019
  status: "queued",
@@ -3526,6 +3583,219 @@ function sleep2(ms) {
3526
3583
  return new Promise((r) => setTimeout(r, ms));
3527
3584
  }
3528
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
+
3529
3799
  // src/cli-launch.ts
3530
3800
  import { spawn as spawn2 } from "child_process";
3531
3801
  var SUPPORTED = {
@@ -3834,6 +4104,7 @@ function registerApi(app2, d) {
3834
4104
  const engine = {
3835
4105
  id: active?.id ?? "",
3836
4106
  name: active?.name ?? "",
4107
+ kind: active?.kind ?? "",
3837
4108
  state: ms.state,
3838
4109
  port: ms.port,
3839
4110
  pid: ms.pid
@@ -4429,11 +4700,19 @@ function registerApi(app2, d) {
4429
4700
  }
4430
4701
  cuUpdates.url = u;
4431
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
+ }
4432
4710
  const before = d.store.snapshot().daemon;
4433
4711
  d.store.update((cfg2) => {
4434
4712
  Object.assign(cfg2.daemon, updates);
4435
4713
  Object.assign(cfg2.modelDefaults, mdUpdates);
4436
4714
  Object.assign(cfg2.comfyui, cuUpdates);
4715
+ Object.assign(cfg2.gateway, gwUpdates);
4437
4716
  if (b.autoLoadOnStart !== void 0) cfg2.autoLoadOnStart = !!b.autoLoadOnStart;
4438
4717
  if (telemetryLevel !== void 0) cfg2.telemetry.level = telemetryLevel;
4439
4718
  if (b.hfToken !== void 0) cfg2.hf.token = String(b.hfToken).trim();
@@ -4502,7 +4781,7 @@ function registerApi(app2, d) {
4502
4781
  const q = (c.req.query("q") ?? "").trim();
4503
4782
  if (!q) return c.json({ results: [] });
4504
4783
  try {
4505
- const results = await d.hf.searchModels(q);
4784
+ const results = await d.hf.searchModels(q, d.registry.active()?.kind);
4506
4785
  const withLocal = results.map((r) => ({ ...r, localCount: localCountFor(d, r.repo) }));
4507
4786
  return c.json({ results: withLocal });
4508
4787
  } catch (e) {
@@ -4698,6 +4977,7 @@ function settingsPayload(d) {
4698
4977
  telemetryLevel,
4699
4978
  modelDefaults: cfg2.modelDefaults,
4700
4979
  comfyui: cfg2.comfyui,
4980
+ gateway: cfg2.gateway,
4701
4981
  // The HF token is write-only over the wire (spec 10 §4): we never echo it back,
4702
4982
  // only whether one is set, so the UI can show "configured" without leaking it.
4703
4983
  hfTokenSet: cfg2.hf.token.length > 0
@@ -5713,13 +5993,6 @@ function cbStop(index) {
5713
5993
  // src/gateway/gateway.ts
5714
5994
  function registerGateway(app2, d) {
5715
5995
  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
5996
  let req;
5724
5997
  try {
5725
5998
  req = await c.req.json();
@@ -5737,7 +6010,14 @@ function registerGateway(app2, d) {
5737
6010
  }
5738
6011
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5739
6012
  req.max_tokens = clampMaxTokens(req.max_tokens, maxLimit) ?? req.max_tokens;
5740
- 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;
5741
6021
  const status = d.manager.status();
5742
6022
  const modelName = status.state === "running" ? status.model?.name ?? req.model ?? "local" : req.model ?? "local";
5743
6023
  const oaiBody = mapToOpenAI(req);
@@ -5832,46 +6112,40 @@ function registerGateway(app2, d) {
5832
6112
  return c.json({ input_tokens: estimate });
5833
6113
  });
5834
6114
  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") {
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") {
5838
6129
  return c.json({ object: "list", data: [] });
5839
6130
  }
5840
6131
  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
- },
6132
+ { error: { message: routeResult.message, type: "model_not_loaded", code: "model_not_loaded" } },
5848
6133
  503
5849
6134
  );
5850
6135
  }
5851
- d.manager.touch();
5852
- const url = new URL(c.req.url);
6136
+ const target = routeResult.target;
5853
6137
  const upstream = target + url.pathname + url.search;
5854
6138
  const headers = new Headers(c.req.raw.headers);
5855
6139
  headers.delete("host");
5856
- const isChat = c.req.method === "POST" && url.pathname === "/v1/chat/completions";
5857
6140
  const maxLimit = d.store.snapshot().modelDefaults.maxTokens ?? 0;
5858
6141
  const init = { method: c.req.method, headers };
5859
6142
  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;
5866
- }
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";
6143
+ if (isChat) {
6144
+ if (parsedBody && maxLimit > 0) {
6145
+ parsedBody.max_tokens = clampMaxTokens(parsedBody.max_tokens, maxLimit);
5874
6146
  }
6147
+ headers.delete("content-length");
6148
+ init.body = parsedBody ? JSON.stringify(parsedBody) : "";
5875
6149
  } else {
5876
6150
  init.body = c.req.raw.body;
5877
6151
  init.duplex = "half";
@@ -6155,8 +6429,9 @@ var hf = new HfClient(() => store.snapshot().hf.token, version);
6155
6429
  var downloads = new DownloadManager(store, () => void scanner.rescan(), () => hf.authHeaders());
6156
6430
  var bench = new BenchRunner(manager, store, scanner, registry, version);
6157
6431
  var comfy = new ComfyGuard(store, manager);
6432
+ var modelRouter = new ModelRouter(store, registry, manager, scanner, comfy);
6158
6433
  var startedAt = Date.now();
6159
- 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 };
6160
6435
  var app = createApp(deps);
6161
6436
  var cfg = store.snapshot();
6162
6437
  var defaultHost = cfg.daemon.lanBind ? "0.0.0.0" : cfg.daemon.host || "127.0.0.1";