Files
llama-cpp/llama-swap/controller.py
T
mozempkandClaude Fable 5 5e68d30d31 swap-stack: duo config — qwen3-4b full GPU, ornith-35b pure CPU
Duo group (resident main+subagent for pi): flip VRAM to the small model.
Ornith experts never touch VRAM; its dense-on-GPU split starved qwen to
176 MiB / 5.8 t/s concurrent. After flip: qwen 43 t/s, ornith 8 t/s.
CUDA_VISIBLE_DEVICES= required for ornith — ngl 0 still allocates ~1GB
pp compute buffer on CUDA builds (OOM+segfault). Duo section in
MOE-FINDINGS.md; also snapshots prior swap-stack migration state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:28:32 +02:00

179 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""llama-swap controller — hot-swap models via API"""
import os, time
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
import docker, httpx
from pydantic import BaseModel
app = FastAPI(title="llama-swap", version="1.1.0")
client = docker.from_env()
MODELS = {
"ornith-9b": {"profile": "ornith-9b", "file": "ornith-9b-Q8_0.gguf", "name": "Ornith 9B Q8 (coding, MIT, 32K)", "env_file": "/workspace/envs/.env.ornith-9b"},
"qwen35-9b": {"profile": "qwen35-9b", "file": "Qwen3.5-9B.Q8_0.gguf", "name": "Qwen3.5 9B Q8 (reasoning, 32K)", "env_file": "/workspace/envs/.env.qwen35-9b"},
"qwen3-4b": {"profile": "qwen3-4b", "file": "Qwen3-4B-Q4_K_M.gguf", "name": "Qwen3 4B Q4 (thinking, 16K)", "env_file": "/workspace/envs/.env.qwen3-4b"},
"gemma4-e2b": {"profile": "gemma4-e2b", "file": "google_gemma-4-E2B-it-Q4_K_M.gguf", "name": "Gemma 4 E2B (multimodal, 24K)", "env_file": "/workspace/envs/.env.gemma4-e2b"},
"gemma4-e4b": {"profile": "gemma4-e4b", "file": "google_gemma-4-E4B-it-Q4_K_M.gguf", "name": "Gemma 4 E4B (multimodal, 24K, CPU-split)", "env_file": "/workspace/envs/.env.gemma4-e4b"},
"smollm3-3b": {"profile": "smollm3-3b", "file": "HuggingFaceTB_SmolLM3-3B-Q4_K_M.gguf", "name": "SmolLM3 3B (thinking+tools, 24K)", "env_file": "/workspace/envs/.env.smollm3-3b"}
}
CONTAINER_NAME, LLAMA_API = "llama_server", "http://llama_server:8080"
IMAGE = "local/llama-cpp-turboquant:server-cuda-sm75-mmq"
NETWORK = "llama-cpp_llama-net"
def load_env_file(path: str) -> dict:
"""Parse .env file into dict"""
env = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, val = line.split('=', 1)
env[key.strip()] = val.strip()
except: pass
return env
def get_active_model() -> Optional[str]:
try:
c = client.containers.get(CONTAINER_NAME)
if c.status != "running": return None
model = c.labels.get("llama-swap.model")
return model if model in MODELS else None
except: pass
return None
def stop_active():
try:
c = client.containers.get(CONTAINER_NAME)
c.stop(timeout=10); c.remove()
except: pass
def start_model(model_id: str) -> dict:
if model_id not in MODELS: raise ValueError(f"Unknown model: {model_id}")
stop_active()
meta = MODELS[model_id]
env = load_env_file(meta["env_file"])
# Build command args from env vars
cmd_args = [
"/app/llama-server",
"--model", f"/models/{env.get('MODEL_FILE', meta['file'])}",
"--host", "0.0.0.0",
"--port", "8080",
"--ctx-size", env.get('CTX_SIZE', '32768'),
"--n-gpu-layers", env.get('N_GPU_LAYERS', '99'),
"--threads", env.get('THREADS', '6'),
"--threads-batch", env.get('THREADS_BATCH', '6'),
"--batch-size", env.get('BATCH_SIZE', '512'),
"--ubatch-size", env.get('UBATCH_SIZE', '128'),
"--cache-type-k", env.get('CACHE_TYPE_K', 'f16'),
"--cache-type-v", env.get('CACHE_TYPE_V', 'f16'),
"--parallel", env.get('PARALLEL', '1')
]
# Add EXTRA_ARGS if present (strip quotes if present)
extra = env.get('EXTRA_ARGS', '').strip().strip('"').strip("'")
if extra:
cmd_args.extend(extra.split())
# Create container with GPU device request
try:
container = client.containers.create(
image=IMAGE,
name=CONTAINER_NAME,
entrypoint=[], # Override image entrypoint
command=cmd_args,
detach=True,
runtime="nvidia",
device_requests=[
docker.types.DeviceRequest(
count=-1, # all GPUs
capabilities=[['gpu', 'compute', 'utility']]
)
],
environment={
"NVIDIA_VISIBLE_DEVICES": "all",
"NVIDIA_DRIVER_CAPABILITIES": "compute,utility",
**env
},
volumes={
"/home/moze/Sources/llama-cpp/models": {"bind": "/models", "mode": "ro"}
},
ports={"8080/tcp": 8080},
network=NETWORK,
shm_size="1g",
ulimits=[docker.types.Ulimit(name='memlock', soft=-1, hard=-1)],
restart_policy={"Name": "unless-stopped"},
labels={
"com.docker.compose.project": "llama-cpp",
"com.docker.compose.service": f"llama-{model_id}",
"llama-swap.model": model_id
},
healthcheck={
"test": ["CMD-SHELL", "curl -sf http://localhost:8080/health | grep -q '\"status\":\"ok\"'"],
"interval": 20000000000, # 20s in nanoseconds
"timeout": 10000000000,
"retries": 10,
"start_period": 180000000000 # 180s for 9B models with mlock
}
)
container.start()
# Wait for health check
for _ in range(90):
try:
container.reload()
health = container.attrs.get("State", {}).get("Health", {}).get("Status")
if health == "healthy":
return {"model": model_id, "status": "healthy", "container": CONTAINER_NAME}
except: pass
time.sleep(2)
return {"model": model_id, "status": "starting", "container": CONTAINER_NAME}
except Exception as e:
raise RuntimeError(f"Failed to start {model_id}: {str(e)}")
class SwitchRequest(BaseModel):
model: str
@app.get("/")
async def root(): return {"service": "llama-swap", "version": "1.1.0"}
@app.get("/models")
async def list_models():
active = get_active_model()
return {"models": [{"id": mid, "name": m["name"], "file": m["file"], "active": mid==active} for mid, m in MODELS.items()], "active": active}
@app.post("/models/switch")
async def switch_model(req: SwitchRequest):
if req.model not in MODELS: raise HTTPException(404, f"Unknown: {req.model}")
if get_active_model() == req.model: return {"model": req.model, "status": "already_active"}
try: return start_model(req.model)
except Exception as e: raise HTTPException(500, str(e))
@app.get("/status")
async def status():
active = get_active_model()
if not active: return {"status": "no_model", "model": None}
try:
c = client.containers.get(CONTAINER_NAME)
return {"status": "running", "model": active, "health": c.attrs.get("State",{}).get("Health",{}).get("Status","unknown")}
except: return {"status": "no_model", "model": None}
@app.api_route("/v1/{path:path}", methods=["GET","POST","PUT","DELETE","PATCH"])
async def proxy_llama(path: str, request: Request):
if not get_active_model(): raise HTTPException(503, "No model active")
url = f"{LLAMA_API}/v1/{path}"
headers = dict(request.headers); headers.pop("host", None)
async with httpx.AsyncClient(timeout=300.0) as http:
if request.method == "GET":
resp = await http.get(url, headers=headers, params=request.query_params)
else:
resp = await http.request(request.method, url, headers=headers, params=request.query_params, content=await request.body())
if "text/event-stream" in resp.headers.get("content-type",""):
return StreamingResponse(resp.aiter_bytes(), media_type=resp.headers.get("content-type"), headers=dict(resp.headers))
return JSONResponse(content=resp.json() if "json" in resp.headers.get("content-type","") else resp.text, status_code=resp.status_code, headers=dict(resp.headers))