""" LLM Client for Overgrowth Pipeline Supports multiple providers: OpenAI, Anthropic, OpenRouter """ import os import json import logging import uuid import time from typing import Dict, List, Optional, Iterator, Any from dataclasses import dataclass logger = logging.getLogger(__name__) # Import API monitor for tracking try: from agent.api_monitor import monitor except ImportError: logger.warning("API monitor not available - tracking disabled") monitor = None @dataclass class LLMMessage: role: str # system, user, assistant content: str class LLMClient: """ Unified LLM client supporting multiple providers Falls back gracefully if API keys not available """ def __init__(self): # Support both standard and MCP hackathon naming conventions self.openai_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_MCP_1ST_BDAY") self.anthropic_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_MCP_1ST_BDAY") self.openrouter_key = os.getenv("OPENROUTER_API_KEY") # Determine which provider to use self.provider = self._detect_provider() if self.provider: logger.info(f"LLM client initialized with provider: {self.provider}") else: logger.warning("No LLM API keys found - using mock responses") @staticmethod def env_status() -> Dict[str, str]: """ Report which keys are present (without exposing values) and chosen provider. Helpful for UI/debug when Secrets are misconfigured. """ status = { "openai_key": "present" if (os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_MCP_1ST_BDAY")) else "missing", "anthropic_key": "present" if (os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_MCP_1ST_BDAY")) else "missing", "openrouter_key": "present" if os.getenv("OPENROUTER_API_KEY") else "missing", "provider": "unknown", } if status["anthropic_key"] == "present": status["provider"] = "anthropic" elif status["openai_key"] == "present": status["provider"] = "openai" elif status["openrouter_key"] == "present": status["provider"] = "openrouter" return status def _detect_provider(self) -> Optional[str]: """Detect which LLM provider is available""" if self.anthropic_key: return "anthropic" elif self.openai_key: return "openai" elif self.openrouter_key: return "openrouter" return None def chat( self, messages: List[LLMMessage], temperature: float = 0.7, max_tokens: int = 4000, stream: bool = False ) -> str: """ Send chat completion request Returns response text or yields chunks if streaming """ if not self.provider: raise RuntimeError("No LLM provider configured. Set ANTHROPIC_MCP_1ST_BDAY or OPENAI_MCP_1ST_BDAY.") if self.provider == "openrouter": return self._call_openrouter(messages, temperature, max_tokens, stream) elif self.provider == "openai": return self._call_openai(messages, temperature, max_tokens, stream) elif self.provider == "anthropic": return self._call_anthropic(messages, temperature, max_tokens, stream) def chat_stream( self, messages: List[LLMMessage], temperature: float = 0.7, max_tokens: int = 4000 ) -> Iterator[str]: """Stream chat completion response""" if not self.provider: raise RuntimeError("No LLM provider configured. Set ANTHROPIC_MCP_1ST_BDAY or OPENAI_MCP_1ST_BDAY.") if self.provider == "openrouter": yield from self._stream_openrouter(messages, temperature, max_tokens) elif self.provider == "openai": yield from self._stream_openai(messages, temperature, max_tokens) elif self.provider == "anthropic": yield from self._stream_anthropic(messages, temperature, max_tokens) def _call_openrouter(self, messages, temperature, max_tokens, stream): """Call OpenRouter API""" call_id = str(uuid.uuid4()) model = "anthropic/claude-3.5-sonnet" if monitor: monitor.start_call(call_id, "llm", "openrouter", model, temperature=temperature) try: import requests url = "https://openrouter.ai/api/v1/chat/completions" headers = { "Authorization": f"Bearer {self.openrouter_key}", "Content-Type": "application/json" } data = { "model": model, # Good balance of cost/quality "messages": [{"role": m.role, "content": m.content} for m in messages], "temperature": temperature, "max_tokens": max_tokens } response = requests.post(url, headers=headers, json=data, timeout=60) response.raise_for_status() result = response.json() content = result['choices'][0]['message']['content'] usage = result.get('usage', {}) if monitor: monitor.complete_call( call_id, success=True, input_tokens=usage.get('prompt_tokens', 0), output_tokens=usage.get('completion_tokens', 0) ) return content except Exception as e: logger.error(f"OpenRouter API error: {e}") if monitor: monitor.complete_call(call_id, success=False, error_message=str(e)) raise def _stream_openrouter(self, messages, temperature, max_tokens): """Stream from OpenRouter""" call_id = str(uuid.uuid4()) model = "anthropic/claude-3.5-sonnet" if monitor: monitor.start_call(call_id, "llm", "openrouter", model, temperature=temperature) total_tokens_est = 0 try: import requests url = "https://openrouter.ai/api/v1/chat/completions" headers = { "Authorization": f"Bearer {self.openrouter_key}", "Content-Type": "application/json" } data = { "model": model, "messages": [{"role": m.role, "content": m.content} for m in messages], "temperature": temperature, "max_tokens": max_tokens, "stream": True } with requests.post(url, headers=headers, json=data, stream=True, timeout=60) as response: response.raise_for_status() for line in response.iter_lines(): if line: line = line.decode('utf-8') if line.startswith('data: '): line = line[6:] if line == '[DONE]': # Estimate tokens (rough approximation: 4 chars = 1 token) if monitor: monitor.complete_call(call_id, success=True, input_tokens=total_tokens_est // 2, output_tokens=total_tokens_est // 2) break try: chunk = json.loads(line) if 'choices' in chunk and len(chunk['choices']) > 0: delta = chunk['choices'][0].get('delta', {}) if 'content' in delta: content = delta['content'] total_tokens_est += len(content) // 4 yield content except json.JSONDecodeError: continue except Exception as e: logger.error(f"OpenRouter streaming error: {e}") if monitor: monitor.complete_call(call_id, success=False, error_message=str(e)) raise def _call_openai(self, messages, temperature, max_tokens, stream): """Call OpenAI API""" call_id = str(uuid.uuid4()) model = "gpt-4o" if monitor: monitor.start_call(call_id, "llm", "openai", model, temperature=temperature) try: from openai import OpenAI client = OpenAI(api_key=self.openai_key) response = client.chat.completions.create( model=model, messages=[{"role": m.role, "content": m.content} for m in messages], temperature=temperature, max_tokens=max_tokens ) content = response.choices[0].message.content if monitor and response.usage: monitor.complete_call( call_id, success=True, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens ) return content except Exception as e: logger.error(f"OpenAI API error: {e}") if monitor: monitor.complete_call(call_id, success=False, error_message=str(e)) raise def _stream_openai(self, messages, temperature, max_tokens): """Stream from OpenAI""" call_id = str(uuid.uuid4()) model = "gpt-4o" if monitor: monitor.start_call(call_id, "llm", "openai", model, temperature=temperature) total_tokens_est = 0 try: from openai import OpenAI client = OpenAI(api_key=self.openai_key) stream = client.chat.completions.create( model=model, messages=[{"role": m.role, "content": m.content} for m in messages], temperature=temperature, max_tokens=max_tokens, stream=True ) for chunk in stream: if chunk.choices[0].delta.content: content = chunk.choices[0].delta.content total_tokens_est += len(content) // 4 yield content # Complete call after streaming if monitor: monitor.complete_call(call_id, success=True, input_tokens=total_tokens_est // 2, output_tokens=total_tokens_est // 2) except Exception as e: logger.error(f"OpenAI streaming error: {e}") if monitor: monitor.complete_call(call_id, success=False, error_message=str(e)) raise def _call_anthropic(self, messages, temperature, max_tokens, stream): """Call Anthropic API""" call_id = str(uuid.uuid4()) model = "claude-3-haiku-20240307" if monitor: monitor.start_call(call_id, "llm", "anthropic", model, temperature=temperature) try: import anthropic client = anthropic.Anthropic(api_key=self.anthropic_key) # Convert messages format system_msg = None user_messages = [] for m in messages: if m.role == "system": system_msg = m.content else: user_messages.append({"role": m.role, "content": m.content}) response = client.messages.create( model=model, max_tokens=max_tokens, temperature=temperature, system=system_msg if system_msg else "You are a helpful network automation assistant.", messages=user_messages ) content = response.content[0].text if monitor and response.usage: monitor.complete_call( call_id, success=True, input_tokens=response.usage.input_tokens, output_tokens=response.usage.output_tokens ) return content except Exception as e: err_msg = f"{e} | cause: {repr(getattr(e, '__cause__', ''))}" logger.error(f"Anthropic API error: {err_msg}") if monitor: monitor.complete_call(call_id, success=False, error_message=err_msg) # Fallback to OpenAI if available if self.openai_key: logger.info("Falling back to OpenAI due to Anthropic error") return self._call_openai(messages, temperature, max_tokens, stream=False) raise RuntimeError(f"Anthropic call failed: {err_msg}") def _stream_anthropic(self, messages, temperature, max_tokens): """Stream from Anthropic""" call_id = str(uuid.uuid4()) model = "claude-3-haiku-20240307" if monitor: monitor.start_call(call_id, "llm", "anthropic", model, temperature=temperature) total_input_tokens = 0 total_output_tokens = 0 try: import anthropic client = anthropic.Anthropic(api_key=self.anthropic_key) # Convert messages format system_msg = None user_messages = [] for m in messages: if m.role == "system": system_msg = m.content else: user_messages.append({"role": m.role, "content": m.content}) with client.messages.stream( model=model, max_tokens=max_tokens, temperature=temperature, system=system_msg if system_msg else "You are a helpful network automation assistant.", messages=user_messages ) as stream: for text in stream.text_stream: yield text # Get final usage stats final_message = stream.get_final_message() if final_message and final_message.usage and monitor: monitor.complete_call( call_id, success=True, input_tokens=final_message.usage.input_tokens, output_tokens=final_message.usage.output_tokens ) except Exception as e: err_msg = f"{e} | cause: {repr(getattr(e, '__cause__', ''))}" logger.error(f"Anthropic streaming error: {err_msg}") if monitor: monitor.complete_call(call_id, success=False, error_message=err_msg) if self.openai_key: logger.info("Falling back to OpenAI streaming due to Anthropic error") yield from self._stream_openai(messages, temperature, max_tokens) else: raise RuntimeError(f"Anthropic streaming failed: {err_msg}") def _mock_response(self, messages: List[LLMMessage]) -> str: """Deprecated: mocks disabled to avoid hiding real failures.""" raise RuntimeError("LLM mock responses are disabled. Provide a valid API key.")