Coverage for src/local_deep_research/llm/providers/implementations/custom_anthropic_endpoint.py: 100%

34 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1"""Custom Anthropic-compatible (Messages API) endpoint provider. 

2 

3Connects to a self-hosted LLM service that speaks the Anthropic Messages API 

4(``/v1/messages``) rather than the OpenAI chat-completions format. Mirrors 

5``CustomOpenAIEndpointProvider`` but builds a ``ChatAnthropic`` client (via the 

6parent ``AnthropicProvider``) pointed at an operator-configured ``base_url``. 

7""" 

8 

9from ....security.secure_logging import logger 

10 

11from ....config.thread_settings import get_setting_from_snapshot 

12from ....security.log_sanitizer import redact_secrets 

13from ..base import Exposure 

14from .anthropic import AnthropicProvider 

15 

16 

17class CustomAnthropicEndpointProvider(AnthropicProvider): 

18 """Custom Anthropic-format endpoint provider. 

19 

20 Lets users connect to any service implementing the Anthropic Messages API 

21 by specifying a base URL in settings. The URL is SSRF-validated and passed 

22 to ``ChatAnthropic`` by the inherited ``create_llm`` (which activates its 

23 URL/keyless branch because ``url_setting`` is set here). 

24 """ 

25 

26 provider_name = "Anthropic-Compatible Endpoint" 

27 api_key_setting = "llm.anthropic_endpoint.api_key" 

28 # Many self-hosted Anthropic-format gateways don't require auth; fall back 

29 # to the shared placeholder instead of raising at construction time. 

30 api_key_optional = True 

31 url_setting = "llm.anthropic_endpoint.url" # type: ignore[assignment] 

32 

33 # Metadata for auto-discovery (registered as the "anthropic_endpoint" 

34 # provider string). The egress policy classifies it generically via 

35 # ``llm.anthropic_endpoint.url`` — keep the setting name in sync. 

36 provider_key = "ANTHROPIC_ENDPOINT" 

37 company_name = "Anthropic-Compatible" 

38 is_cloud = None # Unknown — could be local or cloud 

39 egress_exposure = Exposure.EXPOSING # URL-configurable; fail closed to exposing until classifier refines by URL 

40 

41 @classmethod 

42 def requires_auth_for_models(cls): 

43 """Self-hosted Anthropic-format endpoints may allow keyless listing. 

44 

45 Return False so model listing is attempted without an API key. If the 

46 endpoint requires auth, the Anthropic client raises and we degrade to 

47 an empty list. 

48 """ 

49 return False 

50 

51 @classmethod 

52 def is_available(cls, settings_snapshot=None): 

53 """Available when either an API key or a custom URL is configured. 

54 

55 Unlike the cloud Anthropic provider, this custom endpoint supports 

56 keyless local servers — so a configured URL alone makes it usable. 

57 """ 

58 api_key = None 

59 try: 

60 api_key = get_setting_from_snapshot( 

61 cls.api_key_setting, 

62 default=None, 

63 settings_snapshot=settings_snapshot, 

64 ) 

65 if api_key and str(api_key).strip(): 

66 return True 

67 except Exception as e: 

68 # Redact in case a settings-layer error message embeds the key. 

69 safe_msg = redact_secrets(str(e), api_key) 

70 logger.debug(f"Error checking provider availability: {safe_msg}") 

71 

72 try: 

73 custom_url = get_setting_from_snapshot( 

74 cls.url_setting, 

75 default=None, 

76 settings_snapshot=settings_snapshot, 

77 ) 

78 if custom_url and str(custom_url).strip(): 

79 return True 

80 except Exception: 

81 logger.debug( 

82 f"Error reading URL setting '{cls.url_setting}'", 

83 exc_info=True, 

84 ) 

85 

86 return False 

87 

88 # list_models_for_api is inherited from AnthropicProvider, which lists 

89 # models via the anthropic SDK and honors cls.url_setting (SSRF-guarding 

90 # the configured base_url; returning [] when no URL is set).