Port und Erreichbarkeit kommen aus der .env der Box ' +
+ '(LLM_OPENAI_PORT, LLM_OPENAI_BIND) — sie stehen beim Container-Start fest.
' +
+ warn + '
';
+ }
+
+ function saveOpenAiKey(instanceId, fieldId) {
+ const el = document.getElementById(fieldId);
+ const msg = document.getElementById(fieldId + '-msg');
+ if (!el) return;
+ openAiReqId = 'oa_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
+ if (msg) msg.textContent = 'speichere …';
+ send({ action: 'llm_openai_config', instanceId, apiKey: el.value, requestId: openAiReqId });
+ }
+ let openAiReqId = null;
+
+ function onOpenAiConfigResult(p) {
+ if (!p || (openAiReqId && p.requestId && p.requestId !== openAiReqId)) return;
+ const id = 'oa-' + String(p.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
+ const msg = document.getElementById(id + '-msg');
+ if (!msg) return;
+ msg.innerHTML = p.ok
+ ? '✓ ' + escapeHtml(p.message || 'gespeichert') + ''
+ : '✗ ' + escapeHtml(p.error || 'fehlgeschlagen') + '';
+ }
+
// Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
function gotoComputeFleet() {
diff --git a/diagnostic/server.js b/diagnostic/server.js
index a45f9b1..b75d1bc 100644
--- a/diagnostic/server.js
+++ b/diagnostic/server.js
@@ -656,6 +656,8 @@ function workerList() {
return Array.from(workers.values()).map(w => ({
instanceId: w.instanceId, service: w.service, node: w.node,
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
+ openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
+ openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
@@ -1180,6 +1182,8 @@ function connectRVS(forcePlain) {
} else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} });
+ } else if (msg.type === "llm_openai_config_result") {
+ broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
broadcast({ type: "sat_result", payload: msg.payload || {} });
@@ -1209,6 +1213,11 @@ function connectRVS(forcePlain) {
instanceId: p.instanceId, service: p.service || "",
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
+ // OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
+ // Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
+ // die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
+ openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
+ openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
busy: !!prev.busy, last_seen: Date.now(),
});
broadcastWorkers();
@@ -2964,6 +2973,17 @@ wss.on("connection", (ws) => {
},
timestamp: Date.now(),
});
+ } else if (msg.action === "llm_openai_config") {
+ // Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
+ sendToRVS_raw({
+ type: "llm_openai_config",
+ payload: {
+ instanceId: String(msg.instanceId || ""),
+ apiKey: String(msg.apiKey || ""),
+ requestId: String(msg.requestId || ""),
+ },
+ timestamp: Date.now(),
+ });
} else if (msg.action === "agent_history_query") {
// Browser will die gespeicherte Historie eines Agenten — die Bridge
// antwortet mit agent_history_list (auf derselben rvsWs).
diff --git a/rvs/server.js b/rvs/server.js
index 063f7b4..593c8a7 100644
--- a/rvs/server.js
+++ b/rvs/server.js
@@ -113,6 +113,9 @@ const ALLOWED_TYPES = new Set([
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
"worker_hello", "worker_ping", "worker_update", "worker_list",
+ // OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
+ // immer aus der .env der Box — er ist ein Docker-Port-Mapping).
+ "llm_openai_config", "llm_openai_config_result",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
diff --git a/xtts/.env.example b/xtts/.env.example
index edff6bf..b711673 100644
--- a/xtts/.env.example
+++ b/xtts/.env.example
@@ -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://:/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://: 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
diff --git a/xtts/README.md b/xtts/README.md
index b62685b..696823c 100644
--- a/xtts/README.md
+++ b/xtts/README.md
@@ -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://:/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://:` (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.
diff --git a/xtts/docker-compose.yml b/xtts/docker-compose.yml
index 72a53bd..3c87528 100644
--- a/xtts/docker-compose.yml
+++ b/xtts/docker-compose.yml
@@ -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://:${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
diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py
index bfc193c..b4c4b42 100644
--- a/xtts/llm-adapter/adapter.py
+++ b/xtts/llm-adapter/adapter.py
@@ -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":