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 +324 -49
- 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: [] };
|
|
@@ -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:
|
|
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
|
|
2707
|
-
|
|
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
|
|
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
|
|
2726
|
-
const
|
|
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(
|
|
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.
|
|
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
|
|
5836
|
-
|
|
5837
|
-
|
|
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
|
-
|
|
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
|
|
5861
|
-
|
|
5862
|
-
|
|
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";
|