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

1"""Anthropic LLM provider for Local Deep Research.""" 

2 

3from langchain_anthropic import ChatAnthropic 

4from ....security.secure_logging import logger 

5 

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) 

20 

21 

22class AnthropicProvider(OpenAICompatibleProvider): 

23 """Anthropic provider for Local Deep Research. 

24 

25 This is the official Anthropic API provider. 

26 """ 

27 

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" 

32 

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 

41 

42 @classmethod 

43 def create_llm(cls, model_name=None, temperature=0.7, **kwargs): 

44 """Factory function for Anthropic LLMs. 

45 

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 

50 

51 Returns: 

52 A configured ChatAnthropic instance 

53 

54 Raises: 

55 ValueError: If API key is not configured 

56 """ 

57 from ....config.thread_settings import NoSettingsContextError 

58 

59 settings_snapshot = kwargs.get("settings_snapshot") 

60 

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) 

72 

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 ) 

81 

82 # Build Anthropic-specific parameters 

83 anthropic_params = { 

84 "model": model_name, 

85 "anthropic_api_key": api_key, 

86 "temperature": temperature, 

87 } 

88 

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 ) 

95 

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 

108 

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) 

116 

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 

124 

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 ) 

144 

145 logger.info( 

146 f"Creating {cls.provider_name} LLM with model: {model_name}, " 

147 f"temperature: {temperature}" 

148 ) 

149 

150 return ChatAnthropic(**anthropic_params) 

151 

152 @classmethod 

153 def list_models_for_api(cls, api_key=None, base_url=None): 

154 """List models via the anthropic SDK. 

155 

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 [] 

172 

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 [] 

188 

189 from anthropic import Anthropic, Timeout as AnthropicTimeout 

190 

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() 

198 

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 ) 

210 

211 logger.debug(f"Fetching models from {cls.provider_name}") 

212 models_response = client.models.list() 

213 

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}) 

220 

221 logger.info(f"Found {len(models)} models from {cls.provider_name}") 

222 return models 

223 

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 []