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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Ollama LLM provider for Local Deep Research."""
3import requests
4from httpx import Timeout
5from langchain_ollama import ChatOllama
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
18class OllamaProvider(BaseLLMProvider):
19 """Ollama provider for Local Deep Research.
21 This is the Ollama local model provider.
22 """
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
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
39 @classmethod
40 def _get_auth_headers(cls, api_key=None, settings_snapshot=None):
41 """Get authentication headers for Ollama API requests.
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
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)
55 @classmethod
56 def list_models_for_api(cls, api_key=None, base_url=None):
57 """Get available models from Ollama.
59 Args:
60 api_key: Optional API key for authentication
61 base_url: Base URL for Ollama API (required)
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 )
71 if not base_url:
72 logger.warning("Ollama URL not configured")
73 return []
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 []
87 # Get authentication headers
88 headers = cls._get_auth_headers(api_key=api_key)
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 )
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"
104 logger.info(f"Found {len(models)} Ollama models")
105 return models
107 @classmethod
108 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
109 """Factory function for Ollama LLMs.
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
116 Returns:
117 A configured ChatOllama instance
119 Raises:
120 ValueError: If Ollama is not available
121 """
122 settings_snapshot = kwargs.get("settings_snapshot")
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 )
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")
152 logger.info(
153 f"Creating ChatOllama with model={model_name}, "
154 f"base_url={redact_url_credentials(base_url)}"
155 )
157 # Build Ollama parameters
158 ollama_params = {
159 "model": model_name,
160 "base_url": base_url,
161 "temperature": temperature,
162 }
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 )
175 context_window_size = get_context_window_for_provider(
176 "ollama", settings_snapshot=settings_snapshot
177 )
178 ollama_params["num_ctx"] = context_window_size
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
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
217 llm = ChatOllama(**ollama_params)
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 )
225 return llm
227 @classmethod
228 def is_available(cls, settings_snapshot=None):
229 """Check if Ollama is running.
231 Args:
232 settings_snapshot: Optional settings snapshot to use
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 )
263 # Get authentication headers
264 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
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
297 @classmethod
298 def requires_auth_for_models(cls):
299 """Ollama is local and does not need auth to list models."""
300 return False