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

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.local_servers import detect_local_server 

22 

23if TYPE_CHECKING: 

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

25 from lilbee.providers.model_ref import ProviderModelRef 

26 

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) 

40 

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} 

43 

44 

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} 

47 

48 

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

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

51 

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 

59 

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 

65 

66 

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) 

79 

80 

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

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

83 

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 

96 

97 

98@dataclass(frozen=True) 

99class SdkToolCall: 

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

101 

102 id: str 

103 name: str 

104 arguments: str 

105 

106 

107@dataclass(frozen=True) 

108class SdkToolCallDelta: 

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

110 

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

115 

116 index: int 

117 id: str | None = None 

118 name: str | None = None 

119 arguments_delta: str | None = None 

120 

121 

122@dataclass(frozen=True) 

123class CompletionResult: 

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

125 

126 content: str 

127 finish_reason: str | None = None 

128 model: str | None = None 

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

130 

131 

132@dataclass(frozen=True) 

133class StreamChunk: 

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

135 

136 content: str 

137 finish_reason: str | None = None 

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

139 

140 

141@dataclass(frozen=True) 

142class EmbeddingResult: 

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

144 

145 vectors: list[list[float]] 

146 model: str | None = None 

147 

148 

149@dataclass(frozen=True) 

150class CompletionRequest: 

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

152 

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

159 

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 

165 

166 

167@dataclass(frozen=True) 

168class EmbeddingRequest: 

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

170 

171 ref: ProviderModelRef 

172 inputs: list[str] 

173 api_base: str | None = None 

174 api_key: str | None = None 

175 

176 

177@dataclass(frozen=True) 

178class RerankRequest: 

179 """Backend-agnostic rerank request.""" 

180 

181 ref: ProviderModelRef 

182 query: str 

183 candidates: list[str] 

184 api_base: str | None = None 

185 api_key: str | None = None 

186 

187 

188@dataclass(frozen=True) 

189class RerankResult: 

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

191 

192 scores: list[float] 

193 model: str | None = None 

194 

195 

196class LlmSdkBackend(Protocol): 

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

198 

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. 

202 

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. 

209 

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

216 

217 @property 

218 def provider_name(self) -> str: 

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

220 ... 

221 

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

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

224 

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

230 

231 def available(self) -> bool: 

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

233 ... 

234 

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

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

237 ... 

238 

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

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

241 ... 

242 

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

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

245 ... 

246 

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

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

249 ... 

250 

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

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

253 

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

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

256 without an SDK call. 

257 """ 

258 ... 

259 

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

263 

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

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

266 

267 Returns the unfiltered upstream catalog. Backends without a 

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

269 

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

271 not a runtime HTTP probe. 

272 """ 

273 ... 

274 

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

284 

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

288 

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

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

291 ...