Coverage for apps / ai / services / openrouter.py: 20%

141 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:40 +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 pass 

18 

19 

20class AIUnavailableError(AIServiceError): 

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

22 pass 

23 

24 

25class AIResponseError(AIServiceError): 

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

27 pass 

28 

29 

30class OpenRouterService: 

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

32 

33 # Class-level cache for API key validation: {key_hash: (is_valid, timestamp)} 

34 _key_validation_cache: dict[int, tuple[bool, float]] = {} 

35 KEY_VALIDATION_TTL = 300 # 5 minutes 

36 

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

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

39 

40 Args: 

41 api_key: OpenRouter API key. If None, fetches from AppSettings. 

42 """ 

43 if api_key is None: 

44 settings = AppSettings.get() 

45 api_key = settings.openrouter_api_key 

46 

47 if not api_key: 

48 raise AIUnavailableError('OpenRouter API key not configured') 

49 

50 self.api_key = api_key 

51 

52 def complete( 

53 self, 

54 system_prompt: str, 

55 user_prompt: str, 

56 model: str = 'anthropic/claude-3.5-haiku', 

57 json_response: bool = True, 

58 ) -> dict[str, Any]: 

59 """Send a completion request to OpenRouter. 

60 

61 Args: 

62 system_prompt: System message for the AI. 

63 user_prompt: User message/query. 

64 model: Model identifier (e.g., 'anthropic/claude-3.5-haiku'). 

65 json_response: Whether to request JSON output. 

66 

67 Returns: 

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

69 

70 Raises: 

71 AIUnavailableError: If no API key is configured. 

72 AIResponseError: If the response is invalid. 

73 """ 

74 messages = [ 

75 {'role': 'system', 'content': system_prompt}, 

76 {'role': 'user', 'content': user_prompt}, 

77 ] 

78 

79 try: 

80 with OpenRouter(api_key=self.api_key) as client: 

81 response = client.chat.send( 

82 messages=messages, 

83 model=model, 

84 stream=False, 

85 ) 

86 

87 # Extract the response content 

88 if not response or not hasattr(response, 'choices'): 

89 raise AIResponseError('Invalid response structure from OpenRouter') 

90 

91 if not response.choices: 

92 raise AIResponseError('No choices in OpenRouter response') 

93 

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

95 

96 if json_response: 

97 # Parse JSON from the response 

98 try: 

99 # Handle potential markdown code blocks 

100 if content.startswith('```'): 

101 # Extract JSON from code block 

102 lines = content.split('\n') 

103 json_lines = [] 

104 in_block = False 

105 for line in lines: 

106 if line.startswith('```'): 

107 in_block = not in_block 

108 continue 

109 if in_block: 

110 json_lines.append(line) 

111 content = '\n'.join(json_lines) 

112 

113 return json.loads(content) 

114 except json.JSONDecodeError as e: 

115 # Note: This log appears in test output for test_complete_invalid_json - expected 

116 logger.error(f'Failed to parse AI response as JSON: {content}') 

117 raise AIResponseError(f'Invalid JSON in AI response: {e}') 

118 

119 return {'content': content} 

120 

121 except AIServiceError: 

122 raise 

123 except Exception as e: 

124 logger.exception('OpenRouter API error') 

125 raise AIResponseError(f'OpenRouter API error: {e}') 

126 

127 async def complete_async( 

128 self, 

129 system_prompt: str, 

130 user_prompt: str, 

131 model: str = 'anthropic/claude-3.5-haiku', 

132 json_response: bool = True, 

133 ) -> dict[str, Any]: 

134 """Async version of complete(). 

135 

136 Args: 

137 system_prompt: System message for the AI. 

138 user_prompt: User message/query. 

139 model: Model identifier (e.g., 'anthropic/claude-3.5-haiku'). 

140 json_response: Whether to request JSON output. 

141 

142 Returns: 

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

