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:
@@ -73,3 +73,10 @@ GITEA_USER=duffyduck
|
||||
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
|
||||
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
|
||||
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
|
||||
|
||||
@@ -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/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`
|
||||
- **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 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)
|
||||
|
||||
@@ -21,5 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY aria_bridge.py .
|
||||
COPY modes.py .
|
||||
COPY llm_cluster.py .
|
||||
|
||||
CMD ["python", "aria_bridge.py"]
|
||||
|
||||
+29
-59
@@ -41,6 +41,7 @@ import websockets
|
||||
from faster_whisper import WhisperModel
|
||||
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
|
||||
|
||||
|
||||
@@ -806,6 +807,9 @@ class ARIABridge:
|
||||
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
|
||||
# llm_response.
|
||||
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
|
||||
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
|
||||
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
|
||||
@@ -2453,6 +2457,9 @@ class ARIABridge:
|
||||
|
||||
msg_type = message.get("type", "")
|
||||
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":
|
||||
# Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
|
||||
@@ -4252,65 +4259,28 @@ class ARIABridge:
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return {"ok": False, "error": "messages leer/ungueltig"}
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending_llm[request_id] = future
|
||||
try:
|
||||
req_payload = {
|
||||
"requestId": request_id,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if stop:
|
||||
req_payload["stop"] = stop
|
||||
if tools:
|
||||
req_payload["tools"] = tools
|
||||
if model:
|
||||
req_payload["model"] = model
|
||||
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
|
||||
# das gewaehlte Modell fahren kann; None → Broadcast wie bisher.
|
||||
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt).
|
||||
llm_target = self._pick_worker("llm", model=model or None)
|
||||
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)
|
||||
# Derselbe Router wie fuer Fremdprogramme: Box nach Last waehlen, bei
|
||||
# Ausfall einmal anderswo versuchen, ehrliche Absage, wenn das Modell
|
||||
# nirgends geladen ist (statt Broadcast + Timeout).
|
||||
res = await self._llm_cluster.route({
|
||||
"model": model or "", "messages": messages, "max_tokens": max_tokens,
|
||||
"temperature": temperature, "stop": stop, "tools": tools,
|
||||
}, timeout=self._LLM_TIMEOUT_S)
|
||||
if not res.get("ok"):
|
||||
err = res.get("error") or "llm-adapter Fehler"
|
||||
if res.get("errorCode") == "model_unavailable":
|
||||
err += " Laden: Diagnostic → Einstellungen → LLM-Karten."
|
||||
return {"ok": False, "error": err, "errorCode": res.get("errorCode")}
|
||||
logger.info("[cluster] ARIA ← %s (Karte %s, %s ms, Versuche %s)",
|
||||
res.get("node") or res.get("instanceId"), res.get("gpu", "?"),
|
||||
res.get("elapsedMs"), res.get("attempts"))
|
||||
return {
|
||||
"ok": True,
|
||||
"content": res.get("content", ""),
|
||||
"tool_calls": res.get("tool_calls"),
|
||||
"model": res.get("model"),
|
||||
"elapsedMs": res.get("elapsedMs"),
|
||||
}
|
||||
|
||||
async def _flux_generate(self, prompt: str, width: int, height: int,
|
||||
steps: Optional[int], guidance: Optional[float],
|
||||
|
||||
@@ -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)})
|
||||
@@ -136,6 +136,7 @@ services:
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN:-}
|
||||
- CLUSTER_LLM_TIMEOUT_S=${CLUSTER_LLM_TIMEOUT_S:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
|
||||
|
||||
+236
-175
@@ -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
|
||||
KI-Boxen von ARIA nutzt: über den RVS (WebSocket) oder direkt über den
|
||||
OpenAI-kompatiblen Port einer Box.
|
||||
Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
hält **genau ein Modell, dauerhaft geladen**.
|
||||
- Welches Modell auf welcher Karte liegt, legt ausschließlich der Betreiber in
|
||||
der ARIA-Diagnostic fest (Modell-Katalog → Laden → Karte).
|
||||
- Ein Client — ARIA selbst, ein Drittprogramm, OpenHands — sieht nur die
|
||||
**geladenen** Modelle aller Boxen und schickt seine Anfrage an eines davon.
|
||||
- Eine Anfrage für ein Modell, das nicht geladen ist, wird **abgelehnt**. Sie
|
||||
lädt nichts nach und verdrängt nichts. Kein Client kann einem anderen das
|
||||
Modell wegnehmen.
|
||||
- Mehrere Clients dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
|
||||
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer, die
|
||||
Karte schafft in Summe aber mehr.
|
||||
- Welches Modell wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
|
||||
(Einstellungen → LLM-Karten).
|
||||
- Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
|
||||
**einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
|
||||
- Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
|
||||
Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
|
||||
demselben Modell. Davon merkt das Programm nichts.
|
||||
- Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
|
||||
Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
|
||||
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
|
||||
# Beispiel: Karte 0 für Spracherkennung + Sprachausgabe, Karten 1 und 2 fürs LLM
|
||||
WHISPER_GPU=0
|
||||
F5TTS_GPU=0
|
||||
LLM_GPUS=1,2
|
||||
```
|
||||
|
||||
## 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) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
|
||||
|---|---|---|---|
|
||||
| Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
|
||||
| Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
|
||||
| Verteilung + Ausfall-Ersatz | ja | ja | nein |
|
||||
| Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
|
||||
|
||||
---
|
||||
|
||||
## A) Über den RVS
|
||||
## A) Cluster über RVS — empfohlen
|
||||
|
||||
### Verbinden
|
||||
|
||||
@@ -49,13 +45,19 @@ LLM_GPUS=1,2
|
||||
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
|
||||
```
|
||||
|
||||
- Das **Token ist der Raum**. Es ist dasselbe Token, das ARIA, die Diagnostic
|
||||
und die KI-Boxen nutzen (`RVS_TOKEN`). Wer das Token hat, ist im Raum.
|
||||
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an *alle anderen*
|
||||
Teilnehmer des Raums. Du siehst also auch Antworten, die für andere Clients
|
||||
bestimmt sind — ordne Antworten immer über die `requestId` zu.
|
||||
- Der RVS lässt nur bekannte Nachrichtentypen durch. Unbekannte Typen
|
||||
verschwinden **ohne Fehlermeldung**. Die hier beschriebenen sind freigegeben.
|
||||
- Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
|
||||
KI-Boxen. Wer es hat, ist im Raum.
|
||||
- Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
|
||||
Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
|
||||
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
|
||||
Du siehst also auch Antworten für andere — ordne sie immer über die
|
||||
`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:
|
||||
|
||||
@@ -63,25 +65,33 @@ Jede Nachricht hat diese Hülle:
|
||||
{ "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
|
||||
{ "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
|
||||
ein paar Sekunden lang ein (2–3 s reichen):
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_models",
|
||||
"type": "llm_cluster_models",
|
||||
"payload": {
|
||||
"requestId": "abc-1",
|
||||
"instanceId": "llm@ki-box",
|
||||
"node": "ki-box",
|
||||
"requestId": "a1",
|
||||
"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 |
|
||||
|---|---|
|
||||
| `instanceId` | Adresse der Box — brauchst du gleich als `targetInstance` |
|
||||
| `node` | lesbarer Name der Box |
|
||||
| `models[].model` | Modellname, so in der Anfrage angeben |
|
||||
| `models[].gpu` | Karte, auf der es läuft |
|
||||
| `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 |
|
||||
| `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
|
||||
| `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. |
|
||||
| `available` | auf wie vielen Karten es gerade liegt |
|
||||
| `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
|
||||
|
||||
Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst
|
||||
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`
|
||||
### 2. Fragen — `llm_cluster_request`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_request",
|
||||
"type": "llm_cluster_request",
|
||||
"payload": {
|
||||
"requestId": "abc-2",
|
||||
"targetInstance": "llm@ki-box",
|
||||
"requestId": "a2",
|
||||
"model": "qwen3-8b",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Du antwortest kurz." },
|
||||
@@ -125,172 +124,234 @@ bleibt, kann die Liste daraus aktuell halten.
|
||||
|
||||
| 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`) |
|
||||
| `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 |
|
||||
| `temperature` | nein | Standard 0.7 |
|
||||
| `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
|
||||
{
|
||||
"type": "llm_response",
|
||||
"type": "llm_cluster_response",
|
||||
"payload": {
|
||||
"requestId": "abc-2",
|
||||
"requestId": "a2",
|
||||
"ok": true,
|
||||
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
||||
"tool_calls": null,
|
||||
"model": "qwen3-8b",
|
||||
"instanceId": "llm@ki-box",
|
||||
"node": "ki-box",
|
||||
"gpu": "1",
|
||||
"node": "werkstatt",
|
||||
"gpu": "0",
|
||||
"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
|
||||
`errorCode` (für Programme):
|
||||
`tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
|
||||
`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 |
|
||||
|---|---|---|
|
||||
| `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`). |
|
||||
| `no_model_loaded` | Auf dieser Box ist keine LLM-Karte belegt. | Andere Box wählen. |
|
||||
| *(fehlt)* | Fehler des Modells selbst, z.B. Kontext zu lang | `error` lesen |
|
||||
| `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
|
||||
| `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
|
||||
| `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`
|
||||
|
||||
Ohne `targetInstance` geht die Anfrage an alle Boxen. Antworten werden nur die,
|
||||
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.
|
||||
**Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
|
||||
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
|
||||
Verlauf.
|
||||
|
||||
### Beispiel (Python)
|
||||
|
||||
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Genau so gegen einen
|
||||
echten RVS, llm-adapter und llama-swap getestet — nur die Adresse war eine andere.
|
||||
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
|
||||
pro Verbindung, mehrere Anfragen gleichzeitig möglich.
|
||||
|
||||
```python
|
||||
import asyncio, json, uuid
|
||||
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):
|
||||
"""Alle geladenen Modelle aller Boxen: [(instanceId, node, model, gpu)]."""
|
||||
rid = str(uuid.uuid4())
|
||||
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
|
||||
class AriaCluster:
|
||||
"""Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
|
||||
wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
|
||||
|
||||
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):
|
||||
rid = str(uuid.uuid4())
|
||||
await ws.send(json.dumps({"type": "llm_request", "payload": {
|
||||
"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())
|
||||
async def _read(self):
|
||||
async for raw in self.ws:
|
||||
msg = json.loads(raw)
|
||||
p = msg.get("payload") or {}
|
||||
# Der Raum ist ein Verteiler: nur UNSERE Antwort nehmen.
|
||||
if msg.get("type") == "llm_response" and p.get("requestId") == rid:
|
||||
if not p["ok"]:
|
||||
raise RuntimeError(f"{p.get('errorCode')}: {p.get('error')}")
|
||||
return p["content"]
|
||||
fut = self.wait.get((msg.get("type"), p.get("requestId")))
|
||||
if fut and not fut.done():
|
||||
fut.set_result(p)
|
||||
|
||||
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 with websockets.connect(RVS, max_size=2**24) as ws:
|
||||
available = await models(ws)
|
||||
for iid, node, model, gpu in available:
|
||||
print(f"{node} / Karte {gpu}: {model} ({iid})")
|
||||
if available:
|
||||
iid, node, model, gpu = available[0]
|
||||
print(await ask(ws, iid, model, "Sag Hallo in einem Satz."))
|
||||
cluster = AriaCluster(ws)
|
||||
models = await cluster.models()
|
||||
for m in models:
|
||||
print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
|
||||
if models:
|
||||
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())
|
||||
```
|
||||
|
||||
### 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
|
||||
„OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …).
|
||||
Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
|
||||
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.
|
||||
- `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.
|
||||
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
|
||||
|
||||
```bash
|
||||
curl http://192.168.177.153:8081/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hallo"}]}'
|
||||
cd xtts/openai_rvs_wrapper
|
||||
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
|
||||
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
|
||||
|
||||
- Mehrere Clients können **dasselbe Modell gleichzeitig** nutzen. llama.cpp
|
||||
bedient sie parallel (Continuous Batching); jede Antwort wird etwas
|
||||
langsamer, insgesamt schafft die Karte mehr.
|
||||
- Der Kontext (`-c`, Standard 8192 Tokens) ist bei aktuellen llama.cpp-Versionen
|
||||
ein **gemeinsamer Vorrat** aller gleichzeitigen Anfragen. Schicken mehrere
|
||||
Clients gleichzeitig sehr lange Verläufe, wird es dort eng.
|
||||
- Ältere llama.cpp-Versionen arbeiten Anfragen an dasselbe Modell nacheinander
|
||||
ab statt parallel. Welche Version eine Box fährt, hängt am Image von
|
||||
llama-swap (`docker compose pull llama-swap` holt die aktuelle).
|
||||
- Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
|
||||
Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
|
||||
- Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
|
||||
die Karte mit den wenigsten laufenden.
|
||||
- Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
|
||||
Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
|
||||
Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
|
||||
|
||||
## Wenn sich etwas ändert
|
||||
|
||||
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte sofort
|
||||
weg; laufende Clients bekommen dann `model_not_loaded` und sollten die Liste
|
||||
neu holen.
|
||||
- Beim Laden oder Entladen startet die Box **alle** ihre Modelle kurz neu
|
||||
(wenige Sekunden). Anfragen in diesem Moment warten, bis das Modell wieder
|
||||
bereit ist (`state: "starting"`).
|
||||
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
|
||||
Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
|
||||
`model_unavailable` — dann die Liste neu holen.
|
||||
- Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
|
||||
kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
|
||||
eine andere Box.
|
||||
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
|
||||
dazukommen. Ein Client sollte unbekannte Felder ignorieren.
|
||||
dazukommen. Unbekannte Felder ignorieren.
|
||||
|
||||
@@ -133,6 +133,10 @@ const ALLOWED_TYPES = new Set([
|
||||
// auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models).
|
||||
// Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
|
||||
"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_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
websockets==13.1
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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())
|
||||
Reference in New Issue
Block a user