Kansho/backend/providers.py
Lars bef422a429
Some checks failed
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Failing after 2m40s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s
Add selectable LLM profiles and treat LAN Ollama as local.
Saved presets keep URL and model per stage so detect can switch to Ollama without re-entering settings or sending plaintext to OpenRouter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 12:48:25 +02:00

324 lines
10 KiB
Python

"""Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations
import ipaddress
import json
import os
import re
from dataclasses import dataclass, field
from urllib.parse import urlparse
import httpx
from env_loader import load_env_file
load_env_file()
CONTEXT_LENGTH_REJECT = re.compile(
r"context.?length|maximum context|prompt is too long|too many tokens|"
r"context window|max context|exceeds? (?:the )?(?:maximum|context)",
re.I,
)
class ProviderError(Exception):
def __init__(self, code: str, message: str, status_code: int = 503, diagnostics: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.diagnostics = diagnostics or {}
PROVIDER_MESSAGE_MAX = 240
_PROVIDER_USAGE_KEYS = ("prompt_tokens", "completion_tokens", "total_tokens", "cost", "total_cost")
def provider_error_diagnostics(status_code: int, body: str, *, extra: dict | None = None) -> dict:
"""Compact provider failure fields. No prompt body, no mapping labels."""
diagnostics: dict = {"http_status": status_code}
if extra:
diagnostics.update({key: value for key, value in extra.items() if value is not None})
parsed = None
try:
parsed = json.loads(body or "")
except (TypeError, ValueError, json.JSONDecodeError):
parsed = None
if isinstance(parsed, dict):
err = parsed.get("error")
if isinstance(err, str) and err.strip():
diagnostics["provider_message"] = err.strip()[:PROVIDER_MESSAGE_MAX]
elif isinstance(err, dict):
msg = str(err.get("message") or err.get("msg") or "").strip()
if msg:
diagnostics["provider_message"] = msg[:PROVIDER_MESSAGE_MAX]
code = err.get("code") or err.get("type")
if code:
diagnostics["provider_code"] = str(code)[:80]
usage = parsed.get("usage")
if isinstance(usage, dict):
kept = {key: usage[key] for key in _PROVIDER_USAGE_KEYS if usage.get(key) is not None}
if kept:
diagnostics["usage"] = kept
if kept.get("cost") is not None:
diagnostics["cost"] = kept["cost"]
elif kept.get("total_cost") is not None:
diagnostics["cost"] = kept["total_cost"]
return diagnostics
def _reject_message(role: str, status_code: int, diagnostics: dict) -> str:
base = f"{role}-Provider hat die Anfrage abgelehnt"
if status_code:
base += f" (HTTP {status_code})"
snippet = (diagnostics.get("provider_message") or "").strip()
if snippet:
return f"{base}: {snippet}"
return f"{base}."
@dataclass(frozen=True)
class ProviderConfig:
role: str
name: str
mode: str
url: str
model: str
key: str
local: bool
zdr: bool
no_train: bool
@dataclass
class ChatResult:
content: str
model: str | None = None
usage: dict = field(default_factory=dict)
context_compression: str = "not_applicable"
finish_reason: str | None = None
def is_openrouter(config: ProviderConfig) -> bool:
host = (urlparse(config.url or "").hostname or "").lower()
return host.endswith("openrouter.ai") or (config.name or "").lower() == "openrouter"
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:
return is_local_url(url)
def _setting(role: str) -> dict:
try:
from provider_settings import get_setting
return get_setting(role) or {}
except Exception:
return {}
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 is None:
return default
return str(db_value).strip()
def _flag(env_name: str, db_value, default: bool = True) -> bool:
if (os.environ.get(env_name) or "").strip():
return _truthy(env_name)
if db_value is None:
return default
return bool(int(db_value) if not isinstance(db_value, bool) else db_value)
def generate_provider() -> ProviderConfig | None:
if _truthy("KANSHO_FAKE_PROVIDER"):
return ProviderConfig(
role="generate",
name="fake",
mode="fake",
url="",
model="fake",
key="",
local=True,
zdr=True,
no_train=True,
)
row = _setting("generate")
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)
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):
return None
return ProviderConfig(
role="generate",
name=row.get("name") or ("ollama" if local else "openrouter"),
mode="http",
url=url,
model=model,
key=key,
local=local,
zdr=zdr,
no_train=no_train,
)
def detect_provider() -> ProviderConfig | None:
if _truthy("KANSHO_FAKE_DETECT"):
return ProviderConfig(
role="detect",
name="fake-detect",
mode="fake",
url="",
model="fake",
key="",
local=True,
zdr=True,
no_train=True,
)
row = _setting("detect")
url = _text("KANSHO_DETECT_PROVIDER_URL", row.get("url"), "")
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)
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)
no_train = local or _flag("KANSHO_DETECT_NO_TRAIN", row.get("no_train"), True)
if not local and not key:
return None
if not local and (not zdr or not no_train):
return None
return ProviderConfig(
role="detect",
name=row.get("name") or ("ollama" if local else "openrouter"),
mode="http",
url=url,
model=model,
key=key,
local=local,
zdr=zdr,
no_train=no_train,
)
def complete_chat(
config: ProviderConfig,
messages: list[dict],
*,
timeout: float,
max_tokens: int | None = None,
disable_context_compression: bool = False,
) -> ChatResult:
payload: dict = {"model": config.model, "messages": messages}
if max_tokens is not None:
payload["max_tokens"] = max_tokens
compression = "not_applicable"
if not config.local:
payload["provider"] = {"data_collection": "deny"}
if disable_context_compression and is_openrouter(config) and not config.local:
payload["plugins"] = [{"id": "context-compression", "enabled": False}]
compression = "disabled"
headers = {"Content-Type": "application/json"}
if config.key:
headers["Authorization"] = f"Bearer {config.key}"
if not config.local:
headers["HTTP-Referer"] = "https://kansho.local"
headers["X-Title"] = "Kansho"
try:
response = httpx.post(config.url, json=payload, headers=headers, timeout=timeout)
except httpx.TimeoutException as exc:
raise ProviderError(
"provider_timeout",
f"{config.role}-Provider hat nicht innerhalb von {int(timeout)} Sekunden geantwortet.",
504,
diagnostics={"timeout_s": timeout, "generate_requested": True},
) from exc
except httpx.HTTPError as exc:
raise ProviderError(
"provider_unreachable",
f"{config.role}-Provider ist nicht erreichbar.",
503,
diagnostics={"generate_requested": True},
) from exc
if response.status_code >= 400:
body = ""
try:
body = response.text or ""
except Exception:
body = ""
diagnostics = provider_error_diagnostics(
response.status_code,
body,
extra={"context_compression": compression, "generate_requested": True},
)
if CONTEXT_LENGTH_REJECT.search(body):
raise ProviderError(
"provider_context_length_rejected",
"Der Anbieter hat die Anfrage wegen der Kontextlänge abgelehnt. "
"Kanshō hat den Tagesdialog nicht automatisch gekürzt.",
502,
diagnostics=diagnostics,
)
raise ProviderError(
"provider_rejected",
_reject_message(config.role, response.status_code, diagnostics),
502,
diagnostics=diagnostics,
)
data = response.json()
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ProviderError("provider_shape", f"Unerwartete {config.role}-Antwort.") from exc
usage = data.get("usage") if isinstance(data.get("usage"), dict) else {}
finish_reason = None
try:
finish_reason = data["choices"][0].get("finish_reason")
except (KeyError, IndexError, TypeError, AttributeError):
finish_reason = None
return ChatResult(
content=content or "",
model=data.get("model") or config.model,
usage=usage,
context_compression=compression,
finish_reason=finish_reason,
)