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