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

115 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

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

2 

3import requests 

4from httpx import Timeout 

5from langchain_ollama import ChatOllama 

6 

7from ....config.thread_settings import get_setting_from_snapshot 

8from ....security import safe_get 

9from ....security.secure_logging import logger 

10from ....security.log_sanitizer import scrub_error 

11from ....security.ssrf_validator import assert_base_url_safe 

12from ....security.url_redaction import redact_url_credentials 

13from ....settings.exceptions import SettingsValueError 

14from ....utilities.url_utils import normalize_url 

15from ..base import BaseLLMProvider, Exposure 

16 

17 

18class OllamaProvider(BaseLLMProvider): 

19 """Ollama provider for Local Deep Research. 

20 

21 This is the Ollama local model provider. 

22 """ 

23 

24 provider_name = "Ollama" 

25 default_model = "" 

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

27 api_key_optional = ( 

28 True # Bare Ollama needs no auth; key only matters behind a proxy 

29 ) 

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

31 

32 # Metadata for auto-discovery 

33 provider_key = "OLLAMA" 

34 company_name = "Ollama" 

35 is_cloud = False 

36 # Egress exposure (ADR-0007): local inference sink — data stays on the box. 

37 egress_exposure = Exposure.CONTAINED 

38 

39 @classmethod 

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

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

42 

43 Args: 

44 api_key: Optional API key to use (takes precedence over settings) 

45 settings_snapshot: Optional settings snapshot to get API key from 

46 

47 Returns: 

48 Dict of headers, empty if no API key configured 

49 """ 

50 if api_key is not None: 

51 key = str(api_key).strip() 

52 return {"Authorization": f"Bearer {key}"} if key else {} 

53 return cls.build_bearer_header(settings_snapshot=settings_snapshot) 

54 

55 @classmethod 

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

57 """Get available models from Ollama. 

58 

59 Args: 

60 api_key: Optional API key for authentication 

61 base_url: Base URL for Ollama API (required) 

62 

63 Returns: 

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

65 """ 

66 from ....utilities.llm_utils import ( 

67 OLLAMA_MODEL_LIST_TIMEOUT, 

68 fetch_ollama_models, 

69 ) 

70 

71 if not base_url: 

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

73 return [] 

74 

75 base_url = normalize_url(base_url) 

76 try: 

77 base_url = assert_base_url_safe( 

78 base_url, setting_key="llm.ollama.url" 

79 ) 

80 except ValueError as e: 

81 # The message carries no URL, only the setting key, and 

82 # distinguishes "could not resolve host" from a blocked host. 

83 safe_msg = scrub_error(e) 

84 logger.warning("Ollama {}", safe_msg) 

85 return [] 

86 

87 # Get authentication headers 

88 headers = cls._get_auth_headers(api_key=api_key) 

89 

90 # Fetch models using centralized function. See OLLAMA_MODEL_LIST_TIMEOUT 

91 # for why the timeout is generous rather than a couple seconds. 

92 models = fetch_ollama_models( 

93 base_url, timeout=OLLAMA_MODEL_LIST_TIMEOUT, auth_headers=headers 

94 ) 

95 

96 # Add provider info and format for LLM API 

97 for model in models: 

98 # Clean up the model name for display 

99 model_name = model["value"] 

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

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

102 model["provider"] = "OLLAMA" 

103 

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

105 return models 

106 

107 @classmethod 

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

109 """Factory function for Ollama LLMs. 

110 

111 Args: 

112 model_name: Name of the model to use 

113 temperature: Model temperature (0.0-1.0) 

114 **kwargs: Additional arguments including settings_snapshot 

115 

116 Returns: 

117 A configured ChatOllama instance 

118 

119 Raises: 

120 ValueError: If Ollama is not available 

