Coverage for src / local_deep_research / llm / providers / implementations / ollama.py: 88%

122 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-01-11 00:51 +0000

1"""Ollama LLM provider for Local Deep Research.""" 

2 

3import requests 

4from langchain_ollama import ChatOllama 

5from loguru import logger 

6 

7from ....config.thread_settings import ( 

8 get_setting_from_snapshot as _get_setting_from_snapshot, 

9) 

10from ....utilities.url_utils import normalize_url 

11from ...llm_registry import register_llm 

12from ....security import safe_get 

13 

14 

15def get_setting_from_snapshot( 

16 key, default=None, username=None, settings_snapshot=None 

17): 

18 """Get setting from context only - no database access from threads. 

19 

20 This is a wrapper around the shared function that enables fallback LLM check. 

21 """ 

22 return _get_setting_from_snapshot( 

23 key, default, username, settings_snapshot, check_fallback_llm=True 

24 ) 

25 

26 

27class OllamaProvider: 

28 """Ollama provider for Local Deep Research. 

29 

30 This is the Ollama local model provider. 

31 """ 

32 

33 provider_name = "Ollama" 

34 default_model = "gemma:latest" 

35 api_key_setting = "llm.ollama.api_key" # Optional API key for authenticated Ollama instances 

36 url_setting = "llm.ollama.url" # URL setting for model listing 

37 

38 # Metadata for auto-discovery 

39 provider_key = "OLLAMA" 

40 company_name = "Ollama" 

41 region = "Local" 

42 country = "Local" 

43 data_location = "Local" 

44 is_cloud = False 

45 

46 @classmethod 

47 def _get_auth_headers(cls, api_key=None, settings_snapshot=None): 

48 """Get authentication headers for Ollama API requests. 

49 

50 Args: 

51 api_key: Optional API key to use (takes precedence) 

52 settings_snapshot: Optional settings snapshot to get API key from 

53 

54 Returns: 

55 Dict of headers, empty if no API key configured 

56 """ 

57 headers = {} 

58 

59 # Use provided API key or get from settings 

60 if api_key is None and settings_snapshot is not None: 

61 api_key = get_setting_from_snapshot( 

62 cls.api_key_setting, 

63 "", # Empty string instead of None to avoid NoSettingsContextError 

64 settings_snapshot=settings_snapshot, 

65 ) 

66 

67 if api_key: 

68 # Support Bearer token authentication for proxied Ollama instances 

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

70 

71 return headers 

72 

73 @classmethod 

74 def list_models_for_api(cls, api_key=None, base_url=None): 

75 """Get available models from Ollama. 

76 

77 Args: 

78 api_key: Optional API key for authentication 

79 base_url: Base URL for Ollama API (required) 

80 

81 Returns: 

82 List of model dictionaries with 'value' and 'label' keys 

83 """ 

84 from ....utilities.llm_utils import fetch_ollama_models 

85 

86 if not base_url: 

87 logger.warning("Ollama URL not configured") 

88 return [] 

89 

90 base_url = normalize_url(base_url) 

91 

92 # Get authentication headers 

93 headers = cls._get_auth_headers(api_key=api_key) 

94 

95 # Fetch models using centralized function 

96 models = fetch_ollama_models( 

97 base_url, timeout=2.0, auth_headers=headers 

98 ) 

99 

100 # Add provider info and format for LLM API 

101 for model in models: 

102 # Clean up the model name for display 

103 model_name = model["value"] 

104 display_name = model_name.replace(":latest", "").replace(":", " ") 

105 model["label"] = f"{display_name} (Ollama)" 

106 model["provider"] = "OLLAMA" 

107 

108 logger.info(f"Found {len(models)} Ollama models") 

109 return models 

110 

111 @classmethod 

112 def create_llm(cls, model_name=None, temperature=0.7, **kwargs): 

