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

174 statements  

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

1"""Routing provider: prefix-based dispatch between the SDK backend and the local engine.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7import threading 

8from collections.abc import Callable 

9from pathlib import Path 

10from typing import TYPE_CHECKING, Any, Literal, overload 

11 

12from lilbee.app.services import get_services 

13from lilbee.catalog.refs import is_bare_hf_repo 

14from lilbee.core.config import cfg 

15from lilbee.core.vectors import Vector 

16from lilbee.providers.base import ( 

17 ChatResult, 

18 ChatStreamItem, 

19 ChatToolResult, 

20 ClosableIterator, 

21 LLMProvider, 

22 ProviderError, 

23 require_role_ref, 

24) 

25from lilbee.providers.litellm_sdk import LitellmSdkBackend 

26from lilbee.providers.model_ref import ProviderModelRef, parse_model_ref, routes_to_native_gguf 

27from lilbee.providers.roles import ROLE_REGISTRY, WorkerRole 

28from lilbee.providers.sdk_llm_provider import SdkLLMProvider 

29 

30if TYPE_CHECKING: 

31 from lilbee.providers.warm_progress import WarmProgress 

32 

33log = logging.getLogger(__name__) 

34 

35 

36class RoutingProvider(LLMProvider): 

37 """Dispatches calls based on the model ref prefix. 

38 

39 ``ollama/``, ``openai/``, ``anthropic/``, ``gemini/`` go to the SDK 

40 provider. Other refs (the HuggingFace ``<org>/<repo>/<file>.gguf`` 

41 shape) go to the local llama-server engine, which resolves them against the native 

42 registry. A registry miss surfaces the native ProviderError 

43 unchanged, rather than silently falling through to a remote backend. 

