You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
4.8 KiB
156 lines
4.8 KiB
"""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', |
|
}
|
|
|