Kansho/backend/provider_settings.py
Lars bef422a429
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
Add selectable LLM profiles and treat LAN Ollama as local.
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>
2026-09-08 12:48:25 +02:00

459 lines
16 KiB
Python

"""Admin-configurable generate/detect endpoints. Keys stay in env, never in the DB."""
from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
from urllib.parse import urlparse
from db import get_db
from env_loader import (
allows_remote_plaintext_detect,
remote_plaintext_detect_reason,
runtime_env,
upsert_env_value,
)
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",
}
# Interne Rollen bleiben generate/detect. Fachlich: Sprachmodell vs. Maskierung.
ROLE_META = {
"generate": {
"title": "Sprachmodell",
"task": "Dialogzug und Journalentwurf. Nur maskierter Kontext. Wählt die Operation im Dialogzug mit.",
},
"detect": {
"title": "Maskierung",
"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.",
},
}
class SettingsError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400):
super().__init__(message)
self.code = code
self.message = message
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:
role = item["role"]
if role not in ROLES:
continue
existing = conn.execute("SELECT role FROM provider_settings WHERE role = ?", (role,)).fetchone()
if existing:
continue
conn.execute(
"""
INSERT INTO provider_settings (role, name, url, model, zdr, no_train)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
role,
item.get("name") or "",
item.get("url") or "",
item.get("model") or "",
1 if item.get("zdr", 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:
if role not in ROLES:
return None
with get_db() as conn:
row = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone()
return dict(row) if row else None
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 = _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 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, 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, chosen or None),
)
return get_setting(role) or {}
def set_role_key(role: str, key: str) -> None:
if role not in ROLES:
raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.")
value = (key or "").strip()
if not value:
raise SettingsError("provider_key_empty", "Leerer Key wird nicht gespeichert.")
upsert_env_value(_KEY_ENV[role], value)
def public_role_status(role: str) -> dict:
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 "")
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:
key_source = "own"
elif role == "detect" and generate_key and not local:
key_source = "generate"
else:
key_source = "none"
meta = ROLE_META.get(role, {})
return {
"role": role,
"title": meta.get("title") or role,
"task": meta.get("task") or "",
"name": row.get("name") or (config.name if config else ""),
"url": row.get("url") or "",
"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,
"ready": config is not None,
"mode": config.mode if config else "unconfigured",
"remote_plaintext_detect": bool(config and not config.local and config.mode == "http" and role == "detect"),
"remote_plaintext_allowed": allows_remote_plaintext_detect() if role == "detect" else None,
"remote_plaintext_reason": remote_plaintext_detect_reason() if role == "detect" else None,
"updated": row.get("updated") or "",
}
def public_status() -> dict:
return {
"roles": [public_role_status(role) for role in ROLES],
"profiles": list_profiles(),
"runtime_env": runtime_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,
}