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

105 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

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

2 

3import requests 

4from langchain_ollama import ChatOllama 

5 

6from ....config.thread_settings import get_setting_from_snapshot 

7from ....security import safe_get 

8from ....security.secure_logging import logger 

9from ....security.ssrf_validator import assert_base_url_safe 

10from ....utilities.url_utils import normalize_url 

11from ..base import BaseLLMProvider, Exposure 

12 

13 

14class OllamaProvider(BaseLLMProvider): 

15 """Ollama provider for Local Deep Research. 

16 

17 This is the Ollama local model provider. 

18 """ 

19 

20 provider_name = "Ollama" 

21 default_model = "" 

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

23 api_key_optional = ( 

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

25 ) 

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

27 

28 # Metadata for auto-discovery 

29 provider_key = "OLLAMA" 

30 company_name = "Ollama" 

31 is_cloud = False 

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

33 egress_exposure = Exposure.CONTAINED 

34 

35 @classmethod 

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

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

38 

39 Args: 

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

41 settings_snapshot: Optional settings snapshot to get API key from 

42 

43 Returns: 

44 Dict of headers, empty if no API key configured 

45 """ 

46 if api_key is not None: 

47 key = str(api_key).strip() 

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

49 return cls.build_bearer_header(settings_snapshot=settings_snapshot) 

50 

51 @classmethod 

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

53 """Get available models from Ollama. 

54 

55 Args: 

56 api_key: Optional API key for authentication 

57 base_url: Base URL for Ollama API (required) 

58 

59 Returns: 

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

61 """ 

62 from ....utilities.llm_utils import fetch_ollama_models 

63 

64 if not base_url: 

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

66 return [] 

67 

68 base_url = normalize_url(base_url) 

69 try: 

70 base_url = assert_base_url_safe( 

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

72 ) 

73 except ValueError: 

74 logger.warning( 

75 "Ollama base_url failed SSRF validation; " 

76 "check llm.ollama.url config" 

77 ) 

78 return [] 

79 

80 # Get authentication headers 

81 headers = cls._get_auth_headers(api_key=api_key) 

82 

83 # Fetch models using centralized function. 

84 # 5s (not 2s) because a cold Ollama — process just started, connection 

85 # not yet warm, host busy right after a restart — regularly needs more 

86 # than 2 seconds to answer /api/tags. A timeout here is silently turned 

87 # into an empty model list, which surfaces to the user as an empty model 

88 # dropdown, so err on the side of waiting a little longer. 

89 models = fetch_ollama_models( 

90 base_url, timeout=5.0, auth_headers=headers 

91 ) 

92 

93 # Add provider info and format for LLM API 

94 for model in models: 

95 # Clean up the model name for display 

96 model_name = model["value"] 

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

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

99 model["provider"] = "OLLAMA" 

100 

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

102 return models 

103 

104 @classmethod 

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

106 """Factory function for Ollama LLMs. 

107 

108 Args: 

109 model_name: Name of the model to use 

110 temperature: Model temperature (0.0-1.0) 

111 **kwargs: Additional arguments including settings_snapshot 

112 

113 Returns: 

114 A configured ChatOllama instance 

115 

116 Raises: 

117 ValueError: If Ollama is not available 

118 """ 

119 settings_snapshot = kwargs.get("settings_snapshot") 

120 

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

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

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

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

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

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

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

128 raise ValueError( 

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

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

131 ) 

132 

133 # Use the configurable Ollama base URL 

134 raw_base_url = get_setting_from_snapshot( 

135 "llm.ollama.url", 

136 None, 

137 settings_snapshot=settings_snapshot, 

138 ) 

139 if not raw_base_url: 

140 raise ValueError( 

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

142 ) 

143 base_url = normalize_url(raw_base_url) 

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

145 # caller surfaces a clear config error instead of silently 

146 # routing inference traffic to a misconfigured target. 

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

148 

149 logger.info( 

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

151 ) 

152 

153 # Build Ollama parameters 

154 ollama_params = { 

155 "model": model_name, 

156 "base_url": base_url, 

157 "temperature": temperature, 

158 } 

159 

160 # Add authentication headers if configured 

161 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

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

163 # ChatOllama supports auth via headers parameter 

164 ollama_params["headers"] = headers 

165 

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

167 # always matches the context_limit reported for overflow detection 

168 # (wrap_llm_without_think_tags uses the same resolution). No 

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

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

171 from .._helpers import get_context_window_for_provider 

172 

173 context_window_size = get_context_window_for_provider( 

174 "ollama", settings_snapshot=settings_snapshot 

175 ) 

176 ollama_params["num_ctx"] = context_window_size 

177 

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

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

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

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

182 # previously only applied in the dead procedural code at 

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

184 # path honors the setting too. 

185 enable_thinking = get_setting_from_snapshot( 

186 "llm.ollama.enable_thinking", 

187 True, 

188 settings_snapshot=settings_snapshot, 

189 ) 

190 if isinstance(enable_thinking, bool): 

191 ollama_params["reasoning"] = enable_thinking 

192 

193 llm = ChatOllama(**ollama_params) 

194 

195 # Log the actual client configuration after creation 

196 logger.debug( 

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

198 ) 

199 

200 return llm 

201 

202 @classmethod 

203 def is_available(cls, settings_snapshot=None): 

204 """Check if Ollama is running. 

205 

206 Args: 

207 settings_snapshot: Optional settings snapshot to use 

208 

209 Returns: 

210 True if Ollama is available, False otherwise 

211 """ 

212 try: 

213 raw_base_url = get_setting_from_snapshot( 

214 "llm.ollama.url", 

215 None, 

216 settings_snapshot=settings_snapshot, 

217 ) 

218 if not raw_base_url: 

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

220 return False 

221 base_url = normalize_url(raw_base_url) 

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

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

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

225 try: 

226 base_url = assert_base_url_safe( 

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

228 ) 

229 except ValueError: 

230 logger.warning( 

231 "Ollama base_url failed SSRF validation; " 

232 "check llm.ollama.url config" 

233 ) 

234 return False 

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

236 

237 # Get authentication headers 

238 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

239 

240 try: 

241 response = safe_get( 

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

243 timeout=3, 

244 headers=headers, 

245 allow_localhost=True, 

246 allow_private_ips=True, 

247 ) 

248 if response.status_code == 200: 

249 logger.info( 

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

251 ) 

252 # Log first 100 chars of response to debug 

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

254 return True 

255 logger.warning( 

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

257 ) 

258 return False 

259 except requests.exceptions.RequestException: 

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

261 return False 

262 except Exception: 

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

264 return False 

265 except Exception: 

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

267 return False 

268 

269 @classmethod 

270 def requires_auth_for_models(cls): 

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

272 return False