From 23feb3ba2d7fa36d43d2f51bed5d0f4b9db52d37 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 18 Oct 2026 16:04:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(llm):=20ein=20Modell=20pro=20Karte,=20Clie?= =?UTF-8?q?nts=20waehlen=20nur=20aus=20=E2=80=94=20und=20Doku=20fuer=20Dri?= =?UTF-8?q?ttprogramme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=, 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 --- README.md | 1 + bridge/aria_bridge.py | 27 ++ diagnostic/index.html | 112 +++++++-- diagnostic/server.js | 21 +- docs/LLM-SCHNITTSTELLE.md | 295 ++++++++++++++++++++++ rvs/server.js | 4 + xtts/.env.example | 9 +- xtts/docker-compose.yml | 22 +- xtts/llm-adapter/adapter.py | 484 ++++++++++++++++++++++++++---------- 9 files changed, 805 insertions(+), 170 deletions(-) create mode 100644 docs/LLM-SCHNITTSTELLE.md diff --git a/README.md b/README.md index 760cce0..acc15a8 100644 --- a/README.md +++ b/README.md @@ -544,6 +544,7 @@ Erreichbar unter `http://:3001`. Teilt das Netzwerk mit der Bridge. - `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat. - `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs). - **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke` +- **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=` (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/.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) - **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** (``), 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 diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 54f7a84..e1f876b 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -3774,6 +3774,9 @@ class ARIABridge: # Fallback auf [model] fuer alte Adapter ohne models-Feld. "models": [m for m in _models if m] if isinstance(_models, list) 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)), "last_seen": time.time(), } @@ -3819,6 +3822,13 @@ class ARIABridge: "model": "", "busy": False, "last_seen": 0.0, } 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() return @@ -4265,6 +4275,16 @@ class ARIABridge: llm_target = self._pick_worker("llm", model=model or None) if llm_target: req_payload["targetInstance"] = llm_target + elif model and self._llm_boxes_with_slots(): + # Boxen mit Karten-Belegung laden nichts nach und schweigen auf + # Broadcasts fuer fremde Modelle. Ein Broadcast liefe also nur in + # den Timeout — lieber sofort ehrlich absagen. + self._pending_llm.pop(request_id, None) + loaded = sorted({m for w in self._llm_boxes_with_slots() for m in (w.get("models") or [])}) + return {"ok": False, "errorCode": "model_not_loaded", + "error": (f"Lokales Modell {model!r} ist auf keiner KI-Box geladen. " + f"Geladen: {', '.join(loaded) or 'nichts'}. " + "Laden: Diagnostic → Modell-Katalog.")} logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)", request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-", llm_target or "(broadcast)") @@ -5234,6 +5254,13 @@ class ARIABridge: }) 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]: """Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing diff --git a/diagnostic/index.html b/diagnostic/index.html index cb95772..fb27c31 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -1873,9 +1873,10 @@
- Pro LLM-Box läuft immer ein Modell auf ihrer LLM-Karte - (LLM_GPU in der .env der Box). Laden eines anderen wechselt dorthin — das vorige - wird entladen, bleibt aber heruntergeladen. + Jede LLM-Karte hält genau ein Modell, dauerhaft. Welche + Karten dem LLM gehören, steht in der .env der Box (LLM_GPUS). Laden ersetzt, was auf + 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.
@@ -2609,6 +2610,7 @@ const st = document.getElementById('llm-catalog-status'); if (st) { 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 { 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 dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F'); const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung'); - // llm-Boxen koennen mehrere Modelle FAHREN (llama-swap), aber nur - // eins liegt gerade in der Karte — das ist gruen markiert. Die - // anderen werden beim ersten Aufruf geladen (und verdraengen es). + // LLM-Boxen: pro Karte genau ein Modell, dauerhaft geladen. Laden und + // Entladen nur hier (Modell-Katalog), Clients waehlen nur aus. let modelHtml; - if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) { - const loaded = Array.isArray(w.loaded) ? w.loaded : null; - const isLoaded = id => loaded && loaded.some(l => l.model === id); - const state = id => (loaded.find(l => l.model === id) || {}).state; - modelHtml = w.models.map(id => isLoaded(id) - ? '' + escapeHtml(id) - + (state(id) && state(id) !== 'ready' ? ' (' + escapeHtml(state(id)) + ' …)' : ' ● geladen') + '' - : '' + escapeHtml(id) + '' - ).join(', '); - if (loaded && !loaded.length) modelHtml += ' — keins geladen (lädt beim ersten Aufruf)'; - else if (!loaded && w.online) modelHtml += ' — geladen: ?'; + if (w.service === 'llm' && Array.isArray(w.slots)) { + modelHtml = ''; // Karten folgen als eigene Zeilen unten + } else if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) { + // Box mit altem Adapter (ohne Karten): nur die Liste + modelHtml = '' + + escapeHtml(w.models.join(', ')) + ''; } else { modelHtml = '' + escapeHtml(w.model || '') + ''; } @@ -5178,9 +5174,10 @@ '' + '' + meta.icon + ' ' + escapeHtml(meta.label) + '' + '' + modelHtml + '' + - (w.gpus ? 'GPU ' + escapeHtml(w.gpus) + '' : '') + + (w.gpus && !(w.service === 'llm' && Array.isArray(w.slots)) ? 'GPU ' + escapeHtml(w.gpus) + '' : '') + '' + stat + '' + '' + + (w.service === 'llm' && Array.isArray(w.slots) ? renderLlmSlots(w) : '') + // Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.) (w.service === 'llm' ? renderOpenAiEndpoint(w) : ''); }).join(''); @@ -8869,7 +8866,9 @@ const order = Array.from(ids).sort(); sel.innerHTML = order.map(id => { 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 ``; }).join('') || ''; if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel; @@ -8966,11 +8965,18 @@ const td = 'style="padding:6px 8px;vertical-align:top;"'; const rows = _llmCatalog.map((m, i) => { const have = boxesServing(m.id); - const haveTxt = have.length ? `✓ ${have.map(b => escapeHtml(b.node)).join(', ')}` : 'nicht geladen'; + 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 ? `✓ ${have.map(where).join(', ')}` : 'nicht geladen'; const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—'; const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;'; - const opts = boxes.length - ? boxes.map(b => ``).join('') + // Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste. + const targets = llmCardTargets(); + const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value; + const opts = targets.length + ? targets.map(t => ``).join('') : ''; return '' + '' + escapeHtml(m.id) + '' + @@ -8984,7 +8990,7 @@ box.innerHTML = '' + '' + '' + - '' + + '' + '' + rows + '
ModellKann gut / BeschreibungVRAMStatusZiel-BoxStatusZiel-Karte
'; } // ── Download-Fenster ────────────────────────────────────────────── @@ -9029,7 +9035,8 @@ return; } 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 ? ' (ersetzt ' + escapeHtml(j.replaces) + ')' : ''); let pct = 0, color = '#0096FF', line = '', sub = ''; if (j.phase === 'queued') { // 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(); }, 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 ? ' (' + escapeHtml(sl.gpuName) + ')' : ''); + if (!sl.model) { + return `
+ ${card} + — leer · im Modell-Katalog „Laden" → diese Karte
`; + } + const [c, label] = SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')]; + return `
+ ${card} + ${escapeHtml(sl.model)} + ${label} + +
`; + }).join(''); + return `
${rows || 'Keine LLM-Karte konfiguriert (LLM_GPUS in der .env der Box).'}
`; + } + 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) { const m = _llmCatalog[i]; if (!m) return; const sel = document.getElementById('llm-cat-box-' + i); - const target = sel && sel.value; + const [target, gpu] = String((sel && sel.value) || '').split('|'); 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'); 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(); } function runLlmTest() { diff --git a/diagnostic/server.js b/diagnostic/server.js index 6d8225c..00225f3 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1260,9 +1260,11 @@ function connectRVS(forcePlain) { instanceId: p.instanceId, service: p.service || "", node: p.node || "", gpus: p.gpus || "", model: p.model || "", models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []), - // Was GERADE in der Karte liegt (nur llm, aus llama-swap /running). - // null = Box konnte llama-swap nicht fragen, [] = nichts geladen. - loaded: Array.isArray(p.loaded) ? p.loaded : (prev.loaded ?? null), + // Nur llm: pro LLM-Karte das Modell + Zustand. "models" ist dann nur + // noch die Auswahl der GELADENEN Modelle; "knownModels" das, was man + // 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 // Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob // die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt). @@ -1285,7 +1287,10 @@ function connectRVS(forcePlain) { workers.set(p.instanceId, w); } 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(); broadcastWorkers(); } @@ -1377,7 +1382,7 @@ function connectRVS(forcePlain) { } else if (msg.type === "llm_provision_result") { // Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status). 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" }); if (job) broadcast({ type: "llm_provision_progress", payload: job }); } @@ -3371,8 +3376,14 @@ wss.on("connection", (ws) => { sendToRVS_raw({ type: "llm_provision_model", payload: { targetInstance: msg.targetInstance || "", 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() }); 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") { sendToRVS_raw({ type: "llm_remove_model", payload: { targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() }); diff --git a/docs/LLM-SCHNITTSTELLE.md b/docs/LLM-SCHNITTSTELLE.md new file mode 100644 index 0000000..2ba5b5a --- /dev/null +++ b/docs/LLM-SCHNITTSTELLE.md @@ -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://:/?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": "", "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://:/v1 (Standard-Port 8081) +``` + +- `GET /v1/models` listet **nur die geladenen** Modelle dieser Box. +- `POST /v1/chat/completions` mit `"model": ""` 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 ` 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. diff --git a/rvs/server.js b/rvs/server.js index 31343d7..d3342a4 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -129,6 +129,10 @@ const ALLOWED_TYPES = new Set([ // Liste, die Box hat nie davon erfahren. _progress meldet den Download in %. "llm_provision_model", "llm_provision_result", "llm_provision_progress", "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_history_request", "node_stats_history", "node_stats_reset", "node_stats_reset_done", diff --git a/xtts/.env.example b/xtts/.env.example index 88bf32b..6bfbc60 100644 --- a/xtts/.env.example +++ b/xtts/.env.example @@ -37,7 +37,14 @@ NODE_NAME=ai-box WHISPER_GPU=0 # kleines STT → passt neben F5-TTS auf EINE Karte 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) -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 ─────────────────────────────────────── # WICHTIG: Host, Port UND Token muessen EXAKT mit dem ARIA-Stack (Bridge/ diff --git a/xtts/docker-compose.yml b/xtts/docker-compose.yml index cb961fd..4a29637 100644 --- a/xtts/docker-compose.yml +++ b/xtts/docker-compose.yml @@ -133,12 +133,20 @@ services: volumes: - ./models:/models # HF-Cache + generierte Config 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= in der + # generierten Config). Erlaubt sind nur die LLM-Karten aus LLM_GPUS. + - NVIDIA_VISIBLE_DEVICES=all - 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 - # Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt - # restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat. - command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"] + # Liest die vom llm-adapter generierte Config. --watch-config: Aenderungen + # (Modell in Karte geladen/entladen) greifen von selbst — einen Reload- + # 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 # koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das # lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer @@ -178,8 +186,10 @@ services: - LLM_REGISTRY=/models/aria_models.json # Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig. - LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600} - # Auf welche Karte llama-swap gepinnt ist — fuer die Meldung „in Karte N - # geladen" samt VRAM nach dem Laden. Dieselbe Variable wie bei llama-swap. + # Welche Karten dem LLM gehoeren. Jede haelt genau ein Modell, geladen + # 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} # Fuer gesperrte (gated) HF-Repos: die Dateiliste fuer den Fortschritt. - HF_TOKEN=${HF_TOKEN:-} diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py index 677ff00..45dd5cb 100644 --- a/xtts/llm-adapter/adapter.py +++ b/xtts/llm-adapter/adapter.py @@ -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") 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.) ───── # 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 @@ -161,69 +217,93 @@ def _save_registry(reg: list) -> None: logger.warning("Registry speichern fehlgeschlagen: %s", e) -def _generate_config() -> int: - """Schreibt die llama-swap-Config aus Basis-Template + Registry. Gibt die - Anzahl Modelle zurueck. Idempotent, bei jeder Aenderung + beim Start.""" - base = {} +def _base_config() -> dict: try: with open(BASE_CONFIG_PATH) as f: - base = yaml.safe_load(f) or {} + return yaml.safe_load(f) or {} except Exception as e: logger.warning("Basis-Template %s nicht lesbar (%s)", BASE_CONFIG_PATH, e) - models = dict(base.get("models") or {}) - 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) + return {} - 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= 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 + if models: + out["groups"] = {"aria-karten": {"swap": False, "exclusive": True, + "persistent": True, "members": list(models)}} + out["hooks"] = {"on_startup": {"preload": list(models)}} try: os.makedirs(os.path.dirname(GEN_CONFIG_PATH), exist_ok=True) tmp = GEN_CONFIG_PATH + ".tmp" with open(tmp, "w") as f: yaml.safe_dump(out, f, sort_keys=False, default_flow_style=False) 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: logger.error("Config schreiben fehlgeschlagen: %s", e) return len(models) async def _reload_llama() -> None: - """Stoesst llama-swap-Reload an. Viele Builds watchen die Config-Datei ohnehin; - zusaetzlich versuchen wir bekannte Reload-Endpunkte (Fehler ignoriert).""" - for path in ("/api/config/reload", "/reload"): - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.post(f"{LLAMA_URL}{path}") - 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") + """llama-swap laeuft mit --watch-config und liest die Datei selbst neu + (Poll alle 2 s). Die frueher hier aufgerufenen Endpunkte /reload und + /api/config/reload gibt es nicht (404, ausprobiert). Beim Neuladen startet + llama-swap ALLE Modelle neu und laedt sie per Preload wieder — die anderen + Karten sind also kurz nicht erreichbar.""" + await asyncio.sleep(3) # Poll-Intervall + Puffer, bevor jemand fragt async def _send(ws, mtype: str, payload: dict) -> None: @@ -331,27 +411,77 @@ async def _fetch_loaded() -> list: 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: - """Lebenszeichen mit busy + geladenem Modell. loaded=None heisst "konnte - llama-swap nicht fragen" — das ist etwas anderes als "nichts geladen".""" + """Lebenszeichen: busy + Karten-Belegung + auswaehlbare Modelle.""" + info = await _slots_info() await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": _inflight > 0, - "loaded": await _fetch_loaded()}) + "slots": info, "models": _selectable(info)}) async def _announce(ws) -> None: """Sendet ein frisches worker_hello mit der aktuellen Modell-Liste (nach 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", { "instanceId": INSTANCE_ID, "service": WORKER_SERVICE, - # Die LLM-Karte ist die von llama-swap (LLM_GPU). Die eigene - # NVIDIA_VISIBLE_DEVICES des Adapters ist "all" bzw. wird vom - # Container-Toolkit ersetzt (Anzeige "GPU void") — sagt nichts darueber, - # wo das Modell laeuft. - "node": NODE_NAME, "gpus": LLM_GPU or GPU_IDS, "model": LLM_MODEL, - "models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys) - "loaded": await _fetch_loaded(), # welches davon GERADE in der Karte liegt + # Die LLM-Karten (LLM_GPUS/LLM_GPU). Die eigene NVIDIA_VISIBLE_DEVICES + # des Adapters ist "all" bzw. wird vom Container-Toolkit ersetzt + # (Anzeige "GPU void") — sagt nichts darueber, wo die Modelle laufen. + "node": NODE_NAME, "gpus": ",".join(_llm_cards()), + "model": models[0] if models else "", + # NUR geladene Modelle — das ist die Auswahl fuer Clients (ARIA, Fremd- + # 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 # NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env # ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt). @@ -360,7 +490,7 @@ async def _announce(ws) -> None: "openaiKeySet": bool(_openai_key()), "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: @@ -404,34 +534,48 @@ async def _do_llm_request(ws, payload: dict) -> None: stop = payload.get("stop") tools = payload.get("tools") 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. - switching = eff_model != _last_model - if switching: - await _emit_llm_status(ws, "loading", eff_model) + async def _refuse(code: str, msg: str) -> None: + await _send(ws, "llm_response", { + "requestId": req_id, "ok": False, "error": msg, "errorCode": code, + "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() res = await _call_llama(messages, max_tokens=max_tokens, temperature=temperature, stop=stop, tools=tools, model=model) dt = time.time() - t0 - - if switching: - 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]) + if not res.get("ok"): + await _emit_llm_status(ws, "error", eff_model, error=(res.get("error") or "")[:120]) tc = res.get("tool_calls") 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) await _send(ws, "llm_response", { "requestId": req_id, @@ -439,7 +583,10 @@ async def _do_llm_request(ws, payload: dict) -> None: "content": res.get("content", ""), "tool_calls": tc, "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), }) @@ -470,14 +617,13 @@ async def _handle_openai_config(ws, payload: dict) -> None: return # Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt. _generate_config() - logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)", + logger.info("OpenAI-API-Key %s (llama-swap laedt die Modelle neu)", "gesetzt" if key else "entfernt") await _send(ws, "llm_openai_config_result", { "requestId": req_id, "instanceId": INSTANCE_ID, "ok": True, "keySet": bool(key), - "message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal " - "geladen wird — bereits laufende Modelle einmal entladen lassen " - "(TTL) oder ein anderes Modell anfragen." + "message": ("API-Key gesetzt — greift in wenigen Sekunden. llama-swap startet " + "die Modelle dafuer kurz neu." if key else "API-Key entfernt — der Endpunkt ist jetzt offen."), }) await _announce(ws) @@ -494,7 +640,6 @@ async def _handle_openai_config(ws, payload: dict) -> None: # llama.cpp-Layouts: alle *.downloadInProgress unter /models. MODELS_DIR = os.getenv("LLAMA_CACHE_DIR", "/models") 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 _INPROGRESS = ".downloadInProgress" _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 -async def _gpu_info() -> dict: - """Name + VRAM der LLM-Karte nach dem Laden (fuer 'in Karte N geladen').""" - if not LLM_GPU: +async def _gpu_info(card: str) -> dict: + """Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen').""" + if not card: return {} try: 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", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL) 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)} except Exception as 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: @@ -618,39 +763,62 @@ async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> Non 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: - """Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup - (zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste.""" + """Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt, + 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() repo = (payload.get("hfRepo") or "").strip() - if not key or not repo: - await _send(ws, "llm_provision_result", - {"instanceId": INSTANCE_ID, "key": key, "ok": False, "error": "key/hfRepo fehlt"}) - return - 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), - } - reg = [e for e in _load_registry() if e.get("key") != key] - reg.append(entry) - _save_registry(reg) + cards = _llm_cards() + card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip() + if not card and len(cards) == 1: + card = cards[0] # aeltere Diagnostic ohne Kartenwahl, Box mit einer Karte + if not key: + return await _provision_result(ws, key, False, error="key fehlt") + if card not in cards: + return await _provision_result( + ws, key, False, gpu=card, + error=(f"Karte {card or '?'} ist auf {NODE_NAME} nicht fuers LLM vorgesehen " + f"(LLM-Karten: {', '.join(cards)} — LLM_GPUS in der .env der Box).")) + slots = _load_slots() + 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() - await _reload_llama() - await _announce(ws) # Bridge/Diagnostic lernen das neue Modell - # Warmup: Mini-Request → llama-swap zieht das GGUF (falls noch nicht da) - # und laedt es in die Karte. Daneben misst _watch_download den Download. - files = await _hf_expected_files(repo, entry["quant"]) + + quant = next((e.get("quant") for e in _load_registry() if e.get("key") == key), "") or "Q4_K_M" + files = await _hf_expected_files(repo, quant) if repo else [] await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files), - files=len(files), gpu=LLM_GPU) - await _emit_llm_status(ws, "loading", key) + files=len(files), gpu=card, replaces=previous) + await _reload_llama() + await _announce(ws) stop = asyncio.Event() watcher = asyncio.create_task(_watch_download(ws, key, files, stop)) t0 = time.time() # 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 - # Hintergrund munter weiterlud. + # LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud. res = await _call_llama([{"role": "user", "content": "hi"}], max_tokens=1, temperature=0.0, stop=None, model=key, timeout=WARMUP_TIMEOUT_S) @@ -662,35 +830,82 @@ async def _handle_provision(ws, payload: dict) -> None: pass if res.get("ok"): _ready_models.add(key) - await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1), - freshlyDownloaded=dt > 25) + await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1), freshlyDownloaded=dt > 25) await _emit_progress(ws, key, "ready", percent=100, loadSeconds=round(dt, 1), - **(await _gpu_info())) + replaces=previous, **(await _gpu_info(card))) await _ping(ws) - else: - await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160]) - await _emit_progress(ws, key, "error", error=(res.get("error") or "")[:200]) - 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: + await _provision_result(ws, key, True, gpu=card, elapsedMs=int(dt * 1000)) + logger.info("Karte %s: %s geladen (%.1fs)%s", card, key, dt, + f", ersetzt {previous}" if previous else "") return - reg = [e for e in _load_registry() if e.get("key") != key] - _save_registry(reg) + # Fehlgeschlagen (z.B. VRAM zu klein): Karte zuruecksetzen. Sonst stuende + # 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() await _reload_llama() await _announce(ws) - await _send(ws, "llm_provision_result", - {"instanceId": INSTANCE_ID, "key": key, "ok": True, "removed": True}) + note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer" + 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) +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: if not RVS_HOST: logger.error("RVS_HOST nicht gesetzt — Abbruch") @@ -755,12 +970,17 @@ async def _run() -> None: if mtype == "llm_openai_config": asyncio.create_task(_handle_openai_config(ws, payload)) continue - if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"): + if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model", + "llm_unload_model", "llm_models_request"): continue if mtype == "llm_provision_model": asyncio.create_task(_handle_provision(ws, payload)) + elif mtype == "llm_unload_model": + asyncio.create_task(_handle_unload(ws, payload)) elif mtype == "llm_remove_model": asyncio.create_task(_handle_remove(ws, payload)) + elif mtype == "llm_models_request": + asyncio.create_task(_handle_models_request(ws, payload)) else: # Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern, # aber wir blockieren so nicht den Empfang weiterer Messages.