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>
This commit is contained in:
parent
af6fe55577
commit
bef422a429
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
16
backend/migrations/023_llm_profiles.sql
Normal file
16
backend/migrations/023_llm_profiles.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ TABLES = [
|
|||
"writing_profile_reviews",
|
||||
"writing_profile_versions",
|
||||
"provider_settings",
|
||||
"llm_profiles",
|
||||
"journal_generation_selection",
|
||||
"generation_guidelines",
|
||||
"app_settings",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
36
backend/tests/test_local_url.py
Normal file
36
backend/tests/test_local_url.py
Normal 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()
|
||||
|
|
@ -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.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,17 +59,16 @@ 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 load = () =>
|
||||
api('/api/admin/providers', { token: session.token })
|
||||
.then((payload) => {
|
||||
const applyPayload = (payload) => {
|
||||
setData(payload)
|
||||
const next = {}
|
||||
for (const role of payload.roles) {
|
||||
for (const role of payload.roles || []) {
|
||||
next[role.role] = {
|
||||
role: role.role,
|
||||
name: role.name || '',
|
||||
|
|
@ -65,35 +76,62 @@ export default function AdminProvidersPage() {
|
|||
model: role.model || '',
|
||||
zdr: Boolean(role.zdr),
|
||||
no_train: Boolean(role.no_train),
|
||||
key: ''
|
||||
profile_id: role.profile_id || '',
|
||||
key: '',
|
||||
saveAsTitle: ''
|
||||
}
|
||||
}
|
||||
setForms(next)
|
||||
})
|
||||
}
|
||||
|
||||
const load = () =>
|
||||
api('/api/admin/providers', { token: session.token })
|
||||
.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() {
|
|||
<section className="card">
|
||||
<h1>Schnittstellen</h1>
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
{data && (
|
||||
<p className="muted">Laufzeit: {data.runtime_env}</p>
|
||||
)}
|
||||
{data && <p className="muted">Laufzeit: {data.runtime_env}</p>}
|
||||
{banner && <p>{banner}</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
{notice && <p>{notice}</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{data.roles.map((role) => {
|
||||
const form = forms[role.role] || emptyRole()
|
||||
const options = modelOptions(role.role)
|
||||
const listId = `kansho-models-${role.role}`
|
||||
return (
|
||||
<form
|
||||
key={role.role}
|
||||
className="stack"
|
||||
onSubmit={(e) => { e.preventDefault(); save(role.role) }}
|
||||
onSubmit={(e) => { e.preventDefault(); saveRole(role.role) }}
|
||||
>
|
||||
<h2>{role.title || role.role}</h2>
|
||||
{role.task && <p className="muted">{role.task}</p>}
|
||||
|
|
@ -163,32 +238,140 @@ export default function AdminProvidersPage() {
|
|||
<dt>Status</dt>
|
||||
<dd>{statusLabel(role)}</dd>
|
||||
<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>
|
||||
<dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd>
|
||||
</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>
|
||||
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>
|
||||
URL
|
||||
<input value={form.url} onChange={(e) => updateRole(role.role, 'url', e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
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
|
||||
value={form.saveAsTitle}
|
||||
onChange={(e) => updateRole(role.role, 'saveAsTitle', e.target.value)}
|
||||
placeholder="z. B. Ollama Detect"
|
||||
/>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.zdr} onChange={(e) => updateRole(role.role, 'zdr', e.target.checked)} />
|
||||
Zero Data Retention
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.no_train}
|
||||
onChange={(e) => updateRole(role.role, 'no_train', e.target.checked)}
|
||||
/>
|
||||
kein Provider-Training
|
||||
</label>
|
||||
<label>
|
||||
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(für externe URL)'}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={form.key}
|
||||
onChange={(e) => updateRole(role.role, 'key', e.target.value)}
|
||||
placeholder={role.local ? 'lokal ohne Key' : (role.role === 'detect' ? 'leer = Key des Sprachmodells' : '')}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={saving === role.role}>
|
||||
{saving === role.role ? 'Speichert …' : 'Stufe speichern'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
})}
|
||||
|
||||
<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={form.url}
|
||||
onChange={(e) => update(role.role, 'url', e.target.value)}
|
||||
placeholder={role.role === 'detect' ? 'http://127.0.0.1:11434/v1/chat/completions' : ''}
|
||||
value={draft.url}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, url: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Modell für diese Stufe
|
||||
Modell
|
||||
<input
|
||||
list={listId}
|
||||
value={form.model}
|
||||
onChange={(e) => update(role.role, 'model', e.target.value)}
|
||||
placeholder={role.role === 'detect' ? 'openai/gpt-4.1-nano oder lokales Ollama-Modell' : 'openai/gpt-5.4'}
|
||||
list="kansho-profile-models"
|
||||
value={draft.model}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, model: e.target.value }))}
|
||||
placeholder="Ollama-Tag oder OpenRouter-ID"
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{options.map((item) => (
|
||||
<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>
|
||||
|
|
@ -196,34 +379,33 @@ export default function AdminProvidersPage() {
|
|||
</datalist>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.zdr} onChange={(e) => update(role.role, 'zdr', e.target.checked)} />
|
||||
<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={form.no_train}
|
||||
onChange={(e) => update(role.role, 'no_train', e.target.checked)}
|
||||
checked={draft.no_train}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, no_train: e.target.checked }))}
|
||||
/>
|
||||
kein Provider-Training
|
||||
</label>
|
||||
<label>
|
||||
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={form.key}
|
||||
onChange={(e) => update(role.role, 'key', e.target.value)}
|
||||
placeholder={role.role === 'detect' ? 'leer = Key des Sprachmodells' : ''}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={saving === role.role}>
|
||||
{saving === role.role ? 'Speichert …' : 'Speichern'}
|
||||
<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 das Sprachmodell fail-closed. Maskierung wählt keine Operation. Gateway lässt sich darüber nicht umgehen.</p>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user