diff --git a/routes/shell_routes.py b/routes/shell_routes.py index fa8177b..583220c 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -92,6 +92,115 @@ def _docker_row_status(*, on_remote, in_container, installed, default_hint): return DockerRowStatus(applicable=True, install_hint=default_hint) +def _package_installed_from_probe(name: str, probe: dict) -> bool: + """Return whether an optional dependency is usable by Cookbook. + + A Python import alone is not enough: namespace packages can be created by a + same-named directory, and vLLM serving needs the CLI on PATH. Keep this + aligned with the actual serve command each backend launches. + """ + binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {} + dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {} + modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {} + + if name == "vllm": + return bool(binaries.get("vllm")) + if name == "llama_cpp": + return bool(binaries.get("llama-server") or dists.get("llama-cpp-python")) + if name == "sglang": + return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module")) + if name == "diffusers": + return bool( + (dists.get("diffusers") or modules.get("diffusers", {}).get("real_module")) + and (dists.get("torch") or modules.get("torch", {}).get("real_module")) + ) + if name == "hf_transfer": + return bool(dists.get("hf-transfer") or modules.get("hf_transfer", {}).get("real_module")) + return bool(dists.get(name) or modules.get(name, {}).get("real_module")) + + +def _package_status_note(name: str, probe: dict) -> str: + binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {} + modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {} + dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {} + module = modules.get(name) if isinstance(modules.get(name), dict) else {} + locations = module.get("locations") or [] + if name == "vllm": + if binaries.get("vllm"): + return f"vLLM CLI: {binaries['vllm']}" + if module.get("found") and not dists.get("vllm"): + loc = locations[0] if locations else module.get("origin") or "unknown path" + return f"Python sees a vllm namespace at {loc}, but no vLLM CLI is on PATH." + return "vLLM CLI not found on PATH." + if name == "llama_cpp": + parts = [] + if binaries.get("llama-server"): + parts.append(f"native llama-server: {binaries['llama-server']}") + if dists.get("llama-cpp-python"): + parts.append(f"python package: llama-cpp-python {dists['llama-cpp-python']}") + return "; ".join(parts) if parts else "No native llama-server or llama-cpp-python server package found." + if name == "diffusers": + if _package_installed_from_probe(name, probe): + return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}" + return "Diffusers serving needs both diffusers and torch." + if name in dists: + return f"{name} {dists[name]}" + return "" + + +def _package_probe_script(names: list[str]) -> str: + names_lit = ",".join(repr(n) for n in names) + return f""" +import importlib.util +import importlib.metadata as md +import json +import shutil + +names=[{names_lit}] +dist_names={{ + 'vllm':['vllm'], + 'llama_cpp':['llama-cpp-python'], + 'sglang':['sglang'], + 'diffusers':['diffusers','torch'], + 'hf_transfer':['hf-transfer','hf_transfer'], +}} +bin_names={{ + 'vllm':['vllm'], + 'llama_cpp':['llama-server'], +}} + +def mod_status(n): + spec = importlib.util.find_spec(n) + loader = getattr(spec, 'loader', None) if spec else None + return {{ + 'found': bool(spec), + 'origin': getattr(spec, 'origin', None) if spec else None, + 'loader': type(loader).__name__ if loader else None, + 'locations': list(getattr(spec, 'submodule_search_locations', []) or []), + 'real_module': bool(spec and loader), + }} + +def dist_status(ds): + out = {{}} + for d in ds: + try: + out[d] = md.version(d) + except Exception: + pass + return out + +def probe(n): + mods = {{n: mod_status(n)}} + if n == 'diffusers': + mods['torch'] = mod_status('torch') + dists = dist_status(dist_names.get(n, [n])) + bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}} + return {{'modules': mods, 'dists': dists, 'binaries': bins}} + +print(json.dumps({{n: probe(n) for n in names}})) +""" + + def _find_line_break(buf): """Find next line terminator in buffer. Returns (index, separator_length) or (-1, 0).""" ni = buf.find(b"\n") @@ -646,7 +755,7 @@ def setup_shell_routes() -> APIRouter: never reflected because the check only ever looked at the local host. """ _require_admin(request) - import importlib, shlex, json as _json + import importlib, importlib.metadata as importlib_metadata, shlex, json as _json port_arg = "" if ssh_port and str(ssh_port).strip() not in ("", "22"): _port = str(ssh_port).strip() @@ -672,18 +781,12 @@ def setup_shell_routes() -> APIRouter: # Remote check: for remote-target packages, probe the selected server's # venv over SSH so a remote `pip install` actually reflects here. remote_status: dict = {} + remote_details: dict = {} remote_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") != "system"] remote_system_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") == "system"] if host and remote_names: try: - names_lit = ",".join(repr(n) for n in remote_names) - py = ( - "import importlib.util,json,shutil;" - f"names=[{names_lit}];" - "status={n:(importlib.util.find_spec(n) is not None) for n in names};" - "status['llama_cpp']=status.get('llama_cpp',False) or shutil.which('llama-server') is not None;" - "print(json.dumps(status))" - ) + py = _package_probe_script(remote_names) src = "" if venv: act = venv if venv.endswith("/bin/activate") else venv.rstrip("/") + "/bin/activate" @@ -705,7 +808,12 @@ def setup_shell_routes() -> APIRouter: for line in reversed(txt.splitlines()): line = line.strip() if line.startswith("{"): - remote_status = _json.loads(line) + remote_details = _json.loads(line) + remote_status = { + name: _package_installed_from_probe(name, probe) + for name, probe in remote_details.items() + if isinstance(probe, dict) + } break except Exception: remote_status = {} @@ -736,16 +844,29 @@ def setup_shell_routes() -> APIRouter: on_remote = bool(host and pkg.get("target") == "remote") if on_remote: pkg["installed"] = bool(remote_status.get(pkg["name"], False)) + probe = remote_details.get(pkg["name"]) + if isinstance(probe, dict): + pkg["details"] = probe + note = _package_status_note(pkg["name"], probe) + if note: + pkg["status_note"] = note elif pkg.get("kind") == "system": pkg["installed"] = shutil.which(pkg["name"]) is not None elif pkg["name"] == "llama_cpp" and shutil.which("llama-server"): pkg["installed"] = True + pkg["status_note"] = f"native llama-server: {shutil.which('llama-server')}" else: try: importlib.import_module(pkg["name"]) - pkg["installed"] = True + if pkg["name"] == "vllm": + pkg["installed"] = shutil.which("vllm") is not None + else: + importlib_metadata.version(pkg["name"].replace("_", "-")) + pkg["installed"] = True except ImportError: pkg["installed"] = False + except importlib_metadata.PackageNotFoundError: + pkg["installed"] = False if pkg["name"] == "docker": status = _docker_row_status( diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 1a3cec7..8d230d2 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -554,10 +554,12 @@ async function _fetchDependencies() { const isLocal = pkg.target === 'local'; const isSystemDep = pkg.kind === 'system'; const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name); + const note = pkg.status_note ? `
` : ''; return `