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

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 

14from collections.abc import Callable, Iterator 

15from dataclasses import dataclass, field 

16from typing import TYPE_CHECKING, Any, Protocol 

17 

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 

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# 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) 

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 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} 

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

57 SDK's own env var is honored by :mod:`lilbee.providers.key_check`. 

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 

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) 

80 

81 

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

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

84 

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 

97 

98 

99@dataclass(frozen=True) 

100class SdkToolCall: 

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

102 

103 id: str 

104 name: str 

105 arguments: str 

106 

107 

108@dataclass(frozen=True) 

109class SdkToolCallDelta: 

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

111 

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 """ 

116 

117 index: int 

118 id: str | None = None 

119 name: str | None = None 

120 arguments_delta: str | None = None 

121 

122 

123@dataclass(frozen=True) 

124class CompletionResult: 

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

126 

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) 

132 

133 

134@dataclass(frozen=True) 

135class StreamChunk: 

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

137 

138 content: str 

139 finish_reason: str | None = None 

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

141 usage: TokenUsage | None = None 

142 

143 

144@dataclass(frozen=True) 

145class EmbeddingResult: 

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

147 

148 vectors: list[list[float]] 

149 model: str | None = None 

150 

151 

152@dataclass(frozen=True) 

153class CompletionRequest: 

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

155 

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 """ 

162 

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 

168 

169 

170@dataclass(frozen=True) 

171class EmbeddingRequest: 

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

173 

174 ref: ProviderModelRef 

175 inputs: list[str] 

176 api_base: str | None = None 

177 api_key: str | None = None 

178 

179 

180@dataclass(frozen=True) 

181class RerankRequest: 

182 """Backend-agnostic rerank request.""" 

183 

184 ref: ProviderModelRef 

185 query: str 

186 candidates: list[str] 

187 api_base: str | None = None 

188 api_key: str | None = None 

189 

190 

191@dataclass(frozen=True) 

192class RerankResult: 

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

194 

195 scores: list[float] 

196 model: str | None = None 

197 

198 

199class LlmSdkBackend(Protocol): 

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

201 

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. 

205 

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. 

212 

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 """ 

219 

220 @property 

221 def provider_name(self) -> str: 

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

223 ... 

224 

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

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

227 

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 ... 

233 

234 def available(self) -> bool: 

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

236 ... 

237 

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

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

240 ... 

241 

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

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

244 ... 

245 

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

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

248 ... 

249 

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

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

252 ... 

253 

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

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

256 

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

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

259 without an SDK call. 

260 """ 

261 ... 

262 

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*. 

265 

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 ... 

270 

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

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

273 

274 Returns the unfiltered upstream catalog. Backends without a 

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

276 

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

278 not a runtime HTTP probe. 

279 """ 

280 ... 

281 

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 ... 

291 

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 ... 

295 

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

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

298 ...