diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ab43bc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2116ee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +models/ +client/continue/.env diff --git a/PLAN.md b/PLAN.md index 58844bc..97abc00 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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: diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a994c9 --- /dev/null +++ b/README.md @@ -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 " http://: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 /-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 | diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..5ff0aec --- /dev/null +++ b/caddy/Caddyfile @@ -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 + } + } +} diff --git a/client/continue/.env.example b/client/continue/.env.example new file mode 100644 index 0000000..13f1d3f --- /dev/null +++ b/client/continue/.env.example @@ -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 diff --git a/client/continue/config.yaml b/client/continue/config.yaml new file mode 100644 index 0000000..cf15d8b --- /dev/null +++ b/client/continue/config.yaml @@ -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= +# +# 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 diff --git a/client/opencode.json b/client/opencode.json new file mode 100644 index 0000000..44d8173 --- /dev/null +++ b/client/opencode.json @@ -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 } + } + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6e2e56d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/continue.md b/docs/continue.md new file mode 100644 index 0000000..fc89d09 --- /dev/null +++ b/docs/continue.md @@ -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://: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 " 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= 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.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`. diff --git a/docs/opencode.md b/docs/opencode.md new file mode 100644 index 0000000..73c9b4b --- /dev/null +++ b/docs/opencode.md @@ -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 +``` +**bash/zsh** (`~/.bashrc` / `~/.zshrc`): +```bash +export LOCALAI_HOST=100.73.82.219 +export LOCALAI_API_KEY= +``` +**Windows (PowerShell, dauerhaft):** +```powershell +[Environment]::SetEnvironmentVariable("LOCALAI_HOST", "100.73.82.219", "User") +[Environment]::SetEnvironmentVariable("LOCALAI_API_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. diff --git a/llama-swap/config.yaml b/llama-swap/config.yaml new file mode 100644 index 0000000..6967e4b --- /dev/null +++ b/llama-swap/config.yaml @@ -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 diff --git a/scripts/firewall-ufw.sh b/scripts/firewall-ufw.sh new file mode 100755 index 0000000..f61ed9e --- /dev/null +++ b/scripts/firewall-ufw.sh @@ -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 diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..172b76b --- /dev/null +++ b/scripts/start.sh @@ -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"