Files
ss-tools/backend/src/plugins/translate/preview_llm_client.py
busya d294e0295b fix: QA findings — GRACE contracts for _get_verify, fix _llm_http.py structure
- 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)
2026-05-29 11:10:11 +03:00

126 lines
5.3 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.

# #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