Coverage for apps / ai / services / openrouter.py: 84%
131 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""OpenRouter API service using the official SDK."""
3import hashlib
4import json
5import logging
6import time
7from typing import Any
9from openrouter import OpenRouter
11from apps.core.models import AppSettings
13logger = logging.getLogger(__name__)
16class AIServiceError(Exception):
17 """Base exception for AI service errors."""
19 pass
22class AIUnavailableError(AIServiceError):
23 """Raised when AI service is not available (no API key)."""
25 pass
28class AIResponseError(AIServiceError):
29 """Raised when AI returns an invalid or unexpected response."""
31 pass
34class OpenRouterService:
35 """Service for interacting with OpenRouter API."""
37 # Class-level cache for API key validation: {key_hash: (is_valid, timestamp)}
38 _key_validation_cache: dict[int, tuple[bool, float]] = {}
39 KEY_VALIDATION_TTL = 300 # 5 minutes
41 def __init__(self, api_key: str | None = None):
42 if api_key is None:
43 settings = AppSettings.get()
44 api_key = settings.openrouter_api_key
46 if not api_key:
47 raise AIUnavailableError("OpenRouter API key not configured")
49 self.api_key = api_key
51 def _parse_json_response(self, content: str) -> dict:
52 """Parse JSON from AI response, handling markdown code blocks."""
53 try:
54 if content.startswith("```"):
55 lines = content.split("\n")
56 json_lines = []
57 in_block = False
58 for line in lines:
59 if line.startswith("```"):
60 in_block = not in_block
61 continue
62 if in_block:
63 json_lines.append(line)
64 content = "\n".join(json_lines)
66 return json.loads(content)
67 except json.JSONDecodeError as e:
68 logger.error(f"Failed to parse AI response as JSON: {content}")
69 raise AIResponseError(f"Invalid JSON in AI response: {e}")
71 def complete(
72 self,
73 system_prompt: str,
74 user_prompt: str,
75 model: str = "anthropic/claude-haiku-4.5",
76 json_response: bool = True,
77 timeout: int = 30,
78 ) -> dict[str, Any]:
79 """Send a completion request to OpenRouter."""
80 messages = [
81 {"role": "system", "content": system_prompt},
82 {"role": "user", "content": user_prompt},
83 ]
85 timeout_ms = timeout * 1000
87 try:
88 with OpenRouter(api_key=self.api_key) as client:
89 response = client.chat.send(
90 messages=messages,
91 model=model,
92 stream=False,
93 timeout_ms=timeout_ms,
94 )
96 if not response or not hasattr(response, "choices"):
97 raise AIResponseError("Invalid response structure from OpenRouter")
99 if not response.choices:
100 raise AIResponseError("No choices in OpenRouter response")
102 content = response.choices[0].message.content
104 if json_response:
105 return self._parse_json_response(content)
107 return {"content": content}
109 except AIServiceError:
110 raise
111 except Exception as e:
112 logger.exception("OpenRouter API error")
113 raise AIResponseError(f"OpenRouter API error: {e}")
115 async def complete_async(
116 self,
117 system_prompt: str,
118 user_prompt: str,
119 model: str = "anthropic/claude-haiku-4.5",
120 json_response: bool = True,
121 timeout: int = 30,
122 ) -> dict[str, Any]:
123 """Async version of complete()."""
124 messages = [
125 {"role": "system", "content": system_prompt},
126 {"role": "user", "content": user_prompt},
127 ]
129 timeout_ms = timeout * 1000
131 try:
132 async with OpenRouter(api_key=self.api_key) as client:
133 response = await client.chat.send_async(
134 messages=messages,
135 model=model,
136 stream=False,
137 timeout_ms=timeout_ms,
138 )
140 if not response or not hasattr(response, "choices"):
141 raise AIResponseError("Invalid response structure from OpenRouter")
143 if not response.choices:
144 raise AIResponseError("No choices in OpenRouter response")
146 content = response.choices[0].message.content
148 if json_response:
149 return self._parse_json_response(content)
151 return {"content": content}
153 except AIServiceError:
154 raise
155 except Exception as e:
156 logger.exception("OpenRouter API error")
157 raise AIResponseError(f"OpenRouter API error: {e}")
159 @classmethod
160 def is_available(cls) -> bool:
161 """Check if AI service is available (API key configured)."""
162 settings = AppSettings.get()
163 return bool(settings.openrouter_api_key)
165 def get_available_models(self) -> list[dict[str, str]]:
166 """Get list of available models from OpenRouter."""
167 try:
168 with OpenRouter(api_key=self.api_key) as client:
169 response = client.models.list()
171 if not response or not hasattr(response, "data"):
172 raise AIResponseError("Invalid response from OpenRouter models API")
174 models = [
175 {"id": model.id, "name": model.name}
176 for model in response.data
177 if hasattr(model, "id") and hasattr(model, "name")
178 ]
179 return sorted(models, key=lambda m: m["name"].lower())
180 except AIServiceError:
181 raise
182 except Exception as e:
183 logger.exception("Failed to fetch OpenRouter models")
184 raise AIResponseError(f"Failed to fetch available models: {e}")
186 @classmethod
187 def test_connection(cls, api_key: str) -> tuple[bool, str]:
188 """Test if an API key is valid by making a minimal request."""
189 try:
190 service = cls(api_key=api_key)
191 service.complete(
192 system_prompt='Respond with exactly: {"status": "ok"}',
193 user_prompt="Test connection",
194 model="anthropic/claude-3.5-haiku",
195 json_response=True,
196 timeout=10,
197 )
198 return True, "Connection successful"
199 except AIUnavailableError:
200 return False, "API key not provided"
201 except AIResponseError as e:
202 return False, f"API error: {e}"
203 except Exception as e:
204 return False, f"Connection failed: {e}"
206 @classmethod
207 def validate_key_cached(cls, api_key: str | None = None) -> tuple[bool, str | None]:
208 """Validate API key with caching to avoid excessive API calls."""
209 if api_key is None:
210 settings = AppSettings.get()
211 api_key = settings.openrouter_api_key
213 if not api_key:
214 return False, "No API key configured"
216 # SHA-256 used as a cache lookup key (not for password storage)
217 cache_id = hashlib.sha256(api_key.encode()).hexdigest() # nosec
218 now = time.time()
220 if cache_id in cls._key_validation_cache:
221 is_valid, timestamp = cls._key_validation_cache[cache_id]
222 if now - timestamp < cls.KEY_VALIDATION_TTL:
223 return is_valid, None if is_valid else "API key is invalid or expired"
225 try:
226 is_valid, message = cls.test_connection(api_key)
227 cls._key_validation_cache[cache_id] = (is_valid, now)
228 return is_valid, None if is_valid else message
229 except Exception as e:
230 logger.exception("Failed to validate API key")
231 return False, f"Unable to verify API key: {e}"
233 @classmethod
234 def invalidate_key_cache(cls):
235 """Clear validation cache (call when key is updated)."""
236 cls._key_validation_cache.clear()