LocalAI/PLAN.md
2026-10-14 19:29:06 +02:00

11 KiB

Plan: Lokaler LLM-Server für opencode per Docker Compose

Context

Ziel: Programmieren mit opencode und dem VS-Code-Plugin Continue ohne Cloud-KI. Diese Maschine (RX 6950 XT 16 GB, gfx1030; Ryzen 9 7900X3D; 62 GB RAM) stellt zentral eine OpenAI-kompatible LLM-API bereit. opencode läuft auf den Client-PCs (auch auf dieser Maschine) mit deren eigenem Code und nutzt diese API. Anforderungen:

  • Alles als docker-compose in /home/alegab/workspace/localAI, damit es portabel ist. Die Software auf dem Host (systemd-Ollama, llama-cpp-Paket) wird nicht genutzt.
  • Zugriff nur von localhost, aus dem LAN (br0 192.168.178.0/24) und über NetBird (wt0 100.73.0.0/16), zusätzlich mit API-Key.
  • Backend: llama.cpp mit ROCm (schnell bei MoE dank --n-cpu-moe). Die Modelle liegen als GGUF in ./models.

Wichtig: ufw ist aktiv, aber Ports, die Docker veröffentlicht, umgehen ufw. Deshalb läuft der Proxy im Host-Netz (dort greift ufw) und filtert zusätzlich selbst nach Quell-IP. Das Backend ist nur auf 127.0.0.1 gebunden.

Architektur

Client-PC (opencode) ──LAN / NetBird──┐
Lokales opencode ─────────────────────┤
                                      ▼
            caddy (network_mode: host, :8000)
              ├─ IP-Filter: 127.0.0.0/8, ::1, 192.168.178.0/24, 100.73.0.0/16
              ├─ Bearer-Token-Prüfung (LLM_API_KEY)
              └─ reverse_proxy → 127.0.0.1:8080
                                      ▼
            llama-swap (ghcr.io/mostlygeek/llama-swap:rocm, nur 127.0.0.1:8080)
              └─ startet/wechselt llama-server je nach "model"-Feld
                 /dev/kfd + /dev/dri → RX 6950 XT, Modelle in ./models

Dateien (alle neu)

localAI/
├── docker-compose.yml
├── .env.example          # Vorlage. .env wird lokal erzeugt (git-ignored)
├── .gitignore            # .env, models/
├── llama-swap/config.yaml
├── caddy/Caddyfile
├── client/opencode.json  # Vorlage für ~/.config/opencode/opencode.json der Clients
├── client/continue/config.yaml   # Vorlage für ~/.continue/config.yaml
├── client/continue/.env.example  # LOCALAI_API_KEY für Continue-Secrets
├── docs/opencode.md      # Client-Einrichtung opencode (Linux/Windows/macOS)
├── docs/continue.md      # Client-Einrichtung Continue in VS Code, Schritt für Schritt
├── scripts/firewall-ufw.sh   # einmalige ufw-Freigaben für Port 8000 (LAN + wt0)
├── PLAN.md
└── README.md             # Überblick, Server-Setup, Portieren, Links auf docs/

docker-compose.yml (Kern)

  • llama-swap
    • Image: ghcr.io/mostlygeek/llama-swap:rocm, beim Umsetzen auf einen festen Tag pinnen. Das Image bringt llama-server mit ROCm mit.
    • Ports: 127.0.0.1:8080:8080.
    • devices: [/dev/kfd, /dev/dri], group_add: ["${RENDER_GID}", "${VIDEO_GID}"] (hier 987/983), security_opt: [seccomp=unconfined], ipc: host.
    • Umgebung: HIP_VISIBLE_DEVICES=0 (iGPU ausblenden), LLAMA_CACHE=/models.
    • Volumes: ./models:/models und ./llama-swap/config.yaml:/app/config.yaml:ro.
    • restart: unless-stopped, Healthcheck auf /health.
  • caddy
    • Image: caddy:2-alpine (Tag pinnen), network_mode: host.
    • Volume: ./caddy/Caddyfile, Umgebung LLM_API_KEY, ALLOWED_NETS, LISTEN_PORT.
    • depends_on: llama-swap.

caddy/Caddyfile

{ admin off
  auto_https off }
:{$LISTEN_PORT:8000} {
  @blocked not remote_ip {$ALLOWED_NETS}
  respond @blocked 403
  @noauth not header Authorization "Bearer {$LLM_API_KEY}"
  respond @noauth 401
  reverse_proxy 127.0.0.1:8080 { flush_interval -1 }
}

ALLOWED_NETS in .env: 127.0.0.0/8 ::1 192.168.178.0/24 100.73.0.0/16. Auf einem anderen PC passt man nur diese Zeile an.

