149 lines
7.3 KiB
Python
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]}")
|