87 lines
3.9 KiB
Python
87 lines
3.9 KiB
Python
"""LLM Client — протокол + httpx-реализация + fake для тестов.
|
|
|
|
План decoupling Ф2:
|
|
- LLMClient.complete(prompt) -> str — единственный метод
|
|
- Парсинг JSON остаётся у вызывающего (call_llm / _call_llm_classify)
|
|
- HttpxLLMClient — продакшен
|
|
- FakeLLMClient — тесты (возвращает сохранённые ответы)
|
|
"""
|
|
from typing import Protocol
|
|
|
|
|
|
class LLMClient(Protocol):
|
|
"""Протокол LLM-клиента. Один метод — сырой вызов."""
|
|
def complete(self, prompt: str) -> str:
|
|
"""Отправить prompt в LLM, вернуть raw_text."""
|
|
...
|
|
|
|
|
|
class HttpxLLMClient:
|
|
"""Продакшен-клиент через httpx → api.aillm.ru."""
|
|
|
|
def __init__(self, url: str, key: str, model: str = "gpt-oss-120b",
|
|
max_tokens: int = 8000, temperature: float = 0.1, timeout: int = 120):
|
|
self.url = url
|
|
self.key = key
|
|
self.model = model
|
|
self.max_tokens = max_tokens
|
|
self.temperature = temperature
|
|
self.timeout = timeout
|
|
|
|
def complete(self, prompt: str) -> str:
|
|
"""Отправить запрос к модели LLM через HTTP POST и вернуть текстовое содержимое ответа.
|
|
|
|
Назначение и инварианты:
|
|
- Проверяет обязательное наличие непустого API-ключа перед формированием HTTP-запроса.
|
|
- Если ключ отсутствует или пуст, выбрасывает явное исключение ValueError вместо передачи
|
|
невалидного заголовка Authorization (например 'Bearer '), приводящего к ошибке
|
|
'Illegal header value' на уровне транспортной библиотеки httpx/httpcore.
|
|
- Обрезает случайные пробельные символы и переносы строк по краям токена.
|
|
"""
|
|
import httpx
|
|
clean_key = (self.key or "").strip()
|
|
if not clean_key:
|
|
raise ValueError("LLM_API_KEY is not configured or empty")
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"max_tokens": self.max_tokens,
|
|
"temperature": self.temperature,
|
|
}
|
|
with httpx.Client(timeout=self.timeout, verify=True) as client:
|
|
resp = client.post(
|
|
self.url,
|
|
json=payload,
|
|
headers={
|
|
"Authorization": f"Bearer {clean_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data["choices"][0]["message"]["content"]
|
|
|
|
|
|
class FakeLLMClient:
|
|
"""Тестовый клиент — возвращает сохранённые ответы из словаря."""
|
|
|
|
def __init__(self, responses: dict = None):
|
|
self.responses = responses or {}
|
|
self.calls: list[str] = [] # история вызовов для проверок
|
|
|
|
def add(self, prompt_key: str, response: str):
|
|
"""Зарегистрировать ответ для конкретного prompt_key."""
|
|
self.responses[prompt_key] = response
|
|
|
|
def complete(self, prompt: str) -> str:
|
|
self.calls.append(prompt)
|
|
# Ищем точное совпадение
|
|
if prompt in self.responses:
|
|
return self.responses[prompt]
|
|
# Ищем по частичному ключу
|
|
for key, resp in self.responses.items():
|
|
if key in prompt:
|
|
return resp
|
|
# Fallback
|
|
return self.responses.get("default", '{"error": "no response registered"}')
|