Coverage for src/chat_limiter/models.py: 92%

130 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-07 15:28 +0100

1""" 

2Dynamic model discovery from provider APIs. 

3 

4This module provides functionality to query provider APIs for available models 

5instead of relying on hardcoded lists. 

6""" 

7 

8import asyncio 

9import logging 

10from datetime import datetime, timedelta 

11from typing import Any 

12 

13import httpx 

14 

15logger = logging.getLogger(__name__) 

16 

17# Cache for model lists to avoid hitting APIs too frequently 

18_model_cache: dict[str, dict[str, Any]] = {} 

19_cache_duration = timedelta(hours=1) # Cache models for 1 hour 

20 

21 

22class ModelDiscovery: 

23 """Dynamic model discovery from provider APIs.""" 

24 

25 @staticmethod 

26 async def get_openai_models(api_key: str) -> set[str]: 

27 """Get available OpenAI models from the API.""" 

28 cache_key = f"openai_models_{hash(api_key)}" 

29 

30 # Check cache first 

31 if _model_cache.get(cache_key): 

32 cache_entry = _model_cache[cache_key] 

33 if datetime.now() - cache_entry["timestamp"] < _cache_duration: 

34 return cache_entry["models"] # type: ignore[no-any-return] 

35 

36 try: 

37 async with httpx.AsyncClient() as client: 

38 response = await client.get( 

39 "https://api.openai.com/v1/models", 

40 headers={"Authorization": f"Bearer {api_key}"}, 

41 timeout=10.0 

42 ) 

43 response.raise_for_status() 

44 

45 data = response.json() 

46 models = set() 

47 

48 for model in data.get("data", []): 

49 model_id = model.get("id", "") 

50 # Filter for chat completion models 

51 if any(keyword in model_id.lower() for keyword in ["gpt", "chat"]): 

52 models.add(model_id) 

53 

54 # Cache the result 

55 _model_cache[cache_key] = { 

56 "models": models, 

57 "timestamp": datetime.now() 

58 } 

59 

60 logger.info(f"Retrieved {len(models)} OpenAI models from API") 

61 return models 

62 

63 except Exception as e: 

64 logger.warning(f"Failed to fetch OpenAI models: {e}") 

65 raise 

66 

67 @staticmethod 

68 async def get_anthropic_models(api_key: str) -> set[str]: 

69 """Get available Anthropic models from the API.""" 

70 cache_key = f"anthropic_models_{hash(api_key)}" 

71 

72 # Check cache first 

73 if _model_cache.get(cache_key): 

74 cache_entry = _model_cache[cache_key] 

75 if datetime.now() - cache_entry["timestamp"] < _cache_duration: 

76 return cache_entry["models"] # type: ignore[no-any-return] 

77 

78 try: 

79 async with httpx.AsyncClient() as client: 

80 response = await client.get( 

81 "https://api.anthropic.com/v1/models", 

82 headers={ 

83 "x-api-key": api_key, 

84 "anthropic-version": "2023-06-01" 

85 }, 

86 timeout=10.0 

87 ) 

88 response.raise_for_status() 

89 

90 data = response.json() 

91 models = set() 

92 

93 for model in data.get("data", []): 

94 model_id = model.get("id", "") 

95 # Filter for Claude models 

96 if "claude" in model_id.lower(): 

97 models.add(model_id) 

98 

99 # Cache the result 

100 _model_cache[cache_key] = { 

101 "models": models, 

102 "timestamp": datetime.now() 

103 } 

104 

105 logger.info(f"Retrieved {len(models)} Anthropic models from API") 

106 return models 

107 

108 except Exception as e: 

109 logger.warning(f"Failed to fetch Anthropic models: {e}") 

110 raise 

111 

112 @staticmethod 

113 async def get_openrouter_models(api_key: str | None = None) -> set[str]: 

114 """Get available OpenRouter models from the API.""" 

115 cache_key = "openrouter_models" 

116 

117 # Check cache first 

118 if _model_cache.get(cache_key): 

119 cache_entry = _model_cache[cache_key] 

120 if datetime.now() - cache_entry["timestamp"] < _cache_duration: 

121 return cache_entry["models"] # type: ignore[no-any-return] 

122 

123 try: 

124 headers = {} 

125 if api_key: 

126 headers["Authorization"] = f"Bearer {api_key}" 

127 

128 async with httpx.AsyncClient() as client: 

129 response = await client.get( 

130 "https://openrouter.ai/api/v1/models", 

131 headers=headers, 

132 timeout=10.0 

133 ) 

134 response.raise_for_status() 

135 

