Add selectable LLM profiles and treat LAN Ollama as local.
Some checks failed
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Failing after 2m40s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s

Saved presets keep URL and model per stage so detect can switch to Ollama without re-entering settings or sending plaintext to OpenRouter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-08 12:48:25 +02:00
parent af6fe55577
commit bef422a429
20 changed files with 903 additions and 114 deletions

View File

@ -14,8 +14,8 @@ KANSHO_PROVIDER_KEY=
# KANSHO_PROVIDER_ZDR=1 # KANSHO_PROVIDER_ZDR=1
# KANSHO_PROVIDER_NO_TRAIN=1 # KANSHO_PROVIDER_NO_TRAIN=1
# Detect: Klartext lokal, in Development/Test, oder in Production mit KANSHO_ALLOW_REMOTE_DETECT. # Detect: lokal (Ollama LAN) oder in Development/Test remote. Production remote nur mit KANSHO_ALLOW_REMOTE_DETECT.
# Leer = Generate-Key mitnutzen (nur wenn remote Detect erlaubt ist). # URL/Modell liegen in LLM-Profilen (Admin → Schnittstellen), nicht in der .env.
KANSHO_DETECT_PROVIDER_KEY= KANSHO_DETECT_PROVIDER_KEY=
# KANSHO_DETECT_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions # KANSHO_DETECT_PROVIDER_URL=https://openrouter.ai/api/v1/chat/completions
# KANSHO_DETECT_PROVIDER_MODEL=openai/gpt-4.1-nano # KANSHO_DETECT_PROVIDER_MODEL=openai/gpt-4.1-nano

View File

@ -580,6 +580,7 @@ def init_db() -> None:
_seed_prompts(conn) _seed_prompts(conn)
from provider_settings import seed_provider_settings from provider_settings import seed_provider_settings
_ensure_columns(conn, "provider_settings", {"profile_id": "TEXT"})
seed_provider_settings(conn) seed_provider_settings(conn)
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS) _ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS) _ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)

View File

@ -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;

View File