llama-swap/config.yaml

Nur ein Modell gleichzeitig (16 GB VRAM). Die Modelle werden beim ersten Aufruf per -hf nach /models geladen. Repo- und Quant-Namen beim Umsetzen auf Hugging Face verifizieren.

healthCheckTimeout: 900          # erster Start lädt ~20 GB
models:
  qwen3.5-35b-a3b:               # Hauptmodell (MoE, ~3B aktiv)
    cmd: >
      llama-server --port ${PORT} --host 127.0.0.1
      -hf unsloth/Qwen3.5-35B-A3B-GGUF:Q4_K_M --jinja
      -c 65536 -ngl 99 --n-cpu-moe 16 -fa on -ctk q8_0 -ctv q8_0
      --threads 12 --no-mmap      
    ttl: 1800
  glm-4.7-flash:                 # Alternative mit starkem Tool-Calling
    cmd: > ...analog (unsloth/GLM-4.7-Flash-GGUF:Q4_K_M)
    ttl: 1800
  qwen3.5-9b:                    # klein, komplett im VRAM, schnell
    cmd: > ...analog, -c 32768, ohne --n-cpu-moe
    ttl: 1800

--n-cpu-moe wird beim Test eingestellt: so niedrig wie möglich, ohne dass der VRAM überläuft. Den Verbrauch dabei mit rocm-smi beobachten.

Ergänzung für Continue: Zwei weitere Modelle laufen dauerhaft neben dem jeweils aktiven Chat-Modell.

  qwen2.5-coder-1.5b:            # Autocomplete (FIM), GPU, ~2 GB VRAM
    cmd: llama-server ... -hf Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF:Q8_0 -c 8192 -ngl 99 -fa on
  nomic-embed:                   # Embeddings für @codebase, auf der CPU
    cmd: llama-server ... -hf nomic-ai/nomic-embed-text-v1.5-GGUF:Q8_0 --embedding --pooling mean -ngl 0 -c 8192
groups:
  chat:      { swap: true,  exclusive: false, members: [qwen3.5-35b-a3b, glm-4.7-flash, qwen3.5-9b] }
  always-on: { swap: false, exclusive: false, persistent: true, members: [qwen2.5-coder-1.5b, nomic-embed] }

Der VRAM-Bedarf des Autocomplete-Modells (~2 GB) wird beim Einstellen von --n-cpu-moe der Chat-Modelle eingeplant, also etwas höher ansetzen. Die Gruppen-Syntax beim Umsetzen gegen die llama-swap-Doku prüfen. Optional kommt ein Reranker hinzu, z. B. bge-reranker-v2-m3 mit --reranking.

client/opencode.json (Vorlage)

{
  "$schema": "https://opencode.ai/config.json",
  "model": "localai/qwen3.5-35b-a3b",
  "small_model": "localai/qwen3.5-35b-a3b",
  "share": "disabled",
  "autoupdate": false,
  "disabled_providers": ["opencode", "anthropic", "openai", "google", "ollama"],
  "provider": {
    "localai": {
      "name": "LocalAI (alegab-pc)",
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "http://{env:LOCALAI_HOST}:8000/v1",
        "apiKey": "{env:LOCALAI_API_KEY}"
      },
      "models": {
        "qwen3.5-35b-a3b": { "name": "Qwen3.5 35B-A3B", "limit": { "context": 65536, "output": 16384 } },
        "glm-4.7-flash":   { "name": "GLM-4.7 Flash",   "limit": { "context": 65536, "output": 16384 } },
        "qwen3.5-9b":      { "name": "Qwen3.5 9B",      "limit": { "context": 32768, "output": 8192 } }
      }
    }
  }
}

small_model zeigt bewusst auf das Hauptmodell. Ein eigenes kleines Modell würde llama-swap bei jedem Titel- oder Zusammenfassungs-Aufruf zum Modellwechsel zwingen. LOCALAI_HOST ist hier 127.0.0.1, im LAN 192.168.178.72 und über NetBird 100.73.82.219 bzw. der NetBird-DNS-Name.

client/continue/config.yaml (Vorlage)

