Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ Use `--config <file>` to point at an alternate config (its directory becomes the

## Requirements

- **Node.js 22 or newer** — enforced at startup with a clear message. <https://nodejs.org>
- **Node.js 22.13.0 or newer** — enforced at startup with a clear message. <https://nodejs.org>
- **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
Expand Down Expand Up @@ -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: <https://nodejs.org>.
- **`TurboLLM requires Node.js 22.13.0 or newer`** — upgrade Node: <https://nodejs.org>.
- **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
Expand All @@ -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.

---
Expand Down
30 changes: 30 additions & 0 deletions turbollm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cli>` 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.**
Expand Down
6 changes: 3 additions & 3 deletions turbollm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ Use `--config <file>` to point at an alternate config (its directory becomes the

## Requirements

- **Node.js 22 or newer** — enforced at startup with a clear message. <https://nodejs.org>
- **Node.js 22.13.0 or newer** — enforced at startup with a clear message. <https://nodejs.org>
- **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
Expand Down Expand Up @@ -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: <https://nodejs.org>.
- **`TurboLLM requires Node.js 22.13.0 or newer`** — upgrade Node: <https://nodejs.org>.
- **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
Expand All @@ -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.

---
Expand Down
12 changes: 8 additions & 4 deletions turbollm/bin/turbollm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
)
Expand Down
4 changes: 2 additions & 2 deletions turbollm/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -58,6 +58,6 @@
"typescript": "^5.7.0"
},
"engines": {
"node": ">=22"
"node": ">=22.13.0"
}
}
50 changes: 50 additions & 0 deletions turbollm/src/api/connect-snippets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Developer screen "Connect a CLI" snippets. Regression: opencode/kilo/openclaw/hermes
// must lead with the `turbollm launch <cli>` 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')
// 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', () => {
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, [])
})
70 changes: 68 additions & 2 deletions turbollm/src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
})
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
19 changes: 13 additions & 6 deletions turbollm/src/bench/bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,19 +297,26 @@ 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
// footprint (~1-2 GB) — searching with it excluded would pick an offload that fits WITHOUT
// 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

Expand Down
9 changes: 6 additions & 3 deletions turbollm/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
)
Expand Down
36 changes: 36 additions & 0 deletions turbollm/src/engines/probe.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
})
Loading
Loading