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

1"""OpenRouter API service using the official SDK.""" 

2 

3import json 

4import logging 

5import time 

6from typing import Any 

7 

8from openrouter import OpenRouter 

9 

10from apps.core.models import AppSettings 

11 

12logger = logging.getLogger(__name__) 

13 

14 

15class AIServiceError(Exception): 

16 """Base exception for AI service errors.""" 

17 

18 pass 

19 

20 

21class AIUnavailableError(AIServiceError): 

22 """Raised when AI service is not available (no API key).""" 

23 

24 pass 

25 

26 

27class AIResponseError(AIServiceError): 

28 """Raised when AI returns an invalid or unexpected response.""" 

29 

30 pass 

31 

32 

33class OpenRouterService: 

34 """Service for interacting with OpenRouter API.""" 

35 

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 

39 

40 def __init__(self, api_key: str | None = None): 

41 """Initialize the service with an API key. 

42 

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 

49 

50 if not api_key: 

51 raise AIUnavailableError("OpenRouter API key not configured") 

52 

53 self.api_key = api_key 

54 

55 def _parse_json_response(self, content: str) -> dict: 

56 """Parse JSON from AI response, handling markdown code blocks. 

57 

58 Args: 

59 content: Raw response content from the AI. 

60 

61 Returns: 

62 Parsed JSON as a dict. 

63 

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) 

81 

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}") 

86 

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. 

95 

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. 

101 

102 Returns: 

103 Parsed JSON response from the AI, or raw text response. 

104 

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 ] 

113 

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 ) 

121 

122 # Extract the response content 

123 if not response or not hasattr(response, "choices"): 

124 raise AIResponseError("Invalid response structure from OpenRouter") 

125 

126 if not response.choices: 

127 raise AIResponseError("No choices in OpenRouter response") 

128 

129 content = response.choices[0].message.content 

130 

131 if json_response: 

132 return self._parse_json_response(content) 

133 

134 return {"content": content} 

135 

136 except AIServiceError: 

137 raise 

138 except Exception as e: 

139 logger.exception("OpenRouter API error") 

140 raise AIResponseError(f"OpenRouter API error: {e}") 

141 

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(). 

150 

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. 

156 

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 ] 

164 

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 ) 

172 

173 # Extract the response content 

174 if not response or not hasattr(response, "choices"): 

175 raise AIResponseError("Invalid response structure from OpenRouter") 

176 

177 if not response.choices: 

178 raise AIResponseError("No choices in OpenRouter response") 

179 

180 content = response.choices[0].message.content 

181 

182 if json_response: 

183 return self._parse_json_response(content) 

184 

185 return {"content": content} 

186 

187 except AIServiceError: 

188 raise 

189 except Exception as e: 

190 logger.exception("OpenRouter API error") 

191 raise AIResponseError(f"OpenRouter API error: {e}") 

192 

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) 

198 

199 def get_available_models(self) -> list[dict[str, str]]: 

200 """Get list of available models from OpenRouter. 

201 

202 Returns: 

203 List of dicts with 'id' and 'name' keys for each available model. 

204 

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() 

211 

212 if not response or not hasattr(response, "data"): 

213 raise AIResponseError("Invalid response from OpenRouter models API") 

214 

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}") 

226 

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. 

230 

231 Args: 

232 api_key: The API key to test. 

233 

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}" 

253 

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. 

257 

258 Args: 

259 api_key: API key to validate. If None, fetches from AppSettings. 

260 

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 

267 

268 if not api_key: 

269 return False, "No API key configured" 

270 

271 key_hash = hash(api_key) 

272 now = time.time() 

273 

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" 

279 

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}" 

288 

289 @classmethod 

290 def invalidate_key_cache(cls): 

291 """Clear validation cache (call when key is updated).""" 

292 cls._key_validation_cache.clear() 

← Back to Dashboard