diff --git a/app/api/admin.py b/app/api/admin.py index fb640bf..51642d4 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -19,6 +19,7 @@ from app.audit import log_admin_action from app.auth import CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store +from app.model_presets import LLM_KEYS, load_presets from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings from app.schemas import AdminUserPrefsUpdate, MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate, UserAdminUpdate @@ -451,6 +452,50 @@ async def delete_runtime_config(key: str, request: Request): return {"deleted": key} +# ── Erprobte KI-Modelle (Dropdown) ───────────────────────────────────────── + +class ModelSelect(BaseModel): + index: int + + +@router.get("/admin/models", dependencies=[Depends(require_admin)]) +async def list_model_presets(): + """Kuratierte Modell-Presets + aktuell aktives LLM (für das Admin-Dropdown).""" + presets = load_presets() + effective = {k: str(getattr(runtime_settings, k, "") or "") for k in LLM_KEYS} + overrides = get_store().get_config_overrides() + no_llm_override = not any(k in overrides for k in LLM_KEYS) + active = None + for p in presets: + if p["is_default"]: + if no_llm_override: + active = p["index"] + elif p["set"] and all(effective.get(k) == v for k, v in p["set"].items()): + if active is None: + active = p["index"] + return {"presets": presets, "current": effective, "active_index": active} + + +@router.post("/admin/models/select", dependencies=[Depends(require_admin)]) +async def select_model_preset(body: ModelSelect, request: Request): + """Wendet ein Preset an: Default -> LLM-Overrides löschen (Config-Standard); + sonst die Override-Keys des Presets setzen. Wirkt sofort (kein Neustart).""" + preset = next((p for p in load_presets() if p["index"] == body.index), None) + if preset is None: + raise HTTPException(status_code=404, detail=f"Preset {body.index} nicht gefunden.") + store = get_store() + if preset["is_default"]: + for key in LLM_KEYS: + store.delete_config_override(key) + log_admin_action(request, "model_preset_reset", label=preset["label"]) + else: + for key, value in preset["set"].items(): + store.set_config_override(key, value) + log_admin_action(request, "model_preset_set", label=preset["label"], set=preset["set"]) + invalidate_cache() + return {"selected": preset["label"], "set": preset["set"], "is_default": preset["is_default"]} + + # ── Live-Log (journalctl → WebSocket) ────────────────────────────────────── @router.websocket("/admin/log") diff --git a/app/model_presets.py b/app/model_presets.py new file mode 100644 index 0000000..5132a33 --- /dev/null +++ b/app/model_presets.py @@ -0,0 +1,43 @@ +"""Kuratierte „erprobte" KI-Modell-Presets fürs Admin-Dropdown (System › Status). + +Geladen aus ``config/models.yaml``. Jede Auswahl stellt Provider + Modell als +Laufzeit-Override um (siehe RUNTIME_SETTABLE) — ohne Neustart. Ein Preset mit +``default: true`` steht für den Config-Standard: seine Auswahl ENTFERNT die +LLM-Overrides, sodass wieder Profil/TOML greift. +""" +from pathlib import Path + +import yaml + +from app.runtime_config import RUNTIME_SETTABLE + +_PATH = Path(__file__).resolve().parent.parent / "config" / "models.yaml" + +# LLM-Schlüssel, die ein Preset/Reset berührt (Schnittmenge mit RUNTIME_SETTABLE). +LLM_KEYS = ("default_llm_provider", "openrouter_llm_model", "local_llm_model") + + +def load_presets() -> list[dict]: + """Liest die Presets aus der YAML. Unbekannte/leere Datei -> leere Liste. + Nur Keys aus RUNTIME_SETTABLE werden im `set` übernommen (Sicherheit).""" + try: + data = yaml.safe_load(_PATH.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return [] + out: list[dict] = [] + for i, p in enumerate(data.get("presets", []) or []): + if not isinstance(p, dict): + continue + sets = { + k: str(v) + for k, v in (p.get("set") or {}).items() + if k in RUNTIME_SETTABLE + } + out.append({ + "index": i, + "label": str(p.get("label") or f"Preset {i}"), + "note": str(p.get("note") or ""), + "is_default": bool(p.get("default")), + "set": sets, + }) + return out diff --git a/app/web/app.js b/app/web/app.js index 93783f9..a09fd10 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1570,13 +1570,36 @@ async function loadStatus() { const container = $("#status-content"); container.innerHTML = '

