Kansho/backend/providers.py
2026-08-26 10:42:20 +02:00

224 lines
7.0 KiB
Python

"""Two independently configured OpenAI-compatible providers: generate vs detect."""
from __future__ import annotations
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 {}
@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"
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:
host = (urlparse(url).hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1", "ollama"}
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:
return str(db_value).strip()
return default
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 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")
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.HTTPError as exc:
raise ProviderError("provider_unreachable", f"{config.role}-Provider ist nicht erreichbar.") from exc
if response.status_code >= 400:
body = ""
try:
body = response.text or ""
except Exception:
body = ""
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={"http_status": response.status_code, "context_compression": compression},
)
raise ProviderError("provider_rejected", f"{config.role}-Provider hat die Anfrage abgelehnt.")
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 {}
return ChatResult(
content=content or "",
model=data.get("model") or config.model,
usage=usage,
context_compression=compression,
)