"""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]}")