44 """ 

45 

46 def __init__(self, *, hold_warm: bool = False) -> None: 

47 self._local: LLMProvider | None = None 

48 self._sdk_provider: SdkLLMProvider | None = None 

49 # Carried until the local fleet is lazily built, so a provider created for 

50 # an interactive session hands that down to the FleetProvider it composes. 

51 self._hold_warm = hold_warm 

52 # Guards both lazy inits so two concurrent first-callers on the shared 

53 # daemon don't each build a backend and leak the loser. (Construction is 

54 # cheap field init; FleetProvider defers the role-server spawn to first 

55 # use and single-flights it internally, so this lock is the singleton 

56 # guard, not the spawn guard.) 

57 self._init_lock = threading.Lock() 

58 

59 def _get_local(self) -> LLMProvider: 

60 if self._local is None: 

61 # FleetProvider composes the llama-server stack; its role servers 

62 # spawn lazily on first use, not here. Double-checked under 

63 # _init_lock so only the first concurrent caller builds it. 

64 with self._init_lock: 

65 if self._local is None: 

66 from lilbee.providers.fleet.provider import FleetProvider 

67 

68 self._local = FleetProvider(hold_warm=self._hold_warm) 

69 return self._local 

70 

71 def _get_sdk_provider(self) -> SdkLLMProvider: 

72 if self._sdk_provider is None: 

73 with self._init_lock: 

74 if self._sdk_provider is None: 

75 self._sdk_provider = SdkLLMProvider( 

76 LitellmSdkBackend(), 

77 api_key=cfg.llm_api_key, 

78 ) 

79 return self._sdk_provider 

80 

81 def _pick_backend(self, ref: ProviderModelRef) -> LLMProvider: 

82 """Pick the backend for *ref* purely by prefix.""" 

83 if ref.is_remote: 

84 return self._get_sdk_provider() 

85 return self._get_local() 

86 

87 def embed(self, texts: list[str]) -> list[Vector]: 

88 ref = parse_model_ref(require_role_ref(cfg.embedding_model, WorkerRole.EMBED)) 

89 return self._pick_backend(ref).embed(texts) 

90 

91 def count_tokens(self, text: str) -> int: 

92 ref = parse_model_ref(require_role_ref(cfg.embedding_model, WorkerRole.EMBED)) 

93 return self._pick_backend(ref).count_tokens(text) 

94 

95 @overload 

96 def chat( 

97 self, 

98 messages: list[dict[str, str]], 

99 *, 

100 stream: Literal[False] = False, 

101 options: dict[str, Any] | None = None, 

102 model: str | None = None, 

103 tools: list[dict[str, Any]] | None = None, 

104 tool_choice: str | dict[str, Any] | None = None, 

105 ) -> ChatResult: ... 

106 

107 @overload 

108 def chat( 

109 self, 

110 messages: list[dict[str, str]], 

111 *, 

112 stream: Literal[True], 

113 options: dict[str, Any] | None = None, 

114 model: str | None = None, 

115 tools: list[dict[str, Any]] | None = None, 

116 tool_choice: str | dict[str, Any] | None = None, 

117 ) -> ClosableIterator[ChatStreamItem]: ... 

118 

119 def chat( 

120 self, 

121 messages: list[dict[str, str]], 

122 *, 

123 stream: bool = False, 

124 options: dict[str, Any] | None = None, 

125 model: str | None = None, 

126 tools: list[dict[str, Any]] | None = None, 

127 tool_choice: str | dict[str, Any] | None = None, 

128 ) -> ChatResult | ClosableIterator[ChatStreamItem]: 

129 ref = parse_model_ref(require_role_ref(model or cfg.chat_model, WorkerRole.CHAT)) 

130 backend = self._pick_backend(ref) 

131 # Split on stream so each call resolves to a specific overload; the 

132 # base impl signature accepts bool but the @overloads on the LLMProvider 

133 # Protocol require Literal narrowing at the boundary. 

134 if stream: 

135 return backend.chat( 

136 messages, 

137 stream=True, 

138 options=options, 

139 model=model, 

140 tools=tools, 

141 tool_choice=tool_choice, 

142 ) 

143 return backend.chat( 

144 messages, 

145 stream=False, 

146 options=options, 

147 model=model, 

148 tools=tools, 

149 tool_choice=tool_choice, 

150 ) 

151 

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

153 """Delegate the tool-capability probe to the backend the ref routes to.""" 

154 resolved = model_ref or cfg.chat_model 

155 if not resolved: 

156 return False # no model configured: nothing to advertise tools 

157 return self._pick_backend(parse_model_ref(resolved)).supports_tools(resolved) 

158 

159 def chat_with_tools( 

160 self, 

161 messages: list[dict[str, str]], 

162 *, 

163 tools: list[dict[str, Any]], 

164 tool_choice: str | dict[str, Any] | None = None, 

165 options: dict[str, Any] | None = None, 

166 model: str | None = None, 

167 ) -> ChatToolResult: 

168 """Dispatch a tool-enabled chat turn to the backend the ref routes to.""" 

169 ref = parse_model_ref(require_role_ref(model or cfg.chat_model, WorkerRole.CHAT)) 

170 backend = self._pick_backend(ref) 

171 return backend.chat_with_tools( 

172 messages, tools=tools, tool_choice=tool_choice, options=options, model=model 

173 ) 

174 

175 def vision_ocr( 

176 self, 

177 png_bytes: bytes, 

178 model: str, 

179 prompt: str = "", 

180 *, 

181 timeout: float | None = None, 

182 ) -> str: 

183 """Dispatch by ``model``'s ref prefix, same rules as :meth:`chat`.""" 

184 ref = parse_model_ref(model) 

185 return self._pick_backend(ref).vision_ocr(png_bytes, model, prompt, timeout=timeout) 

186 

187 def vision_slot_capacity(self) -> int | None: 

188 """Delegate to the local fleet, but never build it just to size the fan-out.""" 

189 return self._local.vision_slot_capacity() if self._local is not None else None 

190 

191 def list_models(self) -> list[str]: 

192 """Return the union of native and SDK-visible models. 

193 

194 Both halves are wrapped so an unreachable remote backend or a 

195 missing native registry does not mask the other. 

196 """ 

197 native: set[str] = set() 

198 with contextlib.suppress(Exception): 

199 native = set(self._get_local().list_models()) 

200 sdk = self._get_sdk_provider() 

201 if not sdk.available(): 

202 return sorted(native) 

203 try: 

204 remote = set(sdk.list_models()) 

205 except Exception: 

206 return sorted(native) 

207 return sorted(native | remote) 

208 

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

