first try

This commit is contained in:
Alexander Gabriel 2026-10-15 00:01:26 +02:00
parent b10435f5f7
commit 99517f483a
14 changed files with 822 additions and 0 deletions

30
.env.example Normal file
View File

@ -0,0 +1,30 @@
# Kopieren nach .env und anpassen: cp .env.example .env
# API-Key erzeugen: openssl rand -hex 32
LLM_API_KEY=CHANGE_ME
# Netze, die auf die API zugreifen dürfen (Leerzeichen-getrennt)
# localhost | LAN | NetBird
ALLOWED_NETS="127.0.0.0/8 ::1 192.168.178.0/24 100.73.0.0/16"
# Port, auf dem Caddy lauscht (extern) und llama-swap (nur 127.0.0.1)
LISTEN_PORT=8800
BACKEND_PORT=8080
# Host-Nutzer, dem die Modelldateien in ./models gehören: id -u / id -g
HOST_UID=1000
HOST_GID=1000
# GIDs der Gruppen render/video auf dem Host: getent group render video
RENDER_GID=987
VIDEO_GID=983
# GPU-Auswahl (0 = erste ROCm-GPU; iGPU ausblenden)
HIP_VISIBLE_DEVICES=0
# Optional: Hugging-Face-Token für gated Modelle / höhere Download-Limits
HF_TOKEN=
# Image-Versionen (gepinnt)
LLAMA_SWAP_TAG=v265-rocm-b11610
CADDY_TAG=2.10.2-alpine

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
models/
client/continue/.env

View File

@ -1,5 +1,12 @@
# 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:

117
README.md Normal file
View File

