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

55 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Base class for LLM providers.""" 

2 

3from ...security.egress.classification import Exposure 

4from ...settings.exceptions import SettingsValueError 

5 

6# Single placeholder for ChatOpenAI(api_key=...) when no real key is 

7# configured (unauthenticated local LM Studio, llama.cpp, etc.). Subclasses 

8# MUST NOT override this constant; per-provider drift here was the reason 

9# we previously had three different placeholder strings. 

10OPTIONAL_API_KEY_PLACEHOLDER = "not-required" # noqa: S105 

11 

12 

13def normalize_provider(provider): 

14 """Normalize provider name to lowercase canonical form. 

15 

16 All provider comparisons in route/service code should use 

17 this function to ensure consistent casing. Surrounding whitespace is 

18 stripped too (a hand-edited or env-supplied ``" qwen "``); a 

19 whitespace-only value normalises to None like any other empty value. 

20 

21 Only ``str`` input is stripped. Any other truthy value keeps the 

22 pre-strip contract exactly: it is duck-typed through ``.lower()``, so a 

23 list/int still raises ``AttributeError`` (callers and the contract 

24 tests rely on that), and an object that only implements ``lower()`` 

25 passes through as before instead of failing on a missing ``strip()``. 

26 """ 

27 if not provider: 

28 return None 

29 if isinstance(provider, str): 

30 return provider.strip().lower() or None 

31 return provider.lower() 

32 

33 

34class BaseLLMProvider: 

35 """Base class for all LLM providers. 

36 

37 Defines the minimum interface that all providers must satisfy. 

38 Subclasses should override the methods below as needed. 

39 

40 **Class attribute contract:** 

41 

42 - ``api_key_setting`` — settings key for the provider's API key 

43 (e.g. ``"llm.openai.api_key"``), or ``None`` for providers that have 

44 no API key concept at all. 

45 - ``api_key_optional`` — when ``True``, a missing/whitespace-only key 

46 returns ``None`` from ``resolve_api_key()`` instead of raising. 

47 Construction-time callers can then substitute 

48 ``OPTIONAL_API_KEY_PLACEHOLDER`` (typical for local providers like 

49 LM Studio that may or may not have auth enabled). 

50 - ``provider_name`` — display name used in error messages and logs. 

51 Subclasses MUST override this away from the ``"unknown"`` default 

52 or auto-discovery will skip them. 

53 

54 **Helper methods (use these instead of reading settings directly):** 

55 

56 - ``resolve_api_key(snapshot)`` → ``str | None``. Returns the 

57 stripped key, ``None`` for optional providers when missing, or 

58 raises ``ValueError`` for required providers when missing. 

59 - ``resolve_api_key_or_placeholder(snapshot)`` → ``str``. Same as 

60 above but substitutes the placeholder when optional+missing — use 

61 this when constructing ``ChatOpenAI(api_key=...)``. 

62 - ``build_bearer_header(snapshot)`` → ``dict``. Returns 

63 ``{"Authorization": "Bearer <key>"}`` or ``{}``. Use this when 

64 sending Authorization headers to a provider's REST API. 

65 - ``has_api_key(snapshot)`` → ``bool``. Use in ``is_available()`` 

66 overrides to fail-closed when a required key is missing. 

67 

68 **`create_llm()` contract:** subclass implementations return a *bare* 

69 LangChain ``BaseChatModel``. Wrapping (rate limiting, token counting, 

70 think-tag stripping) is applied by ``llm_config.get_llm()`` after the 

71 provider class returns. Do NOT wrap inside ``create_llm()``. 

72 """ 

73 

74 # Settings key for this provider's API key. None means the provider 

75 # has no API key concept at all. 

76 api_key_setting: str | None = None 

77 

78 # Display name used in error messages and logs. Subclasses set this. 

79 provider_name: str = "unknown" 

80 

81 # When True, a missing/whitespace-only key returns None (callers may 

82 # substitute OPTIONAL_API_KEY_PLACEHOLDER) instead of raising. 

83 api_key_optional: bool = False 

84 

85 # Egress exposure (ADR-0007): declares whether this is an exposing 

86 # inference sink (cloud = exposing, local = contained). Declarative default 

87 # only — the egress resolver classifies the provider's ACTUAL endpoint at 

88 # run time (run_classification.llm_label via evaluate_llm_endpoint), so a 

89 # URL-configurable provider is refined then; this attribute documents intent 

90 # and is asserted consistent by test. 

91 egress_exposure = Exposure.EXPOSING 

92 

93 @classmethod 

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

95 """Create and return a LangChain chat model instance. 

