# #region LLMHttpLegacy [Tombstone] [TYPE Module] [SEMANTICS translate, llm, http, deprecated] # @BRIEF Legacy synchronous LLM HTTP client — DEPRECATED. # @DEPRECATED 2026-06-04 — replaced by _llm_async_http.py (httpx.AsyncClient) # @REPLACED_BY -> [LLMHttpClient] # The async equivalent is in _llm_async_http.py # structured output fallback. Extracted from _llm_call.py for INV_7 compliance. # @LAYER Infrastructure # @RELATION DEPENDS_ON -> [EXT:requests] # @PRE Valid API endpoint, key, model, and prompt. # @POST Returns (response text, finish_reason) tuple. # @SIDE_EFFECT HTTP POST to LLM API with optional retry on 429. # @RATIONALE Extracted from LLMTranslationService (793 lines) to keep module under INV_7 limit. # @REJECTED Single HTTP client class — kept as module-level functions for stateless reusability. import os import 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 # #region call_openai_compatible [C:3] [TYPE Function] [SEMANTICS translate, llm, http, openai] # @BRIEF Call OpenAI-compatible API with rate-limit handling and structured output fallback. # @PRE Valid API endpoint, key, model, and prompt. # @POST Returns (response text, finish_reason). # @SIDE_EFFECT HTTP POST to LLM API. 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, ) -> tuple[str, str | None]: """Call OpenAI-compatible API for batch translation.""" if not base_url: raise ValueError("LLM provider has no base_url configured") 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} " f"response_format={'yes' if 'response_format' in payload else 'no'} " f"prompt_len={len(prompt)}" ) response, response_text = _do_http_request(url, headers, payload) _handle_response_format_fallback(response, response_text, payload, url, headers) if not response.ok: logger.explore( f"LLM API error status={response.status_code} " f"model={payload.get('model')} " f"body={response_text[:2000]}" ) response.raise_for_status() data = response.json() choices = data.get("choices", []) if not choices: logger.explore("LLM returned no choices", extra={ "src": "executor", "response_keys": list(data.keys()), "response_preview": str(data)[:2000], }) raise ValueError("LLM returned no choices") try: finish_reason = choices[0].get("finish_reason") or "none" msg = choices[0].get("message") or {} except (TypeError, AttributeError) as e: logger.explore("TypeError processing LLM response choices", extra={ "src": "executor_diag", "error": str(e), "choices_0_type": type(choices[0]).__name__ if choices else "N/A", "choices_0_repr": repr(choices[0])[:2000] if choices else "N/A", "data_type": type(data).__name__, "data_preview": str(data)[:2000], }) raise ValueError(f"LLM response processing failed: {e}") # Log provider token usage for batch sizing calibration usage = data.get("usage") or {} if usage: logger.reason( "LLM provider usage", { "prompt_tokens": usage.get("prompt_tokens"), "completion_tokens": usage.get("completion_tokens"), "total_tokens": usage.get("total_tokens"), "finish_reason": finish_reason, "max_tokens_sent": max_tokens, "chars_sent": len(prompt), }, ) refusal = msg.get("refusal") if isinstance(msg, dict) else None if refusal: logger.explore("LLM refused to respond", extra={ "src": "executor", "refusal": str(refusal)[:500], "finish_reason": finish_reason, }) raise ValueError(f"LLM refused to respond: {refusal}") content = msg.get("content") if isinstance(msg, dict) else "" if not content and isinstance(msg, dict): content = msg.get("content") or "" logger.reason( f"LLM response finish_reason={finish_reason} content_len={len(content)} " f"msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}" ) if not content: logger.explore("LLM returned empty content", extra={ "src": "executor", "finish_reason": finish_reason, "msg_keys": list(msg.keys()) if isinstance(msg, dict) else [], "response_preview": str(data)[:2000], }) raise ValueError("LLM returned empty content") return content, finish_reason # #endregion call_openai_compatible # #region _do_http_request [C:1] [TYPE Function] def _do_http_request(url: str, headers: dict, payload: dict) -> tuple[Any, str]: """Make HTTP POST with rate-limit (429) retry handling.""" import requests as http_requests _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=180, verify=_get_verify()) response_text = response.text if response.status_code == 429: _retry_count_429 += 1 retry_after = response.headers.get("Retry-After") if retry_after: try: wait = int(retry_after) except (ValueError, TypeError): wait = 2 ** _retry_count_429 else: wait = 2 ** _retry_count_429 logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s", extra={"src": "executor", "retry_after": retry_after, "wait": wait}) time.sleep(wait) if _retry_count_429 >= _max_retry_429: break else: break return response, response_text # #endregion _do_http_request # #region _handle_response_format_fallback [C:1] [TYPE Function] def _handle_response_format_fallback( response: Any, response_text: str, payload: dict, url: str, headers: dict, ) -> None: """Handle 400 errors from structured_outputs not being supported.""" import requests as http_requests _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 _patterns) ): logger.explore("Structured outputs not supported, retrying without response_format", extra={"src": "executor"}) payload.pop("response_format", None) new_response = http_requests.post(url, headers=headers, json=payload, timeout=180, verify=_get_verify()) response.status_code = new_response.status_code response._content = new_response.content response.encoding = new_response.encoding response.headers = new_response.headers # #endregion _handle_response_format_fallback # #endregion LLMHttpClient