""" llm_client.py — Тонкий HTTP-клиент к LLM API. Только отправить промпт → получить ответ. Никакой бизнес-логики. Бизнес-логика (промпты, парсинг ответа) → extractor.py. Использует h2 + ssl + socket — HTTP/2 на чистом Python. api.aillm.ru за ddos-guard, требует HTTP/2. h2 — pure Python, ставится без компиляции в slim-контейнерах. Переменные окружения: LLM_API_URL — URL API (по умолчанию https://api.aillm.ru/v1/chat/completions) LLM_API_KEY — ключ авторизации """ import os import json import ssl import socket import h2.connection import h2.events from urllib.parse import urlparse # ── Конфигурация ──────────────────────────────────────────────── DEFAULT_URL = "https://api.aillm.ru/v1/chat/completions" DEFAULT_MODEL = "gpt-oss-120b" # 120B модель через LiteLLM-прокси def _http2_request(url: str, headers: dict, body: str, timeout: int = 120) -> dict: """ HTTP/2 POST-запрос на чистом Python (h2 + ssl + socket). Возвращает (status_code, response_body) или бросает исключение. """ parsed = urlparse(url) host = parsed.hostname port = parsed.port or 443 # SSL-контекст с ALPN = h2 (HTTP/2) ctx = ssl.create_default_context() ctx.set_alpn_protocols(["h2"]) sock = socket.create_connection((host, port), timeout=timeout) ssl_sock = ctx.wrap_socket(sock, server_hostname=host) conn = h2.connection.H2Connection() conn.initiate_connection() ssl_sock.sendall(conn.data_to_send()) # Формируем заголовки request_headers = [ (":method", "POST"), (":path", parsed.path + ("?" + parsed.query if parsed.query else "")), (":authority", host), (":scheme", "https"), ("content-type", "application/json"), ("accept", "application/json"), ] for k, v in headers.items(): request_headers.append((k.lower(), v)) conn.send_headers(1, request_headers, end_stream=False) body_bytes = body.encode("utf-8") conn.send_data(1, body_bytes, end_stream=True) ssl_sock.sendall(conn.data_to_send()) # Читаем ответ response_data = b"" response_headers = {} while True: data = ssl_sock.recv(65535) if not data: break events = conn.receive_data(data) for event in events: if isinstance(event, h2.events.ResponseReceived): response_headers = dict(event.headers) elif isinstance(event, h2.events.DataReceived): response_data += event.data conn.acknowledge_received_data(event.flow_controlled_length, event.stream_id) elif isinstance(event, h2.events.StreamEnded): ssl_sock.sendall(conn.data_to_send()) ssl_sock.close() status = int(response_headers.get(b":status", b"0")) return status, response_data.decode("utf-8") ssl_sock.sendall(conn.data_to_send()) ssl_sock.close() raise Exception("HTTP/2 stream ended without response") def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict: """ Отправить промпт к LLM API через HTTP/2 (h2). Args: prompt: текст промпта model: модель (None = DEFAULT_MODEL) max_tokens: максимум токенов в ответе Returns: {"text": "ответ модели", "model": "...", "usage": {...}} или {"error": "описание ошибки"} """ api_url = os.getenv("LLM_API_URL", DEFAULT_URL) api_key = os.getenv("LLM_API_KEY", "") if not api_key: return {"error": "LLM_API_KEY not set"} model = model or DEFAULT_MODEL payload = json.dumps({ "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": 0.1, }) headers = {"authorization": f"Bearer {api_key}"} try: status, body = _http2_request(api_url, headers, payload) if status != 200: return {"error": f"LLM HTTP {status}: {body[:500]}"} data = json.loads(body) choice = data.get("choices", [{}])[0] message = choice.get("message", {}) text = message.get("content", "") return { "text": text, "model": data.get("model", model), "usage": data.get("usage", {}), } except socket.timeout: return {"error": "LLM timeout"} except Exception as e: return {"error": f"LLM unexpected: {e}"}