diff --git a/backend/.env.example b/backend/.env.example index 1d5114b..0ef42ef 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,8 +14,8 @@ KANSHO_PROVIDER_KEY= # KANSHO_PROVIDER_ZDR=1 # KANSHO_PROVIDER_NO_TRAIN=1 -# Detect: Klartext lokal, in Development/Test, oder in Production mit KANSHO_ALLOW_REMOTE_DETECT. -# Leer = Generate-Key mitnutzen (nur wenn remote Detect erlaubt ist). +# Detect: lokal (Ollama LAN) oder in Development/Test remote. Production remote nur mit KANSHO_ALLOW_REMOTE_DETECT. +# URL/Modell liegen in LLM-Profilen (Admin → Schnittstellen), nicht in der .env. KANSHO_DETECT_PROVIDER_KEY= # KANSHO_DETECT_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions # KANSHO_DETECT_PROVIDER_MODEL=openai/gpt-4.1-nano diff --git a/backend/db.py b/backend/db.py index 23ce2a6..e9db051 100644 --- a/backend/db.py +++ b/backend/db.py @@ -580,6 +580,7 @@ def init_db() -> None: _seed_prompts(conn) from provider_settings import seed_provider_settings + _ensure_columns(conn, "provider_settings", {"profile_id": "TEXT"}) seed_provider_settings(conn) _ensure_columns(conn, "profiles", _PROFILE_COLUMNS) _ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS) diff --git a/backend/migrations/023_llm_profiles.sql b/backend/migrations/023_llm_profiles.sql new file mode 100644 index 0000000..7bffcc6 --- /dev/null +++ b/backend/migrations/023_llm_profiles.sql @@ -0,0 +1,16 @@ +-- LLM profiles: named, reusable generate/detect endpoint+model presets. +-- Keys stay in env. Active assignment remains provider_settings. + +CREATE TABLE IF NOT EXISTS llm_profiles ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + zdr INTEGER NOT NULL DEFAULT 1, + no_train INTEGER NOT NULL DEFAULT 1, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text, + updated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text +); + +ALTER TABLE provider_settings ADD COLUMN IF NOT EXISTS profile_id TEXT; diff --git a/backend/model_catalog.py b/backend/model_catalog.py index 9b6fb25..f7940c8 100644 --- a/backend/model_catalog.py +++ b/backend/model_catalog.py @@ -263,17 +263,77 @@ def list_provider_models(config) -> list[dict]: url = getattr(config, "url", "") or "" if not url: return [] - catalog_key = models_url(url) + key = getattr(config, "key", "") or "" + items = _list_openai_models(url, key) + if items: + return items + return _list_ollama_tags(url) + + +def list_models_for_url(url: str, key: str = "") -> list[dict]: + from providers import ProviderConfig, is_local_url + + url = (url or "").strip() + if not url: + return [] + local = is_local_url(url) + return list_provider_models( + ProviderConfig( + role="generate", + name="ollama" if local else "openrouter", + mode="http", + url=url, + model="", + key=key, + local=local, + zdr=True, + no_train=True, + ) + ) + + +def _list_openai_models(chat_url: str, key: str) -> list[dict]: + catalog_key = models_url(chat_url) try: - rows = _request_models_payload(catalog_key, getattr(config, "key", "") or "") + rows = _request_models_payload(catalog_key, key) except JournalBudgetError: return [] + return _ids_from_rows(rows, id_keys=("id",)) + + +def _list_ollama_tags(chat_url: str) -> list[dict]: + parsed = urlparse(chat_url or "") + if not parsed.scheme or not parsed.netloc: + return [] + tags_url = f"{parsed.scheme}://{parsed.netloc}/api/tags" + headers = {"Content-Type": "application/json"} + try: + response = httpx.get(tags_url, headers=headers, timeout=8.0) + except httpx.HTTPError: + return [] + if response.status_code >= 400: + return [] + try: + payload = response.json() + except ValueError: + return [] + rows = payload.get("models") if isinstance(payload, dict) else payload + if not isinstance(rows, list): + return [] + return _ids_from_rows(rows, id_keys=("name", "model", "id")) + + +def _ids_from_rows(rows: list, *, id_keys: tuple[str, ...]) -> list[dict]: seen: set[str] = set() items: list[dict] = [] for row in rows: if not isinstance(row, dict): continue - model_id = str(row.get("id") or "").strip() + model_id = "" + for key in id_keys: + model_id = str(row.get(key) or "").strip() + if model_id: + break if not model_id or model_id in seen: continue seen.add(model_id) diff --git a/backend/provider_settings.py b/backend/provider_settings.py index 8418a2c..7bb994f 100644 --- a/backend/provider_settings.py +++ b/backend/provider_settings.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import os +import uuid from pathlib import Path from urllib.parse import urlparse @@ -16,6 +17,8 @@ from env_loader import ( SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json" ROLES = ("generate", "detect") +OLLAMA_LAN_ID = "llm-ollama-lan" +OLLAMA_LAN_URL = "http://192.168.2.144:11434/v1/chat/completions" _KEY_ENV = { "generate": "KANSHO_PROVIDER_KEY", "detect": "KANSHO_DETECT_PROVIDER_KEY", @@ -28,7 +31,7 @@ ROLE_META = { }, "detect": { "title": "Maskierung", - "task": "Eigener Detect-Endpunkt und eigenes Modell. Vollständige semantische Detection des persönlichen Egress. Ziel: lokales Detect-Modell. Übergang: externes Klartext-Detect nur mit Operator-Freigabe. Kein Pattern-Fallback.", + "task": "Eigener Detect-Endpunkt und eigenes Modell. Vollständige semantische Detection des persönlichen Egress. Ziel: lokales Detect-Modell (Ollama). Übergang: externes Klartext-Detect nur mit Operator-Freigabe. Kein Pattern-Fallback.", }, } @@ -41,6 +44,38 @@ class SettingsError(Exception): self.status_code = status_code +def _validate_url(url: str) -> str: + url = (url or "").strip() + if not url: + return "" + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise SettingsError("provider_url_invalid", "URL muss http(s) sein.") + return url + + +def _profile_row(conn, profile_id: str) -> dict | None: + row = conn.execute("SELECT * FROM llm_profiles WHERE id = ?", (profile_id,)).fetchone() + return dict(row) if row else None + + +def _public_profile(row: dict) -> dict: + from providers import is_local_url + + url = row.get("url") or "" + return { + "id": row.get("id") or "", + "title": row.get("title") or "", + "name": row.get("name") or "", + "url": url, + "model": row.get("model") or "", + "zdr": bool(row.get("zdr", 1)), + "no_train": bool(row.get("no_train", 1)), + "local": is_local_url(url), + "updated": row.get("updated") or "", + } + + def seed_provider_settings(conn) -> None: items = json.loads(SEED_PATH.read_text(encoding="utf-8")) for item in items: @@ -64,6 +99,228 @@ def seed_provider_settings(conn) -> None: 1 if item.get("no_train", True) else 0, ), ) + seed_llm_profiles(conn) + + +def seed_llm_profiles(conn) -> None: + """Named presets. Existing rows are not overwritten. Keys never stored.""" + ollama = conn.execute("SELECT id FROM llm_profiles WHERE id = ?", (OLLAMA_LAN_ID,)).fetchone() + if not ollama: + conn.execute( + """ + INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train) + VALUES (?, ?, ?, ?, ?, 1, 1) + """, + (OLLAMA_LAN_ID, "Ollama (LAN)", "ollama", OLLAMA_LAN_URL, ""), + ) + for role in ROLES: + row = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone() + if not row: + continue + data = dict(row) + profile_id = (data.get("profile_id") or "").strip() + if profile_id and _profile_row(conn, profile_id): + continue + url = (data.get("url") or "").strip() + model = (data.get("model") or "").strip() + if not url: + continue + match = conn.execute( + "SELECT id FROM llm_profiles WHERE url = ? AND model = ? ORDER BY created LIMIT 1", + (url, model), + ).fetchone() + if match: + chosen = match["id"] + else: + chosen = f"llm-{role}-current" + if _profile_row(conn, chosen): + chosen = str(uuid.uuid4()) + title = "Sprachmodell (aktuell)" if role == "generate" else "Maskierung (aktuell)" + conn.execute( + """ + INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + chosen, + title, + data.get("name") or "", + url, + model, + 1 if data.get("zdr", 1) else 0, + 1 if data.get("no_train", 1) else 0, + ), + ) + conn.execute( + "UPDATE provider_settings SET profile_id = ? WHERE role = ?", + (chosen, role), + ) + + +def list_profiles() -> list[dict]: + with get_db() as conn: + rows = conn.execute("SELECT * FROM llm_profiles ORDER BY title, created").fetchall() + return [_public_profile(dict(row)) for row in rows] + + +def get_profile(profile_id: str) -> dict | None: + with get_db() as conn: + row = _profile_row(conn, profile_id) + return _public_profile(row) if row else None + + +def _write_profile( + conn, + profile_id: str, + *, + title: str, + name: str, + url: str, + model: str, + zdr: bool, + no_train: bool, +) -> dict: + title = (title or "").strip() + if not title: + raise SettingsError("profile_title_required", "Ein Profil braucht einen Namen.") + url = _validate_url(url) + if not url: + raise SettingsError("provider_url_required", "Ein Profil braucht eine URL.") + model = (model or "").strip() + name = (name or "").strip() + existing = _profile_row(conn, profile_id) + if existing: + conn.execute( + """ + UPDATE llm_profiles + SET title = ?, name = ?, url = ?, model = ?, zdr = ?, no_train = ?, updated = datetime('now') + WHERE id = ? + """, + (title, name, url, model, 1 if zdr else 0, 1 if no_train else 0, profile_id), + ) + else: + conn.execute( + """ + INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (profile_id, title, name, url, model, 1 if zdr else 0, 1 if no_train else 0), + ) + for role in ROLES: + active = conn.execute( + "SELECT role FROM provider_settings WHERE role = ? AND profile_id = ?", + (role, profile_id), + ).fetchone() + if not active: + continue + conn.execute( + """ + UPDATE provider_settings + SET name = ?, url = ?, model = ?, zdr = ?, no_train = ?, updated = datetime('now') + WHERE role = ? + """, + (name, url, model, 1 if zdr else 0, 1 if no_train else 0, role), + ) + row = _profile_row(conn, profile_id) + return _public_profile(row or {}) + + +def create_profile(*, title: str, name: str, url: str, model: str, zdr: bool, no_train: bool) -> dict: + profile_id = str(uuid.uuid4()) + with get_db() as conn: + return _write_profile( + conn, + profile_id, + title=title, + name=name, + url=url, + model=model, + zdr=zdr, + no_train=no_train, + ) + + +def update_profile( + profile_id: str, + *, + title: str, + name: str, + url: str, + model: str, + zdr: bool, + no_train: bool, +) -> dict: + with get_db() as conn: + if not _profile_row(conn, profile_id): + raise SettingsError("profile_missing", "Profil fehlt.", 404) + return _write_profile( + conn, + profile_id, + title=title, + name=name, + url=url, + model=model, + zdr=zdr, + no_train=no_train, + ) + + +def delete_profile(profile_id: str) -> None: + with get_db() as conn: + if not _profile_row(conn, profile_id): + raise SettingsError("profile_missing", "Profil fehlt.", 404) + used = conn.execute( + "SELECT role FROM provider_settings WHERE profile_id = ?", + (profile_id,), + ).fetchall() + if used: + roles = ", ".join(row["role"] for row in used) + raise SettingsError( + "profile_in_use", + f"Profil ist noch der Stufe {roles} zugeordnet. Zuerst ein anderes Profil wählen.", + ) + conn.execute("DELETE FROM llm_profiles WHERE id = ?", (profile_id,)) + + +def activate_profile(role: str, profile_id: str) -> dict: + if role not in ROLES: + raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") + with get_db() as conn: + row = _profile_row(conn, profile_id) + if not row: + raise SettingsError("profile_missing", "Profil fehlt.", 404) + url = (row.get("url") or "").strip() + model = (row.get("model") or "").strip() + if role == "generate" and not url: + raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.") + if role == "detect" and not url: + raise SettingsError("provider_url_required", "Die Maskierung braucht eine URL.") + if not model: + raise SettingsError("provider_model_required", "Das Profil braucht ein Modell.") + conn.execute( + """ + INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(role) DO UPDATE SET + name = excluded.name, + url = excluded.url, + model = excluded.model, + zdr = excluded.zdr, + no_train = excluded.no_train, + profile_id = excluded.profile_id, + updated = datetime('now') + """, + ( + role, + row.get("name") or "", + url, + model, + 1 if row.get("zdr", 1) else 0, + 1 if row.get("no_train", 1) else 0, + profile_id, + ), + ) + return get_setting(role) or {} def get_setting(role: str) -> dict | None: @@ -74,34 +331,71 @@ def get_setting(role: str) -> dict | None: return dict(row) if row else None -def upsert_setting(role: str, *, name: str, url: str, model: str, zdr: bool, no_train: bool) -> dict: +def upsert_setting( + role: str, + *, + name: str, + url: str, + model: str, + zdr: bool, + no_train: bool, + profile_id: str | None = None, + save_as_title: str | None = None, +) -> dict: if role not in ROLES: raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") - url = (url or "").strip() + url = _validate_url(url) model = (model or "").strip() name = (name or "").strip() if role == "generate" and not url: raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.") - if url: - parsed = urlparse(url) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - raise SettingsError("provider_url_invalid", "URL muss http(s) sein.") if role == "generate" and not model: raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.") + title = (save_as_title or "").strip() with get_db() as conn: + current = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone() + current_id = (dict(current).get("profile_id") if current else "") or "" + chosen = (profile_id or "").strip() or current_id + if title: + chosen = str(uuid.uuid4()) + _write_profile( + conn, + chosen, + title=title, + name=name, + url=url, + model=model, + zdr=zdr, + no_train=no_train, + ) + elif chosen: + existing = _profile_row(conn, chosen) + if not existing: + raise SettingsError("profile_missing", "Profil fehlt.", 404) + _write_profile( + conn, + chosen, + title=existing.get("title") or title or role, + name=name, + url=url, + model=model, + zdr=zdr, + no_train=no_train, + ) conn.execute( """ - INSERT INTO provider_settings (role, name, url, model, zdr, no_train, updated) - VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(role) DO UPDATE SET name = excluded.name, url = excluded.url, model = excluded.model, zdr = excluded.zdr, no_train = excluded.no_train, + profile_id = excluded.profile_id, updated = datetime('now') """, - (role, name, url, model, 1 if zdr else 0, 1 if no_train else 0), + (role, name, url, model, 1 if zdr else 0, 1 if no_train else 0, chosen or None), ) return get_setting(role) or {} @@ -116,11 +410,11 @@ def set_role_key(role: str, key: str) -> None: def public_role_status(role: str) -> dict: - from providers import _is_local_url, detect_provider, generate_provider + from providers import detect_provider, generate_provider, is_local_url row = get_setting(role) or {} config = generate_provider() if role == "generate" else detect_provider() - local = _is_local_url(row.get("url") or "") + local = is_local_url(row.get("url") or "") own_key = bool((os.environ.get(_KEY_ENV[role]) or "").strip()) generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip()) if own_key: @@ -139,6 +433,7 @@ def public_role_status(role: str) -> dict: "model": row.get("model") or "", "zdr": bool(row.get("zdr", 1)), "no_train": bool(row.get("no_train", 1)), + "profile_id": row.get("profile_id") or "", "local": local, "key_present": key_source != "none", "key_source": key_source, @@ -154,8 +449,10 @@ def public_role_status(role: str) -> dict: def public_status() -> dict: return { "roles": [public_role_status(role) for role in ROLES], + "profiles": list_profiles(), "runtime_env": runtime_env(), - "note": "Zwei Stufen, zwei Modelle: Maskierung und Sprachmodell. Semantische Detection ist Pflicht vor Generate. Ziel bleibt lokales Detect. Externes Klartext-Detect in Production nur mit KANSHO_ALLOW_REMOTE_DETECT. Keys nur in .env.", + "note": "LLM-Profile speichern URL und Modell je Endpunkt. Maskierung und Sprachmodell wählen je ein Profil. Semantische Detection ist Pflicht vor Generate. LAN-Ollama gilt als lokal. Keys nur in .env.", "remote_plaintext_reason": remote_plaintext_detect_reason(), "remote_plaintext_allowed": allows_remote_plaintext_detect(), + "ollama_url": OLLAMA_LAN_URL, } diff --git a/backend/providers.py b/backend/providers.py index 5668acc..2667c7e 100644 --- a/backend/providers.py +++ b/backend/providers.py @@ -1,6 +1,7 @@ """Two independently configured OpenAI-compatible providers: generate vs detect.""" from __future__ import annotations +import ipaddress import json import os import re @@ -107,9 +108,27 @@ def _truthy(name: str) -> bool: return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} +def is_local_url(url: str) -> bool: + """Loopback, RFC1918/ULA and .local stay in the local trusted zone. + + Homelab Ollama (e.g. 192.168.2.144) is local, not an external provider. + """ + host = (urlparse(url or "").hostname or "").lower() + if not host: + return False + if host in {"localhost", "127.0.0.1", "::1", "ollama"}: + return True + if host.endswith(".local"): + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + return bool(ip.is_loopback or ip.is_private or ip.is_link_local) + + def _is_local_url(url: str) -> bool: - host = (urlparse(url).hostname or "").lower() - return host in {"localhost", "127.0.0.1", "::1", "ollama"} + return is_local_url(url) def _setting(role: str) -> dict: @@ -125,9 +144,9 @@ def _text(env_name: str, db_value: str | None, default: str = "") -> str: env = (os.environ.get(env_name) or "").strip() if env: return env - if db_value: - return str(db_value).strip() - return default + if db_value is None: + return default + return str(db_value).strip() def _flag(env_name: str, db_value, default: bool = True) -> bool: @@ -155,9 +174,11 @@ def generate_provider() -> ProviderConfig | None: key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() url = _text("KANSHO_PROVIDER_URL", row.get("url"), "https://openrouter.ai/api/v1/chat/completions") model = _text("KANSHO_PROVIDER_MODEL", row.get("model"), "openai/gpt-4o-mini") - local = _is_local_url(url) + local = is_local_url(url) zdr = local or _flag("KANSHO_PROVIDER_ZDR", row.get("zdr"), True) no_train = local or _flag("KANSHO_PROVIDER_NO_TRAIN", row.get("no_train"), True) + if not url or not model: + return None if not local and not key: return None if not local and (not zdr or not no_train): @@ -193,8 +214,10 @@ def detect_provider() -> ProviderConfig | None: if not url: return None model = _text("KANSHO_DETECT_PROVIDER_MODEL", row.get("model"), "openai/gpt-4.1-nano") + if not model: + return None key = (os.environ.get("KANSHO_DETECT_PROVIDER_KEY") or "").strip() - local = _is_local_url(url) + local = is_local_url(url) if not key and not local: key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() zdr = local or _flag("KANSHO_DETECT_ZDR", row.get("zdr"), True) diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 767d917..9c3bd14 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from pydantic import BaseModel, Field import json +import os from auth import require_admin_dep from db import get_db @@ -30,7 +31,18 @@ from identity_store import ( list_review_proposals, update_identity, ) -from provider_settings import ROLES, SettingsError, public_status, set_role_key, upsert_setting +from provider_settings import ( + ROLES, + SettingsError, + activate_profile, + create_profile, + delete_profile, + list_profiles, + public_status, + set_role_key, + update_profile, + upsert_setting, +) from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -196,19 +208,41 @@ class ProviderUpdate(BaseModel): model: str = "" zdr: bool = True no_train: bool = True + profile_id: str | None = None + save_as_title: str | None = None key: str | None = Field(default=None, description="Write-only. Never returned.") +class LlmProfileWrite(BaseModel): + title: str + name: str = "" + url: str + model: str = "" + zdr: bool = True + no_train: bool = True + + +class ActivateProfile(BaseModel): + profile_id: str + + @router.get("/providers") def admin_providers(session: dict = Depends(require_admin_dep)): return public_status() @router.get("/providers/models") -def admin_provider_models(session: dict = Depends(require_admin_dep)): - from model_catalog import list_provider_models +def admin_provider_models(url: str | None = None, session: dict = Depends(require_admin_dep)): + from model_catalog import list_models_for_url, list_provider_models from providers import detect_provider, generate_provider + if url: + key = "" + from providers import is_local_url + + if not is_local_url(url): + key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() + return {"url": url, "models": list_models_for_url(url, key)} return { "roles": { "generate": list_provider_models(generate_provider()), @@ -229,6 +263,8 @@ def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depen model=body.model, zdr=body.zdr, no_train=body.no_train, + profile_id=body.profile_id, + save_as_title=body.save_as_title, ) if body.key is not None and body.key.strip(): set_role_key(role, body.key) @@ -237,6 +273,64 @@ def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depen return public_status() +@router.post("/providers/{role}/activate") +def admin_activate_provider(role: str, body: ActivateProfile, session: dict = Depends(require_admin_dep)): + if role not in ROLES: + raise HTTPException(status_code=404, detail={"code": "unknown_provider_role", "message": "Unbekannte Provider-Rolle."}) + try: + activate_profile(role, body.profile_id) + except SettingsError as exc: + raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc + return public_status() + + +@router.get("/llm-profiles") +def admin_list_profiles(session: dict = Depends(require_admin_dep)): + return {"profiles": list_profiles()} + + +@router.post("/llm-profiles") +def admin_create_profile(body: LlmProfileWrite, session: dict = Depends(require_admin_dep)): + try: + profile = create_profile( + title=body.title, + name=body.name, + url=body.url, + model=body.model, + zdr=body.zdr, + no_train=body.no_train, + ) + except SettingsError as exc: + raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc + return {"profile": profile, **public_status()} + + +@router.put("/llm-profiles/{profile_id}") +def admin_update_profile(profile_id: str, body: LlmProfileWrite, session: dict = Depends(require_admin_dep)): + try: + profile = update_profile( + profile_id, + title=body.title, + name=body.name, + url=body.url, + model=body.model, + zdr=body.zdr, + no_train=body.no_train, + ) + except SettingsError as exc: + raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc + return {"profile": profile, **public_status()} + + +@router.delete("/llm-profiles/{profile_id}") +def admin_delete_profile(profile_id: str, session: dict = Depends(require_admin_dep)): + try: + delete_profile(profile_id) + except SettingsError as exc: + raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc + return public_status() + + class IdentityWrite(BaseModel): canonical_label: str = "" entity_type: str = "PERSON" diff --git a/backend/schema.sql b/backend/schema.sql index acea468..9a02634 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -455,6 +455,18 @@ CREATE TABLE IF NOT EXISTS writing_profile_versions ( UNIQUE (profile_id, seq) ); +CREATE TABLE IF NOT EXISTS llm_profiles ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + zdr INTEGER NOT NULL DEFAULT 1, + no_train INTEGER NOT NULL DEFAULT 1, + created TEXT NOT NULL DEFAULT (datetime('now')), + updated TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS provider_settings ( role TEXT PRIMARY KEY CHECK (role IN ('generate', 'detect')), name TEXT NOT NULL DEFAULT '', @@ -462,6 +474,7 @@ CREATE TABLE IF NOT EXISTS provider_settings ( model TEXT NOT NULL DEFAULT '', zdr INTEGER NOT NULL DEFAULT 1, no_train INTEGER NOT NULL DEFAULT 1, + profile_id TEXT, updated TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/backend/sqlite_to_postgres.py b/backend/sqlite_to_postgres.py index 7a230d9..e0dcd3a 100644 --- a/backend/sqlite_to_postgres.py +++ b/backend/sqlite_to_postgres.py @@ -63,6 +63,7 @@ TABLES = [ "writing_profile_reviews", "writing_profile_versions", "provider_settings", + "llm_profiles", "journal_generation_selection", "generation_guidelines", "app_settings", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 75a5e09..6e239c5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -31,6 +31,7 @@ UNIT_MODULES = frozenset( "test_local_backup", "test_provenance", "test_model_catalog", + "test_local_url", "test_privacy_detect_eval", "test_journal_shape", "test_journal_body", diff --git a/backend/tests/test_frame.py b/backend/tests/test_frame.py index 5620227..3851cbd 100644 --- a/backend/tests/test_frame.py +++ b/backend/tests/test_frame.py @@ -107,6 +107,37 @@ def main() -> None: expect(saved.status_code == 200, f"save generate settings {saved.text}") saved_roles = {item["role"]: item for item in saved.json()["roles"]} expect(saved_roles["generate"]["model"] == "openai/gpt-4o", "generate model persisted") + expect("profiles" in saved.json(), "llm profiles are listed") + titles = {item["title"] for item in saved.json()["profiles"]} + expect("Ollama (LAN)" in titles, "LAN Ollama profile is seeded") + ollama = next(item for item in saved.json()["profiles"] if item["title"] == "Ollama (LAN)") + expect(ollama["local"] is True, "LAN Ollama is a local trusted endpoint") + expect(ollama["url"].startswith("http://192.168.2.144:11434"), "Ollama URL is the agreed LAN host") + + switched = client.put( + "/api/admin/llm-profiles/" + ollama["id"], + headers=headers, + json={ + "title": "Ollama (LAN)", + "name": "ollama", + "url": ollama["url"], + "model": "llama3.1:latest", + "zdr": True, + "no_train": True, + }, + ) + expect(switched.status_code == 200, f"save ollama model {switched.text}") + activated = client.post( + "/api/admin/providers/detect/activate", + headers=headers, + json={"profile_id": ollama["id"]}, + ) + expect(activated.status_code == 200, f"activate ollama detect {activated.text}") + detect_role = {item["role"]: item for item in activated.json()["roles"]}["detect"] + expect(detect_role["local"] is True, "detect uses local ollama after profile switch") + expect(detect_role["model"] == "llama3.1:latest", "detect keeps the saved ollama model") + generate_role = {item["role"]: item for item in activated.json()["roles"]}["generate"] + expect(generate_role["model"] == "openai/gpt-4o", "generate profile stays on its own model") unknown = client.put( "/api/admin/providers/training", diff --git a/backend/tests/test_local_url.py b/backend/tests/test_local_url.py new file mode 100644 index 0000000..e285055 --- /dev/null +++ b/backend/tests/test_local_url.py @@ -0,0 +1,36 @@ +"""Local trusted-zone URL tests. No database.""" +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +os.environ.setdefault("KANSHO_PROVIDER_KEY", "") +os.environ.setdefault("KANSHO_DETECT_PROVIDER_KEY", "") +Path(tempfile.gettempdir()).mkdir(parents=True, exist_ok=True) + +from providers import is_local_url + + +def expect(ok: bool, message: str) -> None: + if not ok: + raise SystemExit(f"FAIL: {message}") + print(f"OK {message}") + + +def main() -> None: + expect(is_local_url("http://127.0.0.1:11434/v1/chat/completions"), "loopback is local") + expect(is_local_url("http://localhost:11434/v1/chat/completions"), "localhost is local") + expect(is_local_url("http://192.168.2.144:11434/v1/chat/completions"), "RFC1918 Ollama host is local") + expect(is_local_url("http://10.0.0.8:11434/v1/chat/completions"), "10/8 is local") + expect(is_local_url("http://ollama:11434/v1/chat/completions"), "compose hostname ollama is local") + expect(not is_local_url("https://openrouter.ai/api/v1/chat/completions"), "OpenRouter is not local") + expect(not is_local_url("https://8.8.8.8/v1/chat/completions"), "public IP is not local") + print("local url tests passed.") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_model_catalog.py b/backend/tests/test_model_catalog.py index 9a7fc28..5696ae7 100644 --- a/backend/tests/test_model_catalog.py +++ b/backend/tests/test_model_catalog.py @@ -210,6 +210,30 @@ def test_list_provider_models() -> None: expect(list_provider_models(fake) == [], "fake provider has no catalog") +def test_list_ollama_tags() -> None: + reset_catalog() + + def fake_get(url, *args, **kwargs): + if str(url).endswith("/api/tags"): + return FakeResponse({"models": [{"name": "llama3.1:latest"}, {"name": "qwen2.5:7b"}]}) + return FakeResponse({"nope": True}) + + config = ProviderConfig( + role="detect", + name="ollama", + mode="http", + url="http://192.168.2.144:11434/v1/chat/completions", + model="llama3.1:latest", + key="", + local=True, + zdr=True, + no_train=True, + ) + with patch("model_catalog.httpx.get", fake_get): + items = list_provider_models(config) + expect([item["id"] for item in items] == ["llama3.1:latest", "qwen2.5:7b"], "ollama tags fill the picker") + + def main() -> None: test_reads_context_and_completion() test_model_not_in_catalog() @@ -217,6 +241,7 @@ def main() -> None: test_cache_hit_and_ttl_expiry() test_env_fallback_and_fail_closed() test_list_provider_models() + test_list_ollama_tags() print("model catalog tests passed.") diff --git a/docs/architecture/functional/guardrails.md b/docs/architecture/functional/guardrails.md index c1a517c..7d5f818 100644 --- a/docs/architecture/functional/guardrails.md +++ b/docs/architecture/functional/guardrails.md @@ -705,6 +705,8 @@ Das ist kein Admin-Feature-Flag und kein Umdeuten von `KANSHO_ENV=production` na **Entschieden (Modellwahl):** Maskierung und Sprachmodell bleiben zwei getrennte Stufen. URL und Modell sind je Stufe in Admin → Schnittstellen wählbar. Das größere Modell gehört an Generate, nicht zwingend an Detect. +**Additiv 2026-09-08 (LLM-Profile / LAN-Ollama):** Benannte Profile speichern Endpunkt und Modell ohne Keys. Ein Wechsel lädt das gespeicherte Profil. RFC1918-Ollama (`192.168.2.144:11434`) liegt in der lokalen Trusted Zone; Detect dorthin ist kein externes Klartext-Detect. + **Später prüfen:** Flag entfernen, sobald lokales Detect auf Prod läuft. Dann wieder Production ohne Klartext-Detect. --- diff --git a/docs/architecture/technical/admin_diagnostics.md b/docs/architecture/technical/admin_diagnostics.md index 38c3d3c..2db7036 100644 --- a/docs/architecture/technical/admin_diagnostics.md +++ b/docs/architecture/technical/admin_diagnostics.md @@ -53,6 +53,8 @@ Nicht übernehmen: Mitai-Admin für Körpertarife, Coupons, Training Types als K **Additiv 2026-09-08:** Zwei Stufen mit je eigenem Modell. `GET /api/admin/providers/models` liefert den Katalog zur Auswahl (soft-fail). `remote_plaintext_reason` macht den Production-Übergang `KANSHO_ALLOW_REMOTE_DETECT` sichtbar, setzt ihn aber nicht. +**Additiv 2026-09-08 (LLM-Profile):** `GET/POST/PUT/DELETE /api/admin/llm-profiles`, `POST /api/admin/providers/{role}/activate`. Profile enthalten URL/Modell/ZDR, niemals Keys. LAN-Ollama (`192.168.2.144:11434`) ist lokal. + ## 4.2 Implementierungsstand (Dialog-Testspur) **Additiv 2026-08-26:** Lokale Identitätsregistry unter `/admin/identities`. Detect-Vorschläge sind unbestätigt. Compact-Diagnose enthält Detect-Abdeckung, Chunks, Kosten und Laufzeit, aber keine Labels. diff --git a/docs/architecture/technical/backend_and_api.md b/docs/architecture/technical/backend_and_api.md index de50567..67a1ef0 100644 --- a/docs/architecture/technical/backend_and_api.md +++ b/docs/architecture/technical/backend_and_api.md @@ -139,6 +139,8 @@ UI: `/admin/generation` mit getrennten Dimensionen, Karten und einem Editor nach **Status: Code vorhanden.** `GET /api/admin/providers`, `PUT /api/admin/providers/{generate|detect}`. Session-Admin. Body darf einen write-only `key` enthalten; die Antwort enthält ihn nicht. Persistenz: URL/Modell/Flags in `provider_settings`, Key in `backend/.env`. +**Additiv 2026-09-08:** `llm_profiles`. `POST /api/admin/providers/{role}/activate`, CRUD unter `/api/admin/llm-profiles`. Keys weiterhin nicht in der DB. + ## 5.4 Implementierungsstand (Debug-Persistenz) **Additiv 2026-08-28.** Session-Admin. Default aus. Nur eigenes Profil in Liste/Export. diff --git a/docs/architecture/technical/mvp_implementation.md b/docs/architecture/technical/mvp_implementation.md index a3c8795..4dde443 100644 --- a/docs/architecture/technical/mvp_implementation.md +++ b/docs/architecture/technical/mvp_implementation.md @@ -258,7 +258,7 @@ Additiv zum Slice, 2026-08-25. Kein Target-Model-Vorbau. - Provenance von Draft und Entry-Version ist relational (`journal_*_source_refs`); JSON-ID-Listen werden verlustfrei migriert. Source-Messages bleiben unangetastet. - Context Builder spricht Selection-Specs; Recency bleibt die aktuelle Retrieval-Heuristik, nicht die fachliche API. - Dialogzug kann begrenzte jüngere Original-Conversations desselben Space sehen, ohne denselben Journal Day voll zu laden. -- Externes Klartext-Detect ist kein Produktmodus (`KANSHO_ENV=production` ohne lokales Detect fail-closed). **Additiv 2026-09-08:** Operator darf mit `KANSHO_ALLOW_REMOTE_DETECT` den Übergang bis Ollama explizit öffnen; ohne Flag bleibt der Block. Lokales Detect bleibt austauschbare Rolle. Detect-Ausgabe persistiert keine aktive Identität. +- Externes Klartext-Detect ist kein Produktmodus (`KANSHO_ENV=production` ohne lokales Detect fail-closed). **Additiv 2026-09-08:** Operator darf mit `KANSHO_ALLOW_REMOTE_DETECT` den Übergang bis Ollama explizit öffnen; ohne Flag bleibt der Block. **Additiv 2026-09-08:** RFC1918-Ollama gilt als lokal; LLM-Profile speichern URL/Modell je Endpunkt. Lokales Detect bleibt austauschbare Rolle. Detect-Ausgabe persistiert keine aktive Identität. - Response Validation blockiert Klartext-Identität vor Demask. Dialog-Egress maskiert den ganzen Prompt inkl. Opening/Space-Kontext; ein Leak bricht den Zug nicht leer ab. Journal-Generate bricht bei einem Leak nach Retry nicht ab: lokales Quellenartefakt bzw. lokaler Entwurf, die leckende Modellantwort wird nicht verwendet. - Konsolidierung nutzt lokale Signale, nicht `count >= 2`. - `/dialog` ist Admin-Harness, nicht Produkt-IA. diff --git a/docs/architecture/technical/platform_extensibility.md b/docs/architecture/technical/platform_extensibility.md index 8bced77..c0dccd8 100644 --- a/docs/architecture/technical/platform_extensibility.md +++ b/docs/architecture/technical/platform_extensibility.md @@ -124,6 +124,7 @@ Ziel wie Mitai: **Einfachheit in der Nutzer-UX, Differenzierung in administrierb - UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides - System-Prompts vs. Instanz-Anpassungen (Reset auf Default) - Provider-URL, Modell und ZDR/No-Train für Generate und Detect (`provider_settings`, Admin Schnittstellen) +- **Additiv 2026-09-08:** benannte LLM-Profile (`llm_profiles`); aktive Zuordnung je Stufe. Secrets bleiben Env. - später: Privacy-Profile (Strict/Balanced), sobald fachlich konkret ### 4.2 Code / Env (nicht pro Klick im Admin) diff --git a/docs/architecture/technical/privacy_gateway.md b/docs/architecture/technical/privacy_gateway.md index e0c9898..0ba26c6 100644 --- a/docs/architecture/technical/privacy_gateway.md +++ b/docs/architecture/technical/privacy_gateway.md @@ -249,7 +249,9 @@ Default in `KANSHO_ENV=production`: externes Klartext-Detect bleibt fail-closed Operator-Übergang bis Ollama: `KANSHO_ALLOW_REMOTE_DETECT=1` (Compose `.env`, Container neu anlegen). Das Gateway, die Pflicht-Detection vor Generate, ZDR/No-Train für Generate und die Key-Trennung bleiben. Admin kann das Flag nicht setzen. URL und Modell je Stufe bleiben in `provider_settings`. -Status in `GET /api/admin/providers`: `remote_plaintext_allowed`, `remote_plaintext_reason` (`non_production` | `operator_override` | `blocked`). Modellauswahl: `GET /api/admin/providers/models` (Katalog, soft-fail). +**Additiv 2026-09-08 (LLM-Profile, LAN-Ollama):** `llm_profiles` speichert benannte URL/Modell-Presets ohne Keys. Jede Stufe (`generate` / `detect`) zeigt auf ein Profil; Wechsel kopiert die gespeicherten Felder, statt sie neu einzugeben. RFC1918-, Loopback- und `.local`-URLs gelten als lokale Trusted Zone. `http://192.168.2.144:11434/v1/chat/completions` ist damit lokales Detect, kein externes Klartext-Detect. Ollama muss auf dem Host auf `0.0.0.0:11434` lauschen; der Pi muss Port 11434 erreichen. + +Status in `GET /api/admin/providers`: `remote_plaintext_allowed`, `remote_plaintext_reason` (`non_production` | `operator_override` | `blocked`), `profiles`. Modellauswahl: `GET /api/admin/providers/models` (OpenRouter-Katalog oder Ollama `/api/tags`, soft-fail). ## 10. Offene Fragen diff --git a/frontend/src/pages/AdminProvidersPage.jsx b/frontend/src/pages/AdminProvidersPage.jsx index 800210f..b28362d 100644 --- a/frontend/src/pages/AdminProvidersPage.jsx +++ b/frontend/src/pages/AdminProvidersPage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { api } from '../api.js' import { useAuth } from '../context/AuthContext.jsx' @@ -9,13 +9,20 @@ const emptyRole = () => ({ model: '', zdr: true, no_train: true, - key: '' + profile_id: '', + key: '', + saveAsTitle: '' }) -const FALLBACK_MODELS = { - generate: ['openai/gpt-5.4', 'openai/gpt-4o', 'openai/gpt-4o-mini', 'anthropic/claude-sonnet-4'], - detect: ['openai/gpt-4.1-nano', 'openai/gpt-4o-mini', 'llama3.1'] -} +const emptyDraft = () => ({ + id: '', + title: '', + name: '', + url: 'http://192.168.2.144:11434/v1/chat/completions', + model: '', + zdr: true, + no_train: true +}) function statusLabel(role) { if (role.role === 'detect' && !role.url) return 'nicht konfiguriert, fail-closed' @@ -29,16 +36,21 @@ function statusLabel(role) { if (role.ready) return 'bereit' if (!role.key_present && !role.local) return 'Key fehlt' if (!role.url) return 'URL fehlt' + if (role.local && !role.model) return 'Modell fehlt' return 'nicht bereit' } function detectBanner(data) { if (!data) return '' + const detect = (data.roles || []).find((role) => role.role === 'detect') + if (detect?.local) { + return 'Maskierung läuft lokal (LAN/Ollama). Generate sieht nur den maskierten Kontext.' + } if (data.runtime_env === 'production' && data.remote_plaintext_reason === 'operator_override') { - return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Das Gateway bleibt aktiv; Generate sieht nur maskierten Kontext. Ziel bleibt ein lokales Detect-Modell.' + return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Ziel bleibt ein lokales Detect-Modell.' } if (data.runtime_env === 'production' && data.remote_plaintext_allowed === false) { - return 'Production blockiert externes Klartext-Detect. Maskierung braucht eine lokale URL oder die Operator-Freigabe, sonst entsteht kein Impuls.' + return 'Production blockiert externes Klartext-Detect. Maskierung braucht ein lokales Profil oder die Operator-Freigabe.' } return '' } @@ -47,53 +59,79 @@ export default function AdminProvidersPage() { const { session } = useAuth() const [data, setData] = useState(null) const [forms, setForms] = useState({}) - const [catalog, setCatalog] = useState({ generate: [], detect: [] }) + const [draft, setDraft] = useState(emptyDraft()) + const [catalog, setCatalog] = useState([]) const [error, setError] = useState('') const [notice, setNotice] = useState('') const [saving, setSaving] = useState('') + const applyPayload = (payload) => { + setData(payload) + const next = {} + for (const role of payload.roles || []) { + next[role.role] = { + role: role.role, + name: role.name || '', + url: role.url || '', + model: role.model || '', + zdr: Boolean(role.zdr), + no_train: Boolean(role.no_train), + profile_id: role.profile_id || '', + key: '', + saveAsTitle: '' + } + } + setForms(next) + } + const load = () => api('/api/admin/providers', { token: session.token }) - .then((payload) => { - setData(payload) - const next = {} - for (const role of payload.roles) { - next[role.role] = { - role: role.role, - name: role.name || '', - url: role.url || '', - model: role.model || '', - zdr: Boolean(role.zdr), - no_train: Boolean(role.no_train), - key: '' - } - } - setForms(next) - }) + .then(applyPayload) .catch((e) => setError(e.message)) - const loadCatalog = () => - api('/api/admin/providers/models', { token: session.token }) - .then((payload) => { - setCatalog({ - generate: payload.roles?.generate || [], - detect: payload.roles?.detect || [] - }) - }) - .catch(() => { - setCatalog({ generate: [], detect: [] }) - }) + const loadModels = (url) => { + if (!url) { + setCatalog([]) + return + } + api(`/api/admin/providers/models?url=${encodeURIComponent(url)}`, { token: session.token }) + .then((payload) => setCatalog(payload.models || payload.roles?.generate || [])) + .catch(() => setCatalog([])) + } - useEffect(() => { - load() - loadCatalog() - }, [session.token]) + useEffect(() => { load() }, [session.token]) + useEffect(() => { loadModels(draft.url) }, [draft.url, session.token]) - const update = (role, field, value) => { + const profiles = data?.profiles || [] + const usedIds = useMemo( + () => new Set((data?.roles || []).map((role) => role.profile_id).filter(Boolean)), + [data] + ) + + const updateRole = (role, field, value) => { setForms((prev) => ({ ...prev, [role]: { ...(prev[role] || emptyRole()), [field]: value } })) } - const save = async (role) => { + const applyRole = async (role, profileId) => { + setError('') + setNotice('') + setSaving(`activate-${role}`) + try { + const payload = await api(`/api/admin/providers/${role}/activate`, { + token: session.token, + method: 'POST', + body: { profile_id: profileId } + }) + applyPayload(payload) + setNotice(`${role === 'detect' ? 'Maskierung' : 'Sprachmodell'}: Profil übernommen.`) + } catch (err) { + setError(err.message) + } finally { + setSaving('') + } + } + + const saveRole = async (role) => { const form = forms[role] if (!form) return setError('') @@ -105,7 +143,9 @@ export default function AdminProvidersPage() { url: form.url, model: form.model, zdr: form.zdr, - no_train: form.no_train + no_train: form.no_train, + profile_id: form.profile_id || undefined, + save_as_title: form.saveAsTitle.trim() || undefined } if (form.key.trim()) body.key = form.key.trim() const payload = await api(`/api/admin/providers/${role}`, { @@ -113,10 +153,8 @@ export default function AdminProvidersPage() { method: 'PUT', body }) - setData(payload) - setForms((prev) => ({ ...prev, [role]: { ...prev[role], key: '' } })) - setNotice(form.key.trim() ? `${role}: gespeichert, Key in .env geschrieben.` : `${role}: gespeichert.`) - loadCatalog() + applyPayload(payload) + setNotice(form.saveAsTitle.trim() ? 'Neues Profil gespeichert und dieser Stufe zugeordnet.' : 'Stufe gespeichert. Zugeordnetes Profil wurde mitaktualisiert.') } catch (err) { setError(err.message) } finally { @@ -124,10 +162,50 @@ export default function AdminProvidersPage() { } } - const modelOptions = (role) => { - const fromCatalog = catalog[role] || [] - if (fromCatalog.length) return fromCatalog - return (FALLBACK_MODELS[role] || []).map((id) => ({ id, name: id })) + const saveDraft = async () => { + setError('') + setNotice('') + setSaving('draft') + try { + const body = { + title: draft.title, + name: draft.name, + url: draft.url, + model: draft.model, + zdr: draft.zdr, + no_train: draft.no_train + } + const payload = draft.id + ? await api(`/api/admin/llm-profiles/${draft.id}`, { token: session.token, method: 'PUT', body }) + : await api('/api/admin/llm-profiles', { token: session.token, method: 'POST', body }) + applyPayload(payload) + const saved = payload.profile || profiles.find((item) => item.id === draft.id) + setDraft(saved ? { ...saved } : emptyDraft()) + setNotice(draft.id ? 'Profil aktualisiert.' : 'Profil angelegt. Du kannst es den Stufen zuordnen, ohne die Felder neu einzugeben.') + } catch (err) { + setError(err.message) + } finally { + setSaving('') + } + } + + const removeProfile = async (profileId) => { + setError('') + setNotice('') + setSaving(`delete-${profileId}`) + try { + const payload = await api(`/api/admin/llm-profiles/${profileId}`, { + token: session.token, + method: 'DELETE' + }) + applyPayload(payload) + if (draft.id === profileId) setDraft(emptyDraft()) + setNotice('Profil gelöscht.') + } catch (err) { + setError(err.message) + } finally { + setSaving('') + } } const banner = detectBanner(data) @@ -136,26 +214,23 @@ export default function AdminProvidersPage() {

