877ec91ff0
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 40s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Objaw zgloszony przez uzytkownika: w Kalendarzu horoskop wyswietla sie
poprawnie, w Interpretacjach zapytanie wychodzi, wraca — i NIC sie nie
pokazuje. Bez zadnego komunikatu.
Przyczyna: model potrafi oddac pusta tresc (finish_reason=length,
completion_tokens=0), a generate() zwracalo wtedy pusty tekst BEZ bledu.
Widok sprawdza {% if prompt_result.horoscope %} -> falsz -> nie renderuje nic,
a llm_error nie jest ustawiony -> zero wyjasnienia. Cicha awaria.
Asymetria miedzy ekranami wynika z rozmiaru promptu: natalny (13 obiektow x
fasety x opisy z bazy) wypelnia okno kontekstu modelu lokalnego i na odpowiedz
nie zostaje miejsca; okresowy jest mniejszy i sie miesci.
- wspolny straznik _require_text() dla obu dostawcow: pusta lub bialoznakowa
odpowiedz podnosi LLMError,
- komunikat PROWADZI DO PRZYCZYNY: podaje finish_reason i zuzycie tokenow oraz
radzi zmniejszyc budzet promptu / zwiekszyc num_ctx / LLM_MAX_TOKENS,
- Anthropic sprowadzony do wspolnego ksztaltu diagnostyki (input/output_tokens).
Dzieki temu uzytkownik widzi powod ORAZ gotowy prompt do recznego uzycia.
Zweryfikowane na zywym stosie z atrapa modelu oddajaca pusta tresc: zamiast
pustej strony pojawia sie pelny komunikat z diagnostyka. Testy: 148 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.6 KiB
Python
186 lines
7.6 KiB
Python
"""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}")
|
|
|
|
|
|
def _require_text(text: str, usage: dict, finish_reason: str | None, max_tokens: int) -> str:
|
|
"""Pusta odpowiedź modelu MUSI być błędem, nie pustym tekstem.
|
|
|
|
Inaczej mamy cichą awarię: warstwa wyżej dostaje `horoscope=""`, widok nic nie
|
|
renderuje i NIC nie tłumaczy użytkownikowi, dlaczego strona wróciła pusta.
|
|
Najczęstsza przyczyna: prompt wypełnił okno kontekstu modelu, więc na odpowiedź
|
|
nie zostało miejsca (`finish_reason=length`, `completion_tokens=0`) — dotyczy to
|
|
zwłaszcza obszernych promptów natalnych na modelach lokalnych o małym kontekście.
|
|
"""
|
|
if text and text.strip():
|
|
return text
|
|
|
|
powod = []
|
|
if finish_reason:
|
|
powod.append(f"finish_reason={finish_reason}")
|
|
if usage:
|
|
pt, ct = usage.get("prompt_tokens"), usage.get("completion_tokens")
|
|
if pt is not None:
|
|
powod.append(f"tokeny promptu={pt}")
|
|
if ct is not None:
|
|
powod.append(f"tokeny odpowiedzi={ct}")
|
|
szczegoly = f" ({', '.join(powod)})" if powod else ""
|
|
|
|
rada = (
|
|
"Najczęstsza przyczyna: prompt nie zmieścił się w oknie kontekstu modelu i na "
|
|
"odpowiedź nie zostało miejsca. Zmniejsz budżet promptu (zwięzły), zwiększ okno "
|
|
"kontekstu modelu (w Ollamie num_ctx) albo podnieś LLM_MAX_TOKENS."
|
|
if finish_reason == "length" or (usage or {}).get("completion_tokens") == 0
|
|
else "Model przyjął żądanie, ale nie wygenerował treści."
|
|
)
|
|
raise LLMError(f"Model zwrócił pustą odpowiedź{szczegoly}. {rada}")
|
|
|
|
|
|
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:
|
|
choice = data["choices"][0]
|
|
text = choice["message"]["content"]
|
|
except (KeyError, IndexError, TypeError) as e:
|
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
|
usage = data.get("usage") or {}
|
|
text = _require_text(text, usage, choice.get("finish_reason"), max_tokens)
|
|
return Completion(
|
|
text=text, model=data.get("model", self.model), provider=self.name,
|
|
leaves_lan=self.leaves_lan, usage=usage,
|
|
)
|
|
|
|
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
|
|
usage = data.get("usage") or {}
|
|
# Anthropic nazywa to inaczej — sprowadzamy do wspólnego kształtu dla diagnostyki
|
|
norm = {"prompt_tokens": usage.get("input_tokens"),
|
|
"completion_tokens": usage.get("output_tokens")}
|
|
text = _require_text(text, {k: v for k, v in norm.items() if v is not None},
|
|
data.get("stop_reason"), max_tokens)
|
|
return Completion(
|
|
text=text, model=data.get("model", self.model), provider=self.name,
|
|
leaves_lan=True, usage=usage,
|
|
)
|
|
|
|
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",
|
|
}
|