# Model Menu + Persistent Daemon Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a model menu (3 permissive SDXL models), a persistent HTTP daemon that holds one model resident in XPU VRAM for fast prompt iteration, and docker compose lifecycle, to the local Intel Arc image generator. **Architecture:** `generate.py` is refactored to expose a model registry and reusable `resolve_model`/`build_pipe`/`generate` functions, keeping its one-shot CLI. A new `server.py` imports that core and serves `/status`, `/generate`, `/model` over stdlib HTTP on localhost. A `compose.yaml` runs the daemon as the host user; the `imggen` wrapper gains `start`/`stop`/`status`/`list` subcommands and routes prompts to the daemon (with a one-shot fallback). **Tech Stack:** Python 3 stdlib (`http.server`, `json`, `argparse`), diffusers `StableDiffusionXLPipeline` + `AutoencoderKL`, torch XPU, docker compose v2, bash. --- ## File Structure - `generate.py` (modify) — model registry, `resolve_model`, refactored `build_pipe(key)` with variant fallback, unchanged `generate`, `-m/--model` CLI flag. - `server.py` (create) — stdlib HTTP daemon holding one resident pipe; imports from `generate.py`. - `compose.yaml` (create) — daemon service, runs as host user, GPU + mounts + port. - `.env.example` (create) — documents `.env` shape (real `.env` is gitignored, already handled). - `imggen` (modify, in `~/bin/`) — subcommand dispatch + daemon client + one-shot fallback + interactive menu. - `test_generate.py` (create) — pure-logic asserts (registry, resolve_model). - `README.md` (modify) — new usage, lifecycle, VRAM warning. - `CLAUDE.md` (modify) — new architecture, corrected defaults. Note: `.gitignore` already committed (protects `.env`). HF cache chown + `.env` creation are done by the user, not tasks here. --- ## Task 1: Model registry + resolve_model (pure logic, TDD) **Files:** - Modify: `generate.py:27-30` (replace `MODEL`/`VAE` constants) - Test: `test_generate.py` (create) - [ ] **Step 1: Write the failing test** Create `test_generate.py`: ```python import generate def test_registry_integrity(): assert generate.DEFAULT_MODEL in generate.MODELS for key, repo in generate.MODELS.items(): assert isinstance(key, str) and key assert isinstance(repo, str) and "/" in repo def test_resolve_known_key(): assert generate.resolve_model("sdxl") == generate.MODELS["sdxl"] assert generate.resolve_model("realvis") == generate.MODELS["realvis"] def test_resolve_unknown_key_raises_listing_valid(): try: generate.resolve_model("nope") except SystemExit as e: assert "sdxl" in str(e) and "realvis" in str(e) and "pony" in str(e) else: assert False, "expected SystemExit for unknown key" if __name__ == "__main__": test_registry_integrity() test_resolve_known_key() test_resolve_unknown_key_raises_listing_valid() print("ok") ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 test_generate.py` Expected: FAIL — `AttributeError: module 'generate' has no attribute 'MODELS'` (or `resolve_model`). - [ ] **Step 3: Write minimal implementation** In `generate.py`, replace lines 27-29 (the `MODEL` and `VAE` constants) with: ```python MODELS = { "sdxl": "stabilityai/stable-diffusion-xl-base-1.0", # default, general "realvis": "SG161222/RealVisXL_V5.0", # photoreal, permissive "pony": "AstraliteHeart/pony-diffusion-v6-xl", # unrestricted, versatile } DEFAULT_MODEL = "sdxl" # fp16-fix VAE avoids the fp32 upcast that OOMs a 12GB card at decode time. VAE = "madebyollin/sdxl-vae-fp16-fix" def resolve_model(key): if key not in MODELS: valid = ", ".join(sorted(MODELS)) raise SystemExit(f"unknown model '{key}'. valid: {valid}") return MODELS[key] ``` Note: importing `generate.py` currently runs nothing at import time (all logic is under `main()` / `if __name__`), so `import generate` is safe for the test. Confirm no top-level executable code was added. - [ ] **Step 4: Run test to verify it passes** Run: `python3 test_generate.py` Expected: `ok` - [ ] **Step 5: Commit** ```bash git add generate.py test_generate.py git commit -S -m "Add model registry and resolve_model with valid-key errors Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 2: Refactor build_pipe to take a model key with variant fallback **Files:** - Modify: `generate.py:33-53` (`build_pipe`) This is GPU/download code; no cheap unit test. Verified in Task 7 (integration on real Arc). TDD does not apply to the XPU load itself. - [ ] **Step 1: Rewrite build_pipe** Replace `build_pipe` (currently lines 33-53) with: ```python def build_pipe(model_key): if not torch.xpu.is_available(): # Fail loud and specific. A silent CPU fallback would run but take # minutes per image, which is worse than a clear error. print( "ERROR: torch.xpu is not available. The Level Zero runtime is not " "visible inside this container. Check that you passed --device " "/dev/dri and that the base image ships the Intel GPU runtime.", file=sys.stderr, ) sys.exit(2) repo = resolve_model(model_key) vae = AutoencoderKL.from_pretrained(VAE, torch_dtype=torch.float16) try: # SDXL base publishes an fp16 variant (smaller download). pipe = StableDiffusionXLPipeline.from_pretrained( repo, vae=vae, torch_dtype=torch.float16, use_safetensors=True, variant="fp16", ) except (ValueError, OSError, EnvironmentError): # Many fine-tunes do not publish a separate fp16 variant; load the # default files at fp16 dtype instead. pipe = StableDiffusionXLPipeline.from_pretrained( repo, vae=vae, torch_dtype=torch.float16, use_safetensors=True, ) return pipe.to("xpu") ``` - [ ] **Step 2: Verify it still imports (no GPU needed)** Run: `python3 -c "import generate; print('import ok')"` Expected: `import ok` (import does not touch XPU). - [ ] **Step 3: Commit** ```bash git add generate.py git commit -S -m "Refactor build_pipe to take a model key with fp16-variant fallback Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 3: Add -m/--model flag to the one-shot CLI **Files:** - Modify: `generate.py` (argparse in `main`, the `build_pipe()` call, the load message) - [ ] **Step 1: Add the argument** In `main()`, after the `prompt` positional (currently around line 80), add: ```python ap.add_argument( "-m", "--model", default=DEFAULT_MODEL, help=f"Model key: {', '.join(sorted(MODELS))} (default: {DEFAULT_MODEL}).", ) ``` - [ ] **Step 2: Pass the key into build_pipe and the load message** Replace the load block (currently lines 133-136): ```python print(f"Loading {args.model} + fp16-fix VAE on XPU ...", file=sys.stderr) t0 = time.time() pipe = build_pipe(args.model) print(f"Loaded in {time.time() - t0:.1f}s", file=sys.stderr) ``` - [ ] **Step 3: Verify the CLI parses (no GPU)** Run: `python3 generate.py --help` Expected: help text shows `-m/--model` with `sdxl, realvis, pony` and default `sdxl`. Run: `python3 generate.py "x" -m nope 2>&1 | head -1` is NOT valid here (would hit XPU/build first only after arg parse). Instead verify unknown-key handling stays in `resolve_model` (covered by Task 1). Skip runtime call. - [ ] **Step 4: Commit** ```bash git add generate.py git commit -S -m "Add -m/--model flag to one-shot CLI Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 4: HTTP daemon (server.py) **Files:** - Create: `server.py` Holds one resident pipe. Reuses `build_pipe`/`generate`/`resolve_model` from `generate.py`. Serial by construction (`http.server` default), which is required (one GPU). - [ ] **Step 1: Write server.py** Create `server.py`: ```python #!/usr/bin/env python3 # Copyright (C) 2026 Danilo M. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # Long-lived daemon that keeps ONE SDXL model resident in XPU VRAM so prompt # iteration skips the model-reload tax. Stdlib HTTP only. Serial by design: # one GPU, so concurrent generation would OOM. See README.md. import io import json import os import sys from http.server import BaseHTTPRequestHandler, HTTPServer import torch import generate PORT = int(os.environ.get("IMGGEN_PORT", "8765")) STATE = {"model": None, "pipe": None} def load(model_key): if STATE["model"] == model_key and STATE["pipe"] is not None: return if STATE["pipe"] is not None: del STATE["pipe"] STATE["pipe"] = None torch.xpu.empty_cache() STATE["pipe"] = generate.build_pipe(model_key) STATE["model"] = model_key class Handler(BaseHTTPRequestHandler): def _json(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json(self): length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length) if length else b"{}" return json.loads(raw) # raises on bad JSON -> caught by caller def do_GET(self): if self.path == "/status": self._json(200, {"model": STATE["model"], "ready": STATE["pipe"] is not None}) else: self._json(404, {"error": "not found"}) def do_POST(self): try: data = self._read_json() except (ValueError, json.JSONDecodeError): self._json(400, {"error": "bad JSON"}) return if self.path == "/model": name = data.get("name") try: generate.resolve_model(name) except SystemExit as e: self._json(400, {"error": str(e)}) return try: load(name) except Exception as e: # download / load failure self._json(500, {"error": f"load failed: {e}"}) return self._json(200, {"model": STATE["model"], "ready": True}) return if self.path == "/generate": try: image, elapsed = generate.generate( STATE["pipe"], data["prompt"], data.get("negative", ""), int(data.get("steps", 25)), float(data.get("guidance", 7.0)), data.get("seed"), int(data.get("width", 1024)), int(data.get("height", 1024)), ) except torch.OutOfMemoryError: torch.xpu.empty_cache() self._json(500, {"error": "OOM, try fewer steps or smaller size"}) return except Exception as e: self._json(500, {"error": str(e)}) return buf = io.BytesIO() image.save(buf, format="PNG") body = buf.getvalue() self.send_response(200) self.send_header("Content-Type", "image/png") self.send_header("X-Elapsed", f"{elapsed:.2f}") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return self._json(404, {"error": "not found"}) def log_message(self, fmt, *args): sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) def main(): startup = os.environ.get("IMGGEN_MODEL", generate.DEFAULT_MODEL) print(f"Loading {startup} on XPU ...", file=sys.stderr) load(startup) print(f"Ready on 127.0.0.1:{PORT} with model {startup}", file=sys.stderr) HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() if __name__ == "__main__": main() ``` Note: bind `0.0.0.0` inside the container; compose publishes only to `127.0.0.1` on the host, so it is not exposed to the network. - [ ] **Step 2: Verify it imports and parses (no GPU, no serve)** Run: `python3 -c "import ast; ast.parse(open('server.py').read()); print('parse ok')"` Expected: `parse ok` (Cannot import-run `main()` without XPU; syntax check is the cheap gate. Full run is Task 7.) - [ ] **Step 3: Commit** ```bash git add server.py git commit -S -m "Add persistent HTTP daemon holding one model resident in VRAM Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 5: compose.yaml + .env.example + Dockerfile CMD check **Files:** - Create: `compose.yaml` - Create: `.env.example` - Read (verify): `Dockerfile` (ensure `server.py` gets copied in; add if missing) - [ ] **Step 1: Verify the Dockerfile copies server.py** Run: `grep -nE 'COPY|ADD' Dockerfile` Expected: a `COPY` that brings in the app files. If it copies `generate.py` explicitly (e.g. `COPY generate.py .`), add a sibling line `COPY server.py .` right after it. If it copies the whole context (`COPY . .`), no change needed. Make the minimal edit to ensure `server.py` lands in the image working dir. - [ ] **Step 2: Write compose.yaml** Create `compose.yaml`: ```yaml services: imggen: build: . image: imggen:local user: "${IMGGEN_UID:-1000}:${IMGGEN_GID:-100}" group_add: - video # gid for /dev/dri/card* access as non-root devices: - /dev/dri volumes: - ${IMGGEN_OUT:-./out}:/out - ${IMGGEN_CACHE:-./models}:/models environment: - HF_HOME=/models - IMGGEN_MODEL=${IMGGEN_MODEL:-sdxl} - IMGGEN_PORT=${IMGGEN_PORT:-8765} ports: - "127.0.0.1:${IMGGEN_PORT:-8765}:8765" command: ["python3", "server.py"] ``` - [ ] **Step 3: Write .env.example** Create `.env.example`: ``` # Copy to .env (gitignored) and set your real host paths. IMGGEN_OUT=/path/to/output IMGGEN_CACHE=/path/to/model-cache # IMGGEN_PORT=8765 # IMGGEN_MODEL=sdxl # IMGGEN_UID=1000 # IMGGEN_GID=100 ``` - [ ] **Step 4: Validate compose config (needs .env present)** Run: `docker compose config >/dev/null && echo "compose ok"` Expected: `compose ok`. If it errors on missing `.env` variables, confirm the user's `.env` exists (they created it); the `:-` defaults cover the rest. - [ ] **Step 5: Commit** ```bash git add compose.yaml .env.example Dockerfile git commit -S -m "Add compose service (runs as host user) and .env.example Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 6: Rewrite the imggen wrapper (subcommands + daemon client + fallback) **Files:** - Modify: `~/bin/imggen` (and keep the repo copy `./imggen` in sync — copy after) The wrapper must `cd` to the repo dir so `docker compose` finds `compose.yaml` and `.env`. Set `IMGGEN_DIR` to the repo path. - [ ] **Step 1: Write the new wrapper** Replace `~/bin/imggen` with: ```bash #!/bin/bash # Copyright (C) 2026 Danilo # Licensed under the GNU General Public License version 2. # # Host-side wrapper for local SDXL image generation on Intel Arc. # Subcommands manage a persistent daemon (model resident in VRAM); a bare # prompt talks to the daemon if up, else falls back to a one-shot run. # # Usage: # imggen start [-m realvis] # spin up daemon, load model, hold VRAM # imggen stop # kill daemon, free VRAM # imggen status # is it up? which model? # imggen list # show model keys # imggen "a red bicycle" [-m realvis] [--steps 30] [--seed 42] [-o out.png] [-n "blurry"] set -euo pipefail IMGGEN_DIR="${IMGGEN_DIR:-$HOME/Programming/GIT/imggen}" cd "$IMGGEN_DIR" # Load .env for OUT dir + port on the host side. if [ -f .env ]; then set -a; . ./.env; set +a; fi OUT_DIR="${IMGGEN_OUT:-$HOME/Pictures/AI-gen}" PORT="${IMGGEN_PORT:-8765}" BASE="http://127.0.0.1:${PORT}" mkdir -p "$OUT_DIR" usage() { echo "usage:" >&2 echo " imggen start [-m KEY] | stop | status | list" >&2 echo " imggen \"\" [-m KEY] [--steps N] [--guidance G] [--seed S] [-n NEG] [-o name.png]" >&2 exit 1 } daemon_up() { curl -sf "${BASE}/status" >/dev/null 2>&1; } case "${1:-}" in start) shift model="${IMGGEN_MODEL:-sdxl}" if [ "${1:-}" = "-m" ] || [ "${1:-}" = "--model" ]; then model="$2"; fi IMGGEN_MODEL="$model" docker compose up -d echo "daemon starting with model '$model' (VRAM ~9GB held until 'imggen stop')" exit 0 ;; stop) docker compose down echo "daemon stopped, VRAM freed" exit 0 ;; status) if daemon_up; then curl -s "${BASE}/status"; echo; else echo "daemon down"; fi exit 0 ;; list) echo "sdxl general (default)" echo "realvis photoreal, permissive" echo "pony unrestricted, versatile" exit 0 ;; "" ) usage ;; -h|--help) usage ;; esac # --- prompt path --- PROMPT="$1"; shift MODEL=""; NEG=""; STEPS=""; GUID=""; SEED=""; OUT=""; W=""; H="" while [ "$#" -gt 0 ]; do case "$1" in -m|--model) MODEL="$2"; shift 2 ;; -n|--negative) NEG="$2"; shift 2 ;; -s|--steps) STEPS="$2"; shift 2 ;; -g|--guidance) GUID="$2"; shift 2 ;; --seed) SEED="$2"; shift 2 ;; -W|--width) W="$2"; shift 2 ;; -H|--height) H="$2"; shift 2 ;; -o|--out) OUT="$2"; shift 2 ;; *) echo "unknown arg: $1" >&2; usage ;; esac done OUT_NAME="${OUT:-sdxl-$(date +%Y%m%d-%H%M%S).png}" OUT_PATH="${OUT_DIR}/${OUT_NAME}" if daemon_up; then # Interactive menu only when no -m and a TTY is present. if [ -z "$MODEL" ] && [ -t 0 ] && [ -t 1 ]; then echo "which model? 1) sdxl 2) realvis 3) pony" >&2 read -rp "choice [1]: " choice case "$choice" in 2) MODEL=realvis ;; 3) MODEL=pony ;; *) MODEL=sdxl ;; esac fi MODEL="${MODEL:-sdxl}" # Swap resident model if needed. current="$(curl -s "${BASE}/status" | sed -n 's/.*"model": *"\([^"]*\)".*/\1/p')" if [ "$current" != "$MODEL" ]; then echo "swapping daemon model $current -> $MODEL ..." >&2 curl -sf -X POST "${BASE}/model" -H 'Content-Type: application/json' \ -d "{\"name\":\"$MODEL\"}" >/dev/null fi # Build JSON request. jq not assumed; hand-build (values are ours/simple). req="{\"prompt\":$(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" [ -n "$NEG" ] && req="$req,\"negative\":$(printf '%s' "$NEG" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" [ -n "$STEPS" ] && req="$req,\"steps\":$STEPS" [ -n "$GUID" ] && req="$req,\"guidance\":$GUID" [ -n "$SEED" ] && req="$req,\"seed\":$SEED" [ -n "$W" ] && req="$req,\"width\":$W" [ -n "$H" ] && req="$req,\"height\":$H" req="$req}" if curl -sf -X POST "${BASE}/generate" -H 'Content-Type: application/json' \ -d "$req" -o "$OUT_PATH"; then echo "Saved ${OUT_PATH}" else echo "generate failed (see daemon logs: docker compose logs)" >&2 rm -f "$OUT_PATH" exit 1 fi else # One-shot fallback: non-interactive, model defaults to sdxl or -m KEY. echo "daemon down, one-shot run (slower: model loads then exits)" >&2 set -- "$PROMPT" [ -n "$MODEL" ] && set -- "$@" -m "$MODEL" [ -n "$NEG" ] && set -- "$@" -n "$NEG" [ -n "$STEPS" ] && set -- "$@" -s "$STEPS" [ -n "$GUID" ] && set -- "$@" -g "$GUID" [ -n "$SEED" ] && set -- "$@" --seed "$SEED" [ -n "$W" ] && set -- "$@" -W "$W" [ -n "$H" ] && set -- "$@" -H "$H" set -- "$@" -o "$OUT_NAME" docker compose run --rm imggen python3 generate.py "$@" fi ``` - [ ] **Step 2: Syntax check** Run: `bash -n ~/bin/imggen && echo "syntax ok"` Expected: `syntax ok` - [ ] **Step 3: Test subcommand routing without a daemon (no GPU)** Run: `~/bin/imggen list` Expected: three lines (sdxl / realvis / pony). Run: `~/bin/imggen status` Expected: `daemon down` (daemon not started yet). - [ ] **Step 4: Sync repo copy and commit** ```bash cp ~/bin/imggen ./imggen git add imggen git commit -S -m "Rewrite wrapper: daemon lifecycle, client, and one-shot fallback Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 7: Integration on real Arc (agent runs on this machine) **Files:** none (verification task). Prereqs done by user: HF cache chown to `danix:users`, `.env` created with real paths. - [ ] **Step 1: Rebuild the image with server.py** Run: `cd "$IMGGEN_DIR" && docker compose build` Expected: builds `imggen:local`, no error, `server.py` present in image. - [ ] **Step 2: One-shot SDXL still works (cached weights)** Run: `~/bin/imggen "a red bicycle against a stone wall" -o test-oneshot.png` Expected: daemon down → one-shot path → `Saved .../test-oneshot.png`. Verify: `file "$HOME/Pictures/AI-gen/test-oneshot.png"` shows PNG 1024x1024, and `ls -l` shows owner `danix:users` (permissions fix works). - [ ] **Step 3: Daemon lifecycle on SDXL** Run: `~/bin/imggen start` Then: `~/bin/imggen status` Expected: `{"model": "sdxl", "ready": true}`. Run: `~/bin/imggen "a foggy harbour at dawn" -m sdxl -o test-daemon.png` Expected: no reload message, `Saved .../test-daemon.png`, owner `danix:users`. - [ ] **Step 4: Model download + swap (realvis, then pony) — approved pulls** Run: `~/bin/imggen "a portrait of a fox, photoreal" -m realvis -o test-realvis.png` Expected: `swapping daemon model sdxl -> realvis`, ~7GB downloads once to `/data/LLM-models/imggen-models`, then `Saved`. If load fails with a diffusers-format / variant error, the fp16-variant fallback (Task 2) should handle the missing-variant case; a single-file-only repo would need `from_single_file` — record the exact error and stop for a fix decision. Run: `~/bin/imggen "anime character" -m pony -o test-pony.png` Expected: swaps to pony, downloads once, `Saved`. Same fallback caveat. - [ ] **Step 5: Stop frees VRAM** Run: `~/bin/imggen stop` Then: `~/bin/imggen status` Expected: `daemon down`. (Optionally confirm VRAM freed via `xpu-smi` if available; otherwise container-gone is the signal.) - [ ] **Step 6: Commit any fixes made during integration** If Task 2/4 needed adjustment (e.g. single-file load path for pony), commit: ```bash git add -A git commit -S -m "Fix model load path discovered during Arc integration Co-Authored-By: Claude Opus 4.8 " ``` If nothing changed, skip. --- ## Task 8: Update README.md and CLAUDE.md **Files:** - Modify: `README.md` - Modify: `CLAUDE.md` - [ ] **Step 1: Update README.md** Add a "Models" section (the three keys + descriptions), a "Daemon / lifecycle" section documenting `start`/`stop`/`status`/`list`, the VRAM-locked-while-up warning (~9GB held until `stop`), the one-shot fallback behavior, and `.env` setup (copy `.env.example` → `.env`, set real paths, `IMGGEN_UID`/`GID`). Keep the existing "Development Approach" section at the bottom verbatim. - [ ] **Step 2: Update CLAUDE.md** Update to reflect: 3 files → 5 (`imggen`, `generate.py`, `server.py`, `compose.yaml`, `.env.example`); daemon is in scope as local burst-iteration (still no refiner/Flux/batching); compose replaces raw `docker run`; the registry is the source of truth for models; correct the container-boundary defaults to note `.env` overrides and that the real paths live there, not in the repo. Keep the 12GB / fp16-fix VAE / XPU-only constraints intact. - [ ] **Step 3: Commit** ```bash git add README.md CLAUDE.md git commit -S -m "Document model menu, daemon lifecycle, and compose setup Co-Authored-By: Claude Opus 4.8 " ``` --- ## Self-Review notes - **Spec coverage:** registry (T1), load robustness/variant fallback (T2), `-m` CLI (T3), daemon API status/generate/model (T4), compose + user perms + .env.example (T5), wrapper subcommands/menu/fallback (T6), integration incl. approved pulls + perms check (T7), README + CLAUDE.md (T8). All spec sections mapped. - **Type consistency:** `build_pipe(model_key)`, `resolve_model(key)`, `generate(pipe, prompt, negative, steps, guidance, seed, width, height)` used identically in `generate.py`, `server.py`, and the wrapper's JSON keys (`prompt/negative/steps/guidance/seed/width/height`). - **Placeholders:** none; every code step has full code. Docs tasks (T8) describe exact sections to write, not "TODO". - **Known open risk:** pony/realvis single-file-vs-diffusers load path is only resolvable at download time (T7 step 4), with a defined fallback and a stop-for-decision if it is single-file-only.