Files
contracts-app/site/llm_client.py
T
naeel a684d51169 fix(llm): h2+socket вместо curl — HTTP/2 на чистом Python
h2 — pure Python, ставится без компиляции в slim-контейнерах.
ssl.create_default_context() + ALPN h2 + h2.connection.H2Connection.
2026-06-14 07:44:21 +04:00

148 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}"}