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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +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 (
63 OLLAMA_MODEL_LIST_TIMEOUT,
64 fetch_ollama_models,
65 )
67 if not base_url:
68 logger.warning("Ollama URL not configured")
69 return []
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 []
83 # Get authentication headers
84 headers = cls._get_auth_headers(api_key=api_key)
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 )
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"
100 logger.info(f"Found {len(models)} Ollama models")
101 return models
103 @classmethod
104 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
105 """Factory function for Ollama LLMs.
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
112 Returns:
113 A configured ChatOllama instance
115 Raises:
116 ValueError: If Ollama is not available
117 """
118 settings_snapshot = kwargs.get("settings_snapshot")
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 )
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")
148 logger.info(
149 f"Creating ChatOllama with model={model_name}, base_url={base_url}"
150 )
152 # Build Ollama parameters
153 ollama_params = {
154 "model": model_name,
155 "base_url": base_url,
156 "temperature": temperature,
157 }
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
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
172 context_window_size = get_context_window_for_provider(
173 "ollama", settings_snapshot=settings_snapshot
174 )
175 ollama_params["num_ctx"] = context_window_size
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
192 llm = ChatOllama(**ollama_params)
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 )
199 return llm
201 @classmethod
202 def is_available(cls, settings_snapshot=None):
203 """Check if Ollama is running.
205 Args:
206 settings_snapshot: Optional settings snapshot to use
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")
236 # Get authentication headers
237 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
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
268 @classmethod
269 def requires_auth_for_models(cls):
270 """Ollama is local and does not need auth to list models."""
271 return False