Coverage for src/local_deep_research/llm/providers/implementations/anthropic.py: 97%
75 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"""Anthropic LLM provider for Local Deep Research."""
3from langchain_anthropic import ChatAnthropic
4from ....security.secure_logging import logger
6# get_setting_from_snapshot and NoSettingsContextError are imported inside
7# the methods that use them so test patches at the source module
8# (`local_deep_research.config.thread_settings`) are picked up by the
9# function-local imports at call time.
10from ..base import OPTIONAL_API_KEY_PLACEHOLDER, Exposure
11from ..openai_base import OpenAICompatibleProvider
12from ....security.log_sanitizer import scrub_error
13from ....security.ssrf_validator import assert_base_url_safe
14from .._helpers import (
15 MODEL_DISCOVERY_TIMEOUT_SECONDS,
16 build_httpx_timeout,
17 resolve_max_retries,
18 resolve_request_timeout,
19)
22class AnthropicProvider(OpenAICompatibleProvider):
23 """Anthropic provider for Local Deep Research.
25 This is the official Anthropic API provider.
26 """
28 provider_name = "Anthropic"
29 api_key_setting = "llm.anthropic.api_key"
30 default_model = "" # User must explicitly pick a model — no silent fallback
31 default_base_url = "https://api.anthropic.com/v1"
33 # Metadata for auto-discovery
34 provider_key = "ANTHROPIC"
35 company_name = "Anthropic"
36 # Annotated so subclasses (e.g. the custom-endpoint provider, which may be
37 # local or cloud) can set is_cloud = None without a type conflict.
38 is_cloud: bool | None = True
39 # Egress exposure (ADR-0007): cloud inference sink — data leaves the box.
40 egress_exposure = Exposure.EXPOSING
42 @classmethod
43 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
44 """Factory function for Anthropic LLMs.
46 Args:
47 model_name: Name of the model to use
48 temperature: Model temperature (0.0-1.0)
49 **kwargs: Additional arguments including settings_snapshot
51 Returns:
52 A configured ChatAnthropic instance
54 Raises:
55 ValueError: If API key is not configured
56 """
57 from ....config.thread_settings import NoSettingsContextError
59 settings_snapshot = kwargs.get("settings_snapshot")
61 # resolve_api_key raises ValueError when the required key is missing
62 # (preserves the legacy behavior with a unified error message). When
63 # api_key_optional is True (custom self-hosted endpoints), fall back
64 # to the shared placeholder instead of raising — and pass it
65 # explicitly so langchain_anthropic does not silently read a real
66 # ANTHROPIC_API_KEY from the environment and ship it to the endpoint.
67 api_key: str | None
68 if cls.api_key_optional:
69 api_key = cls.resolve_api_key_or_placeholder(settings_snapshot)
70 else:
71 api_key = cls.resolve_api_key(settings_snapshot)
73 # Require an explicit model — no silent fallback to a hardcoded default.
74 if not model_name or not model_name.strip():
75 logger.error(f"{cls.provider_name} model name not provided")
76 raise ValueError(
77 f"{cls.provider_name} model not configured. "
78 f"Please set llm.model in settings "
79 f"(e.g. 'claude-3-5-sonnet-20241022')."
80 )
82 # Build Anthropic-specific parameters
83 anthropic_params = {
84 "model": model_name,
85 "anthropic_api_key": api_key,
86 "temperature": temperature,
87 }
89 # Apply context-window-aware max_tokens cap (was previously only
90 # applied in dead code in llm_config.get_llm).
91 from .._helpers import (
92 compute_max_tokens,
93 get_context_window_for_provider,
94 )
96 try:
97 context_window_size = get_context_window_for_provider(
98 "anthropic", settings_snapshot=settings_snapshot
99 )
100 max_tokens = compute_max_tokens(
101 settings_snapshot=settings_snapshot,
102 context_window_size=context_window_size,
103 )
104 if max_tokens: # Treat 0 as unset (matches legacy behavior)
105 anthropic_params["max_tokens"] = max_tokens
106 except NoSettingsContextError:
107 pass # Optional parameter
109 # ``ChatAnthropic.default_request_timeout`` is typed ``float | None``
110 # and its httpx client comes from a cached factory, so — unlike the
111 # OpenAI-flavoured providers — a per-operation ``Timeout`` object
112 # cannot be injected here. The scalar still bounds every operation
113 # (connect included), just without the shorter connect cap.
114 anthropic_params["timeout"] = resolve_request_timeout(settings_snapshot)
115 anthropic_params["max_retries"] = resolve_max_retries(settings_snapshot)
117 # Operator-configurable base_url for self-hosted Anthropic-format
118 # endpoints. No-op for the official cloud provider, whose url_setting
119 # is None (so it always talks to api.anthropic.com via the SDK
120 # default). Subclasses like CustomAnthropicEndpointProvider set
121 # url_setting to opt in.
122 if cls.url_setting:
123 from ....config.thread_settings import get_setting_from_snapshot
125 custom_url = get_setting_from_snapshot(
126 cls.url_setting,
127 default=None,
128 settings_snapshot=settings_snapshot,
129 )
130 custom_url = str(custom_url).strip() if custom_url else ""
131 if not custom_url:
132 # No silent fallback to the cloud endpoint — a custom-endpoint
133 # provider with no URL is a misconfiguration, not a default.
134 raise ValueError(
135 f"{cls.provider_name} requires a base URL. "
136 f"Please set {cls.url_setting} in settings."
137 )
138 # SSRF guard before constructing the client. Cloud-metadata IPs
139 # stay blocked even under the permissive localhost/private-IP
140 # posture that legitimate self-hosted endpoints rely on.
141 anthropic_params["base_url"] = assert_base_url_safe(
142 custom_url, setting_key=cls.url_setting
143 )
145 logger.info(
146 f"Creating {cls.provider_name} LLM with model: {model_name}, "
147 f"temperature: {temperature}"
148 )
150 return ChatAnthropic(**anthropic_params)
152 @classmethod
153 def list_models_for_api(cls, api_key=None, base_url=None):
154 """List models via the anthropic SDK.
156 Overrides the OpenAICompatibleProvider implementation, which uses the
157 OpenAI SDK and cannot talk to an Anthropic endpoint — it sends
158 ``Authorization: Bearer`` while Anthropic requires ``x-api-key``, so
159 the inherited path returns []. Works for both the official cloud
160 provider (``base_url`` None → the SDK's api.anthropic.com default) and
161 the custom-endpoint subclass (``base_url`` from
162 ``llm.anthropic_endpoint.url``, SSRF-guarded). Degrades to [] on any
163 failure so model-listing never 500s.
164 """
165 try:
166 # A URL-based (custom-endpoint) provider with no URL configured has
167 # no endpoint to query — return [] rather than falling back to the
168 # cloud default. ``url_setting`` is None for the cloud provider,
169 # where the cloud default IS the intended target.
170 if cls.url_setting and not base_url:
171 return []
173 # SSRF-guard an operator-configured base_url (custom-endpoint
174 # subclass). The cloud provider has url_setting=None and base_url
175 # None, so it skips this and uses the SDK's cloud default.
176 if base_url and cls.url_setting:
177 try:
178 base_url = assert_base_url_safe(
179 base_url, setting_key=cls.url_setting
180 )
181 except ValueError as e:
182 # The message carries no URL, only the setting key,
183 # and tells "could not resolve host" from a blocked
184 # host.
185 safe_msg = scrub_error(e)
186 logger.warning("{} {}", cls.provider_name, safe_msg)
187 return []
189 from anthropic import Anthropic, Timeout as AnthropicTimeout
191 # Trim a pasted key the way create_llm's resolve_api_key does.
192 # Without this a key with surrounding whitespace works for
193 # research but fails model discovery with a bare auth error, and a
194 # whitespace-only key is truthy so the placeholder is not
195 # substituted.
196 if isinstance(api_key, str):
197 api_key = api_key.strip()
199 # Pass the key explicitly (placeholder when keyless) so the SDK does
200 # not read ANTHROPIC_API_KEY from the environment and ship a real
201 # cloud key to a self-hosted endpoint.
202 client = Anthropic(
203 api_key=api_key or OPTIONAL_API_KEY_PLACEHOLDER,
204 base_url=base_url or None,
205 timeout=build_httpx_timeout(
206 MODEL_DISCOVERY_TIMEOUT_SECONDS, AnthropicTimeout
207 ),
208 max_retries=0,
209 )
211 logger.debug(f"Fetching models from {cls.provider_name}")
212 models_response = client.models.list()
214 models = []
215 for model in models_response.data:
216 model_id = getattr(model, "id", None)
217 if model_id: 217 ↛ 215line 217 didn't jump to line 215 because the condition on line 217 was always true
218 label = getattr(model, "display_name", None) or model_id
219 models.append({"value": model_id, "label": label})
221 logger.info(f"Found {len(models)} models from {cls.provider_name}")
222 return models
224 except Exception:
225 # Connection failures are expected when the endpoint isn't running.
226 logger.warning(f"Could not list models from {cls.provider_name}")
227 return []