""" LLM client supporting both Anthropic and OpenAI APIs. Prefers Anthropic format (per project convention). Falls back to OpenAI format if Anthropic is unavailable. """ import os import logging from .cache import LLMCache logger = logging.getLogger(__name__) class LLMClient: """LLM client with caching support. Prefers Anthropic API.""" def __init__(self, cache: LLMCache | None = None): self.cache = cache or LLMCache() self._anthropic_client = None self._openai_client = None self._init_clients() def _init_clients(self): """Initialize API clients based on available environment variables.""" # Anthropic (preferred) anthropic_key = os.environ.get("ANTHROPIC_API_KEY") anthropic_base = os.environ.get("ANTHROPIC_BASE_URL") if anthropic_key: try: import anthropic kwargs: dict = {"api_key": anthropic_key} if anthropic_base: kwargs["base_url"] = anthropic_base self._anthropic_client = anthropic.Anthropic(**kwargs) logger.info("Anthropic client initialized") except ImportError: logger.warning("anthropic package not installed") # OpenAI (fallback) openai_key = os.environ.get("OPENAI_API_KEY") openai_base = os.environ.get("OPENAI_BASE_URL") if openai_key: try: import openai kwargs = {"api_key": openai_key} if openai_base: kwargs["base_url"] = openai_base self._openai_client = openai.OpenAI(**kwargs) logger.info("OpenAI client initialized") except ImportError: logger.warning("openai package not installed") @property def available(self) -> bool: """Check if any LLM client is available.""" return ( self._anthropic_client is not None or self._openai_client is not None ) def complete( self, prompt: str, system_prompt: str = "", max_tokens: int = 2000 ) -> str: """Send a completion request, with caching. Args: prompt: User message. system_prompt: System message (sets role / persona). max_tokens: Max response tokens. Returns: LLM response text. Raises: RuntimeError: If no client is available. """ model = os.environ.get("MODEL", "deepseek-v4-flash") # Check cache cached = self.cache.get(prompt, model, system_prompt) if cached: logger.info("Cache hit for LLM request") return cached["content"] # Try Anthropic first if self._anthropic_client: content = self._call_anthropic( prompt, system_prompt, model, max_tokens ) elif self._openai_client: content = self._call_openai(prompt, system_prompt, model, max_tokens) else: raise RuntimeError( "No LLM client available. " "Set ANTHROPIC_API_KEY or OPENAI_API_KEY." ) # Cache the response self.cache.set(prompt, model, system_prompt, {"content": content}) return content # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ def _call_anthropic( self, prompt: str, system_prompt: str, model: str, max_tokens: int ) -> str: """Call Anthropic API.""" kwargs: dict = { "model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], } if system_prompt: kwargs["system"] = system_prompt # Only add reasoning_effort if the model supports it reasoning_effort = os.environ.get("REASONING_EFFORT") if reasoning_effort: try: kwargs["reasoning_effort"] = reasoning_effort response = self._anthropic_client.messages.create(**kwargs) except Exception as e: if ( "reasoning_effort" in str(e).lower() or "unexpected" in str(e).lower() ): del kwargs["reasoning_effort"] response = self._anthropic_client.messages.create(**kwargs) else: raise else: response = self._anthropic_client.messages.create(**kwargs) # Extract text from response content blocks if hasattr(response, "content") and response.content: block = response.content[0] if hasattr(block, "text"): return block.text if hasattr(block, "type") and block.type == "thinking": for b in response.content: if hasattr(b, "text"): return b.text return str(block) return str(response) def _call_openai( self, prompt: str, system_prompt: str, model: str, max_tokens: int ) -> str: """Call OpenAI-compatible API.""" messages: list[dict] = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": prompt}) kwargs: dict = { "model": model, "messages": messages, "max_tokens": max_tokens, } # Only add reasoning_effort if supported reasoning_effort = os.environ.get("REASONING_EFFORT") if reasoning_effort: try: kwargs["reasoning_effort"] = reasoning_effort response = self._openai_client.chat.completions.create( **kwargs ) except Exception as e: if ( "reasoning_effort" in str(e).lower() or "unexpected" in str(e).lower() ): del kwargs["reasoning_effort"] response = self._openai_client.chat.completions.create( **kwargs ) else: raise else: response = self._openai_client.chat.completions.create(**kwargs) return response.choices[0].message.content