""" LLM service. Wraps OpenRouter API calls and robust JSON parsing. Kept separate from AnalysisService to avoid circular imports. """ import json import requests from typing import Dict, Any, Optional, List from app.utils.logger import get_logger from app.config import APIKeys from app.utils.config_loader import load_addon_config logger = get_logger(__name__) class LLMService: """LLM provider wrapper.""" def __init__(self): # Config may not be loaded yet during import time; we resolve lazily via properties. pass @property def api_key(self): return APIKeys.OPENROUTER_API_KEY @property def base_url(self): config = load_addon_config() # Keep compatible with old/new config keys. import os return config.get('openrouter', {}).get('base_url') or os.getenv('OPENROUTER_BASE_URL', "https://openrouter.ai/api/v1") def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str: """Call OpenRouter API, with optional fallback models.""" config = load_addon_config() openrouter_config = config.get('openrouter', {}) default_model = openrouter_config.get('model', 'openai/gpt-4o') if model is None: model = default_model url = f"{self.base_url}/chat/completions" headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://quantdinger.com", "X-Title": "QuantDinger Analysis" } # Build model candidates (primary + optional fallbacks). models_to_try = [model] # Fallback models are currently hard-coded for local mode. fallback_models = ["openai/gpt-4o-mini"] if use_fallback and model == default_model: models_to_try.extend(fallback_models) last_error = None timeout = int(openrouter_config.get('timeout', 120)) for current_model in models_to_try: try: data = { "model": current_model, "messages": messages, "temperature": temperature, "response_format": {"type": "json_object"} } # logger.debug(f"Trying model: {current_model}") response = requests.post(url, headers=headers, json=data, timeout=timeout) if response.status_code == 402: logger.warning(f"OpenRouter returned 402 for model {current_model}; trying fallback model...") last_error = f"402 Payment Required for model {current_model}" continue response.raise_for_status() result = response.json() if "choices" in result and len(result["choices"]) > 0: content = result["choices"][0]["message"]["content"] if not content: raise ValueError(f"Model {current_model} returned empty content") if current_model != model: logger.info(f"Fallback model succeeded: {current_model}") return content else: logger.error(f"OpenRouter API returned unexpected structure ({current_model}): {json.dumps(result)}") raise ValueError("OpenRouter API response is missing 'choices'") except requests.exceptions.HTTPError as e: logger.error(f"OpenRouter API HTTP error ({current_model}): {e.response.text if e.response else str(e)}") last_error = str(e) if not use_fallback or current_model == models_to_try[-1]: raise except requests.exceptions.RequestException as e: logger.error(f"OpenRouter API request error ({current_model}): {str(e)}") last_error = str(e) if not use_fallback or current_model == models_to_try[-1]: raise except ValueError as e: logger.warning(f"Model {current_model} returned invalid data: {str(e)}") last_error = str(e) # If this is not the last candidate model, try the next one if current_model == models_to_try[-1]: raise error_msg = f"All model calls failed. Last error: {last_error}" logger.error(error_msg) raise Exception(error_msg) def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any], model: str = None) -> Dict[str, Any]: """Safe LLM call with robust JSON parsing and fallback structure.""" response_text = "" try: response_text = self.call_openrouter_api([ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], model=model) # Strip markdown fences if present clean_text = response_text.strip() if clean_text.startswith("```"): first_newline = clean_text.find("\n") if first_newline != -1: clean_text = clean_text[first_newline+1:] if clean_text.endswith("```"): clean_text = clean_text[:-3] clean_text = clean_text.strip() # Parse JSON result = json.loads(clean_text) return result except json.JSONDecodeError: logger.error(f"JSON parse failed. Raw text: {response_text[:200] if response_text else 'N/A'}") # Try extracting JSON substring try: if response_text: start = response_text.find('{') end = response_text.rfind('}') + 1 if start >= 0 and end > start: result = json.loads(response_text[start:end]) return result except: pass default_structure['report'] = f"Failed to parse analysis result JSON. Raw output (partial): {response_text[:500] if response_text else 'N/A'}" return default_structure except Exception as e: logger.error(f"LLM call failed: {str(e)}") default_structure['report'] = f"Analysis failed: {str(e)}" return default_structure