@ -0,0 +1,117 @@
# LocalAI – zentraler LLM-Server für opencode & Continue
Ein Docker-Compose-Stack, der auf **alegab-pc** (RX 6950 XT 16 GB, Ryzen 9 7900X3D, 62 GB RAM) eine **OpenAI-kompatible API** bereitstellt. Clients im Heim-LAN und über NetBird nutzen sie zum Programmieren, ganz ohne Cloud-KI.
```
Client-PC (opencode / VS Code + Continue) ──LAN / NetBird──┐
▼
caddy (Host-Netz, :8800) IP-Filter + API-Key
└─► llama-swap (nur 127.0.0.1:8080) lädt/wechselt Modelle
└─► llama-server (llama.cpp, ROCm) ─► GPU + RAM
Modelle: ./models (GGUF, Download von Hugging Face beim ersten Aufruf)
```
| Modell-ID | Zweck | Verhalten |
|---|---|---|
| `qwen3.5-35b-a3b` | Hauptmodell Chat/Agent (MoE) | eines der Chat-Modelle gleichzeitig geladen |
| `glm-4.7-flash` | Alternative Chat/Agent (MoE) | 〃 |
| `qwen3.5-9b` | schnelles kleines Chat-Modell | 〃 |
| `qwen2.5-coder-1.5b` | Autocomplete (FIM) für Continue | dauerhaft geladen (GPU) |
| `nomic-embed` | Embeddings für Continue `@codebase` | dauerhaft geladen (CPU) |
## Server einrichten
```bash
cd ~/workspace/localAI
cp .env.example .env
sed -i "s/^LLM_API_KEY=.*/LLM_API_KEY=$(openssl rand -hex 32)/" .env
chmod 600 .env
$EDITOR .env # ALLOWED_NETS, GIDs (getent group render video), UID/GID prüfen
sudo ./scripts/firewall-ufw.sh # einmalig: Port 8800 für LAN + wt0 in ufw öffnen
./scripts/start.sh # prüfen, starten, Modelle vorladen, Verbindungsdaten zeigen
```
`scripts/start.sh` legt eine fehlende `.env` beim ersten Start auch selbst an: mit neuem API-Key, UID/GID und den GIDs von render/video. Weitere Optionen:
| Aufruf | Wirkung |
|---|---|
| `./scripts/start.sh` | starten, Autocomplete + Embeddings + Hauptmodell vorladen |
| `./scripts/start.sh --no-preload` | nur starten |
| `./scripts/start.sh --model glm-4.7-flash` | anderes Chat-Modell vorladen |
| `./scripts/start.sh --show-key` | API-Key im Klartext anzeigen (für Client-Setup) |
| `./scripts/start.sh --pull` | Images vorher aktualisieren |
**Nach einem Reboot** startet der Stack von selbst: Der Docker-Dienst ist aktiviert, und die Container haben `restart: unless-stopped`. Autocomplete und Embeddings lädt llama-swap per `hooks.on_startup.preload` ebenfalls von selbst, das Chat-Modell kommt beim ersten Request dazu.
Der erste Aufruf eines Modells lädt es von Hugging Face herunter (Hauptmodell ~22 GB). Danach läuft alles offline.
### Warum Caddy im Host-Netz?
Ports, die Docker veröffentlicht, **umgehen ufw**. Deshalb ist llama-swap nur an `127.0.0.1` gebunden. Caddy läuft mit `network_mode: host`, dort greift ufw, und Caddy sieht die echte Quell-IP. Daraus ergeben sich zwei Schutzschichten:
1. **ufw** lässt Port 8800 nur aus dem LAN und über `wt0` zu.
2. **Caddy** prüft zusätzlich die Quell-IP (`ALLOWED_NETS`) und den Bearer-Token (`LLM_API_KEY`).
Die API läuft über plain HTTP. Über NetBird ist das unkritisch (WireGuard verschlüsselt den Tunnel), im Heim-LAN ist es akzeptabel.
## Clients einrichten
- **opencode** (Terminal-Agent): [docs/opencode.md](docs/opencode.md)
- **Continue** (VS-Code-Plugin): [docs/continue.md](docs/continue.md)
Schnelltest von jedem Client:
```bash
curl -H "Authorization: Bearer <KEY>" http://<server>:8800/v1/models
```
## Betrieb
```bash
docker compose ps # Status
docker compose logs -f llama-swap # Modell-Laden, Requests
docker compose logs -f caddy # Zugriffe
docker compose restart # nach Änderungen an .env / Caddyfile
```
- `llama-swap/config.yaml` wird **automatisch neu geladen**, ein Neustart ist nicht nötig.
- Web-Oberfläche von llama-swap (Modellstatus, Logs): `http://127.0.0.1:8080/ui`, nur lokal auf dem Server erreichbar.
- VRAM-Auslastung: `rocm-smi --showmeminfo vram`
### Modell hinzufügen
Einen Eintrag in `llama-swap/config.yaml` anlegen, z. B.
```yaml
mein-modell:
cmd: ${server} ${agent-ctx} -hf <hf-user>/<repo>-GGUF:Q4_K_M
ttl: 3600
```
Den Eintrag unter `routing.router.settings.groups.chat.members` aufnehmen. Danach die Client-Configs (`client/opencode.json`, `client/continue/config.yaml`) um die neue Modell-ID ergänzen.
GPU-Layer und MoE-Auslagerung in den RAM verteilt llama.cpp automatisch (`--fit`, Standard: an). `--fit-target 3072` hält ~3 GB VRAM für das Autocomplete-Modell frei.
## Auf einen anderen PC übertragen
1. Den Ordner kopieren. `models/` kann man mitnehmen, um den Download zu sparen.
2. `.env` anpassen: `ALLOWED_NETS` (LAN-Subnetz, NetBird-Netz), `RENDER_GID`/`VIDEO_GID`, `HOST_UID`/`HOST_GID`.
3. Je nach GPU das Image in `.env` wählen (`LLAMA_SWAP_TAG`):
| GPU | Tag-Variante |
|---|---|
| AMD (ROCm) | `vXXX-rocm-bYYYY` (wie hier) |
| NVIDIA | `vXXX-cuda-bYYYY`. Dann in `docker-compose.yml` `devices`/`group_add`/`security_opt` durch `deploy.resources.reservations.devices` (nvidia) ersetzen. |
| herstellerunabhängig | `vXXX-vulkan-bYYYY` (nur `/dev/dri` nötig) |
| nur CPU | `vXXX-cpu-bYYYY` |
Verfügbare Tags: https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap
4. Threads (`--threads` im Makro `server`) an die CPU anpassen.
5. Firewall des Ziel-PCs entsprechend `scripts/firewall-ufw.sh` konfigurieren.
## Dateien
| Datei | Inhalt |
|---|---|
| `docker-compose.yml` | Dienste llama-swap + caddy |
| `.env` / `.env.example` | Secrets, Netze, Ports, GIDs, Image-Tags |
| `llama-swap/config.yaml` | Modelle, Startparameter, Gruppen |
| `caddy/Caddyfile` | IP-Filter, API-Key, Reverse Proxy |
| `client/opencode.json` | Vorlage für opencode-Clients |
| `client/continue/` | Vorlage für Continue-Clients |
| `scripts/start.sh` | Start mit Prüfungen, Preload und Statusausgabe |
| `scripts/firewall-ufw.sh` | ufw-Freigaben |
| `PLAN.md` | ursprünglicher Umsetzungsplan |

22
caddy/Caddyfile Normal file
View File

@ -0,0 +1,22 @@
{
admin off
auto_https off
}
:{$LISTEN_PORT} {
# 1. nur erlaubte Netze (localhost, LAN, NetBird) – siehe ALLOWED_NETS in .env
@blocked not remote_ip {$ALLOWED_NETS}
respond @blocked "forbidden" 403
# 2. API-Key als Bearer-Token
@noauth not header Authorization "Bearer {$LLM_API_KEY}"
respond @noauth "unauthorized" 401
# 3. an llama-swap; flush_interval -1 = Streaming (SSE) sofort durchreichen
reverse_proxy 127.0.0.1:{$BACKEND_PORT} {
flush_interval -1
transport http {
read_timeout 30m
}
}
}

View File

@ -0,0 +1,3 @@
# Nach ~/.continue/.env kopieren (Windows: %USERPROFILE%\.continue\.env)
# Wert = LLM_API_KEY aus der .env auf dem Server
LOCALAI_API_KEY=CHANGE_ME

View File

