96 lines
3.0 KiB
Python
96 lines
3.0 KiB
Python
import json
|
|
import logging
|
|
|
|
from openai import OpenAI
|
|
|
|
from app.core.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
settings = get_settings()
|
|
|
|
MAX_ATTEMPTS = 3
|
|
|
|
_client: OpenAI | None = None
|
|
|
|
|
|
class LLMUnavailableError(RuntimeError):
|
|
"""Kein LLM konfiguriert oder alle Versuche fehlgeschlagen."""
|
|
|
|
|
|
def is_configured() -> bool:
|
|
return bool(settings.llm_api_key)
|
|
|
|
|
|
def _get_client() -> OpenAI:
|
|
global _client
|
|
if _client is None:
|
|
# max_retries=0: Wiederholungen macht complete_json selbst, weil die
|
|
# KIT-Toolbox transiente Fehler als 400 meldet (siehe _request_json).
|
|
_client = OpenAI(
|
|
api_key=settings.llm_api_key,
|
|
base_url=settings.llm_api_url,
|
|
timeout=60.0,
|
|
max_retries=0,
|
|
)
|
|
return _client
|
|
|
|
|
|
def _extract_json(content: str | None) -> dict:
|
|
if content is None:
|
|
raise ValueError("Antwort ohne Inhalt")
|
|
|
|
text = content.strip()
|
|
# Reasoning-Modelle stellen der Antwort teils einen <think>-Block voran.
|
|
if "</think>" in text:
|
|
text = text.split("</think>")[-1].strip()
|
|
if text.startswith("```"):
|
|
parts = text.split("```")
|
|
if len(parts) > 1:
|
|
text = parts[1]
|
|
if text.startswith("json"):
|
|
text = text[4:]
|
|
text = text.strip()
|
|
|
|
start, end = text.find("{"), text.rfind("}")
|
|
if start == -1 or end == -1:
|
|
raise ValueError("Kein JSON-Objekt in der Antwort")
|
|
return json.loads(text[start : end + 1])
|
|
|
|
|
|
def complete_json(system_prompt: str, user_prompt: str, max_tokens: int = 500) -> dict:
|
|
"""Fragt das LLM und gibt die geparste JSON-Antwort zurück.
|
|
|
|
Wiederholt bei JEDEM Fehler bis zu MAX_ATTEMPTS mal — auch bei HTTP 400.
|
|
Das ist normalerweise falsch (400 = Client-Fehler), hier aber nötig: die
|
|
KIT-Toolbox liefert sporadisch 400er mit gateway-internen Meldungen wie
|
|
"Model not found", "Function not found: token_usage_display" oder
|
|
psycopg-Verbindungsfehlern, die beim nächsten identischen Request weg sind.
|
|
"""
|
|
if not is_configured():
|
|
raise LLMUnavailableError("Kein LLM-API-Key konfiguriert")
|
|
|
|
last_error: Exception | None = None
|
|
for attempt in range(MAX_ATTEMPTS):
|
|
try:
|
|
response = _get_client().chat.completions.create(
|
|
model=settings.llm_model,
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
temperature=0.2,
|
|
max_tokens=max_tokens,
|
|
response_format={"type": "json_object"},
|
|
)
|
|
return _extract_json(response.choices[0].message.content)
|
|
except Exception as error:
|
|
last_error = error
|
|
logger.warning(
|
|
"LLM-Anfrage fehlgeschlagen (Versuch %d/%d): %s",
|
|
attempt + 1,
|
|
MAX_ATTEMPTS,
|
|
error,
|
|
)
|
|
|
|
raise LLMUnavailableError(f"LLM nach {MAX_ATTEMPTS} Versuchen nicht erreichbar: {last_error}")
|