Skip to content

Commit 240db38

Browse files
committed
fix(cookbook): harden local llama-server launch commands
1 parent 2128f5c commit 240db38

6 files changed

Lines changed: 89 additions & 17 deletions

File tree

routes/cookbook_routes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,6 +1487,10 @@ async def model_serve(request: Request, req: ServeRequest):
14871487
# shell resolves the bundled python3/hf, mirroring the download flow.
14881488
if not remote:
14891489
runner_lines.append(_local_tooling_path_export(sys.executable))
1490+
if local_windows:
1491+
# Detached Git Bash runs do not always inherit recently edited
1492+
# user PATH entries from the already-running Odysseus process.
1493+
runner_lines.append('export PATH="$HOME/bin:$HOME/llama.cpp/build-cuda/bin/Release:$HOME/llama.cpp/build/bin/Release:$HOME/llama.cpp/build/bin/Debug:$HOME/llama.cpp/build/bin:$PATH"')
14901494
runner_lines.append("export FLASHINFER_DISABLE_VERSION_CHECK=1")
14911495
if req.hf_token:
14921496
runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'")

static/js/cookbook.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,14 +2624,14 @@ function _renderRecipes() {
26242624
const isLocal = !s.host || s.host.toLowerCase() === 'local';
26252625
if (isLocal) {
26262626
s.host = '';
2627-
s.platform = _envState.hostPlatform || _envState.platform || '';
2627+
s.platform = _envState.hostPlatform || '';
26282628
if (_localSeen) return false;
26292629
_localSeen = true;
26302630
}
26312631
return true;
26322632
});
26332633
if (!_localSeen) {
2634-
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub', platform: _envState.hostPlatform || _envState.platform || '' });
2634+
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub', platform: _envState.hostPlatform || '' });
26352635
}
26362636
if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) {
26372637
_es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });

static/js/cookbookRunning.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,7 +1677,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
16771677
|| _envState.servers.find(s => s.host === _host) || {};
16781678
const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local');
16791679
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
1680-
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || '');
1680+
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');
16811681
const _replaceTaskId = fields?._replaceTaskId || '';
16821682
if (_replaceTaskId) {
16831683
try {
@@ -1692,7 +1692,6 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
16921692
}
16931693
} catch {}
16941694
}
1695-
16961695
// Replace any serve already targeting this same host:port — you can't run two
16971696
// servers on one port, so re-serving (or retrying) should stop & remove the
16981697
// old one instead of leaving a dead duplicate behind. (The retry buttons

static/js/cookbookServe.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ function _selectedServeTarget(panel) {
527527
env: server?.env || '',
528528
port: host ? (server?.port || _getPort(host) || '') : '',
529529
venv,
530-
platform: server?.platform || _envState.platform || '',
530+
platform: host ? (server?.platform || '') : (_envState.hostPlatform || ''),
531531
label,
532532
};
533533
}
@@ -658,6 +658,12 @@ function _selectedGgufSizeGb(model, relPath) {
658658
return bytes / (1024 ** 3);
659659
}
660660

