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_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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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 ""
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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")
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,17 +59,16 @@ 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 load = () =>
|
const applyPayload = (payload) => {
|
||||||
api('/api/admin/providers', { token: session.token })
|
|
||||||
.then((payload) => {
|
|
||||||
setData(payload)
|
setData(payload)
|
||||||
const next = {}
|
const next = {}
|
||||||
for (const role of payload.roles) {
|
for (const role of payload.roles || []) {
|
||||||
next[role.role] = {
|
next[role.role] = {
|
||||||
role: role.role,
|
role: role.role,
|
||||||
name: role.name || '',
|
name: role.name || '',
|
||||||
|
|
@ -65,35 +76,62 @@ export default function AdminProvidersPage() {
|
||||||
model: role.model || '',
|
model: role.model || '',
|
||||||
zdr: Boolean(role.zdr),
|
zdr: Boolean(role.zdr),
|
||||||
no_train: Boolean(role.no_train),
|
no_train: Boolean(role.no_train),
|
||||||
key: ''
|
profile_id: role.profile_id || '',
|
||||||
|
key: '',
|
||||||
|
saveAsTitle: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setForms(next)
|
setForms(next)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
api('/api/admin/providers', { token: session.token })
|
||||||
|
.then(applyPayload)
|
||||||
.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,32 +238,140 @@ 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>
|
||||||
|
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>
|
||||||
<label>
|
<label>
|
||||||
URL
|
URL
|
||||||
<input
|
<input
|
||||||
value={form.url}
|
value={draft.url}
|
||||||
onChange={(e) => update(role.role, 'url', e.target.value)}
|
onChange={(e) => setDraft((prev) => ({ ...prev, 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
|
||||||
<input
|
<input
|
||||||
list={listId}
|
list="kansho-profile-models"
|
||||||
value={form.model}
|
value={draft.model}
|
||||||
onChange={(e) => update(role.role, 'model', e.target.value)}
|
onChange={(e) => setDraft((prev) => ({ ...prev, model: e.target.value }))}
|
||||||
placeholder={role.role === 'detect' ? 'openai/gpt-4.1-nano oder lokales Ollama-Modell' : 'openai/gpt-5.4'}
|
placeholder="Ollama-Tag oder OpenRouter-ID"
|
||||||
/>
|
/>
|
||||||
<datalist id={listId}>
|
<datalist id="kansho-profile-models">
|
||||||
{options.map((item) => (
|
{catalog.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
{item.name && item.name !== item.id ? item.name : item.id}
|
{item.name && item.name !== item.id ? item.name : item.id}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -196,34 +379,33 @@ export default function AdminProvidersPage() {
|
||||||
</datalist>
|
</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={draft.zdr}
|
||||||
|
onChange={(e) => setDraft((prev) => ({ ...prev, 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={draft.no_train}
|
||||||
onChange={(e) => update(role.role, 'no_train', e.target.checked)}
|
onChange={(e) => setDraft((prev) => ({ ...prev, no_train: e.target.checked }))}
|
||||||
/>
|
/>
|
||||||
kein Provider-Training
|
kein Provider-Training
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<div className="row-actions">
|
||||||
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'}
|
<button type="submit" disabled={saving === 'draft'}>
|
||||||
<input
|
{saving === 'draft' ? 'Speichert …' : (draft.id ? 'Profil speichern' : 'Profil anlegen')}
|
||||||
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'}
|
|
||||||
</button>
|
</button>
|
||||||
|
{draft.id && (
|
||||||
|
<button type="button" className="ghost" onClick={() => setDraft(emptyDraft())}>
|
||||||
|
Neu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</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>
|
||||||
})}
|
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user