opspilot-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ """Multi-provider LLM system with automatic fallback.
2
+
3
+ Supports:
4
+ - Ollama (local, free)
5
+ - OpenAI (cloud, paid)
6
+ - Anthropic Claude (cloud, paid)
7
+ - Google Gemini (cloud, free tier)
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import shutil
14
+ from pathlib import Path
15
+ from typing import Dict, Optional, List
16
+ import requests
17
+
18
+
19
+ class LLMProvider:
20
+ """Base class for LLM providers."""
21
+
22
+ def __init__(self, timeout: int = 60):
23
+ self.timeout = timeout
24
+
25
+ def is_available(self) -> bool:
26
+ """Check if provider is available."""
27
+ raise NotImplementedError
28
+
29
+ def call(self, prompt: str) -> str:
30
+ """Call the LLM with a prompt."""
31
+ raise NotImplementedError
32
+
33
+ def parse_json(self, raw: str) -> Dict:
34
+ """Parse JSON from LLM output."""
35
+ # Try parsing as-is first
36
+ try:
37
+ return json.loads(raw)
38
+ except json.JSONDecodeError:
39
+ pass
40
+
41
+ # Try extracting JSON from text
42
+ start_idx = raw.find('{')
43
+ end_idx = raw.rfind('}')
44
+
45
+ if start_idx != -1 and end_idx != -1 and start_idx < end_idx:
46
+ json_str = raw[start_idx:end_idx + 1]
47
+ try:
48
+ return json.loads(json_str)
49
+ except json.JSONDecodeError:
50
+ pass
51
+
52
+ raise json.JSONDecodeError(
53
+ f"Could not parse LLM output as JSON",
54
+ raw[:100],
55
+ 0
56
+ )
57
+
58
+
59
+ class OllamaProvider(LLMProvider):
60
+ """Ollama local LLM provider."""
61
+
62
+ def __init__(self, model: str = "llama3", timeout: int = 60):
63
+ super().__init__(timeout)
64
+ self.model = model
65
+
66
+ def _resolve_ollama_path(self) -> Optional[str]:
67
+ """Resolve Ollama binary path."""
68
+ ollama_path = shutil.which("ollama")
69
+ if ollama_path:
70
+ return ollama_path
71
+
72
+ # Windows fallback
73
+ fallback = Path.home() / "AppData/Local/Programs/Ollama/ollama.exe"
74
+ if fallback.exists():
75
+ return str(fallback)
76
+
77
+ return None
78
+
79
+ def is_available(self) -> bool:
80
+ """Check if Ollama is available."""
81
+ try:
82
+ ollama_path = self._resolve_ollama_path()
83
+ if not ollama_path:
84
+ return False
85
+
86
+ result = subprocess.run(
87
+ [ollama_path, "list"],
88
+ capture_output=True,
89
+ timeout=5,
90
+ check=True
91
+ )
92
+ return result.returncode == 0
93
+ except Exception:
94
+ return False
95
+
96
+ def call(self, prompt: str) -> str:
97
+ """Call Ollama LLM."""
98
+ ollama_path = self._resolve_ollama_path()
99
+ if not ollama_path:
100
+ raise RuntimeError("Ollama not found")
101
+
102
+ try:
103
+ process = subprocess.run(
104
+ [ollama_path, "run", self.model],
105
+ input=prompt,
106
+ capture_output=True,
107
+ text=True,
108
+ encoding="utf-8",
109
+ timeout=self.timeout
110
+ )
111
+
112
+ if process.returncode != 0:
113
+ raise RuntimeError(f"Ollama failed: {process.stderr.strip()}")
114
+
115
+ return process.stdout.strip()
116
+
117
+ except subprocess.TimeoutExpired:
118
+ raise RuntimeError(f"Ollama inference timed out after {self.timeout}s")
119
+
120
+
121
+ class OpenRouterProvider(LLMProvider):
122
+ """OpenRouter provider - access to many free open-source models."""
123
+
124
+ def __init__(self, model: str = "google/gemini-2.0-flash-exp:free", timeout: int = 60):
125
+ super().__init__(timeout)
126
+ self.model = model
127
+ self.api_key = os.getenv("OPENROUTER_API_KEY") # Free tier available
128
+ self.base_url = "https://openrouter.ai/api/v1/chat/completions"
129
+
130
+ def is_available(self) -> bool:
131
+ """Check if OpenRouter API key is configured."""
132
+ return self.api_key is not None
133
+
134
+ def call(self, prompt: str) -> str:
135
+ """Call OpenRouter API with free models."""
136
+ if not self.api_key:
137
+ raise RuntimeError("OPENROUTER_API_KEY environment variable not set")
138
+
139
+ headers = {
140
+ "Authorization": f"Bearer {self.api_key}",
141
+ "Content-Type": "application/json",
142
+ "HTTP-Referer": "https://github.com/opspilot", # Required by OpenRouter
143
+ "X-Title": "OpsPilot"
144
+ }
145
+
146
+ payload = {
147
+ "model": self.model,
148
+ "messages": [
149
+ {"role": "user", "content": prompt}
150
+ ],
151
+ "temperature": 0.3,
152
+ "max_tokens": 2000
153
+ }
154
+
155
+ try:
156
+ response = requests.post(
157
+ self.base_url,
158
+ headers=headers,
159
+ json=payload,
160
+ timeout=self.timeout
161
+ )
162
+ response.raise_for_status()
163
+
164
+ data = response.json()
165
+ return data["choices"][0]["message"]["content"]
166
+
167
+ except requests.RequestException as e:
168
+ raise RuntimeError(f"OpenRouter API call failed: {e}")
169
+
170
+
171
+ class HuggingFaceProvider(LLMProvider):
172
+ """HuggingFace Inference API provider - free tier for open models."""
173
+
174
+ def __init__(self, model: str = "mistralai/Mistral-7B-Instruct-v0.2", timeout: int = 60):
175
+ super().__init__(timeout)
176
+ self.model = model
177
+ self.api_key = os.getenv("HUGGINGFACE_API_KEY") # Free tier available
178
+ self.base_url = f"https://api-inference.huggingface.co/models/{self.model}"
179
+
180
+ def is_available(self) -> bool:
181
+ """Check if HuggingFace API key is configured."""
182
+ return self.api_key is not None
183
+
184
+ def call(self, prompt: str) -> str:
185
+ """Call HuggingFace Inference API."""
186
+ if not self.api_key:
187
+ raise RuntimeError("HUGGINGFACE_API_KEY environment variable not set")
188
+
189
+ headers = {
190
+ "Authorization": f"Bearer {self.api_key}",
191
+ "Content-Type": "application/json"
192
+ }
193
+
194
+ payload = {
195
+ "inputs": prompt,
196
+ "parameters": {
197
+ "temperature": 0.3,
198
+ "max_new_tokens": 2000,
199
+ "return_full_text": False
200
+ }
201
+ }
202
+
203
+ try:
204
+ response = requests.post(
205
+ self.base_url,
206
+ headers=headers,
207
+ json=payload,
208
+ timeout=self.timeout
209
+ )
210
+ response.raise_for_status()
211
+
212
+ data = response.json()
213
+ # HuggingFace returns different formats
214
+ if isinstance(data, list):
215
+ return data[0]["generated_text"]
216
+ elif isinstance(data, dict) and "generated_text" in data:
217
+ return data["generated_text"]
218
+ else:
219
+ return str(data)
220
+
221
+ except requests.RequestException as e:
222
+ raise RuntimeError(f"HuggingFace API call failed: {e}")
223
+
224
+
225
+ class AnthropicProvider(LLMProvider):
226
+ """Anthropic Claude provider."""
227
+
228
+ def __init__(self, model: str = "claude-3-5-haiku-20241022", timeout: int = 60):
229
+ super().__init__(timeout)
230
+ self.model = model
231
+ self.api_key = os.getenv("ANTHROPIC_API_KEY")
232
+ self.base_url = "https://api.anthropic.com/v1/messages"
233
+
234
+ def is_available(self) -> bool:
235
+ """Check if Anthropic API key is configured."""
236
+ return self.api_key is not None
237
+
238
+ def call(self, prompt: str) -> str:
239
+ """Call Anthropic API."""
240
+ if not self.api_key:
241
+ raise RuntimeError("ANTHROPIC_API_KEY environment variable not set")
242
+
243
+ headers = {
244
+ "x-api-key": self.api_key,
245
+ "anthropic-version": "2023-06-01",
246
+ "Content-Type": "application/json"
247
+ }
248
+
249
+ payload = {
250
+ "model": self.model,
251
+ "max_tokens": 2000,
252
+ "messages": [
253
+ {"role": "user", "content": prompt}
254
+ ]
255
+ }
256
+
257
+ try:
258
+ response = requests.post(
259
+ self.base_url,
260
+ headers=headers,
261
+ json=payload,
262
+ timeout=self.timeout
263
+ )
264
+ response.raise_for_status()
265
+
266
+ data = response.json()
267
+ return data["content"][0]["text"]
268
+
269
+ except requests.RequestException as e:
270
+ raise RuntimeError(f"Anthropic API call failed: {e}")
271
+
272
+
273
+ class GeminiProvider(LLMProvider):
274
+ """Google Gemini provider."""
275
+
276
+ def __init__(self, model: str = "gemini-2.0-flash-exp", timeout: int = 60):
277
+ super().__init__(timeout)
278
+ self.model = model
279
+ self.api_key = os.getenv("GOOGLE_API_KEY")
280
+
281
+ def is_available(self) -> bool:
282
+ """Check if Google API key is configured."""
283
+ return self.api_key is not None
284
+
285
+ def call(self, prompt: str) -> str:
286
+ """Call Google Gemini API."""
287
+ if not self.api_key:
288
+ raise RuntimeError("GOOGLE_API_KEY environment variable not set")
289
+
290
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}"
291
+
292
+ payload = {
293
+ "contents": [{
294
+ "parts": [{
295
+ "text": prompt
296
+ }]
297
+ }],
298
+ "generationConfig": {
299
+ "temperature": 0.3,
300
+ "maxOutputTokens": 2000
301
+ }
302
+ }
303
+
304
+ try:
305
+ response = requests.post(
306
+ url,
307
+ json=payload,
308
+ timeout=self.timeout
309
+ )
310
+ response.raise_for_status()
311
+
312
+ data = response.json()
313
+ return data["candidates"][0]["content"]["parts"][0]["text"]
314
+
315
+ except requests.RequestException as e:
316
+ raise RuntimeError(f"Google Gemini API call failed: {e}")
317
+
318
+
319
+ class LLMRouter:
320
+ """Smart LLM router with automatic fallback."""
321
+
322
+ def __init__(self, prefer_local: bool = True):
323
+ """
324
+ Initialize LLM router.
325
+
326
+ Args:
327
+ prefer_local: If True, try Ollama first (free, local)
328
+ """
329
+ self.prefer_local = prefer_local
330
+ self.providers = self._initialize_providers()
331
+ self.last_successful_provider = None
332
+
333
+ def _initialize_providers(self) -> List[LLMProvider]:
334
+ """Initialize providers in priority order (all free/open-source)."""
335
+ if self.prefer_local:
336
+ # Local first, then free cloud providers
337
+ return [
338
+ OllamaProvider(), # Free, local, private
339
+ GeminiProvider(), # Free tier (Google)
340
+ OpenRouterProvider(), # Free models via OpenRouter
341
+ HuggingFaceProvider() # Free tier (HuggingFace)
342
+ ]
343
+ else:
344
+ # Cloud first (faster), then local
345
+ return [
346
+ GeminiProvider(), # Free tier, fastest
347
+ OpenRouterProvider(), # Free models, many options
348
+ HuggingFaceProvider(), # Free tier, open models
349
+ OllamaProvider() # Free, local fallback
350
+ ]
351
+
352
+ def get_available_providers(self) -> List[str]:
353
+ """Get list of available provider names."""
354
+ available = []
355
+ for provider in self.providers:
356
+ if provider.is_available():
357
+ available.append(provider.__class__.__name__)
358
+ return available
359
+
360
+ def call(self, prompt: str, timeout: int = 60) -> str:
361
+ """
362
+ Call LLM with automatic fallback.
363
+
364
+ Tries providers in order until one succeeds.
365
+
366
+ Args:
367
+ prompt: The prompt to send
368
+ timeout: Timeout in seconds
369
+
370
+ Returns:
371
+ LLM response text
372
+
373
+ Raises:
374
+ RuntimeError: If all providers fail
375
+ """
376
+ errors = []
377
+
378
+ # Try last successful provider first (if any)
379
+ if self.last_successful_provider:
380
+ try:
381
+ self.last_successful_provider.timeout = timeout
382
+ result = self.last_successful_provider.call(prompt)
383
+ return result
384
+ except Exception as e:
385
+ errors.append(f"{self.last_successful_provider.__class__.__name__}: {e}")
386
+ # Continue to other providers
387
+
388
+ # Try all providers
389
+ for provider in self.providers:
390
+ if not provider.is_available():
391
+ errors.append(f"{provider.__class__.__name__}: Not available")
392
+ continue
393
+
394
+ try:
395
+ provider.timeout = timeout
396
+ result = provider.call(prompt)
397
+
398
+ # Success! Remember this provider
399
+ self.last_successful_provider = provider
400
+ print(f"[LLM] Using {provider.__class__.__name__}")
401
+ return result
402
+
403
+ except Exception as e:
404
+ errors.append(f"{provider.__class__.__name__}: {e}")
405
+ continue
406
+
407
+ # All providers failed
408
+ error_summary = "\n".join(errors)
409
+ raise RuntimeError(
410
+ f"All LLM providers failed:\n{error_summary}\n\n"
411
+ f"Setup instructions (all FREE/open-source):\n"
412
+ f"1. Ollama (local): Install from https://ollama.ai/ → ollama pull llama3\n"
413
+ f"2. Google Gemini (free tier): Get key at https://aistudio.google.com/ → export GOOGLE_API_KEY=...\n"
414
+ f"3. OpenRouter (free models): Get key at https://openrouter.ai/ → export OPENROUTER_API_KEY=...\n"
415
+ f"4. HuggingFace (free tier): Get key at https://huggingface.co/settings/tokens → export HUGGINGFACE_API_KEY=..."
416
+ )
417
+
418
+ def safe_json_parse(self, raw: str, retry_timeout: int = 15) -> Dict:
419
+ """
420
+ Parse JSON from LLM output with retry.
421
+
422
+ Args:
423
+ raw: Raw LLM output
424
+ retry_timeout: Timeout for retry call
425
+
426
+ Returns:
427
+ Parsed JSON dict
428
+ """
429
+ # Try parsing with base provider logic
430
+ try:
431
+ provider = self.last_successful_provider or self.providers[0]
432
+ return provider.parse_json(raw)
433
+ except json.JSONDecodeError:
434
+ pass
435
+
436
+ # Last resort: ask LLM to fix it
437
+ correction_prompt = f"""
438
+ The following output was NOT valid JSON.
439
+
440
+ Return ONLY valid JSON.
441
+ No explanation.
442
+ No markdown.
443
+
444
+ INVALID OUTPUT:
445
+ {raw}
446
+ """
447
+
448
+ try:
449
+ corrected = self.call(correction_prompt, timeout=retry_timeout)
450
+ provider = self.last_successful_provider or self.providers[0]
451
+ return provider.parse_json(corrected)
452
+ except Exception:
453
+ raise json.JSONDecodeError(
454
+ f"Could not parse LLM output as JSON",
455
+ raw[:100],
456
+ 0
457
+ )
458
+
459
+
460
+ # Global router instance
461
+ _global_router = None
462
+
463
+
464
+ def get_llm_router(prefer_local: bool = True) -> LLMRouter:
465
+ """Get global LLM router instance."""
466
+ global _global_router
467
+ if _global_router is None:
468
+ _global_router = LLMRouter(prefer_local=prefer_local)
469
+ return _global_router
470
+
471
+
472
+ def call_llama(prompt: str, timeout: int = 60) -> str:
473
+ """
474
+ Call LLM with automatic fallback (backward compatible).
475
+
476
+ This function maintains backward compatibility with existing code.
477
+ """
478
+ router = get_llm_router()
479
+ return router.call(prompt, timeout)
480
+
481
+
482
+ def safe_json_parse(raw: str, retry_timeout: int = 15) -> Dict:
483
+ """
484
+ Parse JSON from LLM output (backward compatible).
485
+ """
486
+ router = get_llm_router()
487
+ return router.safe_json_parse(raw, retry_timeout)
488
+
489
+
490
+ def check_ollama_available() -> bool:
491
+ """Check if Ollama is available (backward compatible)."""
492
+ provider = OllamaProvider()
493
+ return provider.is_available()
494
+
495
+
496
+ def check_any_llm_available() -> bool:
497
+ """Check if ANY LLM provider is available."""
498
+ router = get_llm_router()
499
+ return len(router.get_available_providers()) > 0