661+
function _projectorGgufFiles(model) {
662+
return _ggufFilesForModel(model)
663+
.filter(f => (f.role || '') === 'projector' || /(^|\/)mmproj[^/]*\.gguf$/i.test(f.rel_path || f.name || ''))
664+
.sort((a, b) => String(a.rel_path || a.name || '').localeCompare(String(b.rel_path || b.name || '')));
665+
}
666+
661667
function _ggufFileLabel(file) {
662668
const base = (file.name || file.rel_path || '').split('/').pop();
663669
const size = _formatGgufSize(file.size_bytes);
@@ -1198,6 +1204,7 @@ function _rerenderCachedModels() {
11981204
panelHtml += `<div class="hwfit-serve-warn" style="margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);display:flex;gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>${_warnText}</span></div>`;
11991205
}
12001206
panelHtml += `<div class="hwfit-serve-preset-row">${_slotsHtml}</div>`;
1207+
panelHtml += `<div class="hwfit-serve-vision-warn" style="display:none;margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>Vision is enabled, but no mmproj GGUF projector was found in the cached model scan. Download an mmproj-*.gguf for this model, then refresh the cached model list before launching.</span></div>`;
12011208
// Row 1: Engine + Server + Env
12021209
panelHtml += `<div class="hwfit-serve-row">`;
12031210
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
@@ -1543,11 +1550,11 @@ function _rerenderCachedModels() {
15431550
: m.is_local_dir && m.path
15441551
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
15451552
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
1546-
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
1547-
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
1548-
// present (downloaded alongside the model). Empty if none → cmd omits it.
1549-
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
1550-
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
1553+
// Vision: use the scanned projector (CLIP/mmproj) file when present.
1554+
// Keeping this as a printf path avoids generating a command substitution
1555+
// that the backend serve-command validator must reject as unsafe.
1556+
const selectedProjector = _projectorGgufFiles(m)[0];
1557+
f._mmproj_path = selectedProjector ? _selectedGgufExpr(m, repo, selectedProjector.rel_path) : '';
15511558
}
15521559
if (f.reasoning_parser) {
15531560
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
@@ -1563,6 +1570,10 @@ function _rerenderCachedModels() {
15631570
}
15641571
let cmd = _buildServeCmd(f, serveModel, backend);
15651572
if (f.extra && f.extra.trim()) cmd += ' ' + f.extra.trim();
1573+
const missingVisionProjector = backend === 'llamacpp' && !!f.vision && !f._mmproj_path;
1574+
panel._visionMissingProjector = missingVisionProjector;
1575+
const _visionWarn = panel.querySelector('.hwfit-serve-vision-warn');
1576+
if (_visionWarn) _visionWarn.style.display = missingVisionProjector ? 'flex' : 'none';
15661577
const _ce2 = panel.querySelector('.hwfit-serve-cmd'); _ce2.value = _formatServeCmdPreview(cmd); _ce2.style.height = 'auto'; _ce2.style.height = _ce2.scrollHeight + 'px';
15671578
panel._cmd = cmd;
15681579
panel._host = f.host || '';
@@ -2938,12 +2949,16 @@ function _rerenderCachedModels() {
29382949
});
29392950
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
29402951
const launchTarget = _selectedServeTarget(panel);
2952+
if (serveState.backend === 'llamacpp' && serveState.vision && !/(?:^|\s)(?:--mmproj|--clip_model_path)\b/.test(launchCmd)) {
2953+
_restoreLaunchBtn();
2954+
uiModule.showToast('Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.', 8000);
2955+
return;
2956+
}
29412957
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
29422958
_restoreLaunchBtn();
29432959
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
29442960
return;
29452961
}
2946-
29472962
// Pre-launch: check our own task list for a serve already running
29482963
// on this host. Offer to stop+launch as the default action — the
29492964
// SSH-based port probe below is more thorough but it can miss

tests/test_cookbook_cpu_only_serve.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,27 @@ def test_local_windows_platform_comes_from_backend_host_state():
9696
assert "hostOrTask === 'local'" in text
9797
assert "if (hostOrTask === 'local') return _envState.hostPlatform || '';" in text
9898
assert "return _envState.hostPlatform || _envState.platform || ''" not in text
99+
assert "s.platform = _envState.hostPlatform || '';" in text
100+
assert "platform: _envState.hostPlatform || ''" in text
101+
assert "s.platform = _envState.hostPlatform || _envState.platform || '';" not in text
102+
assert "platform: _envState.hostPlatform || _envState.platform || ''" not in text
99103
assert 'return "windows" if IS_WINDOWS else ""' in routes
100104
assert 'env["hostPlatform"] = _client_host_platform()' in routes
101105
assert "return _state_for_client({})" in routes
102106
assert 'env.pop("hostPlatform", None)' in routes
103107
assert "delete env.hostPlatform;" in running
104108

105109

110+
def test_local_serve_payload_ignores_stale_env_platform():
111+
serve = SERVE_SRC.read_text(encoding="utf-8")
112+
running = (SRC.parent / "cookbookRunning.js").read_text(encoding="utf-8")
113+
114+
assert "platform: host ? (server?.platform || '') : (_envState.hostPlatform || '')," in serve
115+
assert "platform: server?.platform || _envState.platform || ''" not in serve
116+
assert "const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');" in running
117+
assert "const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || '');" not in running
118+
119+
106120
def test_local_windows_llamacpp_prefers_native_llama_server():
107121
text = SRC.read_text(encoding="utf-8")
108122
helpers = (ROOT / "routes/cookbook_helpers.py").read_text(encoding="utf-8")
@@ -120,3 +134,36 @@ def test_local_windows_llama_server_skips_source_bootstrap():
120134

