From e1222f30dd5db9044ccb4252a95a85ae4c7312c8 Mon Sep 17 00:00:00 2001 From: "bramha.dev" Date: Mon, 6 Jul 2026 10:44:40 +0530 Subject: [PATCH 1/4] fix: Windows install (#40), MTP model load + RAM regression (#43), auto-tune MTP - install: require Node >=22.13.0 so node:sqlite loads without the --experimental-sqlite flag (below that, `npx turbollm` crashes with ERR_UNKNOWN_BUILTIN_MODULE despite `node -v` reporting 22.x). Guard in bin/turbollm.mjs + cli.ts, engines field, README. (#40) - engines: probe no longer treats llama.cpp's "argument has been removed" --help notices as supported flags, so --draft-max/--draft-min are no longer passed to a build that rejects them (crash on every MTP load). Falls back to --spec-draft-n-max/--spec-draft-n-min when only those are present. ensureProbed auto-reprobes engines carrying the stale flags so an app upgrade fixes already-registered engines without a manual reprobe. (#43) - mtp: native NextN/MTP (--spec-type draft-mtp) no longer passes --model-draft pointing at the main model file. That made llama.cpp load a full second copy of the weights into RAM (measured +35GB on a 35B MoE, and 54% slower generation) with no error. Native MTP now uses ~460MB extra and runs faster, as intended. The fork's own `nextn` spec-type still gets --model-draft. - bench: auto-tune now tunes with the user's speculative setting active instead of forcing it off, so the offload/KV fit reserves room for MTP's real footprint and the winning profile preserves the user's MTP choice instead of silently resetting it to off on save. --- turbollm/README.md | 6 +-- turbollm/bin/turbollm.mjs | 12 +++-- turbollm/package.json | 2 +- turbollm/src/bench/bench.ts | 19 ++++--- turbollm/src/cli.ts | 9 ++-- turbollm/src/engines/probe.test.ts | 36 ++++++++++++++ turbollm/src/engines/probe.ts | 20 +++++++- .../src/engines/registry.staleness.test.ts | 31 ++++++++++++ turbollm/src/engines/registry.ts | 27 ++++++++-- turbollm/src/models/profile.gen.test.ts | 49 +++++++++++++++++++ turbollm/src/models/profile.ts | 34 ++++++++++--- 11 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 turbollm/src/engines/probe.test.ts create mode 100644 turbollm/src/engines/registry.staleness.test.ts diff --git a/turbollm/README.md b/turbollm/README.md index 2a15ba3..bb687c4 100644 --- a/turbollm/README.md +++ b/turbollm/README.md @@ -446,7 +446,7 @@ Use `--config ` to point at an alternate config (its directory becomes the ## Requirements -- **Node.js 22 or newer** — enforced at startup with a clear message. +- **Node.js 22.13.0 or newer** — enforced at startup with a clear message. - **Windows, macOS, or Linux.** - A GPU is recommended but **not required** — a CPU build is provisioned as a fallback. - On Windows, the first time the auto-downloaded `llama-server` runs, SmartScreen/Defender may @@ -492,7 +492,7 @@ Prefer Open WebUI's chat breadth? It works great pointed at TurboLLM's OpenAI en ## Troubleshooting -- **`TurboLLM requires Node.js 22 or newer`** — upgrade Node: . +- **`TurboLLM requires Node.js 22.13.0 or newer`** — upgrade Node: . - **Model won't load / OOM** — pick a smaller quant (the VRAM verdict warns you), lower GPU offload, or close other GPU apps. Failures surface in the Engines screen with the engine log. - **Windows Defender / SmartScreen prompt** — that's the upstream `llama-server` binary on @@ -519,7 +519,7 @@ node dist/cli.js --port 6996 Frontend hot-reload: `cd web && npm run dev` (proxies `/api` and `/v1` to the daemon on :6996). -**Stack:** Node ≥22 · TypeScript · Hono · `node:sqlite` · tsup — and a React 19 + Tailwind v4 + +**Stack:** Node ≥22.13 · TypeScript · Hono · `node:sqlite` · tsup — and a React 19 + Tailwind v4 + shadcn/ui frontend. One TypeScript codebase, shipped as an npm package. --- diff --git a/turbollm/bin/turbollm.mjs b/turbollm/bin/turbollm.mjs index eda5f62..29bc2d7 100644 --- a/turbollm/bin/turbollm.mjs +++ b/turbollm/bin/turbollm.mjs @@ -3,14 +3,18 @@ // daemon (dist/cli.js) loads, so it can do two things that must happen before // node:sqlite is imported (the bundle hoists that import, emitting an experimental // warning that PowerShell renders as a scary red error block on every run): -// 1. Guard the Node version with a friendly message (node:sqlite needs Node 22+). +// 1. Guard the Node version with a friendly message (node:sqlite needs Node 22.13+ — +// that's when node:sqlite became available WITHOUT the --experimental-sqlite flag; +// on 22.5.0-22.12.x it's registered only behind that flag, so importing it bare +// throws ERR_UNKNOWN_BUILTIN_MODULE: "No such built-in module: node:sqlite" +// (GitHub #40) even though `node -v` reports 22.x). // 2. Register a 'warning' filter that swallows the node:sqlite experimental notice // while still printing every other warning. // Then it hands off to the real CLI via dynamic import (same process; argv intact). -const major = Number(process.versions.node.split('.')[0]) -if (major < 22) { +const [major, minor] = process.versions.node.split('.').map(Number) +if (major < 22 || (major === 22 && minor < 13)) { process.stderr.write( - `TurboLLM requires Node.js 22 or newer.\n` + + `TurboLLM requires Node.js 22.13.0 or newer (needs node:sqlite unflagged).\n` + `You are running Node.js ${process.versions.node}.\n` + `Please upgrade: https://nodejs.org\n`, ) diff --git a/turbollm/package.json b/turbollm/package.json index 84df569..2cab31c 100644 --- a/turbollm/package.json +++ b/turbollm/package.json @@ -58,6 +58,6 @@ "typescript": "^5.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.13.0" } } diff --git a/turbollm/src/bench/bench.ts b/turbollm/src/bench/bench.ts index b0bf231..7d4bf21 100644 --- a/turbollm/src/bench/bench.ts +++ b/turbollm/src/bench/bench.ts @@ -297,11 +297,18 @@ export class BenchRunner { // candidate — ctx, flash-attn, sampling, etc. `base` overrides the saved profile + global // defaults. Auto-tune then CHOOSES the KV-cache type (by reasoning about VRAM, below) and tunes // the offload (ngl / nCpuMoe) under it, so the result reflects settings they'll load with. - // Tune with speculative decoding OFF. Spec (NextN / MTP / draft) runs the model ~twice per - // step, so on a partially-offloaded model the extra CPU work craters t/s (measured ~2 t/s vs - // ~24 with it off) and a load-time VRAM probe can't see that runtime cost. The offload + KV - // choice here is for the base model; spec stays a separate load-time toggle, best left to the - // user for when a model fits fully on the GPU. + // Tune WITH the user's speculative-decoding setting (NextN / MTP / draft) active — do NOT force + // it off. Auto-tune's job is to fit the config the user will ACTUALLY load, and native MTP/NextN + // adds real VRAM (draft KV cache + head compute, ~460 MB measured on a 35B MoE at 200K ctx) that + // the offload search must reserve room for; searching with spec excluded would pick an offload + // that fits WITHOUT it, then load spec on top and eat the vramHeadroomMb margin (or spill to RAM, + // exactly what the projector note below guards against). Keeping spec on also means the winning + // profile PRESERVES the user's setting instead of silently resetting it to 'off' on Save. + // (This used to force spec off on the theory that it "runs the model ~twice, cratering t/s ~2 vs + // ~24" — but that number was measured with the --model-draft double-load bug since fixed; native + // MTP is now FASTER, so the premise no longer holds. Modes that genuinely load extra weights + // (the fork's `nextn`, Gemma `mtp`) tune slower but that reflects their true footprint, which is + // the honest thing to fit against.) // Tune WITH the vision projector (mmproj) exactly as the user has it configured (useMmproj / // mmprojGpu come from `resolved`, untouched here). A vision model always loads with mmproj // resident (see resolveProfile), so the offload search must account for its real VRAM @@ -309,7 +316,7 @@ export class BenchRunner { // the projector, then load the projector on top of that afterward, eating into the // vramHeadroomMb safety margin the search thought it had (or spilling outright). const resolved = resolveProfile(entry, sys, saved, base, defaults) - const baseProfile: LoadProfile = { ...resolved, speculative: 'off', mtpHeadPath: '', draftModelPath: '' } + const baseProfile: LoadProfile = resolved // User-configurable VRAM safety margin for the offload search (Settings → Engine). const headroomMb = this.store.snapshot().vramHeadroomMb diff --git a/turbollm/src/cli.ts b/turbollm/src/cli.ts index 17bf141..9ce3a33 100644 --- a/turbollm/src/cli.ts +++ b/turbollm/src/cli.ts @@ -49,10 +49,13 @@ try { } catch { /* keep fallback */ } // ── Node version guard ──────────────────────────────────────────────────────── -const nodeMajor = Number(process.versions.node.split('.')[0]) -if (nodeMajor < 22) { +// 22.13.0, not just 22 — that's when node:sqlite became available without the +// --experimental-sqlite flag (GitHub #40); on 22.5.0-22.12.x importing it bare +// throws ERR_UNKNOWN_BUILTIN_MODULE despite `node -v` reporting 22.x. +const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number) +if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 13)) { process.stderr.write( - `TurboLLM requires Node.js 22 or newer.\n` + + `TurboLLM requires Node.js 22.13.0 or newer.\n` + `You are running Node.js ${process.versions.node}.\n` + `Please upgrade: https://nodejs.org\n`, ) diff --git a/turbollm/src/engines/probe.test.ts b/turbollm/src/engines/probe.test.ts new file mode 100644 index 0000000..a93db84 --- /dev/null +++ b/turbollm/src/engines/probe.test.ts @@ -0,0 +1,36 @@ +// Capability-flag extraction from --help output (GitHub #43 regression). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { extractFlags } from './probe' + +test('captures a normally-documented flag', () => { + const help = `--cache-type-k TYPE KV cache data type for K\n--parallel N number of parallel sequences\n` + const flags = extractFlags(help) + assert.ok(flags.includes('--cache-type-k')) + assert.ok(flags.includes('--parallel')) +}) + +test('excludes flags only mentioned in a "has been removed" notice', () => { + const help = + `--draft, --draft-n, --draft-max N the argument has been removed. use --spec-draft-n-max or\n` + + ` --spec-ngram-mod-n-max\n` + + ` (env: LLAMA_ARG_DRAFT_MAX)\n` + + `--draft-min, --draft-n-min N the argument has been removed. use --spec-draft-n-min or --spec-ngram-mod-n-min\n` + + `--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 3)\n` + + `--spec-draft-n-min N minimum number of draft tokens to use for speculative decoding (default: 0)\n` + const flags = extractFlags(help) + assert.ok(!flags.includes('--draft-max'), '--draft-max should not be reported as supported') + assert.ok(!flags.includes('--draft-min'), '--draft-min should not be reported as supported') + assert.ok(!flags.includes('--draft')) + assert.ok(!flags.includes('--draft-n')) + assert.ok(flags.includes('--spec-draft-n-max'), 'the real successor flag must still be captured') + assert.ok(flags.includes('--spec-draft-n-min'), 'the real successor flag must still be captured') +}) + +test('a flag genuinely supported elsewhere is captured even if also named inside a removal notice', () => { + const help = + `--mtp-head FNAME the argument has been removed. use --model-draft instead\n` + + `--mtp-head FNAME path to the MTP head GGUF\n` + const flags = extractFlags(help) + assert.ok(flags.includes('--mtp-head')) +}) diff --git a/turbollm/src/engines/probe.ts b/turbollm/src/engines/probe.ts index 79abaa0..c393202 100644 --- a/turbollm/src/engines/probe.ts +++ b/turbollm/src/engines/probe.ts @@ -98,7 +98,7 @@ export async function probe(bin: string): Promise { let version = m ? m[1].trim() : trimLen(firstNonEmptyLine(v.out), 100) if (!version) version = 'unknown' - const flags = [...new Set(h.out.match(RE_FLAG) ?? [])].sort() + const flags = extractFlags(h.out) const kvTypes = flags.includes('--cache-type-k') ? [...KNOWN_KV] : ['f16'] if (combined.toLowerCase().includes('turbo')) kvTypes.push('turbo2', 'turbo3', 'turbo4') @@ -153,6 +153,24 @@ function osLabel(fmt: BinFormat): string { return fmt === 'pe' ? 'Windows' : fmt === 'macho' ? 'macOS' : 'Linux' } +/** Extracts the set of `--flag` tokens a `--help` dump documents as currently + * accepted. Matches per-line rather than over the whole blob: llama.cpp keeps + * printing removed flags in --help as "--draft-max N the argument has been + * removed. use --spec-draft-n-max or ..." (GitHub #43) — a naive whole-text + * regex reads that mention as proof --draft-max is still accepted, so + * TurboLLM passes it straight to the engine and it exits immediately on + * launch. Skip any line that says a flag was removed; a genuinely-supported + * successor flag (e.g. --spec-draft-n-max) still gets captured from its own + * dedicated --help entry elsewhere in the output. */ +export function extractFlags(helpText: string): string[] { + const flagSet = new Set() + for (const line of helpText.split('\n')) { + if (/\bhas been removed\b/i.test(line)) continue + for (const f of line.match(RE_FLAG) ?? []) flagSet.add(f) + } + return [...flagSet].sort() +} + function firstNonEmptyLine(s: string): string { for (const ln of s.split('\n')) { const t = ln.trim() diff --git a/turbollm/src/engines/registry.staleness.test.ts b/turbollm/src/engines/registry.staleness.test.ts new file mode 100644 index 0000000..632a785 --- /dev/null +++ b/turbollm/src/engines/registry.staleness.test.ts @@ -0,0 +1,31 @@ +// Auto-reprobe staleness detection (GitHub #43 follow-up): installing a fixed +// TurboLLM build does NOT retroactively fix an already-cached, mis-probed engine — +// ensureProbed() must recognize the stale signature and reprobe it automatically. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { isStaleCapabilities } from './registry' + +test('flags an engine that still carries the removed draft-max/draft-min flags', () => { + assert.equal(isStaleCapabilities(['--mtp-head', '--draft-max', '--draft-min']), true) +}) + +test('flags an engine carrying any one of the removed draft-family flags', () => { + assert.equal(isStaleCapabilities(['--draft']), true) + assert.equal(isStaleCapabilities(['--draft-n']), true) + assert.equal(isStaleCapabilities(['--draft-n-min']), true) +}) + +test('flags the pre-existing spec-type-without-enum staleness case', () => { + assert.equal(isStaleCapabilities(['--spec-type', '--mtp-head']), true) +}) + +test('does not flag a fresh, fully-probed modern engine', () => { + assert.equal( + isStaleCapabilities(['--mtp-head', '--spec-draft-n-max', '--spec-draft-n-min', '--spec-type', 'spec-type:draft-mtp']), + false, + ) +}) + +test('does not flag an engine with no speculative-decoding flags at all', () => { + assert.equal(isStaleCapabilities(['--parallel', '--cache-type-k']), false) +}) diff --git a/turbollm/src/engines/registry.ts b/turbollm/src/engines/registry.ts index 7f1d08b..5c7f035 100644 --- a/turbollm/src/engines/registry.ts +++ b/turbollm/src/engines/registry.ts @@ -312,11 +312,8 @@ export class Registry { * e.g. one with `--spec-type` but no captured `spec-type:` entries, so * NextN/MTP gating has the data it needs without a manual re-probe. */ async ensureProbed(): Promise { - const stale = (e: Engine) => - e.capabilities.flags.includes('--spec-type') && - !e.capabilities.flags.some((f) => f.startsWith('spec-type:')) for (const e of this.list().engines) { - if (e.version && !stale(e)) continue + if (e.version && !isStaleCapabilities(e.capabilities.flags)) continue try { await this.reprobe(e.id) } catch { @@ -325,3 +322,25 @@ export class Registry { } } } + +/** True when a saved `capabilities.flags` list was captured by a daemon predating a + * fix to how flags are read out of `--help` output, so a stale-but-installed engine + * keeps failing the same way after an app upgrade until someone happens to hit + * "re-probe" by hand. Checked at every startup (ensureProbed) so the fix in probe.ts + * actually reaches engines that were added before it existed — installing a new + * TurboLLM build alone does NOT retroactively fix already-cached capability data. */ +export function isStaleCapabilities(flags: string[]): boolean { + // Case 1 (pre-existing): `--spec-type` was captured but its accepted enum values + // (`spec-type:`) weren't — an older probe that predates that extraction. + if (flags.includes('--spec-type') && !flags.some((f) => f.startsWith('spec-type:'))) return true + // Case 2 (GitHub #43): llama.cpp removed --draft-max/--draft-min/--draft/--draft-n/ + // --draft-n-min, but --help still NAMES them inside a "this argument has been + // removed" notice. A probe from before that fix (see extractFlags in probe.ts) + // misread the mention as support and cached these as real flags — profileToArgs + // then passes them straight through and the engine exits immediately on launch. + // A fresh probe of a genuinely-old llama.cpp that still supports them for real is + // an idempotent no-op here (the flags stay present either way), so this is safe + // to check unconditionally. + if (['--draft-max', '--draft-min', '--draft', '--draft-n', '--draft-n-min'].some((f) => flags.includes(f))) return true + return false +} diff --git a/turbollm/src/models/profile.gen.test.ts b/turbollm/src/models/profile.gen.test.ts index eb626d9..7fe7c90 100644 --- a/turbollm/src/models/profile.gen.test.ts +++ b/turbollm/src/models/profile.gen.test.ts @@ -298,9 +298,58 @@ test('nextn mode also honors draftMax/draftMin, defaults to 16/1 when unset', () assert.equal(args[args.indexOf('--draft-min') + 1], '1') }) +// Regression: mainline llama.cpp's native NextN/MTP (--spec-type draft-mtp) uses the +// model's own built-in head — it takes ONLY --spec-type, no --model-draft. Passing +// --model-draft pointing at the SAME GGUF (the previous unconditional behavior) makes +// llama.cpp load a full second copy of the model into RAM — measured +35GB RAM on a +// 35B MoE model for a 54% SLOWER generation, with no error printed at all. +test('nextn mode on mainline (spec-type:draft-mtp only) omits --model-draft', () => { + const capMainline = { kvTypes: [], flags: ['--spec-type', '--model-draft', 'spec-type:draft-mtp'] } + const p = { ...base(), speculative: 'nextn' as const } + const args = profileToArgs(p, model({ nextnLayers: 1 }), capMainline) + assert.ok(args.includes('--spec-type')) + assert.equal(args[args.indexOf('--spec-type') + 1], 'draft-mtp') + assert.ok(!args.includes('--model-draft'), '--model-draft must not be passed for mainline draft-mtp') +}) + +// The TurboQuant fork's own `nextn` spec-type value is a different codebase that DOES +// want --model-draft pointing at the same file — only mainline's draft-mtp is exempted. +test('nextn mode on the TurboQuant fork (spec-type:nextn) still includes --model-draft', () => { + const capFork = { kvTypes: [], flags: ['--spec-type', '--model-draft', 'spec-type:nextn'] } + const p = { ...base(), speculative: 'nextn' as const } + const m = model({ nextnLayers: 1 }) + const args = profileToArgs(p, m, capFork) + assert.equal(args[args.indexOf('--spec-type') + 1], 'nextn') + assert.equal(args[args.indexOf('--model-draft') + 1], m.path) +}) + test('off mode emits no draft-max/draft-min flags', () => { const p = { ...base(), speculative: 'off' as const, draftMax: 4, draftMin: 0 } const args = profileToArgs(p, model(), caps) assert.ok(!args.includes('--draft-max')) assert.ok(!args.includes('--draft-min')) }) + +// Regression: llama.cpp removed --draft-max/--draft-min in favor of +// --spec-draft-n-max/--spec-draft-n-min (GitHub #43). A probe that correctly +// reports the old names as unsupported must fall back to the new ones instead +// of silently dropping draft-window control. +test('falls back to --spec-draft-n-max/--spec-draft-n-min when the engine only supports the new names', () => { + const capNewOnly = { kvTypes: [], flags: ['--mtp-head', '--spec-draft-n-max', '--spec-draft-n-min'] } + const p = { ...base(), speculative: 'mtp' as const, mtpHeadPath: '/models/gemma4-mtp.gguf', draftMax: 8, draftMin: 2 } + const args = profileToArgs(p, model(), capNewOnly) + assert.ok(!args.includes('--draft-max')) + assert.ok(!args.includes('--draft-min')) + assert.equal(args[args.indexOf('--spec-draft-n-max') + 1], '8') + assert.equal(args[args.indexOf('--spec-draft-n-min') + 1], '2') +}) + +test('prefers --draft-max/--draft-min when the engine reports both old and new names', () => { + const capBoth = { kvTypes: [], flags: ['--mtp-head', '--draft-max', '--draft-min', '--spec-draft-n-max', '--spec-draft-n-min'] } + const p = { ...base(), speculative: 'mtp' as const, mtpHeadPath: '/models/gemma4-mtp.gguf', draftMax: 8, draftMin: 2 } + const args = profileToArgs(p, model(), capBoth) + assert.equal(args[args.indexOf('--draft-max') + 1], '8') + assert.equal(args[args.indexOf('--draft-min') + 1], '2') + assert.ok(!args.includes('--spec-draft-n-max')) + assert.ok(!args.includes('--spec-draft-n-min')) +}) diff --git a/turbollm/src/models/profile.ts b/turbollm/src/models/profile.ts index 4d2f12a..7ad26b0 100644 --- a/turbollm/src/models/profile.ts +++ b/turbollm/src/models/profile.ts @@ -377,7 +377,7 @@ export function profileToArgs(p: LoadProfile, m: ModelEntry, caps: Capabilities, if (p.chatTemplateFile && has('--chat-template-file')) a.push('--chat-template-file', p.chatTemplateFile) // Speculative decoding (spec 05 §8). TurboQuant forks expose `--spec-type`: // mtp → Gemma-4 MTP: a separate gemma4_assistant GGUF via --mtp-head - // nextn → Qwen3 NextN: point --model-draft at the SAME main-model GGUF + // nextn → Qwen3 NextN: the model's OWN built-in head as the draft // draft → mainline: a separate small draft GGUF const specType = has('--spec-type') // Whether the engine accepts a given `--spec-type` value (captured by the probe @@ -388,12 +388,22 @@ export function profileToArgs(p: LoadProfile, m: ModelEntry, caps: Capabilities, if (specType) a.push('--spec-type', 'mtp') a.push('--mtp-head', p.mtpHeadPath) specActive = true - } else if (p.speculative === 'nextn' && specType && has('--model-draft')) { - // Qwen3 NextN drives the model's OWN built-in head as the draft. The fork - // names that spec-type `nextn`; mainline llama.cpp names the same mechanism - // `draft-mtp`. Use whichever the engine accepts — skip if neither. + } else if (p.speculative === 'nextn' && specType) { + // Qwen3 NextN speculative decoding uses the model's own built-in NextN/MTP + // head as the draft. Mainline llama.cpp names this spec-type `draft-mtp` and + // takes ONLY --spec-type — no --model-draft. Passing --model-draft pointing + // at the SAME GGUF (as this used to do unconditionally) makes llama.cpp load + // a full second copy of the model into RAM: measured +35GB RAM on a 35B MoE + // model (24GB → 59GB) for a 54% SLOWER generation, with no error printed — + // a silent, severe regression (GitHub VRAM report). The TurboQuant fork's own + // `nextn` spec-type value is a different codebase that DOES want --model-draft + // pointing at the same file (verified when this branch was first written) — + // only mainline's draft-mtp is exempted here. const nextnVal = ['nextn', 'draft-mtp'].find((v) => specAccepts(v)) - if (nextnVal) { + if (nextnVal === 'draft-mtp') { + a.push('--spec-type', nextnVal) + specActive = true + } else if (nextnVal === 'nextn' && has('--model-draft')) { a.push('--spec-type', nextnVal, '--model-draft', m.path) specActive = true } @@ -408,8 +418,16 @@ export function profileToArgs(p: LoadProfile, m: ModelEntry, caps: Capabilities, // specific to how the draft is produced — applies to all three modes above, matching // e.g. LM Studio's MTP "max/min draft tokens" controls. Absent -> the previous // hardcoded 16/1 defaults (unchanged behavior for existing profiles). - if (specActive && has('--draft-max')) a.push('--draft-max', String(p.draftMax ?? 16)) - if (specActive && has('--draft-min')) a.push('--draft-min', String(p.draftMin ?? 1)) + // llama.cpp removed --draft-max/--draft-min (GitHub #43); the successors are + // --spec-draft-n-max/--spec-draft-n-min. Prefer the old names when the probe + // confirms them (keeps unprobed/graceful-degrade and older-engine behavior + // unchanged) and fall back to the new names when it confirms only those. + if (specActive) { + if (has('--draft-max')) a.push('--draft-max', String(p.draftMax ?? 16)) + else if (has('--spec-draft-n-max')) a.push('--spec-draft-n-max', String(p.draftMax ?? 16)) + if (has('--draft-min')) a.push('--draft-min', String(p.draftMin ?? 1)) + else if (has('--spec-draft-n-min')) a.push('--spec-draft-n-min', String(p.draftMin ?? 1)) + } // Sampling startup defaults — become the engine's per-request defaults; can still // be overridden in the chat request body. Only emitted when non-default to avoid // cluttering the startup command. llama-server built-in defaults match these values. From 06efad6934eb6cd5fb92c92f93d5711870250529 Mon Sep 17 00:00:00 2001 From: "bramha.dev" Date: Mon, 6 Jul 2026 10:44:53 +0530 Subject: [PATCH 2/4] feat(developer): surface all launch targets in Connect a CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Developer → Connect a CLI section still showed only the old manual env-var / config-file setup and was missing openclaw and hermes entirely, even though `turbollm launch ` already supports them. - add openclaw and hermes cards (were hitting the default branch → empty steps) - lead opencode/kilo/openclaw/hermes with the one-command `turbollm launch ` step, manual config kept as a fallback - thread the model key through so openclaw/hermes snippets key the provider by the stable model key, matching what cli-launch.ts actually writes --- turbollm/src/api/connect-snippets.test.ts | 49 ++++++++++++++ turbollm/src/api/routes.ts | 70 +++++++++++++++++++- turbollm/web/src/screens/DeveloperScreen.tsx | 10 +-- 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 turbollm/src/api/connect-snippets.test.ts diff --git a/turbollm/src/api/connect-snippets.test.ts b/turbollm/src/api/connect-snippets.test.ts new file mode 100644 index 0000000..17f6eb2 --- /dev/null +++ b/turbollm/src/api/connect-snippets.test.ts @@ -0,0 +1,49 @@ +// Developer screen "Connect a CLI" snippets. Regression: opencode/kilo/openclaw/hermes +// must lead with the `turbollm launch ` one-command flow — not just the old +// manual config-file/env-var instructions — now that the launch command supports them. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildConnectSnippets } from './routes' + +const BASE = 'http://127.0.0.1:6996' + +test('claude-code leads with the one-command launch step', () => { + const r = buildConnectSnippets('claude-code', BASE, 'key', 'Model') + assert.equal(r.steps[0].snippet, 'turbollm launch claude') +}) + +test('opencode leads with the one-command launch step, config snippet still present', () => { + const r = buildConnectSnippets('opencode', BASE, 'key', 'Model') + assert.equal(r.steps[0].snippet, 'turbollm launch opencode') + assert.ok(r.steps.some((s) => s.snippet.includes('opencode.json') || s.lang === 'json')) +}) + +test('kilo leads with the one-command launch step, config snippet still present', () => { + const r = buildConnectSnippets('kilo', BASE, 'key', 'Model') + assert.equal(r.steps[0].snippet, 'turbollm launch kilo') + assert.ok(r.steps.some((s) => s.lang === 'jsonc')) +}) + +test('openclaw is a known CLI with a one-command launch step and a config fallback', () => { + const r = buildConnectSnippets('openclaw', BASE, 'key', 'Model', 'model|q4|1') + assert.equal(r.steps[0].snippet, 'turbollm launch openclaw') + const config = r.steps.find((s) => s.lang === 'json') + assert.ok(config, 'expected a JSON config fallback step') + assert.ok(config!.snippet.includes('model|q4|1'), 'the fallback should key the provider by the model KEY, not the display name') +}) + +test('hermes is a known CLI with a one-command launch step and a config-set fallback', () => { + const r = buildConnectSnippets('hermes', BASE, 'key', 'Model', 'model|q4|1') + assert.equal(r.steps[0].snippet, 'turbollm launch hermes') + assert.ok(r.steps.some((s) => s.snippet.includes('hermes config set model.default model|q4|1'))) +}) + +test('qwen (not a turbollm launch target) has no one-command step', () => { + const r = buildConnectSnippets('qwen', BASE, 'key', 'Model') + assert.ok(!r.steps.some((s) => s.snippet.startsWith('turbollm launch'))) +}) + +test('unknown cli returns empty steps', () => { + const r = buildConnectSnippets('nonexistent', BASE, 'key', 'Model') + assert.deepEqual(r.steps, []) +}) diff --git a/turbollm/src/api/routes.ts b/turbollm/src/api/routes.ts index 53e6076..3035743 100644 --- a/turbollm/src/api/routes.ts +++ b/turbollm/src/api/routes.ts @@ -1890,6 +1890,7 @@ export function registerApi(app: Hono, d: Deps): void { const base = `http://${host}:${port}` const ms = d.manager.status() const modelName = ms.state === 'running' ? (ms.model?.name ?? 'local') : 'local' + const modelKey = ms.state === 'running' ? (ms.model?.key ?? modelName) : modelName let apiKey = 'not-needed-on-localhost' if (lanBind) { @@ -1910,7 +1911,7 @@ export function registerApi(app: Hono, d: Deps): void { apiKey = full } - return c.json(buildConnectSnippets(cli, base, apiKey, modelName)) + return c.json(buildConnectSnippets(cli, base, apiKey, modelName, modelKey)) }) } @@ -2268,7 +2269,7 @@ function getLanIp(): string { type ConnectStep = { label: string; snippet: string; lang: string } type ConnectResult = { cli: string; title: string; steps: ConnectStep[] } -function buildConnectSnippets(cli: string, base: string, apiKey: string, modelName: string): ConnectResult { +export function buildConnectSnippets(cli: string, base: string, apiKey: string, modelName: string, modelKey: string = modelName): ConnectResult { switch (cli) { case 'claude-code': return { @@ -2297,6 +2298,11 @@ function buildConnectSnippets(cli: string, base: string, apiKey: string, modelNa cli, title: 'opencode', steps: [ + { + label: 'Quickest — one command (ships with TurboLLM)', + snippet: `turbollm launch opencode`, + lang: 'bash', + }, { label: 'Merge into ~/.config/opencode/opencode.json', snippet: JSON.stringify( @@ -2324,6 +2330,11 @@ function buildConnectSnippets(cli: string, base: string, apiKey: string, modelNa cli, title: 'Kilo Code', steps: [ + { + label: 'Quickest — one command (ships with TurboLLM)', + snippet: `turbollm launch kilo`, + lang: 'bash', + }, { label: 'Add to ~/.config/kilo/kilo.jsonc providers array', snippet: JSON.stringify( @@ -2342,6 +2353,61 @@ function buildConnectSnippets(cli: string, base: string, apiKey: string, modelNa }, ], } + case 'openclaw': + return { + cli, + title: 'openclaw', + steps: [ + { + label: 'Quickest — one command (ships with TurboLLM)', + snippet: `turbollm launch openclaw`, + lang: 'bash', + }, + { + label: 'Merge into ~/.config/openclaw/openclaw.json', + snippet: JSON.stringify( + { + models: { + providers: { + turbollm: { + baseUrl: `${base}/v1`, + apiKey: apiKey !== 'not-needed-on-localhost' ? apiKey : 'not-required', + api: 'openai-completions', + models: [{ id: modelKey, name: modelName }], + }, + }, + }, + agents: { defaults: { model: { primary: `turbollm/${modelKey}` } } }, + }, + null, + 2, + ), + lang: 'json', + }, + ], + } + case 'hermes': + return { + cli, + title: 'Hermes Agent', + steps: [ + { + label: 'Quickest — one command (ships with TurboLLM)', + snippet: `turbollm launch hermes`, + lang: 'bash', + }, + { + label: 'PowerShell', + snippet: `hermes config set model.provider custom; hermes config set model.base_url ${base}/v1; hermes config set model.default ${modelKey}`, + lang: 'powershell', + }, + { + label: 'bash / zsh', + snippet: `hermes config set model.provider custom && hermes config set model.base_url ${base}/v1 && hermes config set model.default ${modelKey}`, + lang: 'bash', + }, + ], + } case 'qwen': return { cli, diff --git a/turbollm/web/src/screens/DeveloperScreen.tsx b/turbollm/web/src/screens/DeveloperScreen.tsx index f6b7ec0..de57985 100644 --- a/turbollm/web/src/screens/DeveloperScreen.tsx +++ b/turbollm/web/src/screens/DeveloperScreen.tsx @@ -25,10 +25,12 @@ const PUBLIC_APIS = [ ] as const const CLI_LIST = [ - { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic-compatible endpoint — the hero demo' }, - { id: 'opencode', name: 'opencode', desc: 'OpenAI-compatible, AI SDK provider config' }, - { id: 'kilo', name: 'Kilo Code', desc: 'OpenAI-compatible, kilo.jsonc provider entry' }, - { id: 'qwen', name: 'Qwen Code', desc: 'OpenAI-compatible (OPENAI_BASE_URL)' }, + { id: 'claude-code', name: 'Claude Code', desc: 'turbollm launch claude — Anthropic-compatible endpoint' }, + { id: 'opencode', name: 'opencode', desc: 'turbollm launch opencode — AI SDK provider config' }, + { id: 'kilo', name: 'Kilo Code', desc: 'turbollm launch kilo — kilo.jsonc provider entry' }, + { id: 'openclaw', name: 'openclaw', desc: 'turbollm launch openclaw — provider + default model' }, + { id: 'hermes', name: 'Hermes Agent', desc: 'turbollm launch hermes — hermes config set' }, + { id: 'qwen', name: 'Qwen Code', desc: 'OpenAI-compatible (OPENAI_BASE_URL)' }, ] export function DeveloperScreen() { From 42f6f139c942b5ee06d590d0836c65fcfa365f30 Mon Sep 17 00:00:00 2001 From: "bramha.dev" Date: Mon, 6 Jul 2026 10:53:42 +0530 Subject: [PATCH 3/4] fix(developer): quote model key in hermes connect snippets A real model key contains spaces and | (e.g. 'name|Q5_K_M|123'); unquoted in the copy-paste hermes snippet it would split into multiple args / a pipe in the shell. The actual 'turbollm launch hermes' path was already safe (spawn without a shell). Flagged by the v1.7.2 pre-release review (M1). --- turbollm/src/api/connect-snippets.test.ts | 3 ++- turbollm/src/api/routes.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/turbollm/src/api/connect-snippets.test.ts b/turbollm/src/api/connect-snippets.test.ts index 17f6eb2..d70b0f4 100644 --- a/turbollm/src/api/connect-snippets.test.ts +++ b/turbollm/src/api/connect-snippets.test.ts @@ -35,7 +35,8 @@ test('openclaw is a known CLI with a one-command launch step and a config fallba test('hermes is a known CLI with a one-command launch step and a config-set fallback', () => { const r = buildConnectSnippets('hermes', BASE, 'key', 'Model', 'model|q4|1') assert.equal(r.steps[0].snippet, 'turbollm launch hermes') - assert.ok(r.steps.some((s) => s.snippet.includes('hermes config set model.default model|q4|1'))) + // modelKey is quoted in the snippet — a real key has spaces and `|`, both shell-breaking unquoted. + assert.ok(r.steps.some((s) => s.snippet.includes('hermes config set model.default "model|q4|1"'))) }) test('qwen (not a turbollm launch target) has no one-command step', () => { diff --git a/turbollm/src/api/routes.ts b/turbollm/src/api/routes.ts index 3035743..a2d17a2 100644 --- a/turbollm/src/api/routes.ts +++ b/turbollm/src/api/routes.ts @@ -2398,12 +2398,12 @@ export function buildConnectSnippets(cli: string, base: string, apiKey: string, }, { label: 'PowerShell', - snippet: `hermes config set model.provider custom; hermes config set model.base_url ${base}/v1; hermes config set model.default ${modelKey}`, + snippet: `hermes config set model.provider custom; hermes config set model.base_url ${base}/v1; hermes config set model.default "${modelKey}"`, lang: 'powershell', }, { label: 'bash / zsh', - snippet: `hermes config set model.provider custom && hermes config set model.base_url ${base}/v1 && hermes config set model.default ${modelKey}`, + snippet: `hermes config set model.provider custom && hermes config set model.base_url ${base}/v1 && hermes config set model.default "${modelKey}"`, lang: 'bash', }, ], From 4e9c1c4dad9eac6795c1973d6951dcdc7eeac8ad Mon Sep 17 00:00:00 2001 From: "bramha.dev" Date: Mon, 6 Jul 2026 10:56:50 +0530 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.7.2=20=E2=80=94=20Wind?= =?UTF-8?q?ows=20install=20fix,=20MTP=20load=20+=20RAM=20fix,=20auto-tune?= =?UTF-8?q?=20MTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- turbollm/CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ turbollm/package.json | 2 +- turbollm/web/src/lib/personas.ts | 1 + 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2a15ba3..bb687c4 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,7 @@ Use `--config ` to point at an alternate config (its directory becomes the ## Requirements -- **Node.js 22 or newer** — enforced at startup with a clear message. +- **Node.js 22.13.0 or newer** — enforced at startup with a clear message. - **Windows, macOS, or Linux.** - A GPU is recommended but **not required** — a CPU build is provisioned as a fallback. - On Windows, the first time the auto-downloaded `llama-server` runs, SmartScreen/Defender may @@ -492,7 +492,7 @@ Prefer Open WebUI's chat breadth? It works great pointed at TurboLLM's OpenAI en ## Troubleshooting -- **`TurboLLM requires Node.js 22 or newer`** — upgrade Node: . +- **`TurboLLM requires Node.js 22.13.0 or newer`** — upgrade Node: . - **Model won't load / OOM** — pick a smaller quant (the VRAM verdict warns you), lower GPU offload, or close other GPU apps. Failures surface in the Engines screen with the engine log. - **Windows Defender / SmartScreen prompt** — that's the upstream `llama-server` binary on @@ -519,7 +519,7 @@ node dist/cli.js --port 6996 Frontend hot-reload: `cd web && npm run dev` (proxies `/api` and `/v1` to the daemon on :6996). -**Stack:** Node ≥22 · TypeScript · Hono · `node:sqlite` · tsup — and a React 19 + Tailwind v4 + +**Stack:** Node ≥22.13 · TypeScript · Hono · `node:sqlite` · tsup — and a React 19 + Tailwind v4 + shadcn/ui frontend. One TypeScript codebase, shipped as an npm package. --- diff --git a/turbollm/CHANGELOG.md b/turbollm/CHANGELOG.md index d4970d1..212d35c 100644 --- a/turbollm/CHANGELOG.md +++ b/turbollm/CHANGELOG.md @@ -25,6 +25,36 @@ published version on npm has a matching `vX.Y.Z` tag in git. _Nothing yet._ +## [1.7.2] - 2026-07-06 + +**MTP / speculative-decoding models load and run correctly, the Windows install crash is fixed, and auto-tune now respects your MTP setting.** + +### Added +- **Developer → Connect a CLI now covers every launch target.** `openclaw` and `hermes` have their + own cards (previously missing), and opencode / kilo / openclaw / hermes each lead with the + one-command `turbollm launch ` setup, with the manual config kept as a fallback. + +### Changed +- **Auto-tune respects your MTP setting.** Running auto-tune on an MTP / NextN model now tunes with + speculative decoding active — so the chosen GPU/CPU offload leaves room for its real memory + footprint — and keeps MTP enabled in the saved profile instead of silently turning it off. + +### Fixed +- **Install no longer crashes on Node 22.5–22.12.** TurboLLM uses Node's built-in SQLite, which only + works without an experimental flag on **Node 22.13.0+**. Below that, `npx turbollm` crashed with an + obscure `node:sqlite` error; it now requires 22.13.0+ and says so clearly. ([#40](https://github.com/mohitsoni48/TurboLLM/issues/40)) +- **MTP / speculative-decoding models load again.** A newer llama.cpp renamed its speculative-decoding + flags; TurboLLM kept passing the removed ones, so any MTP model failed to load. It now detects and + uses the current flags, and auto-corrects engines that were already installed. ([#43](https://github.com/mohitsoni48/TurboLLM/issues/43)) +- **MTP no longer balloons system RAM.** Native MTP was loading a second full copy of the model into + RAM (tens of GB on large models) and running *slower*; it now uses only a small amount of extra + memory and runs faster, as intended. + +### Discord +- Fixed: MTP / speculative-decoding models load again. +- New: auto-tune keeps your MTP setting on (and tunes with it active) instead of quietly switching it off. +- New: the Developer → Connect a CLI screen now shows one-command setup for opencode, kilo, openclaw, and hermes too. + ## [1.7.1] - 2026-07-05 **Local coding CLIs: model pinning restored, `/model` now lists your local models, and four new launch targets.** diff --git a/turbollm/package.json b/turbollm/package.json index 2cab31c..701e225 100644 --- a/turbollm/package.json +++ b/turbollm/package.json @@ -1,6 +1,6 @@ { "name": "turbollm", - "version": "1.7.1", + "version": "1.7.2", "description": "TurboLLM — local LLM platform: run any inference engine auto-tuned to your GPU, with a web UI and OpenAI/Anthropic-compatible API. Point Claude Code at your own machine in one command.", "license": "FSL-1.1-ALv2", "author": "Mohit Soni", diff --git a/turbollm/web/src/lib/personas.ts b/turbollm/web/src/lib/personas.ts index 7e7c3c1..0561b15 100644 --- a/turbollm/web/src/lib/personas.ts +++ b/turbollm/web/src/lib/personas.ts @@ -100,6 +100,7 @@ const TURBOLLM_KNOWLEDGE = 'One real prefill + generation run. Bench prompt: `min(50,000 tokens, ctx × 75%)`. Per-test cap: 3 minutes. Stop/restart/load cancel a running auto-tune. Records prefill t/s, generation t/s, TTFT ms, and VRAM delta.\n\n' + '**Phase 4 — Recommended sampling extraction**:\n' + 'Checks the repo for a structured `params`/`generation_config.json` sidecar first (some quantizers, e.g. unsloth, publish one with exact recommended values — used as-is, no parsing needed). Otherwise fetches the HuggingFace model card and extracts recommended temperature, top_k, top_p, min_p (falling back to the base model\'s card if the quant card doesn\'t have sampling info, then an LLM-read fallback on unusual phrasing). Prefills the Sampling section of the load profile.\n\n' + + 'Speculative decoding (MTP/NextN/draft) stays ACTIVE during the whole tune — the offload/KV search fits the config you actually load, leaving room for spec\'s real VRAM — and Save preserves your speculative setting rather than resetting it to off.\n\n' + 'Results dialog: Save applies the winner config to the model profile (tunedBy: "bench"). "Download run log" checkbox (default checked) downloads a JSON log of every probe (timestamps, parameters, outcomes, VRAM readings, and the winner).\n\n' + '## Load Profile Parameters\n\n' +