LocalAI/PLAN.md
2026-10-15 00:01:26 +02:00

210 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Plan: Lokaler LLM-Server für opencode per Docker Compose
> **Stand der Umsetzung (2026-10-14) – Abweichungen vom Plan:**
> - Externer Port ist **8800** statt 8000 (8000 ist lokal von VS Code belegt).
> - Kein manuelles `--n-cpu-moe`: llama.cpp `--fit` (Standard an) verteilt automatisch, `--fit-target 3072` reserviert VRAM fürs Autocomplete-Modell. `--no-mmap` existiert in diesem Build nicht mehr.
> - Autocomplete-Modell: Base-Variante `ggml-org/Qwen2.5-Coder-1.5B-Q8_0-GGUF` (besser für FIM als Instruct).
> - Image: `ghcr.io/mostlygeek/llama-swap:v265-rocm-b11610`, läuft als UID 1000.
> - Offen (braucht sudo / deine Entscheidung): ufw-Freigabe, Host-Ollama deaktivieren, alte Ollama-Modelle löschen, lokale opencode-Config ersetzen.
## 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.
```yaml
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.
```yaml
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)
```json
{
"$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)
```yaml
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
0. Diesen Plan als `/home/alegab/workspace/localAI/PLAN.md` ablegen.
1. 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`.
2. 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.**
3. `docker compose pull && docker compose up -d`, dann `scripts/firewall-ufw.sh` ausführen (sudo, bei dir nachfragen).
4. Den ersten Request schicken, damit der Modell-Download startet. `--n-cpu-moe` einstellen.
5. Die lokale `~/.config/opencode/opencode.json` sichern und durch die Client-Vorlage ersetzen.
6. Continue-Vorlage, `docs/continue.md`, `docs/opencode.md` und README schreiben. Continue in VS Code auf dieser Maschine mit der Vorlage testen.
7. 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.