Merge pull request #792 from andrewyng/feat/llamacpp-guided-setup

Guided llama.cpp setup on the Mac (UX-057)
This commit is contained in:
Rohit Prasad
2026-10-26 12:44:44 -07:00
committed by GitHub
19 changed files with 2507 additions and 28 deletions
+55
View File
@@ -0,0 +1,55 @@
{
"version": 1,
"about": "Models the llama.cpp setup suggests (UX-057). Read by coworker/providers/llamacpp_setup.py. Each names a Hugging Face repo and the GGUF files OpenWorker downloads into llama.cpp's own cache; `draft` is a second file for speculative decoding (`spec_type`). `size` is the bytes of all files, for the fit advice; a model too large for the machine is not offered. `ollama` is the same model's name in Ollama, for the download command shown next to it. `license` and `license_url` are shown before Download, read from the model card on Hugging Face (`license_source` says where; checked 2026-10-26). Edit in git; give a source.",
"models": [
{
"id": "nemotron-3.5-lightning",
"name": "Nemotron 3.5 Lightning",
"vendor": "NVIDIA",
"quant": "Q8 + MTP",
"repo": "ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF",
"file": "NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf",
"draft": "mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf",
"spec_type": "draft-mtp",
"size": 35761208832,
"context_max": 1048576,
"features": ["thinking", "tools"],
"license": "OpenMDW-1.1",
"license_url": "https://openmdw.ai/license/1-1/",
"license_source": "https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16 (model card: license: other, license_name: openmdw-1.1; the GGUF repo only says other)",
"source": "https://huggingface.co/ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF"
},
{
"id": "qwen3-coder-30b",
"name": "Qwen3 Coder 30B",
"vendor": "Alibaba",
"quant": "Q4",
"repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"file": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"size": 18556689568,
"context_max": 262144,
"features": ["tools"],
"license": "Apache-2.0",
"license_url": "https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct/blob/main/LICENSE",
"license_source": "https://huggingface.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF (model card: license: apache-2.0, license_link as above)",
"ollama": "qwen3-coder:30b",
"source": "https://huggingface.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF"
},
{
"id": "gpt-oss-20b",
"name": "gpt-oss 20B",
"vendor": "OpenAI",
"quant": "MXFP4",
"repo": "ggml-org/gpt-oss-20b-GGUF",
"file": "gpt-oss-20b-MXFP4.gguf",
"size": 12109566624,
"context_max": 131072,
"features": ["reasoning", "tools"],
"license": "Apache-2.0",
"license_url": "https://huggingface.co/openai/gpt-oss-20b/blob/main/LICENSE",
"license_source": "https://huggingface.co/ggml-org/gpt-oss-20b-GGUF (model card: license: apache-2.0)",
"ollama": "gpt-oss:20b",
"source": "https://huggingface.co/ggml-org/gpt-oss-20b-GGUF"
}
]
}
+911
View File
@@ -0,0 +1,911 @@
"""llama.cpp on this Mac, set up and run by OpenWorker (UX-057).
The llama.cpp page offers three steps: get llama.cpp (Homebrew), pick a model sized to
this machine, and start it. After that OpenWorker runs `llama-server` itself, one model at
a time, and points the `llamacpp` provider at it. "Connect to it instead" (a server the
user runs) stays the plain provider form; nothing here touches it unless the user starts
a model.
Where models go: llama.cpp's own cache, the Hugging Face layout `llama-server -hf` uses
(`$LLAMA_CACHE`, else the Hugging Face hub cache, `~/.cache/huggingface/hub`). OpenWorker
downloads the files itself, into that layout, because `-hf` cannot fetch a model's second
file (the MTP draft) and gives no progress; a later `llama-server -hf` finds them there.
Models Ollama already downloaded are GGUF files too, and are loaded in place.
The desktop app ends its server with SIGKILL, so no shutdown code runs on quit. Two guards
keep a model from holding memory after OpenWorker is gone: a small watcher process kills
llama-server when this server exits, and the pid file lets the next start stop one left
over.
The GUI polls `state()` while something runs, like the sandbox setup (setup_job.py).
"""
from __future__ import annotations
import datetime
import functools
import hashlib
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
from collections import deque
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any, Callable, Optional
import httpx
from . import local_machine
from .recommended import recommendation_for
_CATALOG = Path(__file__).with_name("llamacpp_models.json")
HF = "https://huggingface.co"
DEFAULT_PORT = 8080
LOAD_TIMEOUT_S = 900.0
CHECK_TIMEOUT_S = 180.0
INSTALL_LINES = 6
# Hugging Face answers 429 with how long to wait (per-IP windows of five minutes). Wait
# that long, within reason, a few times; then give up with the error.
RATE_LIMIT_RETRIES = 6
RATE_LIMIT_WAIT_MAX_S = 120
RATE_LIMIT_WAIT_DEFAULT_S = 5
# Where Homebrew puts programs on Apple silicon and on Intel Macs; the desktop app's PATH
# often has neither.
_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin")
def supported() -> bool:
"""The guided setup is Mac-only for now (ruled 2026-10-06)."""
return sys.platform == "darwin"
def _which(program: str) -> Optional[str]:
found = shutil.which(program)
if found:
return found
for d in _BIN_DIRS:
p = Path(d) / program
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
def find_llama_server() -> Optional[str]:
return _which("llama-server")
def find_brew() -> Optional[str]:
return _which("brew")
def llama_version(path: Optional[str]) -> Optional[str]:
"""`llama-server --version` prints `version: 0.6.0 (build …)` on stderr."""
if not path:
return None
try:
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15)
except (OSError, subprocess.TimeoutExpired):
return None
m = re.search(r"version:\s*([^\s(]+)", (out.stdout or "") + (out.stderr or ""))
return m.group(1) if m else None
def cache_dir() -> Path:
"""The folder llama.cpp keeps downloads in, resolved the way llama.cpp 0.6 does."""
env = os.environ
if env.get("LLAMA_CACHE"):
return Path(env["LLAMA_CACHE"]).expanduser()
for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"):
if env.get(key):
return Path(env[key]).expanduser()
if env.get("HF_HOME"):
return Path(env["HF_HOME"]).expanduser() / "hub"
if env.get("XDG_CACHE_HOME"):
return Path(env["XDG_CACHE_HOME"]).expanduser() / "huggingface" / "hub"
return Path.home() / ".cache" / "huggingface" / "hub"
def _pretty(path: Path) -> str:
home = str(Path.home())
s = str(path)
return "~" + s[len(home):] if s.startswith(home) else s
def _free_bytes(path: Path) -> Optional[int]:
p = path
while not p.exists() and p != p.parent:
p = p.parent
try:
return shutil.disk_usage(p).free
except OSError:
return None
class _Cancelled(Exception):
pass
# -- the models it offers --------------------------------------------------------------------
@functools.lru_cache(maxsize=1)
def catalog() -> tuple[dict[str, Any], ...]:
try:
raw = json.loads(_CATALOG.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return ()
return tuple(raw.get("models") or ())
def _catalog_entry(model_id: str) -> Optional[dict[str, Any]]:
return next((m for m in catalog() if m.get("id") == model_id), None)
def _repo_dir(repo: str) -> Path:
return cache_dir() / ("models--" + repo.replace("/", "--"))
def _cached_file(repo: str, filename: str) -> Optional[Path]:
"""A file already in the cache (any snapshot), else None."""
snaps = _repo_dir(repo) / "snapshots"
if not snaps.is_dir():
return None
for snap in snaps.iterdir():
p = snap / filename
if p.exists():
return p
return None
def _downloaded(entry: dict[str, Any]) -> bool:
files = [entry["file"], *([entry["draft"]] if entry.get("draft") else [])]
return all(_cached_file(entry["repo"], f) for f in files)
def ollama_models_dir() -> Path:
env = os.environ.get("OLLAMA_MODELS", "").strip()
return Path(env).expanduser() if env else Path.home() / ".ollama" / "models"
def ollama_models() -> list[dict[str, Any]]:
"""The models Ollama downloaded, each with its GGUF file, read from Ollama's manifests
(no Ollama server needed). Names as Ollama shows them: `qwen3-coder:30b`."""
root = ollama_models_dir()
manifests = root / "manifests"
if not manifests.is_dir():
return []
out: list[dict[str, Any]] = []
for path in sorted(manifests.rglob("*")):
if not path.is_file():
continue
parts = path.relative_to(manifests).parts # host / namespace / model / tag
if len(parts) != 4:
continue
host, ns, model, tag = parts
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
layer = next(
(l for l in doc.get("layers") or [] if l.get("mediaType") == "application/vnd.ollama.image.model"),
None,
)
if not layer or not str(layer.get("digest", "")).startswith("sha256:"):
continue
blob = root / "blobs" / layer["digest"].replace(":", "-")
if not blob.is_file():
continue
if host == "registry.ollama.ai":
name = f"{model}:{tag}" if ns == "library" else f"{ns}/{model}:{tag}"
else:
name = f"{host}/{ns}/{model}:{tag}"
out.append({"name": name, "path": str(blob), "size": int(layer.get("size") or 0)})
return out
def overview() -> dict[str, Any]:
"""Everything the setup card shows: whether llama.cpp is here, where models go, what
to run, and what is running."""
memory = local_machine.model_memory_bytes()
server = find_llama_server()
have_ollama = {m["name"] for m in ollama_models()}
suggested = []
ollama_pull = []
for m in catalog():
fit = local_machine.fit_for(m.get("size"), memory)
if fit == "too_large":
continue
suggested.append(
{
"id": m["id"],
"name": m["name"],
"vendor": m.get("vendor"),
"quant": m.get("quant"),
"features": m.get("features") or [],
"size": m.get("size"),
"fit": fit,
"downloaded": _downloaded(m),
"license": m.get("license"),
"license_url": m.get("license_url"),
}
)
if m.get("ollama") and m["ollama"] not in have_ollama:
ollama_pull.append({"name": m["ollama"], "size": m.get("size"), "fit": fit, "command": f"ollama pull {m['ollama']}"})
on_mac = [
{**m, "fit": local_machine.fit_for(m["size"], memory)}
for m in ollama_models()
if local_machine.fit_for(m["size"], memory) != "too_large"
]
cache = cache_dir()
return {
"supported": supported(),
"brew": find_brew(),
"installed": {"path": server, "version": llama_version(server)} if server else None,
"install_command": "brew install llama.cpp",
"cache_dir": _pretty(cache),
"cache_free_bytes": _free_bytes(cache),
"suggested": suggested,
"ollama": on_mac,
"ollama_pull": ollama_pull,
**runner().state(),
}
# -- downloads into llama.cpp's cache --------------------------------------------------------
def _retry_after(resp: httpx.Response) -> int:
"""Seconds to wait after a 429, from `Retry-After` (seconds or a date), `RateLimit-Reset`,
or Hugging Face's `RateLimit: "resolvers";r=0;t=52` — bounded, at least one second."""
h = resp.headers
wait: Optional[float] = None
raw = h.get("retry-after")
if raw:
try:
wait = float(raw)
except ValueError:
try:
wait = (parsedate_to_datetime(raw) - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
except (TypeError, ValueError):
wait = None
if wait is None and h.get("ratelimit-reset"):
try:
wait = float(h["ratelimit-reset"])
except ValueError:
wait = None
if wait is None and h.get("ratelimit"):
m = re.search(r"\bt=(\d+)", h["ratelimit"])
wait = float(m.group(1)) if m else None
if wait is None:
wait = RATE_LIMIT_WAIT_DEFAULT_S
return int(min(max(wait, 1), RATE_LIMIT_WAIT_MAX_S))
def _hf_request(method: str, url: str, *, on_wait: Optional[Callable[[int], None]] = None, **kw: Any) -> httpx.Response:
"""One Hugging Face request; on 429 it waits as told and tries again, a few times."""
kw.setdefault("timeout", 30)
for attempt in range(RATE_LIMIT_RETRIES + 1):
resp = httpx.request(method, url, **kw)
if resp.status_code != 429 or attempt == RATE_LIMIT_RETRIES:
return resp
wait = _retry_after(resp)
if on_wait:
on_wait(wait)
else:
time.sleep(wait)
return resp # pragma: no cover - the loop returns
def _hf_files(repo: str, on_wait: Optional[Callable[[int], None]] = None) -> tuple[str, dict[str, dict[str, Any]]]:
"""(commit, {path: {size, oid, sha256}}) for a repo's main branch. `sha256` is the LFS
object id, the checksum the download is verified against; None for a non-LFS file."""
rev = _hf_request("GET", f"{HF}/api/models/{repo}/revision/main", on_wait=on_wait, follow_redirects=True)
rev.raise_for_status()
commit = rev.json()["sha"]
tree = _hf_request("GET", f"{HF}/api/models/{repo}/tree/{commit}", on_wait=on_wait, follow_redirects=True)
tree.raise_for_status()
files = {}
for f in tree.json():
lfs = f.get("lfs") or {}
oid = lfs.get("oid") or f.get("oid")
files[f["path"]] = {"size": int(lfs.get("size") or f.get("size") or 0), "oid": oid, "sha256": lfs.get("oid")}
return commit, files
def _hf_resolve_info(repo: str, filename: str, on_wait: Optional[Callable[[int], None]] = None) -> tuple[str, dict[str, Any]]:
"""What the file's own download address says about it, for when the API is not
answering: the 302 to the CDN carries the commit, the size and the ETag. The ETag names
the blob as huggingface_hub does; it is not trusted as a checksum."""
resp = _hf_request("HEAD", f"{HF}/{repo}/resolve/main/{filename}", on_wait=on_wait, follow_redirects=False)
if resp.status_code not in (200, 302, 307):
raise RuntimeError(f"download failed: HTTP {resp.status_code}")
h = resp.headers
etag = (h.get("x-linked-etag") or h.get("etag") or "").strip().removeprefix("W/").strip('"')
size = int(h.get("x-linked-size") or h.get("content-length") or 0)
if not etag or not size:
raise RuntimeError(f"download failed: no size for {filename}")
return h.get("x-repo-commit") or "main", {"size": size, "oid": etag, "sha256": None}
def _sha256(path: Path, cancel: Optional[threading.Event] = None) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
while True:
if cancel is not None and cancel.is_set():
raise _Cancelled()
chunk = fh.read(8 << 20)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def _place(repo: str, commit: str, filename: str, blob: Path) -> Path:
"""Link a downloaded blob into the snapshot folder, as huggingface_hub does."""
snap = _repo_dir(repo) / "snapshots" / commit
snap.mkdir(parents=True, exist_ok=True)
target = snap / filename
if not target.exists():
try:
target.symlink_to(os.path.relpath(blob, snap))
except OSError:
shutil.copyfile(blob, target)
refs = _repo_dir(repo) / "refs"
refs.mkdir(parents=True, exist_ok=True)
(refs / "main").write_text(commit, encoding="utf-8")
return target
# -- the managed server ---------------------------------------------------------------------
def _state_path(name: str) -> Path:
from ..secrets import state_dir
return state_dir() / name
def _load_settings() -> dict[str, Any]:
try:
data = json.loads(_state_path("llamacpp.json").read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def _save_settings(data: dict[str, Any]) -> None:
p = _state_path("llamacpp.json")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# As llama-server binds: a port a stopped server just left (TIME_WAIT) is free.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _pick_port() -> int:
for port in range(DEFAULT_PORT, DEFAULT_PORT + 20):
if _port_free(port):
return port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _is_llama_server(pid: int) -> bool:
try:
out = subprocess.run(["ps", "-p", str(pid), "-o", "comm="], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return False
return out.stdout.strip().endswith("llama-server")
def _kill(pid: int) -> None:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
return
for _ in range(50):
try:
os.kill(pid, 0)
except OSError:
return
time.sleep(0.1)
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
def _end(proc: subprocess.Popen) -> None:
"""Stop a child of ours: SIGTERM, then SIGKILL after 5 s. Waiting on it also reaps it
(a zombie still answers `kill -0`, so `_kill` would wait the full time)."""
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(5)
class LlamaCppRunner:
"""Installs llama.cpp and runs one llama-server. `phase` is one of idle, installing,
downloading, loading, checking, running, failed. One thing at a time."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._cancel = threading.Event()
self._thread: Optional[threading.Thread] = None
self._proc: Optional[subprocess.Popen] = None
self._watcher: Optional[subprocess.Popen] = None
self.phase = "idle"
self.lines: deque[str] = deque(maxlen=INSTALL_LINES)
self.progress: Optional[dict[str, Any]] = None
self.model: Optional[dict[str, Any]] = None
self.error: Optional[str] = None
self.check: Optional[dict[str, bool]] = None
self.port: Optional[int] = None
# "unverified" when a download could not be checked against its checksum.
self.note: Optional[str] = None
# Seconds until the next try while Hugging Face rate-limits us.
self.retry_in: Optional[int] = None
# Set by the manager: point the provider at the server and tick its model.
self.on_running: Optional[Callable[[str, str], None]] = None
# -- reading -----------------------------------------------------------------------
def busy(self) -> bool:
return self.phase in ("installing", "downloading", "loading", "checking")
def state(self) -> dict[str, Any]:
settings = _load_settings()
with self._lock:
return {
"phase": self.phase,
"lines": list(self.lines),
"progress": dict(self.progress) if self.progress else None,
"model": dict(self.model) if self.model else None,
"error": self.error,
"check": dict(self.check) if self.check else None,
"note": self.note,
"retry_in": self.retry_in,
"address": f"127.0.0.1:{self.port}" if self.port and self.phase == "running" else None,
"autostart": bool(settings.get("autostart")),
"last_choice": settings.get("choice"),
}
# -- install ------------------------------------------------------------------------
def install(self) -> dict[str, Any]:
brew = find_brew()
if not brew:
return {"ok": False, "error": "Homebrew is not installed"}
return self._run("installing", lambda: self._install(brew))
def _install(self, brew: str) -> None:
env = {**os.environ, "HOMEBREW_NO_ENV_HINTS": "1", "NONINTERACTIVE": "1"}
env["PATH"] = ":".join([*_BIN_DIRS, env.get("PATH", "")])
proc = subprocess.Popen(
[brew, "install", "llama.cpp"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
text=True,
env=env,
)
assert proc.stdout is not None
for line in proc.stdout:
if self._cancel.is_set():
proc.kill()
raise _Cancelled()
line = line.rstrip()
if line:
with self._lock:
self.lines.append(line[:200])
proc.wait()
if proc.returncode != 0 or not find_llama_server():
last = self.lines[-1] if self.lines else f"brew exited with {proc.returncode}"
raise RuntimeError(last)
with self._lock:
self.phase = "idle"
# -- start --------------------------------------------------------------------------
def start(self, choice: dict[str, Any]) -> dict[str, Any]:
"""Run a model: `{"kind": "suggested", "id": …}`, `{"kind": "ollama", "name": …}` or
`{"kind": "file", "path": …}`. Stops the one running first."""
try:
plan = self._plan(choice)
except ValueError as e:
return {"ok": False, "error": str(e)}
return self._run("downloading" if plan.get("download") else "loading", lambda: self._start(choice, plan))
def _plan(self, choice: dict[str, Any]) -> dict[str, Any]:
kind = (choice or {}).get("kind")
if kind == "suggested":
entry = _catalog_entry(str(choice.get("id") or ""))
if not entry:
raise ValueError("unknown model")
return {
"name": entry["name"],
"detail": entry.get("quant"),
"alias": entry["id"],
"download": entry,
"spec_type": entry.get("spec_type"),
"context_max": entry.get("context_max"),
}
if kind == "ollama":
name = str(choice.get("name") or "")
found = next((m for m in ollama_models() if m["name"] == name), None)
if not found:
raise ValueError(f"Ollama has no model named {name}")
return {"name": name, "detail": "from Ollama", "alias": name, "file": found["path"]}
if kind == "file":
path = Path(str(choice.get("path") or "")).expanduser()
if not path.is_file() or path.suffix.lower() != ".gguf":
raise ValueError("choose a .gguf file")
plan: dict[str, Any] = {"name": path.stem, "detail": "your file", "alias": path.stem, "file": str(path)}
# A multi-token-prediction draft saved beside it (`mtp-<name>`), as ggml-org ships them.
draft = path.with_name("mtp-" + path.name)
if draft.is_file():
plan.update(draft=str(draft), spec_type="draft-mtp", detail="your file + MTP")
return plan
raise ValueError("unknown choice")
def _start(self, choice: dict[str, Any], plan: dict[str, Any]) -> None:
server = find_llama_server()
if not server:
raise RuntimeError("llama.cpp is not installed")
self._stop_process()
with self._lock:
self.model = {
"name": plan["name"],
"detail": plan.get("detail"),
"alias": plan["alias"],
"download": bool(plan.get("download")) and not _downloaded(plan["download"]),
}
self.check = None
if plan.get("download"):
plan = {**plan, **self._download(plan["download"])}
with self._lock:
self.phase = "loading"
self.progress = None
memory = local_machine.model_memory_bytes()
rec = recommendation_for(plan["alias"])
model_max = plan.get("context_max") or (rec.context_max if rec else None)
context = local_machine.recommended_context(model_max, memory) or 32_768
port = _pick_port()
args = [server, "-m", plan["file"], "--alias", plan["alias"], "-c", str(context), "--jinja",
"--host", "127.0.0.1", "--port", str(port)]
if plan.get("draft"):
args += ["--spec-type", plan.get("spec_type") or "draft-mtp", "--spec-draft-model", plan["draft"]]
log = _state_path("llamacpp-server.log")
log.parent.mkdir(parents=True, exist_ok=True)
with open(log, "wb") as fh:
proc = subprocess.Popen(args, stdout=fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
watcher = subprocess.Popen(
["/bin/sh", "-c", 'while kill -0 "$0" 2>/dev/null; do sleep 2; done; kill "$1" 2>/dev/null', str(os.getpid()), str(proc.pid)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
)
_state_path("llamacpp.pid").write_text(json.dumps({"pid": proc.pid, "watcher": watcher.pid}), encoding="utf-8")
with self._lock:
self._proc, self._watcher, self.port = proc, watcher, port
self.model = {**(self.model or {}), "context": context, "file": _pretty(Path(plan["file"]))}
self._wait_loaded(proc, port, log)
with self._lock:
self.phase = "checking"
check = self._check(port, plan["alias"])
with self._lock:
self.check = check
if not check.get("answers"):
self._stop_process()
raise RuntimeError("the model loaded but did not answer")
settings = _load_settings()
settings["choice"] = choice
_save_settings(settings)
if self.on_running:
self.on_running(f"http://127.0.0.1:{port}", plan["alias"])
with self._lock:
self.phase = "running"
threading.Thread(target=self._watch_exit, args=(proc, log), daemon=True).start()
def _download(self, entry: dict[str, Any]) -> dict[str, Any]:
wanted = [entry["file"], *([entry["draft"]] if entry.get("draft") else [])]
have = {f: _cached_file(entry["repo"], f) for f in wanted}
if all(have.values()):
return {"file": str(have[entry["file"]]), "draft": str(have[entry["draft"]]) if entry.get("draft") else None}
missing = [f for f in wanted if not have[f]]
try:
# Once, at the start: the commit, each file's size and its sha256 (the LFS id).
commit, files = _hf_files(entry["repo"], self._rate_wait)
except (httpx.HTTPError, KeyError, ValueError):
# The API is not answering (down, or its own rate limit); the files come from
# another address with its own limit, so download anyway, unverified.
commit, files = "", {}
for f in missing:
c, info = _hf_resolve_info(entry["repo"], f, self._rate_wait)
commit = commit or c
files[f] = info
with self._lock:
self.note = "unverified"
for f in missing:
if f not in files:
raise RuntimeError(f"{f} is no longer in {entry['repo']}")
total = sum(files[f]["size"] for f in missing)
free = _free_bytes(cache_dir())
if free is not None and free < total:
raise RuntimeError(f"not enough free space: {total // 10**9} GB needed, {free // 10**9} GB free")
done_before = 0
started = time.monotonic()
for f in missing:
blobs = _repo_dir(entry["repo"]) / "blobs"
blobs.mkdir(parents=True, exist_ok=True)
blob = blobs / files[f]["oid"]
part = blob.with_name(blob.name + ".incomplete")
size = files[f]["size"]
if not (blob.is_file() and blob.stat().st_size == size):
url = f"{HF}/{entry['repo']}/resolve/{commit}/{f}"
for attempt in range(RATE_LIMIT_RETRIES + 1):
wait = self._fetch(url, part, size, done_before, total, started)
if wait is None:
break
if attempt == RATE_LIMIT_RETRIES:
raise RuntimeError("download failed: HTTP 429 (Hugging Face is rate-limiting downloads)")
self._rate_wait(wait)
if part.stat().st_size != size:
raise RuntimeError("download incomplete; start again to resume")
if files[f].get("sha256"):
with self._lock:
self.progress = {**(self.progress or {}), "done": done_before + size, "total": total, "verifying": True}
if _sha256(part, self._cancel) != files[f]["sha256"]:
part.unlink(missing_ok=True)
raise RuntimeError(f"{f} did not match its checksum and was removed; start again to download it afresh")
part.rename(blob)
done_before += size
self._set_progress(done_before, total, started)
have[f] = _place(entry["repo"], commit, f, blob)
return {"file": str(have[entry["file"]]), "draft": str(have[entry["draft"]]) if entry.get("draft") else None}
def _fetch(self, url: str, part: Path, size: int, done_before: int, total: int, started: float) -> Optional[int]:
"""Download `url` into `part`, resuming what is there. None when done; on a 429,
the seconds Hugging Face asks us to wait."""
have_bytes = part.stat().st_size if part.is_file() else 0
headers = {"Range": f"bytes={have_bytes}-"} if have_bytes else {}
with httpx.stream("GET", url, headers=headers, follow_redirects=True, timeout=60) as resp:
if resp.status_code == 429:
return _retry_after(resp)
if resp.status_code == 200:
have_bytes = 0
elif resp.status_code != 206:
raise RuntimeError(f"download failed: HTTP {resp.status_code}")
with open(part, "ab" if have_bytes else "wb") as out:
got = have_bytes
last = 0.0
for chunk in resp.iter_bytes(1 << 20):
if self._cancel.is_set():
raise _Cancelled()
out.write(chunk)
got += len(chunk)
now = time.monotonic()
if now - last > 0.5:
last = now
self._set_progress(done_before + got, total, started)
return None
def _rate_wait(self, seconds: int) -> None:
"""Wait out a 429, a second at a time so the card can count down and Cancel works."""
end = time.monotonic() + seconds
while True:
left = int(end - time.monotonic() + 0.999)
if left <= 0:
break
with self._lock:
self.retry_in = left
if self._cancel.wait(min(1.0, left)):
with self._lock:
self.retry_in = None
raise _Cancelled()
with self._lock:
self.retry_in = None
def _set_progress(self, done: int, total: int, started: float) -> None:
elapsed = max(time.monotonic() - started, 0.001)
rate = done / elapsed if done else 0
left = int((total - done) / rate) if rate > 0 else None
with self._lock:
self.progress = {"done": done, "total": total, "seconds_left": left}
def _wait_loaded(self, proc: subprocess.Popen, port: int, log: Path) -> None:
deadline = time.monotonic() + LOAD_TIMEOUT_S
while time.monotonic() < deadline:
if self._cancel.is_set():
self._stop_process()
raise _Cancelled()
if proc.poll() is not None:
raise RuntimeError(_last_error(log) or f"llama-server exited with {proc.returncode}")
try:
if httpx.get(f"http://127.0.0.1:{port}/health", timeout=2).status_code == 200:
return
except httpx.HTTPError:
pass
time.sleep(1)
self._stop_process()
raise RuntimeError("the model took too long to load")
def _check(self, port: int, alias: str) -> dict[str, bool]:
"""One reply and one tool call, so a model that cannot drive the agent is caught
here and not in the user's first session."""
tool = {
"type": "function",
"function": {
"name": "get_time",
"description": "The current time in a city.",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
def ask(body: dict[str, Any]) -> Optional[dict[str, Any]]:
try:
r = httpx.post(
f"http://127.0.0.1:{port}/v1/chat/completions",
json={"model": alias, "max_tokens": 1024, **body},
timeout=CHECK_TIMEOUT_S,
)
if r.status_code != 200:
return None
return (r.json().get("choices") or [{}])[0].get("message") or {}
except Exception: # noqa: BLE001 - a failed check is reported, not raised
return None
reply = ask({"messages": [{"role": "user", "content": "Say hello in one word."}]})
if reply is None:
return {"answers": False, "tools": False}
call = ask({"messages": [{"role": "user", "content": "What time is it in Paris? Use the tool."}], "tools": [tool]})
return {"answers": True, "tools": bool(call and call.get("tool_calls"))}
def _watch_exit(self, proc: subprocess.Popen, log: Path) -> None:
proc.wait()
with self._lock:
if self._proc is not proc:
return # stopped or replaced on purpose
self._proc = None
self.phase = "failed"
self.error = "llama-server stopped: " + (_last_error(log) or f"exit {proc.returncode}")
# -- stop, cancel, autostart -------------------------------------------------------
def stop(self) -> dict[str, Any]:
self._cancel.set()
self._stop_process()
with self._lock:
self.phase, self.progress, self.error = "idle", None, None
return {"ok": True, **self.state()}
def cancel(self) -> dict[str, Any]:
self._cancel.set()
return {"ok": True}
def _stop_process(self) -> None:
with self._lock:
proc, watcher = self._proc, self._watcher
self._proc = self._watcher = None
if watcher is not None:
_end(watcher)
if proc is not None:
_end(proc)
try:
_state_path("llamacpp.pid").unlink()
except OSError:
pass
def set_autostart(self, on: bool) -> dict[str, Any]:
settings = _load_settings()
settings["autostart"] = bool(on)
_save_settings(settings)
return {"ok": True, **self.state()}
def on_app_start(self) -> None:
"""At server start: stop a llama-server an earlier run left behind, then start the
last model if the user turned on "Start with OpenWorker"."""
try:
left = json.loads(_state_path("llamacpp.pid").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
left = None
if isinstance(left, dict):
pid = left.get("pid")
if isinstance(pid, int) and _is_llama_server(pid):
_kill(pid)
try:
_state_path("llamacpp.pid").unlink()
except OSError:
pass
settings = _load_settings()
if supported() and settings.get("autostart") and settings.get("choice") and find_llama_server():
self.start(settings["choice"])
def shutdown(self) -> None:
self._cancel.set()
self._stop_process()
# -- the worker thread ----------------------------------------------------------------
def _run(self, phase: str, work: Callable[[], None]) -> dict[str, Any]:
with self._lock:
if self.busy():
return {"ok": False, "error": "busy"}
self._cancel.clear()
self.phase, self.error, self.progress = phase, None, None
self.note = self.retry_in = None
self.lines.clear()
def body() -> None:
try:
work()
except _Cancelled:
self._stop_process()
with self._lock:
self.phase, self.progress = "idle", None
except Exception as e: # noqa: BLE001 - shown on the card
with self._lock:
self.phase, self.error = "failed", str(e) or type(e).__name__
self._thread = threading.Thread(target=body, name="llamacpp-setup", daemon=True)
self._thread.start()
return {"ok": True, **self.state()}
def _last_error(log: Path) -> Optional[str]:
"""The most telling line of llama-server's log: the last error, else the last line."""
try:
lines = [l.strip() for l in log.read_text(encoding="utf-8", errors="replace").splitlines() if l.strip()]
except OSError:
return None
errors = [l for l in lines if " E " in l or "error" in l.lower()]
pick = (errors or lines or [None])[-1]
return pick[:200] if pick else None
_runner: Optional[LlamaCppRunner] = None
_runner_lock = threading.Lock()
def runner() -> LlamaCppRunner:
global _runner
with _runner_lock:
if _runner is None:
_runner = LlamaCppRunner()
return _runner
def pick_gguf_file() -> dict[str, Any]:
"""The Mac's file chooser, from the server (a web page cannot learn a file's path)."""
if sys.platform != "darwin":
return {"ok": False, "error": "Mac only"}
script = 'POSIX path of (choose file with prompt "Choose a GGUF model file")'
try:
out = subprocess.run(
["osascript", "-e", 'tell application "System Events" to activate', "-e", script],
capture_output=True, text=True, timeout=600,
)
except (OSError, subprocess.TimeoutExpired):
return {"ok": False, "error": "no file chooser available"}
path = (out.stdout or "").strip()
if out.returncode != 0 or not path:
return {"ok": False, "canceled": True}
return {"ok": True, "path": path}
+45
View File
@@ -216,6 +216,17 @@ def create_app(manager: SessionManager) -> FastAPI:
except Exception: # never let a bad connector stop the server
import traceback
traceback.print_exc()
try:
# A llama-server left by an earlier run is stopped; "Start with OpenWorker"
# starts the last model again (providers/llamacpp_setup.py).
from ..providers import llamacpp_setup
if llamacpp_setup.supported():
await asyncio.to_thread(manager.llamacpp_runner().on_app_start)
except Exception:
import traceback
traceback.print_exc()
yield
openrouter_auth.cancel()
@@ -2415,6 +2426,40 @@ def create_app(manager: SessionManager) -> FastAPI:
def system_get() -> dict[str, Any]:
return manager.system_facts()
# UX-057: llama.cpp set up and run by OpenWorker. GET polls the state while it works.
@app.get("/v1/llamacpp/setup")
async def llamacpp_setup_get() -> dict[str, Any]:
from ..providers import llamacpp_setup
manager.llamacpp_runner()
return await asyncio.to_thread(llamacpp_setup.overview)
@app.post("/v1/llamacpp/install")
def llamacpp_install() -> dict[str, Any]:
return manager.llamacpp_runner().install()
@app.post("/v1/llamacpp/start")
def llamacpp_start(body: dict) -> dict[str, Any]:
return manager.llamacpp_runner().start((body or {}).get("choice") or {})
@app.post("/v1/llamacpp/stop")
async def llamacpp_stop() -> dict[str, Any]:
return await asyncio.to_thread(manager.llamacpp_stop)
@app.post("/v1/llamacpp/cancel")
def llamacpp_cancel() -> dict[str, Any]:
return manager.llamacpp_runner().cancel()
@app.post("/v1/llamacpp/autostart")
def llamacpp_autostart(body: dict) -> dict[str, Any]:
return manager.llamacpp_runner().set_autostart(bool((body or {}).get("on")))
@app.post("/v1/llamacpp/pick-file")
async def llamacpp_pick_file() -> dict[str, Any]:
from ..providers import llamacpp_setup
return await asyncio.to_thread(llamacpp_setup.pick_gguf_file)
@app.post("/v1/providers/verify")
async def providers_verify(body: dict) -> dict[str, Any]:
# Live read-only credential check (sync httpx) — run off the event loop.
+44
View File
@@ -4223,6 +4223,14 @@ class SessionManager:
# A keyless server is "connected" when it answers (cached probes), not
# when a form was saved; the page groups connected providers first.
row["alive"] = self._ollama_alive() if d.name == "ollama" else self._local_server_alive(d.name)
if d.name == "llamacpp":
# UX-057: OpenWorker can set it up and run it here (Mac for now).
from ..providers import llamacpp_setup
if llamacpp_setup.supported():
st = self.llamacpp_runner().state()
row["setup_here"] = True
row["managed"] = {"phase": st["phase"], "model": (st["model"] or {}).get("name")}
if d.auth == "oauth":
# Sign-in state instead of key state; the token values themselves
# never leave the SecretStore.
@@ -4795,6 +4803,39 @@ class SessionManager:
return local_machine.system_facts()
def llamacpp_runner(self):
"""The llama-server OpenWorker runs (UX-057), wired to point the `llamacpp`
provider at it and tick its model once it answers."""
from ..providers import llamacpp_setup
runner = llamacpp_setup.runner()
if runner.on_running is None:
runner.on_running = self._llamacpp_running
return runner
def llamacpp_stop(self) -> dict[str, Any]:
from ..providers import local_server
result = self.llamacpp_runner().stop()
# The cached probe would say "running" for up to 30 s after the server is gone.
local_server.forget_all()
self._local_alive_cache = {}
return result
def _llamacpp_running(self, base_url: str, alias: str) -> None:
from ..providers import local_server
local_server.forget_all()
self._local_alive_cache = {}
model = f"llamacpp:{alias}"
# Switching models: the one it served before is gone, so it leaves the picker.
previous = self._prefs.get("llamacpp_managed_model")
if previous and previous != model:
self.remove_model(previous)
self._prefs["llamacpp_managed_model"] = model
self.set_provider("llamacpp", {"base_url": base_url})
self.add_model(model)
def model_selectable(self, model: str) -> bool:
"""Can this machine run `model` right now? Its provider has a key — or, for the
keyless Ollama, a local Ollama answers (cached liveness probe)."""
@@ -6255,6 +6296,9 @@ class SessionManager:
await self.stop_gateway()
await self.mcp.aclose()
self.audit_store.close()
from ..providers import llamacpp_setup
llamacpp_setup.runner().shutdown()
# -- automation (scheduled tasks) -------------------------------------------
def worker_call_for(self, arguments: dict[str, Any], *, lead_session: str | None = None) -> dict[str, Any] | None:
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+230
View File
@@ -0,0 +1,230 @@
import { expect, type Page } from "@playwright/test";
import { test } from "./fixtures";
// UX-057: on a Mac, OpenWorker sets llama.cpp up and runs it — get llama.cpp, pick a
// model, start it — and "Connect to it instead" keeps the plain form.
const GB = 1024 ** 3;
const provider = (managed: { phase: string; model: string | null }) => ({
name: "llamacpp", title: "llama.cpp", kind: "local", needs_key: false, configured: false, values: {},
suggested_models: [], recommended_model: null, alive: managed.phase === "running", setup_here: true, managed,
fields: [
{ key: "base_url", label: "Server address", secret: false, required: false, help: "", placeholder: "http://localhost:8080" },
{ key: "api_key", label: "API key (only if the server was started with one)", secret: true, required: false, help: "", placeholder: "" },
],
});
function base(over: Record<string, unknown> = {}) {
return {
supported: true, brew: "/opt/homebrew/bin/brew", installed: null, install_command: "brew install llama.cpp",
cache_dir: "~/.cache/huggingface/hub", cache_free_bytes: 56 * GB,
suggested: [
{ id: "nemotron-3.5-lightning", name: "Nemotron 3.5 Lightning", vendor: "NVIDIA", quant: "Q8 + MTP", features: ["thinking", "tools"], size: 36e9, fit: "runs_well", downloaded: false,
license: "OpenMDW-1.1", license_url: "https://openmdw.ai/license/1-1/" },
{ id: "gpt-oss-20b", name: "gpt-oss 20B", vendor: "OpenAI", quant: "MXFP4", features: ["reasoning", "tools"], size: 12e9, fit: "runs_well", downloaded: false,
license: "Apache-2.0", license_url: "https://huggingface.co/openai/gpt-oss-20b/blob/main/LICENSE" },
],
ollama: [{ name: "qwen3-coder:30b", path: "/Users/me/.ollama/models/blobs/sha256-1194", size: 18e9, fit: "runs_well" }],
ollama_pull: [{ name: "gpt-oss:20b", size: 12e9, fit: "runs_well", command: "ollama pull gpt-oss:20b" }],
phase: "idle", lines: [], progress: null, model: null, error: null, check: null, note: null, retry_in: null, address: null,
autostart: false, last_choice: null,
...over,
};
}
const RUNNING = {
phase: "running",
model: { name: "qwen3-coder:30b", detail: "from Ollama", alias: "qwen3-coder:30b", download: false, context: 131072, file: "~/.ollama/models/blobs/sha256-1194" },
check: { answers: true, tools: true },
address: "127.0.0.1:8080",
};
async function openPage(page: Page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models & Keys" }).click();
}
test("llama.cpp: install, pick a model Ollama has, start it, and it runs", async ({ page }) => {
let state = base();
const posts: { path: string; body: unknown }[] = [];
await page.route("**/v1/providers", (route) =>
route.fulfill({ json: [provider({ phase: state.phase, model: (state.model as { name: string } | null)?.name ?? null })] }),
);
await page.route("**/v1/providers/llamacpp/models", (route) => route.fulfill({ json: { provider: "llamacpp", alive: true, models: [] } }));
await page.route("**/v1/llamacpp/**", (route) => {
const path = new URL(route.request().url()).pathname.replace("/v1/llamacpp/", "");
if (route.request().method() === "GET") {
// Each poll moves the work along, as the server's thread would.
const now = state;
if (state.phase === "installing") state = base({ installed: { path: "/opt/homebrew/bin/llama-server", version: "0.6.0" } });
if (state.phase === "loading") state = base({ installed: { path: "/opt/homebrew/bin/llama-server", version: "0.6.0" }, ...RUNNING });
return route.fulfill({ json: now });
}
posts.push({ path, body: route.request().postDataJSON() });
if (path === "install") state = base({ phase: "installing", lines: ["==> Fetching downloads for: llama.cpp", "==> Pouring llama.cpp--0.6.0.arm64_tahoe.bottle.tar.gz"] });
if (path === "start") state = base({ installed: { path: "/opt/homebrew/bin/llama-server", version: "0.6.0" }, phase: "loading", model: { ...RUNNING.model } });
if (path === "autostart") state = { ...state, autostart: (route.request().postDataJSON() as { on: boolean }).on };
return route.fulfill({ json: { ok: true, ...state } });
});
await openPage(page);
await expect(page.getByTestId("set-provider-llamacpp")).toContainText("Not set up · Set up on this Mac");
await page.getByTestId("set-provider-llamacpp").click();
const main = page.locator("main");
await expect(main).toContainText("Runs open models on this Mac");
await expect(page.getByTestId("llamacpp-step-get")).toHaveAttribute("data-state", "now");
await expect(page.getByTestId("llamacpp-step-get")).toContainText("brew install llama.cpp");
// The guided card replaces the address form until the user asks for it.
await expect(page.getByTestId("set-field-api_key")).toHaveCount(0);
await expect(page.getByTestId("set-endpoint-link")).toHaveCount(0);
await page.getByTestId("llamacpp-install").click();
await expect(page.getByTestId("llamacpp-install-log")).toContainText("Pouring llama.cpp");
await expect(page.getByTestId("llamacpp-step-get")).toHaveAttribute("data-state", "done");
await expect(main).toContainText("Installed · 0.6.0");
// Pick: suggested models, what Ollama has, the pull command, and where models go.
const pick = page.getByTestId("llamacpp-step-pick");
await expect(pick).toHaveAttribute("data-state", "now");
await expect(pick).toContainText("Nemotron 3.5 Lightning");
await expect(page.getByTestId("llamacpp-pull")).toContainText("ollama pull gpt-oss:20b");
await expect(page.getByTestId("llamacpp-cache")).toContainText("~/.cache/huggingface/hub");
// Each model that downloads names its licence, linked, before Download.
await expect(page.getByTestId("llamacpp-license-nemotron-3.5-lightning")).toContainText("Licence: OpenMDW-1.1");
await expect(page.getByTestId("llamacpp-license-gpt-oss-20b")).toContainText("Licence: Apache-2.0");
await expect(page.getByTestId("llamacpp-start")).toHaveText("Download and start");
await page.getByTestId("llamacpp-model-o:qwen3-coder:30b").click();
await expect(page.getByTestId("llamacpp-start")).toHaveText("Start");
await page.getByTestId("llamacpp-start").click();
const running = page.getByTestId("llamacpp-running");
await expect(running).toContainText("qwen3-coder:30b");
await expect(running).toContainText("128K");
await expect(running).toContainText("127.0.0.1:8080");
await expect(page.getByTestId("llamacpp-no-tools")).toHaveCount(0);
await expect(main).toContainText("✓ Running · 1 model");
// "Start with OpenWorker" is off until the user turns it on.
const auto = page.getByTestId("llamacpp-autostart");
await expect(auto).toHaveAttribute("aria-checked", "false");
await auto.click();
await expect(auto).toHaveAttribute("aria-checked", "true");
expect(posts.map((p) => p.path)).toEqual(["install", "start", "autostart"]);
expect(posts[1].body).toEqual({ choice: { kind: "ollama", name: "qwen3-coder:30b" } });
expect(posts[2].body).toEqual({ on: true });
await page.getByTestId("set-back").click();
await expect(page.getByTestId("set-provider-llamacpp")).toContainText("✓ Running · qwen3-coder:30b");
});
test("llama.cpp: a model that cannot call tools runs with a warning", async ({ page }) => {
const state = base({ installed: { path: "/opt/homebrew/bin/llama-server", version: "0.6.0" }, ...RUNNING, check: { answers: true, tools: false } });
await page.route("**/v1/providers", (route) => route.fulfill({ json: [provider({ phase: "running", model: "qwen3-coder:30b" })] }));
await page.route("**/v1/providers/llamacpp/models", (route) => route.fulfill({ json: { provider: "llamacpp", alive: true, models: [] } }));
await page.route("**/v1/llamacpp/**", (route) => route.fulfill({ json: state }));
await openPage(page);
await page.getByTestId("set-provider-llamacpp").click();
await expect(page.getByTestId("llamacpp-no-tools")).toContainText("did not call a tool");
});
test("llama.cpp: Connect to it instead shows the address form, and back", async ({ page }) => {
await page.route("**/v1/providers", (route) => route.fulfill({ json: [provider({ phase: "idle", model: null })] }));
await page.route("**/v1/providers/llamacpp/models", (route) => route.fulfill({ json: { provider: "llamacpp", alive: false, models: [] } }));
await page.route("**/v1/llamacpp/**", (route) => route.fulfill({ json: base({ brew: null }) }));
await openPage(page);
await page.getByTestId("set-provider-llamacpp").click();
// No Homebrew: it says so instead of offering Install.
await expect(page.getByTestId("llamacpp-no-brew")).toBeVisible();
await expect(page.getByTestId("llamacpp-install")).toHaveCount(0);
await page.getByTestId("llamacpp-connect-instead").click();
// Today's form: the key field, the address behind "Custom endpoint", the help line.
await expect(page.getByTestId("set-field-api_key")).toBeVisible();
await expect(page.getByTestId("set-endpoint-link")).toBeVisible();
await expect(page.locator("main")).toContainText("llama-server");
await page.getByTestId("llamacpp-setup-here").click();
await expect(page.getByTestId("llamacpp-setup")).toBeVisible();
});
const INSTALLED = { installed: { path: "/opt/homebrew/bin/llama-server", version: "0.6.0" } };
const DOWNLOADING = {
...INSTALLED,
phase: "downloading",
model: { name: "Nemotron 3.5 Lightning", detail: "Q8 + MTP", alias: "nemotron-3.5-lightning", download: true },
progress: { done: 12.4e9, total: 35.8e9, seconds_left: 840 },
};
test("llama.cpp: a download waits out a rate limit, verifies the file, and says when it could not", async ({ page }) => {
// Each poll moves the download along: rate-limited, then verifying with the API gone.
const states = [
base({ ...DOWNLOADING, retry_in: 42 }),
base({ ...DOWNLOADING, retry_in: 42 }),
base({ ...DOWNLOADING, progress: { done: 35.8e9, total: 35.8e9, seconds_left: 0, verifying: true }, note: "unverified" }),
];
let poll = 0;
await page.route("**/v1/providers", (route) => route.fulfill({ json: [provider({ phase: "downloading", model: "Nemotron 3.5 Lightning" })] }));
await page.route("**/v1/providers/llamacpp/models", (route) => route.fulfill({ json: { provider: "llamacpp", alive: false, models: [] } }));
await page.route("**/v1/llamacpp/**", (route) => route.fulfill({ json: states[Math.min(poll++, states.length - 1)] }));
await openPage(page);
await expect(page.getByTestId("set-provider-llamacpp")).toContainText("Starting…");
await page.getByTestId("set-provider-llamacpp").click();
await expect(page.getByTestId("llamacpp-step-start")).toHaveAttribute("data-state", "now");
const progress = page.getByTestId("llamacpp-progress");
await expect(progress).toHaveText("Hugging Face is rate-limiting downloads, retrying in 42s");
await expect(progress).toHaveText("Verifying the download…");
await expect(page.getByTestId("llamacpp-note")).toContainText("checksum was not checked");
await expect(page.getByTestId("llamacpp-cancel")).toBeVisible();
});
// The screenshots in docs/assets (light and dark): the three-step card before anything is
// installed, the model picker, and the running card.
for (const theme of ["light", "dark"] as const) {
test(`llama.cpp: screenshots (${theme})`, async ({ page }) => {
let state = base();
await page.addInitScript((value) => localStorage.setItem("openwork-theme", value), theme);
await page.setViewportSize({ width: 1280, height: 900 });
await page.route("**/v1/providers", (route) =>
route.fulfill({ json: [provider({ phase: state.phase, model: (state.model as { name: string } | null)?.name ?? null })] }),
);
await page.route("**/v1/providers/llamacpp/models", (route) =>
route.fulfill({
json: {
provider: "llamacpp", alive: state.phase === "running",
models: state.phase === "running"
? [{ model: "llamacpp:qwen3-coder:30b", name: "qwen3-coder:30b", size_bytes: 18e9, tools: true, thinking: false, vision: null, remote: false,
parameter_size: null, quantization: null, context_max: 262144, context: 131072, context_from: "server", fit: "runs_well", recommendation: null }]
: [],
},
}),
);
await page.route("**/v1/llamacpp/**", (route) => route.fulfill({ json: state }));
// "Your system", as a 96 GB Mac reports it.
await page.route("**/v1/system", (route) =>
route.fulfill({
json: { processor: "Apple M4 Max", graphics: "Apple M4 Max (40-core GPU)", kind: "apple_silicon", memory_bytes: 96 * GB, gpu_memory_bytes: null,
storage_free_bytes: 420 * GB, storage_total_bytes: 926 * GB, model_memory_bytes: 72 * GB, runs_well_up_to_bytes: 64 * GB },
}),
);
const suffix = theme === "dark" ? "-dark" : "";
await openPage(page);
await page.getByTestId("set-provider-llamacpp").click();
await expect(page.getByTestId("llamacpp-step-get")).toHaveAttribute("data-state", "now");
await page.screenshot({ path: `test-results/models-llamacpp-setup${suffix}.png` });
state = base(INSTALLED);
await page.getByTestId("set-back").click();
await page.getByTestId("set-provider-llamacpp").click();
await expect(page.getByTestId("llamacpp-step-pick")).toHaveAttribute("data-state", "now");
await expect(page.getByTestId("llamacpp-license-nemotron-3.5-lightning")).toBeVisible();
await page.screenshot({ path: `test-results/models-llamacpp-pick${suffix}.png` });
state = base({ ...INSTALLED, ...RUNNING });
await page.getByTestId("set-back").click();
await page.getByTestId("set-provider-llamacpp").click();
await expect(page.getByTestId("llamacpp-running")).toContainText("qwen3-coder:30b");
await page.screenshot({ path: `test-results/models-llamacpp-running${suffix}.png` });
});
}
+74
View File
@@ -2520,6 +2520,9 @@ export interface ProviderInfo {
is_custom?: boolean; // user-created OpenAI-compatible provider
// Local servers only: whether the server answers right now (cached probe).
alive?: boolean;
// llama.cpp on a Mac (UX-057): OpenWorker can set it up and run it, and what it runs.
setup_here?: boolean;
managed?: { phase: LlamaCppPhase; model: string | null };
signed_in?: boolean;
account?: string | null; // signed-in account label (email or id)
authorizing?: boolean;
@@ -2717,6 +2720,77 @@ export async function getLocalModels(
return res.json();
}
// -- llama.cpp set up and run by OpenWorker (UX-057) ---------------------------------
export type LlamaCppPhase = "idle" | "installing" | "downloading" | "loading" | "checking" | "running" | "failed";
export type ModelFit = "runs_well" | "tight" | "too_large" | "unknown";
export type LlamaCppChoice =
| { kind: "suggested"; id: string }
| { kind: "ollama"; name: string }
| { kind: "file"; path: string };
export interface LlamaCppSetup {
supported: boolean;
brew: string | null;
installed: { path: string; version: string | null } | null;
install_command: string;
cache_dir: string;
cache_free_bytes: number | null;
suggested: {
id: string;
name: string;
vendor: string | null;
quant: string | null;
features: string[];
size: number | null;
fit: ModelFit;
downloaded: boolean;
// The model's licence, named and linked before Download (from its Hugging Face card).
license: string | null;
license_url: string | null;
}[];
ollama: { name: string; path: string; size: number; fit: ModelFit }[];
ollama_pull: { name: string; size: number | null; fit: ModelFit; command: string }[];
phase: LlamaCppPhase;
lines: string[];
// `verifying`: the file is down and its sha256 is being checked against Hugging Face's.
progress: { done: number; total: number; seconds_left: number | null; verifying?: boolean } | null;
model: { name: string; detail: string | null; alias: string; download?: boolean; context?: number; file?: string } | null;
error: string | null;
check: { answers: boolean; tools: boolean } | null;
// "unverified": the Hugging Face API was unreachable, so the download's checksum was not checked.
note: "unverified" | null;
// Seconds until the next try while Hugging Face rate-limits the download.
retry_in: number | null;
address: string | null;
autostart: boolean;
last_choice: LlamaCppChoice | null;
}
async function llamacppPost(path: string, body?: unknown): Promise<LlamaCppSetup & { ok?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/llamacpp/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export async function getLlamaCppSetup(): Promise<LlamaCppSetup> {
const res = await fetch(`${httpBase()}/v1/llamacpp/setup`, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export const installLlamaCpp = () => llamacppPost("install");
export const startLlamaCpp = (choice: LlamaCppChoice) => llamacppPost("start", { choice });
export const stopLlamaCpp = () => llamacppPost("stop");
export const cancelLlamaCpp = () => llamacppPost("cancel");
export const setLlamaCppAutostart = (on: boolean) => llamacppPost("autostart", { on });
export async function pickGgufFile(): Promise<{ ok: boolean; path?: string; canceled?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/llamacpp/pick-file`, { method: "POST" });
return res.json();
}
export async function getProviders(): Promise<ProviderInfo[]> {
const res = await fetch(`${httpBase()}/v1/providers`);
const data = await res.json();
+28 -16
View File
@@ -26,6 +26,7 @@ import {
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
import { Icon } from "./Icon";
import { ModelChecklist } from "./ModelChecklist";
import { LlamaCppPage } from "./models/LlamaCppSetup";
import { LocalModelsTable, YourSystem } from "./models/LocalModels";
import { PickModelDialog } from "./models/PickModelDialog";
import { ProviderGroups } from "./models/ProviderGroups";
@@ -209,13 +210,34 @@ export function ModelsTab() {
);
}
const removeKeyFooter = <ProviderKeyFooter info={info} credentialed={ps.credentialed} onRemove={ps.removeKey} />;
const localTables = (key = "", withTable = true) => (
<>
<YourSystem />
{withTable && <LocalModelsTable
key={key}
provider={ps.sel!}
curated={settings.models}
defaultModel={settings.model}
onChanged={refreshSettings}
onConfigure={(m) => setDialog(m)}
/>}
{dialogEl}
</>
);
if (ps.sel === "llamacpp" && info?.setup_here) {
// UX-057: OpenWorker sets llama.cpp up and runs it on this Mac.
return (
<div>
<LlamaCppPage ps={ps} tp="set" footer={removeKeyFooter} onChanged={refreshSettings} after={localTables} />
</div>
);
}
return (
<div>
<ProviderForm
ps={ps}
tp="set"
footer={<ProviderKeyFooter info={info} credentialed={ps.credentialed} onRemove={ps.removeKey} />}
/>
<ProviderForm ps={ps} tp="set" footer={removeKeyFooter} />
{info?.env_key && (
<p className="text-meta text-muted mt-3 leading-relaxed" data-testid="set-env-key-note">
@@ -230,17 +252,7 @@ export function ModelsTab() {
{info?.kind === "local" ? (
// A local provider: the machine, then what the server holds (UX-055).
<>
<YourSystem />
<LocalModelsTable
provider={ps.sel}
curated={settings.models}
defaultModel={settings.model}
onChanged={refreshSettings}
onConfigure={(m) => setDialog(m)}
/>
{dialogEl}
</>
localTables()
) : info?.configured ? (
<div className="mt-6">
<div className={SEC_H + " mb-1.5"}>{t("manage.models")}</div>
@@ -0,0 +1,554 @@
// llama.cpp set up and run by OpenWorker on a Mac (UX-057): get llama.cpp, pick a model
// sized to this Mac, start it. Then a card for the running server. Polls the state while
// something runs, like the sandbox setup. "Connect to it instead" keeps the plain form.
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
cancelLlamaCpp,
getLlamaCppSetup,
installLlamaCpp,
pickGgufFile,
setLlamaCppAutostart,
startLlamaCpp,
stopLlamaCpp,
type LlamaCppChoice,
type LlamaCppSetup,
type ModelFit,
} from "../../api";
import { ProviderForm, type ProviderSetupState } from "../../providers/ProviderSetup";
import { openExternal } from "../../tauri";
import { fmtBytes, fmtContext } from "./LocalModels";
const BUSY = new Set(["installing", "downloading", "loading", "checking"]);
function choiceKey(c: LlamaCppChoice | null): string {
if (!c) return "";
return c.kind === "suggested" ? `s:${c.id}` : c.kind === "ollama" ? `o:${c.name}` : `f:${c.path}`;
}
function fromKey(key: string): LlamaCppChoice | null {
const v = key.slice(2);
if (key.startsWith("s:")) return { kind: "suggested", id: v };
if (key.startsWith("o:")) return { kind: "ollama", name: v };
if (key.startsWith("f:")) return { kind: "file", path: v };
return null;
}
function minutes(s: number | null | undefined): number | null {
return s == null ? null : Math.max(1, Math.round(s / 60));
}
/** The llama.cpp page when OpenWorker can run it here: the provider head, the guided
* card, then `after` (Your system, and the models table once a server runs), re-mounted
* when what runs changes so it reads the new server. */
export function LlamaCppPage({
ps,
tp,
footer,
onChanged,
after,
}: {
ps: ProviderSetupState;
tp: string;
footer?: ReactNode;
onChanged: () => void;
after: (key: string, withTable: boolean) => ReactNode;
}) {
const { t } = useTranslation();
const [setup, setSetup] = useState<LlamaCppSetup | null>(null);
const [loadError, setLoadError] = useState(false);
// A server the user runs themselves answers and OpenWorker runs nothing: their form.
const [connect, setConnect] = useState<boolean | null>(null);
const load = useCallback(async () => {
try {
const s = await getLlamaCppSetup();
setSetup(s);
setLoadError(false);
return s;
} catch {
setLoadError(true);
return null;
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (connect !== null || !setup) return;
setConnect(!!ps.info?.alive && setup.phase !== "running" && !BUSY.has(setup.phase));
}, [setup, connect, ps.info?.alive]);
// Poll while something runs; when it settles, refresh the providers and the picker.
const phase = setup?.phase;
const wasBusy = useRef(false);
useEffect(() => {
const busy = !!phase && BUSY.has(phase);
if (!busy) {
if (wasBusy.current) {
wasBusy.current = false;
void ps.refreshProviders();
onChanged();
}
return;
}
wasBusy.current = true;
const id = window.setInterval(() => void load(), 1000);
return () => window.clearInterval(id);
}, [phase, load, ps, onChanged]);
const status = (() => {
if (!setup || connect) return undefined;
const cls = "block text-meta text-muted";
if (setup.phase === "running") return <span className="block text-meta text-ok font-medium">{t("llamacpp.status_running")}</span>;
if (setup.phase === "installing") return <span className={cls}>{t("llamacpp.status_installing")}</span>;
if (BUSY.has(setup.phase)) return <span className={cls}>{t("llamacpp.status_starting")}</span>;
if (setup.installed)
return <span className={cls}>{t("llamacpp.status_installed", { version: setup.installed.version || "" })}</span>;
return <span className={cls}>{t("provider.not_set_up")}</span>;
})();
const guided =
connect === false && setup ? (
<SetupCard setup={setup} onState={setSetup} reload={load} onConnectInstead={() => setConnect(true)} onChanged={() => { void ps.refreshProviders(); onChanged(); }} />
) : connect === null && loadError ? (
<p className="text-meta text-muted mt-4" data-testid="llamacpp-load-failed">
{t("llamacpp.load_failed")}{" "}
<button className="underline underline-offset-2 hover:text-ink" onClick={() => void load()}>
{t("manage.retry")}
</button>
</p>
) : connect === null ? (
<p className="text-meta text-faint mt-4">{t("llamacpp.loading")}</p>
) : null;
return (
<>
<ProviderForm
ps={ps}
tp={tp}
footer={footer}
status={status}
blurb={connect ? undefined : t("llamacpp.blurb")}
guided={guided ?? undefined}
/>
{connect && (
<p className="text-meta text-faint mt-3">
<button className="text-muted underline decoration-line underline-offset-2 hover:text-ink" onClick={() => setConnect(false)} data-testid="llamacpp-setup-here">
{t("llamacpp.setup_here")}
</button>
</p>
)}
{/* What the server reports, once there is a server: one it runs, or the user's own. */}
{after(`${setup?.phase === "running" ? setup.model?.alias : ""}:${setup?.address ?? ""}`, !!connect || setup?.phase === "running")}
</>
);
}
function SetupCard({
setup,
onState,
reload,
onConnectInstead,
onChanged,
}: {
setup: LlamaCppSetup;
onState: (s: LlamaCppSetup) => void;
reload: () => Promise<LlamaCppSetup | null>;
onConnectInstead: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [picked, setPicked] = useState<string>(() => choiceKey(setup.last_choice) || (setup.suggested[0] ? `s:${setup.suggested[0].id}` : ""));
const [file, setFile] = useState<string | null>(setup.last_choice?.kind === "file" ? setup.last_choice.path : null);
const [switching, setSwitching] = useState(false);
const [lastStart, setLastStart] = useState<LlamaCppChoice | null>(null);
const [copied, setCopied] = useState("");
const [error, setError] = useState<string | null>(null);
const { phase } = setup;
const installed = !!setup.installed;
const busy = BUSY.has(phase);
const act = async (fn: () => Promise<LlamaCppSetup & { ok?: boolean; error?: string }>) => {
setError(null);
try {
const r = await fn();
if (r.ok === false && r.error) setError(r.error);
if (r.phase) onState(r);
else await reload();
// Stop and the switch settle at once; the poll covers the rest.
if (r.phase && !BUSY.has(r.phase)) onChanged();
} catch (e) {
setError(String((e as Error).message || e));
}
};
const copy = (text: string) => {
void navigator.clipboard?.writeText(text);
setCopied(text);
window.setTimeout(() => setCopied(""), 1500);
};
const start = (choice: LlamaCppChoice | null) => {
if (!choice) return;
setLastStart(choice);
setSwitching(false);
void act(() => startLlamaCpp(choice));
};
const chooseFile = async () => {
const r = await pickGgufFile();
if (r.ok && r.path) {
setFile(r.path);
setPicked(`f:${r.path}`);
}
};
// -- the running server (screen 5) ---------------------------------------------------
if (phase === "running" && !switching && setup.model) {
const m = setup.model;
return (
<div className="mt-4 rounded-xl border border-line bg-panel px-5 py-4" data-testid="llamacpp-running">
<div className="flex items-center gap-2.5">
<span className="w-[7px] h-[7px] rounded-full bg-ok shrink-0" />
<span className="text-ui font-semibold truncate">{m.name}</span>
{m.detail && <span className="text-meta text-muted truncate">{m.detail}</span>}
<span className="ml-auto flex gap-2 shrink-0">
<button className={BTN} onClick={() => setSwitching(true)} data-testid="llamacpp-switch">
{t("llamacpp.switch_model")}
</button>
<button className={BTN} onClick={() => void act(stopLlamaCpp)} data-testid="llamacpp-stop">
{t("llamacpp.stop")}
</button>
</span>
</div>
{setup.check && !setup.check.tools && (
<p className="text-meta text-warnInk mt-2.5" data-testid="llamacpp-no-tools">
{t("llamacpp.no_tools")}
</p>
)}
{setup.note === "unverified" && <p className="text-meta text-faint mt-2.5" data-testid="llamacpp-note">{t("llamacpp.note_unverified")}</p>}
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-1 text-ui mt-3">
<span className="text-muted">{t("llamacpp.context")}</span>
<span>{fmtContext(m.context)}</span>
<span className="text-muted">{t("llamacpp.address")}</span>
<span className="font-mono text-meta">{setup.address}</span>
<span className="text-muted">{t("llamacpp.model_file")}</span>
<span className="font-mono text-meta truncate" title={m.file}>{m.file}</span>
</div>
<div className="border-t border-line my-3.5" />
<label className="flex items-center gap-2.5 text-meta cursor-pointer">
{t("llamacpp.autostart")}
<Switch on={setup.autostart} onChange={(on) => void act(() => setLlamaCppAutostart(on))} testid="llamacpp-autostart" />
</label>
<p className="text-meta text-faint mt-1">{t(setup.autostart ? "llamacpp.autostart_on" : "llamacpp.autostart_off")}</p>
</div>
);
}
// -- the three steps (screens 1–4) ---------------------------------------------------
const pickNow = installed && !busy && phase !== "installing";
const startNow = busy && phase !== "installing";
const failedStart = phase === "failed" && installed && (lastStart || setup.model);
const choice = fromKey(picked);
const pickedSuggested = choice?.kind === "suggested" ? setup.suggested.find((s) => s.id === choice.id) : undefined;
const pickedSummary = setup.model ? [setup.model.name, setup.model.detail].filter(Boolean).join(" · ") : "";
const p = setup.progress;
return (
<div className="mt-4">
<div className="rounded-xl border border-line bg-panel px-5 py-4" data-testid="llamacpp-setup">
<ol className="relative pl-[26px] before:content-[''] before:absolute before:left-[7px] before:top-2 before:bottom-2 before:w-px before:bg-lineStrong">
{/* 1. Get llama.cpp */}
<Step state={installed ? "done" : "now"} title={t("llamacpp.step_get")} sum={installed ? t("llamacpp.installed_sum", { version: setup.installed?.version || "" }) : phase === "installing" ? setup.install_command : undefined} testid="llamacpp-step-get">
{!installed && phase !== "installing" && (
<div className="mt-2 text-ui">
{setup.brew ? (
<>
<p className="text-muted">{t("llamacpp.not_installed")}</p>
<Cmd text={setup.install_command} copied={copied === setup.install_command} onCopy={copy} t={t} />
<div className="flex items-center gap-2.5 mt-2.5">
<button className={BTN_PRIMARY} onClick={() => void act(installLlamaCpp)} data-testid="llamacpp-install">
{phase === "failed" ? t("manage.retry") : t("llamacpp.install")}
</button>
<span className="text-meta text-faint">{t("llamacpp.install_hint")}</span>
</div>
</>
) : (
<p className="text-muted" data-testid="llamacpp-no-brew">
{t("llamacpp.no_brew")}{" "}
<button className="underline underline-offset-2 hover:text-ink" onClick={() => openExternal("https://brew.sh")}>brew.sh ↗</button>
</p>
)}
{phase === "failed" && setup.error && <p className="text-meta text-danger mt-2" data-testid="llamacpp-error">{setup.error}</p>}
</div>
)}
{phase === "installing" && (
<div className="mt-2">
<pre className="rounded-lg bg-[#1d1f23] text-[#c9ced6] px-3 py-2 text-[11.5px] leading-relaxed font-mono whitespace-pre-wrap break-all" data-testid="llamacpp-install-log">
{setup.lines.length ? setup.lines.join("\n") : "…"}
</pre>
<button className={BTN + " mt-2"} onClick={() => void act(cancelLlamaCpp)} data-testid="llamacpp-cancel">
{t("llamacpp.cancel")}
</button>
</div>
)}
</Step>
{/* 2. Pick a model */}
<Step
state={pickNow ? "now" : startNow ? "done" : "todo"}
title={t("llamacpp.step_pick")}
sum={startNow ? pickedSummary : undefined}
testid="llamacpp-step-pick"
>
{pickNow && (
<div className="mt-2">
<Group label={t("llamacpp.suggested")} />
<div className="rounded-[10px] border border-line">
{setup.suggested.map((s) => (
<ModelRow
key={s.id}
id={`s:${s.id}`}
picked={picked}
onPick={setPicked}
name={s.name}
sub={[s.vendor, s.quant, ...s.features.map((f) => t(`llamacpp.feature_${f}`))].filter(Boolean).join(" · ")}
size={s.size}
fit={s.fit}
extra={s.downloaded ? t("llamacpp.downloaded") : undefined}
line={
s.license ? (
<button
className="text-label text-faint underline decoration-line underline-offset-2 hover:text-ink"
onClick={(e) => { e.stopPropagation(); if (s.license_url) openExternal(s.license_url); }}
data-testid={`llamacpp-license-${s.id}`}
>
{t("llamacpp.license", { name: s.license })} ↗
</button>
) : undefined
}
/>
))}
{setup.suggested.length === 0 && <p className="px-3 py-2.5 text-meta text-faint">{t("llamacpp.none_fit")}</p>}
</div>
<Group label={t("llamacpp.on_this_mac")} />
<div className="rounded-[10px] border border-line">
{setup.ollama.map((o) => (
<ModelRow key={o.name} id={`o:${o.name}`} picked={picked} onPick={setPicked} name={o.name} sub={t("llamacpp.from_ollama")} size={o.size} fit={o.fit} />
))}
<ModelRow
id={file ? `f:${file}` : "f:"}
picked={picked}
onPick={() => (file ? setPicked(`f:${file}`) : void chooseFile())}
name={file ? file.split("/").pop() || file : t("llamacpp.choose_file")}
sub={file ? file : t("llamacpp.choose_file_sub")}
action={file ? <button className="text-meta text-muted hover:text-ink" onClick={(e) => { e.stopPropagation(); void chooseFile(); }}>{t("llamacpp.change")}</button> : undefined}
/>
</div>
{setup.ollama_pull.length > 0 && (
<>
<Group label={t("llamacpp.not_downloaded")} />
<div className="rounded-[10px] border border-line" data-testid="llamacpp-pull">
{setup.ollama_pull.map((o) => (
<div key={o.name} className="flex items-center gap-3 px-3 py-2.5 border-t border-line first:border-t-0">
<span className="min-w-0 flex-1">
<span className="block text-ui font-medium">{o.name}</span>
<span className="block text-label text-faint">{t("llamacpp.pull_sub", { size: fmtBytes(o.size), fit: t(`manage.fit_${o.fit}`) })}</span>
</span>
<Cmd text={o.command} copied={copied === o.command} onCopy={copy} t={t} compact />
</div>
))}
</div>
</>
)}
<p className="text-meta text-faint mt-2" data-testid="llamacpp-cache">
{t("llamacpp.cache_where")} <code className="font-mono">{setup.cache_dir}</code>
{setup.cache_free_bytes != null && <> · {t("llamacpp.free", { size: fmtBytes(setup.cache_free_bytes) })}</>}
</p>
{failedStart && setup.error && (
<p className="text-meta text-danger mt-2" data-testid="llamacpp-error">{setup.error}</p>
)}
<div className="flex items-center gap-2.5 mt-3">
<button className={BTN_PRIMARY} disabled={!choice || picked === "f:"} onClick={() => start(choice)} data-testid="llamacpp-start">
{pickedSuggested && !pickedSuggested.downloaded ? t("llamacpp.download_and_start") : t("llamacpp.start")}
</button>
{switching && (
<button className={BTN} onClick={() => setSwitching(false)}>
{t("llamacpp.cancel")}
</button>
)}
</div>
</div>
)}
</Step>
{/* 3. Start it */}
<Step state={startNow ? "now" : "todo"} title={t("llamacpp.step_start")} testid="llamacpp-step-start">
{startNow && (
<div className="mt-2">
{phase === "downloading" && (
<>
<p className="text-meta text-muted" data-testid="llamacpp-progress">
{setup.retry_in != null
? t("llamacpp.rate_limited", { seconds: setup.retry_in })
: p?.verifying
? t("llamacpp.verifying")
: p
? t("llamacpp.downloading", { done: fmtBytes(p.done), total: fmtBytes(p.total) }) +
(minutes(p.seconds_left) != null ? " · " + t("llamacpp.minutes_left", { count: minutes(p.seconds_left) ?? 0 }) : "")
: t("llamacpp.download_starting")}
</p>
<div className="h-1.5 rounded bg-paper overflow-hidden my-2">
<i className="block h-full bg-muted rounded" style={{ width: `${p && p.total ? Math.round((100 * p.done) / p.total) : 0}%` }} />
</div>
</>
)}
<ul className="text-meta space-y-0.5">
{setup.model?.download && <Check done={phase !== "downloading"} now={phase === "downloading"} text={t("llamacpp.check_download")} />}
<Check done={phase === "checking"} now={phase === "loading"} text={t("llamacpp.check_load")} />
<Check done={false} now={phase === "checking"} text={t("llamacpp.check_answers")} />
<Check done={false} now={false} text={t("llamacpp.check_tools")} />
</ul>
{setup.note === "unverified" && <p className="text-meta text-faint mt-2" data-testid="llamacpp-note">{t("llamacpp.note_unverified")}</p>}
<div className="flex items-center gap-2.5 mt-2.5">
<button className={BTN} onClick={() => void act(cancelLlamaCpp)} data-testid="llamacpp-cancel">
{t("llamacpp.cancel")}
</button>
<span className="text-meta text-faint">{t("llamacpp.leave_ok")}</span>
</div>
</div>
)}
</Step>
</ol>
</div>
<p className="text-meta text-faint mt-3">
{t("llamacpp.own_server")}{" "}
<button className="text-muted underline decoration-line underline-offset-2 hover:text-ink" onClick={onConnectInstead} data-testid="llamacpp-connect-instead">
{t("llamacpp.connect_instead")}
</button>
</p>
{error && <p className="text-meta text-danger mt-2">{error}</p>}
</div>
);
}
const BTN = "rounded-lg border border-lineStrong bg-panel px-3 py-1.5 text-ui text-ink hover:border-ink/40 disabled:opacity-40";
const BTN_PRIMARY = "rounded-lg border border-ink bg-ink px-3 py-1.5 text-ui font-medium text-panel hover:brightness-110 disabled:opacity-40";
function Step({ state, title, sum, testid, children }: { state: "done" | "now" | "todo"; title: string; sum?: string; testid: string; children?: ReactNode }) {
return (
<li className="relative pb-4 last:pb-0" data-testid={testid} data-state={state}>
<span
className={
"absolute -left-[26px] top-[3px] w-[15px] h-[15px] rounded-full border-[1.5px] grid place-items-center text-[9px] " +
(state === "now" ? "border-ink bg-ink" : state === "done" ? "border-lineStrong bg-paper text-muted" : "border-lineStrong bg-panel")
}
>
{state === "done" ? "✓" : ""}
</span>
<div className="flex items-baseline gap-2">
<span className={"text-ui " + (state === "todo" ? "text-faint" : "font-semibold")}>{title}</span>
{sum && <span className="text-meta text-muted truncate">{sum}</span>}
</div>
{children}
</li>
);
}
function Group({ label }: { label: string }) {
return <div className="text-[11px] tracking-wide uppercase text-faint font-semibold mt-3 mb-1">{label}</div>;
}
function ModelRow({
id,
picked,
onPick,
name,
sub,
size,
fit,
extra,
action,
line,
}: {
id: string;
picked: string;
onPick: (id: string) => void;
name: string;
sub?: string;
size?: number | null;
fit?: ModelFit;
extra?: string;
action?: ReactNode;
// A third line under the name: the licence, for a model that downloads.
line?: ReactNode;
}) {
const { t } = useTranslation();
const on = picked === id && id !== "f:";
return (
<div
role="radio"
aria-checked={on}
tabIndex={0}
className="grid grid-cols-[18px_1fr_auto_auto] gap-2.5 items-center px-3 py-2.5 border-t border-line first:border-t-0 cursor-pointer hover:bg-paper/50"
onClick={() => onPick(id)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onPick(id)}
data-testid={`llamacpp-model-${id}`}
>
<span className={"w-3.5 h-3.5 rounded-full " + (on ? "border-[4.5px] border-ink" : "border-[1.5px] border-lineStrong")} />
<span className="min-w-0">
<span className="block text-ui font-medium truncate">{name}</span>
{sub && <span className="block text-label text-faint truncate">{sub}</span>}
{line && <span className="block truncate">{line}</span>}
</span>
<span className="text-meta text-muted text-right">{action ?? (size ? fmtBytes(size) : "")}</span>
<span className={"text-meta text-right min-w-[72px] " + (fit === "runs_well" ? "text-ok" : "text-warnInk")}>
{extra ? <span className="text-muted">{extra}</span> : fit && fit !== "unknown" ? t(`manage.fit_${fit}`) : ""}
</span>
</div>
);
}
function Cmd({ text, copied, onCopy, t, compact }: { text: string; copied: boolean; onCopy: (s: string) => void; t: (k: string) => string; compact?: boolean }) {
return (
<button
className={"flex items-center gap-2 rounded-lg bg-paper font-mono text-meta text-ink text-left " + (compact ? "px-2 py-1 shrink-0" : "w-full px-2.5 py-2 mt-2")}
onClick={() => onCopy(text)}
title={t("provider.copy_command")}
data-testid={`llamacpp-copy-${text.split(" ").pop()}`}
>
<span className="truncate">{text}</span>
<span className="ml-auto font-sans text-label text-faint pl-2">{copied ? t("llamacpp.copied") : t("llamacpp.copy")}</span>
</button>
);
}
function Check({ done, now, text }: { done: boolean; now: boolean; text: string }) {
return (
<li className={"flex gap-2 " + (done || now ? "text-ink" : "text-muted")}>
<span className="w-3.5 text-center text-muted">
{done ? "✓" : now ? <span className="inline-block w-2.5 h-2.5 rounded-full border-2 border-lineStrong border-t-ink animate-spin" /> : "·"}
</span>
{text}
</li>
);
}
function Switch({ on, onChange, testid }: { on: boolean; onChange: (on: boolean) => void; testid: string }) {
return (
<button
role="switch"
aria-checked={on}
className={"ml-auto relative w-[34px] h-5 rounded-full transition-colors " + (on ? "bg-ink" : "bg-lineStrong")}
onClick={() => onChange(!on)}
data-testid={testid}
>
<span className={"absolute top-0.5 w-4 h-4 rounded-full bg-panel transition-all " + (on ? "right-0.5" : "left-0.5")} />
</button>
);
}
@@ -22,7 +22,7 @@ export function providerKind(p: ProviderInfo): Kind {
* answers (the setup state's `keylessOk`). */
export function providerConnected(p: ProviderInfo, keylessOk: Set<string>): boolean {
if (p.auth === "oauth") return !!p.signed_in;
if (!p.needs_key) return !!p.alive || keylessOk.has(p.name);
if (!p.needs_key) return !!p.alive || keylessOk.has(p.name) || p.managed?.phase === "running";
return p.configured;
}
@@ -70,14 +70,18 @@ export function ProviderGroups({
<ProviderMark name={p.name} title={p.title} />
<span className="min-w-0 flex-1">
<span className={"block text-ui font-semibold leading-tight truncate" + (connected ? "" : " text-muted")}>{p.title}</span>
{connected && count > 0 ? (
{p.managed?.phase === "running" && p.managed.model ? (
<span className="block text-meta text-ok font-medium truncate">{t("llamacpp.card_running", { model: p.managed.model })}</span>
) : p.managed && !connected && ["installing", "downloading", "loading", "checking"].includes(p.managed.phase) ? (
<span className="block text-meta text-muted truncate">{t(p.managed.phase === "installing" ? "llamacpp.status_installing" : "llamacpp.status_starting")}</span>
) : connected && count > 0 ? (
<span className="block text-meta text-ok font-medium truncate">
{t(p.needs_key || p.auth === "oauth" ? "provider.connected_count" : "provider.running_count", { count })}
</span>
) : connected && !p.needs_key && p.auth !== "oauth" ? (
<span className="block text-meta text-ok font-medium truncate">{t("provider.running")}</span>
) : !connected && !p.needs_key && p.auth !== "oauth" && p.kind === "local" && p.name !== "ollama" ? (
<span className="block text-meta text-faint truncate">{t("provider.not_connected")}</span>
<span className="block text-meta text-faint truncate">{t(p.setup_here ? "llamacpp.card_not_set_up" : "provider.not_connected")}</span>
) : (
ps.statusFor(p, { lastUsed: true })
)}
+65 -2
View File
@@ -2874,8 +2874,71 @@
"next": "Next",
"add": "Add",
"save": "Save"
}
,
},
"llamacpp": {
"blurb": "Runs open models on this Mac. OpenWorker can set it up and keep it running for you.",
"loading": "Checking this Mac…",
"load_failed": "Couldn't read the llama.cpp setup.",
"status_installing": "Installing…",
"status_starting": "Starting…",
"status_installed": "Installed · {{version}}",
"status_running": "✓ Running · 1 model",
"card_running": "✓ Running · {{model}}",
"card_not_set_up": "Not set up · Set up on this Mac",
"step_get": "Get llama.cpp",
"step_pick": "Pick a model",
"step_start": "Start it",
"installed_sum": "{{version}}, Homebrew",
"not_installed": "llama.cpp is not installed. OpenWorker installs it with Homebrew:",
"install": "Install",
"install_hint": "About 30 MB. You can watch the output.",
"no_brew": "llama.cpp installs with Homebrew, which is not on this Mac. Install Homebrew first:",
"cancel": "Cancel",
"copy": "Copy",
"copied": "Copied",
"suggested": "Suggested for this Mac",
"on_this_mac": "Already on this Mac",
"not_downloaded": "Recommended, not downloaded yet",
"pull_sub": "{{size}} · {{fit}} · in Ollama, then it shows above",
"none_fit": "None of the suggested models fits this Mac's memory.",
"from_ollama": "From Ollama · nothing to download",
"downloaded": "Downloaded",
"license": "Licence: {{name}}",
"choose_file": "Choose a GGUF file…",
"choose_file_sub": "A model file you downloaded yourself",
"change": "Change",
"feature_thinking": "thinking",
"feature_tools": "tools",
"feature_reasoning": "reasoning levels",
"cache_where": "Models download to llama.cpp's own cache,",
"free": "{{size}} free",
"download_and_start": "Download and start",
"start": "Start",
"download_starting": "Starting the download…",
"downloading": "Downloading · {{done}} of {{total}}",
"verifying": "Verifying the download…",
"rate_limited": "Hugging Face is rate-limiting downloads, retrying in {{seconds}}s",
"note_unverified": "Hugging Face's API couldn't be reached, so this download's checksum was not checked.",
"minutes_left_one": "about {{count}} min left",
"minutes_left_other": "about {{count}} min left",
"check_download": "Download the model",
"check_load": "Load the model",
"check_answers": "Check that it answers",
"check_tools": "Check that it calls a tool",
"leave_ok": "You can leave this page; it keeps going.",
"switch_model": "Switch model",
"stop": "Stop",
"no_tools": "It answers, but it did not call a tool when asked. Coworkers use tools on every task, so pick a larger model for real work.",
"context": "Context",
"address": "Address",
"model_file": "Model file",
"autostart": "Start with OpenWorker",
"autostart_off": "Off: it runs only after you press Start. It always stops when OpenWorker quits.",
"autostart_on": "On: it starts when OpenWorker opens. It always stops when OpenWorker quits.",
"own_server": "Already run llama-server yourself?",
"connect_instead": "Connect to it instead",
"setup_here": "Set up llama.cpp on this Mac instead"
},
"customization": {
"title": "Customization",
"lead": "Pick the services OpenWorker uses behind its tools. Each choice applies to new sessions on this machine.",
+65 -2
View File
@@ -2834,8 +2834,71 @@
"next": "下一步",
"add": "添加",
"save": "保存"
}
,
},
"llamacpp": {
"blurb": "在这台 Mac 上运行开源模型。OpenWorker 可以为你安装并保持运行。",
"loading": "正在检查这台 Mac…",
"load_failed": "无法读取 llama.cpp 设置。",
"status_installing": "正在安装…",
"status_starting": "正在启动…",
"status_installed": "已安装 · {{version}}",
"status_running": "✓ 运行中 · 1 个模型",
"card_running": "✓ 运行中 · {{model}}",
"card_not_set_up": "未设置 · 在这台 Mac 上设置",
"step_get": "获取 llama.cpp",
"step_pick": "选择模型",
"step_start": "启动",
"installed_sum": "{{version}},Homebrew",
"not_installed": "尚未安装 llama.cpp。OpenWorker 会用 Homebrew 安装:",
"install": "安装",
"install_hint": "约 30 MB。可以查看输出。",
"no_brew": "llama.cpp 通过 Homebrew 安装,但这台 Mac 上没有 Homebrew。请先安装 Homebrew:",
"cancel": "取消",
"copy": "复制",
"copied": "已复制",
"suggested": "推荐给这台 Mac",
"on_this_mac": "这台 Mac 上已有",
"not_downloaded": "推荐,尚未下载",
"pull_sub": "{{size}} · {{fit}} · 下载到 Ollama 后会显示在上方",
"none_fit": "推荐的模型都超出这台 Mac 的内存。",
"from_ollama": "来自 Ollama · 无需下载",
"downloaded": "已下载",
"license": "许可证:{{name}}",
"choose_file": "选择 GGUF 文件…",
"choose_file_sub": "你自己下载的模型文件",
"change": "更改",
"feature_thinking": "思考",
"feature_tools": "工具",
"feature_reasoning": "推理级别",
"cache_where": "模型下载到 llama.cpp 自己的缓存:",
"free": "剩余 {{size}}",
"download_and_start": "下载并启动",
"start": "启动",
"download_starting": "正在开始下载…",
"downloading": "正在下载 · {{done}} / {{total}}",
"verifying": "正在校验下载…",
"rate_limited": "Hugging Face 正在限制下载,{{seconds}} 秒后重试",
"note_unverified": "无法连接 Hugging Face API,未校验此次下载的校验和。",
"minutes_left_one": "约剩 {{count}} 分钟",
"minutes_left_other": "约剩 {{count}} 分钟",
"check_download": "下载模型",
"check_load": "加载模型",
"check_answers": "检查能否回答",
"check_tools": "检查能否调用工具",
"leave_ok": "可以离开此页面,它会继续进行。",
"switch_model": "切换模型",
"stop": "停止",
"no_tools": "它能回答,但在要求时没有调用工具。同事在每个任务中都会使用工具,正式工作请选择更大的模型。",
"context": "上下文",
"address": "地址",
"model_file": "模型文件",
"autostart": "随 OpenWorker 启动",
"autostart_off": "关闭:只有按下启动后才运行。OpenWorker 退出时总会停止。",
"autostart_on": "开启:OpenWorker 打开时启动。OpenWorker 退出时总会停止。",
"own_server": "已经自己运行 llama-server?",
"connect_instead": "改为连接到它",
"setup_here": "改为在这台 Mac 上设置 llama.cpp"
},
"customization": {
"title": "自定义",
"lead": "选择 OpenWorker 各工具背后使用的服务。所做选择对本机的新会话生效。",
+14 -5
View File
@@ -482,10 +482,18 @@ export function ProviderForm({
ps,
tp,
footer,
status,
blurb,
guided,
}: {
ps: ProviderSetupState;
tp: string;
footer?: ReactNode;
// A guided setup (llama.cpp on a Mac, UX-057) replaces the status line, the blurb and
// the fields; the footer stays.
status?: ReactNode;
blurb?: string;
guided?: ReactNode;
}) {
const { t } = useTranslation();
const { info, sel } = ps;
@@ -568,10 +576,11 @@ export function ProviderForm({
<ProviderMark name={info?.name || ""} title={info?.title || ""} size={36} />
<span className="min-w-0">
<span className="block text-body font-semibold leading-tight">{info?.title}</span>
{info ? ps.statusFor(info) : null}
{status ?? (info ? ps.statusFor(info) : null)}
</span>
</div>
{info?.blurb && <p className="text-meta text-faint mt-1">{info.blurb}</p>}
{(blurb ?? info?.blurb) && <p className="text-meta text-faint mt-1">{blurb ?? info?.blurb}</p>}
{guided}
{info?.auth === "oauth" &&
(info.name === "openrouter-account" ? (
@@ -580,7 +589,7 @@ export function ProviderForm({
<OAuthSignIn info={info} tp={tp} onChanged={ps.refreshProviders} />
))}
{fieldsAll
{!guided && fieldsAll
.filter(
(f) =>
!f.show_when &&
@@ -674,7 +683,7 @@ export function ProviderForm({
{t("provider.takes_a_minute")}
</p>
)}
{info && !info.needs_key && info.auth !== "oauth" && LOCAL_HELP[info.name] && (
{!guided && info && !info.needs_key && info.auth !== "oauth" && LOCAL_HELP[info.name] && (
<p className="text-meta text-faint mt-2">
{t(LOCAL_HELP[info.name].desc)}{" "}
<button
@@ -691,7 +700,7 @@ export function ProviderForm({
(owner calls 2026-07-18 + 2026-07-19). */}
{(() => {
const keyed = (info?.fields || []).some((x) => x.secret);
const ep = keyed ? (info?.fields || []).find((f) => f.key === "base_url") : undefined;
const ep = keyed && !guided ? (info?.fields || []).find((f) => f.key === "base_url") : undefined;
if (!ep) return null;
if (!ps.showEndpoint)
return (
+415
View File
@@ -0,0 +1,415 @@
"""llama.cpp set up and run by OpenWorker (providers/llamacpp_setup.py, UX-057): where
models go, what it offers, the download into llama.cpp's cache, and the managed server's
start, check, switch, stop and clean-up — against a stand-in llama-server."""
from __future__ import annotations
import hashlib
import json
import os
import signal
import threading
import sys
import textwrap
import time
from pathlib import Path
import httpx
import pytest
from coworker.providers import llamacpp_setup as setup
from coworker.providers import local_machine
GB = 1024**3
@pytest.fixture(autouse=True)
def _state(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("LLAMA_CACHE", str(tmp_path / "cache"))
monkeypatch.setenv("OLLAMA_MODELS", str(tmp_path / "ollama"))
monkeypatch.setattr(local_machine, "model_memory_bytes", lambda: 32 * GB)
monkeypatch.setattr(setup, "_runner", None)
yield
if setup._runner is not None:
setup._runner.shutdown()
def test_cache_dir_follows_llama_cpp(monkeypatch, tmp_path):
for key in ("LLAMA_CACHE", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "HF_HOME", "XDG_CACHE_HOME"):
monkeypatch.delenv(key, raising=False)
assert setup.cache_dir() == Path.home() / ".cache" / "huggingface" / "hub"
monkeypatch.setenv("HF_HOME", str(tmp_path / "hf"))
assert setup.cache_dir() == tmp_path / "hf" / "hub"
monkeypatch.setenv("LLAMA_CACHE", str(tmp_path / "llama"))
assert setup.cache_dir() == tmp_path / "llama"
def _ollama_model(root: Path, name: str, tag: str, size: int, ns: str = "library") -> Path:
digest = f"sha256:{name}{tag}".replace(".", "")
manifest = root / "manifests" / "registry.ollama.ai" / ns / name / tag
manifest.parent.mkdir(parents=True, exist_ok=True)
layers = [
{"mediaType": "application/vnd.ollama.image.license", "digest": "sha256:lic", "size": 10},
{"mediaType": "application/vnd.ollama.image.model", "digest": digest, "size": size},
]
manifest.write_text(json.dumps({"layers": layers}))
blob = root / "blobs" / digest.replace(":", "-")
blob.parent.mkdir(parents=True, exist_ok=True)
blob.write_bytes(b"GGUF")
return blob
def test_ollama_models_are_read_from_its_manifests(tmp_path):
root = tmp_path / "ollama"
blob = _ollama_model(root, "qwen3-coder", "30b", 18 * GB)
_ollama_model(root, "mymodel", "latest", GB, ns="someone")
# A manifest whose model file is missing is not offered.
gone = root / "manifests" / "registry.ollama.ai" / "library" / "gone" / "1b"
gone.parent.mkdir(parents=True)
gone.write_text(json.dumps({"layers": [{"mediaType": "application/vnd.ollama.image.model", "digest": "sha256:nope", "size": 1}]}))
models = {m["name"]: m for m in setup.ollama_models()}
assert set(models) == {"qwen3-coder:30b", "someone/mymodel:latest"}
assert models["qwen3-coder:30b"]["path"] == str(blob)
assert models["qwen3-coder:30b"]["size"] == 18 * GB
def test_overview_offers_what_fits_and_the_pull_commands(tmp_path, monkeypatch):
_ollama_model(tmp_path / "ollama", "qwen3-coder", "30b", 18 * GB)
_ollama_model(tmp_path / "ollama", "huge", "1t", 400 * GB)
monkeypatch.setattr(setup, "find_llama_server", lambda: None)
o = setup.overview()
ids = [m["id"] for m in o["suggested"]]
# 32 GB: the 36 GB Nemotron is too large; the others are offered.
assert "nemotron-3.5-lightning" not in ids
assert "gpt-oss-20b" in ids and "qwen3-coder-30b" in ids
assert [m["name"] for m in o["ollama"]] == ["qwen3-coder:30b"]
# Ollama already has qwen3-coder, so only gpt-oss gets a download command.
assert [p["command"] for p in o["ollama_pull"]] == ["ollama pull gpt-oss:20b"]
assert o["installed"] is None and o["install_command"] == "brew install llama.cpp"
assert o["cache_dir"].endswith("cache") and o["phase"] == "idle" and o["autostart"] is False
def test_a_file_with_an_mtp_draft_beside_it_runs_with_the_draft(tmp_path):
model = tmp_path / "Nemo-Q8_0.gguf"
model.write_bytes(b"GGUF")
plan = setup.runner()._plan({"kind": "file", "path": str(model)})
assert "draft" not in plan
(tmp_path / "mtp-Nemo-Q8_0.gguf").write_bytes(b"GGUF")
plan = setup.runner()._plan({"kind": "file", "path": str(model)})
assert plan["draft"].endswith("mtp-Nemo-Q8_0.gguf") and plan["spec_type"] == "draft-mtp"
with pytest.raises(ValueError):
setup.runner()._plan({"kind": "file", "path": str(tmp_path / "x.bin")})
def test_download_lands_in_the_hugging_face_layout_and_resumes(tmp_path, monkeypatch):
entry = {"id": "t", "name": "T", "repo": "org/t-GGUF", "file": "t.gguf", "draft": "mtp-t.gguf"}
blobs = {"t.gguf": b"M" * 5000, "mtp-t.gguf": b"D" * 300}
monkeypatch.setattr(
setup, "_hf_files", lambda repo, *a: ("c0ffee", {f: {"size": len(b), "oid": f"oid-{f}"} for f, b in blobs.items()})
)
# Half of the main file is already there from an interrupted run.
part = setup._repo_dir("org/t-GGUF") / "blobs" / "oid-t.gguf.incomplete"
part.parent.mkdir(parents=True)
part.write_bytes(blobs["t.gguf"][:2000])
ranges = []
def handler(req: httpx.Request) -> httpx.Response:
name = req.url.path.rsplit("/", 1)[-1]
assert "/resolve/c0ffee/" in req.url.path
body = blobs[name]
rng = req.headers.get("range")
ranges.append((name, rng))
if rng:
start = int(rng.split("=")[1].rstrip("-"))
return httpx.Response(206, content=body[start:])
return httpx.Response(200, content=body)
client = httpx.Client(transport=httpx.MockTransport(handler))
monkeypatch.setattr(httpx, "stream", lambda method, url, headers=None, **kw: client.stream(method, url, headers=headers))
got = setup.runner()._download(entry)
assert ranges == [("t.gguf", "bytes=2000-"), ("mtp-t.gguf", None)]
snap = setup._repo_dir("org/t-GGUF") / "snapshots" / "c0ffee"
assert Path(got["file"]) == snap / "t.gguf" and (snap / "t.gguf").is_symlink()
assert (snap / "t.gguf").read_bytes() == blobs["t.gguf"]
assert Path(got["draft"]).read_bytes() == blobs["mtp-t.gguf"]
assert (setup._repo_dir("org/t-GGUF") / "refs" / "main").read_text() == "c0ffee"
assert setup._downloaded(entry)
assert setup.runner().progress["done"] == setup.runner().progress["total"] == 5300
def test_the_catalog_names_each_model_licence_and_the_card_shows_it(monkeypatch):
for m in setup.catalog():
assert m["license"] and m["license_url"].startswith("https://"), m["id"]
assert m["license_source"].startswith("https://huggingface.co/"), m["id"]
monkeypatch.setattr(local_machine, "model_memory_bytes", lambda: 96 * GB)
monkeypatch.setattr(setup, "find_llama_server", lambda: None)
rows = {m["id"]: m for m in setup.overview()["suggested"]}
assert rows["nemotron-3.5-lightning"]["license"] == "OpenMDW-1.1"
assert rows["qwen3-coder-30b"]["license"] == "Apache-2.0" and rows["gpt-oss-20b"]["license"] == "Apache-2.0"
assert all(r["license_url"].startswith("https://") for r in rows.values())
def test_retry_after_is_read_from_the_headers_and_bounded():
r = lambda **h: httpx.Response(429, headers=h) # noqa: E731
assert setup._retry_after(r(**{"Retry-After": "7"})) == 7
assert setup._retry_after(r(**{"RateLimit-Reset": "12"})) == 12
# Hugging Face's own header: remaining and seconds to the window's end.
assert setup._retry_after(r(**{"RateLimit": '"resolvers";r=0;t=52'})) == 52
assert setup._retry_after(r(**{"Retry-After": "0"})) == 1
assert setup._retry_after(r(**{"Retry-After": "9000"})) == setup.RATE_LIMIT_WAIT_MAX_S
assert setup._retry_after(r()) == setup.RATE_LIMIT_WAIT_DEFAULT_S
from email.utils import format_datetime
import datetime as dt
soon = format_datetime(dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=30), usegmt=True)
assert 27 <= setup._retry_after(r(**{"Retry-After": soon})) <= 30
def _serve(monkeypatch, handler, *, api=True, files=True):
"""Route the module's Hugging Face calls (API and file downloads) to `handler`."""
client = httpx.Client(transport=httpx.MockTransport(handler))
if api:
monkeypatch.setattr(httpx, "request", lambda method, url, **kw: client.request(method, url, headers=kw.get("headers")))
if files:
monkeypatch.setattr(httpx, "stream", lambda method, url, headers=None, **kw: client.stream(method, url, headers=headers))
def test_download_verifies_the_checksum_and_removes_a_bad_file(tmp_path, monkeypatch):
good = b"G" * 4000
sha = hashlib.sha256(good).hexdigest()
entry = {"id": "t", "name": "T", "repo": "org/t-GGUF", "file": "t.gguf"}
monkeypatch.setattr(setup, "_hf_files", lambda repo, *a: ("c0ffee", {"t.gguf": {"size": 4000, "oid": sha, "sha256": sha}}))
body = {"ok": good, "bad": b"B" * 4000}
serving = ["ok"]
_serve(monkeypatch, lambda req: httpx.Response(200, content=body[serving[0]]), api=False)
r = setup.runner()
got = r._download(entry)
assert Path(got["file"]).read_bytes() == good
assert (setup._repo_dir("org/t-GGUF") / "blobs" / sha).is_file()
assert r.progress["done"] == r.progress["total"] == 4000 and r.state()["note"] is None
# The same size, different bytes: the file is refused and removed, nothing is placed.
serving[0] = "bad"
monkeypatch.setattr(setup, "_hf_files", lambda repo, *a: ("c0ffee", {"t.gguf": {"size": 4000, "oid": "x" * 64, "sha256": "x" * 64}}))
with pytest.raises(RuntimeError, match="checksum"):
setup.LlamaCppRunner()._download({**entry, "repo": "org/u-GGUF"})
blobs = setup._repo_dir("org/u-GGUF") / "blobs"
assert not list(blobs.iterdir()) and not (setup._repo_dir("org/u-GGUF") / "snapshots").exists()
def test_without_the_api_the_download_is_unverified_and_says_so(tmp_path, monkeypatch):
data = b"Q" * 2500
entry = {"id": "t", "name": "T", "repo": "org/t-GGUF", "file": "t.gguf"}
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path.startswith("/api/"):
raise httpx.ConnectError("api down")
if req.method == "HEAD":
# What huggingface.co's 302 to the CDN says about the file.
return httpx.Response(302, headers={"Location": "https://cdn.example/x", "X-Repo-Commit": "abc123",
"X-Linked-Size": str(len(data)), "X-Linked-ETag": '"etag-t"'})
assert req.url.path == "/org/t-GGUF/resolve/abc123/t.gguf"
return httpx.Response(200, content=data)
_serve(monkeypatch, handler)
r = setup.runner()
got = r._download(entry)
assert Path(got["file"]) == setup._repo_dir("org/t-GGUF") / "snapshots" / "abc123" / "t.gguf"
assert Path(got["file"]).read_bytes() == data
assert (setup._repo_dir("org/t-GGUF") / "blobs" / "etag-t").is_file()
assert r.state()["note"] == "unverified"
def test_a_429_is_waited_out_as_hugging_face_asks(tmp_path, monkeypatch):
data = b"R" * 1200
sha = hashlib.sha256(data).hexdigest()
entry = {"id": "t", "name": "T", "repo": "org/t-GGUF", "file": "t.gguf"}
limited = {"/api/models/org/t-GGUF/revision/main": 1, "/org/t-GGUF/resolve/c0ffee/t.gguf": 1}
def handler(req: httpx.Request) -> httpx.Response:
path = req.url.path
if limited.get(path):
limited[path] -= 1
hdr = {"Retry-After": "1"} if path.startswith("/api/") else {"RateLimit": '"resolvers";r=0;t=1'}
return httpx.Response(429, headers=hdr)
if path.endswith("/revision/main"):
return httpx.Response(200, json={"sha": "c0ffee"})
if "/tree/" in path:
return httpx.Response(200, json=[{"path": "t.gguf", "size": len(data), "lfs": {"oid": sha, "size": len(data)}}])
return httpx.Response(200, content=data)
_serve(monkeypatch, handler)
r = setup.runner()
waits = []
real = r._rate_wait
monkeypatch.setattr(r, "_rate_wait", lambda s: (waits.append(s), real(s)))
got = r._download(entry)
assert waits == [1, 1]
assert Path(got["file"]).read_bytes() == data and r.state()["retry_in"] is None
# While it waits, the card can count down; Cancel ends the wait at once.
t = threading.Thread(target=lambda: pytest.raises(setup._Cancelled, real, 30))
t.start()
time.sleep(0.3)
assert r.state()["retry_in"] == 30
r.cancel()
t.join(5)
assert not t.is_alive() and r.state()["retry_in"] is None
def test_too_many_429s_end_with_a_clear_error(tmp_path, monkeypatch):
entry = {"id": "t", "name": "T", "repo": "org/t-GGUF", "file": "t.gguf"}
monkeypatch.setattr(setup, "_hf_files", lambda repo, *a: ("c0ffee", {"t.gguf": {"size": 10, "oid": "o", "sha256": None}}))
_serve(monkeypatch, lambda req: httpx.Response(429, headers={"Retry-After": "1"}), api=False)
monkeypatch.setattr(setup, "RATE_LIMIT_RETRIES", 1)
r = setup.runner()
monkeypatch.setattr(r, "_rate_wait", lambda s: None)
with pytest.raises(RuntimeError, match="rate-limiting"):
r._download(entry)
# -- the managed server, against a stand-in llama-server --------------------------------
FAKE = textwrap.dedent(
"""
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
args = sys.argv[1:]
port = int(args[args.index("--port") + 1])
alias = args[args.index("--alias") + 1]
if "broken" in alias:
print("E llama_model_load: error loading model", flush=True)
sys.exit(1)
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, obj):
data = json.dumps(obj).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
self._send({"status": "ok"} if self.path == "/health" else {"data": [{"id": alias}]})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
msg = {"role": "assistant", "content": "hello"}
if body.get("tools") and "notools" not in alias:
msg = {"role": "assistant", "content": None, "tool_calls": [{"id": "1", "type": "function", "function": {"name": "get_time", "arguments": "{}"}}]}
self._send({"choices": [{"message": msg}]})
HTTPServer(("127.0.0.1", port), H).serve_forever()
"""
)
@pytest.fixture
def fake_server(tmp_path, monkeypatch):
script = tmp_path / "fake_llama_server.py"
script.write_text(FAKE)
exe = tmp_path / "llama-server"
exe.write_text(f"#!/bin/sh\nexec {sys.executable} {script} \"$@\"\n")
exe.chmod(0o755)
monkeypatch.setattr(setup, "find_llama_server", lambda: str(exe))
monkeypatch.setattr(setup, "DEFAULT_PORT", 18_900)
return exe
def _settle(r: setup.LlamaCppRunner, timeout: float = 30) -> dict:
end = time.monotonic() + timeout
while r.busy() and time.monotonic() < end:
time.sleep(0.1)
return r.state()
def _alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except OSError:
return False
def _gguf(tmp_path: Path, name: str) -> str:
p = tmp_path / f"{name}.gguf"
p.write_bytes(b"GGUF")
return str(p)
def test_start_checks_points_the_provider_and_switches(fake_server, tmp_path):
r = setup.runner()
told = []
r.on_running = lambda url, alias: told.append((url, alias))
assert r.start({"kind": "file", "path": _gguf(tmp_path, "good")})["ok"]
st = _settle(r)
assert st["phase"] == "running", st["error"]
assert st["check"] == {"answers": True, "tools": True}
assert told == [(f"http://127.0.0.1:{st['address'].split(':')[1]}", "good")]
first = json.loads(setup._state_path("llamacpp.pid").read_text())
assert _alive(first["pid"]) and _alive(first["watcher"])
assert setup._load_settings()["choice"]["path"].endswith("good.gguf")
# Switch: the first server goes, the new one answers but cannot call tools.
r.start({"kind": "file", "path": _gguf(tmp_path, "notools")})
st = _settle(r)
assert st["phase"] == "running" and st["check"] == {"answers": True, "tools": False}
assert not _alive(first["pid"])
r.stop()
assert r.state()["phase"] == "idle"
assert not setup._state_path("llamacpp.pid").exists()
def test_a_model_that_fails_to_load_shows_the_error(fake_server, tmp_path):
r = setup.runner()
r.start({"kind": "file", "path": _gguf(tmp_path, "broken")})
st = _settle(r)
assert st["phase"] == "failed"
assert "error loading model" in st["error"]
def test_the_server_does_not_outlive_openworker(fake_server, tmp_path):
"""The desktop app ends its server with SIGKILL: the watcher stops llama-server."""
import subprocess
code = textwrap.dedent(
f"""
import json, os, signal, sys, time
sys.path.insert(0, {str(Path(__file__).resolve().parents[1])!r})
from coworker.providers import llamacpp_setup as s
s.find_llama_server = lambda: {str(fake_server)!r}
s.DEFAULT_PORT = 18950
r = s.runner()
r.start({{"kind": "file", "path": {_gguf(tmp_path, "orphan")!r}}})
while r.busy():
time.sleep(0.1)
print(json.dumps(r.state()["phase"]), flush=True)
os.kill(os.getpid(), signal.SIGKILL)
"""
)
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=60, env=os.environ)
assert '"running"' in out.stdout, out.stderr
pid = json.loads(setup._state_path("llamacpp.pid").read_text())["pid"]
for _ in range(50):
if not _alive(pid):
break
time.sleep(0.2)
assert not _alive(pid)
def test_app_start_reaps_a_leftover_and_honours_autostart(fake_server, tmp_path, monkeypatch):
import subprocess
leftover = subprocess.Popen(["sleep", "60"])
setup._state_path("llamacpp.pid").parent.mkdir(parents=True, exist_ok=True)
setup._state_path("llamacpp.pid").write_text(json.dumps({"pid": leftover.pid}))
monkeypatch.setattr(setup, "_is_llama_server", lambda pid: pid == leftover.pid)
monkeypatch.setattr(setup, "supported", lambda: True)
r = setup.runner()
r.on_app_start()
leftover.wait(timeout=10)
assert r.state()["phase"] == "idle" # autostart is off by default
setup._save_settings({"autostart": True, "choice": {"kind": "file", "path": _gguf(tmp_path, "auto")}})
r.on_app_start()
assert _settle(r)["phase"] == "running"