fix(llm): httpx + h2 раздельно в requirements

httpx[http2] мог не парситься платформой. httpx и h2 отдельными строками.
This commit is contained in:
2026-06-14 07:45:15 +04:00
parent a684d51169
commit 8cb7139924
2 changed files with 22 additions and 98 deletions
+1
View File
@@ -5,4 +5,5 @@ requests
psycopg2-binary psycopg2-binary
python-dotenv python-dotenv
pdfplumber pdfplumber
httpx
h2 h2
+21 -98
View File
@@ -4,9 +4,8 @@ llm_client.py — Тонкий HTTP-клиент к LLM API.
Только отправить промпт → получить ответ. Никакой бизнес-логики. Только отправить промпт → получить ответ. Никакой бизнес-логики.
Бизнес-логика (промпты, парсинг ответа) → extractor.py. Бизнес-логика (промпты, парсинг ответа) → extractor.py.
Использует h2 + ssl + socket — HTTP/2 на чистом Python. Использует httpx с HTTP/2 — api.aillm.ru за ddos-guard, требует HTTP/2.
api.aillm.ru за ddos-guard, требует HTTP/2. h2 (pure Python) в requirements.txt отдельной строкой.
h2 — pure Python, ставится без компиляции в slim-контейнерах.
Переменные окружения: Переменные окружения:
LLM_API_URL — URL API (по умолчанию https://api.aillm.ru/v1/chat/completions) LLM_API_URL — URL API (по умолчанию https://api.aillm.ru/v1/chat/completions)
@@ -14,97 +13,20 @@ h2 — pure Python, ставится без компиляции в slim-кон
""" """
import os import os
import json import httpx
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_URL = "https://api.aillm.ru/v1/chat/completions"
DEFAULT_MODEL = "gpt-oss-120b" # 120B модель через LiteLLM-прокси DEFAULT_MODEL = "gpt-oss-120b"
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: def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict:
""" """
Отправить промпт к LLM API через HTTP/2 (h2). Отправить промпт к LLM API.
Args:
prompt: текст промпта
model: модель (None = DEFAULT_MODEL)
max_tokens: максимум токенов в ответе
Returns: Returns:
{"text": "ответ модели", "model": "...", "usage": {...}} {"text": "...", "model": "...", "usage": {...}} или {"error": "..."}
или
{"error": "описание ошибки"}
""" """
api_url = os.getenv("LLM_API_URL", DEFAULT_URL) api_url = os.getenv("LLM_API_URL", DEFAULT_URL)
api_key = os.getenv("LLM_API_KEY", "") 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 model = model or DEFAULT_MODEL
payload = json.dumps({ payload = {
"model": model, "model": model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": 0.1, "temperature": 0.1,
}) }
headers = {"authorization": f"Bearer {api_key}"} headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try: try:
status, body = _http2_request(api_url, headers, payload) with httpx.Client(http2=True, timeout=120) as client:
resp = client.post(api_url, json=payload, headers=headers)
if status != 200: resp.raise_for_status()
return {"error": f"LLM HTTP {status}: {body[:500]}"} data = resp.json()
data = json.loads(body)
choice = data.get("choices", [{}])[0] choice = data.get("choices", [{}])[0]
message = choice.get("message", {}) message = choice.get("message", {})
text = message.get("content", "")
return { return {
"text": text, "text": message.get("content", ""),
"model": data.get("model", model), "model": data.get("model", model),
"usage": data.get("usage", {}), "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"} return {"error": "LLM timeout"}
except Exception as e: except Exception as e:
return {"error": f"LLM unexpected: {e}"} return {"error": f"LLM: {e}"}