feat(llm): ein Modell pro Karte, Clients waehlen nur aus — und Doku fuer Drittprogramme
Bisher konnte jeder Client per Modellname llama-swap zum Wechseln bringen — ein Fremdprogramm haette ARIA das Modell unter den Fuessen weggetauscht. Jetzt: - Jede LLM-Karte einer Box (LLM_GPUS=1,2, alt LLM_GPU=1) haelt GENAU ein Modell, dauerhaft. Welches, legt nur die Diagnostic fest. Karten, die nicht fuers LLM vorgesehen sind, tauchen beim LLM nicht auf und werden als Ziel abgelehnt. - Clients (ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port) sehen nur die geladenen Modelle und laden nie: Anfrage fuer ein anderes → model_not_loaded mit Liste des Geladenen (RVS) bzw. HTTP 404 (OpenAI-Port). Nichts wird verdraengt. - Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen. llm-adapter: Belegung in /models/aria_slots.json. Die llama-swap-Config enthaelt nur belegte Karten: pro Modell CUDA_VISIBLE_DEVICES=<Karte>, ttl 0, groups.swap=false, hooks.on_startup.preload. Alles gegen llama-swap ausprobiert (zwei Modelle gleichzeitig ready, keins verdraengt, Unbekanntes 404, env kommt beim jeweiligen llama-server an). llama-swap laeuft mit --watch-config; die frueher aufgerufenen Reload-Endpunkte (/reload, /api/config/reload) gibt es nicht (404). Neu: llm_unload_model (Karte leeren), llm_models_request → llm_models (Erkundung, jede Box antwortet einzeln). Fehlgeschlagenes Laden setzt die Karte auf ihr vorheriges Modell zurueck, sonst risse jedes Neuladen die anderen Karten mit. Uebernahme: ohne Belegungsdatei landet LLM_MODEL auf der ersten LLM-Karte. Compose: llama-swap sieht alle Karten (NVIDIA_VISIBLE_DEVICES=all) und zaehlt sie wie nvidia-smi (CUDA_DEVICE_ORDER=PCI_BUS_ID); das Pinnen pro Modell macht der Adapter. Bridge: uebernimmt models/slots auch aus worker_ping (nach "Entladen" sonst 30 s veraltet) und sagt sofort ab, wenn das Modell auf keiner Box liegt, statt per Broadcast in den Timeout zu laufen. Diagnostic: Flotte zeigt pro LLM-Karte Modell + Zustand + "Entladen"; Katalog waehlt eine Ziel-KARTE (leere vorausgewaehlt, Ersetzen mit Rueckfrage); Download-Fenster nennt Karte und was ersetzt wird. RVS: llm_unload_model, llm_models_request, llm_models freigegeben. docs/LLM-SCHNITTSTELLE.md: Schnittstelle fuer Drittprogramme (RVS + OpenAI- Port), Fehlercodes, Gleichzeitigkeit, Python-Beispiel. Tests: 23 gegen llama-swap (Adapter direkt) + Ende-zu-Ende ueber den echten RVS mit dem Adapter als Prozess: Laden per RVS mit Fortschritt, das Doku-Beispiel unveraendert (nur Adresse) findet beide Karten und bekommt eine Antwort, Absage/Schweigen wie dokumentiert, zwei Clients parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||||
|
- **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)
|
||||||
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
|
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
|
||||||
|
|||||||
@@ -3774,6 +3774,9 @@ class ARIABridge:
|
|||||||
# Fallback auf [model] fuer alte Adapter ohne models-Feld.
|
# Fallback auf [model] fuer alte Adapter ohne models-Feld.
|
||||||
"models": [m for m in _models if m] if isinstance(_models, list)
|
"models": [m for m in _models if m] if isinstance(_models, list)
|
||||||
else ([payload.get("model")] if payload.get("model") else []),
|
else ([payload.get("model")] if payload.get("model") else []),
|
||||||
|
# Karten-Belegung (neuer llm-adapter): dann ist "models" nur
|
||||||
|
# noch das GELADENE — Clients laden nichts nach.
|
||||||
|
**({"slots": payload["slots"]} if isinstance(payload.get("slots"), list) else {}),
|
||||||
"busy": bool(prev.get("busy", False)),
|
"busy": bool(prev.get("busy", False)),
|
||||||
"last_seen": time.time(),
|
"last_seen": time.time(),
|
||||||
}
|
}
|
||||||
@@ -3819,6 +3822,13 @@ class ARIABridge:
|
|||||||
"model": "", "busy": False, "last_seen": 0.0,
|
"model": "", "busy": False, "last_seen": 0.0,
|
||||||
}
|
}
|
||||||
w["busy"] = bool(payload.get("busy", False))
|
w["busy"] = bool(payload.get("busy", False))
|
||||||
|
# LLM-Boxen melden mit jedem Ping, welche Modelle GELADEN sind.
|
||||||
|
# Nach "Karte leeren" in der Diagnostic sonst bis zum naechsten
|
||||||
|
# hello (30 s) veraltet — ARIA wuerde ein entladenes Modell anfragen.
|
||||||
|
if isinstance(payload.get("models"), list):
|
||||||
|
w["models"] = payload["models"]
|
||||||
|
if isinstance(payload.get("slots"), list):
|
||||||
|
w["slots"] = payload["slots"]
|
||||||
w["last_seen"] = time.time()
|
w["last_seen"] = time.time()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -4265,6 +4275,16 @@ class ARIABridge:
|
|||||||
llm_target = self._pick_worker("llm", model=model or None)
|
llm_target = self._pick_worker("llm", model=model or None)
|
||||||
if llm_target:
|
if llm_target:
|
||||||
req_payload["targetInstance"] = 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)",
|
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,
|
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
|
||||||
model or "-", llm_target or "(broadcast)")
|
model or "-", llm_target or "(broadcast)")
|
||||||
@@ -5234,6 +5254,13 @@ class ARIABridge:
|
|||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _llm_boxes_with_slots(self) -> list:
|
||||||
|
"""Online-LLM-Boxen, die schon Karten-Belegung melden (neuer Adapter)."""
|
||||||
|
now = time.time()
|
||||||
|
return [w for w in self._workers.values()
|
||||||
|
if w.get("service") == "llm" and isinstance(w.get("slots"), list)
|
||||||
|
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
|
||||||
|
|
||||||
def _pick_worker(self, service: str, model: Optional[str] = None) -> Optional[str]:
|
def _pick_worker(self, service: str, model: Optional[str] = None) -> Optional[str]:
|
||||||
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
|
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
|
||||||
ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing
|
ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing
|
||||||
|
|||||||
+86
-26
@@ -1873,9 +1873,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
|
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
|
||||||
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
|
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
|
||||||
Pro LLM-Box läuft immer <strong style="color:#8888AA;">ein</strong> Modell auf ihrer LLM-Karte
|
Jede LLM-Karte hält genau <strong style="color:#8888AA;">ein</strong> Modell, dauerhaft. Welche
|
||||||
(<code>LLM_GPU</code> in der .env der Box). Laden eines anderen wechselt dorthin — das vorige
|
Karten dem LLM gehören, steht in der .env der Box (<code>LLM_GPUS</code>). Laden ersetzt, was auf
|
||||||
wird entladen, bleibt aber heruntergeladen.
|
der Karte lag; die anderen Karten der Box starten dabei kurz neu. Clients (ARIA, Fremdprogramme,
|
||||||
|
OpenHands) wählen nur unter den geladenen Modellen und laden nie selbst.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2609,6 +2610,7 @@
|
|||||||
const st = document.getElementById('llm-catalog-status');
|
const st = document.getElementById('llm-catalog-status');
|
||||||
if (st) {
|
if (st) {
|
||||||
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
|
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
|
||||||
|
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
|
||||||
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
|
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
|
||||||
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
|
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
|
||||||
}
|
}
|
||||||
@@ -5156,21 +5158,15 @@
|
|||||||
const meta = WORKER_SVC_META[w.service] || { icon: '⚙️', label: w.service };
|
const meta = WORKER_SVC_META[w.service] || { icon: '⚙️', label: w.service };
|
||||||
const dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F');
|
const dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F');
|
||||||
const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung');
|
const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung');
|
||||||
// llm-Boxen koennen mehrere Modelle FAHREN (llama-swap), aber nur
|
// LLM-Boxen: pro Karte genau ein Modell, dauerhaft geladen. Laden und
|
||||||
// eins liegt gerade in der Karte — das ist gruen markiert. Die
|
// Entladen nur hier (Modell-Katalog), Clients waehlen nur aus.
|
||||||
// anderen werden beim ersten Aufruf geladen (und verdraengen es).
|
|
||||||
let modelHtml;
|
let modelHtml;
|
||||||
if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
|
if (w.service === 'llm' && Array.isArray(w.slots)) {
|
||||||
const loaded = Array.isArray(w.loaded) ? w.loaded : null;
|
modelHtml = ''; // Karten folgen als eigene Zeilen unten
|
||||||
const isLoaded = id => loaded && loaded.some(l => l.model === id);
|
} else if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
|
||||||
const state = id => (loaded.find(l => l.model === id) || {}).state;
|
// Box mit altem Adapter (ohne Karten): nur die Liste
|
||||||
modelHtml = w.models.map(id => isLoaded(id)
|
modelHtml = '<span style="color:#8888AA;" title="Box meldet noch keine Karten — llm-adapter aktualisieren">'
|
||||||
? '<span style="color:#3FFF3F;font-weight:600;" title="liegt gerade in der Karte">' + escapeHtml(id)
|
+ escapeHtml(w.models.join(', ')) + '</span>';
|
||||||
+ (state(id) && state(id) !== 'ready' ? ' (' + escapeHtml(state(id)) + ' …)' : ' ● geladen') + '</span>'
|
|
||||||
: '<span style="color:#8888AA;" title="lädt beim ersten Aufruf und verdrängt das geladene">' + escapeHtml(id) + '</span>'
|
|
||||||
).join('<span style="color:#555570;">, </span>');
|
|
||||||
if (loaded && !loaded.length) modelHtml += ' <span style="color:#6a6a88;">— keins geladen (lädt beim ersten Aufruf)</span>';
|
|
||||||
else if (!loaded && w.online) modelHtml += ' <span style="color:#6a6a88;" title="Box meldet es nicht — Adapter noch alt oder llama-swap nicht erreichbar">— geladen: ?</span>';
|
|
||||||
} else {
|
} else {
|
||||||
modelHtml = '<span style="color:#8888AA;">' + escapeHtml(w.model || '') + '</span>';
|
modelHtml = '<span style="color:#8888AA;">' + escapeHtml(w.model || '') + '</span>';
|
||||||
}
|
}
|
||||||
@@ -5178,9 +5174,10 @@
|
|||||||
'<span style="width:8px;height:8px;border-radius:50%;background:' + dot + ';display:inline-block;"></span>' +
|
'<span style="width:8px;height:8px;border-radius:50%;background:' + dot + ';display:inline-block;"></span>' +
|
||||||
'<span>' + meta.icon + ' <b>' + escapeHtml(meta.label) + '</b></span>' +
|
'<span>' + meta.icon + ' <b>' + escapeHtml(meta.label) + '</b></span>' +
|
||||||
'<span>' + modelHtml + '</span>' +
|
'<span>' + modelHtml + '</span>' +
|
||||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
(w.gpus && !(w.service === 'llm' && Array.isArray(w.slots)) ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
(w.service === 'llm' && Array.isArray(w.slots) ? renderLlmSlots(w) : '') +
|
||||||
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
||||||
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -8869,7 +8866,9 @@
|
|||||||
const order = Array.from(ids).sort();
|
const order = Array.from(ids).sort();
|
||||||
sel.innerHTML = order.map(id => {
|
sel.innerHTML = order.map(id => {
|
||||||
const n = counts[id] || 0;
|
const n = counts[id] || 0;
|
||||||
const avail = n > 0 ? ` · ${n} Box${n > 1 ? 'en' : ''}` : ' · offline';
|
// Nur geladene Modelle zaehlen (Karten-Belegung) — alles andere kann
|
||||||
|
// ARIA nicht nutzen; laden geht im Modell-Katalog.
|
||||||
|
const avail = n > 0 ? ` · geladen auf ${n} Box${n > 1 ? 'en' : ''}` : ' · nicht geladen';
|
||||||
return `<option value="${id}">${escapeHtml(nameOf(id))}${avail}</option>`;
|
return `<option value="${id}">${escapeHtml(nameOf(id))}${avail}</option>`;
|
||||||
}).join('') || '<option value="">(keine)</option>';
|
}).join('') || '<option value="">(keine)</option>';
|
||||||
if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel;
|
if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel;
|
||||||
@@ -8966,11 +8965,18 @@
|
|||||||
const td = 'style="padding:6px 8px;vertical-align:top;"';
|
const td = 'style="padding:6px 8px;vertical-align:top;"';
|
||||||
const rows = _llmCatalog.map((m, i) => {
|
const rows = _llmCatalog.map((m, i) => {
|
||||||
const have = boxesServing(m.id);
|
const have = boxesServing(m.id);
|
||||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(b => escapeHtml(b.node)).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
const where = b => {
|
||||||
|
const sl = (b.slots || []).find(x => x.model === m.id);
|
||||||
|
return escapeHtml(b.node) + (sl ? ' · Karte ' + escapeHtml(sl.gpu) : '');
|
||||||
|
};
|
||||||
|
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||||
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
|
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
|
||||||
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
|
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
|
||||||
const opts = boxes.length
|
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
|
||||||
? boxes.map(b => `<option value="${escapeHtml(b.instanceId)}">${escapeHtml(b.node)}</option>`).join('')
|
const targets = llmCardTargets();
|
||||||
|
const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
|
||||||
|
const opts = targets.length
|
||||||
|
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
|
||||||
: '<option value="">(keine Box online)</option>';
|
: '<option value="">(keine Box online)</option>';
|
||||||
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
|
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
|
||||||
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
|
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
|
||||||
@@ -8984,7 +8990,7 @@
|
|||||||
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
|
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
|
||||||
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
|
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
|
||||||
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
|
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
|
||||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
|
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
|
||||||
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||||
}
|
}
|
||||||
// ── Download-Fenster ──────────────────────────────────────────────
|
// ── Download-Fenster ──────────────────────────────────────────────
|
||||||
@@ -9029,7 +9035,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
box.innerHTML = jobs.map(j => {
|
box.innerHTML = jobs.map(j => {
|
||||||
const box_ = escapeHtml(_boxName(j.instanceId));
|
const box_ = escapeHtml(_boxName(j.instanceId)) + (j.gpu != null && j.gpu !== '' ? ' · Karte ' + escapeHtml(String(j.gpu)) : '')
|
||||||
|
+ (j.replaces ? ' <span style="color:#6a6a88;">(ersetzt ' + escapeHtml(j.replaces) + ')</span>' : '');
|
||||||
let pct = 0, color = '#0096FF', line = '', sub = '';
|
let pct = 0, color = '#0096FF', line = '', sub = '';
|
||||||
if (j.phase === 'queued') {
|
if (j.phase === 'queued') {
|
||||||
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
|
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
|
||||||
@@ -9086,15 +9093,68 @@
|
|||||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// ── LLM-Karten ────────────────────────────────────────────────────
|
||||||
|
const SLOT_STATE = {
|
||||||
|
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
|
||||||
|
stopping: ['#FF9F0A', 'stoppt …'], stopped: ['#FF6B6B', '✗ nicht gestartet'],
|
||||||
|
unknown: ['#8888AA', '? (llama-swap antwortet nicht)'],
|
||||||
|
};
|
||||||
|
function renderLlmSlots(w) {
|
||||||
|
const rows = (w.slots || []).map(sl => {
|
||||||
|
const card = 'Karte ' + escapeHtml(sl.gpu) + (sl.gpuName ? ' <span style="color:#555570;">(' + escapeHtml(sl.gpuName) + ')</span>' : '');
|
||||||
|
if (!sl.model) {
|
||||||
|
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||||
|
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||||
|
<span style="color:#555570;">— leer · im Modell-Katalog „Laden" → diese Karte</span></div>`;
|
||||||
|
}
|
||||||
|
const [c, label] = SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')];
|
||||||
|
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||||
|
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||||
|
<b style="color:#E0E0F0;">${escapeHtml(sl.model)}</b>
|
||||||
|
<span style="color:${c};">${label}</span>
|
||||||
|
<button class="btn secondary" onclick="unloadLlmCard('${escapeHtmlAttr(w.instanceId)}','${escapeHtmlAttr(sl.gpu)}','${escapeHtmlAttr(sl.model)}')"
|
||||||
|
title="Karte leeren — das Modell bleibt auf der Box gespeichert" style="margin-left:auto;padding:2px 8px;font-size:10px;">Entladen</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div style="margin:2px 0 6px 18px;padding:6px 10px;background:#0D0D18;border-left:2px solid #2A2A3E;font-size:11px;">${rows || '<span style="color:#555570;">Keine LLM-Karte konfiguriert (LLM_GPUS in der .env der Box).</span>'}</div>`;
|
||||||
|
}
|
||||||
|
function unloadLlmCard(instanceId, gpu, model) {
|
||||||
|
if (!confirm(`Karte ${gpu} leeren?\n\n${model} wird entladen; Clients können es danach nicht mehr wählen. `
|
||||||
|
+ `Die Datei bleibt auf der Box — erneutes Laden geht ohne Download.\n\n`
|
||||||
|
+ `Die anderen Karten dieser Box starten dabei kurz neu (wenige Sekunden).`)) return;
|
||||||
|
send({ action: 'llm_unload_model', targetInstance: instanceId, gpu });
|
||||||
|
}
|
||||||
|
// Ziele im Katalog: jede LLM-Karte jeder Box ("instanceId|karte").
|
||||||
|
function llmCardTargets() {
|
||||||
|
const out = [];
|
||||||
|
for (const b of onlineLlmBoxes()) {
|
||||||
|
if (Array.isArray(b.slots) && b.slots.length) {
|
||||||
|
for (const sl of b.slots) out.push({ value: b.instanceId + '|' + sl.gpu, box: b, slot: sl,
|
||||||
|
label: `${b.node} · Karte ${sl.gpu}` + (sl.model ? ` (${sl.model})` : ' (leer)') });
|
||||||
|
} else {
|
||||||
|
out.push({ value: b.instanceId + '|', box: b, slot: null, label: b.node }); // alter Adapter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function provisionModel(i) {
|
function provisionModel(i) {
|
||||||
const m = _llmCatalog[i];
|
const m = _llmCatalog[i];
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
const sel = document.getElementById('llm-cat-box-' + i);
|
const sel = document.getElementById('llm-cat-box-' + i);
|
||||||
const target = sel && sel.value;
|
const [target, gpu] = String((sel && sel.value) || '').split('|');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
const tgt = llmCardTargets().find(t => t.value === sel.value);
|
||||||
|
if (tgt && tgt.slot && tgt.slot.model === m.id) { alert(`${m.id} liegt schon auf dieser Karte.`); return; }
|
||||||
|
const busyBox = ((tgt && tgt.box.slots) || []).find(sl => sl.model === m.id);
|
||||||
|
if (busyBox) { alert(`${m.id} liegt auf dieser Box schon auf Karte ${busyBox.gpu}. Ein Modell pro Box nur einmal.`); return; }
|
||||||
|
if (tgt && tgt.slot && tgt.slot.model &&
|
||||||
|
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
|
||||||
|
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
|
||||||
|
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
|
||||||
const st = document.getElementById('llm-catalog-status');
|
const st = document.getElementById('llm-catalog-status');
|
||||||
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
|
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
|
||||||
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||||
openLlmDownloads();
|
openLlmDownloads();
|
||||||
}
|
}
|
||||||
function runLlmTest() {
|
function runLlmTest() {
|
||||||
|
|||||||
+16
-5
@@ -1260,9 +1260,11 @@ function connectRVS(forcePlain) {
|
|||||||
instanceId: p.instanceId, service: p.service || "",
|
instanceId: p.instanceId, service: p.service || "",
|
||||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||||
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||||
// Was GERADE in der Karte liegt (nur llm, aus llama-swap /running).
|
// Nur llm: pro LLM-Karte das Modell + Zustand. "models" ist dann nur
|
||||||
// null = Box konnte llama-swap nicht fragen, [] = nichts geladen.
|
// noch die Auswahl der GELADENEN Modelle; "knownModels" das, was man
|
||||||
loaded: Array.isArray(p.loaded) ? p.loaded : (prev.loaded ?? null),
|
// in eine Karte laden koennte.
|
||||||
|
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
|
||||||
|
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
|
||||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||||
@@ -1285,7 +1287,10 @@ function connectRVS(forcePlain) {
|
|||||||
workers.set(p.instanceId, w);
|
workers.set(p.instanceId, w);
|
||||||
}
|
}
|
||||||
w.busy = !!p.busy;
|
w.busy = !!p.busy;
|
||||||
if ("loaded" in p) w.loaded = Array.isArray(p.loaded) ? p.loaded : null;
|
// LLM-Boxen schicken mit jedem Ping die Karten-Belegung — Laden,
|
||||||
|
// Entladen und Neustarts sind so nach spaetestens 10 s sichtbar.
|
||||||
|
if (Array.isArray(p.slots)) w.slots = p.slots;
|
||||||
|
if (Array.isArray(p.models)) w.models = p.models;
|
||||||
w.last_seen = Date.now();
|
w.last_seen = Date.now();
|
||||||
broadcastWorkers();
|
broadcastWorkers();
|
||||||
}
|
}
|
||||||
@@ -1377,7 +1382,7 @@ function connectRVS(forcePlain) {
|
|||||||
} else if (msg.type === "llm_provision_result") {
|
} else if (msg.type === "llm_provision_result") {
|
||||||
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
if (p.key && p.instanceId && !p.removed) {
|
if (p.key && p.instanceId && !p.removed && !p.unloaded) {
|
||||||
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
|
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
|
||||||
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
||||||
}
|
}
|
||||||
@@ -3371,8 +3376,14 @@ wss.on("connection", (ws) => {
|
|||||||
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
||||||
targetInstance: msg.targetInstance || "",
|
targetInstance: msg.targetInstance || "",
|
||||||
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
||||||
|
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||||||
}, timestamp: Date.now() });
|
}, timestamp: Date.now() });
|
||||||
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||||||
|
} else if (msg.action === "llm_unload_model") {
|
||||||
|
// LLM-Karte leeren (das GGUF bleibt auf der Box).
|
||||||
|
sendToRVS_raw({ type: "llm_unload_model", payload: {
|
||||||
|
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
|
||||||
|
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
|
||||||
} else if (msg.action === "llm_remove_model") {
|
} else if (msg.action === "llm_remove_model") {
|
||||||
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||||||
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# Lokale Sprachmodelle nutzen — Schnittstelle für Drittprogramme
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Die Grundregel
|
||||||
|
|
||||||
|
**Clients wählen 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.
|
||||||
|
|
||||||
|
Welche Karten einer Box dem LLM gehören, steht in deren `.env`:
|
||||||
|
|
||||||
|
```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) Über den RVS
|
||||||
|
|
||||||
|
### Verbinden
|
||||||
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
Jede Nachricht hat diese Hülle:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Welche Modelle gibt es? — `llm_models_request`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "llm_models_request", "payload": { "requestId": "abc-1" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jede** Box im Raum antwortet einzeln mit `llm_models`. Sammle die Antworten
|
||||||
|
ein paar Sekunden lang ein (2–3 s reichen):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "llm_models",
|
||||||
|
"payload": {
|
||||||
|
"requestId": "abc-1",
|
||||||
|
"instanceId": "llm@ki-box",
|
||||||
|
"node": "ki-box",
|
||||||
|
"models": [
|
||||||
|
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
|
||||||
|
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 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[].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
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"type": "llm_request",
|
||||||
|
"payload": {
|
||||||
|
"requestId": "abc-2",
|
||||||
|
"targetInstance": "llm@ki-box",
|
||||||
|
"model": "qwen3-8b",
|
||||||
|
"messages": [
|
||||||
|
{ "role": "system", "content": "Du antwortest kurz." },
|
||||||
|
{ "role": "user", "content": "Wie hoch ist die Zugspitze?" }
|
||||||
|
],
|
||||||
|
"max_tokens": 256,
|
||||||
|
"temperature": 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feld | Pflicht | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) — damit findest du deine Antwort |
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
### 3. Antwort — `llm_response`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "llm_response",
|
||||||
|
"payload": {
|
||||||
|
"requestId": "abc-2",
|
||||||
|
"ok": true,
|
||||||
|
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
||||||
|
"tool_calls": null,
|
||||||
|
"model": "qwen3-8b",
|
||||||
|
"instanceId": "llm@ki-box",
|
||||||
|
"node": "ki-box",
|
||||||
|
"gpu": "1",
|
||||||
|
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
|
||||||
|
"elapsedMs": 640
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Fehlerfall ist `ok` `false`, dazu kommen `error` (Text für Menschen) und
|
||||||
|
`errorCode` (für Programme):
|
||||||
|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio, json, uuid
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
RVS = "wss://rvs.example.org:443/?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
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
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."))
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B) Über den OpenAI-Port einer Box
|
||||||
|
|
||||||
|
Jede KI-Box bietet einen OpenAI-kompatiblen Endpunkt — für Programme, die nur
|
||||||
|
„OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …).
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<IP der Box>:<LLM_OPENAI_PORT>/v1 (Standard-Port 8081)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
```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"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## 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"`).
|
||||||
|
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
|
||||||
|
dazukommen. Ein Client sollte unbekannte Felder ignorieren.
|
||||||
@@ -129,6 +129,10 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
||||||
"llm_provision_model", "llm_provision_result", "llm_provision_progress",
|
"llm_provision_model", "llm_provision_result", "llm_provision_progress",
|
||||||
"llm_remove_model",
|
"llm_remove_model",
|
||||||
|
// Karten: Diagnostic leert eine LLM-Karte; Clients fragen, welche Modelle
|
||||||
|
// 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",
|
||||||
"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",
|
||||||
|
|||||||
+8
-1
@@ -37,7 +37,14 @@ NODE_NAME=ai-box
|
|||||||
WHISPER_GPU=0 # kleines STT → passt neben F5-TTS auf EINE Karte
|
WHISPER_GPU=0 # kleines STT → passt neben F5-TTS auf EINE Karte
|
||||||
F5TTS_GPU=0 # TTS ist klein → gleiche Karte wie Whisper
|
F5TTS_GPU=0 # TTS ist klein → gleiche Karte wie Whisper
|
||||||
VOXTRAL_GPU=1 # STT-3B ~9 GB → nur auf einer groesseren Karte (z.B. 12 GB)
|
VOXTRAL_GPU=1 # STT-3B ~9 GB → nur auf einer groesseren Karte (z.B. 12 GB)
|
||||||
LLM_GPU=0 # lokales LLM (teilt sich ggf. die Karte mit F5-TTS)
|
LLM_GPU=0 # lokales LLM auf EINER Karte (teilt sich ggf. die Karte mit F5-TTS)
|
||||||
|
# Mehrere Karten fuers LLM: Liste statt LLM_GPU. Jede Karte haelt genau EIN
|
||||||
|
# Modell, dauerhaft geladen — welches, legst du in der Diagnostic fest
|
||||||
|
# (Modell-Katalog → Laden → Karte). Clients (ARIA, Fremdprogramme, OpenHands)
|
||||||
|
# koennen nur die geladenen nutzen und nie selbst etwas laden.
|
||||||
|
# Beispiel 3 Karten: 0 = Whisper + F5-TTS, 1 und 2 nur LLM:
|
||||||
|
# WHISPER_GPU=0 F5TTS_GPU=0 LLM_GPUS=1,2
|
||||||
|
#LLM_GPUS=
|
||||||
|
|
||||||
# ─── RVS-Verbindung ───────────────────────────────────────
|
# ─── RVS-Verbindung ───────────────────────────────────────
|
||||||
# WICHTIG: Host, Port UND Token muessen EXAKT mit dem ARIA-Stack (Bridge/
|
# WICHTIG: Host, Port UND Token muessen EXAKT mit dem ARIA-Stack (Bridge/
|
||||||
|
|||||||
+16
-6
@@ -133,12 +133,20 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./models:/models # HF-Cache + generierte Config
|
- ./models:/models # HF-Cache + generierte Config
|
||||||
environment:
|
environment:
|
||||||
- NVIDIA_VISIBLE_DEVICES=${LLM_GPU:-0}
|
# Alle Karten sichtbar — welches Modell auf welcher Karte laeuft, legt der
|
||||||
|
# llm-adapter pro Modell fest (CUDA_VISIBLE_DEVICES=<Karte> in der
|
||||||
|
# generierten Config). Erlaubt sind nur die LLM-Karten aus LLM_GPUS.
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
# CUDA zaehlt Karten sonst "schnellste zuerst" — dann waere CUDA-Karte 0
|
||||||
|
# nicht unbedingt nvidia-smi-Karte 0. PCI-Reihenfolge = wie nvidia-smi.
|
||||||
|
- CUDA_DEVICE_ORDER=PCI_BUS_ID
|
||||||
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
|
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
|
||||||
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
|
# Liest die vom llm-adapter generierte Config. --watch-config: Aenderungen
|
||||||
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
|
# (Modell in Karte geladen/entladen) greifen von selbst — einen Reload-
|
||||||
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
|
# Endpunkt hat llama-swap nicht. Beim allerersten Boot faengt restart:
|
||||||
|
# unless-stopped die Reihenfolge ab, bis der Adapter die Config geschrieben hat.
|
||||||
|
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080", "--watch-config"]
|
||||||
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
|
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
|
||||||
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
|
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
|
||||||
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
|
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
|
||||||
@@ -178,8 +186,10 @@ services:
|
|||||||
- LLM_REGISTRY=/models/aria_models.json
|
- LLM_REGISTRY=/models/aria_models.json
|
||||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||||
# Auf welche Karte llama-swap gepinnt ist — fuer die Meldung „in Karte N
|
# Welche Karten dem LLM gehoeren. Jede haelt genau ein Modell, geladen
|
||||||
# geladen" samt VRAM nach dem Laden. Dieselbe Variable wie bei llama-swap.
|
# wird nur ueber die Diagnostic. Mehrere: LLM_GPUS=1,2 — eine: LLM_GPU=1
|
||||||
|
# (alte Schreibweise, gilt weiter, wenn LLM_GPUS leer ist).
|
||||||
|
- LLM_GPUS=${LLM_GPUS:-}
|
||||||
- LLM_GPU=${LLM_GPU:-0}
|
- LLM_GPU=${LLM_GPU:-0}
|
||||||
# Fuer gesperrte (gated) HF-Repos: die Dateiliste fuer den Fortschritt.
|
# Fuer gesperrte (gated) HF-Repos: die Dateiliste fuer den Fortschritt.
|
||||||
- HF_TOKEN=${HF_TOKEN:-}
|
- HF_TOKEN=${HF_TOKEN:-}
|
||||||
|
|||||||
+352
-132
@@ -96,6 +96,62 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
|
|||||||
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||||
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
||||||
|
|
||||||
|
# ── Karten-Belegung ─────────────────────────────────────────────────
|
||||||
|
# Grundregel: Modelle LADEN nur ueber die Diagnostic, und zwar pro Karte. Jede
|
||||||
|
# LLM-Karte haelt genau ein Modell, dauerhaft (ttl 0, beim Start vorgeladen).
|
||||||
|
# Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port —
|
||||||
|
# sehen nur die geladenen Modelle und waehlen daraus. Eine Anfrage fuer ein
|
||||||
|
# anderes Modell wird abgelehnt und laedt NIE etwas nach. So kann kein Client
|
||||||
|
# einem anderen das Modell unter den Fuessen wegtauschen.
|
||||||
|
#
|
||||||
|
# Welche Karten dem LLM gehoeren, steht in der .env der Box:
|
||||||
|
# LLM_GPUS=0,1 (mehrere) — oder wie bisher — LLM_GPU=0 (eine)
|
||||||
|
# Die Belegung (Karte → Modell) liegt in SLOTS_PATH und wird nur von
|
||||||
|
# llm_provision_model / llm_unload_model geaendert.
|
||||||
|
SLOTS_PATH = os.getenv("LLM_SLOTS", "/models/aria_slots.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_cards() -> list:
|
||||||
|
raw = os.getenv("LLM_GPUS", "").strip() or os.getenv("LLM_GPU", "0").strip() or "0"
|
||||||
|
cards = [c.strip() for c in raw.split(",") if c.strip()]
|
||||||
|
return [c for i, c in enumerate(cards) if c not in cards[:i]] # Reihenfolge, ohne Doppelte
|
||||||
|
|
||||||
|
|
||||||
|
def _load_slots() -> dict:
|
||||||
|
"""{karte: modell}. Fehlt die Datei (erste Version mit Karten), wird das
|
||||||
|
bisherige Standardmodell (LLM_MODEL) auf die erste Karte gelegt — so laeuft
|
||||||
|
eine bestehende Box nach dem Update genau so weiter wie vorher."""
|
||||||
|
try:
|
||||||
|
with open(SLOTS_PATH) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
cards = set(_llm_cards())
|
||||||
|
return {str(k): str(v) for k, v in data.items() if str(k) in cards and v}
|
||||||
|
except FileNotFoundError:
|
||||||
|
cards = _llm_cards()
|
||||||
|
if cards and _model_cmd(LLM_MODEL):
|
||||||
|
slots = {cards[0]: LLM_MODEL}
|
||||||
|
_save_slots(slots)
|
||||||
|
logger.info("Karten-Belegung neu angelegt: Karte %s → %s", cards[0], LLM_MODEL)
|
||||||
|
return slots
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Karten-Belegung nicht lesbar (%s) — keine Modelle", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_slots(slots: dict) -> None:
|
||||||
|
tmp = SLOTS_PATH + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(slots, f, indent=2)
|
||||||
|
os.replace(tmp, SLOTS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _card_of(model: str) -> str:
|
||||||
|
for card, m in _load_slots().items():
|
||||||
|
if m == model:
|
||||||
|
return card
|
||||||
|
return ""
|
||||||
|
|
||||||
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
|
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
|
||||||
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
|
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
|
||||||
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
|
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
|
||||||
@@ -161,69 +217,93 @@ def _save_registry(reg: list) -> None:
|
|||||||
logger.warning("Registry speichern fehlgeschlagen: %s", e)
|
logger.warning("Registry speichern fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def _generate_config() -> int:
|
def _base_config() -> dict:
|
||||||
"""Schreibt die llama-swap-Config aus Basis-Template + Registry. Gibt die
|
|
||||||
Anzahl Modelle zurueck. Idempotent, bei jeder Aenderung + beim Start."""
|
|
||||||
base = {}
|
|
||||||
try:
|
try:
|
||||||
with open(BASE_CONFIG_PATH) as f:
|
with open(BASE_CONFIG_PATH) as f:
|
||||||
base = yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Basis-Template %s nicht lesbar (%s)", BASE_CONFIG_PATH, e)
|
logger.warning("Basis-Template %s nicht lesbar (%s)", BASE_CONFIG_PATH, e)
|
||||||
models = dict(base.get("models") or {})
|
return {}
|
||||||
for e in _load_registry():
|
|
||||||
key = (e.get("key") or "").strip()
|
|
||||||
repo = (e.get("hfRepo") or "").strip()
|
|
||||||
if not key or not repo:
|
|
||||||
continue
|
|
||||||
quant = (e.get("quant") or "Q4_K_M").strip()
|
|
||||||
ctx = int(e.get("ctx") or 8192)
|
|
||||||
ngl = int(e.get("ngl") or 99)
|
|
||||||
models[key] = {
|
|
||||||
"cmd": (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
|
|
||||||
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
|
|
||||||
"ttl": 3600,
|
|
||||||
}
|
|
||||||
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
|
|
||||||
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
|
|
||||||
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
|
|
||||||
key = _openai_key()
|
|
||||||
for k, spec in list(models.items()):
|
|
||||||
cmd = str((spec or {}).get("cmd") or "")
|
|
||||||
if not cmd:
|
|
||||||
continue
|
|
||||||
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
|
|
||||||
if key:
|
|
||||||
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
|
|
||||||
models[k] = dict(spec, cmd=cmd)
|
|
||||||
|
|
||||||
out = dict(base)
|
|
||||||
|
def _model_cmd(key: str) -> str:
|
||||||
|
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
|
||||||
|
Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.)."""
|
||||||
|
for e in _load_registry():
|
||||||
|
if (e.get("key") or "").strip() == key and (e.get("hfRepo") or "").strip():
|
||||||
|
quant = (e.get("quant") or "Q4_K_M").strip()
|
||||||
|
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
|
||||||
|
f"-hf {e['hfRepo'].strip()}:{quant}\n"
|
||||||
|
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja")
|
||||||
|
spec = (_base_config().get("models") or {}).get(key) or {}
|
||||||
|
return str(spec.get("cmd") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _known_models() -> list:
|
||||||
|
"""Alles, was diese Box laden KOENNTE (Basis-Template + Registry)."""
|
||||||
|
keys = list((_base_config().get("models") or {}).keys())
|
||||||
|
keys += [e["key"] for e in _load_registry() if e.get("key") and e["key"] not in keys]
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_config() -> int:
|
||||||
|
"""Schreibt die llama-swap-Config: NUR die Modelle, die einer Karte
|
||||||
|
zugeordnet sind. Was nicht drinsteht, kennt llama-swap nicht — auch der
|
||||||
|
OpenAI-Port lehnt es dann ab (404 "no router for requested model"). Das ist
|
||||||
|
der Kern der Regel "Clients laden nichts".
|
||||||
|
|
||||||
|
env CUDA_VISIBLE_DEVICES=<karte> pinnt das Modell auf seine Karte
|
||||||
|
ttl 0 nie wegen Leerlauf entladen
|
||||||
|
groups.swap false alle gleichzeitig geladen, keins
|
||||||
|
verdraengt ein anderes
|
||||||
|
hooks.on_startup.preload beim Start (und nach jedem Reload)
|
||||||
|
sofort laden, nicht erst beim 1. Aufruf
|
||||||
|
Getestet gegen llama-swap: genau dieses Verhalten.
|
||||||
|
"""
|
||||||
|
base = _base_config()
|
||||||
|
slots = _load_slots()
|
||||||
|
key = _openai_key()
|
||||||
|
models = {}
|
||||||
|
for card in _llm_cards():
|
||||||
|
m = slots.get(card)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
cmd = _model_cmd(m)
|
||||||
|
if not cmd:
|
||||||
|
logger.error("Karte %s: Modell %r unbekannt (weder Registry noch Basis) — uebersprungen", card, m)
|
||||||
|
continue
|
||||||
|
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd)
|
||||||
|
if key:
|
||||||
|
# llama-server prueft dann den Authorization-Header (OpenAI-Port).
|
||||||
|
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
|
||||||
|
models[m] = {"cmd": cmd, "env": [f"CUDA_VISIBLE_DEVICES={card}"], "ttl": 0}
|
||||||
|
|
||||||
|
out = {k: v for k, v in base.items() if k not in ("models", "groups", "hooks")}
|
||||||
out["models"] = models
|
out["models"] = models
|
||||||
|
if models:
|
||||||
|
out["groups"] = {"aria-karten": {"swap": False, "exclusive": True,
|
||||||
|
"persistent": True, "members": list(models)}}
|
||||||
|
out["hooks"] = {"on_startup": {"preload": list(models)}}
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(GEN_CONFIG_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(GEN_CONFIG_PATH), exist_ok=True)
|
||||||
tmp = GEN_CONFIG_PATH + ".tmp"
|
tmp = GEN_CONFIG_PATH + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
yaml.safe_dump(out, f, sort_keys=False, default_flow_style=False)
|
yaml.safe_dump(out, f, sort_keys=False, default_flow_style=False)
|
||||||
os.replace(tmp, GEN_CONFIG_PATH)
|
os.replace(tmp, GEN_CONFIG_PATH)
|
||||||
logger.info("llama-swap-Config generiert: %d Modelle → %s", len(models), GEN_CONFIG_PATH)
|
logger.info("llama-swap-Config: %s",
|
||||||
|
", ".join(f"Karte {_card_of(m) or '?'} → {m}" for m in models) or "keine Karte belegt")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Config schreiben fehlgeschlagen: %s", e)
|
logger.error("Config schreiben fehlgeschlagen: %s", e)
|
||||||
return len(models)
|
return len(models)
|
||||||
|
|
||||||
|
|
||||||
async def _reload_llama() -> None:
|
async def _reload_llama() -> None:
|
||||||
"""Stoesst llama-swap-Reload an. Viele Builds watchen die Config-Datei ohnehin;
|
"""llama-swap laeuft mit --watch-config und liest die Datei selbst neu
|
||||||
zusaetzlich versuchen wir bekannte Reload-Endpunkte (Fehler ignoriert)."""
|
(Poll alle 2 s). Die frueher hier aufgerufenen Endpunkte /reload und
|
||||||
for path in ("/api/config/reload", "/reload"):
|
/api/config/reload gibt es nicht (404, ausprobiert). Beim Neuladen startet
|
||||||
try:
|
llama-swap ALLE Modelle neu und laedt sie per Preload wieder — die anderen
|
||||||
async with httpx.AsyncClient(timeout=10) as c:
|
Karten sind also kurz nicht erreichbar."""
|
||||||
r = await c.post(f"{LLAMA_URL}{path}")
|
await asyncio.sleep(3) # Poll-Intervall + Puffer, bevor jemand fragt
|
||||||
if r.status_code < 400:
|
|
||||||
logger.info("llama-swap reload via %s", path)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("llama-swap reload: kein Endpoint — verlasse mich auf File-Watch")
|
|
||||||
|
|
||||||
|
|
||||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||||
@@ -331,27 +411,77 @@ async def _fetch_loaded() -> list:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_gpu_name_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _gpu_names() -> dict:
|
||||||
|
"""{index: name} aller Karten, einmal per nvidia-smi geholt."""
|
||||||
|
if _gpu_name_cache:
|
||||||
|
return _gpu_name_cache
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"nvidia-smi", "--query-gpu=index,name", "--format=csv,noheader",
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), 10)
|
||||||
|
for line in out.decode().splitlines():
|
||||||
|
if "," in line:
|
||||||
|
i, n = line.split(",", 1)
|
||||||
|
_gpu_name_cache[i.strip()] = n.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _gpu_name_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def _slots_info() -> list:
|
||||||
|
"""Pro LLM-Karte: [{gpu, gpuName, model, state}]. state aus llama-swap
|
||||||
|
/running: ready | starting | stopping | stopped; "unknown", wenn llama-swap
|
||||||
|
nicht antwortet; "" bei leerer Karte."""
|
||||||
|
slots = _load_slots()
|
||||||
|
running = await _fetch_loaded()
|
||||||
|
rmap = {r["model"]: r["state"] for r in running} if running is not None else None
|
||||||
|
names = await _gpu_names()
|
||||||
|
out = []
|
||||||
|
for card in _llm_cards():
|
||||||
|
m = slots.get(card, "")
|
||||||
|
state = "" if not m else ("unknown" if rmap is None else rmap.get(m, "stopped"))
|
||||||
|
out.append({"gpu": card, "gpuName": names.get(card, ""), "model": m, "state": state})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _selectable(slots_info: list) -> list:
|
||||||
|
"""Was Clients auswaehlen duerfen: belegte Karten, deren Modell laeuft oder
|
||||||
|
gerade (neu) startet — llama-swap haelt Anfragen bis "ready" fest. Auch
|
||||||
|
"unknown": wenn llama-swap /running nicht kennt (aeltere Version), lieber
|
||||||
|
die Belegung zeigen als die Box unbenutzbar machen."""
|
||||||
|
return [s["model"] for s in slots_info
|
||||||
|
if s["model"] and s["state"] in ("ready", "starting", "unknown")]
|
||||||
|
|
||||||
|
|
||||||
async def _ping(ws) -> None:
|
async def _ping(ws) -> None:
|
||||||
"""Lebenszeichen mit busy + geladenem Modell. loaded=None heisst "konnte
|
"""Lebenszeichen: busy + Karten-Belegung + auswaehlbare Modelle."""
|
||||||
llama-swap nicht fragen" — das ist etwas anderes als "nichts geladen"."""
|
info = await _slots_info()
|
||||||
await _send(ws, "worker_ping",
|
await _send(ws, "worker_ping",
|
||||||
{"instanceId": INSTANCE_ID, "busy": _inflight > 0,
|
{"instanceId": INSTANCE_ID, "busy": _inflight > 0,
|
||||||
"loaded": await _fetch_loaded()})
|
"slots": info, "models": _selectable(info)})
|
||||||
|
|
||||||
|
|
||||||
async def _announce(ws) -> None:
|
async def _announce(ws) -> None:
|
||||||
"""Sendet ein frisches worker_hello mit der aktuellen Modell-Liste (nach
|
"""Sendet ein frisches worker_hello mit der aktuellen Modell-Liste (nach
|
||||||
Provision/Remove aufrufen, damit Bridge+Diagnostic das neue Modell lernen)."""
|
Provision/Remove aufrufen, damit Bridge+Diagnostic das neue Modell lernen)."""
|
||||||
models = await _fetch_available_models()
|
info = await _slots_info()
|
||||||
|
models = _selectable(info)
|
||||||
await _send(ws, "worker_hello", {
|
await _send(ws, "worker_hello", {
|
||||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||||
# Die LLM-Karte ist die von llama-swap (LLM_GPU). Die eigene
|
# Die LLM-Karten (LLM_GPUS/LLM_GPU). Die eigene NVIDIA_VISIBLE_DEVICES
|
||||||
# NVIDIA_VISIBLE_DEVICES des Adapters ist "all" bzw. wird vom
|
# des Adapters ist "all" bzw. wird vom Container-Toolkit ersetzt
|
||||||
# Container-Toolkit ersetzt (Anzeige "GPU void") — sagt nichts darueber,
|
# (Anzeige "GPU void") — sagt nichts darueber, wo die Modelle laufen.
|
||||||
# wo das Modell laeuft.
|
"node": NODE_NAME, "gpus": ",".join(_llm_cards()),
|
||||||
"node": NODE_NAME, "gpus": LLM_GPU or GPU_IDS, "model": LLM_MODEL,
|
"model": models[0] if models else "",
|
||||||
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
# NUR geladene Modelle — das ist die Auswahl fuer Clients (ARIA, Fremd-
|
||||||
"loaded": await _fetch_loaded(), # welches davon GERADE in der Karte liegt
|
# programme). Laden geht ausschliesslich ueber die Diagnostic.
|
||||||
|
"models": models,
|
||||||
|
"slots": info, # pro LLM-Karte: Modell + Zustand (Diagnostic)
|
||||||
|
"knownModels": _known_models(), # was in eine Karte geladen werden KOENNTE
|
||||||
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
|
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
|
||||||
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
|
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
|
||||||
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
|
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
|
||||||
@@ -360,7 +490,7 @@ async def _announce(ws) -> None:
|
|||||||
"openaiKeySet": bool(_openai_key()),
|
"openaiKeySet": bool(_openai_key()),
|
||||||
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
|
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
|
||||||
})
|
})
|
||||||
logger.info("worker_hello: models=%s", models)
|
logger.info("worker_hello: %s", ", ".join(f"Karte {x['gpu']}={x['model'] or '-'}({x['state'] or 'leer'})" for x in info))
|
||||||
|
|
||||||
|
|
||||||
async def _worker_register(ws) -> None:
|
async def _worker_register(ws) -> None:
|
||||||
@@ -404,34 +534,48 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
|||||||
stop = payload.get("stop")
|
stop = payload.get("stop")
|
||||||
tools = payload.get("tools") or None
|
tools = payload.get("tools") or None
|
||||||
model = (payload.get("model") or "").strip() or None
|
model = (payload.get("model") or "").strip() or None
|
||||||
eff_model = model or LLM_MODEL
|
slots = _load_slots()
|
||||||
|
loaded = [slots[c] for c in _llm_cards() if slots.get(c)]
|
||||||
|
targeted = payload.get("targetInstance") == INSTANCE_ID
|
||||||
|
|
||||||
# Modellwechsel (oder erster Request) → llama-swap laedt/swappt: Status melden.
|
async def _refuse(code: str, msg: str) -> None:
|
||||||
switching = eff_model != _last_model
|
await _send(ws, "llm_response", {
|
||||||
if switching:
|
"requestId": req_id, "ok": False, "error": msg, "errorCode": code,
|
||||||
await _emit_llm_status(ws, "loading", eff_model)
|
"instanceId": INSTANCE_ID, "node": NODE_NAME, "loadedModels": loaded,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clients laden nichts: ein Modell, das auf keiner Karte liegt, gibt es
|
||||||
|
# fuer sie nicht. Gezielt an uns adressiert → klare Absage mit der Liste,
|
||||||
|
# was geladen IST. Als Broadcast → schweigen: eine andere Box hat es
|
||||||
|
# vielleicht (sonst antworteten alle Boxen ohne das Modell mit Fehlern).
|
||||||
|
if model and model not in loaded:
|
||||||
|
if targeted:
|
||||||
|
await _refuse("model_not_loaded",
|
||||||
|
f"Modell {model!r} ist auf {NODE_NAME} nicht geladen. Geladen: "
|
||||||
|
+ (", ".join(loaded) or "nichts")
|
||||||
|
+ ". Laden geht nur ueber die ARIA-Diagnostic.")
|
||||||
|
return
|
||||||
|
if not model:
|
||||||
|
if not loaded:
|
||||||
|
if targeted:
|
||||||
|
await _refuse("no_model_loaded",
|
||||||
|
f"Auf {NODE_NAME} ist keine Karte mit einem Modell belegt.")
|
||||||
|
return
|
||||||
|
# Ohne Modellangabe: das der ersten LLM-Karte (Verhalten wie frueher,
|
||||||
|
# als es nur eins gab). Clients sollten das Modell immer angeben.
|
||||||
|
model = loaded[0]
|
||||||
|
eff_model = model
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
res = await _call_llama(messages, max_tokens=max_tokens,
|
res = await _call_llama(messages, max_tokens=max_tokens,
|
||||||
temperature=temperature, stop=stop, tools=tools,
|
temperature=temperature, stop=stop, tools=tools,
|
||||||
model=model)
|
model=model)
|
||||||
dt = time.time() - t0
|
dt = time.time() - t0
|
||||||
|
if not res.get("ok"):
|
||||||
if switching:
|
await _emit_llm_status(ws, "error", eff_model, error=(res.get("error") or "")[:120])
|
||||||
if res.get("ok"):
|
|
||||||
fresh = (eff_model not in _ready_models) and dt > 25
|
|
||||||
_ready_models.add(eff_model)
|
|
||||||
_last_model = eff_model
|
|
||||||
await _emit_llm_status(ws, "ready", eff_model,
|
|
||||||
loadSeconds=round(dt, 1), freshlyDownloaded=fresh)
|
|
||||||
await _ping(ws) # Diagnostic zeigt den Wechsel sofort, nicht erst in 10 s
|
|
||||||
else:
|
|
||||||
# bei Fehler _last_model NICHT setzen → naechster Versuch meldet erneut loading
|
|
||||||
await _emit_llm_status(ws, "error", eff_model,
|
|
||||||
error=(res.get("error") or "")[:120])
|
|
||||||
tc = res.get("tool_calls")
|
tc = res.get("tool_calls")
|
||||||
logger.info("llm_request id=%s model=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
|
logger.info("llm_request id=%s model=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
|
||||||
(req_id[:8] if req_id else "?"), model or LLM_MODEL, res.get("ok"), dt,
|
(req_id[:8] if req_id else "?"), model, res.get("ok"), dt,
|
||||||
len(res.get("content") or ""), len(tc) if tc else 0)
|
len(res.get("content") or ""), len(tc) if tc else 0)
|
||||||
await _send(ws, "llm_response", {
|
await _send(ws, "llm_response", {
|
||||||
"requestId": req_id,
|
"requestId": req_id,
|
||||||
@@ -439,7 +583,10 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
|||||||
"content": res.get("content", ""),
|
"content": res.get("content", ""),
|
||||||
"tool_calls": tc,
|
"tool_calls": tc,
|
||||||
"error": res.get("error"),
|
"error": res.get("error"),
|
||||||
"model": model or LLM_MODEL,
|
"model": model,
|
||||||
|
# Wer hat geantwortet: Box + Karte. Hilft Clients mit mehreren Boxen.
|
||||||
|
"instanceId": INSTANCE_ID, "node": NODE_NAME, "gpu": _card_of(model),
|
||||||
|
"usage": res.get("usage") or {},
|
||||||
"elapsedMs": int(dt * 1000),
|
"elapsedMs": int(dt * 1000),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -470,14 +617,13 @@ async def _handle_openai_config(ws, payload: dict) -> None:
|
|||||||
return
|
return
|
||||||
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
|
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
|
||||||
_generate_config()
|
_generate_config()
|
||||||
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
|
logger.info("OpenAI-API-Key %s (llama-swap laedt die Modelle neu)",
|
||||||
"gesetzt" if key else "entfernt")
|
"gesetzt" if key else "entfernt")
|
||||||
await _send(ws, "llm_openai_config_result", {
|
await _send(ws, "llm_openai_config_result", {
|
||||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
|
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
|
||||||
"keySet": bool(key),
|
"keySet": bool(key),
|
||||||
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
|
"message": ("API-Key gesetzt — greift in wenigen Sekunden. llama-swap startet "
|
||||||
"geladen wird — bereits laufende Modelle einmal entladen lassen "
|
"die Modelle dafuer kurz neu."
|
||||||
"(TTL) oder ein anderes Modell anfragen."
|
|
||||||
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
|
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
|
||||||
})
|
})
|
||||||
await _announce(ws)
|
await _announce(ws)
|
||||||
@@ -494,7 +640,6 @@ async def _handle_openai_config(ws, payload: dict) -> None:
|
|||||||
# llama.cpp-Layouts: alle *.downloadInProgress unter /models.
|
# llama.cpp-Layouts: alle *.downloadInProgress unter /models.
|
||||||
MODELS_DIR = os.getenv("LLAMA_CACHE_DIR", "/models")
|
MODELS_DIR = os.getenv("LLAMA_CACHE_DIR", "/models")
|
||||||
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
|
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
|
||||||
LLM_GPU = os.getenv("LLM_GPU", "").strip() # Karte, auf die llama-swap gepinnt ist
|
|
||||||
WARMUP_TIMEOUT_S = float(os.getenv("LLM_WARMUP_TIMEOUT_S", "1900")) # > healthCheckTimeout
|
WARMUP_TIMEOUT_S = float(os.getenv("LLM_WARMUP_TIMEOUT_S", "1900")) # > healthCheckTimeout
|
||||||
_INPROGRESS = ".downloadInProgress"
|
_INPROGRESS = ".downloadInProgress"
|
||||||
_SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE)
|
_SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE)
|
||||||
@@ -558,13 +703,13 @@ def _scan_download(oids: set) -> tuple:
|
|||||||
return done, active, found
|
return done, active, found
|
||||||
|
|
||||||
|
|
||||||
async def _gpu_info() -> dict:
|
async def _gpu_info(card: str) -> dict:
|
||||||
"""Name + VRAM der LLM-Karte nach dem Laden (fuer 'in Karte N geladen')."""
|
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
|
||||||
if not LLM_GPU:
|
if not card:
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"nvidia-smi", f"--id={LLM_GPU}",
|
"nvidia-smi", f"--id={card}",
|
||||||
"--query-gpu=index,name,memory.used,memory.total", "--format=csv,noheader,nounits",
|
"--query-gpu=index,name,memory.used,memory.total", "--format=csv,noheader,nounits",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||||
out, _ = await asyncio.wait_for(proc.communicate(), 10)
|
out, _ = await asyncio.wait_for(proc.communicate(), 10)
|
||||||
@@ -572,7 +717,7 @@ async def _gpu_info() -> dict:
|
|||||||
return {"gpu": idx, "gpuName": name, "vramUsedMB": int(used), "vramTotalMB": int(total)}
|
return {"gpu": idx, "gpuName": name, "vramUsedMB": int(used), "vramTotalMB": int(total)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info("GPU-Info nicht verfuegbar: %s", e)
|
logger.info("GPU-Info nicht verfuegbar: %s", e)
|
||||||
return {"gpu": LLM_GPU}
|
return {"gpu": card}
|
||||||
|
|
||||||
|
|
||||||
async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
|
async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
|
||||||
@@ -618,39 +763,62 @@ async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> Non
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _provision_result(ws, key: str, ok: bool, **extra) -> None:
|
||||||
|
await _send(ws, "llm_provision_result",
|
||||||
|
{"instanceId": INSTANCE_ID, "key": key, "ok": ok, **extra})
|
||||||
|
|
||||||
|
|
||||||
async def _handle_provision(ws, payload: dict) -> None:
|
async def _handle_provision(ws, payload: dict) -> None:
|
||||||
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
|
"""Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt,
|
||||||
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
|
was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per
|
||||||
|
--watch-config + Preload) → Warmup, der wartet bis es bereit ist; daneben
|
||||||
|
misst _watch_download den Download."""
|
||||||
key = (payload.get("key") or "").strip()
|
key = (payload.get("key") or "").strip()
|
||||||
repo = (payload.get("hfRepo") or "").strip()
|
repo = (payload.get("hfRepo") or "").strip()
|
||||||
if not key or not repo:
|
cards = _llm_cards()
|
||||||
await _send(ws, "llm_provision_result",
|
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
|
||||||
{"instanceId": INSTANCE_ID, "key": key, "ok": False, "error": "key/hfRepo fehlt"})
|
if not card and len(cards) == 1:
|
||||||
return
|
card = cards[0] # aeltere Diagnostic ohne Kartenwahl, Box mit einer Karte
|
||||||
entry = {
|
if not key:
|
||||||
"key": key, "hfRepo": repo,
|
return await _provision_result(ws, key, False, error="key fehlt")
|
||||||
"quant": (payload.get("quant") or "Q4_K_M").strip(),
|
if card not in cards:
|
||||||
"ctx": int(payload.get("ctx") or 8192),
|
return await _provision_result(
|
||||||
"ngl": int(payload.get("ngl") or 99),
|
ws, key, False, gpu=card,
|
||||||
}
|
error=(f"Karte {card or '?'} ist auf {NODE_NAME} nicht fuers LLM vorgesehen "
|
||||||
reg = [e for e in _load_registry() if e.get("key") != key]
|
f"(LLM-Karten: {', '.join(cards)} — LLM_GPUS in der .env der Box)."))
|
||||||
reg.append(entry)
|
slots = _load_slots()
|
||||||
_save_registry(reg)
|
elsewhere = next((c for c, m in slots.items() if m == key and c != card), None)
|
||||||
|
if elsewhere is not None:
|
||||||
|
return await _provision_result(
|
||||||
|
ws, key, False, gpu=card,
|
||||||
|
error=f"{key} liegt schon auf Karte {elsewhere} dieser Box — ein Modell pro Box nur einmal.")
|
||||||
|
if repo:
|
||||||
|
entry = {
|
||||||
|
"key": key, "hfRepo": repo,
|
||||||
|
"quant": (payload.get("quant") or "Q4_K_M").strip(),
|
||||||
|
"ctx": int(payload.get("ctx") or 8192),
|
||||||
|
"ngl": int(payload.get("ngl") or 99),
|
||||||
|
}
|
||||||
|
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
|
||||||
|
elif not _model_cmd(key):
|
||||||
|
return await _provision_result(ws, key, False, gpu=card, error="hfRepo fehlt (unbekanntes Modell)")
|
||||||
|
|
||||||
|
previous = slots.get(card, "")
|
||||||
|
slots[card] = key
|
||||||
|
_save_slots(slots)
|
||||||
_generate_config()
|
_generate_config()
|
||||||
await _reload_llama()
|
|
||||||
await _announce(ws) # Bridge/Diagnostic lernen das neue Modell
|
quant = next((e.get("quant") for e in _load_registry() if e.get("key") == key), "") or "Q4_K_M"
|
||||||
# Warmup: Mini-Request → llama-swap zieht das GGUF (falls noch nicht da)
|
files = await _hf_expected_files(repo, quant) if repo else []
|
||||||
# und laedt es in die Karte. Daneben misst _watch_download den Download.
|
|
||||||
files = await _hf_expected_files(repo, entry["quant"])
|
|
||||||
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files),
|
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files),
|
||||||
files=len(files), gpu=LLM_GPU)
|
files=len(files), gpu=card, replaces=previous)
|
||||||
await _emit_llm_status(ws, "loading", key)
|
await _reload_llama()
|
||||||
|
await _announce(ws)
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
watcher = asyncio.create_task(_watch_download(ws, key, files, stop))
|
watcher = asyncio.create_task(_watch_download(ws, key, files, stop))
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
|
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
|
||||||
# LLM_TIMEOUT_SEC (60 s) meldete das hier "Fehler", waehrend llama-swap im
|
# LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud.
|
||||||
# Hintergrund munter weiterlud.
|
|
||||||
res = await _call_llama([{"role": "user", "content": "hi"}],
|
res = await _call_llama([{"role": "user", "content": "hi"}],
|
||||||
max_tokens=1, temperature=0.0, stop=None, model=key,
|
max_tokens=1, temperature=0.0, stop=None, model=key,
|
||||||
timeout=WARMUP_TIMEOUT_S)
|
timeout=WARMUP_TIMEOUT_S)
|
||||||
@@ -662,35 +830,82 @@ async def _handle_provision(ws, payload: dict) -> None:
|
|||||||
pass
|
pass
|
||||||
if res.get("ok"):
|
if res.get("ok"):
|
||||||
_ready_models.add(key)
|
_ready_models.add(key)
|
||||||
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1),
|
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1), freshlyDownloaded=dt > 25)
|
||||||
freshlyDownloaded=dt > 25)
|
|
||||||
await _emit_progress(ws, key, "ready", percent=100, loadSeconds=round(dt, 1),
|
await _emit_progress(ws, key, "ready", percent=100, loadSeconds=round(dt, 1),
|
||||||
**(await _gpu_info()))
|
replaces=previous, **(await _gpu_info(card)))
|
||||||
await _ping(ws)
|
await _ping(ws)
|
||||||
else:
|
await _provision_result(ws, key, True, gpu=card, elapsedMs=int(dt * 1000))
|
||||||
await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160])
|
logger.info("Karte %s: %s geladen (%.1fs)%s", card, key, dt,
|
||||||
await _emit_progress(ws, key, "error", error=(res.get("error") or "")[:200])
|
f", ersetzt {previous}" if previous else "")
|
||||||
await _send(ws, "llm_provision_result",
|
|
||||||
{"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False),
|
|
||||||
"error": res.get("error"), "elapsedMs": int(dt * 1000)})
|
|
||||||
logger.info("provision %s (%s) → ok=%s %.1fs", key, repo, res.get("ok"), dt)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_remove(ws, payload: dict) -> None:
|
|
||||||
"""Entfernt ein Modell aus Registry+Config (GGUF bleibt im Cache)."""
|
|
||||||
key = (payload.get("key") or "").strip()
|
|
||||||
if not key:
|
|
||||||
return
|
return
|
||||||
reg = [e for e in _load_registry() if e.get("key") != key]
|
# Fehlgeschlagen (z.B. VRAM zu klein): Karte zuruecksetzen. Sonst stuende
|
||||||
_save_registry(reg)
|
# ein kaputtes Modell in der Config, und jedes Neuladen von llama-swap
|
||||||
|
# risse die anderen Karten mit.
|
||||||
|
err = (res.get("error") or "Laden fehlgeschlagen")[:200]
|
||||||
|
slots = _load_slots()
|
||||||
|
if previous:
|
||||||
|
slots[card] = previous
|
||||||
|
else:
|
||||||
|
slots.pop(card, None)
|
||||||
|
_save_slots(slots)
|
||||||
_generate_config()
|
_generate_config()
|
||||||
await _reload_llama()
|
await _reload_llama()
|
||||||
await _announce(ws)
|
await _announce(ws)
|
||||||
await _send(ws, "llm_provision_result",
|
note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer"
|
||||||
{"instanceId": INSTANCE_ID, "key": key, "ok": True, "removed": True})
|
await _emit_llm_status(ws, "error", key, error=err)
|
||||||
|
await _emit_progress(ws, key, "error", gpu=card, error=err + note)
|
||||||
|
await _provision_result(ws, key, False, gpu=card, error=err + note, elapsedMs=int(dt * 1000))
|
||||||
|
logger.warning("Karte %s: %s fehlgeschlagen (%s)%s", card, key, err, note)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_unload(ws, payload: dict) -> None:
|
||||||
|
"""Karte leeren (nur Diagnostic). Das GGUF bleibt im Cache — erneutes Laden
|
||||||
|
geht dann ohne Download."""
|
||||||
|
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
|
||||||
|
slots = _load_slots()
|
||||||
|
key = slots.pop(card, "")
|
||||||
|
if not key:
|
||||||
|
return await _provision_result(ws, "", False, gpu=card, error=f"Karte {card} ist schon leer")
|
||||||
|
_save_slots(slots)
|
||||||
|
_generate_config()
|
||||||
|
await _reload_llama()
|
||||||
|
await _announce(ws)
|
||||||
|
await _provision_result(ws, key, True, gpu=card, unloaded=True)
|
||||||
|
logger.info("Karte %s geleert (war %s)", card, key)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_remove(ws, payload: dict) -> None:
|
||||||
|
"""Modell aus der Registry entfernen (und von seiner Karte, falls belegt).
|
||||||
|
Das GGUF bleibt im Cache."""
|
||||||
|
key = (payload.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
_save_registry([e for e in _load_registry() if e.get("key") != key])
|
||||||
|
slots = _load_slots()
|
||||||
|
for c in [c for c, m in slots.items() if m == key]:
|
||||||
|
slots.pop(c)
|
||||||
|
_save_slots(slots)
|
||||||
|
_generate_config()
|
||||||
|
await _reload_llama()
|
||||||
|
await _announce(ws)
|
||||||
|
await _provision_result(ws, key, True, removed=True)
|
||||||
logger.info("removed model %s", key)
|
logger.info("removed model %s", key)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_models_request(ws, payload: dict) -> None:
|
||||||
|
"""Erkundung fuer Clients: welche Modelle kann ich bei DIESER Box fragen?
|
||||||
|
Jede Box antwortet einzeln (RVS ist ein Broadcast-Raum) — der Client
|
||||||
|
sammelt die Antworten ein. Nur geladene Modelle, mit Karte."""
|
||||||
|
info = await _slots_info()
|
||||||
|
sel = set(_selectable(info))
|
||||||
|
await _send(ws, "llm_models", {
|
||||||
|
"requestId": payload.get("requestId", ""),
|
||||||
|
"instanceId": INSTANCE_ID, "node": NODE_NAME,
|
||||||
|
"models": [{"model": x["model"], "gpu": x["gpu"], "gpuName": x["gpuName"],
|
||||||
|
"state": x["state"]} for x in info if x["model"] in sel],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
if not RVS_HOST:
|
if not RVS_HOST:
|
||||||
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||||
@@ -755,12 +970,17 @@ async def _run() -> None:
|
|||||||
if mtype == "llm_openai_config":
|
if mtype == "llm_openai_config":
|
||||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||||
continue
|
continue
|
||||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
|
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
|
||||||
|
"llm_unload_model", "llm_models_request"):
|
||||||
continue
|
continue
|
||||||
if mtype == "llm_provision_model":
|
if mtype == "llm_provision_model":
|
||||||
asyncio.create_task(_handle_provision(ws, payload))
|
asyncio.create_task(_handle_provision(ws, payload))
|
||||||
|
elif mtype == "llm_unload_model":
|
||||||
|
asyncio.create_task(_handle_unload(ws, payload))
|
||||||
elif mtype == "llm_remove_model":
|
elif mtype == "llm_remove_model":
|
||||||
asyncio.create_task(_handle_remove(ws, payload))
|
asyncio.create_task(_handle_remove(ws, payload))
|
||||||
|
elif mtype == "llm_models_request":
|
||||||
|
asyncio.create_task(_handle_models_request(ws, payload))
|
||||||
else:
|
else:
|
||||||
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
||||||
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
||||||
|
|||||||
Reference in New Issue
Block a user