"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK. Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama, vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`, więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci. """ from __future__ import annotations import time import httpx from app.llm.base import Completion, LLMError, LLMProvider RETRY_STATUSES = {429, 500, 502, 503, 504} MAX_ATTEMPTS = 3 def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict: """POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx.""" last: Exception | None = None for attempt in range(MAX_ATTEMPTS): try: with httpx.Client(timeout=timeout) as client: r = client.post(url, json=payload, headers=headers) if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1: time.sleep(2 ** attempt) continue if r.status_code >= 400: raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}") return r.json() except httpx.TimeoutException as e: last = e if attempt < MAX_ATTEMPTS - 1: time.sleep(2 ** attempt) continue raise LLMError( f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy " f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu." ) from e except httpx.HTTPError as e: last = e if attempt < MAX_ATTEMPTS - 1: time.sleep(2 ** attempt) continue raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e raise LLMError(f"Nie udało się wywołać modelu: {last}") class ChatCompletionsProvider(LLMProvider): """Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI.""" def __init__(self, name: str, base_url: str, model: str, api_key: str = "", timeout: float = 120.0, leaves_lan: bool = True) -> None: self.name = name self.base_url = base_url.rstrip("/") self.model = model self.api_key = api_key self.timeout = timeout self.leaves_lan = leaves_lan def _headers(self) -> dict: h = {"Content-Type": "application/json"} if self.api_key: h["Authorization"] = f"Bearer {self.api_key}" return h def generate(self, prompt: str, max_tokens: int) -> Completion: data = _post_with_retry( f"{self.base_url}/chat/completions", self._headers(), { "model": self.model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], }, self.timeout, ) try: text = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as e: raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e return Completion( text=text, model=data.get("model", self.model), provider=self.name, leaves_lan=self.leaves_lan, usage=data.get("usage") or {}, ) def health(self) -> dict: info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan} try: with httpx.Client(timeout=min(self.timeout, 10.0)) as client: r = client.get(f"{self.base_url}/models", headers=self._headers()) info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}" except httpx.HTTPError as e: info["status"] = f"down: {e}" return info class AnthropicProvider(LLMProvider): """Protokół Anthropic `/v1/messages`.""" leaves_lan = True def __init__(self, base_url: str, model: str, api_key: str = "", timeout: float = 120.0) -> None: self.name = "anthropic" self.base_url = base_url.rstrip("/") self.model = model self.api_key = api_key self.timeout = timeout def generate(self, prompt: str, max_tokens: int) -> Completion: if not self.api_key: raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.") data = _post_with_retry( f"{self.base_url}/v1/messages", { "Content-Type": "application/json", "x-api-key": self.api_key, "anthropic-version": "2023-06-01", }, { "model": self.model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], }, self.timeout, ) try: text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text") except (KeyError, TypeError) as e: raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e return Completion( text=text, model=data.get("model", self.model), provider=self.name, leaves_lan=True, usage=data.get("usage") or {}, ) def health(self) -> dict: return { "provider": self.name, "model": self.model, "leaves_lan": True, "status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY", }