mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-15 12:58:04 +00:00
Checkpoint Odysseus local update
This commit is contained in:
+639
-87
@@ -58,6 +58,7 @@ from routes.cookbook_helpers import (
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
@@ -71,6 +72,72 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
||||
'fi'
|
||||
)
|
||||
|
||||
|
||||
def _venv_root_from_serve_cmd(cmd: str) -> str:
|
||||
"""Best-effort venv root from an absolute venv python in a serve command."""
|
||||
try:
|
||||
parts = shlex.split(cmd or "")
|
||||
except Exception:
|
||||
parts = (cmd or "").split()
|
||||
for part in parts:
|
||||
if re.search(r"/bin/python(?:3(?:\.\d+)?)?$", part or ""):
|
||||
return re.sub(r"/bin/python(?:3(?:\.\d+)?)?$", "", part)
|
||||
return ""
|
||||
|
||||
|
||||
def _append_venv_nvidia_library_path_lines(lines: list[str], *, cmd: str = "") -> None:
|
||||
"""Expose NVIDIA CUDA runtime wheels bundled inside the active venv.
|
||||
|
||||
SGLang/vLLM wheels can depend on CUDA libraries shipped as Python packages
|
||||
under site-packages/nvidia. Activating the venv puts Python packages on
|
||||
sys.path, but the dynamic loader still cannot find libraries such as
|
||||
libnvrtc.so.13 unless those package lib dirs are on LD_LIBRARY_PATH.
|
||||
"""
|
||||
venv_root = _venv_root_from_serve_cmd(cmd)
|
||||
lines.append(f'_ODY_VENV_FOR_LIBS="${{VIRTUAL_ENV:-{_bash_squote(venv_root)}}}"')
|
||||
lines.append('if [ -n "$_ODY_VENV_FOR_LIBS" ] && [ -d "$_ODY_VENV_FOR_LIBS" ]; then')
|
||||
lines.append(' for _ody_nvlib in "$_ODY_VENV_FOR_LIBS"/lib/python*/site-packages/nvidia/cu13/lib "$_ODY_VENV_FOR_LIBS"/lib/python*/site-packages/nvidia/cu12/lib "$_ODY_VENV_FOR_LIBS"/lib/python*/site-packages/nvidia/cuda_nvrtc/lib "$_ODY_VENV_FOR_LIBS"/lib/python*/site-packages/nvidia/cuda_runtime/lib "$_ODY_VENV_FOR_LIBS"/lib/python*/site-packages/nvidia/cublas/lib "$_ODY_VENV_FOR_LIBS"/lib/python*/site-packages/nvidia/cudnn/lib; do')
|
||||
lines.append(' [ -d "$_ody_nvlib" ] && export LD_LIBRARY_PATH="$_ody_nvlib:${LD_LIBRARY_PATH:-}"')
|
||||
lines.append(' done')
|
||||
lines.append('fi')
|
||||
|
||||
|
||||
def _serve_port_from_cmd(cmd: str) -> str:
|
||||
m = re.search(r"--port(?:=|\s+)(\d+)", cmd or "")
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _append_openai_port_preflight_lines(lines: list[str], *, cmd: str, expected_model: str) -> None:
|
||||
port = _serve_port_from_cmd(cmd)
|
||||
if not port:
|
||||
return
|
||||
lines.append(f"ODYSSEUS_SERVE_PORT='{_bash_squote(port)}'")
|
||||
lines.append(f"ODYSSEUS_EXPECTED_MODEL='{_bash_squote(expected_model)}'")
|
||||
lines.append("if [ -n \"$ODYSSEUS_SERVE_PORT\" ]; then")
|
||||
lines.append(" python3 - \"$ODYSSEUS_SERVE_PORT\" \"$ODYSSEUS_EXPECTED_MODEL\" <<'PY'")
|
||||
lines.append("import json, sys, urllib.request")
|
||||
lines.append("port = sys.argv[1]")
|
||||
lines.append("expected = (sys.argv[2] or '').strip()")
|
||||
lines.append("url = f'http://127.0.0.1:{port}/v1/models'")
|
||||
lines.append("try:")
|
||||
lines.append(" with urllib.request.urlopen(url, timeout=1.5) as r:")
|
||||
lines.append(" data = json.loads(r.read().decode('utf-8', 'replace') or '{}')")
|
||||
lines.append("except Exception:")
|
||||
lines.append(" raise SystemExit(0)")
|
||||
lines.append("models = [str(x.get('id') or '') for x in data.get('data', []) if isinstance(x, dict)]")
|
||||
lines.append("def base(s): return s.lower().split('/')[-1]")
|
||||
lines.append("match = bool(expected) and any((m.lower() == expected.lower() or base(m) == base(expected) or base(expected) in m.lower() or base(m) in expected.lower()) for m in models)")
|
||||
lines.append("print(f'ERROR: Port {port} is already serving {models or [\"unknown\"]}.')")
|
||||
lines.append("if expected and not match:")
|
||||
lines.append(" print(f'ERROR: Cookbook was about to launch {expected}, but this port is occupied by a different model. Stop the old server or choose another port.')")
|
||||
lines.append("else:")
|
||||
lines.append(" print('ERROR: Stop the existing server or choose another port before launching a duplicate serve.')")
|
||||
lines.append("raise SystemExit(98)")
|
||||
lines.append("PY")
|
||||
lines.append(" _ody_port_ec=$?")
|
||||
lines.append(" if [ \"$_ody_port_ec\" -ne 0 ]; then ODYSSEUS_PREFLIGHT_EXIT=\"$_ody_port_ec\"; fi")
|
||||
lines.append("fi")
|
||||
|
||||
_OLLAMA_SIDECAR_CONTAINERS = {"ollama-test", "ollama-rocm"}
|
||||
_UNSAFE_DOCKER_EXEC_CHARS = frozenset(";&|<>$`\r\n")
|
||||
_SAFE_OLLAMA_MODEL_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")
|
||||
@@ -145,7 +212,7 @@ async def _remote_binary_available(
|
||||
if windows:
|
||||
check = f'powershell -NoProfile -Command "if (Get-Command {binary} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 127 }}"'
|
||||
else:
|
||||
check = f"command -v {shlex.quote(binary)} >/dev/null 2>&1"
|
||||
check = f'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; command -v {shlex.quote(binary)} >/dev/null 2>&1'
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh",
|
||||
@@ -165,6 +232,45 @@ async def _remote_binary_available(
|
||||
return False
|
||||
|
||||
|
||||
def _remote_posix_path_prefix() -> str:
|
||||
return 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; '
|
||||
|
||||
|
||||
def _remote_tmux_command(*args: str) -> str:
|
||||
"""Shell command for remote tmux when non-login SSH has a thin PATH."""
|
||||
tmux = (
|
||||
'ODYSSEUS_TMUX="$(command -v tmux '
|
||||
'|| command -v /opt/homebrew/bin/tmux '
|
||||
'|| command -v /usr/local/bin/tmux '
|
||||
'|| command -v /usr/bin/tmux '
|
||||
'|| true)"; '
|
||||
'if [ -z "$ODYSSEUS_TMUX" ]; then echo "tmux not found" >&2; exit 127; fi; '
|
||||
)
|
||||
quoted = " ".join(shlex.quote(str(arg)) for arg in args)
|
||||
return f'{_remote_posix_path_prefix()}{tmux}"$ODYSSEUS_TMUX" {quoted}'
|
||||
|
||||
|
||||
def _remote_tmux_launch_command(session_id: str, runner: str) -> str:
|
||||
"""Shell command that chmods a runner and starts it in remote tmux."""
|
||||
tmux = (
|
||||
'ODYSSEUS_TMUX="$(command -v tmux '
|
||||
'|| command -v /opt/homebrew/bin/tmux '
|
||||
'|| command -v /usr/local/bin/tmux '
|
||||
'|| command -v /usr/bin/tmux '
|
||||
'|| true)"; '
|
||||
'if [ -z "$ODYSSEUS_TMUX" ]; then echo "tmux not found" >&2; exit 127; fi; '
|
||||
)
|
||||
sid = shlex.quote(str(session_id))
|
||||
runner_q = shlex.quote(str(runner))
|
||||
runner_exec = shlex.quote(f"./{runner}")
|
||||
return (
|
||||
f'{_remote_posix_path_prefix()}{tmux}'
|
||||
f'chmod +x {runner_q} && '
|
||||
f'"$ODYSSEUS_TMUX" set-option -g history-limit 100000 2>/dev/null; '
|
||||
f'"$ODYSSEUS_TMUX" new-session -d -s {sid} {runner_exec}'
|
||||
)
|
||||
|
||||
|
||||
async def _binary_available(
|
||||
binary: str,
|
||||
remote: str | None,
|
||||
@@ -366,13 +472,14 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||
r"Could not load any common_ops library|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native dependencies are missing on this server.",
|
||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||
[
|
||||
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
|
||||
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
|
||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -380,6 +487,19 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||
),
|
||||
(
|
||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
[
|
||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||
],
|
||||
),
|
||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
||||
# so cmake / build-essential / git missing → a specific OS-package
|
||||
# remediation instead of "install llama-cpp-python[server]" (which
|
||||
@@ -542,6 +662,89 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
add_bool("--enable-expert-parallel")
|
||||
return shlex.join(env_parts + body)
|
||||
|
||||
def _normalize_deepseek_v4_sglang_cmd(cmd: str) -> str:
|
||||
"""Patch stale DeepSeek-V4 SGLang commands into the safer local form.
|
||||
|
||||
The browser command builder already emits these flags, but saved presets,
|
||||
running-row retries, and old tabs can still submit a pre-fix command to
|
||||
/api/model/serve. Normalize server-side so the tmux runner does not keep
|
||||
relaunching DeepSeek-V4 with the known CUDA-graph crash shape.
|
||||
"""
|
||||
cmd_lower = (cmd or "").lower()
|
||||
if (
|
||||
not cmd
|
||||
or "sglang.launch_server" not in cmd_lower
|
||||
or "deepseek-v4" not in cmd_lower
|
||||
):
|
||||
return cmd
|
||||
try:
|
||||
parts = shlex.split(cmd)
|
||||
except ValueError:
|
||||
return cmd
|
||||
|
||||
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
env_parts = [p for p in parts if env_re.match(p)]
|
||||
body = [p for p in parts if not env_re.match(p)]
|
||||
|
||||
def add_env(key: str, value: str) -> None:
|
||||
if not any(p.startswith(f"{key}=") for p in env_parts):
|
||||
env_parts.append(f"{key}={value}")
|
||||
|
||||
def has_flag(flag: str) -> bool:
|
||||
return any(p == flag or p.startswith(flag + "=") for p in body)
|
||||
|
||||
def flag_value(flag: str) -> str | None:
|
||||
for i, part in enumerate(body):
|
||||
if part == flag:
|
||||
return body[i + 1] if i + 1 < len(body) else ""
|
||||
if part.startswith(flag + "="):
|
||||
return part.split("=", 1)[1]
|
||||
return None
|
||||
|
||||
def set_flag(flag: str, value: str) -> None:
|
||||
for i, part in enumerate(body):
|
||||
if part == flag:
|
||||
if i + 1 < len(body):
|
||||
body[i + 1] = value
|
||||
else:
|
||||
body.append(value)
|
||||
return
|
||||
if part.startswith(flag + "="):
|
||||
body[i] = f"{flag}={value}"
|
||||
return
|
||||
body.extend([flag, value])
|
||||
|
||||
def remove_flag(flag: str) -> None:
|
||||
i = 0
|
||||
while i < len(body):
|
||||
part = body[i]
|
||||
if part == flag:
|
||||
del body[i:i + 2]
|
||||
continue
|
||||
if part.startswith(flag + "="):
|
||||
del body[i]
|
||||
continue
|
||||
i += 1
|
||||
|
||||
add_env("SGLANG_DSV4_COMPRESS_STATE_DTYPE", "bf16")
|
||||
mem_fraction = flag_value("--mem-fraction-static")
|
||||
try:
|
||||
mem_fraction_num = float(mem_fraction) if mem_fraction not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
mem_fraction_num = None
|
||||
if mem_fraction in (None, "", "0.90", "0.9") or (
|
||||
mem_fraction_num is not None and mem_fraction_num < 0.76
|
||||
):
|
||||
set_flag("--mem-fraction-static", "0.80")
|
||||
if not has_flag("--reasoning-parser"):
|
||||
set_flag("--reasoning-parser", "deepseek-v4")
|
||||
if not has_flag("--tool-call-parser"):
|
||||
set_flag("--tool-call-parser", "deepseekv4")
|
||||
if not has_flag("--cuda-graph-backend-decode"):
|
||||
remove_flag("--cuda-graph-max-bs-decode")
|
||||
set_flag("--cuda-graph-backend-decode", "disabled")
|
||||
return shlex.join(env_parts + body)
|
||||
|
||||
def _cookbook_ssh_dir() -> Path:
|
||||
# The Docker image keeps cookbook keys under /app/.ssh; that path only
|
||||
# exists inside the container. On Windows (and any non-container host)
|
||||
@@ -555,12 +758,124 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
def _cookbook_ssh_key_path() -> Path:
|
||||
return _cookbook_ssh_dir() / "id_ed25519"
|
||||
|
||||
def _ssh_known_host_name(host: str) -> str:
|
||||
"""Return the host part OpenSSH stores in known_hosts.
|
||||
|
||||
Cookbook accepts `user@host` for convenience, but known_hosts entries
|
||||
are keyed by host, not username.
|
||||
"""
|
||||
return (host or "").rsplit("@", 1)[-1]
|
||||
|
||||
def _known_hosts_targets(host: str, ssh_port: str | None = None) -> list[str]:
|
||||
name = _ssh_known_host_name(host)
|
||||
targets = [name]
|
||||
if ssh_port and ssh_port != "22":
|
||||
targets.insert(0, f"[{name}]:{ssh_port}")
|
||||
return [t for t in targets if t]
|
||||
|
||||
def _ssh_host_key_changed(stderr_txt: str) -> bool:
|
||||
text = stderr_txt or ""
|
||||
return (
|
||||
"REMOTE HOST IDENTIFICATION HAS CHANGED" in text
|
||||
or "Host key verification failed" in text and "Offending" in text
|
||||
)
|
||||
|
||||
async def _repair_cookbook_known_host(host: str, ssh_port: str | None = None) -> tuple[bool, str]:
|
||||
"""Refresh Odysseus' own known_hosts entry for a validated Cookbook host.
|
||||
|
||||
This is intentionally scoped to Cookbook SSH targets and only called
|
||||
after OpenSSH reports a changed host key. It fixes container-local
|
||||
known_hosts drift without asking the user to run ssh-keygen manually.
|
||||
"""
|
||||
known_hosts = _cookbook_ssh_dir() / "known_hosts"
|
||||
known_hosts.parent.mkdir(parents=True, exist_ok=True)
|
||||
known_hosts.touch(mode=0o600, exist_ok=True)
|
||||
safe_chmod(known_hosts, 0o600)
|
||||
|
||||
ssh_keygen = which_tool("ssh-keygen") or "ssh-keygen"
|
||||
ssh_keyscan = which_tool("ssh-keyscan") or "ssh-keyscan"
|
||||
removed_chunks: list[str] = []
|
||||
for target in _known_hosts_targets(host, ssh_port):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ssh_keygen,
|
||||
"-f",
|
||||
str(known_hosts),
|
||||
"-R",
|
||||
target,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
removed_chunks.append((stdout or stderr).decode("utf-8", errors="replace").strip())
|
||||
|
||||
scan_args = [ssh_keyscan, "-H", "-t", "ed25519,ecdsa,rsa"]
|
||||
if ssh_port and ssh_port != "22":
|
||||
scan_args.extend(["-p", ssh_port])
|
||||
scan_args.append(_ssh_known_host_name(host))
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*scan_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return False, "ssh-keyscan timed out while refreshing known_hosts"
|
||||
if proc.returncode != 0 or not stdout.strip():
|
||||
detail = (stderr or stdout).decode("utf-8", errors="replace").strip()
|
||||
return False, detail or "ssh-keyscan returned no host keys"
|
||||
with known_hosts.open("ab") as f:
|
||||
if known_hosts.stat().st_size > 0:
|
||||
f.write(b"\n")
|
||||
f.write(stdout.strip() + b"\n")
|
||||
safe_chmod(known_hosts, 0o600)
|
||||
return True, "\n".join(chunk for chunk in removed_chunks if chunk) or "known_hosts refreshed"
|
||||
|
||||
def _read_cookbook_public_key() -> str:
|
||||
pub = _cookbook_ssh_key_path().with_suffix(".pub")
|
||||
if not pub.exists():
|
||||
return ""
|
||||
return pub.read_text(encoding="utf-8", errors="replace").strip()
|
||||
|
||||
def _server_env_prefix_for_download(remote_host: str | None) -> str | None:
|
||||
"""Recover a server venv/conda activation for stale download clients.
|
||||
|
||||
Older browser bundles could submit /api/model/download without
|
||||
env_prefix even when the selected server profile had an envPath. The
|
||||
remote runner would then use system python and exit before hf download.
|
||||
Resolve the server profile by host here so downloads remain correct even
|
||||
if the user has a cached JS bundle.
|
||||
"""
|
||||
if not remote_host or not _cookbook_state_path.exists():
|
||||
return None
|
||||
try:
|
||||
state = json.loads(_cookbook_state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
env_state = state.get("env") if isinstance(state, dict) else {}
|
||||
servers = env_state.get("servers") if isinstance(env_state, dict) else []
|
||||
if not isinstance(servers, list):
|
||||
return None
|
||||
selected = None
|
||||
for server in servers:
|
||||
if isinstance(server, dict) and (server.get("host") or "").strip() == remote_host:
|
||||
selected = server
|
||||
break
|
||||
if not selected:
|
||||
return None
|
||||
env = (selected.get("env") or "none").strip().lower()
|
||||
env_path = (selected.get("envPath") or "").strip()
|
||||
if not env_path:
|
||||
return None
|
||||
if env == "venv" or (env in {"", "none"} and re.search(r"(?:^|/)(?:\.?venv|env)(?:/|$)|/bin/activate$", env_path, re.I)):
|
||||
activate = env_path if env_path.endswith("/bin/activate") else env_path.rstrip("/") + "/bin/activate"
|
||||
return "source " + shlex.quote(activate)
|
||||
if env == "conda":
|
||||
return 'eval "$(conda shell.bash hook)" && conda activate ' + shlex.quote(env_path)
|
||||
return None
|
||||
|
||||
@router.get("/api/cookbook/ssh-key")
|
||||
async def get_cookbook_ssh_key(request: Request):
|
||||
require_admin(request)
|
||||
@@ -708,6 +1023,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
req.local_dir = _validate_local_dir(req.local_dir)
|
||||
req.hf_token = "" if is_ollama_download else (req.hf_token or _load_stored_hf_token())
|
||||
_validate_token(req.hf_token)
|
||||
if req.remote_host and not req.env_prefix:
|
||||
req.env_prefix = _server_env_prefix_for_download(req.remote_host)
|
||||
TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
session_id = f"cookbook-{uuid.uuid4().hex[:8]}"
|
||||
wrapper_script = TMUX_LOG_DIR / f"{session_id}.sh"
|
||||
@@ -725,9 +1042,10 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# Build the hf download command. Redirection to suppress the interactive
|
||||
# "update available? [Y/n]" prompt is added per-platform further down
|
||||
# (< /dev/null on bash, $null | on PowerShell).
|
||||
hf_cmd = f"hf download {req.repo_id}"
|
||||
hf_download_args = f"download {shlex.quote(req.repo_id)}"
|
||||
if req.include:
|
||||
hf_cmd += f" --include '{req.include}'"
|
||||
hf_download_args += f" --include {shlex.quote(req.include)}"
|
||||
hf_cmd = f"hf {hf_download_args}"
|
||||
ollama_cmd = f"ollama pull {shlex.quote(req.repo_id)}"
|
||||
|
||||
# Build the shell wrapper — runs hf download directly in tmux (which is a TTY)
|
||||
@@ -879,6 +1197,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
)
|
||||
# Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
runner_lines.append('ODYSSEUS_PY="$(command -v python3 || command -v python || true)"')
|
||||
runner_lines.append('if [ -z "$ODYSSEUS_PY" ]; then echo "ERROR: python3/python not found on this server."; exit 127; fi')
|
||||
# Install hf CLI + optional hf_transfer best-effort. Retries disable
|
||||
# hf_transfer because the Rust parallel path is fast but has been
|
||||
# flaky near the end of very large multi-file downloads.
|
||||
@@ -894,13 +1214,16 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi')
|
||||
else:
|
||||
runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}")
|
||||
runner_lines.append(f"command -v hf >/dev/null 2>&1 || command -v huggingface-cli >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='\"$ODYSSEUS_PY\" -m pip', upgrade=True)}")
|
||||
runner_lines.append('hash -r 2>/dev/null || true')
|
||||
runner_lines.append('ODYSSEUS_HF_CLI="$(command -v hf || command -v huggingface-cli || true)"')
|
||||
runner_lines.append('if [ -z "$ODYSSEUS_HF_CLI" ]; then echo "ERROR: HF CLI not found after installing huggingface_hub."; exit 127; fi')
|
||||
if req.disable_hf_transfer:
|
||||
runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0")
|
||||
runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4")
|
||||
else:
|
||||
runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}")
|
||||
runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
|
||||
runner_lines.append(f"\"$ODYSSEUS_PY\" -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='\"$ODYSSEUS_PY\" -m pip')}")
|
||||
runner_lines.append("\"$ODYSSEUS_PY\" -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
|
||||
runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8")
|
||||
# Surface whether the HF token actually reached THIS server, so a gated
|
||||
# download's "not authorized" failure can be told apart from a missing
|
||||
@@ -915,22 +1238,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if is_ollama_download:
|
||||
runner_lines.append(' eval "$ODYSSEUS_OLLAMA_PULL_CMD" < /dev/null')
|
||||
else:
|
||||
runner_lines.append(' if command -v hf &>/dev/null; then')
|
||||
runner_lines.append(f' {hf_cmd} < /dev/null')
|
||||
runner_lines.append(' elif python3 -c "import huggingface_hub" 2>/dev/null; then')
|
||||
runner_lines.append(' [ $_attempt -eq 1 ] && echo "hf CLI not found, using Python huggingface_hub..."')
|
||||
runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={mw})"')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "Installing huggingface-hub and dependencies..."')
|
||||
runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null')
|
||||
if req.disable_hf_transfer:
|
||||
runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null')
|
||||
runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0')
|
||||
else:
|
||||
runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null')
|
||||
runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1")
|
||||
runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={mw})"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(f' "$ODYSSEUS_HF_CLI" {hf_download_args} < /dev/null')
|
||||
runner_lines.append(' _ec=$?')
|
||||
runner_lines.append(' if [ $_ec -eq 0 ]; then break; fi')
|
||||
runner_lines.append(' if [ $_attempt -lt $_max_retries ]; then')
|
||||
@@ -953,7 +1261,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
_spf = f"-p {_port} " if _port and _port != "22" else ""
|
||||
setup_cmd = (
|
||||
f"scp -O {_pf}-q '{runner_path}' {remote}:{remote_runner} && "
|
||||
f"ssh {_spf}{remote} 'chmod +x {remote_runner} && tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
|
||||
f"ssh {_spf}{remote} {shlex.quote(_remote_tmux_launch_command(session_id, remote_runner))}"
|
||||
)
|
||||
else:
|
||||
# Local: run hf download in the background (tmux on POSIX, a detached
|
||||
@@ -1041,49 +1349,64 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
for d in model_dir.split(','):
|
||||
d = d.strip()
|
||||
if d:
|
||||
if d.startswith(("home/", "mnt/", "media/", "data/", "opt/", "srv/", "var/")):
|
||||
d = "/" + d
|
||||
model_dirs.append(d)
|
||||
paths_code = _cached_model_scan_script(model_dirs)
|
||||
|
||||
scan_py = TMUX_LOG_DIR / "scan_cache.py"
|
||||
scan_py.write_text(paths_code, encoding="utf-8")
|
||||
|
||||
if host:
|
||||
_pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
if platform == "windows":
|
||||
# Windows: use 'python' and pipe via stdin with double-quote wrapping
|
||||
cmd = f'ssh {_pf}{host} "python -" < \'{scan_py}\''
|
||||
async def _run_cached_scan_once():
|
||||
if host:
|
||||
_ssh_opts = "-o BatchMode=yes -o ConnectTimeout=8 -o ServerAliveInterval=4 -o ServerAliveCountMax=1 "
|
||||
_pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
if platform == "windows":
|
||||
# Windows: use 'python' and pipe via stdin with double-quote wrapping
|
||||
cmd = f'ssh {_ssh_opts}{_pf}{host} "python -" < \'{scan_py}\''
|
||||
else:
|
||||
cmd = f"ssh {_ssh_opts}{_pf}{host} 'python3 -' < '{scan_py}'"
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path.home()),
|
||||
)
|
||||
else:
|
||||
cmd = f"ssh {_pf}{host} 'python3 -' < '{scan_py}'"
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path.home()),
|
||||
)
|
||||
else:
|
||||
# LOCAL scan: use sys.executable (the venv Python Odysseus is already
|
||||
# running under) — it's guaranteed real Python on all platforms.
|
||||
# Falling back to which_tool on Windows risks hitting the Microsoft
|
||||
# Store stub alias for "python3"/"python", which prints
|
||||
# "Python was not found; run without arguments to install from the
|
||||
# Microsoft Store" and exits 9009, producing empty stdout and a
|
||||
# JSON parse error. sys.executable bypasses PATH entirely.
|
||||
local_py = sys.executable or (
|
||||
which_tool("python3") or which_tool("python")
|
||||
or which_tool("py") or "python"
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
local_py, str(scan_py),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path.home()),
|
||||
)
|
||||
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||||
# LOCAL scan: use sys.executable (the venv Python Odysseus is already
|
||||
# running under) — it's guaranteed real Python on all platforms.
|
||||
# Falling back to which_tool on Windows risks hitting the Microsoft
|
||||
# Store stub alias for "python3"/"python", which prints
|
||||
# "Python was not found; run without arguments to install from the
|
||||
# Microsoft Store" and exits 9009, producing empty stdout and a
|
||||
# JSON parse error. sys.executable bypasses PATH entirely.
|
||||
local_py = sys.executable or (
|
||||
which_tool("python3") or which_tool("python")
|
||||
or which_tool("py") or "python"
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
local_py, str(scan_py),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path.home()),
|
||||
)
|
||||
return await asyncio.wait_for(proc.communicate(), timeout=60), proc.returncode
|
||||
|
||||
(stdout_b, stderr_b), returncode = await _run_cached_scan_once()
|
||||
stderr_txt = stderr_b.decode(errors="replace").strip()
|
||||
stdout_txt = stdout_b.decode(errors="replace").strip()
|
||||
if proc.returncode != 0:
|
||||
msg = stderr_txt or f"Cached model scan failed with exit code {proc.returncode}"
|
||||
logger.warning(f"Cached model scan failed host={host or 'local'} rc={proc.returncode}: {msg[:500]}")
|
||||
if host and returncode != 0 and _ssh_host_key_changed(stderr_txt):
|
||||
ok, detail = await _repair_cookbook_known_host(host, ssh_port)
|
||||
if ok:
|
||||
logger.info("Repaired Cookbook known_hosts for %s after host-key-change scan failure", host)
|
||||
(stdout_b, stderr_b), returncode = await _run_cached_scan_once()
|
||||
stderr_txt = stderr_b.decode(errors="replace").strip()
|
||||
stdout_txt = stdout_b.decode(errors="replace").strip()
|
||||
else:
|
||||
logger.warning("Failed to repair Cookbook known_hosts for %s: %s", host, detail[:300])
|
||||
if returncode != 0:
|
||||
msg = stderr_txt or f"Cached model scan failed with exit code {returncode}"
|
||||
logger.warning(f"Cached model scan failed host={host or 'local'} rc={returncode}: {msg[:500]}")
|
||||
return {"models": [], "host": host or "local", "error": msg}
|
||||
|
||||
models = []
|
||||
@@ -1270,7 +1593,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ssh_args = ["ssh"]
|
||||
if ssh_port and ssh_port != "22":
|
||||
ssh_args.extend(["-p", str(ssh_port)])
|
||||
capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-2000"]
|
||||
capture_cmd = ssh_args + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-2000")]
|
||||
else:
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-2000"]
|
||||
|
||||
@@ -1400,12 +1723,27 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = short_name or "Local model"
|
||||
is_mlx_deepseek_v4 = (
|
||||
"mlx_lm.server" in (req.cmd or "")
|
||||
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
||||
)
|
||||
mlx_shim_model_id = ""
|
||||
if is_mlx_deepseek_v4 and short_name:
|
||||
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
||||
remote_home = home_match.group(1) if home_match else ""
|
||||
if remote_home:
|
||||
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
||||
|
||||
# If the serve command opts models into OpenAI tool-calling, record it so
|
||||
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
||||
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
||||
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
||||
pinned_models = [req.repo_id] if is_ollama_endpoint and req.repo_id else []
|
||||
# Pin the model the user launched for every Cookbook-created LLM
|
||||
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else [])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -1417,11 +1755,20 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
if pinned_models:
|
||||
try:
|
||||
existing_pinned = json.loads(existing.pinned_models or "[]")
|
||||
except Exception:
|
||||
existing_pinned = []
|
||||
merged_pinned = []
|
||||
for mid in [*existing_pinned, *pinned_models]:
|
||||
if mid and mid not in merged_pinned:
|
||||
merged_pinned.append(mid)
|
||||
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
if supports_tools is not None:
|
||||
existing.supports_tools = supports_tools
|
||||
db.commit()
|
||||
@@ -1430,12 +1777,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# serves right now (the URL may have stayed the same but the
|
||||
# model behind it changed across launches).
|
||||
try:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||
if probed:
|
||||
existing.cached_models = _json2.dumps(probed)
|
||||
if mlx_shim_model_id:
|
||||
existing.cached_models = json.dumps([mlx_shim_model_id])
|
||||
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||
if probed:
|
||||
existing.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
# Sweep stale dupes: other endpoints with the same display name
|
||||
@@ -1492,13 +1844,19 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# to a minute later) and the picker shows nothing — even
|
||||
# though the endpoint is in the DB and the server is up.
|
||||
try:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||
if probed:
|
||||
ep.cached_models = _json2.dumps(probed)
|
||||
if mlx_shim_model_id:
|
||||
ep.cached_models = json.dumps([mlx_shim_model_id])
|
||||
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||
if probed:
|
||||
ep.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
||||
return ep_id
|
||||
@@ -1541,6 +1899,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
req.cmd = _validate_serve_cmd(req.cmd) or ""
|
||||
req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or ""
|
||||
req.cmd = _normalize_minimax_m3_vllm_cmd(req.cmd)
|
||||
req.cmd = _normalize_deepseek_v4_sglang_cmd(req.cmd)
|
||||
req.cmd = _venv_safe_local_pip_install_cmd(
|
||||
req.cmd,
|
||||
local=not bool(req.remote_host),
|
||||
@@ -1719,6 +2078,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(_safe_env_prefix(req.env_prefix))
|
||||
else:
|
||||
runner_lines.append("deactivate 2>/dev/null; hash -r")
|
||||
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
|
||||
if "sglang.launch_server" in req.cmd or "mlx_lm.server" in req.cmd or re.search(r"\bvllm\s+serve\b", req.cmd or ""):
|
||||
_append_openai_port_preflight_lines(runner_lines, cmd=req.cmd, expected_model=req.repo_id)
|
||||
# Show whether the HF token reached this server (masked) — a gated
|
||||
# model vLLM has to download will be denied without it.
|
||||
runner_lines.append(_HF_TOKEN_STATUS_SNIPPET)
|
||||
@@ -1990,6 +2352,116 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
elif "mlx_lm.server" in req.cmd:
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$(python3 -c "import mlx_lm" 2>&1)"; then')
|
||||
runner_lines.append(' echo "ERROR: MLX LM is not installed."')
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
|
||||
runner_lines.append(' ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import shlex, sys')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('py = "python3"')
|
||||
runner_lines.append('for i, part in enumerate(parts):')
|
||||
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||
runner_lines.append(' py = part')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append('print(py)')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import json, os, shlex, sys')
|
||||
runner_lines.append('from pathlib import Path')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('try:')
|
||||
runner_lines.append(' i = parts.index("--model")')
|
||||
runner_lines.append(' model = parts[i + 1]')
|
||||
runner_lines.append('except Exception:')
|
||||
runner_lines.append(' print(shlex.join(parts)); raise SystemExit')
|
||||
runner_lines.append('if "/" in model and not model.startswith("/") and model.startswith("mlx-community/"):')
|
||||
runner_lines.append(' roots = []')
|
||||
runner_lines.append(' def add(p):')
|
||||
runner_lines.append(' if not p: return')
|
||||
runner_lines.append(' p = os.path.expanduser(p)')
|
||||
runner_lines.append(' if p not in roots: roots.append(p)')
|
||||
runner_lines.append(' add(os.environ.get("HUGGINGFACE_HUB_CACHE"))')
|
||||
runner_lines.append(' hf_home = os.environ.get("HF_HOME")')
|
||||
runner_lines.append(' if hf_home: add(os.path.join(hf_home, "hub"))')
|
||||
runner_lines.append(' add("~/.cache/huggingface/hub")')
|
||||
runner_lines.append(' cache_name = "models--" + model.replace("/", "--")')
|
||||
runner_lines.append(' best = ""')
|
||||
runner_lines.append(' best_mtime = -1.0')
|
||||
runner_lines.append(' for root in roots:')
|
||||
runner_lines.append(' snap_root = os.path.join(root, cache_name, "snapshots")')
|
||||
runner_lines.append(' if not os.path.isdir(snap_root): continue')
|
||||
runner_lines.append(' for name in os.listdir(snap_root):')
|
||||
runner_lines.append(' path = os.path.join(snap_root, name)')
|
||||
runner_lines.append(' if not os.path.isdir(path): continue')
|
||||
runner_lines.append(' if not os.path.exists(os.path.join(path, "config.json")): continue')
|
||||
runner_lines.append(' try: mtime = os.path.getmtime(path)')
|
||||
runner_lines.append(' except OSError: mtime = 0')
|
||||
runner_lines.append(' if mtime > best_mtime:')
|
||||
runner_lines.append(' best, best_mtime = path, mtime')
|
||||
runner_lines.append(' if best:')
|
||||
runner_lines.append(' print("[odysseus] MLX using cached snapshot:", best, file=sys.stderr)')
|
||||
runner_lines.append(' launch_model = best')
|
||||
runner_lines.append(' if "deepseek-v4" in model.lower():')
|
||||
runner_lines.append(' try:')
|
||||
runner_lines.append(' import mlx_lm.models.deepseek_v4 as dsv4')
|
||||
runner_lines.append(' import mlx_lm.utils as mlx_utils')
|
||||
runner_lines.append(' utils_path = Path(mlx_utils.__file__)')
|
||||
runner_lines.append(' utils_text = utils_path.read_text()')
|
||||
runner_lines.append(' utils_needle = \' def class_predicate(p, m):\\n # Handle custom per layer quantizations\\n if p in config["quantization"]:\\n return config["quantization"][p]\\n if not hasattr(m, "to_quantized"):\\n return False\\n return f"{p}.scales" in weights\\n\'')
|
||||
runner_lines.append(' utils_repl = \' def class_predicate(p, m):\\n # Odysseus: DeepSeek-V4 MXFP4 switch layers may already be quantized.\\n if type(m).__name__ == "QuantizedSwitchLinear":\\n return False\\n # Handle custom per layer quantizations\\n if p in config["quantization"]:\\n return config["quantization"][p]\\n if not hasattr(m, "to_quantized"):\\n return False\\n return f"{p}.scales" in weights\\n\'')
|
||||
runner_lines.append(' if utils_repl not in utils_text and utils_needle in utils_text:')
|
||||
runner_lines.append(' bak = utils_path.with_suffix(utils_path.suffix + ".odysseus_bak")')
|
||||
runner_lines.append(' if not bak.exists(): bak.write_text(utils_text)')
|
||||
runner_lines.append(' utils_path.write_text(utils_text.replace(utils_needle, utils_repl))')
|
||||
runner_lines.append(' print("[odysseus] Patched MLX-LM QuantizedSwitchLinear double-quantization guard.", file=sys.stderr)')
|
||||
runner_lines.append(' dsv4_path = Path(dsv4.__file__)')
|
||||
runner_lines.append(' dsv4_text = dsv4_path.read_text()')
|
||||
runner_lines.append(' dsv4_needle = \' for sub in ("attn", "ffn"):\\n for p in ("fn", "base", "scale"):\\n nk = nk.replace(f".hc_{sub}_{p}", f".hc_{sub}.{p}")\\n for wo, wn in w_remap.items():\\n\'')
|
||||
runner_lines.append(' dsv4_repl = \' for sub in ("attn", "ffn"):\\n for p in ("fn", "base", "scale"):\\n nk = nk.replace(f".hc_{sub}_{p}", f".hc_{sub}.{p}")\\n # Odysseus: normalize alternate hyper-connection key aliases.\\n nk = nk.replace(".attn_hc.", ".hc_attn.")\\n nk = nk.replace(".ffn_hc.", ".hc_ffn.")\\n for wo, wn in w_remap.items():\\n\'')
|
||||
runner_lines.append(' if dsv4_repl not in dsv4_text and dsv4_needle in dsv4_text:')
|
||||
runner_lines.append(' bak = dsv4_path.with_suffix(dsv4_path.suffix + ".odysseus_bak")')
|
||||
runner_lines.append(' if not bak.exists(): bak.write_text(dsv4_text)')
|
||||
runner_lines.append(' dsv4_path.write_text(dsv4_text.replace(dsv4_needle, dsv4_repl))')
|
||||
runner_lines.append(' print("[odysseus] Patched MLX-LM DeepSeek-V4 hyper-connection key aliases.", file=sys.stderr)')
|
||||
runner_lines.append(' except Exception as e:')
|
||||
runner_lines.append(' print("[odysseus] WARNING: failed to apply MLX DeepSeek-V4 compatibility patch:", e, file=sys.stderr)')
|
||||
runner_lines.append(' try:')
|
||||
runner_lines.append(' src = Path(best)')
|
||||
runner_lines.append(' shim = Path.home() / ".cache" / "odysseus" / "mlx-shims" / src.name')
|
||||
runner_lines.append(' if len(src.name) > 20:')
|
||||
runner_lines.append(' shim = Path.home() / ".cache" / "odysseus" / "mlx-shims" / model.split("/")[-1]')
|
||||
runner_lines.append(' shim.mkdir(parents=True, exist_ok=True)')
|
||||
runner_lines.append(' for child in src.iterdir():')
|
||||
runner_lines.append(' target = shim / child.name')
|
||||
runner_lines.append(' if child.name == "tokenizer_config.json":')
|
||||
runner_lines.append(' continue')
|
||||
runner_lines.append(' if target.exists() or target.is_symlink():')
|
||||
runner_lines.append(' continue')
|
||||
runner_lines.append(' target.symlink_to(child)')
|
||||
runner_lines.append(' tc_path = src / "tokenizer_config.json"')
|
||||
runner_lines.append(' if tc_path.exists():')
|
||||
runner_lines.append(' tc = json.loads(tc_path.read_text())')
|
||||
runner_lines.append(' if tc.get("tool_parser_type") == "deepseek_v4":')
|
||||
runner_lines.append(' tc.pop("tool_parser_type", None)')
|
||||
runner_lines.append(' (shim / "tokenizer_config.json").write_text(json.dumps(tc, indent=2, ensure_ascii=False))')
|
||||
runner_lines.append(' launch_model = str(shim)')
|
||||
runner_lines.append(' print("[odysseus] MLX DeepSeek-V4 using sanitized shim:", launch_model, file=sys.stderr)')
|
||||
runner_lines.append(' except Exception as e:')
|
||||
runner_lines.append(' print("[odysseus] WARNING: failed to create MLX DeepSeek-V4 shim:", e, file=sys.stderr)')
|
||||
runner_lines.append(' parts[i + 1] = launch_model')
|
||||
runner_lines.append(' else:')
|
||||
runner_lines.append(' print("[odysseus] MLX cached snapshot not found for:", model, file=sys.stderr)')
|
||||
runner_lines.append('print(shlex.join(parts))')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('fi')
|
||||
elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd:
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$(python3 -c "import torch, diffusers" 2>&1)"; then')
|
||||
@@ -2021,8 +2493,12 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines,
|
||||
keep_shell_open=not local_windows,
|
||||
)
|
||||
if "vllm serve" in req.cmd:
|
||||
if "vllm serve" in req.cmd or "mlx_lm.server" in req.cmd:
|
||||
runner_lines.append('eval "$ODYSSEUS_SERVE_CMD"')
|
||||
elif is_pip_install:
|
||||
if not is_windows and (req.platform or "").lower() in {"darwin", "macos"}:
|
||||
req.cmd = _pip_install_command_without_break_system_packages(req.cmd)
|
||||
_append_pip_install_runner_lines(runner_lines, req.cmd)
|
||||
else:
|
||||
runner_lines.append(req.cmd)
|
||||
if local_windows:
|
||||
@@ -2071,7 +2547,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
setup_cmd = (
|
||||
f"{scp_extras}"
|
||||
f"scp -O {_Pf}-q '{runner_path}' {remote}:{remote_runner} && "
|
||||
f"ssh {_pf}{remote} 'chmod +x {remote_runner} && tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
|
||||
f"ssh {_pf}{remote} {shlex.quote(_remote_tmux_launch_command(session_id, remote_runner))}"
|
||||
)
|
||||
else:
|
||||
setup_cmd = f"tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} {shlex.quote(str(runner_path))}"
|
||||
@@ -2400,6 +2876,59 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
gpus[0]["busy"] = True
|
||||
return gpus
|
||||
|
||||
async def _probe_apple_unified_memory(host: str | None, ssh_port: str | None) -> dict | None:
|
||||
"""Best-effort Apple Silicon unified-memory probe, local or over SSH."""
|
||||
cmd = (
|
||||
"uname -s; uname -m; "
|
||||
"sysctl -n hw.memsize 2>/dev/null || true; "
|
||||
"vm_stat 2>/dev/null | awk '"
|
||||
"/page size of/ {gsub(/[^0-9]/, \"\", $8); page=$8} "
|
||||
"/Pages free/ {gsub(/[^0-9]/, \"\", $3); free=$3} "
|
||||
"/Pages inactive/ {gsub(/[^0-9]/, \"\", $3); inactive=$3} "
|
||||
"/Pages speculative/ {gsub(/[^0-9]/, \"\", $3); speculative=$3} "
|
||||
"/Pages purgeable/ {gsub(/[^0-9]/, \"\", $3); purgeable=$3} "
|
||||
"END {if (!page) page=16384; print page, free+inactive+speculative+purgeable}'"
|
||||
)
|
||||
out, err = await _run_gpu_shell(cmd, host, ssh_port, timeout=6)
|
||||
if err is not None or not out:
|
||||
return None
|
||||
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
|
||||
if len(lines) < 4:
|
||||
return None
|
||||
if lines[0] != "Darwin" or lines[1] not in {"arm64", "arm64e"}:
|
||||
return None
|
||||
try:
|
||||
total_bytes = int(lines[2])
|
||||
page_parts = lines[3].split()
|
||||
page_size = int(page_parts[0])
|
||||
available_pages = int(page_parts[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
if total_bytes <= 0:
|
||||
return None
|
||||
total_mb = int(total_bytes / (1024 * 1024))
|
||||
free_mb = max(0, min(total_mb, int((page_size * available_pages) / (1024 * 1024))))
|
||||
used_mb = max(0, total_mb - free_mb)
|
||||
return {
|
||||
"ok": True,
|
||||
"gpus": [{
|
||||
"index": 0,
|
||||
"name": "Apple Silicon unified memory",
|
||||
"uuid": "apple-metal-0",
|
||||
"free_mb": free_mb,
|
||||
"total_mb": total_mb,
|
||||
"used_mb": used_mb,
|
||||
"util_pct": 0,
|
||||
"busy": bool(total_mb and (free_mb / total_mb) < 0.2),
|
||||
"processes": [],
|
||||
"backend": "metal",
|
||||
"source": "apple-vm-stat",
|
||||
"unified_memory": True,
|
||||
}],
|
||||
"backend": "metal",
|
||||
"source": "apple-vm-stat",
|
||||
}
|
||||
|
||||
@router.get("/api/cookbook/gpus")
|
||||
async def list_gpus(request: Request, host: str | None = None, ssh_port: str | None = None):
|
||||
"""Probe GPU memory/process state locally or via SSH.
|
||||
@@ -2529,6 +3058,12 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Apple Metal GPU fallback failed: %s", e)
|
||||
|
||||
apple_gpus = await _probe_apple_unified_memory(host, ssh_port)
|
||||
if apple_gpus:
|
||||
apple_gpus["fallback_from"] = "nvidia-smi"
|
||||
apple_gpus["nvidia_error"] = nvidia_error
|
||||
return apple_gpus
|
||||
|
||||
amd_gpus = await _probe_amd_sysfs(host, ssh_port)
|
||||
if amd_gpus:
|
||||
# The per-GPU dict already carries the runtime label picked by
|
||||
@@ -2974,7 +3509,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
try:
|
||||
ls = subprocess.run(
|
||||
ssh_base + [host, "tmux ls 2>/dev/null"],
|
||||
ssh_base + [host, _remote_tmux_command("ls")],
|
||||
timeout=6, capture_output=True, text=True,
|
||||
)
|
||||
except Exception:
|
||||
@@ -2987,7 +3522,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
continue
|
||||
try:
|
||||
cap = subprocess.run(
|
||||
ssh_base + [host, "tmux", "capture-pane", "-t", sid, "-p", "-S", "-300"],
|
||||
ssh_base + [host, _remote_tmux_command("capture-pane", "-t", sid, "-p", "-S", "-300")],
|
||||
timeout=6, capture_output=True, text=True,
|
||||
)
|
||||
pane = cap.stdout or ""
|
||||
@@ -3009,16 +3544,27 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
m_repo = re.search(r"snapshot_download\(\s*repo_id\s*=\s*['\"]([^'\"]+/[^'\"]+)['\"]", script_text)
|
||||
if not m_repo:
|
||||
m_repo = re.search(r"(?:https://huggingface\.co/)?([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", script_text)
|
||||
if not m_repo and pane:
|
||||
m_cache = re.search(r"models--([A-Za-z0-9_.-]+)--([A-Za-z0-9_.-]+)", pane)
|
||||
if m_cache:
|
||||
repo_id = f"{m_cache.group(1)}/{m_cache.group(2)}"
|
||||
repo_id = m_repo.group(1) if m_repo else f"adopted:{sid}"
|
||||
adopted_download_done = "DOWNLOAD_OK" in pane
|
||||
if repo_id.startswith("adopted:") and pane:
|
||||
m_cache = re.search(r"models--([A-Za-z0-9_.-]+)--([A-Za-z0-9_.-]+)", pane)
|
||||
if m_cache:
|
||||
repo_id = f"{m_cache.group(1)}/{m_cache.group(2)}"
|
||||
import time as _t2
|
||||
tasks.append({
|
||||
"id": sid,
|
||||
"sessionId": sid,
|
||||
"name": repo_id.split("/")[-1] if "/" in repo_id else repo_id,
|
||||
"type": "download",
|
||||
"status": "running",
|
||||
"status": "completed" if adopted_download_done else "running",
|
||||
"progress": "Download complete" if adopted_download_done else "",
|
||||
"output": (pane or f"Auto-adopted from orphan tmux download session on {host}.")[-5000:],
|
||||
"ts": int(_t2.time() * 1000),
|
||||
"completedAt": int(_t2.time() * 1000) if adopted_download_done else None,
|
||||
"payload": {
|
||||
"repo_id": repo_id,
|
||||
"remote_host": host,
|
||||
@@ -3027,6 +3573,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"remoteHost": host,
|
||||
"sshPort": sport,
|
||||
"platform": "linux",
|
||||
"_cacheComplete": adopted_download_done,
|
||||
"_adoptedExternally": True,
|
||||
})
|
||||
known_sids.add(sid)
|
||||
@@ -3055,7 +3602,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
except Exception:
|
||||
cur = []
|
||||
LIVE_PROCS = {"python", "python3", "vllm", "llama-server",
|
||||
"llama_cpp_main", "sglang", "lmdeploy",
|
||||
"llama_cpp_main", "sglang", "mlx_lm", "lmdeploy",
|
||||
"ollama", "node", "uvicorn"}
|
||||
if not any(c in LIVE_PROCS for c in cur):
|
||||
continue
|
||||
@@ -3635,13 +4182,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ssh_base = ["ssh"]
|
||||
if _tport and _tport != "22":
|
||||
ssh_base.extend(["-p", str(_tport)])
|
||||
check_cmd = ssh_base + [remote, "tmux", "has-session", "-t", session_id]
|
||||
check_cmd = ssh_base + [remote, _remote_tmux_command("has-session", "-t", session_id)]
|
||||
# Capture 500 lines (was 50) so a Python traceback survives
|
||||
# the post-crash neofetch banner + bash prompt that otherwise
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# can't diagnose the actual error.
|
||||
capture_cmd = ssh_base + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
||||
elif IS_WINDOWS:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -3655,7 +4202,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
local_win_task = (not remote) and IS_WINDOWS
|
||||
|
||||
progress_text = ""
|
||||
full_snapshot = ""
|
||||
full_snapshot = (task.get("output") or "")[-12000:] if task_type == "serve" else ""
|
||||
|
||||
if local_win_task:
|
||||
# File-based liveness + output for the detached-process model.
|
||||
@@ -3684,9 +4231,14 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# stopped tasks). The agent's `list_served_models` call
|
||||
# was blocking the chat stream every time.
|
||||
_task_status = (task.get("status") or "").lower()
|
||||
_persisted_serve_ready = (
|
||||
task_type == "serve"
|
||||
and bool(full_snapshot)
|
||||
and _parse_serve_phase(full_snapshot, task_type).get("status") == "ready"
|
||||
)
|
||||
if _task_status in {"stopped", "done", "completed",
|
||||
"crashed", "error", "failed",
|
||||
"ended", "killed"}:
|
||||
"ended", "killed"} and not _persisted_serve_ready:
|
||||
is_alive = False
|
||||
# Keep the persisted output_tail for the UI — it's
|
||||
# what the agent uses to diagnose past failures.
|
||||
@@ -3790,7 +4342,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
# Parse structured phase info — single source of truth for the UI
|
||||
phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and full_snapshot) else {}
|
||||
if phase_info.get("status") == "ready":
|
||||
if phase_info.get("status") == "ready" and is_alive:
|
||||
status = "ready"
|
||||
serve_phase = phase_info.get("phase", "")
|
||||
diagnosis = _diagnose_serve_output(full_snapshot) if task_type == "serve" and full_snapshot else None
|
||||
|
||||
Reference in New Issue
Block a user