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.

161 lines
5.1 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,
as_json: bool = True,
) -> str:
"""Возвращает `assistant.message.content` (строку).
`as_json=True` (по умолчанию) включает `response_format: json_object`. Для свободного
текста (например, объяснения к вопросу) передайте `as_json=False`.
"""
body: dict = {
'model': cfg.model,
'messages': [
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
'temperature': temperature,
}
if as_json and (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',
}