144 """ 

145 messages = [ 

146 {'role': 'system', 'content': system_prompt}, 

147 {'role': 'user', 'content': user_prompt}, 

148 ] 

149 

150 try: 

151 async with OpenRouter(api_key=self.api_key) as client: 

152 response = await client.chat.send_async( 

153 messages=messages, 

154 model=model, 

155 stream=False, 

156 ) 

157 

158 # Extract the response content 

159 if not response or not hasattr(response, 'choices'): 

160 raise AIResponseError('Invalid response structure from OpenRouter') 

161 

162 if not response.choices: 

163 raise AIResponseError('No choices in OpenRouter response') 

164 

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

166 

167 if json_response: 

168 # Parse JSON from the response 

169 try: 

170 # Handle potential markdown code blocks 

171 if content.startswith('```'): 

172 lines = content.split('\n') 

173 json_lines = [] 

174 in_block = False 

175 for line in lines: 

176 if line.startswith('```'): 

177 in_block = not in_block 

178 continue 

179 if in_block: 

180 json_lines.append(line) 

181 content = '\n'.join(json_lines) 

182 

183 return json.loads(content) 

184 except json.JSONDecodeError as e: 

185 logger.error(f'Failed to parse AI response as JSON: {content}') 

186 raise AIResponseError(f'Invalid JSON in AI response: {e}') 

187 

188 return {'content': content} 

189 

190 except AIServiceError: 

191 raise 

192 except Exception as e: 

193 logger.exception('OpenRouter API error') 

194 raise AIResponseError(f'OpenRouter API error: {e}') 

195 

196 @classmethod 

197 def is_available(cls) -> bool: 

198 """Check if AI service is available (API key configured).""" 

199 settings = AppSettings.get() 

200 return bool(settings.openrouter_api_key) 

201 

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

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

204 

205 Returns: 

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

207 

208 Raises: 

209 AIResponseError: If the API call fails. 

210 """ 

211 try: 

212 with OpenRouter(api_key=self.api_key) as client: 

213 response = client.models.list() 

214 

215 if not response or not hasattr(response, 'data'): 

216 raise AIResponseError('Invalid response from OpenRouter models API') 

217 

218 models = [ 

219 {'id': model.id, 'name': model.name} 

220 for model in response.data 

221 if hasattr(model, 'id') and hasattr(model, 'name') 

222 ] 

223 return sorted(models, key=lambda m: m['name'].lower()) 

224 except AIServiceError: 

225 raise 

226 except Exception as e: 

227 logger.exception('Failed to fetch OpenRouter models') 

228 raise AIResponseError(f'Failed to fetch available models: {e}') 

229 

230 @classmethod 

231 def test_connection(cls, api_key: str) -> tuple[bool, str]: 

232 """Test if an API key is valid by making a minimal request. 

233 

234 Args: 

235 api_key: The API key to test. 

236 

237 Returns: 

238 Tuple of (success: bool, message: str) 

239 """ 

240 try: 

241 service = cls(api_key=api_key) 

242 # Make a minimal test request 

243 service.complete( 

244 system_prompt='Respond with exactly: {"status": "ok"}', 

245 user_prompt='Test connection', 

246 model='anthropic/claude-3.5-haiku', 

247 json_response=True, 

248 ) 

249 return True, 'Connection successful' 

250 except AIUnavailableError: 

251 return False, 'API key not provided' 

252 except AIResponseError as e: 

253 return False, f'API error: {e}' 

254 except Exception as e: 

255 return False, f'Connection failed: {e}' 

256 

257 @classmethod 

258 def validate_key_cached(cls, api_key: str | None = None) -> tuple[bool, str | None]: 

259 """Validate API key with caching to avoid excessive API calls. 

260 

261 Args: 

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

263 

264 Returns: 

265 Tuple of (is_valid: bool, error_message: str | None) 

266 """ 

267 if api_key is None: 

268 settings = AppSettings.get() 

269 api_key = settings.openrouter_api_key 

270 

271 if not api_key: 

272 return False, 'No API key configured' 

273 

274 key_hash = hash(api_key) 

275 now = time.time() 

276 

277 # Check cache 

278 if key_hash in cls._key_validation_cache: 

279 is_valid, timestamp = cls._key_validation_cache[key_hash] 

280 if now - timestamp < cls.KEY_VALIDATION_TTL: 

281 return is_valid, None if is_valid else 'API key is invalid or expired' 

282 

283 # Validate with API 

284 try: 

285 is_valid, message = cls.test_connection(api_key) 

286 cls._key_validation_cache[key_hash] = (is_valid, now) 

287 return is_valid, None if is_valid else message 

288 except Exception as e: 

289 logger.exception('Failed to validate API key') 

290 return False, f'Unable to verify API key: {e}' 

291 

292 @classmethod 

293 def invalidate_key_cache(cls): 

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

295 cls._key_validation_cache.clear() 

← Back to Dashboard