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 extractor_llm_config, settings class LLMClient: """Cliente unificado para llamadas Chat Completions (SDK OpenAI). - Centraliza api_key, base_url, modelo y timeout desde configuracion. - Expone helpers para respuestas de texto y JSON. - Registra métricas mínimas en `domain.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: default_config = extractor_llm_config() self.api_key = api_key if api_key is not None else str(default_config["api_key"]) self.base_url = base_url if base_url is not None else str(default_config["base_url"]) self.default_model = default_model if default_model is not None else str(default_config["model"]) self.request_timeout_s = request_timeout_s or float(default_config["timeout_s"]) self._client = self._build_sync_client() self._async_client = self._build_async_client() self._api_logger = logging.getLogger("domain.api_calls") def _build_sync_client(self) -> OpenAI: kwargs: Dict[str, Any] = {"api_key": self.api_key} if self.base_url: kwargs["base_url"] = self.base_url return OpenAI(**kwargs) def _build_async_client(self) -> AsyncOpenAI: kwargs: Dict[str, Any] = {"api_key": self.api_key} if self.base_url: kwargs["base_url"] = self.base_url return AsyncOpenAI(**kwargs) 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 = self._build_sync_client() self._async_client = self._build_async_client() def list_models(self) -> List[str]: """Lista modelos publicados por el backend compatible con OpenAI.""" models = self._client.models.list() return sorted( str(getattr(model, "id", "")) for model in getattr(models, "data", []) if getattr(model, "id", None) ) @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", max_completion_tokens: Optional[int] = None, ) -> 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: completion_kwargs: Dict[str, Any] = { "response_format": {"type": response_format}, } if max_completion_tokens is not None: completion_kwargs["max_completion_tokens"] = max_completion_tokens chat_completion = self._create_completion( messages=messages, model=eff_model, timeout_s=eff_timeout, **completion_kwargs, ) 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", max_completion_tokens: Optional[int] = None, ) -> 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: completion_kwargs: Dict[str, Any] = { "response_format": {"type": response_format}, } if max_completion_tokens is not None: completion_kwargs["max_completion_tokens"] = max_completion_tokens chat_completion = await self._create_completion_async( messages=messages, model=eff_model, timeout_s=eff_timeout, **completion_kwargs, ) 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