feat(xtts): OpenAI-Endpunkt fuers lokale LLM + OpenHands-Profil

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:30:37 +02:00
co-authored by Claude Opus 4.8
parent dc77933014
commit d6c7502810
7 changed files with 319 additions and 1 deletions
+102
View File
@@ -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":