diff --git a/requirements.txt b/requirements.txt index 61dd4d7..8456702 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ requests psycopg2-binary python-dotenv pdfplumber +httpx h2 diff --git a/site/llm_client.py b/site/llm_client.py index 503f1b9..6554394 100644 --- a/site/llm_client.py +++ b/site/llm_client.py @@ -4,9 +4,8 @@ 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-контейнерах. +Использует httpx с HTTP/2 — api.aillm.ru за ddos-guard, требует HTTP/2. +h2 (pure Python) в requirements.txt отдельной строкой. Переменные окружения: LLM_API_URL — URL API (по умолчанию https://api.aillm.ru/v1/chat/completions) @@ -14,97 +13,20 @@ h2 — pure Python, ставится без компиляции в slim-кон """ import os -import json -import ssl -import socket -import h2.connection -import h2.events -from urllib.parse import urlparse +import httpx # ── Конфигурация ──────────────────────────────────────────────── 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") +DEFAULT_MODEL = "gpt-oss-120b" 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: максимум токенов в ответе + Отправить промпт к LLM API. Returns: - {"text": "ответ модели", "model": "...", "usage": {...}} - или - {"error": "описание ошибки"} + {"text": "...", "model": "...", "usage": {...}} или {"error": "..."} """ api_url = os.getenv("LLM_API_URL", DEFAULT_URL) api_key = os.getenv("LLM_API_KEY", "") @@ -114,34 +36,35 @@ def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict: model = model or DEFAULT_MODEL - payload = json.dumps({ + payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": 0.1, - }) + } - headers = {"authorization": f"Bearer {api_key}"} + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } try: - status, body = _http2_request(api_url, headers, payload) - - if status != 200: - return {"error": f"LLM HTTP {status}: {body[:500]}"} - - data = json.loads(body) + with httpx.Client(http2=True, timeout=120) as client: + resp = client.post(api_url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() choice = data.get("choices", [{}])[0] message = choice.get("message", {}) - text = message.get("content", "") - return { - "text": text, + "text": message.get("content", ""), "model": data.get("model", model), "usage": data.get("usage", {}), } - except socket.timeout: + except httpx.HTTPStatusError as e: + return {"error": f"LLM HTTP {e.response.status_code}: {e.response.text[:500]}"} + except httpx.TimeoutException: return {"error": "LLM timeout"} except Exception as e: - return {"error": f"LLM unexpected: {e}"} + return {"error": f"LLM: {e}"}