lade …

'; try { - const [cfg, met, runtimeCfg] = await Promise.all([ + const [cfg, met, runtimeCfg, models] = await Promise.all([ fetch("/api/config").then((r) => r.json()), fetch("/api/metrics").then((r) => r.json()), adminFetch("/api/admin/config"), + adminFetch("/api/admin/models"), ]); const cfgByKey = Object.fromEntries((runtimeCfg || []).map((d) => [d.key, d.effective_value])); const llmModel = cfgByKey["openrouter_llm_model"] || "—"; + + // Dropdown „Zentrales KI-Modell" (erprobte Presets, Auswahl wirkt sofort). + const presets = (models && models.presets) || []; + const activeIdx = models ? models.active_index : null; + const cur = (models && models.current) || {}; + const curProv = cur.default_llm_provider || "—"; + const curModel = curProv === "openrouter" + ? (cur.openrouter_llm_model || "—") : (cur.local_llm_model || "—"); + const modelOptions = presets.map((p) => + ``).join(""); + const modelCard = presets.length ? ` +
+

Zentrales KI-Modell

+

Erprobte Modelle — Auswahl wirkt sofort (kein Neustart).

+
+ + +
+

Aktiv: ${escHtml(curProv)} · ${escHtml(curModel)}

+
` : ""; const route = cfg.default_route || {}; const counters = met.counters || {}; const turnsTotal = counters["turns_total"] || 0; @@ -1602,6 +1625,8 @@ async function loadStatus() { + ${modelCard} +

Provider (Standard-Route)

@@ -1633,12 +1658,29 @@ async function loadStatus() {
`; + wireModelPicker(); } catch (e) { console.error("loadStatus error:", e); container.innerHTML = '

Fehler beim Laden.

'; } } +// Dropdown „Zentrales KI-Modell": Auswahl wendet ein Preset an (Laufzeit-Override). +function wireModelPicker() { + const sel = $("#model-preset"); + const msg = $("#model-preset-msg"); + if (!sel) return; + sel.addEventListener("change", async () => { + msg.textContent = "stelle um …"; + sel.disabled = true; + const res = await adminFetch("/api/admin/models/select", "POST", { index: +sel.value }); + sel.disabled = false; + if (res === null) { msg.textContent = "Fehler beim Umstellen"; return; } + msg.textContent = "✓ " + (res.selected || "umgestellt"); + loadStatus(); // Karte + „Aktiv"-Anzeige aktualisieren + }); +} + // ════════════════════════════════════════ // TAB: WARTUNG (Konfiguration) // ════════════════════════════════════════ diff --git a/app/web/index.html b/app/web/index.html index 27510af..f5cfdb8 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -411,6 +411,6 @@ - + diff --git a/config/models.yaml b/config/models.yaml new file mode 100644 index 0000000..969b155 --- /dev/null +++ b/config/models.yaml @@ -0,0 +1,31 @@ +# Erprobte zentrale KI-Modelle für das Admin-Dropdown (System › Status). +# Die Auswahl stellt Provider + Modell als Laufzeit-Override um (kein Neustart). +# +# Felder je Eintrag: +# label Anzeigename im Dropdown (Pflicht) +# note optionaler Hinweis (z. B. Voraussetzungen) +# default true = "Standard aus der Konfiguration"; Auswahl ENTFERNT die Overrides +# set Map von Laufzeit-Schlüsseln -> Wert (nur Keys aus RUNTIME_SETTABLE wirken) +# +# Nur diese LLM-Schlüssel werden ausgewertet: +# default_llm_provider, openrouter_llm_model, local_llm_model +presets: + - label: "Mistral Small 24B — Standard" + default: true + note: "Standard aus der Konfiguration (OpenRouter)" + + - label: "DeepSeek V4 Flash (OpenRouter)" + set: + default_llm_provider: "openrouter" + openrouter_llm_model: "deepseek/deepseek-v4-flash" + + - label: "DeepSeek V4 Pro (OpenRouter)" + set: + default_llm_provider: "openrouter" + openrouter_llm_model: "deepseek/deepseek-v4-pro" + + - label: "Ollama: Gemma 3 (lokal)" + note: "Ollama muss laufen (make llm-ollama)" + set: + default_llm_provider: "local-openai-compatible" + local_llm_model: "gemma3:latest" diff --git a/tests/test_model_presets.py b/tests/test_model_presets.py new file mode 100644 index 0000000..cec8ba0 --- /dev/null +++ b/tests/test_model_presets.py @@ -0,0 +1,80 @@ +"""Erprobte KI-Modell-Presets (Admin-Dropdown). + +Getestet: Laden der Presets, GET /api/admin/models (aktives Preset), +POST /api/admin/models/select (Override setzen bzw. Default = Overrides löschen). +""" +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.config import settings +from app.main import app +from app.model_presets import LLM_KEYS, load_presets +from app.runtime_config import invalidate_cache + +client = TestClient(app) +ADM_HDR = {"X-Admin-Key": "test-admin-key"} + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", "test-admin-key") + monkeypatch.setattr(settings, "auth_enabled", False) + invalidate_cache() + yield + # Aufräumen: alle LLM-Overrides entfernen, Cache leeren. + store = deps.get_store() + for k in LLM_KEYS: + store.delete_config_override(k) + invalidate_cache() + + +def test_load_presets_has_default_and_sets(): + presets = load_presets() + assert presets, "config/models.yaml liefert Presets" + assert any(p["is_default"] for p in presets) # genau ein Standard + ds = next(p for p in presets if "DeepSeek V4 Flash" in p["label"]) + assert ds["set"]["openrouter_llm_model"] == "deepseek/deepseek-v4-flash" + assert ds["set"]["default_llm_provider"] == "openrouter" + + +def test_get_models_default_active_without_overrides(): + r = client.get("/api/admin/models", headers=ADM_HDR) + assert r.status_code == 200 + body = r.json() + default_idx = next(p["index"] for p in body["presets"] if p["is_default"]) + assert body["active_index"] == default_idx # ohne Override ist der Standard aktiv + + +def test_select_sets_override_and_marks_active(): + presets = client.get("/api/admin/models", headers=ADM_HDR).json()["presets"] + ds_idx = next(p["index"] for p in presets if "DeepSeek V4 Flash" in p["label"]) + r = client.post("/api/admin/models/select", headers=ADM_HDR, json={"index": ds_idx}) + assert r.status_code == 200 + # Override ist gesetzt … + ov = deps.get_store().get_config_overrides() + assert ov.get("openrouter_llm_model") == "deepseek/deepseek-v4-flash" + # … und das Preset wird als aktiv gemeldet. + assert client.get("/api/admin/models", headers=ADM_HDR).json()["active_index"] == ds_idx + + +def test_select_default_clears_overrides(): + presets = client.get("/api/admin/models", headers=ADM_HDR).json()["presets"] + ds_idx = next(p["index"] for p in presets if "DeepSeek V4 Flash" in p["label"]) + def_idx = next(p["index"] for p in presets if p["is_default"]) + client.post("/api/admin/models/select", headers=ADM_HDR, json={"index": ds_idx}) + assert "openrouter_llm_model" in deps.get_store().get_config_overrides() + # Standard wählen -> Overrides weg. + client.post("/api/admin/models/select", headers=ADM_HDR, json={"index": def_idx}) + ov = deps.get_store().get_config_overrides() + assert not any(k in ov for k in LLM_KEYS) + + +def test_select_unknown_index_404(): + r = client.post("/api/admin/models/select", headers=ADM_HDR, json={"index": 999}) + assert r.status_code == 404 + + +def test_models_requires_admin(): + r = client.get("/api/admin/models") # ohne Admin-Key + assert r.status_code in (401, 403)