113 """Factory function for Ollama LLMs. 

114 

115 Args: 

116 model_name: Name of the model to use 

117 temperature: Model temperature (0.0-1.0) 

118 **kwargs: Additional arguments including settings_snapshot 

119 

120 Returns: 

121 A configured ChatOllama instance 

122 

123 Raises: 

124 ValueError: If Ollama is not available 

125 """ 

126 settings_snapshot = kwargs.get("settings_snapshot") 

127 

128 # Use default model if none specified 

129 if not model_name: 

130 model_name = cls.default_model 

131 

132 # Use the configurable Ollama base URL 

133 raw_base_url = get_setting_from_snapshot( 

134 "llm.ollama.url", 

135 None, 

136 settings_snapshot=settings_snapshot, 

137 ) 

138 if not raw_base_url: 

139 raise ValueError( 

140 "Ollama URL not configured. Please set llm.ollama.url in settings." 

141 ) 

142 base_url = normalize_url(raw_base_url) 

143 

144 # Check if Ollama is available before trying to use it 

145 if not cls.is_available(settings_snapshot): 

146 logger.error(f"Ollama not available at {base_url}.") 

147 raise ValueError(f"Ollama not available at {base_url}") 

148 

149 # Check if the requested model exists 

150 try: 

151 logger.info(f"Checking if model '{model_name}' exists in Ollama") 

152 

153 # Get authentication headers 

154 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

155 

156 response = safe_get( 

157 f"{base_url}/api/tags", 

158 timeout=3.0, 

159 headers=headers, 

160 allow_localhost=True, 

161 allow_private_ips=True, 

162 ) 

163 if response.status_code == 200: 163 ↛ 193line 163 didn't jump to line 193 because the condition on line 163 was always true

164 # Handle both newer and older Ollama API formats 

165 data = response.json() 

166 models = [] 

167 if "models" in data: 167 ↛ 172line 167 didn't jump to line 172 because the condition on line 167 was always true

168 # Newer Ollama API 

169 models = data.get("models", []) 

170 else: 

171 # Older Ollama API format 

172 models = data 

173 

174 # Get list of model names 

175 model_names = [m.get("name", "").lower() for m in models] 

176 logger.info( 

177 f"Available Ollama models: {', '.join(model_names[:5])}{' and more' if len(model_names) > 5 else ''}" 

178 ) 

179 

180 if model_name.lower() not in model_names: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 logger.error( 

182 f"Model '{model_name}' not found in Ollama. Available models: {', '.join(model_names[:5])}" 

183 ) 

184 raise ValueError( 

185 f"Model '{model_name}' not found in Ollama" 

186 ) 

187 except Exception: 

188 logger.exception( 

189 f"Error checking for model '{model_name}' in Ollama" 

190 ) 

191 # Continue anyway, let ChatOllama handle potential errors 

192 

193 logger.info( 

194 f"Creating ChatOllama with model={model_name}, base_url={base_url}" 

195 ) 

196 

197 # Build Ollama parameters 

198 ollama_params = { 

199 "model": model_name, 

200 "base_url": base_url, 

201 "temperature": temperature, 

202 } 

203 

204 # Add authentication headers if configured 

205 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

206 if headers: 206 ↛ 208line 206 didn't jump to line 208 because the condition on line 206 was never true

207 # ChatOllama supports auth via headers parameter 

208 ollama_params["headers"] = headers 

209 

210 # Get context window size from settings for local providers 

211 context_window_size = get_setting_from_snapshot( 

212 "llm.local_context_window_size", 

213 4096, 

214 settings_snapshot=settings_snapshot, 

215 ) 

216 if context_window_size is not None: 216 ↛ 220line 216 didn't jump to line 220 because the condition on line 216 was always true

217 ollama_params["num_ctx"] = int(context_window_size) 

218 

219 # Add max_tokens if specified in settings and supported 

