Coverage for src / local_deep_research / utilities / llm_utils.py: 92%
116 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
1# utilities/llm_utils.py
2"""
3LLM utilities for Local Deep Research.
5This module provides utility functions for working with language models
6when the user's llm_config.py is missing or incomplete.
7"""
9from loguru import logger
10from typing import Any, Optional, Dict
12from ..config.constants import DEFAULT_OLLAMA_URL
13from ..config.thread_settings import get_setting_from_snapshot
16def _close_base_llm(llm):
17 """Close per-instance HTTP clients on a raw LLM. Internal use only.
19 Only ChatOllama creates per-instance httpx.Client objects.
20 ChatAnthropic and ChatOpenAI use @lru_cache'd shared httpx clients
21 that must NOT be closed.
22 """
23 # If the llm is another wrapper with its own close(), delegate
24 if hasattr(type(llm), "close"):
25 llm.close()
26 return
27 # Otherwise introspect for Ollama's per-instance httpx client
28 ollama_client = getattr(llm, "_client", None)
29 if ollama_client is None:
30 return
31 if not type(ollama_client).__module__.startswith("ollama"):
32 return
33 httpx_client = getattr(ollama_client, "_client", None)
34 if httpx_client is not None and hasattr(httpx_client, "close"):
35 httpx_client.close()
38def get_ollama_base_url(
39 settings_snapshot: Optional[Dict[str, Any]] = None,
40) -> str:
41 """
42 Get Ollama base URL from settings with normalization.
44 Checks both embeddings.ollama.url and llm.ollama.url settings,
45 falling back to http://localhost:11434.
47 Args:
48 settings_snapshot: Optional settings snapshot
50 Returns:
51 Normalized Ollama base URL
52 """
53 from .url_utils import normalize_url
55 raw_base_url = get_setting_from_snapshot(
56 "embeddings.ollama.url",
57 default=get_setting_from_snapshot(
58 "llm.ollama.url", # Fall back to LLM setting
59 default=DEFAULT_OLLAMA_URL,
60 settings_snapshot=settings_snapshot,
61 ),
62 settings_snapshot=settings_snapshot,
63 )
64 return normalize_url(raw_base_url) if raw_base_url else DEFAULT_OLLAMA_URL
67def get_server_url(settings_snapshot: Optional[Dict[str, Any]] = None) -> str:
68 """
69 Get server URL from settings with fallback logic.
71 Checks multiple sources in order:
72 1. Direct server_url in settings snapshot
73 2. system.server_url in settings
74 3. Constructs from web.host, web.port, and web.use_https
75 4. Fallback to http://127.0.0.1:5000/
77 Args:
78 settings_snapshot: Optional settings snapshot
80 Returns:
81 Server URL with trailing slash
82 """
83 from loguru import logger
85 server_url = None
87 if settings_snapshot:
88 # Try to get server URL from research metadata first (where we added it)
89 server_url = settings_snapshot.get("server_url")
91 # If not found, try system settings
92 if not server_url:
93 system_settings = settings_snapshot.get("system", {})
94 server_url = system_settings.get("server_url")
96 # If not found, try web.host and web.port settings
97 if not server_url:
98 host = get_setting_from_snapshot(
99 "web.host", settings_snapshot, "127.0.0.1"
100 )
101 port = get_setting_from_snapshot(
102 "web.port", settings_snapshot, 5000
103 )
104 use_https = get_setting_from_snapshot(
105 "web.use_https", settings_snapshot, True
106 )
108 # Use localhost for 0.0.0.0 bindings as that's what users will use
109 if host == "0.0.0.0":
110 host = "127.0.0.1"
112 scheme = "https" if use_https else "http"
113 server_url = f"{scheme}://{host}:{port}/"
115 # Fallback to default if still not found
116 if not server_url:
117 server_url = "http://127.0.0.1:5000/"
118 logger.warning("Could not determine server URL, using default")
120 return server_url
123def fetch_ollama_models(
124 base_url: str,
125 timeout: float = 3.0,
126 auth_headers: Optional[Dict[str, str]] = None,
127) -> list[Dict[str, str]]:
128 """
129 Fetch available models from Ollama API.
131 Centralized function to avoid duplication between LLM and embedding providers.
133 Args:
134 base_url: Ollama base URL (should be normalized)
135 timeout: Request timeout in seconds
136 auth_headers: Optional authentication headers
138 Returns:
139 List of model dicts with 'value' (model name) and 'label' (display name) keys.
140 Returns empty list on error.
141 """
142 from loguru import logger
143 from ..security import safe_get
145 models = []
147 try:
148 response = safe_get(
149 f"{base_url}/api/tags",
150 timeout=timeout,
151 headers=auth_headers or {},
152 allow_localhost=True,
153 allow_private_ips=True,
154 )
156 if response.status_code == 200:
157 data = response.json()
159 # Handle both newer and older Ollama API formats
160 ollama_models = (
161 data.get("models", []) if isinstance(data, dict) else data
162 )
164 for model_data in ollama_models:
165 model_name = model_data.get("name", "")
166 if model_name:
167 models.append({"value": model_name, "label": model_name})
169 logger.info(f"Found {len(models)} Ollama models")
170 else:
171 logger.warning(
172 f"Failed to fetch Ollama models: HTTP {response.status_code}"
173 )
175 except Exception:
176 logger.exception("Error fetching Ollama models")
178 return models
181def get_model(
182 model_name: Optional[str] = None,
183 model_type: Optional[str] = None,
184 temperature: Optional[float] = None,
185 **kwargs,
186) -> Any:
187 """
188 Get a language model instance as fallback when llm_config.get_llm is not available.
190 Args:
191 model_name: Name of the model to use
192 model_type: Type of the model provider
193 temperature: Model temperature
194 **kwargs: Additional parameters
196 Returns:
197 LangChain language model instance
198 """
199 # Get default values from kwargs or use reasonable defaults
200 model_name = model_name or kwargs.get("DEFAULT_MODEL", "mistral")
201 model_type = model_type or kwargs.get("DEFAULT_MODEL_TYPE", "ollama")
202 temperature = temperature or kwargs.get("DEFAULT_TEMPERATURE", 0.7)
203 max_tokens = kwargs.get("max_tokens", kwargs.get("MAX_TOKENS", 30000))
205 # Common parameters
206 common_params = {
207 "temperature": temperature,
208 "max_tokens": max_tokens,
209 }
211 # Add additional kwargs
212 common_params.update(
213 {
214 key: value
215 for key, value in kwargs.items()
216 if key
217 not in [
218 "DEFAULT_MODEL",
219 "DEFAULT_MODEL_TYPE",
220 "DEFAULT_TEMPERATURE",
221 "MAX_TOKENS",
222 ]
223 }
224 )
226 # Try to load the model based on type
227 if model_type == "ollama":
228 try:
229 from langchain_ollama import ChatOllama
231 return ChatOllama(model=model_name, **common_params)
232 except ImportError:
233 try:
234 from langchain_community.llms import Ollama
236 return Ollama(model=model_name, **common_params)
237 except ImportError:
238 logger.exception(
239 "Neither langchain_ollama nor langchain_community.llms.Ollama available"
240 )
241 raise
243 elif model_type == "openai":
244 try:
245 from langchain_openai import ChatOpenAI
247 api_key = get_setting_from_snapshot("llm.openai.api_key")
248 if not api_key:
249 raise ValueError("OpenAI API key not found in settings")
250 return ChatOpenAI(
251 model=model_name, api_key=api_key, **common_params
252 )
253 except ImportError:
254 logger.exception("langchain_openai not available")
255 raise
257 elif model_type == "anthropic":
258 try:
259 from langchain_anthropic import ChatAnthropic
261 api_key = get_setting_from_snapshot("llm.anthropic.api_key")
262 if not api_key:
263 raise ValueError("Anthropic API key not found in settings")
264 return ChatAnthropic(
265 model=model_name, anthropic_api_key=api_key, **common_params
266 )
267 except ImportError:
268 logger.exception("langchain_anthropic not available")
269 raise
271 elif model_type == "openai_endpoint":
272 try:
273 from langchain_openai import ChatOpenAI
275 api_key = get_setting_from_snapshot("llm.openai_endpoint.api_key")
276 if not api_key:
277 raise ValueError(
278 "OpenAI endpoint API key not found in settings"
279 )
281 endpoint_url = kwargs.get(
282 "OPENAI_ENDPOINT_URL",
283 get_setting_from_snapshot(
284 "llm.openai_endpoint.url", "https://openrouter.ai/api/v1"
285 ),
286 )
288 if model_name is None and not kwargs.get(
289 "OPENAI_ENDPOINT_REQUIRES_MODEL", True
290 ):
291 return ChatOpenAI( # type: ignore[call-arg]
292 api_key=api_key,
293 openai_api_base=endpoint_url,
294 **common_params,
295 )
296 return ChatOpenAI( # type: ignore[call-arg]
297 model=model_name,
298 api_key=api_key,
299 openai_api_base=endpoint_url,
300 **common_params,
301 )
302 except ImportError:
303 logger.exception("langchain_openai not available")
304 raise
306 # Default fallback
307 try:
308 from langchain_ollama import ChatOllama
310 logger.warning(
311 f"Unknown model type '{model_type}', defaulting to Ollama"
312 )
313 return ChatOllama(model=model_name, **common_params)
314 except (ImportError, Exception):
315 logger.exception("Failed to load any model")
317 raise ValueError(
318 "No language models available. Please install Ollama or set up API keys."
319 )