feat(llm): Cluster — Programme fragen ein Modell, der Cluster waehlt die Karte

Programme sollen nicht Box und Karte aussuchen muessen. Die ARIA-Bridge kennt
alle Boxen samt Karten-Belegung und verteilt (bridge/llm_cluster.py):

- Auswahl: unter allen online Boxen mit dem Modell die mit den wenigsten
  laufenden Anfragen, bei Gleichstand reihum. Eine Karte / alles frei →
  alles dorthin ("verteilen, wenn noetig").
- Ausfall: geht eine Box waehrend der Anfrage offline (kein Ping mehr), wird
  sie EINMAL an eine andere Box mit demselben Modell gegeben. Modell-Fehler
  (z.B. Kontext zu lang) werden nicht wiederholt — waeren ueberall gleich.
- Fehler kommen als Fehler (ok=false + errorCode), nie als Antwort-Text: ein
  Programm wuerde ihn sonst als Aussage der KI in seinen Verlauf schreiben —
  so ist bei ARIA schon mal ein ganzer Chat kaputtgegangen.

RVS: llm_cluster_models_request → llm_cluster_models (jedes Modell EINMAL,
ctx = kleinstes Kontextfenster unter seinen Karten), llm_cluster_request →
llm_cluster_response. In der RVS-Allow-List freigegeben.

ARIA nutzt denselben Router (mit ihrer 30-s-Frist fuer den lokalen
Schnellweg) — vorher Round-Robin ohne Ausfall-Ersatz, und errorCode ging
verloren.

xtts/openai_rvs_wrapper: fuer Programme, die nur "OpenAI-kompatibel" koennen
(OpenHands & Co.). Kleiner Container NEBEN dem Programm, fuer das der Cluster
dann http://127.0.0.1:8099/v1 ist; braucht nur eine ausgehende Verbindung zum
RVS. /v1/models (jedes Modell einmal, context_length), /v1/chat/completions
(auch stream und tools), Fehler als HTTP mit OpenAI-Fehlerobjekt. Hilfsseite
auf / zeigt Status, Base URL und Modelle. Fuer Programme in Docker: Netz
aria-llm, Name openai-rvs-wrapper. .env.example mit RVS_HOST/PORT/TOKEN.
(Ein OpenAI-Endpunkt direkt auf der VM war kurz drin und ist wieder raus —
der Wrapper braucht keinen offenen Port auf der VM.)

docs/LLM-SCHNITTSTELLE.md neu gegliedert: A) Cluster ueber RVS (empfohlen),
B) Wrapper, C) direkt an eine Box (Sonderfall), plus "Umstellen" fuer
bestehende Clients (Software-Schmiede).