220 if get_setting_from_snapshot( 220 ↛ 237line 220 didn't jump to line 237 because the condition on line 220 was always true

221 "llm.supports_max_tokens", True, settings_snapshot=settings_snapshot 

222 ): 

223 # Use 80% of context window to leave room for prompts 

224 if context_window_size is not None: 224 ↛ 237line 224 didn't jump to line 237 because the condition on line 224 was always true

225 max_tokens = min( 

226 int( 

227 get_setting_from_snapshot( 

228 "llm.max_tokens", 

229 100000, 

230 settings_snapshot=settings_snapshot, 

231 ) 

232 ), 

233 int(context_window_size * 0.8), 

234 ) 

235 ollama_params["max_tokens"] = max_tokens 

236 

237 llm = ChatOllama(**ollama_params) 

238 

239 # Log the actual client configuration after creation 

240 logger.debug( 

241 f"ChatOllama created - base_url attribute: {getattr(llm, 'base_url', 'not found')}" 

242 ) 

243 

244 return llm 

245 

246 @classmethod 

247 def is_available(cls, settings_snapshot=None): 

248 """Check if Ollama is running. 

249 

250 Args: 

251 settings_snapshot: Optional settings snapshot to use 

252 

253 Returns: 

254 True if Ollama is available, False otherwise 

255 """ 

256 try: 

257 raw_base_url = get_setting_from_snapshot( 

258 "llm.ollama.url", 

259 None, 

260 settings_snapshot=settings_snapshot, 

261 ) 

262 if not raw_base_url: 

263 logger.debug("Ollama URL not configured") 

264 return False 

265 base_url = normalize_url(raw_base_url) 

266 logger.info(f"Checking Ollama availability at {base_url}/api/tags") 

267 

268 # Get authentication headers 

269 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

270 

271 try: 

272 response = safe_get( 

273 f"{base_url}/api/tags", 

274 timeout=3.0, 

275 headers=headers, 

276 allow_localhost=True, 

277 allow_private_ips=True, 

278 ) 

279 if response.status_code == 200: 

280 logger.info( 

281 f"Ollama is available. Status code: {response.status_code}" 

282 ) 

283 # Log first 100 chars of response to debug 

284 logger.info(f"Response preview: {str(response.text)[:100]}") 

285 return True 

286 else: 

287 logger.warning( 

288 f"Ollama API returned status code: {response.status_code}" 

289 ) 

290 return False 

291 except requests.exceptions.RequestException as req_error: 

292 logger.exception( 

293 f"Request error when checking Ollama: {req_error!s}" 

294 ) 

295 return False 

296 except Exception: 

297 logger.exception("Unexpected error when checking Ollama") 

298 return False 

299 except Exception: 

300 logger.exception("Error in is_ollama_available") 

301 return False 

302 

303 

304# Keep the standalone functions for backward compatibility and registration 

305def create_ollama_llm(model_name=None, temperature=0.7, **kwargs): 

306 """Factory function for Ollama LLMs. 

307 

308 Args: 

309 model_name: Name of the model to use (e.g., "llama2", "gemma", etc.) 

310 temperature: Model temperature (0.0-1.0) 

311 **kwargs: Additional arguments including settings_snapshot 

312 

313 Returns: 

314 A configured ChatOllama instance 

315 

316 Raises: 

317 ValueError: If Ollama is not available 

318 """ 

319 return OllamaProvider.create_llm(model_name, temperature, **kwargs) 

320 

321 

322def is_ollama_available(settings_snapshot=None): 

323 """Check if Ollama is available. 

324 

325 Args: 

326 settings_snapshot: Optional settings snapshot to use 

327 

328 Returns: 

329 True if Ollama is running, False otherwise 

330 """ 

331 return OllamaProvider.is_available(settings_snapshot) 

332 

333 

334def register_ollama_provider(): 

335 """Register the Ollama provider with the LLM registry.""" 

336 register_llm("ollama", create_ollama_llm) 

337 logger.info("Registered Ollama LLM provider")