210 """Delegate to the SDK backend; the native engine has no catalog.""" 

211 sdk = self._get_sdk_provider() 

212 if not sdk.available(): 

213 return [] 

214 return sdk.list_chat_models(provider) 

215 

216 def pull_model(self, model: str, *, on_progress: Callable[..., Any] | None = None) -> None: 

217 """Pull via the SDK backend if installed, otherwise raise.""" 

218 sdk = self._get_sdk_provider() 

219 if not sdk.available(): 

220 raise ProviderError(f"Cannot pull model {model!r}: no pull-capable backend available") 

221 sdk.pull_model(model, on_progress=on_progress) 

222 

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

224 """Show model info from the backend selected by the ref prefix.""" 

225 ref = parse_model_ref(model) 

226 return self._pick_backend(ref).show_model(model) 

227 

228 def get_capabilities(self, model: str) -> list[str]: 

229 """Return capability tags from the backend selected by the ref prefix.""" 

230 ref = parse_model_ref(model) 

231 return self._pick_backend(ref).get_capabilities(model) 

232 

233 def rerank(self, query: str, candidates: list[str]) -> list[float]: 

234 """Dispatch rerank to the backend that owns ``cfg.reranker_model``. 

235 

236 Native GGUF refs go to the local engine; hosted refs go through the SDK 

237 provider. Raises ``ProviderError`` when ``cfg.reranker_model`` is 

238 empty or the selected backend does not support reranking. 

239 """ 

240 if not cfg.reranker_model: 

241 raise ProviderError("No reranker configured. Set cfg.reranker_model first.") 

242 if _is_native_rerank_ref(cfg.reranker_model): 

243 return self._get_local().rerank(query, candidates) 

244 sdk = self._get_sdk_provider() 

245 if not sdk.supports_rerank(): 

246 raise ProviderError( 

247 f"Cannot rerank with {cfg.reranker_model!r}: " 

248 "hosted rerank backend not available. " 

249 "Install the 'litellm' extra to enable hosted reranking." 

250 ) 

251 return sdk.rerank(query, candidates) 

252 

253 def supports_rerank(self) -> bool: 

254 """Capability probe: can the routed backend rerank if configured? 

255 

256 Pure capability check, NOT "a reranker is currently active". An 

257 empty ``cfg.reranker_model`` returns ``True`` so the settings UI 

258 keeps the picker visible; callers that need to know whether 

259 reranking is actually configured must check ``bool(cfg.reranker_model)`` 

260 separately. Delegates to the backend that would handle the 

261 configured model when one is set. 

262 """ 

263 model = cfg.reranker_model 

264 if not model: 

265 return True 

266 if _is_native_rerank_ref(model): 

267 return self._get_local().supports_rerank() 

268 return self._get_sdk_provider().supports_rerank() 

269 

270 def shutdown(self) -> None: 

271 """Shut down sub-providers to release resources.""" 

272 if self._local is not None: 

273 self._local.shutdown() 

274 if self._sdk_provider is not None: 

275 self._sdk_provider.shutdown() 

276 

277 def invalidate_load_cache(self, model_path: Path | None = None) -> None: 

278 """Forward to the native side only; the SDK side has no local cache.""" 

279 if self._local is not None: 

280 self._local.invalidate_load_cache(model_path) 

281 

282 def drop_loaded_models_async(self) -> None: 

283 """Forward the off-thread fleet drop to the native side; SDK has no cache.""" 

284 if self._local is not None: 

285 self._local.drop_loaded_models_async() 

286 

287 def warm_up_pool(self) -> None: 

288 """Forward to the native side; the SDK side has no servers to warm. 

289 

290 Lazily constructs the local engine if it isn't already up so 

291 eager-start during ``Services`` boot still warms the configured 

292 native roles, even when the user hasn't issued a chat call yet. 

293 """ 

294 self._get_local().warm_up_pool() 

295 

296 def cancel_inference(self) -> None: 

297 """Forward to the native engine; the SDK side has nothing to interrupt.""" 

298 if self._local is not None: 

299 self._local.cancel_inference() 

300 

301 def reload_role(self, role: WorkerRole, *, wait: bool = False) -> None: 

