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>
324 lines
10 KiB
Python
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,
|
|
)
|