@ -263,17 +263,77 @@ def list_provider_models(config) -> list[dict]:
url = getattr(config, "url", "") or "" url = getattr(config, "url", "") or ""
if not url: if not url:
return [] 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: try:
rows = _request_models_payload(catalog_key, getattr(config, "key", "") or "") rows = _request_models_payload(catalog_key, key)
except JournalBudgetError: except JournalBudgetError:
return [] 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() seen: set[str] = set()
items: list[dict] = [] items: list[dict] = []
for row in rows: for row in rows:
if not isinstance(row, dict): if not isinstance(row, dict):
continue 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: if not model_id or model_id in seen:
continue continue
seen.add(model_id) seen.add(model_id)

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import os import os
import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@ -16,6 +17,8 @@ from env_loader import (
SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json" SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json"
ROLES = ("generate", "detect") ROLES = ("generate", "detect")
OLLAMA_LAN_ID = "llm-ollama-lan"
OLLAMA_LAN_URL = "http://192.168.2.144:11434/v1/chat/completions"
_KEY_ENV = { _KEY_ENV = {
"generate": "KANSHO_PROVIDER_KEY", "generate": "KANSHO_PROVIDER_KEY",
"detect": "KANSHO_DETECT_PROVIDER_KEY", "detect": "KANSHO_DETECT_PROVIDER_KEY",
@ -28,7 +31,7 @@ ROLE_META = {
}, },
"detect": { "detect": {
"title": "Maskierung", "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 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: def seed_provider_settings(conn) -> None:
items = json.loads(SEED_PATH.read_text(encoding="utf-8")) items = json.loads(SEED_PATH.read_text(encoding="utf-8"))
for item in items: for item in items:
@ -64,6 +99,228 @@ def seed_provider_settings(conn) -> None:
1 if item.get("no_train", True) else 0, 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: 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 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: if role not in ROLES:
raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.")
url = (url or "").strip() url = _validate_url(url)
model = (model or "").strip() model = (model or "").strip()
name = (name or "").strip() name = (name or "").strip()
if role == "generate" and not url: if role == "generate" and not url:
raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine 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: if role == "generate" and not model:
raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.") raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.")
title = (save_as_title or "").strip()
with get_db() as conn: 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( conn.execute(
""" """
INSERT INTO provider_settings (role, name, url, model, zdr, no_train, updated) INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated)
VALUES (?, ?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(role) DO UPDATE SET ON CONFLICT(role) DO UPDATE SET
name = excluded.name, name = excluded.name,
url = excluded.url, url = excluded.url,
model = excluded.model, model = excluded.model,
zdr = excluded.zdr, zdr = excluded.zdr,
no_train = excluded.no_train, no_train = excluded.no_train,
profile_id = excluded.profile_id,
updated = datetime('now') 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 {} 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: 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 {} row = get_setting(role) or {}
config = generate_provider() if role == "generate" else detect_provider() 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()) own_key = bool((os.environ.get(_KEY_ENV[role]) or "").strip())
generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip()) generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip())
if own_key: if own_key:
@ -139,6 +433,7 @@ def public_role_status(role: str) -> dict:
"model": row.get("model") or "", "model": row.get("model") or "",
"zdr": bool(row.get("zdr", 1)), "zdr": bool(row.get("zdr", 1)),
"no_train": bool(row.get("no_train", 1)), "no_train": bool(row.get("no_train", 1)),
"profile_id": row.get("profile_id") or "",
"local": local, "local": local,
"key_present": key_source != "none", "key_present": key_source != "none",
"key_source": key_source, "key_source": key_source,
@ -154,8 +449,10 @@ def public_role_status(role: str) -> dict:
def public_status() -> dict: def public_status() -> dict:
return { return {
"roles": [public_role_status(role) for role in ROLES], "roles": [public_role_status(role) for role in ROLES],
"profiles": list_profiles(),
"runtime_env": runtime_env(), "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_reason": remote_plaintext_detect_reason(),
"remote_plaintext_allowed": allows_remote_plaintext_detect(), "remote_plaintext_allowed": allows_remote_plaintext_detect(),
"ollama_url": OLLAMA_LAN_URL,
} }

View File

@ -1,6 +1,7 @@
"""Two independently configured OpenAI-compatible providers: generate vs detect.""" """Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import os import os
import re import re
@ -107,9 +108,27 @@ def _truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} 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: def _is_local_url(url: str) -> bool:
host = (urlparse(url).hostname or "").lower() return is_local_url(url)
return host in {"localhost", "127.0.0.1", "::1", "ollama"}
def _setting(role: str) -> dict: 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() env = (os.environ.get(env_name) or "").strip()
if env: if env:
return env return env
if db_value: if db_value is None:
return str(db_value).strip() return default
return default return str(db_value).strip()
def _flag(env_name: str, db_value, default: bool = True) -> bool: 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() key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
url = _text("KANSHO_PROVIDER_URL", row.get("url"), "https://openrouter.ai/api/v1/chat/completions") 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") 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) 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) 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: if not local and not key:
return None return None
if not local and (not zdr or not no_train): if not local and (not zdr or not no_train):
@ -193,8 +214,10 @@ def detect_provider() -> ProviderConfig | None:
if not url: if not url:
return None return None
model = _text("KANSHO_DETECT_PROVIDER_MODEL", row.get("model"), "openai/gpt-4.1-nano") 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() 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: if not key and not local:
key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip()
zdr = local or _flag("KANSHO_DETECT_ZDR", row.get("zdr"), True) zdr = local or _flag("KANSHO_DETECT_ZDR", row.get("zdr"), True)

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import json import json
import os
from auth import require_admin_dep from auth import require_admin_dep
from db import get_db from db import get_db
@ -30,7 +31,18 @@ from identity_store import (
list_review_proposals, list_review_proposals,
update_identity, 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 from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@ -196,19 +208,41 @@ class ProviderUpdate(BaseModel):
model: str = "" model: str = ""
zdr: bool = True zdr: bool = True
no_train: 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.") 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") @router.get("/providers")
def admin_providers(session: dict = Depends(require_admin_dep)): def admin_providers(session: dict = Depends(require_admin_dep)):
return public_status() return public_status()
@router.get("/providers/models") @router.get("/providers/models")
def admin_provider_models(session: dict = Depends(require_admin_dep)): def admin_provider_models(url: str | None = None, session: dict = Depends(require_admin_dep)):
from model_catalog import list_provider_models from model_catalog import list_models_for_url, list_provider_models
from providers import detect_provider, generate_provider 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 { return {
"roles": { "roles": {
"generate": list_provider_models(generate_provider()), "generate": list_provider_models(generate_provider()),
@ -229,6 +263,8 @@ def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depen
model=body.model, model=body.model,
zdr=body.zdr, zdr=body.zdr,
no_train=body.no_train, 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(): if body.key is not None and body.key.strip():
set_role_key(role, body.key) set_role_key(role, body.key)
@ -237,6 +273,64 @@ def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depen
return public_status() 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): class IdentityWrite(BaseModel):
canonical_label: str = "" canonical_label: str = ""
entity_type: str = "PERSON" entity_type: str = "PERSON"

View File

@ -455,6 +455,18 @@ CREATE TABLE IF NOT EXISTS writing_profile_versions (
UNIQUE (profile_id, seq) 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 ( CREATE TABLE IF NOT EXISTS provider_settings (
role TEXT PRIMARY KEY CHECK (role IN ('generate', 'detect')), role TEXT PRIMARY KEY CHECK (role IN ('generate', 'detect')),
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
@ -462,6 +474,7 @@ CREATE TABLE IF NOT EXISTS provider_settings (
model TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '',
zdr INTEGER NOT NULL DEFAULT 1, zdr INTEGER NOT NULL DEFAULT 1,
no_train INTEGER NOT NULL DEFAULT 1, no_train INTEGER NOT NULL DEFAULT 1,
profile_id TEXT,
updated TEXT NOT NULL DEFAULT (datetime('now')) updated TEXT NOT NULL DEFAULT (datetime('now'))
); );

View File

@ -63,6 +63,7 @@ TABLES = [
"writing_profile_reviews", "writing_profile_reviews",
"writing_profile_versions", "writing_profile_versions",
"provider_settings", "provider_settings",
"llm_profiles",
"journal_generation_selection", "journal_generation_selection",
"generation_guidelines", "generation_guidelines",
"app_settings", "app_settings",

View File

@ -31,6 +31,7 @@ UNIT_MODULES = frozenset(
"test_local_backup", "test_local_backup",
"test_provenance", "test_provenance",
"test_model_catalog", "test_model_catalog",
"test_local_url",
"test_privacy_detect_eval", "test_privacy_detect_eval",
"test_journal_shape", "test_journal_shape",
"test_journal_body", "test_journal_body",

View File

@ -107,6 +107,37 @@ def main() -> None:
expect(saved.status_code == 200, f"save generate settings {saved.text}") expect(saved.status_code == 200, f"save generate settings {saved.text}")
saved_roles = {item["role"]: item for item in saved.json()["roles"]} saved_roles = {item["role"]: item for item in saved.json()["roles"]}
expect(saved_roles["generate"]["model"] == "openai/gpt-4o", "generate model persisted") 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( unknown = client.put(
"/api/admin/providers/training", "/api/admin/providers/training",

View File

@ -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()

View File

@ -210,6 +210,30 @@ def test_list_provider_models() -> None:
expect(list_provider_models(fake) == [], "fake provider has no catalog") 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: def main() -> None:
test_reads_context_and_completion() test_reads_context_and_completion()
test_model_not_in_catalog() test_model_not_in_catalog()
@ -217,6 +241,7 @@ def main() -> None:
test_cache_hit_and_ttl_expiry() test_cache_hit_and_ttl_expiry()
test_env_fallback_and_fail_closed() test_env_fallback_and_fail_closed()
test_list_provider_models() test_list_provider_models()
test_list_ollama_tags()
print("model catalog tests passed.") print("model catalog tests passed.")

View File

@ -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. **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. **Später prüfen:** Flag entfernen, sobald lokales Detect auf Prod läuft. Dann wieder Production ohne Klartext-Detect.
--- ---

View File

@ -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:** 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) ## 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. **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.

View File

@ -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`. **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) ## 5.4 Implementierungsstand (Debug-Persistenz)
**Additiv 2026-08-28.** Session-Admin. Default aus. Nur eigenes Profil in Liste/Export. **Additiv 2026-08-28.** Session-Admin. Default aus. Nur eigenes Profil in Liste/Export.

View File

@ -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. - 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. - 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. - 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. - 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`. - Konsolidierung nutzt lokale Signale, nicht `count >= 2`.
- `/dialog` ist Admin-Harness, nicht Produkt-IA. - `/dialog` ist Admin-Harness, nicht Produkt-IA.

View File

@ -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 - UI-Katalog: welche Karten/Nav-Beiträge sichtbar, Layout-Overrides
- System-Prompts vs. Instanz-Anpassungen (Reset auf Default) - System-Prompts vs. Instanz-Anpassungen (Reset auf Default)
- Provider-URL, Modell und ZDR/No-Train für Generate und Detect (`provider_settings`, Admin Schnittstellen) - 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 - später: Privacy-Profile (Strict/Balanced), sobald fachlich konkret
### 4.2 Code / Env (nicht pro Klick im Admin) ### 4.2 Code / Env (nicht pro Klick im Admin)

View File

@ -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`. 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 ## 10. Offene Fragen

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { api } from '../api.js' import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx' import { useAuth } from '../context/AuthContext.jsx'
@ -9,13 +9,20 @@ const emptyRole = () => ({
model: '', model: '',
zdr: true, zdr: true,
no_train: true, no_train: true,
key: '' profile_id: '',
key: '',
saveAsTitle: ''
}) })
const FALLBACK_MODELS = { const emptyDraft = () => ({
generate: ['openai/gpt-5.4', 'openai/gpt-4o', 'openai/gpt-4o-mini', 'anthropic/claude-sonnet-4'], id: '',
detect: ['openai/gpt-4.1-nano', 'openai/gpt-4o-mini', 'llama3.1'] title: '',
} name: '',
url: 'http://192.168.2.144:11434/v1/chat/completions',
model: '',
zdr: true,
no_train: true
})
function statusLabel(role) { function statusLabel(role) {
if (role.role === 'detect' && !role.url) return 'nicht konfiguriert, fail-closed' 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.ready) return 'bereit'
if (!role.key_present && !role.local) return 'Key fehlt' if (!role.key_present && !role.local) return 'Key fehlt'
if (!role.url) return 'URL fehlt' if (!role.url) return 'URL fehlt'
if (role.local && !role.model) return 'Modell fehlt'
return 'nicht bereit' return 'nicht bereit'
} }
function detectBanner(data) { function detectBanner(data) {
if (!data) return '' 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') { 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) { 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 '' return ''
} }
@ -47,53 +59,79 @@ export default function AdminProvidersPage() {
const { session } = useAuth() const { session } = useAuth()
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [forms, setForms] = useState({}) const [forms, setForms] = useState({})
const [catalog, setCatalog] = useState({ generate: [], detect: [] }) const [draft, setDraft] = useState(emptyDraft())
const [catalog, setCatalog] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [notice, setNotice] = useState('') const [notice, setNotice] = useState('')
const [saving, setSaving] = 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 = () => const load = () =>
api('/api/admin/providers', { token: session.token }) api('/api/admin/providers', { token: session.token })
.then((payload) => { .then(applyPayload)
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)
})
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
const loadCatalog = () => const loadModels = (url) => {
api('/api/admin/providers/models', { token: session.token }) if (!url) {
.then((payload) => { setCatalog([])
setCatalog({ return
generate: payload.roles?.generate || [], }
detect: payload.roles?.detect || [] api(`/api/admin/providers/models?url=${encodeURIComponent(url)}`, { token: session.token })
}) .then((payload) => setCatalog(payload.models || payload.roles?.generate || []))
}) .catch(() => setCatalog([]))
.catch(() => { }
setCatalog({ generate: [], detect: [] })
})
useEffect(() => { useEffect(() => { load() }, [session.token])
load() useEffect(() => { loadModels(draft.url) }, [draft.url, session.token])
loadCatalog()
}, [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 } })) 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] const form = forms[role]
if (!form) return if (!form) return
setError('') setError('')
@ -105,7 +143,9 @@ export default function AdminProvidersPage() {
url: form.url, url: form.url,
model: form.model, model: form.model,
zdr: form.zdr, 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() if (form.key.trim()) body.key = form.key.trim()
const payload = await api(`/api/admin/providers/${role}`, { const payload = await api(`/api/admin/providers/${role}`, {
@ -113,10 +153,8 @@ export default function AdminProvidersPage() {
method: 'PUT', method: 'PUT',
body body
}) })
setData(payload) applyPayload(payload)
setForms((prev) => ({ ...prev, [role]: { ...prev[role], key: '' } })) setNotice(form.saveAsTitle.trim() ? 'Neues Profil gespeichert und dieser Stufe zugeordnet.' : 'Stufe gespeichert. Zugeordnetes Profil wurde mitaktualisiert.')
setNotice(form.key.trim() ? `${role}: gespeichert, Key in .env geschrieben.` : `${role}: gespeichert.`)
loadCatalog()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -124,10 +162,50 @@ export default function AdminProvidersPage() {
} }
} }
const modelOptions = (role) => { const saveDraft = async () => {
const fromCatalog = catalog[role] || [] setError('')
if (fromCatalog.length) return fromCatalog setNotice('')
return (FALLBACK_MODELS[role] || []).map((id) => ({ id, name: id })) 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) const banner = detectBanner(data)
@ -136,26 +214,23 @@ export default function AdminProvidersPage() {
<section className="card"> <section className="card">
<h1>Schnittstellen</h1> <h1>Schnittstellen</h1>
<p className="muted"> <p className="muted">
Zwei Stufen, zwei eigene Modelle: Maskierung prüft den persönlichen Text, das Sprachmodell schreibt Dialogzug und Journalentwurf. LLM-Profile merken URL, Modell und Policy. Maskierung und Sprachmodell wählen je ein Profil.
Pro Stufe URL und Modell getrennt wählen. Ohne Detection kein Generate. Der Secret-Key wird nie angezeigt und nie in der Datenbank gespeichert. Beim Wechsel musst du die Daten nicht neu eintippen. Keys bleiben in der Umgebung, nicht im Profil.
</p> </p>
{data && ( {data && <p className="muted">Laufzeit: {data.runtime_env}</p>}
<p className="muted">Laufzeit: {data.runtime_env}</p>
)}
{banner && <p>{banner}</p>} {banner && <p>{banner}</p>}
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{notice && <p>{notice}</p>} {notice && <p>{notice}</p>}
{data && ( {data && (
<> <>
{data.roles.map((role) => { {data.roles.map((role) => {
const form = forms[role.role] || emptyRole() const form = forms[role.role] || emptyRole()
const options = modelOptions(role.role)
const listId = `kansho-models-${role.role}`
return ( return (
<form <form
key={role.role} key={role.role}
className="stack" className="stack"
onSubmit={(e) => { e.preventDefault(); save(role.role) }} onSubmit={(e) => { e.preventDefault(); saveRole(role.role) }}
> >
<h2>{role.title || role.role}</h2> <h2>{role.title || role.role}</h2>
{role.task && <p className="muted">{role.task}</p>} {role.task && <p className="muted">{role.task}</p>}
@ -163,67 +238,174 @@ export default function AdminProvidersPage() {
<dt>Status</dt> <dt>Status</dt>
<dd>{statusLabel(role)}</dd> <dd>{statusLabel(role)}</dd>
<dt>Key</dt> <dt>Key</dt>
<dd>{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : 'fehlt'}</dd> <dd>{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : (role.local ? 'nicht nötig (lokal)' : 'fehlt')}</dd>
<dt>Modus</dt> <dt>Modus</dt>
<dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd> <dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd>
</dl> </dl>
<label>
Aktives Profil
<select
value={form.profile_id}
onChange={(e) => {
if (!e.target.value) return
applyRole(role.role, e.target.value)
}}
disabled={saving.startsWith('activate-') || !profiles.length}
>
<option value="">Profil wählen </option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title} ({profile.local ? 'lokal' : 'extern'}{profile.model ? ` · ${profile.model}` : ''})
</option>
))}
</select>
</label>
<label> <label>
Name Name
<input value={form.name} onChange={(e) => update(role.role, 'name', e.target.value)} /> <input value={form.name} onChange={(e) => updateRole(role.role, 'name', e.target.value)} />
</label> </label>
<label> <label>
URL URL
<input <input value={form.url} onChange={(e) => updateRole(role.role, 'url', e.target.value)} />
value={form.url}
onChange={(e) => update(role.role, 'url', e.target.value)}
placeholder={role.role === 'detect' ? 'http://127.0.0.1:11434/v1/chat/completions' : ''}
/>
</label> </label>
<label> <label>
Modell für diese Stufe Modell für diese Stufe
<input value={form.model} onChange={(e) => updateRole(role.role, 'model', e.target.value)} />
</label>
<label>
Als neues Profil speichern (optional)
<input <input
list={listId} value={form.saveAsTitle}
value={form.model} onChange={(e) => updateRole(role.role, 'saveAsTitle', e.target.value)}
onChange={(e) => update(role.role, 'model', e.target.value)} placeholder="z. B. Ollama Detect"
placeholder={role.role === 'detect' ? 'openai/gpt-4.1-nano oder lokales Ollama-Modell' : 'openai/gpt-5.4'}
/> />
<datalist id={listId}>
{options.map((item) => (
<option key={item.id} value={item.id}>
{item.name && item.name !== item.id ? item.name : item.id}
</option>
))}
</datalist>
</label> </label>
<label className="check"> <label className="check">
<input type="checkbox" checked={form.zdr} onChange={(e) => update(role.role, 'zdr', e.target.checked)} /> <input type="checkbox" checked={form.zdr} onChange={(e) => updateRole(role.role, 'zdr', e.target.checked)} />
Zero Data Retention Zero Data Retention
</label> </label>
<label className="check"> <label className="check">
<input <input
type="checkbox" type="checkbox"
checked={form.no_train} checked={form.no_train}
onChange={(e) => update(role.role, 'no_train', e.target.checked)} onChange={(e) => updateRole(role.role, 'no_train', e.target.checked)}
/> />
kein Provider-Training kein Provider-Training
</label> </label>
<label> <label>
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'} API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(für externe URL)'}
<input <input
type="password" type="password"
autoComplete="off" autoComplete="off"
value={form.key} value={form.key}
onChange={(e) => update(role.role, 'key', e.target.value)} onChange={(e) => updateRole(role.role, 'key', e.target.value)}
placeholder={role.role === 'detect' ? 'leer = Key des Sprachmodells' : ''} placeholder={role.local ? 'lokal ohne Key' : (role.role === 'detect' ? 'leer = Key des Sprachmodells' : '')}
/> />
</label> </label>
<button type="submit" disabled={saving === role.role}> <button type="submit" disabled={saving === role.role}>
{saving === role.role ? 'Speichert …' : 'Speichern'} {saving === role.role ? 'Speichert …' : 'Stufe speichern'}
</button> </button>
</form> </form>
) )
})} })}
<p className="muted">Ohne ZDR und ohne Trainingsverbot bleibt das Sprachmodell fail-closed. Maskierung wählt keine Operation. Gateway lässt sich darüber nicht umgehen.</p>
<h2>Gespeicherte Profile</h2>
<p className="muted">Ein Profil kann Maskierung, Sprachmodell oder beiden Stufen dienen. Löschen geht nur, wenn es gerade keiner Stufe zugeordnet ist.</p>
<ul className="stack">
{profiles.map((profile) => (
<li key={profile.id} className="row-item">
<button
type="button"
className="ghost"
onClick={() => setDraft({ ...profile })}
>
{profile.title}
<span className="muted"> · {profile.local ? 'lokal' : 'extern'} · {profile.model || 'kein Modell'} · {profile.url}</span>
</button>
<div className="row-actions">
<button type="button" className="ghost" onClick={() => setDraft({ ...profile })}>Bearbeiten</button>
<button
type="button"
className="ghost"
disabled={usedIds.has(profile.id) || saving === `delete-${profile.id}`}
onClick={() => removeProfile(profile.id)}
>
Löschen
</button>
</div>
</li>
))}
</ul>
<form className="stack" onSubmit={(e) => { e.preventDefault(); saveDraft() }}>
<h2>{draft.id ? 'Profil bearbeiten' : 'Neues Profil'}</h2>
<label>
Profilname
<input
value={draft.title}
onChange={(e) => setDraft((prev) => ({ ...prev, title: e.target.value }))}
placeholder="z. B. Ollama (LAN)"
/>
</label>
<label>
Anbietername
<input
value={draft.name}
onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))}
placeholder="ollama oder openrouter"
/>
</label>
<label>
URL
<input
value={draft.url}
onChange={(e) => setDraft((prev) => ({ ...prev, url: e.target.value }))}
/>
</label>
<label>
Modell
<input
list="kansho-profile-models"
value={draft.model}
onChange={(e) => setDraft((prev) => ({ ...prev, model: e.target.value }))}
placeholder="Ollama-Tag oder OpenRouter-ID"
/>
<datalist id="kansho-profile-models">
{catalog.map((item) => (
<option key={item.id} value={item.id}>
{item.name && item.name !== item.id ? item.name : item.id}
</option>
))}
</datalist>
</label>
<label className="check">
<input
type="checkbox"
checked={draft.zdr}
onChange={(e) => setDraft((prev) => ({ ...prev, zdr: e.target.checked }))}
/>
Zero Data Retention
</label>
<label className="check">
<input
type="checkbox"
checked={draft.no_train}
onChange={(e) => setDraft((prev) => ({ ...prev, no_train: e.target.checked }))}
/>
kein Provider-Training
</label>
<div className="row-actions">
<button type="submit" disabled={saving === 'draft'}>
{saving === 'draft' ? 'Speichert …' : (draft.id ? 'Profil speichern' : 'Profil anlegen')}
</button>
{draft.id && (
<button type="button" className="ghost" onClick={() => setDraft(emptyDraft())}>
Neu
</button>
)}
</div>
</form>
<p className="muted">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.</p>
</> </>
)} )}
</section> </section>