diff --git a/requirements.txt b/requirements.txt index dbe9f25..61dd4d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ requests psycopg2-binary python-dotenv pdfplumber +h2 diff --git a/site/llm_client.py b/site/llm_client.py index 68f9863..503f1b9 100644 --- a/site/llm_client.py +++ b/site/llm_client.py @@ -4,8 +4,9 @@ llm_client.py — Тонкий HTTP-клиент к LLM API. Только отправить промпт → получить ответ. Никакой бизнес-логики. Бизнес-логика (промпты, парсинг ответа) → extractor.py. -Использует curl (HTTP/2) — api.aillm.ru за ddos-guard, требует HTTP/2. -curl есть в любом контейнере и гарантированно работает с HTTP/2. +Использует 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) @@ -14,7 +15,11 @@ curl есть в любом контейнере и гарантированно import os import json -import subprocess +import ssl +import socket +import h2.connection +import h2.events +from urllib.parse import urlparse # ── Конфигурация ──────────────────────────────────────────────── @@ -22,9 +27,74 @@ 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 через curl (HTTP/2). + Отправить промпт к LLM API через HTTP/2 (h2). Args: prompt: текст промпта @@ -51,27 +121,16 @@ def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict: "temperature": 0.1, }) + headers = {"authorization": f"Bearer {api_key}"} + try: - # curl --http2 гарантирует HTTP/2 - proc = subprocess.run( - [ - "curl", "-s", "--http2", - "-H", f"Authorization: Bearer {api_key}", - "-H", "Content-Type: application/json", - "-H", "Accept: application/json", - "--max-time", "120", - "-d", payload, - api_url, - ], - capture_output=True, text=True, timeout=130, - ) + status, body = _http2_request(api_url, headers, payload) - if proc.returncode != 0: - return {"error": f"curl exited with {proc.returncode}: {proc.stderr[:300]}"} + if status != 200: + return {"error": f"LLM HTTP {status}: {body[:500]}"} - data = json.loads(proc.stdout) + data = json.loads(body) - # OpenAI-совместимый формат choice = data.get("choices", [{}])[0] message = choice.get("message", {}) text = message.get("content", "") @@ -82,9 +141,7 @@ def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict: "usage": data.get("usage", {}), } - except subprocess.TimeoutExpired: - return {"error": "LLM timeout (130s)"} - except json.JSONDecodeError as e: - return {"error": f"LLM bad JSON: {e}"} + except socket.timeout: + return {"error": "LLM timeout"} except Exception as e: return {"error": f"LLM unexpected: {e}"}