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 +377 -52
- package/dist/webdist/assets/index-CFP1UlEC.js +127 -0
- package/dist/webdist/assets/index-DeRFSZA-.css +1 -0
- package/dist/webdist/index.html +2 -2
- package/package.json +1 -1
- package/dist/webdist/assets/index-CpWfZMgy.css +0 -1
- package/dist/webdist/assets/index-DJyXIPFR.js +0 -127
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
|
|
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:
|
|
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
|
|
2707
|
-
|
|
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
|
|
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
|
|
2726
|
-
const
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
5836
|
-
|
|
5837
|
-
|
|
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
|
-
|
|
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
|
|
5861
|
-
|
|
5862
|
-
|
|
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 (
|
|
5868
|
-
|
|
5869
|
-
|
|
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";
|