fix(llm): h2+socket вместо curl — HTTP/2 на чистом Python
h2 — pure Python, ставится без компиляции в slim-контейнерах. ssl.create_default_context() + ALPN h2 + h2.connection.H2Connection.
This commit is contained in:
@@ -5,3 +5,4 @@ requests
|
|||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
python-dotenv
|
python-dotenv
|
||||||
pdfplumber
|
pdfplumber
|
||||||
|
h2
|
||||||
|
|||||||
+82
-25
@@ -4,8 +4,9 @@ llm_client.py — Тонкий HTTP-клиент к LLM API.
|
|||||||
Только отправить промпт → получить ответ. Никакой бизнес-логики.
|
Только отправить промпт → получить ответ. Никакой бизнес-логики.
|
||||||
Бизнес-логика (промпты, парсинг ответа) → extractor.py.
|
Бизнес-логика (промпты, парсинг ответа) → extractor.py.
|
||||||
|
|
||||||
Использует curl (HTTP/2) — api.aillm.ru за ddos-guard, требует HTTP/2.
|
Использует h2 + ssl + socket — HTTP/2 на чистом Python.
|
||||||
curl есть в любом контейнере и гарантированно работает с HTTP/2.
|
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_URL — URL API (по умолчанию https://api.aillm.ru/v1/chat/completions)
|
||||||
@@ -14,7 +15,11 @@ curl есть в любом контейнере и гарантированно
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
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-прокси
|
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:
|
def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict:
|
||||||
"""
|
"""
|
||||||
Отправить промпт к LLM API через curl (HTTP/2).
|
Отправить промпт к LLM API через HTTP/2 (h2).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: текст промпта
|
prompt: текст промпта
|
||||||
@@ -51,27 +121,16 @@ def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict:
|
|||||||
"temperature": 0.1,
|
"temperature": 0.1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
headers = {"authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# curl --http2 гарантирует HTTP/2
|
status, body = _http2_request(api_url, headers, payload)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if status != 200:
|
||||||
return {"error": f"curl exited with {proc.returncode}: {proc.stderr[:300]}"}
|
return {"error": f"LLM HTTP {status}: {body[:500]}"}
|
||||||
|
|
||||||
data = json.loads(proc.stdout)
|
data = json.loads(body)
|
||||||
|
|
||||||
# OpenAI-совместимый формат
|
|
||||||
choice = data.get("choices", [{}])[0]
|
choice = data.get("choices", [{}])[0]
|
||||||
message = choice.get("message", {})
|
message = choice.get("message", {})
|
||||||
text = message.get("content", "")
|
text = message.get("content", "")
|
||||||
@@ -82,9 +141,7 @@ def ask(prompt: str, model: str = None, max_tokens: int = 8000) -> dict:
|
|||||||
"usage": data.get("usage", {}),
|
"usage": data.get("usage", {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except socket.timeout:
|
||||||
return {"error": "LLM timeout (130s)"}
|
return {"error": "LLM timeout"}
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
return {"error": f"LLM bad JSON: {e}"}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"LLM unexpected: {e}"}
|
return {"error": f"LLM unexpected: {e}"}
|
||||||
|
|||||||
Reference in New Issue
Block a user