96 

97 Subclasses MUST override this method. The returned LLM is BARE — 

98 wrapping (rate limiting, token counting, think-tag stripping) is 

99 applied by ``llm_config.get_llm()`` after this returns. 

100 

101 Args: 

102 model_name: Name of the model to use 

103 temperature: Model temperature (0.0-1.0) 

104 **kwargs: Additional arguments including settings_snapshot 

105 

106 Returns: 

107 A configured BaseChatModel instance 

108 

109 Raises: 

110 NotImplementedError: If not overridden by subclass 

111 """ 

112 raise NotImplementedError(f"{cls.__name__} must implement create_llm()") 

113 

114 @classmethod 

115 def is_available(cls, settings_snapshot=None): 

116 """Check if this provider is available. 

117 

118 Returns False by default (fail-closed). Subclasses MUST override 

119 this method to implement their own availability logic. 

120 """ 

121 return False 

122 

123 @classmethod 

124 def requires_auth_for_models(cls): 

125 """Whether auth is needed to list models. 

126 

127 Returns True by default. Override in subclasses that allow 

128 unauthenticated model listing (e.g., local providers). 

129 """ 

130 return True 

131 

132 @classmethod 

133 def resolve_api_key(cls, settings_snapshot=None) -> str | None: 

134 """Read this provider's API key from settings, normalized. 

135 

136 Returns: 

137 The stripped key if non-empty; None if optional and missing 

138 or whitespace-only, or if api_key_setting is None. 

139 Raises: 

140 ValueError if required (api_key_optional=False) and missing. 

141 """ 

142 from ...config.thread_settings import get_setting_from_snapshot 

143 

144 if not cls.api_key_setting: 

145 return None 

146 raw = get_setting_from_snapshot( 

147 cls.api_key_setting, "", settings_snapshot=settings_snapshot 

148 ) 

149 key = str(raw or "").strip() 

150 if key: 

151 return key 

152 if cls.api_key_optional: 

153 return None 

154 raise ValueError( 

155 f"{cls.provider_name} API key not configured. " 

156 f"Please set {cls.api_key_setting} in settings." 

157 ) 

158 

159 @classmethod 

160 def resolve_api_key_or_placeholder(cls, settings_snapshot=None) -> str: 

161 """For ``ChatOpenAI(api_key=...)``-style construction. 

162 

163 Always returns a string. Real key when set; otherwise the unified 

164 ``OPTIONAL_API_KEY_PLACEHOLDER`` so the langchain client doesn't 

165 reject the construction call. 

166 """ 

167 key = cls.resolve_api_key(settings_snapshot) 

168 return key if key else OPTIONAL_API_KEY_PLACEHOLDER 

169 

170 @classmethod 

171 def build_bearer_header(cls, settings_snapshot=None) -> dict[str, str]: 

172 """Build an ``Authorization: Bearer`` header from the resolved key. 

173 

174 Returns an empty dict when no real key is configured (so callers 

175 can spread it conditionally). A missing required key is treated as 

176 "no header" here too; its construction-time error happens elsewhere. 

177 Invalid configured values must propagate rather than remove auth. 

178 """ 

179 try: 

180 key = cls.resolve_api_key(settings_snapshot) 

181 except SettingsValueError: 

182 raise 

183 except ValueError: 

184 return {} 

185 return {"Authorization": f"Bearer {key}"} if key else {} 

186 

187 @classmethod 

188 def has_api_key(cls, settings_snapshot=None) -> bool: 

189 """True if a real key is configured. 

190 

191 Returns False for missing/whitespace keys (required providers raise 

192 ValueError; we swallow it). Also returns False on broader settings 

193 infrastructure errors so callers like ``is_available()`` fail closed. 

194 Invalid configured values propagate instead of appearing absent. 

195 """ 

196 try: 

197 return cls.resolve_api_key(settings_snapshot) is not None 

198 except SettingsValueError: 

199 raise 

200 except Exception: 

201 return False