Schnittstellen

- Zwei Stufen, zwei eigene Modelle: Maskierung prüft den persönlichen Text, das Sprachmodell schreibt Dialogzug und Journalentwurf. - Pro Stufe URL und Modell getrennt wählen. Ohne Detection kein Generate. Der Secret-Key wird nie angezeigt und nie in der Datenbank gespeichert. + LLM-Profile merken URL, Modell und Policy. Maskierung und Sprachmodell wählen je ein Profil. + Beim Wechsel musst du die Daten nicht neu eintippen. Keys bleiben in der Umgebung, nicht im Profil.

- {data && ( -

Laufzeit: {data.runtime_env}

- )} + {data &&

Laufzeit: {data.runtime_env}

} {banner &&

{banner}

} {error &&

{error}

} {notice &&

{notice}

} + {data && ( <> {data.roles.map((role) => { const form = forms[role.role] || emptyRole() - const options = modelOptions(role.role) - const listId = `kansho-models-${role.role}` return (
{ e.preventDefault(); save(role.role) }} + onSubmit={(e) => { e.preventDefault(); saveRole(role.role) }} >

{role.title || role.role}

{role.task &&

{role.task}

} @@ -163,67 +238,174 @@ export default function AdminProvidersPage() {
Status
{statusLabel(role)}
Key
-
{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : 'fehlt'}
+
{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : (role.local ? 'nicht nötig (lokal)' : 'fehlt')}
Modus
{role.local ? 'lokal' : 'extern'} · {role.mode}
+ +
) })} -

Ohne ZDR und ohne Trainingsverbot bleibt das Sprachmodell fail-closed. Maskierung wählt keine Operation. Gateway lässt sich darüber nicht umgehen.

+ +

Gespeicherte Profile

+

Ein Profil kann Maskierung, Sprachmodell oder beiden Stufen dienen. Löschen geht nur, wenn es gerade keiner Stufe zugeordnet ist.

+ + +
{ e.preventDefault(); saveDraft() }}> +

{draft.id ? 'Profil bearbeiten' : 'Neues Profil'}

+ + + + + + +
+ + {draft.id && ( + + )} +
+
+

Ohne ZDR und ohne Trainingsverbot bleibt ein externes Sprachmodell fail-closed. Gateway lässt sich darüber nicht umgehen. Ollama auf der LAN-Adresse gilt als lokal.

)}