from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple import time import logging from openai import OpenAI, AsyncOpenAI from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type from .config import settings class LLMClient: """Cliente unificado para llamadas Chat Completions (SDK OpenAI). - Centraliza api_key, base_url, modelo y timeout desde `settings`. - Expone helpers para respuestas de texto y JSON. - Registra métricas mínimas en `application.api_calls` (start/end/error + usage). - Aplica reintentos ante timeouts. """ def __init__( self, api_key: Optional[str] = None, base_url: Optional[str] = None, default_model: Optional[str] = None, request_timeout_s: Optional[float] = None, ) -> None: self.api_key = api_key or settings.api.api_key self.base_url = base_url or settings.api.base_url self.default_model = default_model or settings.api.model self.request_timeout_s = request_timeout_s or settings.api.request_timeout_s self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) self._api_logger = logging.getLogger("application.api_calls") def update_config( self, *, api_key: Optional[str] = None, base_url: Optional[str] = None, default_model: Optional[str] = None, request_timeout_s: Optional[float] = None, ) -> None: if api_key is not None: self.api_key = api_key if base_url is not None: self.base_url = base_url if default_model is not None: self.default_model = default_model if request_timeout_s is not None: self.request_timeout_s = request_timeout_s self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) @retry( stop=stop_after_attempt(settings.retry.max_attempts), wait=wait_exponential(multiplier=settings.retry.wait_min_s, max=settings.retry.wait_max_s), retry=retry_if_exception_type((TimeoutError,)), reraise=True, ) def _create_completion(self, *, messages: List[Dict[str, Any]], model: str, timeout_s: float, **extra) -> Any: return self._client.chat.completions.create( messages=messages, model=model, timeout=timeout_s, **extra, ) @retry( stop=stop_after_attempt(settings.retry.max_attempts), wait=wait_exponential(multiplier=settings.retry.wait_min_s, max=settings.retry.wait_max_s), retry=retry_if_exception_type((TimeoutError,)), reraise=True, ) async def _create_completion_async(self, *, messages: List[Dict[str, Any]], model: str, timeout_s: float, **extra) -> Any: return await self._async_client.chat.completions.create( messages=messages, model=model, timeout=timeout_s, **extra, ) def _log_start(self, *, component: str, model: str, timeout_s: float, messages_count: int) -> None: try: self._api_logger.info( "api_call_start", extra={ "component": component, "base_url": self.base_url, "model": model, "timeout_s": timeout_s, "messages": messages_count, }, ) except Exception: pass def _log_end(self, *, component: str, model: str, timeout_s: float, elapsed_ms: int, usage: Optional[Dict[str, Any]]) -> None: try: self._api_logger.info( "api_call_end", extra={ "component": component, "base_url": self.base_url, "model": model, "timeout_s": timeout_s, "elapsed_ms": elapsed_ms, "prompt_tokens": (usage or {}).get("prompt_tokens"), "completion_tokens": (usage or {}).get("completion_tokens"), "total_tokens": (usage or {}).get("total_tokens"), }, ) except Exception: pass def _log_error(self, *, component: str, model: str, timeout_s: float, elapsed_ms: Optional[int], error: Exception) -> None: try: self._api_logger.info( "api_call_error", extra={ "component": component, "base_url": self.base_url, "model": model, "timeout_s": timeout_s, "elapsed_ms": (elapsed_ms if elapsed_ms is not None else None), "error": str(error), }, ) except Exception: pass @staticmethod def _extract_usage(obj: Any) -> Optional[Dict[str, Any]]: try: usage = getattr(obj, "usage", None) if usage is None: return None usage_dict: Dict[str, Any] = { "prompt_tokens": getattr(usage, "prompt_tokens", None), "completion_tokens": getattr(usage, "completion_tokens", None), "total_tokens": getattr(usage, "total_tokens", None), } details = getattr(usage, "prompt_tokens_details", None) if details is not None: try: usage_dict["prompt_tokens_details"] = dict(details) # type: ignore[arg-type] except Exception: try: usage_dict["prompt_tokens_details"] = { "cached_tokens": getattr(details, "cached_tokens", None), "audio_tokens": getattr(details, "audio_tokens", None), } except Exception: pass return usage_dict except Exception: return None def chat_text( self, messages: List[Dict[str, Any]], *, model: Optional[str] = None, timeout_s: Optional[float] = None, component: str = "llm_client", ) -> Tuple[str, Optional[Dict[str, Any]]]: """Devuelve (texto, usage).""" eff_model = model or self.default_model eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) start = time.time() try: chat_completion = self._create_completion( messages=messages, model=eff_model, timeout_s=eff_timeout, ) elapsed_ms = int((time.time() - start) * 1000) usage = self._extract_usage(chat_completion) self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) text: str = chat_completion.choices[0].message.content # type: ignore[assignment] return text, usage except Exception as ex: elapsed_ms = int((time.time() - start) * 1000) self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) raise async def chat_text_async( self, messages: List[Dict[str, Any]], *, model: Optional[str] = None, timeout_s: Optional[float] = None, component: str = "llm_client", ) -> Tuple[str, Optional[Dict[str, Any]]]: """Devuelve (texto, usage) de forma asíncrona.""" eff_model = model or self.default_model eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) start = time.time() try: chat_completion = await self._create_completion_async( messages=messages, model=eff_model, timeout_s=eff_timeout, ) elapsed_ms = int((time.time() - start) * 1000) usage = self._extract_usage(chat_completion) self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) text: str = chat_completion.choices[0].message.content # type: ignore[assignment] return text, usage except Exception as ex: elapsed_ms = int((time.time() - start) * 1000) self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) raise def chat_json( self, messages: List[Dict[str, Any]], *, model: Optional[str] = None, response_format: str = "json_object", timeout_s: Optional[float] = None, component: str = "llm_client", ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: """Devuelve (objeto JSON parseado, usage).""" import json eff_model = model or self.default_model eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) start = time.time() try: chat_completion = self._create_completion( messages=messages, model=eff_model, timeout_s=eff_timeout, response_format={"type": response_format}, ) elapsed_ms = int((time.time() - start) * 1000) usage = self._extract_usage(chat_completion) self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) content = chat_completion.choices[0].message.content # type: ignore[assignment] obj = json.loads(content) return obj, usage except Exception as ex: elapsed_ms = int((time.time() - start) * 1000) self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) raise async def chat_json_async( self, messages: List[Dict[str, Any]], *, model: Optional[str] = None, response_format: str = "json_object", timeout_s: Optional[float] = None, component: str = "llm_client", ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: """Devuelve (objeto JSON parseado, usage) de forma asíncrona.""" import json eff_model = model or self.default_model eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s) self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or [])) start = time.time() try: chat_completion = await self._create_completion_async( messages=messages, model=eff_model, timeout_s=eff_timeout, response_format={"type": response_format}, ) elapsed_ms = int((time.time() - start) * 1000) usage = self._extract_usage(chat_completion) self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage) content = chat_completion.choices[0].message.content # type: ignore[assignment] obj = json.loads(content) return obj, usage except Exception as ex: elapsed_ms = int((time.time() - start) * 1000) self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex) raise