@ -0,0 +1,85 @@
%YAML 1.1
# Continue (VS Code) – Konfiguration für den zentralen LocalAI-Server
# Ziel: ~/.continue/config.yaml (Windows: %USERPROFILE%\.continue\config.yaml)
# Secret: ~/.continue/.env mit LOCALAI_API_KEY=<Key aus der .env des Servers>
#
# apiBase je nach Standort anpassen (nur EINE Zeile unten aktiv lassen):
# auf dem Server selbst: http://127.0.0.1:8800/v1
# im Heim-LAN: http://192.168.178.72:8800/v1
# über NetBird: http://100.73.82.219:8800/v1 (oder NetBird-DNS-Name)
---
name: LocalAI
version: 1.0.0
schema: v1
# gemeinsame Verbindungsdaten (YAML-Anker, braucht den %YAML-1.1-Header oben)
model_defaults: &localai
provider: openai
apiBase: http://192.168.178.72:8800/v1
apiKey: ${{ secrets.LOCALAI_API_KEY }}
requestOptions:
timeout: 1800 # Sekunden; erster Request lädt ggf. ein Modell
models:
# --- Chat / Agent / Edit ------------------------------------------------
- name: Qwen3.5 35B-A3B
<<: *localai
model: qwen3.5-35b-a3b
roles: [chat, edit, apply, summarize]
capabilities: [tool_use]
defaultCompletionOptions:
contextLength: 65536
maxTokens: 8192
- name: GLM-4.7 Flash
<<: *localai
model: glm-4.7-flash
roles: [chat, edit, apply]
capabilities: [tool_use]
defaultCompletionOptions:
contextLength: 65536
maxTokens: 8192
- name: Qwen3.5 9B (schnell)
<<: *localai
model: qwen3.5-9b
roles: [chat, edit, apply]
capabilities: [tool_use]
defaultCompletionOptions:
contextLength: 32768
maxTokens: 4096
# --- Tab-Autocomplete (Fill-in-the-Middle) ------------------------------
- name: Qwen2.5-Coder 1.5B (Autocomplete)
<<: *localai
model: qwen2.5-coder-1.5b
roles: [autocomplete]
useLegacyCompletionsEndpoint: true # /v1/completions statt Chat
promptTemplates:
autocomplete: "<|fim_prefix|>{{{prefix}}}<|fim_suffix|>{{{suffix}}}<|fim_middle|>"
defaultCompletionOptions:
contextLength: 8192
maxTokens: 256
temperature: 0.1
stop: ["<|endoftext|>", "<|fim_prefix|>", "<|fim_suffix|>", "<|fim_middle|>", "<|file_sep|>", "<|fim_pad|>", "<|repo_name|>"]
autocompleteOptions:
debounceDelay: 250
maxPromptTokens: 2048
onlyMyCode: true
# --- Embeddings für @codebase / Indexing --------------------------------
- name: Nomic Embed
<<: *localai
model: nomic-embed
roles: [embed]
context:
- provider: code
- provider: codebase
- provider: diff
- provider: terminal
- provider: problems
- provider: folder
- provider: file
- provider: currentFile

36
client/opencode.json Normal file
View File

@ -0,0 +1,36 @@
{
"$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}:8800/v1",
"apiKey": "{env:LOCALAI_API_KEY}",
"timeout": 1800000
},
"models": {
"qwen3.5-35b-a3b": {
"name": "Qwen3.5 35B-A3B",
"tool_call": true,
"limit": { "context": 65536, "output": 16384 }
},
"glm-4.7-flash": {
"name": "GLM-4.7 Flash",
"tool_call": true,
"limit": { "context": 65536, "output": 16384 }
},
"qwen3.5-9b": {
"name": "Qwen3.5 9B (schnell)",
"tool_call": true,
"limit": { "context": 32768, "output": 8192 }
}
}
}
}
}

49
docker-compose.yml Normal file
View File

@ -0,0 +1,49 @@
# Lokaler LLM-Server: llama-swap (llama.cpp/ROCm) hinter Caddy (IP-Filter + API-Key)
# Start: docker compose up -d Logs: docker compose logs -f llama-swap
name: localai
services:
llama-swap:
image: ghcr.io/mostlygeek/llama-swap:${LLAMA_SWAP_TAG:-v265-rocm-b11610}
restart: unless-stopped
# nur lokal erreichbar – von außen ausschließlich über Caddy
ports:
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
group_add:
- "${RENDER_GID}"
- "${VIDEO_GID}"
devices:
- /dev/kfd
- /dev/dri
security_opt:
- seccomp=unconfined
ipc: host
environment:
HOME: /tmp
LLAMA_CACHE: /models
HIP_VISIBLE_DEVICES: "${HIP_VISIBLE_DEVICES:-0}"
HF_TOKEN: "${HF_TOKEN:-}"
volumes:
- ./models:/models
- ./llama-swap/config.yaml:/app/config.yaml:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
caddy:
image: caddy:${CADDY_TAG:-2.10.2-alpine}
restart: unless-stopped
# Host-Netz: Quell-IP bleibt erhalten und ufw greift (Docker-Port-Publishing umgeht ufw)
network_mode: host
environment:
LLM_API_KEY: "${LLM_API_KEY:?LLM_API_KEY in .env setzen}"
ALLOWED_NETS: "${ALLOWED_NETS:-127.0.0.0/8 ::1}"
LISTEN_PORT: "${LISTEN_PORT:-8800}"
BACKEND_PORT: "${BACKEND_PORT:-8080}"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- llama-swap

151
docs/continue.md Normal file
View File

