Coverage for src/lilbee/providers/sdk_backend.py: 100%
76 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Protocol and value types for SDK-backed LLM backends.
3A backend hides one third-party SDK. The ``SdkLLMProvider`` speaks to
4backends exclusively through the ``LlmSdkBackend`` Protocol and the
5value types defined here, so SDK response objects never leak outside
6the adapter.
8This module is intentionally dependency-free (no SDK imports, no
9lilbee provider imports beyond the shared base types).
10"""
12from __future__ import annotations
14from collections.abc import Callable, Iterator
15from dataclasses import dataclass, field
16from typing import TYPE_CHECKING, Any, Protocol
18# Display name for the active backend the SDK is talking to. The
19# adapter's own identity is exposed separately via provider_name.
20from lilbee.providers.backend_names import BackendName
21from lilbee.providers.local_servers import detect_local_server
23if TYPE_CHECKING:
24 # circular: sdk_backend -> model_ref -> types -> sdk_backend (annotation-only)
25 from lilbee.providers.model_ref import ProviderModelRef
27# Single source of truth for per-provider API key configuration.
28# Maps (provider_name, config_field, env_var, display_label). Backend-agnostic:
29# lilbee reads the env var, else the cfg field, and passes the key explicitly. Tuple order
30# is the canonical display order downstream consumers (TUI grouping, catalog
31# sections) honor when surfacing providers.
32PROVIDER_KEYS: tuple[tuple[str, str, str, str], ...] = (
33 ("openrouter", "openrouter_api_key", "OPENROUTER_API_KEY", "OpenRouter"),
34 ("gemini", "gemini_api_key", "GEMINI_API_KEY", "Gemini"),
35 ("anthropic", "anthropic_api_key", "ANTHROPIC_API_KEY", "Anthropic"),
36 ("openai", "openai_api_key", "OPENAI_API_KEY", "OpenAI"),
37 ("mistral", "mistral_api_key", "MISTRAL_API_KEY", "Mistral"),
38 ("deepseek", "deepseek_api_key", "DEEPSEEK_API_KEY", "DeepSeek"),
39)
41# Provider name -> cfg attribute holding that provider's API key.
42PROVIDER_API_KEY_FIELD: dict[str, str] = {prov: field for prov, field, *_ in PROVIDER_KEYS}
45# Provider name -> the provider's env var, which wins over the cfg field when set.
46PROVIDER_API_KEY_ENV: dict[str, str] = {prov: env for prov, _field, env, *_ in PROVIDER_KEYS}
49def get_provider_api_key(provider: str) -> str | None:
50 """Return the configured API key for *provider*, or ``None`` if unknown / unset.
52 *provider* is the lowercase routing key from a parsed model ref (e.g.
53 ``"openai"``). Returns ``None`` for unknown providers AND for known
54 providers whose key is unconfigured; callers can distinguish via
55 :data:`PROVIDER_API_KEY_FIELD`. Reads only the lilbee config field; the
56 SDK's own env var is honored by :mod:`lilbee.providers.key_check`.
57 """
58 from lilbee.core.config import cfg
60 field = PROVIDER_API_KEY_FIELD.get(provider.lower())
61 if field is None:
62 return None
63 value = getattr(cfg, field)
64 return value or None
67# Hosted API providers identified by URL substring. Local OpenAI-compatible
68# servers (Ollama, LM Studio) are matched ahead of this table via the
69# local-servers registry, so they are not listed here.
70_REMOTE_API_URL_PATTERNS: tuple[tuple[str, BackendName], ...] = (
71 ("openrouter", BackendName.OPENROUTER),
72 ("openai", BackendName.OPENAI),
73 ("anthropic", BackendName.ANTHROPIC),
74 ("googleapis", BackendName.GEMINI),
75 ("gemini", BackendName.GEMINI),
76 ("mistral", BackendName.MISTRAL),
77 ("deepseek", BackendName.DEEPSEEK),
78)
81def detect_backend_name(base_url: str) -> BackendName:
82 """Return the display name of the backend behind ``base_url``.
84 Adapter-agnostic; any SDK implementation can delegate to this helper.
85 Checks the local-server registry (Ollama, LM Studio) first, then the
86 hosted-API URL patterns, and falls back to ``BackendName.REMOTE``.
87 """
88 local = detect_local_server(base_url)
89 if local is not None:
90 return local.display_name
91 url_lower = base_url.lower()
92 for pattern, name in _REMOTE_API_URL_PATTERNS:
93 if pattern in url_lower:
94 return name
95 return BackendName.REMOTE
98@dataclass(frozen=True)
99class SdkToolCall:
100 """One tool call extracted from a non-streaming SDK chat response."""
102 id: str
103 name: str
104 arguments: str
107@dataclass(frozen=True)
108class SdkToolCallDelta:
109 """One streaming tool-call delta from an SDK chat chunk.
111 ``id`` and ``name`` arrive on the opener; ``arguments_delta`` accumulates
112 across subsequent chunks at the same ``index``. Mirrors the per-frame
113 shape that the dispatch's stream translator already understands.
114 """
116 index: int
117 id: str | None = None
118 name: str | None = None
119 arguments_delta: str | None = None
122@dataclass(frozen=True)
123class CompletionResult:
124 """Single-shot chat completion result returned by a backend."""
126 content: str
127 finish_reason: str | None = None
128 model: str | None = None
129 tool_calls: tuple[SdkToolCall, ...] = ()
132@dataclass(frozen=True)
133class StreamChunk:
134 """One delta yielded during a streaming chat completion."""
136 content: str
137 finish_reason: str | None = None
138 tool_call_deltas: tuple[SdkToolCallDelta, ...] = ()
141@dataclass(frozen=True)
142class EmbeddingResult:
143 """Embedding vectors returned by a backend for a batch of inputs."""
145 vectors: list[list[float]]
146 model: str | None = None
149@dataclass(frozen=True)
150class CompletionRequest:
151 """Backend-agnostic request for a single completion call.
153 ``ref`` carries the parsed model reference; the adapter converts it
154 to the wire format its SDK expects. ``messages`` is the raw lilbee
155 message list (may contain ``images`` bytes); the adapter formats it
156 for its SDK. ``api_base`` is populated for local/Ollama deployments
157 and omitted for API-hosted models.
158 """
160 ref: ProviderModelRef
161 messages: list[dict[str, Any]]
162 options: dict[str, Any] = field(default_factory=dict)
163 api_base: str | None = None
164 api_key: str | None = None
167@dataclass(frozen=True)
168class EmbeddingRequest:
169 """Backend-agnostic request for an embedding call."""
171 ref: ProviderModelRef
172 inputs: list[str]
173 api_base: str | None = None
174 api_key: str | None = None
177@dataclass(frozen=True)
178class RerankRequest:
179 """Backend-agnostic rerank request."""
181 ref: ProviderModelRef
182 query: str
183 candidates: list[str]
184 api_base: str | None = None
185 api_key: str | None = None
188@dataclass(frozen=True)
189class RerankResult:
190 """Rerank scores returned by a backend, one per candidate in input order."""
192 scores: list[float]
193 model: str | None = None
196class LlmSdkBackend(Protocol):
197 """Protocol every LLM SDK adapter must satisfy.
199 The provider calls these methods through the Protocol only; SDK
200 response objects never cross the seam. Methods with a natural
201 "not supported" signal are documented below.
203 Lifecycle: ``available()`` is the cheap install check called before
204 any other method; ``configure_logging`` runs once at first use.
205 ``complete`` / ``complete_stream`` / ``embed`` are the hot-path
206 operations. ``list_models`` / ``list_chat_models`` / ``pull_model``
207 / ``show_model`` are catalog helpers and may raise
208 ``NotImplementedError`` or return empty values when unsupported.
210 Error contract: implementations must raise only ``ProviderError`` or
211 ``NotImplementedError`` from any method. ``SdkLLMProvider`` wraps any
212 other exception at the seam; adapters should translate SDK-specific
213 errors (httpx errors, third-party SDK exceptions) into
214 ``ProviderError`` so the provider can pass them through.
215 """
217 @property
218 def provider_name(self) -> str:
219 """Stable identifier used when wrapping errors in ``ProviderError``."""
220 ...
222 def active_backend_name(self, base_url: str) -> str:
223 """Return the display name of the backend the adapter is talking to.
225 ``"Ollama"`` for an Ollama URL, ``"OpenAI"`` for an OpenAI URL,
226 etc.; unknown URLs fall back to ``"Remote"``. The adapter's own
227 identity is exposed separately through ``provider_name``.
228 """
229 ...
231 def available(self) -> bool:
232 """Return True when the underlying SDK is importable."""
233 ...
235 def configure_logging(self, *, suppress_debug: bool) -> None:
236 """Apply backend-level logging toggles (best-effort no-op if unsupported)."""
237 ...
239 def complete(self, request: CompletionRequest) -> CompletionResult:
240 """Run a single-shot chat completion."""
241 ...
243 def complete_stream(self, request: CompletionRequest) -> Iterator[StreamChunk]:
244 """Run a streaming chat completion, yielding content chunks."""
245 ...
247 def embed(self, request: EmbeddingRequest) -> EmbeddingResult:
248 """Embed a batch of inputs, returning one vector per input."""
249 ...
251 def rerank(self, request: RerankRequest) -> RerankResult:
252 """Score *candidates* against *query*, returning one float per candidate.
254 Raise ``NotImplementedError`` if the backend has no rerank API.
255 An empty ``request.candidates`` returns ``RerankResult([])``
256 without an SDK call.
257 """
258 ...
260 def list_models(self, *, base_url: str, api_key: str) -> list[str]:
261 """List model identifiers visible to the backend. Return [] if unsupported."""
262 ...
264 def list_chat_models(self, provider: str) -> list[str]:
265 """List chat-mode models from the SDK's catalog for *provider*.
267 Returns the unfiltered upstream catalog. Backends without a
268 notion of frontier providers return ``[]``.
270 Unlike ``list_models``, this is a static pricing/capability table,
271 not a runtime HTTP probe.
272 """
273 ...
275 def pull_model(
276 self,
277 model: str,
278 *,
279 base_url: str,
280 on_progress: Callable[..., Any] | None = None,
281 ) -> None:
282 """Pull a model. Raise NotImplementedError if unsupported."""
283 ...
285 def show_model(self, model: str, *, base_url: str) -> dict[str, Any] | None:
286 """Return model metadata dict or None if unsupported / not found."""
287 ...
289 def supports_tools(self, model_ref: str) -> bool:
290 """Return True iff the backend can route tool calls for *model_ref*."""
291 ...