Coverage for src/lilbee/providers/sdk_backend.py: 100%

82 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Protocol and value types for SDK-backed LLM backends. 

2 

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. 

7 

8This module is intentionally dependency-free (no SDK imports, no 

9lilbee provider imports beyond the shared base types). 

10""" 

11 

12from __future__ import annotations 

13 

14import os 

15from collections.abc import Callable, Iterator 

16from dataclasses import dataclass, field 

17from typing import TYPE_CHECKING, Any, Protocol 

18 

19# Display name for the active backend the SDK is talking to. The 

20# adapter's own identity is exposed separately via provider_name. 

21from lilbee.providers.backend_names import BackendName 

22from lilbee.providers.local_servers import detect_local_server 

23 

24if TYPE_CHECKING: 

25 # circular: sdk_backend -> model_ref -> types -> sdk_backend (annotation-only) 

26 from lilbee.providers.model_ref import ProviderModelRef 

27 

28# Single source of truth for per-provider API key configuration. 

29# Maps (provider_name, config_field, env_var, display_label). Backend-agnostic: 

30# OpenAI-compatible SDKs all read these env vars at call time. 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) 

41 

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} 

44 

45 

46# Provider name -> the SDK's own env var (read at call time by the backend). 

47PROVIDER_API_KEY_ENV: dict[str, str] = {prov: env for prov, _field, env, *_ in PROVIDER_KEYS} 

48 

49 

50def get_provider_api_key(provider: str) -> str | None: 

51 """Return the configured API key for *provider*, or ``None`` if unknown / unset. 

52 

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; use 

57 :func:`provider_has_key` to also honor the SDK's own env var. 

58 """ 

59 from lilbee.core.config import cfg 

60 

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 

66 

67 

68def provider_has_key(provider: str) -> bool: 

69 """True if *provider* has a key via its standard env var or the lilbee config field.""" 

70 env_var = PROVIDER_API_KEY_ENV.get(provider.lower()) 

71 if env_var and os.environ.get(env_var): 

72 return True 

73 return get_provider_api_key(provider) is not None 

74 

75 

76# Hosted API providers identified by URL substring. Local OpenAI-compatible 

77# servers (Ollama, LM Studio) are matched ahead of this table via the 

78# local-servers registry, so they are not listed here. 

79_REMOTE_API_URL_PATTERNS: tuple[tuple[str, BackendName], ...] = ( 

80 ("openrouter", BackendName.OPENROUTER), 

81 ("openai", BackendName.OPENAI), 

82 ("anthropic", BackendName.ANTHROPIC), 

83 ("googleapis", BackendName.GEMINI), 

84 ("gemini", BackendName.GEMINI), 

85 ("mistral", BackendName.MISTRAL), 

86 ("deepseek", BackendName.DEEPSEEK), 

87) 

88 

89 

90def detect_backend_name(base_url: str) -> BackendName: 

91 """Return the display name of the backend behind ``base_url``. 

92 

93 Adapter-agnostic; any SDK implementation can delegate to this helper. 

94 Checks the local-server registry (Ollama, LM Studio) first, then the 

95 hosted-API URL patterns, and falls back to ``BackendName.REMOTE``. 

96 """ 

97 local = detect_local_server(base_url) 

98 if local is not None: 

99 return local.display_name 

100 url_lower = base_url.lower() 

101 for pattern, name in _REMOTE_API_URL_PATTERNS: 

102 if pattern in url_lower: 

103 return name 

104 return BackendName.REMOTE 

105 

106 

107@dataclass(frozen=True) 

108class SdkToolCall: 

109 """One tool call extracted from a non-streaming SDK chat response.""" 

110 

111 id: str 

112 name: str 

113 arguments: str 

114 

115 

116@dataclass(frozen=True) 

117class SdkToolCallDelta: 

118 """One streaming tool-call delta from an SDK chat chunk. 

119 

120 ``id`` and ``name`` arrive on the opener; ``arguments_delta`` accumulates 

121 across subsequent chunks at the same ``index``. Mirrors the per-frame 

122 shape that the dispatch's stream translator already understands. 

123 """ 

124 

125 index: int 

126 id: str | None = None 

127 name: str | None = None 

128 arguments_delta: str | None = None 

129 

130 

131@dataclass(frozen=True) 

132class CompletionResult: 

133 """Single-shot chat completion result returned by a backend.""" 

134 

135 content: str 

136 finish_reason: str | None = None 

137 model: str | None = None 

138 tool_calls: tuple[SdkToolCall, ...] = () 

139 

140 

141@dataclass(frozen=True) 

142class StreamChunk: 

143 """One delta yielded during a streaming chat completion.""" 

144 

145 content: str 

146 finish_reason: str | None = None 

147 tool_call_deltas: tuple[SdkToolCallDelta, ...] = () 

148 

149 

150@dataclass(frozen=True) 

151class EmbeddingResult: 

152 """Embedding vectors returned by a backend for a batch of inputs.""" 

153 

154 vectors: list[list[float]] 

155 model: str | None = None 

156 

157 

158@dataclass(frozen=True) 

159class CompletionRequest: 

160 """Backend-agnostic request for a single completion call. 

161 

162 ``ref`` carries the parsed model reference; the adapter converts it 

