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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Base class for LLM providers."""
3from ...security.egress.classification import Exposure
4from ...settings.exceptions import SettingsValueError
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
13def normalize_provider(provider):
14 """Normalize provider name to lowercase canonical form.
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.
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()
34class BaseLLMProvider:
35 """Base class for all LLM providers.
37 Defines the minimum interface that all providers must satisfy.
38 Subclasses should override the methods below as needed.
40 **Class attribute contract:**
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.
54 **Helper methods (use these instead of reading settings directly):**
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.
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 """
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
78 # Display name used in error messages and logs. Subclasses set this.
79 provider_name: str = "unknown"
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
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
93 @classmethod
94 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
95 """Create and return a LangChain chat model instance.
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.
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
106 Returns:
107 A configured BaseChatModel instance
109 Raises:
110 NotImplementedError: If not overridden by subclass
111 """
112 raise NotImplementedError(f"{cls.__name__} must implement create_llm()")
114 @classmethod
115 def is_available(cls, settings_snapshot=None):
116 """Check if this provider is available.
118 Returns False by default (fail-closed). Subclasses MUST override
119 this method to implement their own availability logic.
120 """
121 return False
123 @classmethod
124 def requires_auth_for_models(cls):
125 """Whether auth is needed to list models.
127 Returns True by default. Override in subclasses that allow
128 unauthenticated model listing (e.g., local providers).
129 """
130 return True
132 @classmethod
133 def resolve_api_key(cls, settings_snapshot=None) -> str | None:
134 """Read this provider's API key from settings, normalized.
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
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 )
159 @classmethod
160 def resolve_api_key_or_placeholder(cls, settings_snapshot=None) -> str:
161 """For ``ChatOpenAI(api_key=...)``-style construction.
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
170 @classmethod
171 def build_bearer_header(cls, settings_snapshot=None) -> dict[str, str]:
172 """Build an ``Authorization: Bearer`` header from the resolved key.
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 {}
187 @classmethod
188 def has_api_key(cls, settings_snapshot=None) -> bool:
189 """True if a real key is configured.
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