@ -0,0 +1,151 @@
# Continue (VS Code) mit dem zentralen LocalAI-Server
Continue ist ein Open-Source-Plugin für VS Code (und JetBrains). Es bietet:
| Funktion | Wofür | Modell auf dem Server |
|---|---|---|
| **Chat / Agent** | Fragen zum Code, Agent-Modus mit Tools (Dateien lesen/schreiben, Terminal) | `qwen3.5-35b-a3b` (oder `glm-4.7-flash`, `qwen3.5-9b`) |
| **Edit / Apply** | markierten Code umschreiben lassen (`Ctrl+I`), Vorschläge übernehmen | wie Chat |
| **Autocomplete** | Ghost-Text-Vervollständigung beim Tippen (Tab) | `qwen2.5-coder-1.5b` (läuft dauerhaft) |
| **Embeddings** | `@codebase`-Suche über das ganze Projekt | `nomic-embed` (läuft dauerhaft, CPU) |
Alles läuft über die eine API `http://<server>:8800/v1` mit API-Key. Es geht nichts in die Cloud.
---
## 1. Voraussetzungen
- Der Server läuft (`docker compose ps` auf alegab-pc, siehe [README](../README.md)).
- Der Client-PC ist **im Heim-LAN (192.168.178.0/24)** oder **mit NetBird verbunden**. Andere Netze bekommen `403`.
- Du hast den **API-Key**: Das ist `LLM_API_KEY` aus `.env` auf dem Server.
```bash
grep ^LLM_API_KEY ~/workspace/localAI/.env
```
Verbindungstest vom Client aus:
```bash
curl -H "Authorization: Bearer <KEY>" http://192.168.178.72:8800/v1/models
# NetBird: http://100.73.82.219:8800/v1/models
```
Kommt eine JSON-Liste mit den Modellen zurück, steht die Verbindung.
## 2. Extension installieren
- **VS Code:** Extensions (`Ctrl+Shift+X`) öffnen, nach **„Continue"** suchen (Herausgeber: Continue) und installieren.
Alternativ auf der Kommandozeile: `code --install-extension Continue.continue`
- **VSCodium / Code-OSS:** gleicher Name, die Extension kommt dort aus Open VSX.
Beim ersten Start zeigt Continue eine Anmeldeseite für den „Continue Hub". **Melde dich nicht an.** Wähle stattdessen *„Or, configure your own models"* bzw. *„Skip"* oder *„Local config"*. Damit bleibt alles lokal.
## 3. Konfiguration einspielen
Continue liest die lokale Konfiguration aus:
| System | Pfad |
|---|---|
| Linux / macOS | `~/.continue/config.yaml` und `~/.continue/.env` |
| Windows | `%USERPROFILE%\.continue\config.yaml` und `%USERPROFILE%\.continue\.env` |
**Linux/macOS:**
```bash
mkdir -p ~/.continue
cp ~/.continue/config.yaml ~/.continue/config.yaml.bak 2>/dev/null # Backup, falls vorhanden
cp client/continue/config.yaml ~/.continue/config.yaml
cp client/continue/.env.example ~/.continue/.env
chmod 600 ~/.continue/.env
$EDITOR ~/.continue/.env # LOCALAI_API_KEY=<KEY> eintragen
```
**Windows (PowerShell):**
```powershell
New-Item -ItemType Directory -Force $env:USERPROFILE\.continue
Copy-Item client\continue\config.yaml $env:USERPROFILE\.continue\config.yaml
Copy-Item client\continue\.env.example $env:USERPROFILE\.continue\.env
notepad $env:USERPROFILE\.continue\.env
```
Die Dateien `config.yaml` und `.env.example` liegen in diesem Repo unter [`client/continue/`](../client/continue/). Auf einen anderen PC kopierst du sie per USB-Stick, scp oder Git.
## 4. Server-Adresse eintragen
Oben in `config.yaml` gibt es den Block `model_defaults:`. Dort steht **eine** `apiBase`, die für alle Modelle gilt:
```yaml
model_defaults: &localai
provider: openai
apiBase: http://192.168.178.72:8800/v1 # ← hier anpassen
apiKey: ${{ secrets.LOCALAI_API_KEY }}
```
| Standort des Client-PCs | `apiBase` |
|---|---|
| auf alegab-pc selbst | `http://127.0.0.1:8800/v1` |
| im Heim-LAN | `http://192.168.178.72:8800/v1` |
| unterwegs über NetBird | `http://100.73.82.219:8800/v1` oder `http://<netbird-name>.netbird.cloud:8800/v1` |
> **Tipp für Laptops**, die mal im LAN und mal unterwegs sind: Nimm immer die **NetBird-Adresse**. Sie funktioniert auch zu Hause, solange NetBird verbunden ist. Dann musst du nie umschalten.
Den NetBird-Namen und die NetBird-IP des Servers zeigt `netbird status -d` auf alegab-pc an.
Nach dem Speichern lädt Continue die Konfiguration automatisch neu. Falls nicht: Command Palette (`Ctrl+Shift+P`) → *„Continue: Reload"* oder VS Code neu laden.
## 5. Telemetrie abschalten
Continue sendet standardmäßig anonyme Nutzungsstatistiken. So schaltest du das ab:
VS Code-Einstellungen (`Ctrl+,`) → nach `continue telemetry` suchen → **„Continue: Telemetry Enabled"** abhaken.
Oder direkt in `settings.json`:
```json
"continue.telemetryEnabled": false
```
## 6. Benutzung
| Aktion | Tastenkürzel (Standard) |
|---|---|
| Continue-Seitenleiste öffnen / Chat | `Ctrl+L` (markierten Code mitnehmen) |
| Code inline bearbeiten (Edit) | Code markieren → `Ctrl+I` |
| Autocomplete annehmen | `Tab` (Wort für Wort: `Ctrl+→`) |
| Autocomplete ablehnen | `Esc` |
| Modell wechseln | Dropdown unten in der Chat-Box |
| Modus wechseln (Chat / Plan / Agent) | Dropdown links unten in der Chat-Box |
**Kontext mit `@`** im Chat: `@codebase` (semantische Suche im Projekt), `@file`, `@folder`, `@diff` (aktuelle Git-Änderungen), `@terminal`, `@problems`.
### Hinweise zu den Modellen
- **Erster Request nach längerer Pause:** Das Chat-Modell wird in den VRAM geladen, das dauert 10–60 s. Danach bleibt es 1 h geladen.
- **Modellwechsel** zwischen den Chat-Modellen dauert ebenso. Am besten bei einem Modell bleiben.
- Das **Autocomplete-** und das **Embedding-Modell** laufen dauerhaft und werden durch den Chat nie entladen.
- Der Server ist **ein gemeinsames System**. Wenn mehrere Leute gleichzeitig chatten, werden die Anfragen nacheinander abgearbeitet. Wer ein *anderes* Chat-Modell wählt, löst bei allen einen Modellwechsel aus.
- **Agent-Modus**: Gib kleine, klar umrissene Aufgaben. Lokale Modelle verlieren bei langen Mehrschritt-Aufträgen eher den Faden als Cloud-Modelle.
## 7. Fehlersuche
| Symptom | Ursache / Lösung |
|---|---|
| `401 unauthorized` | API-Key fehlt oder ist falsch. `~/.continue/.env` prüfen (kein Leerzeichen, keine Anführungszeichen). |
| `403 forbidden` | Der Client ist in einem nicht erlaubten Netz. LAN oder NetBird verbinden. |
| Timeout / „connection refused" | Falsche `apiBase`, Server aus oder Firewall. `curl`-Test aus Abschnitt 1 ausprobieren. |
| Erste Antwort dauert lange | Das Modell wird geladen, beim allerersten Mal sogar heruntergeladen. Abwarten. |
| Autocomplete zeigt nichts | Unten rechts in der Statusleiste muss „Continue" aktiv sein (Klick → *Enable autocomplete*). Im Output-Panel *„Continue"* nach Fehlern schauen. Das Template in `config.yaml` (`promptTemplates.autocomplete`) muss unverändert sein. |
| Autocomplete liefert Müll / Chat-Text | Für Autocomplete muss das Modell `qwen2.5-coder-1.5b` mit `useLegacyCompletionsEndpoint: true` eingestellt sein, kein Chat-Modell. |
| Agent ruft keine Tools auf | Das Modell braucht `capabilities: [tool_use]`. `qwen3.5-35b-a3b` oder `glm-4.7-flash` nehmen. |
| `@codebase` findet nichts | Indexierung läuft noch, siehe Fortschritt in der Continue-Seitenleiste. Neu anstoßen: Command Palette (`Ctrl+Shift+P`) → „Continue" eintippen → den Befehl zum Neu-Indexieren (*Re-Index* / *Rebuild codebase index*) wählen. |
Logs des Servers (auf alegab-pc):
```bash
cd ~/workspace/localAI
docker compose logs -f llama-swap # Modell-Laden, Requests
docker compose logs -f caddy # Zugriffe, 401/403
```
## 8. Fertige Varianten
### a) Nur ein Modell, minimal
Wenn du nur Chat und Autocomplete willst, lösche in `config.yaml` die Einträge `GLM-4.7 Flash`, `Qwen3.5 9B` und `Nomic Embed`.
### b) Schwacher Client, schnelle Antworten
Nimm `Qwen3.5 9B (schnell)` als Chat-Modell. Es liegt komplett im VRAM und antwortet deutlich schneller, ist aber weniger klug.
### c) Projektbezogene Konfiguration
Continue liest zusätzlich `.continue/`-Ordner im Projekt. Dort kannst du z. B. projektspezifische Regeln ablegen (`.continue/rules/*.md`) und sie mit ins Repo einchecken. Die Modelle bleiben in der globalen `~/.continue/config.yaml`.

