bifroest/app/ai/provider.py
2026-10-27 08:34:17 +01:00

149 lines
7.3 KiB
Python

"""Provider-Adapter für die Responses API (→ Konzept 8.1).
Zustandslos: store=false, der Verlauf kommt bei jedem Aufruf vollständig mit.
Nur eigene Function Tools, keine eingebauten Tools des Anbieters. Funktioniert
mit OpenAI direkt und mit dem OpenAI-kompatiblen AI Gateway (nur base_url und
Key unterscheiden sich).
"""
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Protocol
import httpx
import openai
from app.ai.service import Target
class ProviderError(Exception):
"""Aufruf gescheitert. retryable: ein neuer Versuch kann helfen (Netz, Last, 5xx)."""
def __init__(self, message: str, *, retryable: bool, user_message: str):
super().__init__(message)
self.retryable, self.user_message = retryable, user_message
class GuardrailBlocked(Exception):
"""Das Gateway hat die Anfrage oder Antwort blockiert (→ Konzept 10.6)."""
def __init__(self, code: str, message: str):
super().__init__(f"{code}: {message}")
self.code, self.message = code, message
@dataclass
class Result:
text: str
# Ausgabe-Elemente unverändert, für den nächsten Aufruf (u. a. verschlüsseltes Reasoning)
items: list[dict[str, Any]] = field(default_factory=list)
usage: dict[str, Any] = field(default_factory=dict)
model: str = ""
incomplete: str = "" # Grund, falls die Antwort abgeschnitten wurde
@property
def calls(self) -> list[dict[str, Any]]:
"""Tool-Aufrufe des Modells in dieser Antwort."""
return [item for item in self.items if item.get("type") == "function_call"]
class Provider(Protocol):
def respond(self, target: Target, *, instructions: str, input: list[dict[str, Any]],
on_text: Callable[[str], None], tools: list[dict[str, Any]] | None = None) -> Result: ...
def list_models(self, target: Target) -> list[str]: ...
def is_guardrail(code: str | None, kind: str | None = None) -> bool:
"""Annahme bis zur Gateway-Spezifikation: Guardrail-Fehler tragen 'guardrail' im Code oder Typ.
Die genauen Codes liefert das Gateway-Team; dann hier auf eine feste Liste umstellen.
"""
return any("guardrail" in (value or "").lower() for value in (code, kind))
def error_details(error: openai.APIStatusError) -> tuple[str, str, str]:
body = error.body if isinstance(error.body, dict) else {}
detail = body.get("error", body) if isinstance(body.get("error", body), dict) else {}
return str(detail.get("code") or ""), str(detail.get("type") or ""), str(detail.get("message") or error.message)
class ResponsesProvider:
def __init__(self, timeout: float = 120.0, http_client: httpx.Client | None = None):
self.timeout, self.http_client = timeout, http_client # http_client nur für Tests
def client(self, target: Target) -> openai.OpenAI:
# Wiederholungen übernimmt der Worker (mit Backoff und Lease), nicht das SDK
return openai.OpenAI(base_url=target.base_url, api_key=target.api_key, timeout=self.timeout,
max_retries=0, http_client=self.http_client)
def respond(self, target: Target, *, instructions: str, input: list[dict[str, Any]],
on_text: Callable[[str], None], tools: list[dict[str, Any]] | None = None) -> Result:
options: dict[str, Any] = {}
if tools:
# Nur eigene Function Tools, nie eingebaute Tools des Anbieters (→ Konzept 8.1)
options |= {"tools": tools, "tool_choice": "auto", "parallel_tool_calls": True}
if target.supports_reasoning:
options["include"] = ["reasoning.encrypted_content"]
try:
with self.client(target).responses.create(model=target.model, instructions=instructions, input=input,
store=False, stream=True, **options) as stream:
for event in stream:
if event.type == "response.output_text.delta":
on_text(event.delta)
elif event.type in ("response.completed", "response.incomplete"):
return self.result(event.response)
elif event.type == "response.failed":
error = event.response.error
self.raise_failed(error.code if error else "", error.message if error else "")
elif event.type == "error":
self.raise_failed(event.code or "", event.message)
except openai.APIStatusError as e:
raise self.translate(e) from e
except (openai.APITimeoutError, openai.APIConnectionError) as e:
raise ProviderError(str(e), retryable=True,
user_message="Das Modell ist gerade nicht erreichbar.") from e
raise ProviderError("Stream ohne Abschluss beendet", retryable=True,
user_message="Die Verbindung zum Modell ist abgebrochen.")
def list_models(self, target: Target) -> list[str]:
try:
return sorted(m.id for m in self.client(target).models.list())
except openai.APIStatusError as e:
raise self.translate(e) from e
except (openai.APITimeoutError, openai.APIConnectionError) as e:
raise ProviderError(str(e), retryable=True, user_message="Endpunkt nicht erreichbar.") from e
@staticmethod
def result(response: Any) -> Result:
items = [item.model_dump(exclude_none=True) for item in response.output]
usage = response.usage.model_dump(exclude_none=True) if response.usage else {}
incomplete = response.incomplete_details.reason if response.incomplete_details else ""
return Result(text=response.output_text, items=items, usage=usage, model=response.model,
incomplete=incomplete or "")
@staticmethod
def raise_failed(code: str, message: str) -> None:
if is_guardrail(code):
raise GuardrailBlocked(code, message)
retryable = code in ("server_error", "rate_limit_exceeded")
raise ProviderError(f"{code}: {message}", retryable=retryable,
user_message="Das Modell konnte die Anfrage nicht beantworten.")
@staticmethod
def translate(error: openai.APIStatusError) -> Exception:
code, kind, message = error_details(error)
if is_guardrail(code, kind):
return GuardrailBlocked(code or kind, message)
if isinstance(error, (openai.AuthenticationError, openai.PermissionDeniedError)):
return ProviderError(message, retryable=False,
user_message="Der API-Key wurde abgelehnt. Bitte den Plattform-Admin ansprechen.")
if isinstance(error, openai.NotFoundError):
return ProviderError(message, retryable=False,
user_message="Das Modell ist beim Anbieter nicht verfügbar.")
if isinstance(error, openai.RateLimitError) or error.status_code >= 500:
return ProviderError(message, retryable=True, user_message="Das Modell ist gerade ausgelastet.")
# Meist ein Konfigurationsfehler (z. B. Tool-Schema, Parameter), kein inhaltliches Nein des Modells
return ProviderError(message, retryable=False,
user_message=f"Der Modell-Endpunkt hat die Anfrage als ungültig abgewiesen "
f"({error.status_code}): {message[:300]}")