test / test (push) Has been cancelled
- deepseek-v4-flash su opencode/go come modello primario - big-pickle su opencode/zen come fallback (gratuito) - Fallback automatico su censura "User Safety" o errore HTTP - Aggiornati .env, config.py, start.sh, docker-compose.yml
162 lines
5.3 KiB
Python
162 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from src.engine.config import ProviderConfig, load_from_env
|
|
|
|
|
|
class LLMProvider(ABC):
|
|
@abstractmethod
|
|
def ask(self, prompt: str, system: str = "") -> str:
|
|
...
|
|
|
|
|
|
class OllamaProvider(LLMProvider):
|
|
def __init__(self, base_url: str, model: str, timeout: int = 60):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.model = model
|
|
self.timeout = timeout
|
|
|
|
def ask(self, prompt: str, system: str = "") -> str:
|
|
body = {"model": self.model, "prompt": prompt, "stream": False}
|
|
if system:
|
|
body["system"] = system
|
|
resp = httpx.post(
|
|
f"{self.base_url}/api/generate",
|
|
json=body,
|
|
timeout=self.timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json().get("response", "")
|
|
|
|
|
|
class OpenRouterProvider(LLMProvider):
|
|
_FREE_FALLBACK = [
|
|
"openrouter/free",
|
|
"google/gemma-4-31b-it:free",
|
|
"qwen/qwen3-coder:free",
|
|
]
|
|
|
|
def __init__(self, api_key: str, model: str, timeout: int = 60, free_fallback: bool = True):
|
|
self.api_key = api_key
|
|
self.model = model
|
|
self.timeout = timeout
|
|
self.free_fallback = free_fallback
|
|
|
|
def _call(self, model: str, prompt: str, system: str) -> str:
|
|
messages = []
|
|
if system:
|
|
messages.append({"role": "system", "content": system})
|
|
messages.append({"role": "user", "content": prompt})
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
body = {"model": model, "messages": messages, "stream": False}
|
|
|
|
resp = httpx.post(
|
|
"https://openrouter.ai/api/v1/chat/completions",
|
|
json=body,
|
|
headers=headers,
|
|
timeout=self.timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()["choices"][0]["message"]["content"]
|
|
|
|
def ask(self, prompt: str, system: str = "") -> str:
|
|
models = [self.model]
|
|
if self.free_fallback:
|
|
models.extend(self._FREE_FALLBACK)
|
|
last_err = None
|
|
for m in models:
|
|
try:
|
|
return self._call(m, prompt, system)
|
|
except Exception as e:
|
|
last_err = e
|
|
continue
|
|
raise last_err # type: ignore
|
|
|
|
|
|
class LocalProvider(LLMProvider):
|
|
def __init__(self, base_url: str, model: str, api_key: str = "",
|
|
timeout: int = 60, fallbacks: list[tuple[str, str]] | None = None):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.model = model
|
|
self.api_key = api_key
|
|
self.timeout = timeout
|
|
self.fallbacks = fallbacks or []
|
|
|
|
def _call(self, base_url: str, model: str, prompt: str, system: str) -> str:
|
|
messages = []
|
|
if system:
|
|
messages.append({"role": "system", "content": system})
|
|
messages.append({"role": "user", "content": prompt})
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
|
|
body = {"model": model, "messages": messages, "stream": False}
|
|
|
|
resp = httpx.post(
|
|
f"{base_url}/chat/completions",
|
|
json=body,
|
|
headers=headers,
|
|
timeout=self.timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data["choices"][0]["message"]["content"]
|
|
|
|
def ask(self, prompt: str, system: str = "") -> str:
|
|
candidates = [(self.base_url, self.model)] + self.fallbacks
|
|
last_err = None
|
|
for base_url, model in candidates:
|
|
try:
|
|
text = self._call(base_url, model, prompt, system)
|
|
# Rileva risposte di censura del modello gratuito
|
|
if "User Safety" in text and "safe" in text.lower():
|
|
raise ValueError(f"Modello '{model}' ha censurato la risposta: {text[:60]}")
|
|
return text
|
|
except Exception as e:
|
|
last_err = e
|
|
continue
|
|
raise last_err # type: ignore
|
|
|
|
|
|
_registry: dict[str, Any] = {
|
|
"ollama": OllamaProvider,
|
|
"openrouter": OpenRouterProvider,
|
|
"local": LocalProvider,
|
|
}
|
|
|
|
|
|
def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider:
|
|
if cfg is None:
|
|
cfg = load_from_env()
|
|
|
|
cls = _registry.get(cfg.kind)
|
|
if not cls:
|
|
raise ValueError(f"Provider sconosciuto: {cfg.kind} (disponibili: {list(_registry.keys())})")
|
|
|
|
if cfg.kind == "ollama":
|
|
return cls(cfg.ollama_base_url, cfg.ollama_model, cfg.timeout)
|
|
elif cfg.kind == "openrouter":
|
|
key = cfg.openrouter_api_key or os.getenv("DCC_OPENROUTER_KEY", os.getenv("OPENROUTER_API_KEY", ""))
|
|
if not key:
|
|
raise ValueError("DCC_OPENROUTER_KEY non configurata")
|
|
return cls(key, cfg.openrouter_model, cfg.timeout, cfg.openrouter_free_fallback)
|
|
elif cfg.kind == "local":
|
|
fallbacks = []
|
|
if cfg.local_fallback_url and cfg.local_fallback_model:
|
|
fallbacks.append((cfg.local_fallback_url, cfg.local_fallback_model))
|
|
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout, fallbacks=fallbacks)
|
|
else:
|
|
raise ValueError(f"Provider non implementato: {cfg.kind}")
|