64
docs/opencode.md Normal file
View File

@ -0,0 +1,64 @@
# opencode mit dem zentralen LocalAI-Server
opencode ist ein Coding-Agent für das Terminal. Er läuft auf dem Client-PC und arbeitet dort auf deinem Code. Nur die Modell-Anfragen gehen an den Server.
## 1. opencode installieren
| System | Befehl |
|---|---|
| Arch / CachyOS | `sudo pacman -S opencode` (oder AUR `opencode-bin`) |
| Linux / macOS allgemein | `curl -fsSL https://opencode.ai/install \| bash` |
| macOS (Homebrew) | `brew install sst/tap/opencode` |
| Windows | `npm i -g opencode-ai` (Node.js nötig) oder `scoop install opencode` |
## 2. Konfiguration einspielen
| System | Pfad |
|---|---|
| Linux / macOS | `~/.config/opencode/opencode.json` |
| Windows | `%USERPROFILE%\.config\opencode\opencode.json` |
```bash
mkdir -p ~/.config/opencode
cp ~/.config/opencode/opencode.json ~/.config/opencode/opencode.json.bak 2>/dev/null
cp client/opencode.json ~/.config/opencode/opencode.json
```
Die Vorlage liegt unter [`client/opencode.json`](../client/opencode.json). Sie nutzt zwei Umgebungsvariablen:
| Variable | Wert |
|---|---|
| `LOCALAI_HOST` | `127.0.0.1` (auf dem Server), `192.168.178.72` (LAN), `100.73.82.219` (NetBird) |
| `LOCALAI_API_KEY` | `LLM_API_KEY` aus der `.env` des Servers |
**fish** (`~/.config/fish/config.fish`):
```fish
set -gx LOCALAI_HOST 100.73.82.219
set -gx LOCALAI_API_KEY <KEY>
```
**bash/zsh** (`~/.bashrc` / `~/.zshrc`):
```bash
export LOCALAI_HOST=100.73.82.219
export LOCALAI_API_KEY=<KEY>
```
**Windows (PowerShell, dauerhaft):**
```powershell
[Environment]::SetEnvironmentVariable("LOCALAI_HOST", "100.73.82.219", "User")
[Environment]::SetEnvironmentVariable("LOCALAI_API_KEY", "<KEY>", "User")
```
## 3. Benutzen
```bash
cd ~/mein-projekt
opencode
```
- `/models` zeigt die Modelle. Dort sollten nur `LocalAI (alegab-pc)`-Einträge stehen.
- `/init` erzeugt eine `AGENTS.md` mit Projektwissen. Das hilft lokalen Modellen sehr und lohnt sich einmal pro Projekt.
- `Tab` wechselt zwischen dem **Build**-Agenten (darf ändern) und dem **Plan**-Agenten (nur lesen).
## Hinweise
- `small_model` zeigt bewusst auf dasselbe Modell wie `model`. Ein zweites Chat-Modell würde auf dem Server bei jedem Titel- oder Zusammenfassungs-Aufruf einen Modellwechsel auslösen.
- Die Kontextgrenzen in `limit.context` passen zur Server-Konfiguration (`-c` in `llama-swap/config.yaml`). Ändert man die eine Seite, muss man die andere mitziehen.
- Fehlercodes (401/403/Timeout): siehe Tabelle in [continue.md](continue.md#7-fehlersuche). Die Ursachen sind dieselben.

93
llama-swap/config.yaml Normal file
View File

@ -0,0 +1,93 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/mostlygeek/llama-swap/refs/heads/main/config-schema.json
#
# Modelle werden beim ersten Aufruf per -hf von Hugging Face nach ./models geladen
# (LLAMA_CACHE=/models). Danach läuft alles offline.
#
# VRAM-Budget RX 6950 XT (16 GB):
# - Autocomplete-Modell (~2 GB) bleibt dauerhaft geladen
# - genau ein Chat-Modell gleichzeitig; llama.cpp --fit (Standard: an) verteilt
# Layer/MoE-Experten automatisch auf VRAM und RAM, --fit-target hält ~3 GB frei
# - Embeddings laufen auf der CPU
healthCheckTimeout: 900 # erster Start kann einen 20-GB-Download enthalten
logLevel: info
startPort: 10001
macros:
server: >-
/app/llama-server --host 127.0.0.1 --port ${PORT}
--jinja -fa on --threads 12 --no-webui
# Agent-Betrieb: großer Kontext, KV-Cache quantisiert, VRAM-Reserve für Autocomplete
agent-ctx: -c 65536 -ctk q8_0 -ctv q8_0 --fit-target 3072
models:
# --- Chat / Agent (opencode, Continue Chat/Agent/Edit) -----------------
qwen3.5-35b-a3b:
name: Qwen3.5 35B-A3B (MoE)
cmd: >-
${server} ${agent-ctx}
-hf unsloth/Qwen3.5-35B-A3B-GGUF:Q4_K_M
ttl: 3600
glm-4.7-flash:
name: GLM-4.7 Flash (MoE)
cmd: >-
${server} ${agent-ctx}
-hf unsloth/GLM-4.7-Flash-GGUF:Q4_K_M
ttl: 3600
qwen3.5-9b:
name: Qwen3.5 9B (schnell, komplett im VRAM)
cmd: >-
${server} -c 32768 -ctk q8_0 -ctv q8_0 --fit-target 3072
-hf unsloth/Qwen3.5-9B-GGUF:Q4_K_M
ttl: 3600
# --- Autocomplete (Continue Tab-Completion, FIM) -----------------------
qwen2.5-coder-1.5b:
name: Qwen2.5-Coder 1.5B Base (FIM)
cmd: >-
${server} -c 8192
-hf ggml-org/Qwen2.5-Coder-1.5B-Q8_0-GGUF
-ngl 99
ttl: 0
# --- Embeddings (Continue @codebase) -----------------------------------
nomic-embed:
name: Nomic Embed Text v1.5
cmd: >-
/app/llama-server --host 127.0.0.1 --port ${PORT} --no-webui
-hf nomic-ai/nomic-embed-text-v1.5-GGUF:Q8_0
--embedding --pooling mean -c 8192 -b 8192 -ub 8192 -ngl 0 --threads 8
ttl: 0
# Helfer-Modelle beim Container-Start laden (auch nach Reboot), damit
# Autocomplete sofort reagiert. Das Chat-Modell lädt beim ersten Request
# bzw. per scripts/start.sh.
hooks:
on_startup:
preload:
- qwen2.5-coder-1.5b
- nomic-embed
routing:
router:
use: group
settings:
groups:
# Chat-Modelle wechseln sich ab, lassen aber die Helfer in Ruhe
chat:
swap: true
exclusive: false
members:
- qwen3.5-35b-a3b
- glm-4.7-flash
- qwen3.5-9b
# Helfer bleiben dauerhaft geladen
always-on:
swap: false
exclusive: false
persistent: true
members:
- qwen2.5-coder-1.5b
- nomic-embed

12
scripts/firewall-ufw.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Einmalig ausführen: sudo ./scripts/firewall-ufw.sh
# Öffnet den Caddy-Port nur für LAN und NetBird (wt0). Caddy filtert zusätzlich selbst.
set -euo pipefail
PORT="${LISTEN_PORT:-8800}"
LAN="${LAN_NET:-192.168.178.0/24}"
VPN_IF="${VPN_IF:-wt0}"
ufw allow from "$LAN" to any port "$PORT" proto tcp comment 'localai LAN'
ufw allow in on "$VPN_IF" to any port "$PORT" proto tcp comment 'localai NetBird'
ufw status numbered | grep -E "$PORT" || true

150
scripts/start.sh Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Startet den LocalAI-Stack, wartet bis er bereit ist und zeigt die Verbindungsdaten.
#
# ./scripts/start.sh starten + Chat-Modell vorladen
# ./scripts/start.sh --no-preload nur starten
# ./scripts/start.sh --model glm-4.7-flash anderes Chat-Modell vorladen
# ./scripts/start.sh --show-key API-Key im Klartext ausgeben
# ./scripts/start.sh --pull Images vorher aktualisieren
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")/.."
PRELOAD=1
MODEL="qwen3.5-35b-a3b"
SHOW_KEY=0
PULL=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-preload) PRELOAD=0 ;;
--model) MODEL="$2"; shift ;;
--show-key) SHOW_KEY=1 ;;
--pull) PULL=1 ;;
-h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
shift
done
if [[ -t 1 ]]; then B=$'\e[1m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m'; else B= G= Y= R= N=; fi
ok() { echo "${G}✔${N} $*"; }
warn() { echo "${Y}!${N} $*"; }
die() { echo "${R}✘${N} $*" >&2; exit 1; }
step() { echo; echo "${B}== $*${N}"; }
# ---------------------------------------------------------------- Prüfungen
step "Prüfungen"
command -v docker >/dev/null || die "docker nicht installiert"
docker info >/dev/null 2>&1 || die "Docker-Daemon läuft nicht oder keine Rechte (Gruppe 'docker'?)"
docker compose version >/dev/null 2>&1 || die "docker compose (v2) fehlt"
ok "Docker $(docker version --format '{{.Server.Version}}')"
[[ -e /dev/kfd && -d /dev/dri ]] || die "/dev/kfd oder /dev/dri fehlt – AMD-GPU-Treiber (amdgpu/ROCm) prüfen"
ok "GPU-Geräte vorhanden"
if [[ ! -f .env ]]; then
warn ".env fehlt – wird aus .env.example erzeugt"
cp .env.example .env
sed -i "s/^LLM_API_KEY=.*/LLM_API_KEY=$(openssl rand -hex 32)/" .env
sed -i "s/^HOST_UID=.*/HOST_UID=$(id -u)/; s/^HOST_GID=.*/HOST_GID=$(id -g)/" .env
rg=$(getent group render | cut -d: -f3 || true); vg=$(getent group video | cut -d: -f3 || true)
[[ -n "$rg" ]] && sed -i "s/^RENDER_GID=.*/RENDER_GID=$rg/" .env
[[ -n "$vg" ]] && sed -i "s/^VIDEO_GID=.*/VIDEO_GID=$vg/" .env
chmod 600 .env
warn "Bitte ALLOWED_NETS in .env prüfen (LAN-/NetBird-Netz)"
fi
set -a; . ./.env; set +a
LISTEN_PORT="${LISTEN_PORT:-8800}"
BACKEND_PORT="${BACKEND_PORT:-8080}"
[[ -n "${LLM_API_KEY:-}" && "$LLM_API_KEY" != "CHANGE_ME" ]] || die "LLM_API_KEY in .env setzen (openssl rand -hex 32)"
ok ".env geladen"
# Ports dürfen nur von unserem eigenen Stack belegt sein
running=$(docker compose ps --status running -q 2>/dev/null | wc -l)
if [[ "$running" -eq 0 ]]; then
for p in "$LISTEN_PORT" "$BACKEND_PORT"; do
if ss -Htln "sport = :$p" | grep -q .; then
die "Port $p ist bereits belegt: $(ss -Htlnp "sport = :$p" | awk '{print $NF}' | head -1) – in .env ändern"
fi
done
ok "Ports $LISTEN_PORT und $BACKEND_PORT frei"
fi
if systemctl is-active --quiet ollama 2>/dev/null; then
warn "Host-Ollama läuft noch (belegt ggf. VRAM, lauscht auf dem Netz) → sudo systemctl disable --now ollama"
fi
if command -v ufw >/dev/null && systemctl is-active --quiet ufw 2>/dev/null; then
if [[ -r /etc/ufw/user.rules ]] && ! grep -q "dport $LISTEN_PORT" /etc/ufw/user.rules; then
warn "Keine ufw-Freigabe für Port $LISTEN_PORT gefunden → sudo ./scripts/firewall-ufw.sh"
fi
fi
mkdir -p models
free_gb=$(df -BG --output=avail models | tail -1 | tr -dc '0-9')
(( free_gb < 30 )) && warn "Nur ${free_gb} GB frei – Modell-Downloads brauchen bis zu 25 GB"
# ------------------------------------------------------------------- Start
step "Starte Container"
(( PULL )) && docker compose pull
docker compose up -d --remove-orphans
step "Warte auf Bereitschaft"
for i in $(seq 1 60); do
curl -fsS -m 2 "http://127.0.0.1:$BACKEND_PORT/health" >/dev/null 2>&1 && break
(( i == 60 )) && die "llama-swap antwortet nicht – docker compose logs llama-swap"
sleep 1
done
ok "llama-swap bereit"
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -m 2 -w '%{http_code}' "http://127.0.0.1:$LISTEN_PORT/v1/models" || true)
[[ "$code" == "401" ]] && break
(( i == 30 )) && die "Caddy antwortet nicht wie erwartet (HTTP $code) – docker compose logs caddy"
sleep 1
done
code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' -H "Authorization: Bearer $LLM_API_KEY" "http://127.0.0.1:$LISTEN_PORT/v1/models")
[[ "$code" == "200" ]] || die "API mit Key liefert HTTP $code"
ok "Caddy bereit (Auth aktiv)"
# ---------------------------------------------------------------- Preload
if (( PRELOAD )); then
step "Lade Modelle vor (erster Start lädt ggf. von Hugging Face herunter)"
preload() { # $1 = Modell, $2 = Endpoint, $3 = JSON-Body
local t0=$SECONDS code
printf ' %-22s … ' "$1"
code=$(curl -s -o /dev/null -m 3600 -w '%{http_code}' -H 'Content-Type: application/json' \
"http://127.0.0.1:$BACKEND_PORT$2" -d "$3" || true)
if [[ "$code" == "200" ]]; then echo "${G}ok${N} ($((SECONDS - t0)) s)"; else echo "${R}HTTP $code${N}"; fi
}
preload qwen2.5-coder-1.5b /v1/completions '{"model":"qwen2.5-coder-1.5b","prompt":"x","max_tokens":1}'
preload nomic-embed /v1/embeddings '{"model":"nomic-embed","input":"x"}'
preload "$MODEL" /v1/chat/completions "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1}"
fi
# ------------------------------------------------------------------ Status
step "Status"
docker compose ps --format '{{.Service}}: {{.Status}}' | sed 's/^/ /'
loaded=$(curl -s -m 5 "http://127.0.0.1:$BACKEND_PORT/running" \
| python3 -c 'import sys,json; print(", ".join(m["model"] for m in json.load(sys.stdin)["running"]) or "-")' 2>/dev/null || echo "?")
echo " geladen: $loaded"
command -v rocm-smi >/dev/null && rocm-smi --showmeminfo vram 2>/dev/null \
| awk '/GPU\[0\].*Total Memory/{t=$NF} /GPU\[0\].*Used Memory/{u=$NF} END{if(t) printf " VRAM: %.1f / %.1f GB\n", u/1e9, t/1e9}'
step "Verbindung"
lan_ip=$(ip -4 -o addr show scope global | awk '$2 !~ /^(wt|docker|virbr|br-|veth)/ {split($4,a,"/"); print a[1]; exit}')
nb_ip=$(ip -4 -o addr show wt0 2>/dev/null | awk '{split($4,a,"/"); print a[1]}')
nb_fqdn=$(netbird status 2>/dev/null | awk '/^FQDN:/{print $2}' || true)
echo " lokal: http://127.0.0.1:$LISTEN_PORT/v1"
[[ -n "$lan_ip" ]] && echo " LAN: http://$lan_ip:$LISTEN_PORT/v1"
[[ -n "$nb_ip" ]] && echo " NetBird: http://$nb_ip:$LISTEN_PORT/v1${nb_fqdn:+ (http://$nb_fqdn:$LISTEN_PORT/v1)}"
if (( SHOW_KEY )); then
echo " API-Key: $LLM_API_KEY"
else
echo " API-Key: ${LLM_API_KEY:0:6}… (vollständig: --show-key)"
fi
echo " Web-UI: http://127.0.0.1:$BACKEND_PORT/ui (nur lokal)"
echo
echo " Clients einrichten: docs/opencode.md, docs/continue.md"