From d6c75028105b9dcff36a183dde8181f173a6a05f Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 30 Sep 2026 22:30:37 +0200 Subject: [PATCH] feat(xtts): OpenAI-Endpunkt fuers lokale LLM + OpenHands-Profil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llama-swap ist bereits OpenAI-kompatibel, war aber nur im Compose-Netz erreichbar (kein ports-Mapping). Jetzt nutzbar fuer OpenHands, Continue, Open WebUI & Co. - compose: llama-swap veroeffentlicht 8080 als ${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081} — sicher per Default (nur localhost), fuer LAN bewusst BIND=0.0.0.0 setzen. - llm-adapter: API-Key wird in JEDEN llama-server-Befehl der generierten llama-swap-Config eingehaengt (--api-key, shlex-gequotet). Quelle: .env (Vorrang) sonst /models/openai_key.json (0600). Neuer Handler llm_openai_config setzt ihn zur Laufzeit und lehnt bei .env-Vorrang ab. worker_hello meldet Port/Bind/keySet/envLocked — den Key NIE (Broadcast!). - Diagnostic: pro LLM-Box Endpunkt-Zeile mit Base-URL, Key-Status und Eingabe. Bei .env-Vorrang ist das Feld schreibgeschuetzt (🔒). Warnung, wenn im LAN offen ohne Key. Der PORT wird nur ANGEZEIGT: er ist ein Docker-Port-Mapping und steht beim Container-Start fest — dafuer ist die .env zustaendig. - rvs: llm_openai_config(_result) in ALLOWED_TYPES (sonst still verworfen). - Neues Profil 'openhands' (COMPOSE_PROFILES=llm,openhands) mit Port/Bind/Modell aus der .env, lokales LLM vorbelegt. Ohne Profil aendert sich nichts. - .env.example + README: alle Variablen, Sicherheitshinweise (offener Endpunkt, Docker-Socket bei OpenHands), curl-Test und wann der Key greift. Co-Authored-By: Claude Opus 4.8 --- diagnostic/index.html | 61 ++++++++++++++++++++- diagnostic/server.js | 20 +++++++ rvs/server.js | 3 ++ xtts/.env.example | 29 ++++++++++ xtts/README.md | 68 ++++++++++++++++++++++++ xtts/docker-compose.yml | 37 +++++++++++++ xtts/llm-adapter/adapter.py | 102 ++++++++++++++++++++++++++++++++++++ 7 files changed, 319 insertions(+), 1 deletion(-) diff --git a/diagnostic/index.html b/diagnostic/index.html index 016eadc..a31cdeb 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -2278,6 +2278,7 @@ if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; } if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; } if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; } + if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; } if (msg.type === 'agent_history_list') { if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) { renderAgentHistory(msg.payload.events || []); @@ -4621,7 +4622,9 @@ '' + escapeHtml(modelText) + '' + (w.gpus ? 'GPU ' + escapeHtml(w.gpus) + '' : '') + '' + stat + '' + - ''; + '' + + // Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.) + (w.service === 'llm' ? renderOpenAiEndpoint(w) : ''); }).join(''); // Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle // GPUs + Tokens), sonst irgendein online Worker des Node. @@ -4634,6 +4637,62 @@ }).join(''); } + // ── OpenAI-Endpunkt einer LLM-Box (fuer OpenHands, Continue, ...) ── + // Der PORT kommt immer aus der .env der Box (Docker-Port-Mapping, steht beim + // Container-Start fest) und wird hier nur angezeigt. Der API-Key ist hier + // setzbar — ausser er steht in der .env, dann hat die Vorrang und das Feld + // ist schreibgeschuetzt. + function renderOpenAiEndpoint(w) { + const id = 'oa-' + (w.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-'); + const port = w.openaiPort || '8081'; + const bind = w.openaiBind || '127.0.0.1'; + const lanOpen = bind === '0.0.0.0'; + const locked = !!w.openaiEnvLocked; + const keyState = w.openaiKeySet + ? 'Key gesetzt' + : 'kein Key — Endpunkt offen'; + const warn = (lanOpen && !w.openaiKeySet) + ? '
⚠ Im LAN erreichbar OHNE Key — jeder im Netz kann das Modell nutzen.
' + : ''; + const field = locked + ? '' + + '🔒 .env hat Vorrang' + : '' + + ''; + return '
' + + '
OpenAI-Endpunkt (OpenHands & Co.): ' + + 'http://' + escapeHtml(bind === '0.0.0.0' ? '<box-ip>' : '127.0.0.1') + + ':' + escapeHtml(port) + '/v1 · ' + keyState + '
' + + '
' + field + '
' + + '
' + + '
Port und Erreichbarkeit kommen aus der .env der Box ' + + '(LLM_OPENAI_PORT, LLM_OPENAI_BIND) — sie stehen beim Container-Start fest.
' + + warn + '
'; + } + + function saveOpenAiKey(instanceId, fieldId) { + const el = document.getElementById(fieldId); + const msg = document.getElementById(fieldId + '-msg'); + if (!el) return; + openAiReqId = 'oa_' + Date.now() + '_' + Math.floor(Math.random() * 100000); + if (msg) msg.textContent = 'speichere …'; + send({ action: 'llm_openai_config', instanceId, apiKey: el.value, requestId: openAiReqId }); + } + let openAiReqId = null; + + function onOpenAiConfigResult(p) { + if (!p || (openAiReqId && p.requestId && p.requestId !== openAiReqId)) return; + const id = 'oa-' + String(p.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-'); + const msg = document.getElementById(id + '-msg'); + if (!msg) return; + msg.innerHTML = p.ok + ? '✓ ' + escapeHtml(p.message || 'gespeichert') + '' + : '✗ ' + escapeHtml(p.error || 'fehlgeschlagen') + ''; + } + // Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der // Auslastung-Button pro Node → Live nvidia-smi + Graphen). function gotoComputeFleet() { diff --git a/diagnostic/server.js b/diagnostic/server.js index a45f9b1..b75d1bc 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -656,6 +656,8 @@ function workerList() { return Array.from(workers.values()).map(w => ({ instanceId: w.instanceId, service: w.service, node: w.node, gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy, + openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "", + openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked, online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS, })); } @@ -1180,6 +1182,8 @@ function connectRVS(forcePlain) { } else if (msg.type === "rooms_info") { // Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose). broadcast({ type: "rooms_info", payload: msg.payload || {} }); + } else if (msg.type === "llm_openai_config_result") { + broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} }); } else if (msg.type === "sat_result") { // Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout). broadcast({ type: "sat_result", payload: msg.payload || {} }); @@ -1209,6 +1213,11 @@ function connectRVS(forcePlain) { instanceId: p.instanceId, service: p.service || "", node: p.node || "", gpus: p.gpus || "", model: p.model || "", models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []), + // OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der + // Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob + // die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt). + openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "", + openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked, busy: !!prev.busy, last_seen: Date.now(), }); broadcastWorkers(); @@ -2964,6 +2973,17 @@ wss.on("connection", (ws) => { }, timestamp: Date.now(), }); + } else if (msg.action === "llm_openai_config") { + // Browser setzt den API-Key des OpenAI-Endpunkts einer Box. + sendToRVS_raw({ + type: "llm_openai_config", + payload: { + instanceId: String(msg.instanceId || ""), + apiKey: String(msg.apiKey || ""), + requestId: String(msg.requestId || ""), + }, + timestamp: Date.now(), + }); } else if (msg.action === "agent_history_query") { // Browser will die gespeicherte Historie eines Agenten — die Bridge // antwortet mit agent_history_list (auf derselben rvsWs). diff --git a/rvs/server.js b/rvs/server.js index 063f7b4..593c8a7 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -113,6 +113,9 @@ const ALLOWED_TYPES = new Set([ // OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312), // und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet. "worker_hello", "worker_ping", "worker_update", "worker_list", + // OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt + // immer aus der .env der Box — er ist ein Docker-Port-Mapping). + "llm_openai_config", "llm_openai_config_result", "node_stats", "node_stats_stream_start", "node_stats_stream_stop", "node_stats_history_request", "node_stats_history", "node_stats_reset", "node_stats_reset_done", diff --git a/xtts/.env.example b/xtts/.env.example index edff6bf..b711673 100644 --- a/xtts/.env.example +++ b/xtts/.env.example @@ -56,3 +56,32 @@ RVS_SNI= # WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic) # WHISPER_LANGUAGE=de # LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml + +# ─── OpenAI-Endpunkt des lokalen LLM (optional) ─── +# llama-swap ist OpenAI-kompatibel (/v1/chat/completions, /v1/models). Damit +# koennen Fremd-Tools direkt auf das lokale Modell: OpenHands, Continue, +# Open WebUI, Aider ... Base-URL dann: http://:/v1 +# +# LLM_OPENAI_BIND steuert, WER drankommt: +# 127.0.0.1 nur diese Maschine (Default, sicher) +# 0.0.0.0 das ganze LAN → dann UNBEDINGT LLM_OPENAI_KEY setzen! +LLM_OPENAI_BIND=127.0.0.1 +LLM_OPENAI_PORT=8081 +# API-Key. Leer = Endpunkt ohne Authentifizierung (nur mit BIND=127.0.0.1 ok). +# WICHTIG: Steht hier ein Wert, hat er VORRANG — das Feld ist dann in der ARIA- +# Diagnostic schreibgeschuetzt. Leer lassen, wenn Du den Key lieber dort pro Box +# setzen willst (er wird dann auf der Box unter /models/openai_key.json abgelegt). +# Der Key wirkt, sobald ein Modell das naechste Mal geladen wird. +LLM_OPENAI_KEY= + +# ─── OpenHands (optional, eigenes Profil) ───────── +# Standalone-Entwickler-Agent, der das lokale LLM nutzt. Start: +# COMPOSE_PROFILES=llm,openhands docker compose up -d +# Dann http://: aufrufen. +# ACHTUNG: OpenHands startet eigene Arbeits-Container und bekommt dafuer den +# Docker-Socket — das ist faktisch Root-Zugriff auf diese Maschine. +OPENHANDS_BIND=127.0.0.1 +OPENHANDS_PORT=3000 +# Wie OpenHands das lokale Modell anspricht (Container-intern, daher :8080): +OPENHANDS_LLM_BASE_URL=http://llama-swap:8080/v1 +OPENHANDS_LLM_MODEL=openai/qwen3-8b diff --git a/xtts/README.md b/xtts/README.md index b62685b..696823c 100644 --- a/xtts/README.md +++ b/xtts/README.md @@ -70,3 +70,71 @@ nvidia-smi # Host sieht die GPU docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready ``` + +## Lokales LLM von außen nutzen (OpenAI-Endpunkt) + +`llama-swap` spricht **OpenAI-kompatibel** (`/v1/chat/completions`, `/v1/models`). +Damit können Fremd-Tools direkt auf das lokale Modell: **OpenHands**, Continue, +Open WebUI, Aider … + +``` +Base-URL: http://:/v1 +Modell: qwen3-8b (oder ein anderer Key aus llama-swap/config.yaml) +API-Key: LLM_OPENAI_KEY (falls gesetzt) +``` + +In der `.env`: + +| Variable | Bedeutung | +|---|---| +| `LLM_OPENAI_BIND` | `127.0.0.1` = nur diese Maschine (**Default, sicher**) · `0.0.0.0` = ganzes LAN | +| `LLM_OPENAI_PORT` | Host-Port (Default `8081`) | +| `LLM_OPENAI_KEY` | API-Key; leer = **ohne Authentifizierung** | + +> ⚠️ `LLM_OPENAI_BIND=0.0.0.0` **ohne** `LLM_OPENAI_KEY` heißt: jeder im Netz kann +> das Modell benutzen. Entweder Key setzen oder auf localhost gebunden lassen. + +**Wo wird was konfiguriert?** + +- **Port und Erreichbarkeit** immer in der `.env`. Beides ist ein Docker-Port-Mapping + und steht beim Container-Start fest — es lässt sich nicht aus der Ferne ändern. +- **API-Key**: entweder in der `.env` **oder** — wenn dort leer — in der ARIA-Diagnostic + unter *Compute-Flotte* bei der jeweiligen Box. Dort abgelegt landet er auf der Box + unter `/models/openai_key.json` (Rechte 0600). +- **Die `.env` hat immer Vorrang.** Steht `LLM_OPENAI_KEY` dort, ist das Feld in der + Diagnostic **schreibgeschützt** (🔒-Hinweis). +- Ein geänderter Key greift, sobald ein Modell das **nächste Mal geladen** wird + (llama-server bekommt ihn per `--api-key`). Laufendes Modell also einmal entladen + lassen (TTL) oder ein anderes Modell anfragen. + +Prüfen: +```bash +curl -s http://localhost:8081/v1/models # ohne Key +curl -s -H "Authorization: Bearer $LLM_OPENAI_KEY" \ + http://localhost:8081/v1/models # mit Key +``` + +## OpenHands (optionales Profil) + +Wer die Box **standalone** als Entwickler-Agent nutzen will, startet OpenHands gleich mit: + +```bash +COMPOSE_PROFILES=llm,openhands docker compose up -d +``` + +Dann `http://:` (Default `3000`) öffnen. Das lokale Modell ist +schon vorbelegt (`OPENHANDS_LLM_BASE_URL`, `OPENHANDS_LLM_MODEL`) und in der +Oberfläche änderbar. + +| Variable | Bedeutung | +|---|---| +| `OPENHANDS_BIND` | `127.0.0.1` (Default) oder `0.0.0.0` fürs LAN | +| `OPENHANDS_PORT` | Host-Port (Default `3000`) | +| `OPENHANDS_LLM_BASE_URL` | Default `http://llama-swap:8080/v1` (Container-intern — **nicht** der Host-Port) | +| `OPENHANDS_LLM_MODEL` | Default `openai/qwen3-8b` | + +> ⚠️ OpenHands startet eigene Arbeits-Container und bekommt dafür den **Docker-Socket**. +> Das ist faktisch Root-Zugriff auf diese Maschine — nur im vertrauten Netz betreiben +> und nicht ungeschützt ins LAN/Internet hängen. + +Ohne das Profil ändert sich nichts: Wer nur ARIAs Box fahren will, startet OpenHands gar nicht. diff --git a/xtts/docker-compose.yml b/xtts/docker-compose.yml index 72a53bd..3c87528 100644 --- a/xtts/docker-compose.yml +++ b/xtts/docker-compose.yml @@ -139,6 +139,13 @@ services: # Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt # restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat. command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"] + # OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit + # koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das + # lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer + # Zugriff aus dem LAN LLM_OPENAI_BIND=0.0.0.0 setzen — dann aber unbedingt + # LLM_OPENAI_KEY vergeben, sonst kann jeder im Netz das Modell nutzen. + ports: + - "${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081}:8080" restart: unless-stopped # ─── Local-LLM-Adapter — RVS <-> llama.cpp ──── @@ -172,3 +179,33 @@ services: # Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig. - LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600} restart: unless-stopped + + # ─── OpenHands (optional, Standalone) ──────────────── + # Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den + # OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur + # ARIAs Box fahren will, startet es gar nicht erst. + # COMPOSE_PROFILES=llm,openhands docker compose up -d + # Danach http://:${OPENHANDS_PORT:-3000} und dort als Provider + # "OpenAI-kompatibel" mit Base-URL http://llama-swap:8080/v1 eintragen + # (aus dem Container heraus erreichbar), Modell z.B. qwen3-8b. + openhands: + image: docker.all-hands.dev/all-hands-ai/openhands:latest + container_name: aria-openhands + profiles: ["openhands"] + environment: + - SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest + - LOG_ALL_EVENTS=true + # Vorbelegung fuer das lokale Modell — in der Oberflaeche aenderbar. + - LLM_BASE_URL=${OPENHANDS_LLM_BASE_URL:-http://llama-swap:8080/v1} + - LLM_API_KEY=${LLM_OPENAI_KEY:-local} + - LLM_MODEL=${OPENHANDS_LLM_MODEL:-openai/qwen3-8b} + volumes: + # OpenHands startet seine Arbeits-Container selbst -> braucht den Socket. + # ACHTUNG: das gibt dem Container Kontrolle ueber den Docker-Host. + - /var/run/docker.sock:/var/run/docker.sock + - ./openhands-state:/.openhands + ports: + - "${OPENHANDS_BIND:-127.0.0.1}:${OPENHANDS_PORT:-3000}:3000" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py index bfc193c..b4c4b42 100644 --- a/xtts/llm-adapter/adapter.py +++ b/xtts/llm-adapter/adapter.py @@ -27,6 +27,8 @@ import asyncio import json import logging import os +import re +import shlex import time import httpx @@ -94,6 +96,44 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml") GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml") REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json") +# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ───── +# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest — +# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der +# Diagnostic gesetzt werden, ABER: steht er in der .env, hat die .env Vorrang +# und das Feld ist in der Diagnostic schreibgeschuetzt. +OPENAI_PORT = os.getenv("LLM_OPENAI_PORT", "8081").strip() or "8081" +OPENAI_BIND = os.getenv("LLM_OPENAI_BIND", "127.0.0.1").strip() or "127.0.0.1" +_OPENAI_KEY_ENV = os.getenv("LLM_OPENAI_KEY", "").strip() +OPENAI_KEY_FILE = os.getenv("LLM_OPENAI_KEY_FILE", "/models/openai_key.json") + + +def _openai_key() -> str: + """Gueltiger API-Key: .env gewinnt, sonst der in der Diagnostic gesetzte.""" + if _OPENAI_KEY_ENV: + return _OPENAI_KEY_ENV + try: + with open(OPENAI_KEY_FILE) as f: + return str((json.load(f) or {}).get("key") or "").strip() + except Exception: + return "" + + +def _openai_key_save(key: str) -> bool: + """Speichert den in der Diagnostic gesetzten Key. Bei .env-Vorrang: nein.""" + if _OPENAI_KEY_ENV: + return False + try: + os.makedirs(os.path.dirname(OPENAI_KEY_FILE), exist_ok=True) + tmp = OPENAI_KEY_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump({"key": key.strip()}, f) + os.replace(tmp, OPENAI_KEY_FILE) + os.chmod(OPENAI_KEY_FILE, 0o600) + return True + except Exception as exc: + logger.error("OpenAI-Key speichern fehlgeschlagen: %s", exc) + return False + # ── Auslastungs-Monitor (Stage E) ────────────────────────── import node_stats STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json") @@ -144,6 +184,19 @@ def _generate_config() -> int: f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"), "ttl": 3600, } + # API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) — + # llama-server prueft dann den Authorization-Header. Ohne Key bleibt der + # Endpunkt offen; deshalb ist er per Default nur an localhost gebunden. + key = _openai_key() + for k, spec in list(models.items()): + cmd = str((spec or {}).get("cmd") or "") + if not cmd: + continue + cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen + if key: + cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}" + models[k] = dict(spec, cmd=cmd) + out = dict(base) out["models"] = models try: @@ -270,6 +323,13 @@ async def _announce(ws) -> None: "instanceId": INSTANCE_ID, "service": WORKER_SERVICE, "node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL, "models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys) + # OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird + # NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env + # ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt). + "openaiPort": OPENAI_PORT, + "openaiBind": OPENAI_BIND, + "openaiKeySet": bool(_openai_key()), + "openaiEnvLocked": bool(_OPENAI_KEY_ENV), }) logger.info("worker_hello: models=%s", models) @@ -355,6 +415,45 @@ async def _do_llm_request(ws, payload: dict) -> None: }) +async def _handle_openai_config(ws, payload: dict) -> None: + """Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic). + + Der PORT wird hier bewusst NICHT geaendert: er ist ein Docker-Port-Mapping + und steht beim Container-Start fest — dafuer ist die .env zustaendig. + Steht der Key in der .env, gewinnt sie und wir lehnen ab. + """ + if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID: + return + req_id = payload.get("requestId", "") + key = str(payload.get("apiKey") or "").strip() + if _OPENAI_KEY_ENV: + await _send(ws, "llm_openai_config_result", { + "requestId": req_id, "instanceId": INSTANCE_ID, "ok": False, + "error": "Der API-Key steht in der .env dieser Box — die hat Vorrang. " + "Zum Aendern dort anpassen und die Box neu starten.", + }) + return + if not _openai_key_save(key): + await _send(ws, "llm_openai_config_result", { + "requestId": req_id, "instanceId": INSTANCE_ID, "ok": False, + "error": "Key konnte nicht gespeichert werden (Schreibrechte auf /models?)", + }) + return + # Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt. + _generate_config() + logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)", + "gesetzt" if key else "entfernt") + await _send(ws, "llm_openai_config_result", { + "requestId": req_id, "instanceId": INSTANCE_ID, "ok": True, + "keySet": bool(key), + "message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal " + "geladen wird — bereits laufende Modelle einmal entladen lassen " + "(TTL) oder ein anderes Modell anfragen." + if key else "API-Key entfernt — der Endpunkt ist jetzt offen."), + }) + await _announce(ws) + + async def _handle_provision(ws, payload: dict) -> None: """Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup (zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste.""" @@ -464,6 +563,9 @@ async def _run() -> None: # Auslastungs-Monitor (node_stats_*) abfangen. if await _stats.handle(ws, mtype, payload, _send): continue + if mtype == "llm_openai_config": + asyncio.create_task(_handle_openai_config(ws, payload)) + continue if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"): continue if mtype == "llm_provision_model":