Coverage for src / lilbee / providers / base.py: 100%
23 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Base protocol and exceptions for LLM providers."""
3from __future__ import annotations
5from collections.abc import Callable, Iterator
6from pathlib import Path
7from typing import Any, Protocol, TypeVar, runtime_checkable
9from pydantic import BaseModel
11T_co = TypeVar("T_co", covariant=True)
14@runtime_checkable
15class ClosableIterator(Iterator[T_co], Protocol[T_co]):
16 """An iterator that releases resources when ``close()`` is called.
18 Streaming chat responses use this to guarantee the upstream model lock
19 is released even when callers truncate the stream before exhaustion.
20 Generators satisfy this implicitly; explicit wrappers (e.g. the llama-cpp
21 chat-lock iterator) implement it directly.
22 """
24 def close(self) -> None: ...
27class LLMOptions(BaseModel):
28 """Validated options passed to LLM providers.
29 Only these fields are forwarded — everything else is rejected
30 to prevent injection of sensitive parameters like api_base or api_key.
31 """
33 temperature: float | None = None
34 top_p: float | None = None
35 top_k: int | None = None
36 seed: int | None = None
37 num_predict: int | None = None
38 repeat_penalty: float | None = None
39 num_ctx: int | None = None
41 def to_dict(self) -> dict[str, Any]:
42 """Return only non-None values as a dict."""
43 return {k: v for k, v in self.model_dump().items() if v is not None}
46def filter_options(options: dict[str, Any]) -> dict[str, Any]:
47 """Validate and filter generation options through LLMOptions model."""
48 return LLMOptions(**options).to_dict()
51class ProviderError(Exception):
52 """Raised when an LLM provider operation fails."""
54 def __init__(self, message: str, *, provider: str = "") -> None:
55 self.provider = provider
56 super().__init__(message)
59ChatMessage = dict[str, str]
62class LLMProvider(Protocol):
63 """Protocol for pluggable LLM backends."""
65 def embed(self, texts: list[str]) -> list[list[float]]:
66 """Embed a batch of texts, return list of vectors."""
67 ...
69 def chat(
70 self,
71 messages: list[ChatMessage],
72 *,
73 stream: bool = False,
74 options: dict[str, Any] | None = None,
75 model: str | None = None,
76 ) -> str | ClosableIterator[str]:
77 """Chat completion. Returns str for non-stream, ClosableIterator[str] for stream."""
78 ...
80 def list_models(self) -> list[str]:
81 """List available model identifiers."""
82 ...
84 def list_chat_models(self, provider: str) -> list[str]:
85 """List frontier chat models the provider is aware of for *provider*.
87 Returns ``[]`` when the backend has no static catalog (for
88 example, native llama-cpp has no notion of external API catalogs).
89 """
90 ...
92 def pull_model(self, model: str, *, on_progress: Callable[..., Any] | None = None) -> None:
93 """Download a model. Raises NotImplementedError if not supported."""
94 ...
96 def show_model(self, model: str) -> dict[str, Any] | None:
97 """Return model metadata, or None if backend doesn't expose it."""
98 ...
100 def get_capabilities(self, model: str) -> list[str]:
101 """Return capability tags (e.g. ``["completion", "vision"]``) for *model*.
103 Returns an empty list when the backend does not support capability
104 reporting or the model is not found.
105 """
106 ...
108 def rerank(self, query: str, candidates: list[str]) -> list[float]:
109 """Score *candidates* for their relevance to *query*, one float per candidate.
111 The backend resolves the reranker model from ``cfg.reranker_model``.
112 Callers MUST check ``cfg.reranker_model`` is non-empty before
113 calling; use :meth:`supports_rerank` for UI-render decisions.
115 Returns: list of floats in input order, higher = more relevant.
116 Empty ``candidates`` returns ``[]``.
117 Raises :class:`ProviderError` when the backend does not support
118 reranking or ``cfg.reranker_model`` is empty.
119 """
120 ...
122 def supports_rerank(self) -> bool:
123 """Capability probe: can this backend rerank *if* a model is configured?
125 Pure capability check, NOT "a reranker is currently active". An
126 empty ``cfg.reranker_model`` returns ``True`` so the settings UI
127 keeps the picker visible; callers that need to know whether
128 reranking is actually configured must check ``bool(cfg.reranker_model)``
129 separately. ``rerank()`` is the gated path that requires a
130 non-empty value.
131 """
132 return False
134 def shutdown(self) -> None:
135 """Release resources (e.g. background threads). No-op if nothing to clean up."""
136 ...
138 def invalidate_load_cache(self, model_path: Path | None = None) -> None:
139 """Drop loaded-model state; ``None`` evicts all, else only that path. No-op default."""
140 return