Skip to content

Commit b67f644

Browse files
zoomdbzsamooth
authored andcommitted
fix(cookbook): treat local Windows as Windows for serve commands (odysseus-dev#3975)
* fix(cookbook): prefer native llama-server on local Windows * fix(cookbook): harden local llama-server launch commands * fix(cookbook): build serve commands for selected target
1 parent c45d031 commit b67f644

7 files changed

Lines changed: 191 additions & 36 deletions

File tree

routes/cookbook_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ def _bash_squote(v: str) -> str:
574574
# Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve.
575575
# Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper.
576576
_SERVE_CMD_ALLOWLIST = {
577-
"vllm", "llama-server", "llama_server", "llama.cpp", "ollama",
577+
"vllm", "llama-server", "llama-server.exe", "llama_server", "llama.cpp", "ollama",
578578
"python", "python3",
579579
"sglang", "lmdeploy",
580580
"node", "npx",

routes/cookbook_routes.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def _mask_secret(value: str) -> str:
7474
return "stored"
7575
return f"{value[:4]}...{value[-4:]}"
7676

77+
def _client_host_platform() -> str:
78+
return "windows" if IS_WINDOWS else ""
79+
7780
def _decrypt_secret(value: str | None) -> str:
7881
if not value:
7982
return ""
@@ -246,11 +249,15 @@ def _state_for_client(state):
246249
"""Return cookbook state without raw secrets for browser clients."""
247250
_strip_task_secrets(state)
248251
env = state.get("env") if isinstance(state, dict) else None
252+
if isinstance(state, dict) and not isinstance(env, dict):
253+
env = {}
254+
state["env"] = env
249255
if isinstance(env, dict):
250256
token = _decrypt_secret(env.get("hfToken"))
251257
env.pop("hfToken", None)
252258
env["hfTokenConfigured"] = bool(token)
253259
env["hfTokenMasked"] = _mask_secret(token)
260+
env["hostPlatform"] = _client_host_platform()
254261
return state
255262

256263
def _state_for_storage(state, on_disk=None):
@@ -269,6 +276,7 @@ def _state_for_storage(state, on_disk=None):
269276
env.pop("hfToken", None)
270277
env.pop("hfTokenMasked", None)
271278
env.pop("hfTokenConfigured", None)
279+
env.pop("hostPlatform", None)
272280
return state
273281

274282
def _load_stored_hf_token() -> str:
@@ -1486,6 +1494,10 @@ async def model_serve(request: Request, req: ServeRequest):
14861494
# shell resolves the bundled python3/hf, mirroring the download flow.
14871495
if not remote:
14881496
runner_lines.append(_local_tooling_path_export(sys.executable))
1497+
if local_windows:
1498+
# Detached Git Bash runs do not always inherit recently edited
1499+
# user PATH entries from the already-running Odysseus process.
1500+
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"')
14891501
runner_lines.append("export FLASHINFER_DISABLE_VERSION_CHECK=1")
14901502
if req.hf_token:
14911503
runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'")
@@ -1500,7 +1512,8 @@ async def model_serve(request: Request, req: ServeRequest):
15001512
runner_lines.append(_HF_TOKEN_STATUS_SNIPPET)
15011513
handled_ollama_serve = False
15021514
# Auto-install inference engine if missing
1503-
if "llama_cpp" in req.cmd or "llama-server" in req.cmd:
1515+
local_windows_llama_cmd = local_windows and ("llama_cpp" in req.cmd or "llama-server" in req.cmd)
1516+
if ("llama_cpp" in req.cmd or "llama-server" in req.cmd) and not local_windows_llama_cmd:
15041517
# Prefer the NATIVE llama-server binary — its minja templating
15051518
# renders modern GGUF chat templates that the Python bindings'
15061519
# Jinja2 rejects (do_tojson ensure_ascii). Build it once from
@@ -2413,8 +2426,8 @@ async def get_cookbook_state(request: Request):
24132426
logger.warning(f"state orphan sweep failed (non-fatal): {_sweep_e!r}")
24142427
return _state_for_client(state)
24152428
except Exception:
2416-
return {}
2417-
return {}
2429+
return _state_for_client({})
2430+
return _state_for_client({})
24182431

24192432
@router.post("/api/cookbook/state")
24202433
async def save_cookbook_state(request: Request):

static/js/cookbook.js

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function _platformIcon(platform) {
7474
return '';
7575
}
7676

77-
export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', defaultServer: '' };
77+
export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', hostPlatform: '', defaultServer: '' };
7878
let _lastCacheHostVal = null;
7979
let _cookbookOpeningSpinners = [];
8080
export function _lastCacheHost() { return _lastCacheHostVal; }
@@ -211,8 +211,13 @@ function _getPort(hostOrTask) {
211211

212212
/** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */
213213
export function _getPlatform(hostOrTask) {
214-
if (!hostOrTask) return _envState.platform || '';
215-
if (typeof hostOrTask === 'object') return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || hostOrTask.remoteHost);
214+
if (hostOrTask === 'local') return _envState.hostPlatform || '';
215+
if (!hostOrTask) return _envState.remoteHost ? (_envState.platform || '') : (_envState.hostPlatform || '');
216+
if (typeof hostOrTask === 'object') {
217+
const taskHost = hostOrTask.remoteServerKey || hostOrTask.remoteHost || '';
218+
if (!taskHost || taskHost === 'local') return _envState.hostPlatform || '';
219+
return hostOrTask.platform || _getPlatform(taskHost);
220+
}
216221
const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null;
217222
const srv = selected || _serverByVal(hostOrTask);
218223
return srv?.platform || '';
@@ -636,7 +641,12 @@ export function _buildServeCmd(f, modelName, backend) {
636641
// GPU list — read from gpus (button strip); fall back to gpu_id for
637642
// backward-compat with older saved presets that pre-date the removal.
638643
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
639-
const py = _isWindows() ? 'python' : 'python3';
644+
const _targetHost = Object.prototype.hasOwnProperty.call(f, 'host')
645+
? String(f.host || '').trim()
646+
: String(_envState.remoteHost || '').trim();
647+
const _isWin = _targetHost ? _isWindows(_targetHost) : _isWindows('local');
648+
const _localWindows = _isWin && !_targetHost;
649+
const py = _isWin ? 'python' : 'python3';
640650
// CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command
641651
// mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to
642652
// start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged.
@@ -658,19 +668,19 @@ export function _buildServeCmd(f, modelName, backend) {
658668
// with misleading prefixes.
659669
const _sb = String(_hwfitCache?.system?.backend || '').toLowerCase();
660670
const _hwfitHost = String(_hwfitCache?._scannedHost || '');
661-
const _curHost = String(_envState.remoteHost || '');
671+
const _curHost = _targetHost;
662672
const _isCudaTarget = (_sb === 'cuda') && (_hwfitHost === _curHost);
663673
const lcPrefix = (() => {
664674
let p = '';
665-
if (f.unified_mem && !_cpuOnly && !_isWindows() && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
666-
// No GPU env var in CPU mode `-ngl 0` already disables offload
675+
if (f.unified_mem && !_cpuOnly && (!_isWin || _localWindows) && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
676+
// No GPU env var in CPU mode - `-ngl 0` already disables offload
667677
// so CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES would be misleading
668678
// clutter ("why is CUDA pinned for a CPU run?").
669-
if (!_isWindows() && !_cpuOnly) p += _gpuEnvPrefix(gpuId);
679+
if ((!_isWin || _localWindows) && !_cpuOnly) p += _gpuEnvPrefix(gpuId);
670680
return p;
671681
})();
672-
if (f.unified_mem && !_cpuOnly && _isWindows() && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
673-
if (_isWindows() && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true);
682+
if (f.unified_mem && !_cpuOnly && _isWin && !_localWindows && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
683+
if (_isWin && !_localWindows && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true);
674684
const needsGgufPrelude = /^\$\(\{\s*find\s/.test(String(ggufPath || ''));
675685
const modelArg = needsGgufPrelude ? '"$MODEL_FILE"' : `"${ggufPath}"`;
676686
// Prefer native llama-server. The backend bootstrap resolves/builds the
@@ -742,11 +752,16 @@ export function _buildServeCmd(f, modelName, backend) {
742752
// llama-cpp-python takes the projector via --clip_model_path.
743753
_lcpExtra += ` --clip_model_path "${f._mmproj_path}"`;
744754
}
745-
if (_isWindows()) {
746-
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`;
755+
const _lcServer = `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
756+
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`;
757+
if (_localWindows) {
758+
// Local Windows serve is launched through Git Bash, so use the native
759+
// llama-server shape and let PATH resolve the CUDA Release wrapper.
760+
cmd += _lcServer;
761+
} else if (_isWin) {
747762
cmd += _lcpServer;
748763
} else {
749-
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
764+
cmd += _lcServer;
750765
}
751766
if (needsGgufPrelude) {
752767
cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host"; exit 1; } && ${cmd}`;
@@ -2615,13 +2630,14 @@ function _renderRecipes() {
26152630
const isLocal = !s.host || s.host.toLowerCase() === 'local';
26162631
if (isLocal) {
26172632
s.host = '';
2633+
s.platform = _envState.hostPlatform || '';
26182634
if (_localSeen) return false;
26192635
_localSeen = true;
26202636
}
26212637
return true;
26222638
});
26232639
if (!_localSeen) {
2624-
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });
2640+
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub', platform: _envState.hostPlatform || '' });
26252641
}
26262642
if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) {
26272643
_es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });

static/js/cookbookRunning.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,7 @@ function _stripStateSecrets(state) {
801801
const safe = { ...state };
802802
if (safe.env && typeof safe.env === 'object') {
803803
const { hfToken, ...env } = safe.env;
804+
delete env.hostPlatform;
804805
safe.env = env;
805806
}
806807
if (Array.isArray(safe.tasks)) safe.tasks = safe.tasks.map(_redactTaskForStorage);
@@ -1694,7 +1695,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
16941695
|| _envState.servers.find(s => s.host === _host) || {};
16951696
const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local');
16961697
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
1697-
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || '');
1698+
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');
16981699
const _replaceTaskId = fields?._replaceTaskId || '';
16991700
if (_replaceTaskId) {
17001701
try {
@@ -1709,7 +1710,6 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
17091710
}
17101711
} catch {}
17111712
}
1712-
17131713
// Replace any serve already targeting this same host:port — you can't run two
17141714
// servers on one port, so re-serving (or retrying) should stop & remove the
17151715
// old one instead of leaving a dead duplicate behind. (The retry buttons

static/js/cookbookServe.js

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ function _selectedServeTarget(panel) {
559559
env: server?.env || '',
560560
port: host ? (server?.port || _getPort(host) || '') : '',
561561
venv,
562-
platform: server?.platform || _envState.platform || '',
562+
platform: host ? (server?.platform || '') : (_envState.hostPlatform || ''),
563563
label,
564564
};
565565
}
@@ -690,6 +690,12 @@ function _selectedGgufSizeGb(model, relPath) {
690690
return bytes / (1024 ** 3);
691691
}
692692

693+
function _projectorGgufFiles(model) {
694+
return _ggufFilesForModel(model)
695+
.filter(f => (f.role || '') === 'projector' || /(^|\/)mmproj[^/]*\.gguf$/i.test(f.rel_path || f.name || ''))
696+
.sort((a, b) => String(a.rel_path || a.name || '').localeCompare(String(b.rel_path || b.name || '')));
697+
}
698+
693699
function _ggufFileLabel(file) {
694700
const base = (file.name || file.rel_path || '').split('/').pop();
695701
const size = _formatGgufSize(file.size_bytes);
@@ -1231,6 +1237,8 @@ function _rerenderCachedModels() {
12311237
: `This model's download isn't complete yet (${esc(m.size || 'partial')}). The serve will start but is likely to crash on a missing shard. Wait for the download to finish, or relaunch after it's done.`;
12321238
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>`;
12331239
}
1240+
panelHtml += `<div class="hwfit-serve-preset-row">${_slotsHtml}</div>`;
1241+
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>`;
12341242
// Row 1: Engine + Server + Env
12351243
panelHtml += `<div class="hwfit-serve-row">`;
12361244
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
@@ -1556,6 +1564,11 @@ function _rerenderCachedModels() {
15561564
if (el.type === 'checkbox') f[el.dataset.field] = el.checked;
15571565
else f[el.dataset.field] = el.value;
15581566
});
1567+
const buildTarget = _selectedServeTarget(panel);
1568+
f.host = buildTarget.host || '';
1569+
f.platform = buildTarget.platform || '';
1570+
const hostField = panel.querySelector('[data-field="host"]');
1571+
if (hostField) hostField.value = f.host;
15591572
const backend = f.backend || 'vllm';
15601573
const serveModel = (f.model_path || '').trim() || (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
15611574
if (backend === 'llamacpp') {
@@ -1575,11 +1588,11 @@ function _rerenderCachedModels() {
15751588
: m.is_local_dir && m.path
15761589
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
15771590
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
1578-
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
1579-
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
1580-
// present (downloaded alongside the model). Empty if none → cmd omits it.
1581-
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
1582-
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
1591+
// Vision: use the scanned projector (CLIP/mmproj) file when present.
1592+
// Keeping this as a printf path avoids generating a command substitution
1593+
// that the backend serve-command validator must reject as unsafe.
1594+
const selectedProjector = _projectorGgufFiles(m)[0];
1595+
f._mmproj_path = selectedProjector ? _selectedGgufExpr(m, repo, selectedProjector.rel_path) : '';
15831596
}
15841597
if (f.reasoning_parser) {
15851598
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
@@ -1595,6 +1608,10 @@ function _rerenderCachedModels() {
15951608
}
15961609
let cmd = _buildServeCmd(f, serveModel, backend);
15971610
if (f.extra && f.extra.trim()) cmd += ' ' + f.extra.trim();
1611+
const missingVisionProjector = backend === 'llamacpp' && !!f.vision && !f._mmproj_path;
1612+
panel._visionMissingProjector = missingVisionProjector;
1613+
const _visionWarn = panel.querySelector('.hwfit-serve-vision-warn');
1614+
if (_visionWarn) _visionWarn.style.display = missingVisionProjector ? 'flex' : 'none';
15981615
const _ce2 = panel.querySelector('.hwfit-serve-cmd'); _ce2.value = _formatServeCmdPreview(cmd); _ce2.style.height = 'auto'; _ce2.style.height = _ce2.scrollHeight + 'px';
15991616
panel._cmd = cmd;
16001617
panel._host = f.host || '';
@@ -2981,12 +2998,16 @@ function _rerenderCachedModels() {
29812998
});
29822999
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
29833000
const launchTarget = _selectedServeTarget(panel);
3001+
if (serveState.backend === 'llamacpp' && serveState.vision && !/(?:^|\s)(?:--mmproj|--clip_model_path)\b/.test(launchCmd)) {
3002+
_restoreLaunchBtn();
3003+
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);
3004+
return;
3005+
}
29843006
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
29853007
_restoreLaunchBtn();
29863008
uiModule.showToast(t('cookbookServe.diffusersWindowsUnsupported'), 9000);
29873009
return;
29883010
}
2989-
29903011
// Pre-launch: check our own task list for a serve already running
29913012
// on this host. Offer to stop+launch as the default action — the
29923013
// SSH-based port probe below is more thorough but it can miss

0 commit comments

Comments
 (0)