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 -Block voran. if "" in text: text = text.split("")[-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}")