- Added #region/#endregion contracts with @RATIONALE/@REJECTED to _get_verify() in both translate plugins (P1 HIGH, P2 MEDIUM) - Removed orphaned duplicates #endregion in _llm_http.py (P1 HIGH, P6 HIGH)
126 lines
5.3 KiB
Python
126 lines
5.3 KiB
Python
# #region LLMClient [C:3] [TYPE Class] [SEMANTICS llm, openai, api, retry]
|
||
# @BRIEF Call OpenAI-compatible LLM APIs with retry logic for rate limiting and structured output fallback.
|
||
# @SIDE_EFFECT Makes HTTP POST calls to external LLM API.
|
||
# @RELATION DEPENDS_ON -> [EXT:requests]
|
||
|
||
import os
|
||
import time as _time
|
||
from typing import Any
|
||
|
||
from ...core.logger import logger
|
||
|
||
|
||
# #region _get_verify [C:1] [TYPE Function] [SEMANTICS translate, ssl, verify]
|
||
# @BRIEF Resolve SSL verification path from LLM_SSL_VERIFY env var.
|
||
# @RATIONALE Используем capath=/etc/ssl/certs/ вместо cafile, потому что
|
||
# OpenSSL 3.x не использует intermediate CA сертификаты из cafile для
|
||
# построения цепочки (verify code 20). capath с хеш-симлинками работает
|
||
# корректно (verify code 0).
|
||
# @REJECTED cafile отвергнут — OpenSSL 3.x не использует intermediate CA
|
||
# из единого bundle-файла. Только capath с хеш-симлинками даёт code 0.
|
||
# @POST Returns path to /etc/ssl/certs/ when enabled, False when disabled.
|
||
def _get_verify() -> str | bool:
|
||
raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
|
||
if raw in ("false", "0", "no", "off"):
|
||
return False
|
||
return "/etc/ssl/certs/"
|
||
# #endregion _get_verify
|
||
|
||
|
||
class LLMClient:
|
||
"""Call OpenAI-compatible LLM APIs with retry and structured output handling."""
|
||
|
||
@staticmethod
|
||
def call_openai_compatible(
|
||
base_url: str,
|
||
api_key: str,
|
||
model: str,
|
||
prompt: str,
|
||
provider_type: str = "openai",
|
||
max_tokens: int = 8192,
|
||
disable_reasoning: bool = False,
|
||
) -> str:
|
||
"""Call an OpenAI-compatible API for translation."""
|
||
if not base_url:
|
||
raise ValueError("LLM provider has no base_url configured")
|
||
import requests as http_requests
|
||
|
||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||
|
||
system_content = (
|
||
"You are a database content translation assistant. "
|
||
"Translate the provided text accurately, preserving data semantics. "
|
||
"Respond directly with ONLY the JSON result. "
|
||
"Do NOT include any reasoning, thinking, chain-of-thought, analysis, "
|
||
"or explanation. Output ONLY valid JSON."
|
||
)
|
||
|
||
payload: dict[str, Any] = {
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
"temperature": 0.1,
|
||
"max_tokens": max_tokens,
|
||
}
|
||
|
||
if provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm"):
|
||
if not disable_reasoning:
|
||
payload["response_format"] = {"type": "json_object"}
|
||
|
||
if disable_reasoning:
|
||
if provider_type not in ("kilo", "openrouter", "litellm"):
|
||
payload["reasoning_effort"] = "none"
|
||
payload["max_tokens"] = max_tokens
|
||
|
||
logger.reason(f"LLM request url={base_url} model={payload.get('model')} "
|
||
f"provider_type={provider_type} prompt_len={len(prompt)}")
|
||
|
||
_max_retry_429 = 3
|
||
_retry_count_429 = 0
|
||
while _retry_count_429 < _max_retry_429:
|
||
response = http_requests.post(url, headers=headers, json=payload, timeout=600, verify=_get_verify())
|
||
if response.status_code == 429:
|
||
_retry_count_429 += 1
|
||
retry_after = response.headers.get("Retry-After")
|
||
wait = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** _retry_count_429
|
||
logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s")
|
||
_time.sleep(wait)
|
||
if _retry_count_429 >= _max_retry_429:
|
||
break
|
||
else:
|
||
break
|
||
|
||
_response_format_error_patterns = ("response_format", "structured_outputs", "structured", "json_object")
|
||
if (not response.ok and response.status_code == 400
|
||
and any(p in (response.text or "").lower() for p in _response_format_error_patterns)):
|
||
logger.explore("Structured outputs not supported, retrying without response_format")
|
||
payload.pop("response_format", None)
|
||
response = http_requests.post(url, headers=headers, json=payload, timeout=600, verify=_get_verify())
|
||
|
||
if not response.ok:
|
||
logger.explore(f"LLM API error status={response.status_code} model={payload.get('model')} body={response.text[:2000]}")
|
||
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
choices = data.get("choices", [])
|
||
if not choices:
|
||
raise ValueError("LLM returned no choices")
|
||
|
||
try:
|
||
msg = choices[0].get("message") or {}
|
||
refusal = msg.get("refusal")
|
||
if refusal:
|
||
raise ValueError(f"LLM refused to respond: {refusal}")
|
||
content = msg.get("content") or ""
|
||
except TypeError as e:
|
||
raise ValueError(f"LLM response processing failed: {e}")
|
||
|
||
if not content:
|
||
raise ValueError("LLM returned empty content")
|
||
|
||
return content
|
||
# #endregion LLMClient
|