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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""Ollama LLM provider for Local Deep Research."""
3import requests
4from langchain_ollama import ChatOllama
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
14class OllamaProvider(BaseLLMProvider):
15 """Ollama provider for Local Deep Research.
17 This is the Ollama local model provider.
18 """
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
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
35 @classmethod
36 def _get_auth_headers(cls, api_key=None, settings_snapshot=None):
37 """Get authentication headers for Ollama API requests.
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
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)
51 @classmethod
52 def list_models_for_api(cls, api_key=None, base_url=None):
53 """Get available models from Ollama.
55 Args:
56 api_key: Optional API key for authentication
57 base_url: Base URL for Ollama API (required)
59 Returns:
60 List of model dictionaries with 'value' and 'label' keys
61 """
62 from ....utilities.llm_utils import fetch_ollama_models
64 if not base_url:
65 logger.warning("Ollama URL not configured")
66 return []
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 []
80 # Get authentication headers
81 headers = cls._get_auth_headers(api_key=api_key)
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 )
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"
101 logger.info(f"Found {len(models)} Ollama models")
102 return models
104 @classmethod
105 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
106 """Factory function for Ollama LLMs.
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
113 Returns:
114 A configured ChatOllama instance
116 Raises:
117 ValueError: If Ollama is not available
118 """
119 settings_snapshot = kwargs.get("settings_snapshot")
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 )
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")
149 logger.info(
150 f"Creating ChatOllama with model={model_name}, base_url={base_url}"
151 )
153 # Build Ollama parameters
154 ollama_params = {
155 "model": model_name,
156 "base_url": base_url,
157 "temperature": temperature,
158 }
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
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
173 context_window_size = get_context_window_for_provider(
174 "ollama", settings_snapshot=settings_snapshot
175 )
176 ollama_params["num_ctx"] = context_window_size
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
193 llm = ChatOllama(**ollama_params)
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 )
200 return llm
202 @classmethod
203 def is_available(cls, settings_snapshot=None):
204 """Check if Ollama is running.
206 Args:
207 settings_snapshot: Optional settings snapshot to use
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")
237 # Get authentication headers
238 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
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
269 @classmethod
270 def requires_auth_for_models(cls):
271 """Ollama is local and does not need auth to list models."""
272 return False