name: LocalAI
version: 1.0.0
schema: v1
models:
  - name: Qwen3.5 35B-A3B (Chat/Agent)
    provider: openai
    model: qwen3.5-35b-a3b
    apiBase: http://192.168.178.72:8000/v1   # bzw. NetBird-IP/-Name
    apiKey: ${{ secrets.LOCALAI_API_KEY }}
    roles: [chat, edit, apply]
    capabilities: [tool_use]
    defaultCompletionOptions: { contextLength: 65536, maxTokens: 8192 }
  - name: GLM-4.7 Flash
    provider: openai
    model: glm-4.7-flash
    ... (gleiche apiBase/apiKey), roles: [chat, edit, apply], capabilities: [tool_use]
  - name: Qwen2.5-Coder 1.5B (Autocomplete)
    provider: openai
    model: qwen2.5-coder-1.5b
    ...
    roles: [autocomplete]
    autocompleteOptions: { debounceDelay: 250, maxPromptTokens: 2048 }
  - name: Nomic Embed
    provider: openai
    model: nomic-embed
    ...
    roles: [embed]
  • Das Secret steht in ~/.continue/.env als LOCALAI_API_KEY=....
  • Beim Umsetzen anhand der Continue-Doku verifizieren: FIM über den OpenAI-Provider (ggf. useLegacyCompletionsEndpoint: true oder explizites promptTemplates.autocomplete im Qwen-FIM-Format), die Schlüssel für Telemetrie und Remote-Config sowie die passende Kontext-Länge.
  • docs/continue.md enthält:
    1. Extension installieren (Marketplace bzw. Open VSX für VSCodium).
    2. Bei Continue nicht anmelden, Hub-Login überspringen.
    3. config.yaml und .env kopieren.
    4. Host-IP je nach Netz eintragen (LAN oder NetBird).
    5. Telemetrie aus (VS-Code-Setting continue.telemetryEnabled: false).
    6. Die Rollen erklären (Chat/Agent, Edit, Autocomplete, @codebase-Indexing).
    7. Troubleshooting: 401 = Key falsch, 403 = falsches Netz, Timeout beim ersten Request = Modell lädt, Autocomplete leer = FIM-Template prüfen.
    8. Fertige Varianten: nur LAN, nur NetBird, Windows-Pfade (%USERPROFILE%\.continue).

scripts/firewall-ufw.sh

Einmalig mit sudo ausführen, nicht Teil von Compose: ufw allow from 192.168.178.0/24 to any port 8000 proto tcp und ufw allow in on wt0 to any port 8000 proto tcp.

Umsetzungsschritte

  1. Diesen Plan als /home/alegab/workspace/localAI/PLAN.md ablegen.
  2. Die Dateien oben anlegen. .env aus .env.example erzeugen, mit LLM_API_KEY=$(openssl rand -hex 32) und den GIDs aus getent group render video.
  3. Den Host-Ollama-Dienst stoppen und deaktivieren (sudo systemctl disable --now ollama). Er belegt VRAM und lauscht auf 0.0.0.0. Vorher bei dir nachfragen.
  4. docker compose pull && docker compose up -d, dann scripts/firewall-ufw.sh ausführen (sudo, bei dir nachfragen).
  5. Den ersten Request schicken, damit der Modell-Download startet. --n-cpu-moe einstellen.
  6. Die lokale ~/.config/opencode/opencode.json sichern und durch die Client-Vorlage ersetzen.
  7. Continue-Vorlage, docs/continue.md, docs/opencode.md und README schreiben. Continue in VS Code auf dieser Maschine mit der Vorlage testen.
  8. Erst nachdem alles verifiziert ist und du zugestimmt hast: die alten Ollama-Modelle unter /usr/share/ollama löschen.

Verifikation

  1. docker compose ps: beide Dienste healthy. docker compose logs llama-swap zeigt beim Laden ROCm/gfx1030 und die Zahl der offloaded layers.
  2. Auth: curl 127.0.0.1:8000/v1/models → 401. Mit -H "Authorization: Bearer $KEY" → Modellliste.
  3. Bindung: ss -tlnp zeigt 8080 nur auf 127.0.0.1 und 8000 auf allen Interfaces.
  4. Chat-Test: curl .../v1/chat/completions mit einem Tool-Definitions-Payload liefert einen tool_calls-Response. Dabei die Tokens/s im Log notieren.
  5. Remote: von einem LAN-PC und über NetBird curl http://<ip>:8000/v1/models mit Key → 200. Ein Zugriff aus einem nicht freigegebenen Netz, z. B. aus einem Docker-Bridge-Container (docker run --rm curlimages/curl http://172.17.0.1:8000/v1/models), → 403.
  6. opencode lokal: /models zeigt nur localai/*. Testauftrag „lege hello.py an und führe sie aus" prüft Read/Write/Bash-Tool-Calls.
  7. Continue:
    • Chat-Antwort kommt an.
    • Agent-Modus führt einen Tool-Call aus.
    • Ghost-Text-Autocomplete erscheint in unter ~1 s. Im llama-swap-Log stehen Requests an qwen2.5-coder-1.5b, ohne dass das Chat-Modell entladen wird.
    • @codebase indexiert, im Log stehen Requests an nomic-embed.
  8. Gegenprobe offline: Das Internet trennen (nach dem Modell-Download), opencode muss weiter funktionieren.