121135
assert 'local_windows_llama_cmd = local_windows and ("llama_cpp" in req.cmd or "llama-server" in req.cmd)' in routes
122136
assert 'if ("llama_cpp" in req.cmd or "llama-server" in req.cmd) and not local_windows_llama_cmd:' in routes
137+
138+
139+
def test_local_windows_llama_server_path_includes_user_wrapper_and_cuda_builds():
140+
routes = (ROOT / "routes/cookbook_routes.py").read_text(encoding="utf-8")
141+
142+
assert 'if local_windows:' in routes
143+
assert (
144+
'export PATH="$HOME/bin:$HOME/llama.cpp/build-cuda/bin/Release:'
145+
'$HOME/llama.cpp/build/bin/Release:$HOME/llama.cpp/build/bin/Debug:'
146+
'$HOME/llama.cpp/build/bin:$PATH"'
147+
) in routes
148+
149+
150+
def test_serve_panel_keeps_row_markup_and_launch_cmd_assignment_executable():
151+
text = SERVE_SRC.read_text(encoding="utf-8").replace("\r\n", "\n")
152+
153+
assert '// Row 1: Engine + Server + Env panelHtml +=' not in text
154+
assert "px'; panel._cmd = cmd;" not in text
155+
assert '// Row 1: Engine + Server + Env\n panelHtml += `<div class="hwfit-serve-row">`;' in text
156+
assert "px';\n panel._cmd = cmd;" in text
157+
158+
159+
def test_llamacpp_vision_uses_scanned_projector_instead_of_runtime_find():
160+
text = SERVE_SRC.read_text(encoding="utf-8")
161+
162+
assert "function _projectorGgufFiles(model)" in text
163+
assert "const selectedProjector = _projectorGgufFiles(m)[0];" in text
164+
assert "f._mmproj_path = selectedProjector ? _selectedGgufExpr(m, repo, selectedProjector.rel_path) : '';" in text
165+
assert "const missingVisionProjector = backend === 'llamacpp' && !!f.vision && !f._mmproj_path;" in text
166+
assert "hwfit-serve-vision-warn" in text
167+
assert "!/(?:^|\\s)(?:--mmproj|--clip_model_path)\\b/.test(launchCmd)" in text
168+
assert "no mmproj projector is in the launch command" in text
169+
assert "find ${_vsearchdir} -iname 'mmproj*.gguf'" not in text

tests/test_cookbook_helpers.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,6 @@ def test_pip_install_attempt_failure_propagates_real_exit_code():
419419
"""Run the generated snippet against a deliberately broken pip install
420420
to confirm the subshell exits with pip's non-zero status."""
421421
snippet = _pip_install_attempt("python3 -m pip install __nonexistent_package_12345__")
422-
if sys.platform == "win32":
423-
snippet = snippet.replace("$", "\\$")
424422
result = subprocess.run(
425423
["bash", "-c", snippet],
426424
capture_output=True,
@@ -433,8 +431,6 @@ def test_pip_install_attempt_failure_propagates_real_exit_code():
433431
def test_pip_install_attempt_success_exits_zero():
434432
"""When pip succeeds, the subshell should exit 0."""
435433
snippet = _pip_install_attempt("python3 -c 'pass'")
436-
if sys.platform == "win32":
437-
snippet = snippet.replace("$", "\\$")
438434
result = subprocess.run(
439435
["bash", "-c", snippet],
440436
capture_output=True,
@@ -447,8 +443,6 @@ def test_pip_install_attempt_success_exits_zero():
447443
def test_pip_install_attempt_surfaces_stderr_on_failure():
448444
"""On failure, the last 5 lines of pip output should appear in stdout."""
449445
snippet = _pip_install_attempt("python3 -m pip install __nonexistent_package_12345__")
450-
if sys.platform == "win32":
451-
snippet = snippet.replace("$", "\\$")
452446
result = subprocess.run(
453447
["bash", "-c", snippet],
454448
capture_output=True,
@@ -557,6 +551,19 @@ def test_validate_serve_cmd_accepts_windows_printf_format():
557551
assert _validate_serve_cmd(cmd) == cmd
558552

559553

554+
def test_validate_serve_cmd_accepts_llama_mmproj_printf_format():
555+
cmd = (
556+
"CUDA_VISIBLE_DEVICES=0 llama-server --model "
557+
"\"$(printf %s ${HOME}'/.cache/huggingface/hub/models--unsloth--Qwen3.6-35B-A3B-GGUF/snapshots/abc/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf')\" "
558+
"--host 0.0.0.0 --port 8000 -ngl 99 -c 20000 "
559+
"--cache-type-k q4_0 --cache-type-v q4_0 --mmproj "
560+
"\"$(printf %s ${HOME}'/.cache/huggingface/hub/models--unsloth--Qwen3.6-35B-A3B-GGUF/snapshots/abc/mmproj-BF16.gguf')\" "
561+
"--image-max-tokens 1024"
562+
)
563+
564+
assert _validate_serve_cmd(cmd) == cmd
565+
566+
560567
def test_normalize_llama_cpp_python_cache_types_for_stale_client_cmd():
561568
cmd = (
562569
"python -m llama_cpp.server --model model.gguf --host 0.0.0.0 --port 8000 "

0 commit comments

Comments
 (0)