136 data = response.json() 

137 models = set() 

138 

139 for model in data.get("data", []): 

140 model_id = model.get("id", "") 

141 if model_id: 

142 models.add(model_id) 

143 

144 # Cache the result 

145 _model_cache[cache_key] = { 

146 "models": models, 

147 "timestamp": datetime.now() 

148 } 

149 

150 logger.info(f"Retrieved {len(models)} OpenRouter models from API") 

151 return models 

152 

153 except Exception as e: 

154 logger.warning(f"Failed to fetch OpenRouter models: {e}") 

155 raise 

156 

157 @staticmethod 

158 def get_openai_models_sync(api_key: str) -> set[str]: 

159 """Synchronous version of get_openai_models.""" 

160 return asyncio.run(ModelDiscovery.get_openai_models(api_key)) 

161 

162 @staticmethod 

163 def get_anthropic_models_sync(api_key: str) -> set[str]: 

164 """Synchronous version of get_anthropic_models.""" 

165 return asyncio.run(ModelDiscovery.get_anthropic_models(api_key)) 

166 

167 @staticmethod 

168 def get_openrouter_models_sync(api_key: str | None = None) -> set[str]: 

169 """Synchronous version of get_openrouter_models.""" 

170 return asyncio.run(ModelDiscovery.get_openrouter_models(api_key)) 

171 

172 

173async def detect_provider_from_model_async( 

174 model: str, 

175 api_keys: dict[str, str] | None = None 

176) -> str | None: 

177 """ 

178 Detect provider from model name using live API queries. 

179 

180 Args: 

181 model: The model name to check 

182 api_keys: Dictionary of API keys {"openai": "sk-...", "anthropic": "sk-ant-..."} 

183 

184 Returns: 

185 Provider name or None if not found 

186 """ 

187 if not api_keys: 

188 api_keys = {} 

189 

190 # First try simple pattern matching for known formats 

191 if "/" in model: # OpenRouter format 

192 return "openrouter" 

193 

194 # Create all tasks 

195 tasks = [] 

196 

197 if api_keys.get("openai"): 

198 tasks.append(("openai", ModelDiscovery.get_openai_models(api_keys["openai"]))) 

199 

200 if api_keys.get("anthropic"): 

201 tasks.append(("anthropic", ModelDiscovery.get_anthropic_models(api_keys["anthropic"]))) 

202 

203 if api_keys.get("openrouter"): 

204 tasks.append(("openrouter", ModelDiscovery.get_openrouter_models(api_keys["openrouter"]))) 

205 else: 

206 # OpenRouter doesn't require API key for model listing 

207 tasks.append(("openrouter", ModelDiscovery.get_openrouter_models())) 

208 

209 # Use asyncio.gather to run all tasks concurrently and properly handle them 

210 try: 

211 # Extract just the coroutines for gather 

212 coroutines = [task[1] for task in tasks] 

213 provider_names = [task[0] for task in tasks] 

214 

215 # Wait for all results 

216 results = await asyncio.gather(*coroutines, return_exceptions=True) 

217 

218 # Check results in order 

219 for provider_name, result in zip(provider_names, results): 

220 if isinstance(result, Exception): 

221 logger.debug(f"Failed to check {provider_name} for model {model}: {result}") 

222 continue 

223 if model in result: 

224 return provider_name 

225 

226 except Exception as e: 

227 logger.debug(f"Failed to run dynamic discovery for model {model}: {e}") 

228 

229 return None 

230 

231 

232def detect_provider_from_model_sync( 

233 model: str, 

234 api_keys: dict[str, str] | None = None 

235) -> str | None: 

236 """Synchronous version of detect_provider_from_model_async.""" 

237 # Check if we're already in an async context 

238 try: 

239 loop = asyncio.get_running_loop() 

240 # We're in an async context, but need to run in sync mode 

241 # Create a new event loop in a thread 

242 import concurrent.futures 

243 

244 def run_in_thread(): 

245 return asyncio.run(detect_provider_from_model_async(model, api_keys)) 

246 

247 with concurrent.futures.ThreadPoolExecutor() as executor: 

248 future = executor.submit(run_in_thread) 

249 return future.result(timeout=30) # 30 second timeout 

250 

251 except RuntimeError: 

252 # No running loop, safe to use asyncio.run 

253 return asyncio.run(detect_provider_from_model_async(model, api_keys)) 

254 

255 

256def clear_model_cache() -> None: 

257 """Clear the model cache to force fresh API queries.""" 

258 global _model_cache 

259 _model_cache.clear() 

260 logger.info("Model cache cleared") 

261 

262 

263