121 """ 

122 settings_snapshot = kwargs.get("settings_snapshot") 

123 

124 # Defense-in-depth: callers using the central get_llm() already get a 

125 # clear ValueError when llm.model is unset. This second check covers 

126 # direct callers of OllamaProvider.create_llm() (programmatic API, 

127 # custom registrations) so they don't get a confusing langchain 

128 # error about a model that wasn't actually requested. 

129 if not model_name or not model_name.strip(): 

130 logger.error("Ollama model name not provided to create_llm()") 

131 raise ValueError( 

132 "Ollama model not configured. Please set llm.model in " 

133 "settings (e.g. 'llama3.1:8b', 'qwen2.5:14b')." 

134 ) 

135 

136 # Use the configurable Ollama base URL 

137 raw_base_url = get_setting_from_snapshot( 

138 "llm.ollama.url", 

139 None, 

140 settings_snapshot=settings_snapshot, 

141 ) 

142 if not raw_base_url: 

143 raise ValueError( 

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

145 ) 

146 base_url = normalize_url(raw_base_url) 

147 # SSRF guard — let ValueError propagate so the research-query 

148 # caller surfaces a clear config error instead of silently 

149 # routing inference traffic to a misconfigured target. 

150 base_url = assert_base_url_safe(base_url, setting_key="llm.ollama.url") 

151 

152 logger.info( 

153 f"Creating ChatOllama with model={model_name}, " 

154 f"base_url={redact_url_credentials(base_url)}" 

155 ) 

156 

157 # Build Ollama parameters 

158 ollama_params = { 

159 "model": model_name, 

160 "base_url": base_url, 

161 "temperature": temperature, 

162 } 

163 

164 # Resolve the context window through the shared helper so num_ctx 

165 # always matches the context_limit reported for overflow detection 

166 # (wrap_llm_without_think_tags uses the same resolution). No 

167 # max_tokens kwarg: ChatOllama ignores it (its output-length control 

168 # is num_predict), so passing it would only feign a cap. 

169 from .._helpers import ( 

170 build_httpx_timeout, 

171 get_context_window_for_provider, 

172 resolve_request_timeout, 

173 ) 

174 

175 context_window_size = get_context_window_for_provider( 

176 "ollama", settings_snapshot=settings_snapshot 

177 ) 

178 ollama_params["num_ctx"] = context_window_size 

179 

180 # Thinking/reasoning support for models like deepseek-r1 / qwen2.5. 

181 # When True, the model performs reasoning and the reasoning content 

182 # is separated into additional_kwargs (and discarded by LDR). When 

183 # False, the model gives direct answers without thinking. This was 

184 # previously only applied in the dead procedural code at 

185 # llm_config.get_llm("ollama"); reproducing it here so the live 

186 # path honors the setting too. 

187 enable_thinking = get_setting_from_snapshot( 

188 "llm.ollama.enable_thinking", 

189 True, 

190 settings_snapshot=settings_snapshot, 

191 ) 

192 if isinstance(enable_thinking, bool): 

193 ollama_params["reasoning"] = enable_thinking 

194 

195 # ChatOllama forwards client_kwargs to both the sync and the async 

196 # ``ollama.Client``, which pass them straight to ``httpx.Client``. 

197 # Auth headers go here too: ChatOllama has no ``headers`` field, so 

198 # a top-level ``headers=`` kwarg was silently dropped and the 

199 # Authorization header never reached the wire. 

200 # follow_redirects=False: ollama.Client defaults it to True, so an 

201 # allowed host could otherwise bounce every chat request (and the 

202 # bearer header) to a metadata endpoint that assert_base_url_safe() 

203 # refuses when named directly. 

204 client_kwargs = { 

205 "timeout": build_httpx_timeout( 

206 resolve_request_timeout(settings_snapshot), Timeout 

207 ), 

208 "follow_redirects": False, 

209 } 

210 auth_headers = cls._get_auth_headers( 

211 settings_snapshot=settings_snapshot 

212 ) 

213 if auth_headers: 

214 client_kwargs["headers"] = auth_headers 

215 ollama_params["client_kwargs"] = client_kwargs 

216 

217 llm = ChatOllama(**ollama_params) 

218 

219 # Log the actual client configuration after creation 

220 logger.debug( 

221 "ChatOllama created - base_url attribute: " 

222 f"{redact_url_credentials(getattr(llm, 'base_url', None))}" 

223 ) 

224 

225 return llm 

226 

227 @classmethod 

228 def is_available(cls, settings_snapshot=None): 

229 """Check if Ollama is running. 

230 

231 Args: 

232 settings_snapshot: Optional settings snapshot to use 

233 

234 Returns: 

235 True if Ollama is available, False otherwise 

236 """ 

237 try: 

238 raw_base_url = get_setting_from_snapshot( 

239 "llm.ollama.url", 

240 None, 

241 settings_snapshot=settings_snapshot, 

242 ) 

243 if not raw_base_url: 

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

245 return False 

246 base_url = normalize_url(raw_base_url) 

247 # Graceful UI degradation: if base_url is unsafe, return 

248 # False instead of raising. Surface it as "Ollama unavailable" 

249 # in the model-list UI rather than crashing the request. 

250 try: 

251 base_url = assert_base_url_safe( 

252 base_url, setting_key="llm.ollama.url" 

253 ) 

254 except ValueError as e: 

255 safe_msg = scrub_error(e) 

256 logger.warning("Ollama {}", safe_msg) 

257 return False 

258 logger.info( 

259 "Checking Ollama availability at " 

260 f"{redact_url_credentials(base_url)}/api/tags" 

261 ) 

262 

263 # Get authentication headers 

264 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

265 

266 try: 

267 response = safe_get( 

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

269 timeout=3, 

270 headers=headers, 

271 allow_localhost=True, 

272 allow_private_ips=True, 

273 ) 

274 if response.status_code == 200: 

275 logger.info( 

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

277 ) 

278 # Log first 100 chars of response to debug 

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

280 return True 

281 logger.warning( 

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

283 ) 

284 return False 

285 except requests.exceptions.RequestException: 

286 logger.warning("Request error when checking Ollama") 

287 return False 

288 except Exception: 

289 logger.warning("Unexpected error when checking Ollama") 

290 return False 

291 except SettingsValueError: 

292 raise 

293 except Exception: 

294 logger.warning("Error in OllamaProvider.is_available") 

295 return False 

296 

297 @classmethod 

298 def requires_auth_for_models(cls): 

299 """Ollama is local and does not need auth to list models.""" 

300 return False