302 """Forward to the native engine; the SDK side has no per-role servers.""" 

303 if self._local is not None: 

304 self._local.reload_role(role, wait=wait) 

305 

306 def reload_placement(self, *, wait: bool = False) -> None: 

307 """Forward to the native engine; the SDK side has no GPU placement.""" 

308 if self._local is not None: 

309 self._local.reload_placement(wait=wait) 

310 

311 def role_ready(self, role: WorkerRole) -> bool: 

312 """Whether *role* can serve a request right now. 

313 

314 A role whose configured ref routes to the SDK backend needs no local 

315 server, so it is always ready; the local fleet's readiness is 

316 irrelevant to it. A native ref with no local engine built yet cannot 

317 serve a token, so it reports not-ready (without building the engine); 

318 health's ``chat_ready`` and the cold-start waits all rely on this 

319 being positive readiness, not reachability. 

320 """ 

321 if self._role_routes_remote(role): 

322 return True 

323 if self._local is None: 

324 return False 

325 return self._local.role_ready(role) 

326 

327 @staticmethod 

328 def _role_routes_remote(role: WorkerRole) -> bool: 

329 """Whether *role*'s configured model ref dispatches to the SDK backend.""" 

330 ref = str(getattr(cfg, ROLE_REGISTRY[role].config_field)) 

331 return bool(ref) and parse_model_ref(ref).is_remote 

332 

333 def max_concurrent_chats(self) -> int: 

334 """Chat concurrency of the local engine; 1 until one exists.""" 

335 if self._local is None: 

336 return 1 

337 return self._local.max_concurrent_chats() 

338 

339 def served_chat_ctx(self) -> int | None: 

340 """Per-slot chat context of the local engine, or None when none exists.""" 

341 if self._local is None: 

342 return None 

343 return self._local.served_chat_ctx() 

344 

345 def warm_pending(self) -> bool: 

346 """Forward to the native side; the SDK side never warms.""" 

347 return self._get_local().warm_pending() 

348 

349 def warm_progress(self) -> WarmProgress | None: 

350 """Cold-load progress of the local engine, or None when none exists yet.""" 

351 if self._local is None: 

352 return None 

353 return self._local.warm_progress() 

354 

355 def add_spawn_listener( 

356 self, 

357 *, 

358 on_spawning: Callable[[WorkerRole], None] | None = None, 

359 on_spawned: Callable[[WorkerRole], None] | None = None, 

360 ) -> None: 

361 """Register on the native engine so its server spawns reach the TUI. 

362 

363 Builds the local engine if it isn't up yet so the listener is attached 

364 before the first spawn, matching ``warm_up_pool``'s eager construction. 

365 """ 

366 self._get_local().add_spawn_listener(on_spawning=on_spawning, on_spawned=on_spawned) 

367 

368 

369def _is_native_rerank_ref(model: str) -> bool: 

370 """Return True iff *model* should route to the native llama-server rerank path. 

371 

372 Two acceptance paths: 

373 

374 1. The ref has the native HuggingFace GGUF shape 

375 ``<org>/<repo>/<filename>.gguf`` (two slashes, ``.gguf`` suffix) and is 

376 not claimed by a local-server prefix (``ollama/``, ``lm_studio/``), 

377 matching :func:`parse_model_ref`'s exemption. 

378 2. The bare ``<org>/<repo>`` names a repo with an installed quant. 

379 

380 The model's name is deliberately not consulted. Hosted rerankers are 

381 usually called rerankers too (``cohere/rerank-english-v3.0``), so matching 

382 on the name captures them and starves the SDK backend. The registry answers 

383 "is this one of ours" without guessing. Non-GGUF refs without a known SDK 

384 prefix still raise downstream through :func:`parse_model_ref`. 

385 """ 

386 if not model: 

387 return False 

388 if routes_to_native_gguf(model): 

389 return True 

390 if not is_bare_hf_repo(model): 

391 return False 

392 try: 

393 return get_services().registry.installed_ref_for_repo(model) is not None 

394 except Exception: 

395 # An unreadable registry must not silently reroute reranking to a 

396 # hosted backend the user never configured. 

397 log.warning("Could not check the registry for %s; treating as hosted", model, exc_info=True) 

398 return False