156 lines
7.2 KiB
Python
156 lines
7.2 KiB
Python
"""HTTP-клиент к OpenAI-совместимому LLM-серверу (llama.cpp server, ТЗ §3.3).
|
||
|
||
`llama.cpp server` (образ `ghcr.io/ggml-org/llama.cpp:server`) отдаёт
|
||
`/v1/chat/completions` в формате OpenAI Chat Completions API: тело запроса
|
||
`{"model": ..., "messages": [{"role": "user", "content": ...}]}`, ответ —
|
||
`choices[0].message.content` (см. `tools/server/README.md` проекта llama.cpp).
|
||
|
||
Надёжность: retry с экспоненциальным backoff на
|
||
сетевых ошибках и retryable HTTP-статусах (5xx, включая 503 — модель ещё
|
||
грузится), плюс circuit breaker поверх retry — после `breaker_threshold`
|
||
подряд неудачных вызовов `complete()` окно `breaker_cooldown_s` секунд все
|
||
вызовы падают немедленно с `LlmUnavailableError`, не делая HTTP-запросов.
|
||
"""
|
||
|
||
import logging
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_INITIAL_BACKOFF_S = 0.5
|
||
"""Базовая пауза перед повтором; растёт экспоненциально: `_INITIAL_BACKOFF_S * 2**attempt`."""
|
||
|
||
_RETRYABLE_STATUS_CODES = frozenset({503})
|
||
"""Дополнительные ретраибл-статусы помимо диапазона 5xx (503 — модель llama.cpp ещё грузится)."""
|
||
|
||
|
||
class LlmUnavailableError(Exception):
|
||
"""LLM-сервер недоступен: исчерпаны попытки retry либо открыт circuit breaker."""
|
||
|
||
|
||
class OpenAICompatClient:
|
||
"""Клиент чат-комплишенов OpenAI-совместимого сервера (llama.cpp, Ollama и т.п.).
|
||
|
||
`transport` — точка внедрения `httpx.MockTransport` в тестах; в проде не
|
||
передаётся (используется реальный сетевой транспорт `httpx`).
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str,
|
||
model: str,
|
||
*,
|
||
temperature: float = 0.2,
|
||
max_tokens: int = 1024,
|
||
timeout_s: float = 600.0,
|
||
max_attempts: int = 3,
|
||
breaker_threshold: int = 5,
|
||
breaker_cooldown_s: float = 60.0,
|
||
transport: httpx.BaseTransport | None = None,
|
||
) -> None:
|
||
self._base_url = base_url.rstrip("/")
|
||
self._model = model
|
||
self._temperature = temperature
|
||
self._max_tokens = max_tokens
|
||
self._max_attempts = max_attempts
|
||
self._breaker_threshold = breaker_threshold
|
||
self._breaker_cooldown_s = breaker_cooldown_s
|
||
self._client = httpx.Client(timeout=timeout_s, transport=transport)
|
||
|
||
self._consecutive_failures = 0
|
||
self._breaker_open_until = 0.0
|
||
|
||
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
|
||
"""Выполнить чат-комплишн по одному пользовательскому сообщению `prompt`.
|
||
|
||
`max_tokens` — переопределение лимита на конкретный вызов (ADR-004:
|
||
раздельные лимиты map/reduce у `QwenLocal`); по умолчанию берётся
|
||
`max_tokens`, заданный в конструкторе клиента.
|
||
|
||
Бросает `LlmUnavailableError`, если circuit breaker открыт (окно
|
||
отказа ещё не истекло) либо все попытки retry исчерпаны.
|
||
"""
|
||
now = time.monotonic()
|
||
if now < self._breaker_open_until:
|
||
raise LlmUnavailableError(
|
||
"LLM-сервер недоступен: circuit breaker открыт после серии сбоев"
|
||
)
|
||
|
||
url = f"{self._base_url}/chat/completions"
|
||
payload: dict[str, Any] = {
|
||
"model": self._model,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": self._temperature,
|
||
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
|
||
}
|
||
|
||
last_error: Exception | None = None
|
||
for attempt in range(self._max_attempts):
|
||
try:
|
||
response = self._client.post(url, json=payload)
|
||
except httpx.TransportError as exc:
|
||
last_error = exc
|
||
logger.warning(
|
||
"Сетевая ошибка при обращении к LLM (попытка %d): %s", attempt + 1, exc
|
||
)
|
||
else:
|
||
if response.status_code == 200:
|
||
self._consecutive_failures = 0
|
||
try:
|
||
data = response.json()
|
||
content: str = data["choices"][0]["message"]["content"]
|
||
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
||
last_error = exc
|
||
logger.warning("Некорректный ответ LLM-сервера: %s", exc)
|
||
else:
|
||
return content
|
||
elif response.status_code >= 500 or response.status_code in _RETRYABLE_STATUS_CODES:
|
||
last_error = RuntimeError(
|
||
f"LLM-сервер вернул retryable статус {response.status_code}"
|
||
)
|
||
logger.warning(
|
||
"Retryable статус %d от LLM (попытка %d)", response.status_code, attempt + 1
|
||
)
|
||
else:
|
||
# Не retryable статус (например, 4xx) — не тратим оставшиеся
|
||
# попытки, но фиксируем сбой для circuit breaker.
|
||
self._register_failure()
|
||
raise LlmUnavailableError(
|
||
f"LLM-сервер вернул статус {response.status_code}: {response.text}"
|
||
) from None
|
||
|
||
if attempt < self._max_attempts - 1:
|
||
time.sleep(_INITIAL_BACKOFF_S * (2**attempt))
|
||
|
||
self._register_failure()
|
||
raise LlmUnavailableError(
|
||
f"LLM-сервер недоступен после {self._max_attempts} попыток"
|
||
) from last_error
|
||
|
||
def _register_failure(self) -> None:
|
||
"""Учесть неудачный вызов `complete()`; открыть breaker при достижении порога."""
|
||
self._consecutive_failures += 1
|
||
if self._consecutive_failures >= self._breaker_threshold:
|
||
self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s
|
||
logger.warning(
|
||
"Circuit breaker открыт на %.0fс после %d подряд неудач",
|
||
self._breaker_cooldown_s,
|
||
self._consecutive_failures,
|
||
)
|
||
|
||
def close(self) -> None:
|
||
"""Закрыть базовый HTTP-клиент (освободить соединения и пул `httpx`).
|
||
|
||
Безопасно вызывать более одного раза — `httpx.Client.close()` идемпотентен.
|
||
"""
|
||
self._client.close()
|
||
|
||
def __enter__(self) -> "OpenAICompatClient":
|
||
return self
|
||
|
||
def __exit__(self, *exc_info: object) -> None:
|
||
self.close()
|