Tests: mit echtem RVS und zwei echten llm-adaptern als zwei Boxen —
Modell einmal gelistet, 6 parallele Anfragen 3/3 verteilt, Box friert
mitten in der Anfrage ein → nach 15 s von der anderen beantwortet
(attempts=2), danach direkt die gesunde, nicht geladenes Modell →
model_unavailable ohne content. Wrapper als Docker-Container gegen das
offizielle OpenAI-SDK: models.list, create mit/ohne stream, NotFoundError,
AuthenticationError. Das Doku-Beispiel lief unveraendert (nur Adresse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 01:43:09 +02:00
co-authored by Claude Opus 5.5
parent 60f2d2e087
commit c26b27f09f
14 changed files with 977 additions and 234 deletions
+7
View File
@@ -73,3 +73,10 @@ GITEA_USER=duffyduck
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de) # Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren. # Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
RVS_UPDATE_HOST= RVS_UPDATE_HOST=
# ── LLM-Cluster ───────────────────────────────────────────────────────
# Die ARIA-Bridge verteilt Anfragen an die lokalen Modelle aller KI-Boxen
# (llm_cluster_* ueber RVS). Wie lange sie auf eine Antwort wartet, bevor sie
# eine andere Box versucht bzw. aufgibt:
#CLUSTER_LLM_TIMEOUT_S=600
# OpenAI-kompatible Programme (OpenHands & Co.): xtts/openai_rvs_wrapper
+1
View File
@@ -544,6 +544,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat. - `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs). - `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke` - **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
- **LLM-Cluster: Programme fragen ein Modell, der Cluster waehlt die Karte** — die ARIA-Bridge ([`bridge/llm_cluster.py`](bridge/llm_cluster.py)) kennt alle Boxen und Karten. Programme schicken `llm_cluster_request` ueber RVS (ohne Box/Karte), die Bridge waehlt unter allen Karten mit diesem Modell die mit den wenigsten laufenden Anfragen, und faellt eine Box mitten in der Anfrage aus (kein Ping mehr), geht sie einmal an eine andere. `llm_cluster_models` liefert jedes Modell **einmal** samt `ctx` (kleinstes Kontextfenster unter seinen Karten). Fehler kommen als Fehler (`model_unavailable` …), nie als Antwort-Text. ARIA selbst nutzt denselben Router (mit ihrer kurzen 30-s-Frist). Fuer Programme, die nur OpenAI koennen: [`xtts/openai_rvs_wrapper/`](xtts/openai_rvs_wrapper/) — kleiner Container NEBEN dem Programm, fuer das der Cluster dann `http://127.0.0.1:8099/v1` ist; Hilfsseite auf `/` zeigt Status und Modelle; braucht nur eine ausgehende Verbindung zum RVS (getestet mit dem offiziellen OpenAI-SDK). Schnittstelle: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md)
- **Lokale Modelle: ein Modell pro Karte, Clients waehlen nur aus** — Schnittstelle fuer Drittprogramme: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md). Jede LLM-Karte einer Box (`LLM_GPUS=1,2` in der `.env`, alt `LLM_GPU=1`) haelt genau ein Modell, dauerhaft geladen; welches, legt nur die Diagnostic fest (Modell-Katalog → „Laden" → Ziel-**Karte**, in der Flotte pro Karte „Entladen"). Karten, die nicht fuers LLM vorgesehen sind, erscheinen dort gar nicht. Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port — sehen nur die geladenen Modelle aller Boxen und laden **nie** etwas: eine Anfrage fuer ein anderes Modell wird abgelehnt (`model_not_loaded` bzw. HTTP 404), nichts wird verdraengt. Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen (gemessen: zwei parallel in 4,5 s statt 10 s nacheinander). Technik: der llm-adapter schreibt eine llama-swap-Config nur aus den belegten Karten — pro Modell `CUDA_VISIBLE_DEVICES=<Karte>` (mit `CUDA_DEVICE_ORDER=PCI_BUS_ID`, damit die Nummern wie bei nvidia-smi zaehlen), `ttl: 0`, eine Gruppe mit `swap: false` und Preload beim Start; llama-swap laeuft mit `--watch-config` (die frueher aufgerufenen Reload-Endpunkte gab es nicht). Laden oder Entladen startet die anderen Karten der Box kurz neu. Schlaegt das Laden fehl, wird die Karte auf ihr vorheriges Modell zurueckgesetzt. Bestehende Boxen: das bisherige Standardmodell (`LLM_MODEL`) landet beim ersten Start auf der ersten LLM-Karte - **Lokale Modelle: ein Modell pro Karte, Clients waehlen nur aus** — Schnittstelle fuer Drittprogramme: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md). Jede LLM-Karte einer Box (`LLM_GPUS=1,2` in der `.env`, alt `LLM_GPU=1`) haelt genau ein Modell, dauerhaft geladen; welches, legt nur die Diagnostic fest (Modell-Katalog → „Laden" → Ziel-**Karte**, in der Flotte pro Karte „Entladen"). Karten, die nicht fuers LLM vorgesehen sind, erscheinen dort gar nicht. Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port — sehen nur die geladenen Modelle aller Boxen und laden **nie** etwas: eine Anfrage fuer ein anderes Modell wird abgelehnt (`model_not_loaded` bzw. HTTP 404), nichts wird verdraengt. Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen (gemessen: zwei parallel in 4,5 s statt 10 s nacheinander). Technik: der llm-adapter schreibt eine llama-swap-Config nur aus den belegten Karten — pro Modell `CUDA_VISIBLE_DEVICES=<Karte>` (mit `CUDA_DEVICE_ORDER=PCI_BUS_ID`, damit die Nummern wie bei nvidia-smi zaehlen), `ttl: 0`, eine Gruppe mit `swap: false` und Preload beim Start; llama-swap laeuft mit `--watch-config` (die frueher aufgerufenen Reload-Endpunkte gab es nicht). Laden oder Entladen startet die anderen Karten der Box kurz neu. Schlaegt das Laden fehl, wird die Karte auf ihr vorheriges Modell zurueckgesetzt. Bestehende Boxen: das bisherige Standardmodell (`LLM_MODEL`) landet beim ersten Start auf der ersten LLM-Karte
- **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht - **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung) - **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
+1
View File
@@ -21,5 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY aria_bridge.py . COPY aria_bridge.py .
COPY modes.py . COPY modes.py .
COPY llm_cluster.py .
CMD ["python", "aria_bridge.py"] CMD ["python", "aria_bridge.py"]
+29 -59
View File
@@ -41,6 +41,7 @@ import websockets
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
from openwakeword.model import Model as WakeWordModel from openwakeword.model import Model as WakeWordModel
from llm_cluster import LlmCluster
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
@@ -806,6 +807,9 @@ class ARIABridge:
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf # llm_request via RVS an den llm-adapter (AI-Box) und warten auf
# llm_response. # llm_response.
self._pending_llm: dict[str, asyncio.Future] = {} self._pending_llm: dict[str, asyncio.Future] = {}
# LLM-Cluster: ein Eingang fuer alle lokalen Modelle aller Boxen
# (Auswahl nach Last, Ausfall-Ersatz). Siehe llm_cluster.py.
self._llm_cluster = LlmCluster(self)
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation # User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei # sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart. # COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -2453,6 +2457,9 @@ class ARIABridge:
msg_type = message.get("type", "") msg_type = message.get("type", "")
payload = message.get("payload", {}) payload = message.get("payload", {})
if msg_type.startswith("llm_cluster_") and isinstance(payload, dict):
if await self._llm_cluster.handle_rvs(msg_type, payload):
return
if msg_type == "chat": if msg_type == "chat":
# Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!) # Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
@@ -4252,65 +4259,28 @@ class ARIABridge:
if not isinstance(messages, list) or not messages: if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"} return {"ok": False, "error": "messages leer/ungueltig"}
request_id = str(uuid.uuid4()) # Derselbe Router wie fuer Fremdprogramme: Box nach Last waehlen, bei
loop = asyncio.get_event_loop() # Ausfall einmal anderswo versuchen, ehrliche Absage, wenn das Modell
future: asyncio.Future = loop.create_future() # nirgends geladen ist (statt Broadcast + Timeout).
self._pending_llm[request_id] = future res = await self._llm_cluster.route({
try: "model": model or "", "messages": messages, "max_tokens": max_tokens,
req_payload = { "temperature": temperature, "stop": stop, "tools": tools,
"requestId": request_id, }, timeout=self._LLM_TIMEOUT_S)
"messages": messages, if not res.get("ok"):
"max_tokens": max_tokens, err = res.get("error") or "llm-adapter Fehler"
"temperature": temperature, if res.get("errorCode") == "model_unavailable":
} err += " Laden: Diagnostic → Einstellungen → LLM-Karten."
if stop: return {"ok": False, "error": err, "errorCode": res.get("errorCode")}
req_payload["stop"] = stop logger.info("[cluster] ARIA ← %s (Karte %s, %s ms, Versuche %s)",
if tools: res.get("node") or res.get("instanceId"), res.get("gpu", "?"),
req_payload["tools"] = tools res.get("elapsedMs"), res.get("attempts"))
if model: return {
req_payload["model"] = model "ok": True,
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die "content": res.get("content", ""),
# das gewaehlte Modell fahren kann; None → Broadcast wie bisher. "tool_calls": res.get("tool_calls"),
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt). "model": res.get("model"),
llm_target = self._pick_worker("llm", model=model or None) "elapsedMs": res.get("elapsedMs"),
if llm_target: }
req_payload["targetInstance"] = llm_target
elif model and self._llm_boxes_with_slots():
# Boxen mit Karten-Belegung laden nichts nach und schweigen auf
# Broadcasts fuer fremde Modelle. Ein Broadcast liefe also nur in
# den Timeout — lieber sofort ehrlich absagen.
self._pending_llm.pop(request_id, None)
loaded = sorted({m for w in self._llm_boxes_with_slots() for m in (w.get("models") or [])})
return {"ok": False, "errorCode": "model_not_loaded",
"error": (f"Lokales Modell {model!r} ist auf keiner KI-Box geladen. "
f"Geladen: {', '.join(loaded) or 'nichts'}. "
"Laden: Diagnostic → Modell-Katalog.")}
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
model or "-", llm_target or "(broadcast)")
ok = await self._send_to_rvs({
"type": "llm_request",
"payload": req_payload,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — AI-Box nicht erreichbar?"}
if not isinstance(result, dict) or not result.get("ok"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "llm-adapter Fehler"}
return {
"ok": True,
"content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"),
"elapsedMs": result.get("elapsedMs"),
}
finally:
self._pending_llm.pop(request_id, None)
async def _flux_generate(self, prompt: str, width: int, height: int, async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float], steps: Optional[int], guidance: Optional[float],
+218
View File
@@ -0,0 +1,218 @@
"""LLM-Cluster: ein Eingang fuer alle lokalen Sprachmodelle aller KI-Boxen.
Clients waehlen ein MODELL, nicht Box oder Karte. Wo es laeuft, entscheidet
dieser Router:
* Auswahl — unter allen online Boxen, die das Modell GELADEN haben
(Karten-Belegung aus worker_hello/worker_ping), die mit den
wenigsten laufenden Anfragen; bei Gleichstand reihum.
Ist nur eine Karte da oder alles frei, landet alles dort —
"verteilen, wenn noetig, sonst nicht".
* Ausfall — verschwindet eine Box mitten in der Anfrage (kein Ping mehr)
oder antwortet nicht, geht die Anfrage EINMAL an eine andere
Box mit demselben Modell. Fehler des Modells selbst (z.B.
Kontext zu lang) werden nicht wiederholt.
* Fehler — kommen als FEHLER zurueck (ok=false / HTTP-Status), nie als
normale Antwort. Ein Client wuerde einen Fehlertext sonst
als Aussage der KI in seinen Verlauf schreiben — genau das
hat bei ARIA schon mal einen ganzen Chat vergiftet.
Eingang ist der RVS:
llm_cluster_models_request → llm_cluster_models
llm_cluster_request → llm_cluster_response
Programme brauchen nur eine ausgehende Verbindung zum RVS, egal in welchem
Netz sie stehen. Werkzeuge, die nur "OpenAI-kompatibel" koennen (OpenHands
& Co.), bekommen den OpenAI-RVS-Wrapper neben sich gestellt
(xtts/openai_rvs_wrapper) — fuer sie ist der Cluster dann 127.0.0.1.
Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
from typing import Optional
logger = logging.getLogger("aria-bridge.llm-cluster")
# Diese errorCodes heissen "die BOX war das Problem" → anderswo nochmal versuchen.
# Alles andere (Modell-Fehler, z.B. Kontext zu lang) waere ueberall gleich.
RETRY_CODES = {"timeout", "box_offline", "send_failed", "model_not_loaded"}
MAX_ATTEMPTS = 2
class LlmCluster:
def __init__(self, bridge):
self.b = bridge
self.inflight: dict[str, int] = {} # instanceId → laufende Anfragen (von uns)
self._rr: dict[str, int] = {}
self.timeout = float(os.getenv("CLUSTER_LLM_TIMEOUT_S", "600"))
# ── Flotte ──────────────────────────────────────────────────────────
def _online_llm(self) -> list:
now = time.time()
return [w for w in self.b._workers.values()
if w.get("service") == "llm"
and (now - w.get("last_seen", 0)) < self.b.WORKER_OFFLINE_S]
def _is_online(self, instance_id: str) -> bool:
return any(w.get("instanceId") == instance_id for w in self._online_llm())
def models(self) -> list:
"""Jedes geladene Modell GENAU EINMAL, egal auf wie vielen Karten.
ctx = das kleinste Kontextfenster unter seinen Karten (darauf muss sich
ein Client verlassen koennen, egal wohin der Router verteilt)."""
out: dict[str, dict] = {}
for w in self._online_llm():
slots = w.get("slots")
if isinstance(slots, list):
for sl in slots:
m = sl.get("model")
if not m or m not in (w.get("models") or []):
continue
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
"gpu": sl.get("gpu", ""), "gpuName": sl.get("gpuName", ""),
"state": sl.get("state", "")})
c = int(sl.get("ctx") or 0)
if c and (not e["ctx"] or c < e["ctx"]):
e["ctx"] = c
else:
# Box mit altem Adapter (ohne Karten): nur die Modell-Liste
for m in (w.get("models") or []):
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
"gpu": "", "gpuName": "", "state": ""})
for e in out.values():
e["available"] = len(e["cards"])
return sorted(out.values(), key=lambda e: e["model"])
def candidates(self, model: str) -> list:
return [w for w in self._online_llm() if model in (w.get("models") or [])]
def pick(self, model: str, exclude=()) -> Optional[str]:
"""Die Box mit den wenigsten laufenden Anfragen; Gleichstand reihum."""
cands = [w["instanceId"] for w in self.candidates(model) if w["instanceId"] not in exclude]
if not cands:
return None
low = min(self.inflight.get(c, 0) for c in cands)
tied = sorted(c for c in cands if self.inflight.get(c, 0) == low)
i = self._rr.get(model, 0) % len(tied)
self._rr[model] = i + 1
return tied[i]
# ── Anfrage an eine bestimmte Box ───────────────────────────────────
async def _send_to(self, target: str, req: dict, deadline: float) -> dict:
"""llm_request an GENAU diese Box, Antwort abwarten. Bricht frueh ab,
wenn die Box unterwegs offline geht — dann kann route() woanders hin."""
rid = str(uuid.uuid4())
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self.b._pending_llm[rid] = fut
self.inflight[target] = self.inflight.get(target, 0) + 1
try:
payload = {k: v for k, v in req.items() if v is not None}
payload.update(requestId=rid, targetInstance=target)
ok = await self.b._send_to_rvs({"type": "llm_request", "payload": payload,
"timestamp": int(time.time() * 1000)})
if not ok:
return {"ok": False, "errorCode": "send_failed", "error": "RVS: Anfrage nicht gesendet"}
while True:
left = deadline - time.time()
if left <= 0:
return {"ok": False, "errorCode": "timeout",
"error": f"Keine Antwort von {target} in der Frist"}
try:
return await asyncio.wait_for(asyncio.shield(fut), timeout=min(5.0, left))
except asyncio.TimeoutError:
if not self._is_online(target):
return {"ok": False, "errorCode": "box_offline",
"error": f"{target} ist waehrend der Anfrage offline gegangen"}
finally:
self.inflight[target] = max(0, self.inflight.get(target, 1) - 1)
self.b._pending_llm.pop(rid, None)
async def route(self, req: dict, timeout: Optional[float] = None) -> dict:
"""Eine Anfrage an den Cluster. req: model, messages, max_tokens,
temperature, stop, tools. Antwort: wie llm_response der Box, plus
attempts; bei Fehler errorCode + error (+ available).
timeout: Gesamtfrist inkl. Ausfall-Ersatz. ARIA nutzt eine kurze (lokal
ist ihr Schnellweg — dauert es zu lange, nimmt sie Claude)."""
model = (req.get("model") or "").strip()
messages = req.get("messages")
if not isinstance(messages, list) or not messages:
return {"ok": False, "errorCode": "bad_request", "error": "messages fehlt oder ist leer"}
available = [m["model"] for m in self.models()]
if not model:
if len(available) == 1:
model = available[0] # eindeutig — dann darf es fehlen
else:
return {"ok": False, "errorCode": "bad_request", "available": available,
"error": "model fehlt. Geladen: " + (", ".join(available) or "nichts")}
body = {"model": model, "messages": messages,
"max_tokens": int(req.get("max_tokens") or 512),
"temperature": float(req["temperature"]) if req.get("temperature") is not None else 0.7,
"stop": req.get("stop") or None, "tools": req.get("tools") or None}
limit = timeout or self.timeout
deadline = time.time() + limit
tried: list = []
last: dict = {}
for _ in range(MAX_ATTEMPTS):
target = self.pick(model, exclude=tried)
if not target:
break
tried.append(target)
t0 = time.time()
last = await self._send_to(target, body, deadline)
if last.get("ok"):
last["attempts"] = len(tried)
return last
logger.warning("[cluster] %s auf %s: %s (%s)%s", model, target, last.get("errorCode") or "Fehler",
(last.get("error") or "")[:120], " — versuche andere Box" if
last.get("errorCode") in RETRY_CODES else "")
if last.get("errorCode") not in RETRY_CODES or time.time() >= deadline:
break
if not tried:
return {"ok": False, "errorCode": "model_unavailable", "available": available,
"error": (f"Modell {model!r} ist gerade auf keiner KI-Box im Cluster geladen. "
f"Geladen: {', '.join(available) or 'nichts'}.")}
last.setdefault("errorCode", "model_error")
last["attempts"] = len(tried)
if last["errorCode"] in RETRY_CODES and len(tried) >= 1:
others = [c for c in self.candidates(model) if c["instanceId"] not in tried]
if not others:
last["error"] = (last.get("error") or "") + (
" — keine weitere Box mit diesem Modell erreichbar.")
return last
# ── RVS-Eingang ─────────────────────────────────────────────────────
async def handle_rvs(self, mtype: str, payload: dict) -> bool:
if mtype == "llm_cluster_models_request":
await self.b._send_to_rvs({"type": "llm_cluster_models", "payload": {
"requestId": payload.get("requestId", ""), "models": self.models()},
"timestamp": int(time.time() * 1000)})
return True
if mtype == "llm_cluster_request":
asyncio.create_task(self._rvs_request(payload))
return True
return False
async def _rvs_request(self, payload: dict) -> None:
rid = payload.get("requestId", "")
res = await self.route(payload)
out = {"requestId": rid, "ok": bool(res.get("ok"))}
for k in ("content", "tool_calls", "model", "node", "gpu", "instanceId", "usage",
"elapsedMs", "attempts", "error", "errorCode", "available"):
if res.get(k) is not None:
out[k] = res[k]
await self.b._send_to_rvs({"type": "llm_cluster_response", "payload": out,
"timestamp": int(time.time() * 1000)})
+1
View File
@@ -136,6 +136,7 @@ services:
- RVS_TLS=${RVS_TLS:-true} - RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true} - RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-} - RVS_TOKEN=${RVS_TOKEN:-}
- CLUSTER_LLM_TIMEOUT_S=${CLUSTER_LLM_TIMEOUT_S:-600}
restart: unless-stopped restart: unless-stopped
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ──── # ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
+236 -175
View File
@@ -1,47 +1,43 @@
# Lokale Sprachmodelle nutzen — Schnittstelle für Drittprogramme # Lokale Sprachmodelle nutzen — Schnittstelle für Programme
Diese Seite beschreibt, wie ein Programm die lokalen Sprachmodelle auf den Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
KI-Boxen von ARIA nutzt: über den RVS (WebSocket) oder direkt über den
OpenAI-kompatiblen Port einer Box. **Kurz:** Du fragst ein **Modell**, nicht eine Box oder Karte. Der Cluster
sucht sich aus, wo es läuft. Eigene Programme sprechen dafür über den RVS
(Abschnitt A). Programme, die nur „OpenAI-kompatibel" können, bekommen den
OpenAI-RVS-Wrapper neben sich gestellt (Abschnitt B).
## Die Grundregel ## Die Grundregel
**Clients wählen aus, laden aber nie.** **Programme wählen ein Modell aus — laden aber nie.**
- Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten - Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten
hält **genau ein Modell, dauerhaft geladen**. hält **genau ein Modell, dauerhaft geladen**.
- Welches Modell auf welcher Karte liegt, legt ausschließlich der Betreiber in - Welches Modell wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
der ARIA-Diagnostic fest (Modell-Katalog → Laden → Karte). (Einstellungen → LLM-Karten).
- Ein Client — ARIA selbst, ein Drittprogramm, OpenHands — sieht nur die - Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
**geladenen** Modelle aller Boxen und schickt seine Anfrage an eines davon. **einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
- Eine Anfrage für ein Modell, das nicht geladen ist, wird **abgelehnt**. Sie - Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
lädt nichts nach und verdrängt nichts. Kein Client kann einem anderen das Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
Modell wegnehmen. demselben Modell. Davon merkt das Programm nichts.
- Mehrere Clients dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte - Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer, die Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
Karte schafft in Summe aber mehr. der KI in seinen Verlauf schreiben.
- Mehrere Programme dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer.
Welche Karten einer Box dem LLM gehören, steht in deren `.env`: ## Welcher Zugang?
```bash | | A) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
# Beispiel: Karte 0 für Spracherkennung + Sprachausgabe, Karten 1 und 2 fürs LLM |---|---|---|---|
WHISPER_GPU=0 | Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
F5TTS_GPU=0 | Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
LLM_GPUS=1,2 | Verteilung + Ausfall-Ersatz | ja | ja | nein |
``` | Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
## Zwei Zugänge
| | RVS (WebSocket) | OpenAI-Port der Box |
|---|---|---|
| Erreichbar | überall, wo der RVS erreichbar ist | nur im Netz der Box (Standard: nur `localhost`) |
| Sieht | alle Boxen und ihre Karten | nur diese eine Box |
| Format | eigene Nachrichten (unten) | OpenAI `/v1/chat/completions` |
| Typische Nutzer | eigene Programme, ARIA | OpenHands, Continue, Open WebUI |
--- ---
## A) Über den RVS ## A) Cluster über RVS — empfohlen
### Verbinden ### Verbinden
@@ -49,13 +45,19 @@ LLM_GPUS=1,2
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN> wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
``` ```
- Das **Token ist der Raum**. Es ist dasselbe Token, das ARIA, die Diagnostic - Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
und die KI-Boxen nutzen (`RVS_TOKEN`). Wer das Token hat, ist im Raum. KI-Boxen. Wer es hat, ist im Raum.
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an *alle anderen* - Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
Teilnehmer des Raums. Du siehst also auch Antworten, die für andere Clients Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
bestimmt sind — ordne Antworten immer über die `requestId` zu. - Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
- Der RVS lässt nur bekannte Nachrichtentypen durch. Unbekannte Typen Du siehst also auch Antworten für andere — ordne sie immer über die
verschwinden **ohne Fehlermeldung**. Die hier beschriebenen sind freigegeben. `requestId` zu.
- Unbekannte Nachrichtentypen verwirft der RVS **ohne Fehlermeldung**. Die hier
beschriebenen sind freigegeben.
- **Nur ein Leser pro Verbindung.** Schickst du mehrere Anfragen gleichzeitig,
liest eine Schleife alle Nachrichten und verteilt sie per `requestId` an die
Wartenden (siehe Beispiel). Mehrere gleichzeitige `recv()` auf derselben
Verbindung gehen nicht.
Jede Nachricht hat diese Hülle: Jede Nachricht hat diese Hülle:
@@ -63,25 +65,33 @@ Jede Nachricht hat diese Hülle:
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 } { "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
``` ```
### 1. Welche Modelle gibt es? — `llm_models_request` Beantwortet werden `llm_cluster_*`-Nachrichten von der **ARIA-Bridge** — sie
kennt alle Boxen und entscheidet, wohin eine Anfrage geht. Läuft ARIA nicht,
kommt keine Antwort (dein Programm sollte einen Timeout haben).
### 1. Welche Modelle gibt es? — `llm_cluster_models_request`
```json ```json
{ "type": "llm_models_request", "payload": { "requestId": "abc-1" } } { "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
``` ```
**Jede** Box im Raum antwortet einzeln mit `llm_models`. Sammle die Antworten Antwort:
ein paar Sekunden lang ein (2–3 s reichen):
```json ```json
{ {
"type": "llm_models", "type": "llm_cluster_models",
"payload": { "payload": {
"requestId": "abc-1", "requestId": "a1",
"instanceId": "llm@ki-box",
"node": "ki-box",
"models": [ "models": [
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "ctx": 8192, "state": "ready" }, {
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "ctx": 8192, "state": "ready" } "model": "qwen3-8b",
"ctx": 8192,
"available": 2,
"cards": [
{ "node": "ki-box", "instanceId": "llm@ki-box", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
{ "node": "werkstatt", "instanceId": "llm@werkstatt", "gpu": "0", "gpuName": "NVIDIA RTX A4000", "state": "ready" }
]
}
] ]
} }
} }
@@ -89,29 +99,18 @@ ein paar Sekunden lang ein (2–3 s reichen):
| Feld | Bedeutung | | Feld | Bedeutung |
|---|---| |---|---|
| `instanceId` | Adresse der Box — brauchst du gleich als `targetInstance` | | `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
| `node` | lesbarer Name der Box | | `ctx` | **Kontextfenster in Tokens** (Eingabe + Ausgabe zusammen). Das kleinste unter allen Karten mit diesem Modell — darauf kannst du dich verlassen, egal wohin verteilt wird. Nicht mehr annehmen. `0` = unbekannt. |
| `models[].model` | Modellname, so in der Anfrage angeben | | `available` | auf wie vielen Karten es gerade liegt |
| `models[].gpu` | Karte, auf der es läuft | | `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
| `models[].ctx` | **Kontextfenster in Tokens**, mit dem das Modell gestartet ist — Eingabe und Ausgabe zusammen. Diesen Wert verwenden, nicht den, den das Modell theoretisch kann: Die Box gibt nur so viel her, wie hier steht. `0` = unbekannt. |
| `models[].state` | `ready` = bereit, `starting` = startet gerade neu (Anfragen warten dann kurz), `unknown` = Box konnte es nicht prüfen |
Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst ### 2. Fragen — `llm_cluster_request`
du nicht nutzen.
Mitlesen statt fragen: Die Boxen melden sich außerdem regelmäßig selbst
(`worker_hello` etwa alle 30 s, `worker_ping` alle 10 s, jeweils mit
`service: "llm"`, `models` und `slots`). Ein Client, der länger verbunden
bleibt, kann die Liste daraus aktuell halten.
### 2. Fragen — `llm_request`
```json ```json
{ {
"type": "llm_request", "type": "llm_cluster_request",
"payload": { "payload": {
"requestId": "abc-2", "requestId": "a2",
"targetInstance": "llm@ki-box",
"model": "qwen3-8b", "model": "qwen3-8b",
"messages": [ "messages": [
{ "role": "system", "content": "Du antwortest kurz." }, { "role": "system", "content": "Du antwortest kurz." },
@@ -125,172 +124,234 @@ bleibt, kann die Liste daraus aktuell halten.
| Feld | Pflicht | Bedeutung | | Feld | Pflicht | Bedeutung |
|---|---|---| |---|---|---|
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) — damit findest du deine Antwort | | `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) |
| `model` | ja* | aus `llm_cluster_models`. *Darf fehlen, wenn im Cluster genau ein Modell geladen ist. |
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) | | `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
| `model` | dringend empfohlen | eines aus `llm_models`. Ohne Angabe nimmt die Box das Modell ihrer ersten LLM-Karte. |
| `targetInstance` | dringend empfohlen | die `instanceId` aus `llm_models`. Siehe „Ohne `targetInstance`". |
| `max_tokens` | nein | Standard 512 | | `max_tokens` | nein | Standard 512 |
| `temperature` | nein | Standard 0.7 | | `temperature` | nein | Standard 0.7 |
| `stop` | nein | Liste von Stop-Strings | | `stop` | nein | Liste von Stop-Strings |
| `tools` | nein | Tool-Definitionen im OpenAI-Format; Antwort enthält dann ggf. `tool_calls` | | `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
### 3. Antwort — `llm_response` ### 3. Antwort — `llm_cluster_response`
Erfolg:
```json ```json
{ {
"type": "llm_response", "type": "llm_cluster_response",
"payload": { "payload": {
"requestId": "abc-2", "requestId": "a2",
"ok": true, "ok": true,
"content": "Die Zugspitze ist 2962 Meter hoch.", "content": "Die Zugspitze ist 2962 Meter hoch.",
"tool_calls": null,
"model": "qwen3-8b", "model": "qwen3-8b",
"instanceId": "llm@ki-box", "node": "werkstatt",
"node": "ki-box", "gpu": "0",
"gpu": "1",
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 }, "usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
"elapsedMs": 640 "elapsedMs": 640,
"attempts": 1
} }
} }
``` ```
Im Fehlerfall ist `ok` `false`, dazu kommen `error` (Text für Menschen) und `tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
`errorCode` (für Programme): `node`/`gpu` sagen, wer geantwortet hat; `attempts: 2` heißt, die erste Box ist
ausgefallen und eine andere hat übernommen.
Fehler — `ok: false`, dazu `error` (Text für Menschen) und `errorCode`:
| `errorCode` | Bedeutung | Was tun | | `errorCode` | Bedeutung | Was tun |
|---|---|---| |---|---|---|
| `model_not_loaded` | Das Modell liegt auf dieser Box nicht. `loadedModels` nennt, was dort geladen ist. | Ein geladenes Modell wählen. Die Liste neu holen (`llm_models_request`). | | `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
| `no_model_loaded` | Auf dieser Box ist keine LLM-Karte belegt. | Andere Box wählen. | | `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
| *(fehlt)* | Fehler des Modells selbst, z.B. Kontext zu lang | `error` lesen | | `timeout` | Keine Antwort innerhalb der Frist (Standard 600 s) — auch nach Ersatzversuch | später nochmal |
| `box_offline` | Box ist während der Anfrage weggefallen, und es gab keine Ersatz-Box | später nochmal |
| `model_error` | Das Modell selbst hat einen Fehler gemeldet, z.B. Kontext zu lang. Wird nicht anderswo wiederholt — wäre dort genauso. | `error` lesen |
### Ohne `targetInstance` **Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
Ohne `targetInstance` geht die Anfrage an alle Boxen. Antworten werden nur die, Verlauf.
die das Modell geladen haben — die anderen **schweigen**, damit du nicht von
jeder Box einen Fehler bekommst. Hat keine Box das Modell, kommt also **gar
keine** Antwort und dein Client läuft in seinen Timeout. Darum: immer
`targetInstance` setzen. Dann bekommst du bei einem Problem sofort eine klare
Absage.
Haben mehrere Boxen dasselbe Modell geladen und du lässt `targetInstance`
weg, antworten **alle**. Auch das spricht für `targetInstance`.
### Was Clients nicht senden
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der RVS
unterscheidet die Teilnehmer nicht. Ein Programm, das sich daran nicht hält,
tauscht anderen Clients das Modell unter den Füßen weg; genau das soll diese
Schnittstelle verhindern.
### Beispiel (Python) ### Beispiel (Python)
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Genau so gegen einen Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
echten RVS, llm-adapter und llama-swap getestet — nur die Adresse war eine andere. pro Verbindung, mehrere Anfragen gleichzeitig möglich.
```python ```python
import asyncio, json, uuid import asyncio, json, uuid
import websockets import websockets
RVS = "wss://rvs.example.org:443/?token=DEIN_RVS_TOKEN" RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
async def models(ws, wait=2.5): class AriaCluster:
"""Alle geladenen Modelle aller Boxen: [(instanceId, node, model, gpu)].""" """Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
rid = str(uuid.uuid4()) wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
await ws.send(json.dumps({"type": "llm_models_request", "payload": {"requestId": rid}}))
found = []
loop = asyncio.get_running_loop()
end = loop.time() + wait
while (left := end - loop.time()) > 0:
try:
msg = json.loads(await asyncio.wait_for(ws.recv(), left))
except asyncio.TimeoutError:
break
p = msg.get("payload") or {}
if msg.get("type") == "llm_models" and p.get("requestId") == rid:
found += [(p["instanceId"], p["node"], m["model"], m["gpu"]) for m in p["models"]]
return found
def __init__(self, ws):
self.ws, self.wait = ws, {}
self.reader = asyncio.create_task(self._read())
async def ask(ws, instance_id, model, prompt, timeout=120): async def _read(self):
rid = str(uuid.uuid4()) async for raw in self.ws:
await ws.send(json.dumps({"type": "llm_request", "payload": { msg = json.loads(raw)
"requestId": rid, "targetInstance": instance_id, "model": model,
"messages": [{"role": "user", "content": prompt}], "max_tokens": 256}}))
async with asyncio.timeout(timeout):
while True:
msg = json.loads(await ws.recv())
p = msg.get("payload") or {} p = msg.get("payload") or {}
# Der Raum ist ein Verteiler: nur UNSERE Antwort nehmen. fut = self.wait.get((msg.get("type"), p.get("requestId")))
if msg.get("type") == "llm_response" and p.get("requestId") == rid: if fut and not fut.done():
if not p["ok"]: fut.set_result(p)
raise RuntimeError(f"{p.get('errorCode')}: {p.get('error')}")
return p["content"] async def _call(self, mtype, answer, payload, timeout):
rid = str(uuid.uuid4())
fut = asyncio.get_running_loop().create_future()
self.wait[(answer, rid)] = fut
try:
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid)}))
return await asyncio.wait_for(fut, timeout)
finally:
self.wait.pop((answer, rid), None)
async def models(self):
res = await self._call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
return res["models"]
async def chat(self, model, messages, timeout=660, **opts):
res = await self._call("llm_cluster_request", "llm_cluster_response",
dict(opts, model=model, messages=messages), timeout)
if not res["ok"]:
raise RuntimeError(f"{res.get('errorCode')}: {res.get('error')}")
return res
async def main(): async def main():
async with websockets.connect(RVS, max_size=2**24) as ws: async with websockets.connect(RVS, max_size=2**24) as ws:
available = await models(ws) cluster = AriaCluster(ws)
for iid, node, model, gpu in available: models = await cluster.models()
print(f"{node} / Karte {gpu}: {model} ({iid})") for m in models:
if available: print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
iid, node, model, gpu = available[0] if models:
print(await ask(ws, iid, model, "Sag Hallo in einem Satz.")) res = await cluster.chat(models[0]["model"],
[{"role": "user", "content": "Sag Hallo in einem Satz."}],
max_tokens=64)
print(res["content"], f"— von {res['node']} / Karte {res['gpu']}")
asyncio.run(main()) asyncio.run(main())
``` ```
### Umstellen von der direkten Box-Ansprache
Wer schon gegen Abschnitt C gebaut hat (`llm_models_request` /
`llm_request` mit `targetInstance`), stellt so um:
1. **Modelliste:** `llm_cluster_models_request` statt `llm_models_request`.
Statt einer Antwort pro Box kommt **eine** Antwort mit jedem Modell
einmal. Kein Einsammeln über Sekunden mehr.
2. **Auswahl:** Dem Nutzer nur den **Modellnamen** zeigen — nicht mehr
„Modell · Karte · Box". Wo es läuft, entscheidet der Cluster.
3. **Kontextfenster:** `ctx` aus der Liste übernehmen statt einen eigenen Wert
einzustellen. Die Box gibt nur so viel her, wie dort steht — rechnet ein
Programm mit mehr, scheitern lange Aufträge oder werden falsch gekürzt.
4. **Anfrage:** `llm_cluster_request` statt `llm_request`; **kein**
`targetInstance` mehr. Die Antwort kommt als `llm_cluster_response`.
5. **Fehler:** `ok: false` als Fehler anzeigen, nicht als Antwort in den
Verlauf. Neue Codes siehe Tabelle oben.
6. **Lesen:** ein Leser pro Verbindung, Antworten per `requestId` zuordnen.
Felder und Format von `messages`, `tools`, `tool_calls` und `usage` bleiben
gleich.
--- ---
## B) Über den OpenAI-Port einer Box ## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
Jede KI-Box bietet einen OpenAI-kompatiblen Endpunkt — für Programme, die nur Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
„OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …). Für das Programm ist der Cluster dann ein ganz normaler OpenAI-Endpunkt:
``` ```
http://<IP der Box>:<LLM_OPENAI_PORT>/v1 (Standard-Port 8081) Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
``` ```
- `GET /v1/models` listet **nur die geladenen** Modelle dieser Box. Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
- `POST /v1/chat/completions` mit `"model": "<Name>"` wie gewohnt.
- Ein nicht geladenes Modell wird mit **HTTP 404** abgelehnt
(`"no router for requested model"`) — es wird nichts nachgeladen.
- Ist in der Diagnostic ein API-Key gesetzt, mit
`Authorization: Bearer <KEY>` schicken.
- Standardmäßig hört der Port nur auf `localhost` der Box. Für Zugriff aus dem
Netz `LLM_OPENAI_BIND=0.0.0.0` in der `.env` der Box setzen — dann
**unbedingt** einen API-Key vergeben.
- Dieser Zugang sieht nur seine eigene Box. Wer mehrere Boxen nutzen will,
nimmt den RVS.
```bash ```bash
curl http://192.168.177.153:8081/v1/chat/completions \ cd xtts/openai_rvs_wrapper
-H "Content-Type: application/json" \ cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
-d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hallo"}]}' docker compose up -d --build
``` ```
Im Browser zeigt **http://127.0.0.1:8099/** eine Hilfsseite: ob die Verbindung
steht und welche Modelle es gibt. Im Programm eintragen:
| Feld | Wert |
|---|---|
| Base URL | `http://127.0.0.1:8099/v1` |
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
| Modell | ein Name von der Hilfsseite |
| Kontextfenster | der Wert von der Hilfsseite |
- `GET /v1/models` — jedes Modell einmal, mit `context_length`
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
Rutsch) und `tools`
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504
- Antwort-Header `X-Aria-Node` / `X-Aria-Gpu` sagen, wer geantwortet hat
- **Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
Container. Dann das Docker-Netz `aria-llm` anhängen und
`http://openai-rvs-wrapper:8099/v1` nehmen (Details in der README des Wrappers).
Mit dem offiziellen OpenAI-Python-SDK getestet (`models.list`,
`chat.completions.create` mit und ohne Stream, Fehlerklassen).
--- ---
## C) Direkt an eine Box — Sonderfall
Ohne den Cluster, eine bestimmte Box gezielt. Keine Verteilung, kein
Ausfall-Ersatz. Nur nötig, wenn ein Programm bewusst eine bestimmte Box will
oder ARIA nicht läuft.
**Modelle der Boxen:** `llm_models_request` `{requestId}` → **jede** Box
antwortet einzeln mit `llm_models` `{requestId, instanceId, node, models:
[{model, gpu, gpuName, ctx, state}]}`. Ein paar Sekunden einsammeln.
**Anfrage:** `llm_request` `{requestId, targetInstance, model, messages, …}`
→ `llm_response` `{requestId, ok, content, tool_calls, model, instanceId,
node, gpu, usage, elapsedMs}`. Bei Fehler `errorCode`:
`model_not_loaded` (mit `loadedModels`) oder `no_model_loaded`.
Ohne `targetInstance` antworten nur Boxen, die das Modell geladen haben — die
anderen schweigen. Hat keine es, kommt **keine** Antwort. Darum hier immer
`targetInstance` setzen.
**OpenAI-Port einer Box:** Jede Box hat zusätzlich einen eigenen
OpenAI-Endpunkt (`http://<IP der Box>:8081/v1`, Standard nur `localhost` der
Box). Er sieht nur diese Box; nicht geladene Modelle → HTTP 404. Für mehrere
Boxen den Wrapper (B) nehmen.
---
## Was Programme nicht senden
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
ARIA-Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der
RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich nicht daran
hält, tauscht anderen das Modell unter den Füßen weg.
## Gleichzeitige Nutzung und Geschwindigkeit ## Gleichzeitige Nutzung und Geschwindigkeit
- Mehrere Clients können **dasselbe Modell gleichzeitig** nutzen. llama.cpp - Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
bedient sie parallel (Continuous Batching); jede Antwort wird etwas Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
langsamer, insgesamt schafft die Karte mehr. - Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
- Der Kontext (`-c`, Standard 8192 Tokens) ist bei aktuellen llama.cpp-Versionen die Karte mit den wenigsten laufenden.
ein **gemeinsamer Vorrat** aller gleichzeitigen Anfragen. Schicken mehrere - Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
Clients gleichzeitig sehr lange Verläufe, wird es dort eng. Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
- Ältere llama.cpp-Versionen arbeiten Anfragen an dasselbe Modell nacheinander Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
ab statt parallel. Welche Version eine Box fährt, hängt am Image von
llama-swap (`docker compose pull llama-swap` holt die aktuelle).
## Wenn sich etwas ändert ## Wenn sich etwas ändert
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte sofort - Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
weg; laufende Clients bekommen dann `model_not_loaded` und sollten die Liste Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
neu holen. `model_unavailable` — dann die Liste neu holen.
- Beim Laden oder Entladen startet die Box **alle** ihre Modelle kurz neu - Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
(wenige Sekunden). Anfragen in diesem Moment warten, bis das Modell wieder kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
bereit ist (`state: "starting"`). eine andere Box.
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können - Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
dazukommen. Ein Client sollte unbekannte Felder ignorieren. dazukommen. Unbekannte Felder ignorieren.
+4
View File
@@ -133,6 +133,10 @@ const ALLOWED_TYPES = new Set([
// auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models). // auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models).
// Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md // Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
"llm_unload_model", "llm_models_request", "llm_models", "llm_unload_model", "llm_models_request", "llm_models",
// LLM-Cluster: Clients fragen MODELLE, die ARIA-Bridge waehlt die Box
// (Last, Ausfall-Ersatz) und antwortet. Empfohlener Weg fuer Programme.
"llm_cluster_models_request", "llm_cluster_models",
"llm_cluster_request", "llm_cluster_response",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop", "node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history", "node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done", "node_stats_reset", "node_stats_reset_done",
+21
View File
@@ -0,0 +1,21 @@
# ── Verbindung zum RVS ───────────────────────────────────────────────
# Dieselben Werte wie RVS_HOST / RVS_PORT / RVS_TOKEN in ARIAs .env.
# Der Wrapper braucht nur eine AUSGEHENDE Verbindung dorthin — keine
# Portfreigabe, kein VPN, egal in welchem Netz dieser Rechner steht.
RVS_HOST=mobil.example.org
RVS_PORT=444
RVS_TLS=true
RVS_TOKEN=
# ── Endpunkt fuer die Programme auf diesem Rechner ───────────────────
# Port am Host (Base URL: http://127.0.0.1:<Port>/v1)
WRAPPER_PORT=8099
# 127.0.0.1 = nur dieser Rechner (empfohlen). 0.0.0.0 = auch aus dem LAN —
# dann unbedingt einen API-Key setzen.
WRAPPER_BIND=127.0.0.1
# Optional. Leer = jeder Key wird akzeptiert (ok, solange nur lokal erreichbar).
WRAPPER_API_KEY=
# Wie lange auf eine Antwort gewartet wird (muss laenger sein als
# CLUSTER_LLM_TIMEOUT_S der ARIA-Bridge, Standard 600).
#REQUEST_TIMEOUT_S=660
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY wrapper.py .
EXPOSE 8099
CMD ["python", "-u", "wrapper.py"]
+59
View File
@@ -0,0 +1,59 @@
# OpenAI-RVS-Wrapper
Macht den ARIA-LLM-Cluster für Programme, die nur „OpenAI-kompatibel" können
(OpenHands, Continue, Open WebUI …), zu einem ganz normalen OpenAI-Endpunkt
auf **diesem** Rechner:
```
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
```
Der Wrapper braucht nur eine ausgehende Verbindung zum RVS. Das Programm kann
also stehen, wo es will — keine Portfreigabe, kein VPN.
## Einrichten
```bash
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
docker compose up -d --build
```
Dann im Browser **http://127.0.0.1:8099/** öffnen. Die Hilfsseite zeigt, ob
die Verbindung steht, welche Modelle es gibt und was man einträgt.
## Im Programm eintragen
| Feld | Wert |
|---|---|
| Base URL | `http://127.0.0.1:8099/v1` |
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
| Modell | ein Name von der Hilfsseite, z.B. `qwen3-8b` |
| Kontextfenster | der Wert von der Hilfsseite (nicht mehr eintragen, die Box gibt nur so viel her) |
**Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
Container. Dann das Netz `aria-llm` anhängen und `http://openai-rvs-wrapper:8099/v1`
nehmen:
```yaml
services:
openhands:
# …
networks: [default, aria-llm]
networks:
aria-llm:
external: true
```
## Was der Cluster dahinter macht
- Jedes Modell steht in der Liste **einmal**, egal auf wie vielen Karten es liegt.
- Liegt es auf mehreren Karten, verteilt der Cluster bei Last. Fällt eine Box
mitten in der Anfrage aus, übernimmt eine andere mit demselben Modell.
- Welche Modelle es gibt, legt nur die ARIA-Diagnostic fest
(Einstellungen → LLM-Karten). Ein Programm kann nichts laden oder entladen.
- Fehler (Modell nicht geladen, Cluster nicht erreichbar) kommen als
HTTP-Fehler mit OpenAI-Fehlerobjekt, nie als normale Antwort.
- `stream: true` geht; die Antwort kommt derzeit in einem Rutsch statt Wort für Wort.
Endpunkte: `GET /v1/models`, `POST /v1/chat/completions`, `GET /health`, `GET /` (Hilfsseite).
Die Schnittstelle dahinter: [`docs/LLM-SCHNITTSTELLE.md`](../../docs/LLM-SCHNITTSTELLE.md).
@@ -0,0 +1,25 @@
# OpenAI-RVS-Wrapper — der ARIA-LLM-Cluster als OpenAI-Endpunkt auf diesem Rechner.
# Laeuft neben dem Programm, das ihn nutzen soll (OpenHands, Continue, …).
#
# cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
# docker compose up -d --build
# → Hilfsseite: http://127.0.0.1:8099/
services:
openai-rvs-wrapper:
build: .
container_name: openai-rvs-wrapper
env_file: .env
ports:
# Standard: nur dieser Rechner. Fuer Zugriff aus dem LAN WRAPPER_BIND=0.0.0.0
# setzen — dann unbedingt WRAPPER_API_KEY vergeben.
- "${WRAPPER_BIND:-127.0.0.1}:${WRAPPER_PORT:-8099}:8099"
environment:
- WRAPPER_PORT=8099
networks: [aria-llm]
restart: unless-stopped
# Programme, die selbst in Docker laufen, erreichen 127.0.0.1 des Hosts nicht —
# sie haengen sich an dieses Netz und nehmen http://openai-rvs-wrapper:8099/v1
networks:
aria-llm:
name: aria-llm
+1
View File
@@ -0,0 +1 @@
websockets==13.1
+367
View File
@@ -0,0 +1,367 @@
"""OpenAI-RVS-Wrapper — der ARIA-LLM-Cluster als OpenAI-Endpunkt auf 127.0.0.1.
Laeuft als kleiner Container NEBEN einem Programm, das nur "OpenAI-kompatibel"
kann (OpenHands, Continue, Open WebUI …). Fuer das Programm ist der Cluster
dann einfach http://127.0.0.1:8099/v1. Der Wrapper selbst braucht nur eine
ausgehende Verbindung zum RVS — wie jede KI-Box auch.
Programm ──HTTP──▶ Wrapper ──RVS──▶ ARIA-Bridge (Router) ──RVS──▶ KI-Box / Karte
GET / Hilfsseite: welche Modelle gibt es, was eintragen
GET /v1/models jedes geladene Modell EINMAL (egal auf wie vielen Karten)
POST /v1/chat/completions auch mit stream=true und tools
GET /health RVS verbunden? Antwortet die ARIA-Bridge?
Welche Box/Karte antwortet, entscheidet die ARIA-Bridge (Last, Ausfall-Ersatz).
Fehler kommen als HTTP-Fehler mit OpenAI-Fehlerobjekt zurueck, nie als normale
Antwort — ein Programm wuerde einen Fehlertext sonst als Aussage der KI in
seinen Verlauf schreiben.
Schnittstelle dahinter: docs/LLM-SCHNITTSTELLE.md
"""
from __future__ import annotations
import asyncio
import hmac
import json
import logging
import os
import ssl
import time
import uuid
import websockets
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("openai-rvs-wrapper")
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = os.getenv("RVS_PORT", "443").strip()
RVS_TLS = os.getenv("RVS_TLS", "true").strip().lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
PORT = int(os.getenv("WRAPPER_PORT", "8099"))
API_KEY = os.getenv("WRAPPER_API_KEY", "").strip()
REQUEST_TIMEOUT_S = float(os.getenv("REQUEST_TIMEOUT_S", "660")) # > CLUSTER_LLM_TIMEOUT_S der Bridge
MODELS_CACHE_S = float(os.getenv("MODELS_CACHE_S", "10"))
MAX_BODY = 32 * 1024 * 1024
# ── RVS ─────────────────────────────────────────────────────────────
class Rvs:
"""Eine Verbindung, ein Leser. Antworten werden per (Typ, requestId) an
die wartende Anfrage gegeben — der RVS-Raum ist ein Verteiler, wir sehen
auch Nachrichten fuer andere Teilnehmer und ignorieren sie."""
def __init__(self):
self.ws = None
self.wait: dict = {}
self.connected_since = 0.0
@property
def connected(self) -> bool:
return self.ws is not None
async def run(self) -> None:
scheme = "wss" if RVS_TLS else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/?token={RVS_TOKEN}"
backoff = 1
while True:
try:
ctx = ssl.create_default_context() if RVS_TLS else None
async with websockets.connect(url, ssl=ctx, max_size=64 * 1024 * 1024,
ping_interval=20, ping_timeout=20) as ws:
self.ws, self.connected_since, backoff = ws, time.time(), 1
logger.info("RVS verbunden: %s://%s:%s", scheme, RVS_HOST, RVS_PORT)
async for raw in ws:
try:
m = json.loads(raw)
except Exception:
continue
p = m.get("payload") or {}
f = self.wait.get((m.get("type"), p.get("requestId")))
if f and not f.done():
f.set_result(p)
except Exception as e:
logger.warning("RVS-Verbindung weg (%s) — neuer Versuch in %ds", e, backoff)
finally:
self.ws = None
for f in list(self.wait.values()):
if not f.done():
f.set_exception(ConnectionError("RVS-Verbindung unterbrochen"))
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
async def call(self, mtype: str, answer: str, payload: dict, timeout: float) -> dict:
if not self.ws:
raise ConnectionError("keine Verbindung zum RVS")
rid = str(uuid.uuid4())
fut = asyncio.get_running_loop().create_future()
self.wait[(answer, rid)] = fut
try:
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid),
"timestamp": int(time.time() * 1000)}))
return await asyncio.wait_for(fut, timeout)
finally:
self.wait.pop((answer, rid), None)
rvs = Rvs()
_models_cache = {"at": 0.0, "models": None}
async def cluster_models() -> list:
"""[{model, ctx, available, cards}] — jedes Modell einmal. Kurz gecacht,
damit ein Programm, das /v1/models dauernd fragt, den Cluster nicht flutet."""
if _models_cache["models"] is not None and time.time() - _models_cache["at"] < MODELS_CACHE_S:
return _models_cache["models"]
res = await rvs.call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
_models_cache.update(at=time.time(), models=res.get("models") or [])
return _models_cache["models"]
# ── HTTP ────────────────────────────────────────────────────────────
def oai_error(message: str, code: str, etype: str = "invalid_request_error") -> dict:
return {"error": {"message": message, "type": etype, "param": None, "code": code}}
async def reply(writer, status: int, payload, ctype="application/json; charset=utf-8", headers=None):
body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode("utf-8")
reason = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 404: "Not Found", 413: "Payload Too Large",
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout"}.get(status, "Error")
head = (f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {len(body)}\r\n"
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n")
for k, v in (headers or {}).items():
head += f"{k}: {v}\r\n"
writer.write((head + "\r\n").encode("utf-8") + body)
await writer.drain()
def authorized(headers: dict) -> bool:
if not API_KEY:
return True
auth = headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
return bool(token) and hmac.compare_digest(token, API_KEY)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
line = await asyncio.wait_for(reader.readline(), 15)
if not line:
return
try:
method, path, _ = line.decode("utf-8", "ignore").strip().split(" ", 2)
except ValueError:
return await reply(writer, 400, oai_error("bad request line", "bad_request"))
headers = {}
while True:
h = await asyncio.wait_for(reader.readline(), 10)
if not h or h in (b"\r\n", b"\n"):
break
k, _, v = h.decode("utf-8", "ignore").partition(":")
headers[k.strip().lower()] = v.strip()
n = int(headers.get("content-length", "0") or "0")
if n > MAX_BODY:
return await reply(writer, 413, oai_error("Anfrage zu gross", "too_large"))
body = await reader.readexactly(n) if n else b""
path = path.split("?", 1)[0].rstrip("/") or "/"
if method == "OPTIONS":
return await reply(writer, 200, b"", "text/plain", {
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"})
if method == "GET" and path == "/":
return await reply(writer, 200, HELP_PAGE.encode("utf-8"), "text/html; charset=utf-8")
if method == "GET" and path == "/health":
bridge_ok = False
if rvs.connected:
try:
await rvs.call("llm_cluster_models_request", "llm_cluster_models", {}, 4)
bridge_ok = True
except Exception:
pass
return await reply(writer, 200 if bridge_ok else 503, {
"rvs": rvs.connected, "bridge": bridge_ok,
"rvsUrl": f"{'wss' if RVS_TLS else 'ws'}://{RVS_HOST}:{RVS_PORT}", "apiKey": bool(API_KEY)})
if method == "GET" and path == "/api/models":
# Fuer die Hilfsseite. Nur Modellnamen — kein Schutz noetig.
try:
return await reply(writer, 200, {"ok": True, "models": await cluster_models()})
except Exception as e:
return await reply(writer, 503, {"ok": False, "error": _unreachable(e)})
if not path.startswith("/v1/"):
return await reply(writer, 404, oai_error(f"{method} {path} gibt es nicht", "not_found"))
if not authorized(headers):
return await reply(writer, 401, oai_error("API-Key fehlt oder ist falsch (Authorization: Bearer <KEY>).",
"invalid_api_key", "authentication_error"))
if method == "GET" and path == "/v1/models":
try:
models = await cluster_models()
except Exception as e:
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
now = int(time.time())
return await reply(writer, 200, {"object": "list", "data": [
{"id": m["model"], "object": "model", "created": now, "owned_by": "aria-cluster",
"context_length": m.get("ctx") or None} for m in models]})
if method == "POST" and path == "/v1/chat/completions":
try:
req = json.loads(body.decode("utf-8", "ignore") or "{}")
except Exception as e:
return await reply(writer, 400, oai_error(f"kein gueltiges JSON: {e}", "bad_json"))
payload = {"model": req.get("model") or "", "messages": req.get("messages"),
"max_tokens": req.get("max_completion_tokens") or req.get("max_tokens"),
"temperature": req.get("temperature"), "stop": req.get("stop"), "tools": req.get("tools")}
try:
res = await rvs.call("llm_cluster_request", "llm_cluster_response",
{k: v for k, v in payload.items() if v is not None}, REQUEST_TIMEOUT_S)
except asyncio.TimeoutError:
return await reply(writer, 504, oai_error(
"Der Cluster hat nicht rechtzeitig geantwortet.", "timeout", "server_error"))
except Exception as e:
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
if not res.get("ok"):
code = res.get("errorCode") or "model_error"
status = {"bad_request": 400, "model_unavailable": 404, "timeout": 504}.get(code, 502)
return await reply(writer, status, oai_error(
res.get("error") or "Fehler", "model_not_found" if code == "model_unavailable" else code,
"invalid_request_error" if status < 500 else "server_error"))
return await completion(writer, req, res)
return await reply(writer, 404, oai_error(f"{method} {path} gibt es nicht", "not_found"))
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError):
pass
except Exception:
logger.exception("HTTP-Fehler")
try:
await reply(writer, 502, oai_error("interner Fehler im Wrapper", "internal", "server_error"))
except Exception:
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
def _unreachable(e: Exception) -> str:
if not rvs.connected:
return f"Keine Verbindung zum RVS ({RVS_HOST}:{RVS_PORT}) — RVS_HOST/RVS_TOKEN in der .env pruefen."
if isinstance(e, asyncio.TimeoutError):
return "RVS verbunden, aber die ARIA-Bridge antwortet nicht (laeuft ARIA? stimmt das RVS_TOKEN?)."
return str(e)
async def completion(writer, req: dict, res: dict) -> None:
cid = "chatcmpl-" + uuid.uuid4().hex[:24]
created = int(time.time())
model = res.get("model") or req.get("model") or ""
content = res.get("content") or ""
tcs = res.get("tool_calls") or None
finish = "tool_calls" if tcs else "stop"
usage = res.get("usage") or {}
extra = {"X-Aria-Node": str(res.get("node", "")), "X-Aria-Gpu": str(res.get("gpu", "")),
"X-Aria-Attempts": str(res.get("attempts", 1))}
if not req.get("stream"):
msg = {"role": "assistant", "content": content if (content or not tcs) else None}
if tcs:
msg["tool_calls"] = tcs
return await reply(writer, 200, {
"id": cid, "object": "chat.completion", "created": created, "model": model,
"choices": [{"index": 0, "message": msg, "finish_reason": finish}], "usage": usage}, headers=extra)
# stream=true: die Boxen liefern (noch) am Stueck — die fertige Antwort geht
# im Stream-Format in einem Rutsch raus. Funktioniert mit jedem Client.
head = ("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\n"
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n"
+ "".join(f"{k}: {v}\r\n" for k, v in extra.items()) + "\r\n")
writer.write(head.encode("utf-8"))
def chunk(delta: dict, fin=None) -> bytes:
return ("data: " + json.dumps({"id": cid, "object": "chat.completion.chunk", "created": created,
"model": model, "choices": [{"index": 0, "delta": delta,
"finish_reason": fin}]},
ensure_ascii=False) + "\n\n").encode("utf-8")
delta = {"role": "assistant", "content": content}
if tcs:
delta["tool_calls"] = [dict(tc, index=i) for i, tc in enumerate(tcs)]
writer.write(chunk(delta))
writer.write(chunk({}, finish))
if usage and (req.get("stream_options") or {}).get("include_usage"):
writer.write(("data: " + json.dumps({"id": cid, "object": "chat.completion.chunk", "created": created,
"model": model, "choices": [], "usage": usage}) + "\n\n").encode())
writer.write(b"data: [DONE]\n\n")
await writer.drain()
HELP_PAGE = """<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ARIA LLM-Cluster</title>
<style>
body{margin:0;background:#0A0A14;color:#E0E0F0;font:14px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:760px;margin:0 auto;padding:24px 16px}
h1{font-size:20px;margin:0 0 4px} .sub{color:#8888AA;margin:0 0 20px}
.card{background:#12121E;border:1px solid #2A2A3E;border-radius:10px;padding:14px 16px;margin-bottom:14px}
.k{color:#8888AA;font-size:12px} code{background:#0A0A14;border:1px solid #2A2A3E;border-radius:5px;padding:2px 6px}
table{width:100%;border-collapse:collapse} td,th{padding:7px 6px;text-align:left;border-bottom:1px solid #1E1E2E}
th{color:#8888AA;font-weight:500;font-size:12px} button{background:#1E1E2E;color:#E0E0F0;border:1px solid #333;
border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px} button:hover{border-color:#0096FF}
.ok{color:#34C759}.err{color:#FF6B6B}.mute{color:#555570}
</style></head><body><main>
<h1>ARIA LLM-Cluster</h1>
<p class="sub">OpenAI-Endpunkt für Programme auf diesem Rechner — dahinter alle Modelle, die auf den KI-Boxen geladen sind.</p>
<div class="card"><div class="k">Status</div><div id="status" class="mute">prüfe …</div></div>
<div class="card">
<div class="k">Im Programm eintragen (OpenAI-kompatibel)</div>
<p style="margin:6px 0">Base URL: <code id="base"></code> <button onclick="copy(document.getElementById('base').textContent)">kopieren</button></p>
<p style="margin:6px 0">API-Key: <span id="keyinfo" class="mute"></span></p>
<p style="margin:6px 0" class="k">Läuft das Programm selbst in Docker, statt 127.0.0.1 den Namen
<code>openai-rvs-wrapper</code> nehmen und das Netz <code>aria-llm</code> anhängen (siehe README).</p>
</div>
<div class="card"><div class="k">Modelle im Cluster — Name genau so eintragen</div>
<table><thead><tr><th>Modell</th><th>Kontext</th><th>Karten</th><th></th></tr></thead><tbody id="models">
<tr><td colspan="4" class="mute">lade …</td></tr></tbody></table></div>
<p class="mute" style="font-size:12px">Auf welcher Box und Karte ein Auftrag läuft, entscheidet der Cluster — bei
Last verteilt, bei Ausfall einer Box übernimmt eine andere mit demselben Modell.</p>
</main><script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function copy(t){navigator.clipboard&&navigator.clipboard.writeText(t)}
document.getElementById('base').textContent=location.origin+'/v1';
async function load(){
try{const h=await (await fetch('/health')).json();
document.getElementById('status').innerHTML=h.bridge?'<span class="ok">✓ verbunden mit dem Cluster</span> <span class="mute">('+esc(h.rvsUrl)+')</span>'
:(h.rvs?'<span class="err">RVS verbunden, aber die ARIA-Bridge antwortet nicht</span>':'<span class="err">keine Verbindung zum RVS — .env prüfen</span>');
document.getElementById('keyinfo').innerHTML=h.apiKey?'der WRAPPER_API_KEY aus der .env':'beliebig (kein Key gesetzt — der Wrapper ist nur lokal erreichbar)';
}catch(e){document.getElementById('status').innerHTML='<span class="err">Wrapper antwortet nicht</span>'}
try{const d=await (await fetch('/api/models')).json();const tb=document.getElementById('models');
if(!d.ok){tb.innerHTML='<tr><td colspan="4" class="err">'+esc(d.error)+'</td></tr>';return}
if(!d.models.length){tb.innerHTML='<tr><td colspan="4" class="mute">Kein Modell geladen — in der ARIA-Diagnostic unter Einstellungen → LLM-Karten.</td></tr>';return}
tb.innerHTML=d.models.map(m=>'<tr><td><code>'+esc(m.model)+'</code></td><td>'+(m.ctx?esc(m.ctx)+' Tokens':'<span class="mute">?</span>')
+'</td><td>'+esc(m.available)+'</td><td><button onclick="copy(\\''+esc(m.model)+'\\')">kopieren</button></td></tr>').join('');
}catch(e){}
}
load();setInterval(load,10000);
</script></body></html>
"""
async def main() -> None:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN muessen in der .env stehen — Abbruch")
return
asyncio.create_task(rvs.run())
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
logger.info("OpenAI-Endpunkt auf :%d (%s) — Hilfsseite: http://127.0.0.1:%d/", PORT,
"mit API-Key" if API_KEY else "ohne API-Key", PORT)
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())