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

105 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 ( 

63 OLLAMA_MODEL_LIST_TIMEOUT, 

64 fetch_ollama_models, 

65 ) 

66 

67 if not base_url: 

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

69 return [] 

70 

71 base_url = normalize_url(base_url) 

72 try: 

73 base_url = assert_base_url_safe( 

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

75 ) 

76 except ValueError: 

77 logger.warning( 

78 "Ollama base_url failed SSRF validation; " 

79 "check llm.ollama.url config" 

80 ) 

81 return [] 

82 

83 # Get authentication headers 

84 headers = cls._get_auth_headers(api_key=api_key) 

85 

86 # Fetch models using centralized function. See OLLAMA_MODEL_LIST_TIMEOUT 

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

88 models = fetch_ollama_models( 

89 base_url, timeout=OLLAMA_MODEL_LIST_TIMEOUT, auth_headers=headers 

90 ) 

91 

92 # Add provider info and format for LLM API 

93 for model in models: 

94 # Clean up the model name for display 

95 model_name = model["value"] 

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

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

98 model["provider"] = "OLLAMA" 

99 

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

101 return models 

102 

103 @classmethod 

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

105 """Factory function for Ollama LLMs. 

106 

107 Args: 

108 model_name: Name of the model to use 

109 temperature: Model temperature (0.0-1.0) 

110 **kwargs: Additional arguments including settings_snapshot 

111 

112 Returns: 

113 A configured ChatOllama instance 

114 

115 Raises: 

116 ValueError: If Ollama is not available 

117 """ 

118 settings_snapshot = kwargs.get("settings_snapshot") 

119 

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

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

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

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

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

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

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

127 raise ValueError( 

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

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

130 ) 

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 # SSRF guard — let ValueError propagate so the research-query 

144 # caller surfaces a clear config error instead of silently 

145 # routing inference traffic to a misconfigured target. 

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

147 

148 logger.info( 

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

150 ) 

151 

152 # Build Ollama parameters 

153 ollama_params = { 

154 "model": model_name, 

155 "base_url": base_url, 

156 "temperature": temperature, 

157 } 

158 

159 # Add authentication headers if configured 

160 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

161 if headers: 

162 # ChatOllama supports auth via headers parameter 

163 ollama_params["headers"] = headers 

164 

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

166 # always matches the context_limit reported for overflow detection 

167 # (wrap_llm_without_think_tags uses the same resolution). No 

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

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

170 from .._helpers import get_context_window_for_provider 

171 

172 context_window_size = get_context_window_for_provider( 

173 "ollama", settings_snapshot=settings_snapshot 

174 ) 

175 ollama_params["num_ctx"] = context_window_size 

176 

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

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

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

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

181 # previously only applied in the dead procedural code at 

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

183 # path honors the setting too. 

184 enable_thinking = get_setting_from_snapshot( 

185 "llm.ollama.enable_thinking", 

186 True, 

187 settings_snapshot=settings_snapshot, 

188 ) 

189 if isinstance(enable_thinking, bool): 

190 ollama_params["reasoning"] = enable_thinking 

191 

192 llm = ChatOllama(**ollama_params) 

193 

194 # Log the actual client configuration after creation 

195 logger.debug( 

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

197 ) 

198 

199 return llm 

200 

201 @classmethod 

202 def is_available(cls, settings_snapshot=None): 

203 """Check if Ollama is running. 

204 

205 Args: 

206 settings_snapshot: Optional settings snapshot to use 

207 

208 Returns: 

209 True if Ollama is available, False otherwise 

210 """ 

211 try: 

212 raw_base_url = get_setting_from_snapshot( 

213 "llm.ollama.url", 

214 None, 

215 settings_snapshot=settings_snapshot, 

216 ) 

217 if not raw_base_url: 

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

219 return False 

220 base_url = normalize_url(raw_base_url) 

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

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

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

224 try: 

225 base_url = assert_base_url_safe( 

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

227 ) 

228 except ValueError: 

229 logger.warning( 

230 "Ollama base_url failed SSRF validation; " 

231 "check llm.ollama.url config" 

232 ) 

233 return False 

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

235 

236 # Get authentication headers 

237 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot) 

238 

239 try: 

240 response = safe_get( 

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

242 timeout=3, 

243 headers=headers, 

244 allow_localhost=True, 

245 allow_private_ips=True, 

246 ) 

247 if response.status_code == 200: 

248 logger.info( 

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

250 ) 

251 # Log first 100 chars of response to debug 

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

253 return True 

254 logger.warning( 

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

256 ) 

257 return False 

258 except requests.exceptions.RequestException: 

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

260 return False 

261 except Exception: 

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

263 return False 

264 except Exception: 

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

266 return False 

267 

268 @classmethod 

269 def requires_auth_for_models(cls): 

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

271 return False