Coverage for src / local_deep_research / llm / providers / implementations / ollama.py: 88%
122 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
1"""Ollama LLM provider for Local Deep Research."""
3import requests
4from langchain_ollama import ChatOllama
5from loguru import logger
7from ....config.thread_settings import (
8 get_setting_from_snapshot as _get_setting_from_snapshot,
9)
10from ....utilities.url_utils import normalize_url
11from ...llm_registry import register_llm
12from ....security import safe_get
15def get_setting_from_snapshot(
16 key, default=None, username=None, settings_snapshot=None
17):
18 """Get setting from context only - no database access from threads.
20 This is a wrapper around the shared function that enables fallback LLM check.
21 """
22 return _get_setting_from_snapshot(
23 key, default, username, settings_snapshot, check_fallback_llm=True
24 )
27class OllamaProvider:
28 """Ollama provider for Local Deep Research.
30 This is the Ollama local model provider.
31 """
33 provider_name = "Ollama"
34 default_model = "gemma:latest"
35 api_key_setting = "llm.ollama.api_key" # Optional API key for authenticated Ollama instances
36 url_setting = "llm.ollama.url" # URL setting for model listing
38 # Metadata for auto-discovery
39 provider_key = "OLLAMA"
40 company_name = "Ollama"
41 region = "Local"
42 country = "Local"
43 data_location = "Local"
44 is_cloud = False
46 @classmethod
47 def _get_auth_headers(cls, api_key=None, settings_snapshot=None):
48 """Get authentication headers for Ollama API requests.
50 Args:
51 api_key: Optional API key to use (takes precedence)
52 settings_snapshot: Optional settings snapshot to get API key from
54 Returns:
55 Dict of headers, empty if no API key configured
56 """
57 headers = {}
59 # Use provided API key or get from settings
60 if api_key is None and settings_snapshot is not None:
61 api_key = get_setting_from_snapshot(
62 cls.api_key_setting,
63 "", # Empty string instead of None to avoid NoSettingsContextError
64 settings_snapshot=settings_snapshot,
65 )
67 if api_key:
68 # Support Bearer token authentication for proxied Ollama instances
69 headers["Authorization"] = f"Bearer {api_key}"
71 return headers
73 @classmethod
74 def list_models_for_api(cls, api_key=None, base_url=None):
75 """Get available models from Ollama.
77 Args:
78 api_key: Optional API key for authentication
79 base_url: Base URL for Ollama API (required)
81 Returns:
82 List of model dictionaries with 'value' and 'label' keys
83 """
84 from ....utilities.llm_utils import fetch_ollama_models
86 if not base_url:
87 logger.warning("Ollama URL not configured")
88 return []
90 base_url = normalize_url(base_url)
92 # Get authentication headers
93 headers = cls._get_auth_headers(api_key=api_key)
95 # Fetch models using centralized function
96 models = fetch_ollama_models(
97 base_url, timeout=2.0, auth_headers=headers
98 )
100 # Add provider info and format for LLM API
101 for model in models:
102 # Clean up the model name for display
103 model_name = model["value"]
104 display_name = model_name.replace(":latest", "").replace(":", " ")
105 model["label"] = f"{display_name} (Ollama)"
106 model["provider"] = "OLLAMA"
108 logger.info(f"Found {len(models)} Ollama models")
109 return models
111 @classmethod
112 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
113 """Factory function for Ollama LLMs.
115 Args:
116 model_name: Name of the model to use
117 temperature: Model temperature (0.0-1.0)
118 **kwargs: Additional arguments including settings_snapshot
120 Returns:
121 A configured ChatOllama instance
123 Raises:
124 ValueError: If Ollama is not available
125 """
126 settings_snapshot = kwargs.get("settings_snapshot")
128 # Use default model if none specified
129 if not model_name:
130 model_name = cls.default_model
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)
144 # Check if Ollama is available before trying to use it
145 if not cls.is_available(settings_snapshot):
146 logger.error(f"Ollama not available at {base_url}.")
147 raise ValueError(f"Ollama not available at {base_url}")
149 # Check if the requested model exists
150 try:
151 logger.info(f"Checking if model '{model_name}' exists in Ollama")
153 # Get authentication headers
154 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
156 response = safe_get(
157 f"{base_url}/api/tags",
158 timeout=3.0,
159 headers=headers,
160 allow_localhost=True,
161 allow_private_ips=True,
162 )
163 if response.status_code == 200: 163 ↛ 193line 163 didn't jump to line 193 because the condition on line 163 was always true
164 # Handle both newer and older Ollama API formats
165 data = response.json()
166 models = []
167 if "models" in data: 167 ↛ 172line 167 didn't jump to line 172 because the condition on line 167 was always true
168 # Newer Ollama API
169 models = data.get("models", [])
170 else:
171 # Older Ollama API format
172 models = data
174 # Get list of model names
175 model_names = [m.get("name", "").lower() for m in models]
176 logger.info(
177 f"Available Ollama models: {', '.join(model_names[:5])}{' and more' if len(model_names) > 5 else ''}"
178 )
180 if model_name.lower() not in model_names: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 logger.error(
182 f"Model '{model_name}' not found in Ollama. Available models: {', '.join(model_names[:5])}"
183 )
184 raise ValueError(
185 f"Model '{model_name}' not found in Ollama"
186 )
187 except Exception:
188 logger.exception(
189 f"Error checking for model '{model_name}' in Ollama"
190 )
191 # Continue anyway, let ChatOllama handle potential errors
193 logger.info(
194 f"Creating ChatOllama with model={model_name}, base_url={base_url}"
195 )
197 # Build Ollama parameters
198 ollama_params = {
199 "model": model_name,
200 "base_url": base_url,
201 "temperature": temperature,
202 }
204 # Add authentication headers if configured
205 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
206 if headers: 206 ↛ 208line 206 didn't jump to line 208 because the condition on line 206 was never true
207 # ChatOllama supports auth via headers parameter
208 ollama_params["headers"] = headers
210 # Get context window size from settings for local providers
211 context_window_size = get_setting_from_snapshot(
212 "llm.local_context_window_size",
213 4096,
214 settings_snapshot=settings_snapshot,
215 )
216 if context_window_size is not None: 216 ↛ 220line 216 didn't jump to line 220 because the condition on line 216 was always true
217 ollama_params["num_ctx"] = int(context_window_size)
219 # Add max_tokens if specified in settings and supported
220 if get_setting_from_snapshot( 220 ↛ 237line 220 didn't jump to line 237 because the condition on line 220 was always true
221 "llm.supports_max_tokens", True, settings_snapshot=settings_snapshot
222 ):
223 # Use 80% of context window to leave room for prompts
224 if context_window_size is not None: 224 ↛ 237line 224 didn't jump to line 237 because the condition on line 224 was always true
225 max_tokens = min(
226 int(
227 get_setting_from_snapshot(
228 "llm.max_tokens",
229 100000,
230 settings_snapshot=settings_snapshot,
231 )
232 ),
233 int(context_window_size * 0.8),
234 )
235 ollama_params["max_tokens"] = max_tokens
237 llm = ChatOllama(**ollama_params)
239 # Log the actual client configuration after creation
240 logger.debug(
241 f"ChatOllama created - base_url attribute: {getattr(llm, 'base_url', 'not found')}"
242 )
244 return llm
246 @classmethod
247 def is_available(cls, settings_snapshot=None):
248 """Check if Ollama is running.
250 Args:
251 settings_snapshot: Optional settings snapshot to use
253 Returns:
254 True if Ollama is available, False otherwise
255 """
256 try:
257 raw_base_url = get_setting_from_snapshot(
258 "llm.ollama.url",
259 None,
260 settings_snapshot=settings_snapshot,
261 )
262 if not raw_base_url:
263 logger.debug("Ollama URL not configured")
264 return False
265 base_url = normalize_url(raw_base_url)
266 logger.info(f"Checking Ollama availability at {base_url}/api/tags")
268 # Get authentication headers
269 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
271 try:
272 response = safe_get(
273 f"{base_url}/api/tags",
274 timeout=3.0,
275 headers=headers,
276 allow_localhost=True,
277 allow_private_ips=True,
278 )
279 if response.status_code == 200:
280 logger.info(
281 f"Ollama is available. Status code: {response.status_code}"
282 )
283 # Log first 100 chars of response to debug
284 logger.info(f"Response preview: {str(response.text)[:100]}")
285 return True
286 else:
287 logger.warning(
288 f"Ollama API returned status code: {response.status_code}"
289 )
290 return False
291 except requests.exceptions.RequestException as req_error:
292 logger.exception(
293 f"Request error when checking Ollama: {req_error!s}"
294 )
295 return False
296 except Exception:
297 logger.exception("Unexpected error when checking Ollama")
298 return False
299 except Exception:
300 logger.exception("Error in is_ollama_available")
301 return False
304# Keep the standalone functions for backward compatibility and registration
305def create_ollama_llm(model_name=None, temperature=0.7, **kwargs):
306 """Factory function for Ollama LLMs.
308 Args:
309 model_name: Name of the model to use (e.g., "llama2", "gemma", etc.)
310 temperature: Model temperature (0.0-1.0)
311 **kwargs: Additional arguments including settings_snapshot
313 Returns:
314 A configured ChatOllama instance
316 Raises:
317 ValueError: If Ollama is not available
318 """
319 return OllamaProvider.create_llm(model_name, temperature, **kwargs)
322def is_ollama_available(settings_snapshot=None):
323 """Check if Ollama is available.
325 Args:
326 settings_snapshot: Optional settings snapshot to use
328 Returns:
329 True if Ollama is running, False otherwise
330 """
331 return OllamaProvider.is_available(settings_snapshot)
334def register_ollama_provider():
335 """Register the Ollama provider with the LLM registry."""
336 register_llm("ollama", create_ollama_llm)
337 logger.info("Registered Ollama LLM provider")