feat(xtts): OpenAI-Endpunkt fuers lokale LLM + OpenHands-Profil
llama-swap ist bereits OpenAI-kompatibel, war aber nur im Compose-Netz erreichbar
(kein ports-Mapping). Jetzt nutzbar fuer OpenHands, Continue, Open WebUI & Co.
- compose: llama-swap veroeffentlicht 8080 als
${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081} — sicher per Default
(nur localhost), fuer LAN bewusst BIND=0.0.0.0 setzen.
- llm-adapter: API-Key wird in JEDEN llama-server-Befehl der generierten
llama-swap-Config eingehaengt (--api-key, shlex-gequotet). Quelle: .env
(Vorrang) sonst /models/openai_key.json (0600). Neuer Handler
llm_openai_config setzt ihn zur Laufzeit und lehnt bei .env-Vorrang ab.
worker_hello meldet Port/Bind/keySet/envLocked — den Key NIE (Broadcast!).
- Diagnostic: pro LLM-Box Endpunkt-Zeile mit Base-URL, Key-Status und Eingabe.
Bei .env-Vorrang ist das Feld schreibgeschuetzt (🔒). Warnung, wenn im LAN
offen ohne Key. Der PORT wird nur ANGEZEIGT: er ist ein Docker-Port-Mapping
und steht beim Container-Start fest — dafuer ist die .env zustaendig.
- rvs: llm_openai_config(_result) in ALLOWED_TYPES (sonst still verworfen).
- Neues Profil 'openhands' (COMPOSE_PROFILES=llm,openhands) mit Port/Bind/Modell
aus der .env, lokales LLM vorbelegt. Ohne Profil aendert sich nichts.
- .env.example + README: alle Variablen, Sicherheitshinweise (offener Endpunkt,
Docker-Socket bei OpenHands), curl-Test und wann der Key greift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,3 +56,32 @@ RVS_SNI=
|
||||
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
|
||||
# WHISPER_LANGUAGE=de
|
||||
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
|
||||
|
||||
# ─── OpenAI-Endpunkt des lokalen LLM (optional) ───
|
||||
# llama-swap ist OpenAI-kompatibel (/v1/chat/completions, /v1/models). Damit
|
||||
# koennen Fremd-Tools direkt auf das lokale Modell: OpenHands, Continue,
|
||||
# Open WebUI, Aider ... Base-URL dann: http://<box>:<LLM_OPENAI_PORT>/v1
|
||||
#
|
||||
# LLM_OPENAI_BIND steuert, WER drankommt:
|
||||
# 127.0.0.1 nur diese Maschine (Default, sicher)
|
||||
# 0.0.0.0 das ganze LAN → dann UNBEDINGT LLM_OPENAI_KEY setzen!
|
||||
LLM_OPENAI_BIND=127.0.0.1
|
||||
LLM_OPENAI_PORT=8081
|
||||
# API-Key. Leer = Endpunkt ohne Authentifizierung (nur mit BIND=127.0.0.1 ok).
|
||||
# WICHTIG: Steht hier ein Wert, hat er VORRANG — das Feld ist dann in der ARIA-
|
||||
# Diagnostic schreibgeschuetzt. Leer lassen, wenn Du den Key lieber dort pro Box
|
||||
# setzen willst (er wird dann auf der Box unter /models/openai_key.json abgelegt).
|
||||
# Der Key wirkt, sobald ein Modell das naechste Mal geladen wird.
|
||||
LLM_OPENAI_KEY=
|
||||
|
||||
# ─── OpenHands (optional, eigenes Profil) ─────────
|
||||
# Standalone-Entwickler-Agent, der das lokale LLM nutzt. Start:
|
||||
# COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
# Dann http://<box>:<OPENHANDS_PORT> aufrufen.
|
||||
# ACHTUNG: OpenHands startet eigene Arbeits-Container und bekommt dafuer den
|
||||
# Docker-Socket — das ist faktisch Root-Zugriff auf diese Maschine.
|
||||
OPENHANDS_BIND=127.0.0.1
|
||||
OPENHANDS_PORT=3000
|
||||
# Wie OpenHands das lokale Modell anspricht (Container-intern, daher :8080):
|
||||
OPENHANDS_LLM_BASE_URL=http://llama-swap:8080/v1
|
||||
OPENHANDS_LLM_MODEL=openai/qwen3-8b
|
||||
|
||||
@@ -70,3 +70,71 @@ nvidia-smi # Host sieht die GPU
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
|
||||
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
|
||||
```
|
||||
|
||||
## Lokales LLM von außen nutzen (OpenAI-Endpunkt)
|
||||
|
||||
`llama-swap` spricht **OpenAI-kompatibel** (`/v1/chat/completions`, `/v1/models`).
|
||||
Damit können Fremd-Tools direkt auf das lokale Modell: **OpenHands**, Continue,
|
||||
Open WebUI, Aider …
|
||||
|
||||
```
|
||||
Base-URL: http://<box>:<LLM_OPENAI_PORT>/v1
|
||||
Modell: qwen3-8b (oder ein anderer Key aus llama-swap/config.yaml)
|
||||
API-Key: LLM_OPENAI_KEY (falls gesetzt)
|
||||
```
|
||||
|
||||
In der `.env`:
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `LLM_OPENAI_BIND` | `127.0.0.1` = nur diese Maschine (**Default, sicher**) · `0.0.0.0` = ganzes LAN |
|
||||
| `LLM_OPENAI_PORT` | Host-Port (Default `8081`) |
|
||||
| `LLM_OPENAI_KEY` | API-Key; leer = **ohne Authentifizierung** |
|
||||
|
||||
> ⚠️ `LLM_OPENAI_BIND=0.0.0.0` **ohne** `LLM_OPENAI_KEY` heißt: jeder im Netz kann
|
||||
> das Modell benutzen. Entweder Key setzen oder auf localhost gebunden lassen.
|
||||
|
||||
**Wo wird was konfiguriert?**
|
||||
|
||||
- **Port und Erreichbarkeit** immer in der `.env`. Beides ist ein Docker-Port-Mapping
|
||||
und steht beim Container-Start fest — es lässt sich nicht aus der Ferne ändern.
|
||||
- **API-Key**: entweder in der `.env` **oder** — wenn dort leer — in der ARIA-Diagnostic
|
||||
unter *Compute-Flotte* bei der jeweiligen Box. Dort abgelegt landet er auf der Box
|
||||
unter `/models/openai_key.json` (Rechte 0600).
|
||||
- **Die `.env` hat immer Vorrang.** Steht `LLM_OPENAI_KEY` dort, ist das Feld in der
|
||||
Diagnostic **schreibgeschützt** (🔒-Hinweis).
|
||||
- Ein geänderter Key greift, sobald ein Modell das **nächste Mal geladen** wird
|
||||
(llama-server bekommt ihn per `--api-key`). Laufendes Modell also einmal entladen
|
||||
lassen (TTL) oder ein anderes Modell anfragen.
|
||||
|
||||
Prüfen:
|
||||
```bash
|
||||
curl -s http://localhost:8081/v1/models # ohne Key
|
||||
curl -s -H "Authorization: Bearer $LLM_OPENAI_KEY" \
|
||||
http://localhost:8081/v1/models # mit Key
|
||||
```
|
||||
|
||||
## OpenHands (optionales Profil)
|
||||
|
||||
Wer die Box **standalone** als Entwickler-Agent nutzen will, startet OpenHands gleich mit:
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
```
|
||||
|
||||
Dann `http://<box>:<OPENHANDS_PORT>` (Default `3000`) öffnen. Das lokale Modell ist
|
||||
schon vorbelegt (`OPENHANDS_LLM_BASE_URL`, `OPENHANDS_LLM_MODEL`) und in der
|
||||
Oberfläche änderbar.
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `OPENHANDS_BIND` | `127.0.0.1` (Default) oder `0.0.0.0` fürs LAN |
|
||||
| `OPENHANDS_PORT` | Host-Port (Default `3000`) |
|
||||
| `OPENHANDS_LLM_BASE_URL` | Default `http://llama-swap:8080/v1` (Container-intern — **nicht** der Host-Port) |
|
||||
| `OPENHANDS_LLM_MODEL` | Default `openai/qwen3-8b` |
|
||||
|
||||
> ⚠️ OpenHands startet eigene Arbeits-Container und bekommt dafür den **Docker-Socket**.
|
||||
> Das ist faktisch Root-Zugriff auf diese Maschine — nur im vertrauten Netz betreiben
|
||||
> und nicht ungeschützt ins LAN/Internet hängen.
|
||||
|
||||
Ohne das Profil ändert sich nichts: Wer nur ARIAs Box fahren will, startet OpenHands gar nicht.
|
||||
|
||||
@@ -139,6 +139,13 @@ services:
|
||||
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
|
||||
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
|
||||
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
|
||||
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
|
||||
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
|
||||
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
|
||||
# Zugriff aus dem LAN LLM_OPENAI_BIND=0.0.0.0 setzen — dann aber unbedingt
|
||||
# LLM_OPENAI_KEY vergeben, sonst kann jeder im Netz das Modell nutzen.
|
||||
ports:
|
||||
- "${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
|
||||
@@ -172,3 +179,33 @@ services:
|
||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── OpenHands (optional, Standalone) ────────────────
|
||||
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
||||
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
||||
# ARIAs Box fahren will, startet es gar nicht erst.
|
||||
# COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
# Danach http://<box>:${OPENHANDS_PORT:-3000} und dort als Provider
|
||||
# "OpenAI-kompatibel" mit Base-URL http://llama-swap:8080/v1 eintragen
|
||||
# (aus dem Container heraus erreichbar), Modell z.B. qwen3-8b.
|
||||
openhands:
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:latest
|
||||
container_name: aria-openhands
|
||||
profiles: ["openhands"]
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest
|
||||
- LOG_ALL_EVENTS=true
|
||||
# Vorbelegung fuer das lokale Modell — in der Oberflaeche aenderbar.
|
||||
- LLM_BASE_URL=${OPENHANDS_LLM_BASE_URL:-http://llama-swap:8080/v1}
|
||||
- LLM_API_KEY=${LLM_OPENAI_KEY:-local}
|
||||
- LLM_MODEL=${OPENHANDS_LLM_MODEL:-openai/qwen3-8b}
|
||||
volumes:
|
||||
# OpenHands startet seine Arbeits-Container selbst -> braucht den Socket.
|
||||
# ACHTUNG: das gibt dem Container Kontrolle ueber den Docker-Host.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./openhands-state:/.openhands
|
||||
ports:
|
||||
- "${OPENHANDS_BIND:-127.0.0.1}:${OPENHANDS_PORT:-3000}:3000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -27,6 +27,8 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
|
||||
import httpx
|
||||
@@ -94,6 +96,44 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
|
||||
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
||||
|
||||
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
|
||||
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
|
||||
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
|
||||
# Diagnostic gesetzt werden, ABER: steht er in der .env, hat die .env Vorrang
|
||||
# und das Feld ist in der Diagnostic schreibgeschuetzt.
|
||||
OPENAI_PORT = os.getenv("LLM_OPENAI_PORT", "8081").strip() or "8081"
|
||||
OPENAI_BIND = os.getenv("LLM_OPENAI_BIND", "127.0.0.1").strip() or "127.0.0.1"
|
||||
_OPENAI_KEY_ENV = os.getenv("LLM_OPENAI_KEY", "").strip()
|
||||
OPENAI_KEY_FILE = os.getenv("LLM_OPENAI_KEY_FILE", "/models/openai_key.json")
|
||||
|
||||
|
||||
def _openai_key() -> str:
|
||||
"""Gueltiger API-Key: .env gewinnt, sonst der in der Diagnostic gesetzte."""
|
||||
if _OPENAI_KEY_ENV:
|
||||
return _OPENAI_KEY_ENV
|
||||
try:
|
||||
with open(OPENAI_KEY_FILE) as f:
|
||||
return str((json.load(f) or {}).get("key") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _openai_key_save(key: str) -> bool:
|
||||
"""Speichert den in der Diagnostic gesetzten Key. Bei .env-Vorrang: nein."""
|
||||
if _OPENAI_KEY_ENV:
|
||||
return False
|
||||
try:
|
||||
os.makedirs(os.path.dirname(OPENAI_KEY_FILE), exist_ok=True)
|
||||
tmp = OPENAI_KEY_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"key": key.strip()}, f)
|
||||
os.replace(tmp, OPENAI_KEY_FILE)
|
||||
os.chmod(OPENAI_KEY_FILE, 0o600)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("OpenAI-Key speichern fehlgeschlagen: %s", exc)
|
||||
return False
|
||||
|
||||
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||
import node_stats
|
||||
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
|
||||
@@ -144,6 +184,19 @@ def _generate_config() -> int:
|
||||
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
|
||||
"ttl": 3600,
|
||||
}
|
||||
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
|
||||
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
|
||||
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
|
||||
key = _openai_key()
|
||||
for k, spec in list(models.items()):
|
||||
cmd = str((spec or {}).get("cmd") or "")
|
||||
if not cmd:
|
||||
continue
|
||||
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
|
||||
if key:
|
||||
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
|
||||
models[k] = dict(spec, cmd=cmd)
|
||||
|
||||
out = dict(base)
|
||||
out["models"] = models
|
||||
try:
|
||||
@@ -270,6 +323,13 @@ async def _announce(ws) -> None:
|
||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
|
||||
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
||||
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
|
||||
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
|
||||
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
|
||||
"openaiPort": OPENAI_PORT,
|
||||
"openaiBind": OPENAI_BIND,
|
||||
"openaiKeySet": bool(_openai_key()),
|
||||
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
|
||||
})
|
||||
logger.info("worker_hello: models=%s", models)
|
||||
|
||||
@@ -355,6 +415,45 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
||||
})
|
||||
|
||||
|
||||
async def _handle_openai_config(ws, payload: dict) -> None:
|
||||
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
||||
|
||||
Der PORT wird hier bewusst NICHT geaendert: er ist ein Docker-Port-Mapping
|
||||
und steht beim Container-Start fest — dafuer ist die .env zustaendig.
|
||||
Steht der Key in der .env, gewinnt sie und wir lehnen ab.
|
||||
"""
|
||||
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
key = str(payload.get("apiKey") or "").strip()
|
||||
if _OPENAI_KEY_ENV:
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
|
||||
"error": "Der API-Key steht in der .env dieser Box — die hat Vorrang. "
|
||||
"Zum Aendern dort anpassen und die Box neu starten.",
|
||||
})
|
||||
return
|
||||
if not _openai_key_save(key):
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
|
||||
"error": "Key konnte nicht gespeichert werden (Schreibrechte auf /models?)",
|
||||
})
|
||||
return
|
||||
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
|
||||
_generate_config()
|
||||
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
|
||||
"gesetzt" if key else "entfernt")
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
|
||||
"keySet": bool(key),
|
||||
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
|
||||
"geladen wird — bereits laufende Modelle einmal entladen lassen "
|
||||
"(TTL) oder ein anderes Modell anfragen."
|
||||
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
|
||||
})
|
||||
await _announce(ws)
|
||||
|
||||
|
||||
async def _handle_provision(ws, payload: dict) -> None:
|
||||
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
|
||||
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
|
||||
@@ -464,6 +563,9 @@ async def _run() -> None:
|
||||
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||
if await _stats.handle(ws, mtype, payload, _send):
|
||||
continue
|
||||
if mtype == "llm_openai_config":
|
||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||
continue
|
||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
|
||||
continue
|
||||
if mtype == "llm_provision_model":
|
||||
|
||||
Reference in New Issue
Block a user