Coverage for src/local_deep_research/llm/providers/implementations/ollama.py: 97%
94 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +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 get_setting_from_snapshot
8from ....utilities.url_utils import normalize_url
9from ....security import safe_get
10from ..base import BaseLLMProvider
13class OllamaProvider(BaseLLMProvider):
14 """Ollama provider for Local Deep Research.
16 This is the Ollama local model provider.
17 """
19 provider_name = "Ollama"
20 default_model = ""
21 api_key_setting = "llm.ollama.api_key" # Optional API key for authenticated Ollama instances
22 url_setting = "llm.ollama.url" # URL setting for model listing
24 # Metadata for auto-discovery
25 provider_key = "OLLAMA"
26 company_name = "Ollama"
27 is_cloud = False
29 @classmethod
30 def _get_auth_headers(cls, api_key=None, settings_snapshot=None):
31 """Get authentication headers for Ollama API requests.
33 Args:
34 api_key: Optional API key to use (takes precedence)
35 settings_snapshot: Optional settings snapshot to get API key from
37 Returns:
38 Dict of headers, empty if no API key configured
39 """
40 headers = {}
42 # Use provided API key or get from settings
43 if api_key is None and settings_snapshot is not None:
44 api_key = get_setting_from_snapshot(
45 cls.api_key_setting,
46 "", # Empty string instead of None to avoid NoSettingsContextError
47 settings_snapshot=settings_snapshot,
48 )
50 if api_key:
51 # Support Bearer token authentication for proxied Ollama instances
52 headers["Authorization"] = f"Bearer {api_key}"
54 return headers
56 @classmethod
57 def list_models_for_api(cls, api_key=None, base_url=None):
58 """Get available models from Ollama.
60 Args:
61 api_key: Optional API key for authentication
62 base_url: Base URL for Ollama API (required)
64 Returns:
65 List of model dictionaries with 'value' and 'label' keys
66 """
67 from ....utilities.llm_utils import fetch_ollama_models
69 if not base_url:
70 logger.warning("Ollama URL not configured")
71 return []
73 base_url = normalize_url(base_url)
75 # Get authentication headers
76 headers = cls._get_auth_headers(api_key=api_key)
78 # Fetch models using centralized function
79 models = fetch_ollama_models(
80 base_url, timeout=2.0, auth_headers=headers
81 )
83 # Add provider info and format for LLM API
84 for model in models:
85 # Clean up the model name for display
86 model_name = model["value"]
87 display_name = model_name.replace(":latest", "").replace(":", " ")
88 model["label"] = f"{display_name} (Ollama)"
89 model["provider"] = "OLLAMA"
91 logger.info(f"Found {len(models)} Ollama models")
92 return models
94 @classmethod
95 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
96 """Factory function for Ollama LLMs.
98 Args:
99 model_name: Name of the model to use
100 temperature: Model temperature (0.0-1.0)
101 **kwargs: Additional arguments including settings_snapshot
103 Returns:
104 A configured ChatOllama instance
106 Raises:
107 ValueError: If Ollama is not available
108 """
109 settings_snapshot = kwargs.get("settings_snapshot")
111 # Defense-in-depth: callers using the central get_llm() already get a
112 # clear ValueError when llm.model is unset. This second check covers
113 # direct callers of OllamaProvider.create_llm() (programmatic API,
114 # custom registrations) so they don't get a confusing langchain
115 # error about a model that wasn't actually requested.
116 if not model_name or not model_name.strip():
117 logger.error("Ollama model name not provided to create_llm()")
118 raise ValueError(
119 "Ollama model not configured. Please set llm.model in "
120 "settings (e.g. 'llama3.1:8b', 'qwen2.5:14b')."
121 )
123 # Use the configurable Ollama base URL
124 raw_base_url = get_setting_from_snapshot(
125 "llm.ollama.url",
126 None,
127 settings_snapshot=settings_snapshot,
128 )
129 if not raw_base_url:
130 raise ValueError(
131 "Ollama URL not configured. Please set llm.ollama.url in settings."
132 )
133 base_url = normalize_url(raw_base_url)
135 logger.info(
136 f"Creating ChatOllama with model={model_name}, base_url={base_url}"
137 )
139 # Build Ollama parameters
140 ollama_params = {
141 "model": model_name,
142 "base_url": base_url,
143 "temperature": temperature,
144 }
146 # Add authentication headers if configured
147 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
148 if headers: 148 ↛ 150line 148 didn't jump to line 150 because the condition on line 148 was never true
149 # ChatOllama supports auth via headers parameter
150 ollama_params["headers"] = headers
152 # Get context window size from settings for local providers
153 context_window_size = get_setting_from_snapshot(
154 "llm.local_context_window_size",
155 4096,
156 settings_snapshot=settings_snapshot,
157 )
158 if context_window_size is not None:
159 ollama_params["num_ctx"] = int(context_window_size)
161 # Add max_tokens if specified in settings and supported
162 if get_setting_from_snapshot( 162 ↛ 179line 162 didn't jump to line 179 because the condition on line 162 was always true
163 "llm.supports_max_tokens", True, settings_snapshot=settings_snapshot
164 ):
165 # Use 80% of context window to leave room for prompts
166 if context_window_size is not None:
167 max_tokens = min(
168 int(
169 get_setting_from_snapshot(
170 "llm.max_tokens",
171 100000,
172 settings_snapshot=settings_snapshot,
173 )
174 ),
175 int(context_window_size * 0.8),
176 )
177 ollama_params["max_tokens"] = max_tokens
179 llm = ChatOllama(**ollama_params)
181 # Log the actual client configuration after creation
182 logger.debug(
183 f"ChatOllama created - base_url attribute: {getattr(llm, 'base_url', 'not found')}"
184 )
186 return llm
188 @classmethod
189 def is_available(cls, settings_snapshot=None):
190 """Check if Ollama is running.
192 Args:
193 settings_snapshot: Optional settings snapshot to use
195 Returns:
196 True if Ollama is available, False otherwise
197 """
198 try:
199 raw_base_url = get_setting_from_snapshot(
200 "llm.ollama.url",
201 None,
202 settings_snapshot=settings_snapshot,
203 )
204 if not raw_base_url:
205 logger.debug("Ollama URL not configured")
206 return False
207 base_url = normalize_url(raw_base_url)
208 logger.info(f"Checking Ollama availability at {base_url}/api/tags")
210 # Get authentication headers
211 headers = cls._get_auth_headers(settings_snapshot=settings_snapshot)
213 try:
214 response = safe_get(
215 f"{base_url}/api/tags",
216 timeout=3,
217 headers=headers,
218 allow_localhost=True,
219 allow_private_ips=True,
220 )
221 if response.status_code == 200:
222 logger.info(
223 f"Ollama is available. Status code: {response.status_code}"
224 )
225 # Log first 100 chars of response to debug
226 logger.info(f"Response preview: {str(response.text)[:100]}")
227 return True
228 logger.warning(
229 f"Ollama API returned status code: {response.status_code}"
230 )
231 return False
232 except requests.exceptions.RequestException:
233 logger.warning("Request error when checking Ollama")
234 return False
235 except Exception:
236 logger.warning("Unexpected error when checking Ollama")
237 return False
238 except Exception:
239 logger.warning("Error in OllamaProvider.is_available")
240 return False
242 @classmethod
243 def requires_auth_for_models(cls):
244 """Ollama is local and does not need auth to list models."""
245 return False