Coverage for src/lilbee/providers/sdk_backend.py: 100%
79 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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.base import TokenUsage
22from lilbee.providers.local_servers import LocalServerSpec, detect_local_server
24if TYPE_CHECKING:
25 # circular: sdk_backend -> model_ref -> types -> sdk_backend (annotation-only)
26 from lilbee.providers.model_ref import ProviderModelRef
28# Single source of truth for per-provider API key configuration.
29# Maps (provider_name, config_field, env_var, display_label). Backend-agnostic:
30# lilbee reads the env var, else the cfg field, and passes the key explicitly. Tuple order
31# is the canonical display order downstream consumers (TUI grouping, catalog
32# sections) honor when surfacing providers.
33PROVIDER_KEYS: tuple[tuple[str, str, str, str], ...] = (
34 ("openrouter", "openrouter_api_key", "OPENROUTER_API_KEY", "OpenRouter"),
35 ("gemini", "gemini_api_key", "GEMINI_API_KEY", "Gemini"),
36 ("anthropic", "anthropic_api_key", "ANTHROPIC_API_KEY", "Anthropic"),
37 ("openai", "openai_api_key", "OPENAI_API_KEY", "OpenAI"),
38 ("mistral", "mistral_api_key", "MISTRAL_API_KEY", "Mistral"),
39 ("deepseek", "deepseek_api_key", "DEEPSEEK_API_KEY", "DeepSeek"),
40)
42# Provider name -> cfg attribute holding that provider's API key.
43PROVIDER_API_KEY_FIELD: dict[str, str] = {prov: field for prov, field, *_ in PROVIDER_KEYS}
46# Provider name -> the provider's env var, which wins over the cfg field when set.
47PROVIDER_API_KEY_ENV: dict[str, str] = {prov: env for prov, _field, env, *_ in PROVIDER_KEYS}
50def get_provider_api_key(provider: str) -> str | None:
51 """Return the configured API key for *provider*, or ``None`` if unknown / unset.
53 *provider* is the lowercase routing key from a parsed model ref (e.g.
54 ``"openai"``). Returns ``None`` for unknown providers AND for known
55 providers whose key is unconfigured; callers can distinguish via
56 :data:`PROVIDER_API_KEY_FIELD`. Reads only the lilbee config field; the
57 SDK's own env var is honored by :mod:`lilbee.providers.key_check`.
58 """
59 from lilbee.core.config import cfg
61 field = PROVIDER_API_KEY_FIELD.get(provider.lower())
62 if field is None:
63 return None
64 value = getattr(cfg, field)
65 return value or None
68# Hosted API providers identified by URL substring. Local OpenAI-compatible
69# servers (Ollama, LM Studio) are matched ahead of this table via the
70# local-servers registry, so they are not listed here.
71_REMOTE_API_URL_PATTERNS: tuple[tuple[str, BackendName], ...] = (
72 ("openrouter", BackendName.OPENROUTER),
73 ("openai", BackendName.OPENAI),
74 ("anthropic", BackendName.ANTHROPIC),
75 ("googleapis", BackendName.GEMINI),
76 ("gemini", BackendName.GEMINI),
77 ("mistral", BackendName.MISTRAL),
78 ("deepseek", BackendName.DEEPSEEK),
79)
82def detect_backend_name(base_url: str) -> BackendName:
83 """Return the display name of the backend behind ``base_url``.
85 Adapter-agnostic; any SDK implementation can delegate to this helper.
86 Checks the local-server registry (Ollama, LM Studio) first, then the
87 hosted-API URL patterns, and falls back to ``BackendName.REMOTE``.
88 """
89 local = detect_local_server(base_url)
90 if local is not None:
91 return local.display_name
92 url_lower = base_url.lower()
93 for pattern, name in _REMOTE_API_URL_PATTERNS:
94 if pattern in url_lower:
95 return name
96 return BackendName.REMOTE
99@dataclass(frozen=True)
100class SdkToolCall:
101 """One tool call extracted from a non-streaming SDK chat response."""
103 id: str
104 name: str
105 arguments: str
108@dataclass(frozen=True)
109class SdkToolCallDelta:
110 """One streaming tool-call delta from an SDK chat chunk.
112 ``id`` and ``name`` arrive on the opener; ``arguments_delta`` accumulates
113 across subsequent chunks at the same ``index``. Mirrors the per-frame
114 shape that the dispatch's stream translator already understands.
115 """
117 index: int
118 id: str | None = None
119 name: str | None = None
120 arguments_delta: str | None = None
123@dataclass(frozen=True)
124class CompletionResult:
125 """Single-shot chat completion result returned by a backend."""
127 content: str
128 finish_reason: str | None = None
129 model: str | None = None
130 tool_calls: tuple[SdkToolCall, ...] = ()
131 usage: TokenUsage = field(default_factory=TokenUsage)
134@dataclass(frozen=True)
135class StreamChunk:
136 """One delta yielded during a streaming chat completion."""
138 content: str
139 finish_reason: str | None = None
140 tool_call_deltas: tuple[SdkToolCallDelta, ...] = ()
141 usage: TokenUsage | None = None
144@dataclass(frozen=True)
145class EmbeddingResult:
146 """Embedding vectors returned by a backend for a batch of inputs."""
148 vectors: list[list[float]]
149 model: str | None = None
152@dataclass(frozen=True)
153class CompletionRequest:
154 """Backend-agnostic request for a single completion call.
156 ``ref`` carries the parsed model reference; the adapter converts it
157 to the wire format its SDK expects. ``messages`` is the raw lilbee
158 message list (may contain ``images`` bytes); the adapter formats it
159 for its SDK. ``api_base`` is populated for local/Ollama deployments
160 and omitted for API-hosted models.
161 """
163 ref: ProviderModelRef
164 messages: list[dict[str, Any]]
165 options: dict[str, Any] = field(default_factory=dict)
166 api_base: str | None = None
167 api_key: str | None = None
170@dataclass(frozen=True)
171class EmbeddingRequest:
172 """Backend-agnostic request for an embedding call."""
174 ref: ProviderModelRef
175 inputs: list[str]
176 api_base: str | None = None
177 api_key: str | None = None
180@dataclass(frozen=True)
181class RerankRequest:
182 """Backend-agnostic rerank request."""
184 ref: ProviderModelRef
185 query: str
186 candidates: list[str]
187 api_base: str | None = None
188 api_key: str | None = None
191@dataclass(frozen=True)
192class RerankResult:
193 """Rerank scores returned by a backend, one per candidate in input order."""
195 scores: list[float]
196 model: str | None = None
199class LlmSdkBackend(Protocol):
200 """Protocol every LLM SDK adapter must satisfy.
202 The provider calls these methods through the Protocol only; SDK
203 response objects never cross the seam. Methods with a natural
204 "not supported" signal are documented below.
206 Lifecycle: ``available()`` is the cheap install check called before
207 any other method; ``configure_logging`` runs once at first use.
208 ``complete`` / ``complete_stream`` / ``embed`` are the hot-path
209 operations. ``list_models`` / ``list_chat_models`` / ``pull_model``
210 / ``show_model`` are catalog helpers and may raise
211 ``NotImplementedError`` or return empty values when unsupported.
213 Error contract: implementations must raise only ``ProviderError`` or
214 ``NotImplementedError`` from any method. ``SdkLLMProvider`` wraps any
215 other exception at the seam; adapters should translate SDK-specific
216 errors (httpx errors, third-party SDK exceptions) into
217 ``ProviderError`` so the provider can pass them through.
218 """
220 @property
221 def provider_name(self) -> str:
222 """Stable identifier used when wrapping errors in ``ProviderError``."""
223 ...
225 def active_backend_name(self, base_url: str) -> str:
226 """Return the display name of the backend the adapter is talking to.
228 ``"Ollama"`` for an Ollama URL, ``"OpenAI"`` for an OpenAI URL,
229 etc.; unknown URLs fall back to ``"Remote"``. The adapter's own
230 identity is exposed separately through ``provider_name``.
231 """
232 ...
234 def available(self) -> bool:
235 """Return True when the underlying SDK is importable."""
236 ...
238 def configure_logging(self, *, suppress_debug: bool) -> None:
239 """Apply backend-level logging toggles (best-effort no-op if unsupported)."""
240 ...
242 def complete(self, request: CompletionRequest) -> CompletionResult:
243 """Run a single-shot chat completion."""
244 ...
246 def complete_stream(self, request: CompletionRequest) -> Iterator[StreamChunk]:
247 """Run a streaming chat completion, yielding content chunks."""
248 ...
250 def embed(self, request: EmbeddingRequest) -> EmbeddingResult:
251 """Embed a batch of inputs, returning one vector per input."""
252 ...
254 def rerank(self, request: RerankRequest) -> RerankResult:
255 """Score *candidates* against *query*, returning one float per candidate.
257 Raise ``NotImplementedError`` if the backend has no rerank API.
258 An empty ``request.candidates`` returns ``RerankResult([])``
259 without an SDK call.
260 """
261 ...
263 def list_models(self, *, spec: LocalServerSpec, base_url: str, api_key: str) -> list[str]:
264 """List model identifiers served by the local server *spec* at *base_url*.
266 The server kind comes from *spec*, never from the address. A server
267 that needs no key (Ollama) gets none. Return [] if unsupported.
268 """
269 ...
271 def list_chat_models(self, provider: str) -> list[str]:
272 """List chat-mode models from the SDK's catalog for *provider*.
274 Returns the unfiltered upstream catalog. Backends without a
275 notion of frontier providers return ``[]``.
277 Unlike ``list_models``, this is a static pricing/capability table,
278 not a runtime HTTP probe.
279 """
280 ...
282 def pull_model(
283 self,
284 model: str,
285 *,
286 base_url: str,
287 on_progress: Callable[..., Any] | None = None,
288 ) -> None:
289 """Pull a model. Raise NotImplementedError if unsupported."""
290 ...
292 def show_model(self, model: str, *, base_url: str) -> dict[str, Any] | None:
293 """Return model metadata dict or None if unsupported / not found."""
294 ...
296 def supports_tools(self, model_ref: str) -> bool:
297 """Return True iff the backend can route tool calls for *model_ref*."""
298 ...