"""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()