|
1
|
""" |
|
2
|
LLM backend abstraction — unified provider interface with auto-discovery. |
|
3
|
|
|
4
|
Provides a common interface for multiple LLM providers (Anthropic, OpenAI, |
|
5
|
Ollama). SDK imports are lazy and guarded so that missing optional dependencies |
|
6
|
produce a clear, actionable ImportError rather than a confusing traceback. |
|
7
|
|
|
8
|
Usage:: |
|
9
|
|
|
10
|
from navegador.llm import get_provider, auto_provider, discover_providers |
|
11
|
|
|
12
|
# Explicit provider |
|
13
|
provider = get_provider("anthropic", model="claude-3-5-haiku-20241022") |
|
14
|
response = provider.complete("Explain this function: ...") |
|
15
|
|
|
16
|
# Auto-detect the first available SDK |
|
17
|
provider = auto_provider() |
|
18
|
|
|
19
|
# See what is installed |
|
20
|
available = discover_providers() # e.g. ["anthropic", "openai"] |
|
21
|
""" |
|
22
|
|
|
23
|
from __future__ import annotations |
|
24
|
|
|
25
|
from abc import ABC, abstractmethod |
|
26
|
|
|
27
|
# ── Abstract base ───────────────────────────────────────────────────────────── |
|
28
|
|
|
29
|
|
|
30
|
class LLMProvider(ABC): |
|
31
|
"""Abstract interface that every concrete LLM provider must satisfy.""" |
|
32
|
|
|
33
|
@abstractmethod |
|
34
|
def complete(self, prompt: str, **kwargs) -> str: |
|
35
|
""" |
|
36
|
Send *prompt* to the model and return the completion as a string. |
|
37
|
|
|
38
|
Args: |
|
39
|
prompt: The user/system prompt text. |
|
40
|
**kwargs: Provider-specific options (temperature, max_tokens, …). |
|
41
|
|
|
42
|
Returns: |
|
43
|
The model's text response. |
|
44
|
""" |
|
45
|
|
|
46
|
@abstractmethod |
|
47
|
def embed(self, text: str) -> list[float]: |
|
48
|
""" |
|
49
|
Return an embedding vector for *text*. |
|
50
|
|
|
51
|
Args: |
|
52
|
text: The input string to embed. |
|
53
|
|
|
54
|
Returns: |
|
55
|
A list of floats representing the embedding. |
|
56
|
""" |
|
57
|
|
|
58
|
@property |
|
59
|
@abstractmethod |
|
60
|
def name(self) -> str: |
|
61
|
"""Short identifier for the provider, e.g. ``"anthropic"``.""" |
|
62
|
|
|
63
|
@property |
|
64
|
@abstractmethod |
|
65
|
def model(self) -> str: |
|
66
|
"""Model identifier used for API calls, e.g. ``"claude-3-5-haiku-20241022"``.""" |
|
67
|
|
|
68
|
|
|
69
|
# ── Concrete providers ──────────────────────────────────────────────────────── |
|
70
|
|
|
71
|
|
|
72
|
class AnthropicProvider(LLMProvider): |
|
73
|
""" |
|
74
|
LLM provider backed by the ``anthropic`` Python SDK. |
|
75
|
|
|
76
|
Install:: |
|
77
|
|
|
78
|
pip install anthropic |
|
79
|
|
|
80
|
Args: |
|
81
|
model: Anthropic model ID (default ``"claude-3-5-haiku-20241022"``). |
|
82
|
""" |
|
83
|
|
|
84
|
_DEFAULT_MODEL = "claude-3-5-haiku-20241022" |
|
85
|
|
|
86
|
def __init__(self, model: str = "") -> None: |
|
87
|
try: |
|
88
|
import anthropic # noqa: F401 |
|
89
|
except ImportError as exc: |
|
90
|
raise ImportError( |
|
91
|
"The 'anthropic' package is required to use AnthropicProvider. " |
|
92
|
"Install it with: pip install anthropic" |
|
93
|
) from exc |
|
94
|
|
|
95
|
self._model = model or self._DEFAULT_MODEL |
|
96
|
|
|
97
|
import anthropic |
|
98
|
|
|
99
|
self._client = anthropic.Anthropic() |
|
100
|
|
|
101
|
@property |
|
102
|
def name(self) -> str: |
|
103
|
return "anthropic" |
|
104
|
|
|
105
|
@property |
|
106
|
def model(self) -> str: |
|
107
|
return self._model |
|
108
|
|
|
109
|
def complete(self, prompt: str, **kwargs) -> str: |
|
110
|
"""Call the Anthropic Messages API and return the first text block.""" |
|
111
|
max_tokens = kwargs.pop("max_tokens", 1024) |
|
112
|
message = self._client.messages.create( |
|
113
|
model=self._model, |
|
114
|
max_tokens=max_tokens, |
|
115
|
messages=[{"role": "user", "content": prompt}], |
|
116
|
**kwargs, |
|
117
|
) |
|
118
|
return message.content[0].text |
|
119
|
|
|
120
|
def embed(self, text: str) -> list[float]: |
|
121
|
""" |
|
122
|
Anthropic does not currently expose a public embeddings API. |
|
123
|
|
|
124
|
Raises: |
|
125
|
NotImplementedError: Always. |
|
126
|
""" |
|
127
|
raise NotImplementedError( |
|
128
|
"Anthropic does not provide a public embeddings API. " |
|
129
|
"Use OpenAIProvider or OllamaProvider for embeddings." |
|
130
|
) |
|
131
|
|
|
132
|
|
|
133
|
class OpenAIProvider(LLMProvider): |
|
134
|
""" |
|
135
|
LLM provider backed by the ``openai`` Python SDK. |
|
136
|
|
|
137
|
Install:: |
|
138
|
|
|
139
|
pip install openai |
|
140
|
|
|
141
|
Args: |
|
142
|
model: OpenAI model ID (default ``"gpt-4o-mini"``). |
|
143
|
""" |
|
144
|
|
|
145
|
_DEFAULT_MODEL = "gpt-4o-mini" |
|
146
|
|
|
147
|
def __init__(self, model: str = "") -> None: |
|
148
|
try: |
|
149
|
import openai # noqa: F401 |
|
150
|
except ImportError as exc: |
|
151
|
raise ImportError( |
|
152
|
"The 'openai' package is required to use OpenAIProvider. " |
|
153
|
"Install it with: pip install openai" |
|
154
|
) from exc |
|
155
|
|
|
156
|
self._model = model or self._DEFAULT_MODEL |
|
157
|
|
|
158
|
import openai |
|
159
|
|
|
160
|
self._client = openai.OpenAI() |
|
161
|
|
|
162
|
@property |
|
163
|
def name(self) -> str: |
|
164
|
return "openai" |
|
165
|
|
|
166
|
@property |
|
167
|
def model(self) -> str: |
|
168
|
return self._model |
|
169
|
|
|
170
|
def complete(self, prompt: str, **kwargs) -> str: |
|
171
|
"""Call the OpenAI Chat Completions API and return the assistant message.""" |
|
172
|
response = self._client.chat.completions.create( |
|
173
|
model=self._model, |
|
174
|
messages=[{"role": "user", "content": prompt}], |
|
175
|
**kwargs, |
|
176
|
) |
|
177
|
return response.choices[0].message.content |
|
178
|
|
|
179
|
def embed(self, text: str) -> list[float]: |
|
180
|
"""Call the OpenAI Embeddings API and return the embedding vector.""" |
|
181
|
embed_model = "text-embedding-3-small" |
|
182
|
response = self._client.embeddings.create(input=text, model=embed_model) |
|
183
|
return response.data[0].embedding |
|
184
|
|
|
185
|
|
|
186
|
class OllamaProvider(LLMProvider): |
|
187
|
""" |
|
188
|
LLM provider backed by the ``ollama`` Python SDK (local models via Ollama). |
|
189
|
|
|
190
|
Install:: |
|
191
|
|
|
192
|
pip install ollama |
|
193
|
|
|
194
|
The Ollama server must be running locally (``ollama serve``). |
|
195
|
|
|
196
|
Args: |
|
197
|
model: Ollama model tag (default ``"llama3.2"``). |
|
198
|
""" |
|
199
|
|
|
200
|
_DEFAULT_MODEL = "llama3.2" |
|
201
|
|
|
202
|
def __init__(self, model: str = "") -> None: |
|
203
|
try: |
|
204
|
import ollama # noqa: F401 |
|
205
|
except ImportError as exc: |
|
206
|
raise ImportError( |
|
207
|
"The 'ollama' package is required to use OllamaProvider. " |
|
208
|
"Install it with: pip install ollama" |
|
209
|
) from exc |
|
210
|
|
|
211
|
self._model = model or self._DEFAULT_MODEL |
|
212
|
|
|
213
|
import ollama |
|
214
|
|
|
215
|
self._client = ollama.Client() |
|
216
|
|
|
217
|
@property |
|
218
|
def name(self) -> str: |
|
219
|
return "ollama" |
|
220
|
|
|
221
|
@property |
|
222
|
def model(self) -> str: |
|
223
|
return self._model |
|
224
|
|
|
225
|
def complete(self, prompt: str, **kwargs) -> str: |
|
226
|
"""Call the Ollama chat API and return the assistant message content.""" |
|
227
|
response = self._client.chat( |
|
228
|
model=self._model, |
|
229
|
messages=[{"role": "user", "content": prompt}], |
|
230
|
**kwargs, |
|
231
|
) |
|
232
|
return response["message"]["content"] |
|
233
|
|
|
234
|
def embed(self, text: str) -> list[float]: |
|
235
|
"""Call the Ollama embeddings API and return the embedding vector.""" |
|
236
|
response = self._client.embeddings(model=self._model, prompt=text) |
|
237
|
return response["embedding"] |
|
238
|
|
|
239
|
|
|
240
|
# ── Discovery & factory ─────────────────────────────────────────────────────── |
|
241
|
|
|
242
|
# Ordered list of known providers — also defines auto_provider priority. |
|
243
|
_PROVIDER_NAMES: list[str] = ["anthropic", "openai", "ollama"] |
|
244
|
|
|
245
|
_PROVIDER_SDK_MAP: dict[str, str] = { |
|
246
|
"anthropic": "anthropic", |
|
247
|
"openai": "openai", |
|
248
|
"ollama": "ollama", |
|
249
|
} |
|
250
|
|
|
251
|
_PROVIDER_CLASS_MAP: dict[str, type[LLMProvider]] = { |
|
252
|
"anthropic": AnthropicProvider, |
|
253
|
"openai": OpenAIProvider, |
|
254
|
"ollama": OllamaProvider, |
|
255
|
} |
|
256
|
|
|
257
|
|
|
258
|
def discover_providers() -> list[str]: |
|
259
|
""" |
|
260
|
Return a list of provider names whose SDKs are currently importable. |
|
261
|
|
|
262
|
The list preserves the canonical priority order: |
|
263
|
``["anthropic", "openai", "ollama"]``. |
|
264
|
|
|
265
|
Returns: |
|
266
|
List of available provider name strings. |
|
267
|
""" |
|
268
|
available: list[str] = [] |
|
269
|
for provider_name in _PROVIDER_NAMES: |
|
270
|
sdk_name = _PROVIDER_SDK_MAP[provider_name] |
|
271
|
try: |
|
272
|
__import__(sdk_name) |
|
273
|
available.append(provider_name) |
|
274
|
except ImportError: |
|
275
|
pass |
|
276
|
return available |
|
277
|
|
|
278
|
|
|
279
|
def get_provider(name: str, model: str = "") -> LLMProvider: |
|
280
|
""" |
|
281
|
Instantiate and return the named LLM provider. |
|
282
|
|
|
283
|
Args: |
|
284
|
name: One of ``"anthropic"``, ``"openai"``, or ``"ollama"``. |
|
285
|
model: Optional model ID to pass to the provider constructor. |
|
286
|
Falls back to each provider's built-in default. |
|
287
|
|
|
288
|
Returns: |
|
289
|
An :class:`LLMProvider` instance. |
|
290
|
|
|
291
|
Raises: |
|
292
|
ValueError: If *name* does not correspond to a known provider. |
|
293
|
ImportError: If the underlying SDK is not installed. |
|
294
|
""" |
|
295
|
if name not in _PROVIDER_CLASS_MAP: |
|
296
|
raise ValueError( |
|
297
|
f"Unknown LLM provider: {name!r}. Valid options are: {sorted(_PROVIDER_CLASS_MAP)}" |
|
298
|
) |
|
299
|
cls = _PROVIDER_CLASS_MAP[name] |
|
300
|
return cls(model=model) |
|
301
|
|
|
302
|
|
|
303
|
def auto_provider(model: str = "") -> LLMProvider: |
|
304
|
""" |
|
305
|
Return the first available LLM provider based on installed SDKs. |
|
306
|
|
|
307
|
Priority order: anthropic → openai → ollama. |
|
308
|
|
|
309
|
Args: |
|
310
|
model: Optional model ID forwarded to the provider constructor. |
|
311
|
|
|
312
|
Returns: |
|
313
|
An :class:`LLMProvider` instance for the first available SDK. |
|
314
|
|
|
315
|
Raises: |
|
316
|
RuntimeError: If no supported LLM SDK is installed. |
|
317
|
""" |
|
318
|
for provider_name in _PROVIDER_NAMES: |
|
319
|
sdk_name = _PROVIDER_SDK_MAP[provider_name] |
|
320
|
try: |
|
321
|
__import__(sdk_name) |
|
322
|
except ImportError: |
|
323
|
continue |
|
324
|
return get_provider(provider_name, model=model) |
|
325
|
|
|
326
|
raise RuntimeError( |
|
327
|
"No LLM SDK is installed. Install at least one of: " |
|
328
|
"anthropic, openai, ollama.\n" |
|
329
|
" pip install anthropic # Anthropic Claude\n" |
|
330
|
" pip install openai # OpenAI GPT\n" |
|
331
|
" pip install ollama # Ollama (local models)" |
|
332
|
) |
|
333
|
|