Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+158
View File
@@ -0,0 +1,158 @@
"""HTTP-клиент к локальному LLM с OpenAI-совместимым API (например llama-server из llama.cpp)."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import requests
class LocalLlmError(Exception):
"""Локальный LLM-сервер недоступен или вернул ошибку."""
def _auth_headers(api_key: str) -> Dict[str, str]:
k = (api_key or "").strip()
if not k:
return {}
return {"Authorization": f"Bearer {k}"}
def openai_list_models(
*, base_url: str, timeout_sec: float, api_key: str = ""
) -> Dict[str, Any]:
"""GET /v1/models — как у OpenAI; llama-server поддерживает."""
url = f"{base_url.rstrip('/')}/v1/models"
try:
r = requests.get(url, timeout=timeout_sec, headers=_auth_headers(api_key))
except requests.RequestException as e:
raise LocalLlmError(f"LLM недоступен: {e}") from e
if r.status_code != 200:
raise LocalLlmError(f"LLM GET /v1/models: HTTP {r.status_code}")
try:
return r.json()
except ValueError as e:
raise LocalLlmError("LLM /v1/models: не JSON") from e
def model_in_openai_payload(payload: Dict[str, Any], want: str) -> bool:
"""Проверяет, есть ли в ответе /v1/models модель с id, совпадающим с want (без учёта регистра)."""
want_l = (want or "").strip().lower()
if not want_l:
return False
data = payload.get("data")
if not isinstance(data, list):
return False
for item in data:
if not isinstance(item, dict):
continue
mid = str(item.get("id") or "").strip().lower()
if not mid:
continue
if mid == want_l or mid.endswith("/" + want_l) or mid.endswith("\\" + want_l):
return True
# имя файла без пути
if mid.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] == want_l:
return True
return False
def _normalize_message_content(content: Any) -> Optional[str]:
if content is None:
return None
if isinstance(content, str):
return content.strip() or None
if isinstance(content, list):
parts: List[str] = []
for c in content:
if isinstance(c, dict) and c.get("type") == "text":
parts.append(str(c.get("text") or ""))
elif isinstance(c, str):
parts.append(c)
joined = "".join(parts).strip()
return joined or None
return str(content).strip() or None
def _parse_assistant_message(data: Dict[str, Any]) -> Dict[str, Any]:
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
raise LocalLlmError("LLM /v1/chat/completions: нет choices")
first = choices[0]
if not isinstance(first, dict):
raise LocalLlmError("LLM /v1/chat/completions: choices[0] не объект")
msg = first.get("message")
if not isinstance(msg, dict):
raise LocalLlmError("LLM /v1/chat/completions: нет message")
content = _normalize_message_content(msg.get("content"))
tool_calls = msg.get("tool_calls")
if tool_calls is not None and not isinstance(tool_calls, list):
tool_calls = None
return {
"content": content,
"tool_calls": tool_calls,
}
def openai_chat_completion_message(
*,
base_url: str,
model: str,
messages: List[Dict[str, Any]],
timeout_sec: float,
max_tokens: int = 512,
api_key: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[str] = None,
) -> Dict[str, Any]:
"""POST /v1/chat/completions — возвращает content и опционально tool_calls."""
url = f"{base_url.rstrip('/')}/v1/chat/completions"
body: Dict[str, Any] = {
"model": model,
"messages": messages,
"max_tokens": max(16, min(int(max_tokens), 4096)),
}
if tools:
body["tools"] = tools
body["tool_choice"] = (tool_choice or "auto") if tool_choice else "auto"
try:
r = requests.post(url, json=body, timeout=timeout_sec, headers=_auth_headers(api_key))
except requests.RequestException as e:
raise LocalLlmError(f"LLM недоступен: {e}") from e
if r.status_code != 200:
raise LocalLlmError(f"LLM /v1/chat/completions: HTTP {r.status_code} {r.text[:500]}")
try:
data = r.json()
except ValueError as e:
raise LocalLlmError("LLM /v1/chat/completions: не JSON") from e
return _parse_assistant_message(data)
def openai_chat_completion(
*,
base_url: str,
model: str,
messages: List[Dict[str, Any]],
timeout_sec: float,
max_tokens: int = 512,
api_key: str = "",
) -> str:
"""POST /v1/chat/completions — ответ ассистента из choices[0].message.content (без tool_calls)."""
msg = openai_chat_completion_message(
base_url=base_url,
model=model,
messages=messages,
timeout_sec=timeout_sec,
max_tokens=max_tokens,
api_key=api_key,
tools=None,
tool_choice=None,
)
content = msg.get("content")
if isinstance(content, str) and content.strip():
return content.strip()
if msg.get("tool_calls"):
raise LocalLlmError(
"LLM вернул вызовы инструментов без финального текста; включите режим инструментов в чате."
)
raise LocalLlmError("LLM /v1/chat/completions: нет choices[0].message.content")