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

117 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-25 01:07 +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_llm_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 

15class OllamaProvider: 

16 """Ollama provider for Local Deep Research. 

17 

18 This is the Ollama local model provider. 

19 """ 

20 

21 provider_name = "Ollama" 

22 default_model = "gemma:latest" 

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

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

25 

26 # Metadata for auto-discovery 

27 provider_key = "OLLAMA" 

28 company_name = "Ollama" 

29 is_cloud = False 

30 

31 @classmethod 

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

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

34 

35 Args: 

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

37 settings_snapshot: Optional settings snapshot to get API key from 

38 

39 Returns: 

40 Dict of headers, empty if no API key configured 

41 """ 

42 headers = {} 

43 

44 # Use provided API key or get from settings 

45 if api_key is None and settings_snapshot is not None: 

46 api_key = get_setting_from_snapshot( 

47 cls.api_key_setting, 

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

49 settings_snapshot=settings_snapshot, 

50 ) 

51 

52 if api_key: 

53 # Support Bearer token authentication for proxied Ollama instances 

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

55 

56 return headers 

57 

58 @classmethod 

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

60 """Get available models from Ollama. 

61 

62 Args: 

63 api_key: Optional API key for authentication 

64 base_url: Base URL for Ollama API (required) 

65 

66 Returns: 

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

68 """ 

69 from ....utilities.llm_utils import fetch_ollama_models 

70 

71 if not base_url: 

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

73 return [] 

74 

75 base_url = normalize_url(base_url) 

76 

77 # Get authentication headers 

78 headers = cls._get_auth_headers(api_key=api_key) 

79 

80 # Fetch models using centralized function 

81 models = fetch_ollama_models( 

82 base_url, timeout=2.0, auth_headers=headers 

83 ) 

84 

85 # Add provider info and format for LLM API 

86 for model in models: 

87 # Clean up the model name for display 

88 model_name = model["value"] 

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

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

91 model["provider"] = "OLLAMA" 

92 

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

94 return models 

95 

96 @classmethod 

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

98 """Factory function for Ollama LLMs. 

99 

100 Args: 

101 model_name: Name of the model to use 

102 temperature: Model temperature (0.0-1.0) 

103 **kwargs: Additional arguments including settings_snapshot 

104 

105 Returns: 

106 A configured ChatOllama instance 

107 

108 Raises: 

109 ValueError: If Ollama is not available 

110 """ 

111 settings_snapshot = kwargs.get("settings_snapshot") 

112 

113 # Use default model if none specified 

114 if not model_name: 

115 model_name = cls.default_model 

116 

117 # Use the configurable Ollama base URL 

118 raw_base_url = get_setting_from_snapshot( 

119 "llm.ollama.url", 

120 None, 

121 settings_snapshot=settings_snapshot, 

122 ) 

123 if not raw_base_url: 

124 raise ValueError( 

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

126 ) 

127 base_url = normalize_url(raw_base_url) 

128 

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

130 if not cls.is_available(settings_snapshot): 

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

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

133 

134 # Check if the requested model exists 

135 try: 

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

137 

138 # Get authentication headers 

139 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

140 

141 response = safe_get( 

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

143 timeout=3.0, 

144 headers=headers, 

145 allow_localhost=True, 

146 allow_private_ips=True, 

147 ) 

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

149 # Handle both newer and older Ollama API formats 

150 data = response.json() 

151 models = [] 

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

153 # Newer Ollama API 

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

155 else: 

156 # Older Ollama API format 

157 models = data 

158 

159 # Get list of model names 

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

161 logger.info( 

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

163 ) 

164 

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

166 logger.error( 

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

168 ) 

169 raise ValueError( 

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

171 ) 

172 except Exception: 

173 logger.debug( 

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

175 exc_info=True, 

176 ) 

177 # Continue anyway, let ChatOllama handle potential errors 

178 

179 logger.info( 

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

181 ) 

182 

183 # Build Ollama parameters 

184 ollama_params = { 

185 "model": model_name, 

186 "base_url": base_url, 

187 "temperature": temperature, 

188 } 

189 

190 # Add authentication headers if configured 

191 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

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

193 # ChatOllama supports auth via headers parameter 

194 ollama_params["headers"] = headers 

195 

196 # Get context window size from settings for local providers 

197 context_window_size = get_setting_from_snapshot( 

198 "llm.local_context_window_size", 

199 4096, 

200 settings_snapshot=settings_snapshot, 

201 ) 

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

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

204 

205 # Add max_tokens if specified in settings and supported 

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

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

208 ): 

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

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

211 max_tokens = min( 

212 int( 

213 get_setting_from_snapshot( 

214 "llm.max_tokens", 

215 100000, 

216 settings_snapshot=settings_snapshot, 

217 ) 

218 ), 

219 int(context_window_size * 0.8), 

220 ) 

221 ollama_params["max_tokens"] = max_tokens 

222 

223 llm = ChatOllama(**ollama_params) 

224 

225 # Log the actual client configuration after creation 

226 logger.debug( 

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

228 ) 

229 

230 return llm 

231 

232 @classmethod 

233 def is_available(cls, settings_snapshot=None): 

234 """Check if Ollama is running. 

235 

236 Args: 

237 settings_snapshot: Optional settings snapshot to use 

238 

239 Returns: 

240 True if Ollama is available, False otherwise 

241 """ 

242 try: 

243 raw_base_url = get_setting_from_snapshot( 

244 "llm.ollama.url", 

245 None, 

246 settings_snapshot=settings_snapshot, 

247 ) 

248 if not raw_base_url: 

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

250 return False 

251 base_url = normalize_url(raw_base_url) 

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

253 

254 # Get authentication headers 

255 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

256 

257 try: 

258 response = safe_get( 

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

260 timeout=3.0, 

261 headers=headers, 

262 allow_localhost=True, 

263 allow_private_ips=True, 

264 ) 

265 if response.status_code == 200: 

266 logger.info( 

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

268 ) 

269 # Log first 100 chars of response to debug 

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

271 return True 

272 else: 

273 logger.warning( 

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

275 ) 

276 return False 

277 except requests.exceptions.RequestException as req_error: 

278 logger.warning( 

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

280 ) 

281 return False 

282 except Exception: 

283 logger.warning( 

284 "Unexpected error when checking Ollama", exc_info=True 

285 ) 

286 return False 

287 except Exception: 

288 logger.warning("Error in is_ollama_available", exc_info=True) 

289 return False 

290 

291 

292# Keep the standalone functions for backward compatibility and registration 

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

294 """Factory function for Ollama LLMs. 

295 

296 Args: 

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

298 temperature: Model temperature (0.0-1.0) 

299 **kwargs: Additional arguments including settings_snapshot 

300 

301 Returns: 

302 A configured ChatOllama instance 

303 

304 Raises: 

305 ValueError: If Ollama is not available 

306 """ 

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

308 

309 

310def is_ollama_available(settings_snapshot=None): 

311 """Check if Ollama is available. 

312 

313 Args: 

314 settings_snapshot: Optional settings snapshot to use 

315 

316 Returns: 

317 True if Ollama is running, False otherwise 

318 """ 

319 return OllamaProvider.is_available(settings_snapshot) 

320 

321 

322def register_ollama_provider(): 

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

324 register_llm("ollama", create_ollama_llm) 

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