"""OpenAI-совместимый клиент Chat Completions (порт `services/llmClient.js`).""" from __future__ import annotations import os from dataclasses import dataclass from typing import Optional import urllib.request import urllib.error import json as _json class LlmError(Exception): """Ошибка работы с LLM API.""" def __init__(self, message: str, code: str = 'llm_error', status: int | None = None): super().__init__(message) self.code = code self.status = status @dataclass class LlmConfig: provider: str api_key: str base_url: str model: str def get_llm_config() -> Optional[LlmConfig]: if k := os.environ.get('DEEPSEEK_API_KEY'): return LlmConfig( provider='deepseek', api_key=k, base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.deepseek.com/v1').rstrip('/'), model=os.environ.get('LLM_MODEL') or 'deepseek-chat', ) if k := os.environ.get('OPENAI_API_KEY'): return LlmConfig( provider='openai', api_key=k, base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.openai.com/v1').rstrip('/'), model=os.environ.get('LLM_MODEL') or 'gpt-4o-mini', ) return None def chat_completion_text_content( cfg: LlmConfig, system: str, user: str, temperature: float = 0.25, timeout: int = 120, ) -> str: """Возвращает `assistant.message.content` (строку).""" body: dict = { 'model': cfg.model, 'messages': [ {'role': 'system', 'content': system}, {'role': 'user', 'content': user}, ], 'temperature': temperature, } if (os.environ.get('LLM_NO_JSON') or '').strip() != '1': body['response_format'] = {'type': 'json_object'} req = urllib.request.Request( f'{cfg.base_url}/chat/completions', data=_json.dumps(body).encode('utf-8'), headers={ 'Content-Type': 'application/json', 'Authorization': f'Bearer {cfg.api_key}', }, method='POST', ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = _json.loads(resp.read().decode('utf-8')) except urllib.error.HTTPError as e: text = '' try: text = e.read().decode('utf-8', errors='replace') except Exception: pass raise LlmError( f'LLM {e.code}: {(text or "").replace(chr(10), " ")[:280]}', code='llm_http', status=e.code, ) except (urllib.error.URLError, TimeoutError) as e: msg = str(getattr(e, 'reason', '') or e) if 'timed out' in msg.lower(): raise LlmError('Превышен таймаут ожидания ответа LLM (120 с).', code='llm_timeout') raise LlmError(f'Сбой сети при обращении к LLM: {msg}', code='llm_network') try: content = data['choices'][0]['message']['content'] except (KeyError, IndexError, TypeError): content = None if not isinstance(content, str) or not content.strip(): raise LlmError('Пустой content в ответе API.', code='llm_empty') return content def ping_llm(timeout: int = 30) -> dict: """Smoke-проверка подключения к LLM. Не бросает исключений — всё в результате. Возвращает: {'ok': bool, 'provider', 'model', 'error'?, 'latencyMs'?, 'sample'?} """ import time cfg = get_llm_config() if cfg is None: return { 'ok': False, 'configured': False, 'error': 'Ключ не задан. Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY в .env.', } started = time.monotonic() try: raw = chat_completion_text_content( cfg, 'Отвечай ТОЛЬКО JSON: {"ok": true}.', 'ping', temperature=0.0, timeout=timeout, ) ms = int((time.monotonic() - started) * 1000) return { 'ok': True, 'configured': True, 'provider': cfg.provider, 'model': cfg.model, 'latencyMs': ms, 'sample': raw[:120], } except LlmError as e: ms = int((time.monotonic() - started) * 1000) return { 'ok': False, 'configured': True, 'provider': cfg.provider, 'model': cfg.model, 'latencyMs': ms, 'error': str(e), 'code': e.code, } except Exception as e: return { 'ok': False, 'configured': True, 'provider': cfg.provider, 'model': cfg.model, 'error': f'{type(e).__name__}: {e}', 'code': 'unknown', }