Coverage for apps / ai / services / openrouter.py: 27%
128 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
1"""OpenRouter API service using the official SDK."""
3import json
4import logging
5import time
6from typing import Any
8from openrouter import OpenRouter
10from apps.core.models import AppSettings
12logger = logging.getLogger(__name__)
15class AIServiceError(Exception):
16 """Base exception for AI service errors."""
18 pass
21class AIUnavailableError(AIServiceError):
22 """Raised when AI service is not available (no API key)."""
24 pass
27class AIResponseError(AIServiceError):
28 """Raised when AI returns an invalid or unexpected response."""
30 pass
33class OpenRouterService:
34 """Service for interacting with OpenRouter API."""
36 # Class-level cache for API key validation: {key_hash: (is_valid, timestamp)}
37 _key_validation_cache: dict[int, tuple[bool, float]] = {}
38 KEY_VALIDATION_TTL = 300 # 5 minutes
40 def __init__(self, api_key: str | None = None):
41 """Initialize the service with an API key.
43 Args:
44 api_key: OpenRouter API key. If None, fetches from AppSettings.
45 """
46 if api_key is None:
47 settings = AppSettings.get()
48 api_key = settings.openrouter_api_key
50 if not api_key:
51 raise AIUnavailableError("OpenRouter API key not configured")
53 self.api_key = api_key
55 def _parse_json_response(self, content: str) -> dict:
56 """Parse JSON from AI response, handling markdown code blocks.
58 Args:
59 content: Raw response content from the AI.
61 Returns:
62 Parsed JSON as a dict.
64 Raises:
65 AIResponseError: If the content is not valid JSON.
66 """
67 try:
68 # Handle potential markdown code blocks
69 if content.startswith("```"):
70 # Extract JSON from code block
71 lines = content.split("\n")
72 json_lines = []
73 in_block = False
74 for line in lines:
75 if line.startswith("```"):
76 in_block = not in_block
77 continue
78 if in_block:
79 json_lines.append(line)
80 content = "\n".join(json_lines)
82 return json.loads(content)
83 except json.JSONDecodeError as e:
84 logger.error(f"Failed to parse AI response as JSON: {content}")
85 raise AIResponseError(f"Invalid JSON in AI response: {e}")
87 def complete(
88 self,
89 system_prompt: str,
90 user_prompt: str,
91 model: str = "anthropic/claude-3.5-haiku",
92 json_response: bool = True,
93 ) -> dict[str, Any]:
94 """Send a completion request to OpenRouter.
96 Args:
97 system_prompt: System message for the AI.
98 user_prompt: User message/query.
99 model: Model identifier (e.g., 'anthropic/claude-3.5-haiku').
100 json_response: Whether to request JSON output.
102 Returns:
103 Parsed JSON response from the AI, or raw text response.
105 Raises:
106 AIUnavailableError: If no API key is configured.
107 AIResponseError: If the response is invalid.
108 """
109 messages = [
110 {"role": "system", "content": system_prompt},
111 {"role": "user", "content": user_prompt},
112 ]
114 try:
115 with OpenRouter(api_key=self.api_key) as client:
116 response = client.chat.send(
117 messages=messages,
118 model=model,
119 stream=False,
120 )
122 # Extract the response content
123 if not response or not hasattr(response, "choices"):
124 raise AIResponseError("Invalid response structure from OpenRouter")
126 if not response.choices:
127 raise AIResponseError("No choices in OpenRouter response")
129 content = response.choices[0].message.content
131 if json_response:
132 return self._parse_json_response(content)
134 return {"content": content}
136 except AIServiceError:
137 raise
138 except Exception as e:
139 logger.exception("OpenRouter API error")
140 raise AIResponseError(f"OpenRouter API error: {e}")
142 async def complete_async(
143 self,
144 system_prompt: str,
145 user_prompt: str,
146 model: str = "anthropic/claude-3.5-haiku",
147 json_response: bool = True,
148 ) -> dict[str, Any]:
149 """Async version of complete().
151 Args:
152 system_prompt: System message for the AI.
153 user_prompt: User message/query.
154 model: Model identifier (e.g., 'anthropic/claude-3.5-haiku').
155 json_response: Whether to request JSON output.
157 Returns:
158 Parsed JSON response from the AI, or raw text response.
159 """
160 messages = [
161 {"role": "system", "content": system_prompt},
162 {"role": "user", "content": user_prompt},
163 ]
165 try:
166 async with OpenRouter(api_key=self.api_key) as client:
167 response = await client.chat.send_async(
168 messages=messages,
169 model=model,
170 stream=False,
171 )
173 # Extract the response content
174 if not response or not hasattr(response, "choices"):
175 raise AIResponseError("Invalid response structure from OpenRouter")
177 if not response.choices:
178 raise AIResponseError("No choices in OpenRouter response")
180 content = response.choices[0].message.content
182 if json_response:
183 return self._parse_json_response(content)
185 return {"content": content}
187 except AIServiceError:
188 raise
189 except Exception as e:
190 logger.exception("OpenRouter API error")
191 raise AIResponseError(f"OpenRouter API error: {e}")
193 @classmethod
194 def is_available(cls) -> bool:
195 """Check if AI service is available (API key configured)."""
196 settings = AppSettings.get()
197 return bool(settings.openrouter_api_key)
199 def get_available_models(self) -> list[dict[str, str]]:
200 """Get list of available models from OpenRouter.
202 Returns:
203 List of dicts with 'id' and 'name' keys for each available model.
205 Raises:
206 AIResponseError: If the API call fails.
207 """
208 try:
209 with OpenRouter(api_key=self.api_key) as client:
210 response = client.models.list()
212 if not response or not hasattr(response, "data"):
213 raise AIResponseError("Invalid response from OpenRouter models API")
215 models = [
216 {"id": model.id, "name": model.name}
217 for model in response.data
218 if hasattr(model, "id") and hasattr(model, "name")
219 ]
220 return sorted(models, key=lambda m: m["name"].lower())
221 except AIServiceError:
222 raise
223 except Exception as e:
224 logger.exception("Failed to fetch OpenRouter models")
225 raise AIResponseError(f"Failed to fetch available models: {e}")
227 @classmethod
228 def test_connection(cls, api_key: str) -> tuple[bool, str]:
229 """Test if an API key is valid by making a minimal request.
231 Args:
232 api_key: The API key to test.
234 Returns:
235 Tuple of (success: bool, message: str)
236 """
237 try:
238 service = cls(api_key=api_key)
239 # Make a minimal test request
240 service.complete(
241 system_prompt='Respond with exactly: {"status": "ok"}',
242 user_prompt="Test connection",
243 model="anthropic/claude-3.5-haiku",
244 json_response=True,
245 )
246 return True, "Connection successful"
247 except AIUnavailableError:
248 return False, "API key not provided"
249 except AIResponseError as e:
250 return False, f"API error: {e}"
251 except Exception as e:
252 return False, f"Connection failed: {e}"
254 @classmethod
255 def validate_key_cached(cls, api_key: str | None = None) -> tuple[bool, str | None]:
256 """Validate API key with caching to avoid excessive API calls.
258 Args:
259 api_key: API key to validate. If None, fetches from AppSettings.
261 Returns:
262 Tuple of (is_valid: bool, error_message: str | None)
263 """
264 if api_key is None:
265 settings = AppSettings.get()
266 api_key = settings.openrouter_api_key
268 if not api_key:
269 return False, "No API key configured"
271 key_hash = hash(api_key)
272 now = time.time()
274 # Check cache
275 if key_hash in cls._key_validation_cache:
276 is_valid, timestamp = cls._key_validation_cache[key_hash]
277 if now - timestamp < cls.KEY_VALIDATION_TTL:
278 return is_valid, None if is_valid else "API key is invalid or expired"
280 # Validate with API
281 try:
282 is_valid, message = cls.test_connection(api_key)
283 cls._key_validation_cache[key_hash] = (is_valid, now)
284 return is_valid, None if is_valid else message
285 except Exception as e:
286 logger.exception("Failed to validate API key")
287 return False, f"Unable to verify API key: {e}"
289 @classmethod
290 def invalidate_key_cache(cls):
291 """Clear validation cache (call when key is updated)."""
292 cls._key_validation_cache.clear()