163 to the wire format its SDK expects. ``messages`` is the raw lilbee 

164 message list (may contain ``images`` bytes); the adapter formats it 

165 for its SDK. ``api_base`` is populated for local/Ollama deployments 

166 and omitted for API-hosted models. 

167 """ 

168 

169 ref: ProviderModelRef 

170 messages: list[dict[str, Any]] 

171 options: dict[str, Any] = field(default_factory=dict) 

172 api_base: str | None = None 

173 api_key: str | None = None 

174 

175 

176@dataclass(frozen=True) 

177class EmbeddingRequest: 

178 """Backend-agnostic request for an embedding call.""" 

179 

180 ref: ProviderModelRef 

181 inputs: list[str] 

182 api_base: str | None = None 

183 api_key: str | None = None 

184 

185 

186@dataclass(frozen=True) 

187class RerankRequest: 

188 """Backend-agnostic rerank request.""" 

189 

190 ref: ProviderModelRef 

191 query: str 

192 candidates: list[str] 

193 api_base: str | None = None 

194 api_key: str | None = None 

195 

196 

197@dataclass(frozen=True) 

198class RerankResult: 

199 """Rerank scores returned by a backend, one per candidate in input order.""" 

200 

201 scores: list[float] 

202 model: str | None = None 

203 

204 

205class LlmSdkBackend(Protocol): 

206 """Protocol every LLM SDK adapter must satisfy. 

207 

208 The provider calls these methods through the Protocol only; SDK 

209 response objects never cross the seam. Methods with a natural 

210 "not supported" signal are documented below. 

211 

212 Lifecycle: ``available()`` is the cheap install check called before 

213 any other method; ``configure_logging`` runs once at first use. 

214 ``complete`` / ``complete_stream`` / ``embed`` are the hot-path 

215 operations. ``list_models`` / ``list_chat_models`` / ``pull_model`` 

216 / ``show_model`` are catalog helpers and may raise 

217 ``NotImplementedError`` or return empty values when unsupported. 

218 

219 Error contract: implementations must raise only ``ProviderError`` or 

220 ``NotImplementedError`` from any method. ``SdkLLMProvider`` wraps any 

221 other exception at the seam; adapters should translate SDK-specific 

222 errors (httpx errors, third-party SDK exceptions) into 

223 ``ProviderError`` so the provider can pass them through. 

224 """ 

225 

226 @property 

227 def provider_name(self) -> str: 

228 """Stable identifier used when wrapping errors in ``ProviderError``.""" 

229 ... 

230 

231 def active_backend_name(self, base_url: str) -> str: 

232 """Return the display name of the backend the adapter is talking to. 

233 

234 ``"Ollama"`` for an Ollama URL, ``"OpenAI"`` for an OpenAI URL, 

235 etc.; unknown URLs fall back to ``"Remote"``. The adapter's own 

236 identity is exposed separately through ``provider_name``. 

237 """ 

238 ... 

239 

240 def available(self) -> bool: 

241 """Return True when the underlying SDK is importable.""" 

242 ... 

243 

244 def configure_logging(self, *, suppress_debug: bool) -> None: 

245 """Apply backend-level logging toggles (best-effort no-op if unsupported).""" 

246 ... 

247 

248 def complete(self, request: CompletionRequest) -> CompletionResult: 

249 """Run a single-shot chat completion.""" 

250 ... 

251 

252 def complete_stream(self, request: CompletionRequest) -> Iterator[StreamChunk]: 

253 """Run a streaming chat completion, yielding content chunks.""" 

254 ... 

255 

256 def embed(self, request: EmbeddingRequest) -> EmbeddingResult: 

257 """Embed a batch of inputs, returning one vector per input.""" 

258 ... 

259 

260 def rerank(self, request: RerankRequest) -> RerankResult: 

261 """Score *candidates* against *query*, returning one float per candidate. 

262 

263 Raise ``NotImplementedError`` if the backend has no rerank API. 

264 An empty ``request.candidates`` returns ``RerankResult([])`` 

265 without an SDK call. 

266 """ 

267 ... 

268 

269 def list_models(self, *, base_url: str, api_key: str) -> list[str]: 

270 """List model identifiers visible to the backend. Return [] if unsupported.""" 

271 ... 

272 

273 def list_chat_models(self, provider: str) -> list[str]: 

274 """List chat-mode models from the SDK's catalog for *provider*. 

275 

276 Returns the unfiltered upstream catalog. Backends without a 

277 notion of frontier providers return ``[]``. 

278 

279 Unlike ``list_models``, this is a static pricing/capability table, 

280 not a runtime HTTP probe. 

281 """ 

282 ... 

283 

284 def pull_model( 

285 self, 

286 model: str, 

287 *, 

288 base_url: str, 

289 on_progress: Callable[..., Any] | None = None, 

290 ) -> None: 

291 """Pull a model. Raise NotImplementedError if unsupported.""" 

292 ... 

293 

294 def show_model(self, model: str, *, base_url: str) -> dict[str, Any] | None: 

295 """Return model metadata dict or None if unsupported / not found.""" 

296 ... 

297 

298 def supports_tools(self, model_ref: str) -> bool: 

299 """Return True iff the backend can route tool calls for *model_ref*.""" 

300 ...