Files
site/WESP_REL/app/services/local_llm_autostart.py
2026-07-17 12:57:18 +03:00

147 lines
4.2 KiB
Python

"""Фоновый запуск llama-server при старте WESP (если WESP_LLM_AUTOSTART и LLM включён)."""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_proc: Optional[subprocess.Popen] = None
def _should_run_in_this_process(app: Any) -> bool:
"""Не дублировать в родителе Flask reloader (как sync_client)."""
if bool(app.config.get("DEBUG", False)):
return os.environ.get("WERKZEUG_RUN_MAIN") == "true"
return True
def _parse_base_url(url: str) -> tuple[str, int]:
u = urlparse((url or "").strip() or "http://127.0.0.1:8080")
host = u.hostname or "127.0.0.1"
port = int(u.port or 8080)
return host, port
def _resolve_llama_binary(base_dir: Path) -> Optional[Path]:
env = os.getenv("LLAMA_SERVER", "").strip()
if env:
p = Path(env)
if p.is_file():
return p
if sys.platform == "win32":
for rel in ("tools/llama-server.exe", "tools/llama-server/llama-server.exe"):
cand = base_dir / rel.replace("/", os.sep)
if cand.is_file():
return cand
else:
cand = base_dir / "tools" / "llama-server"
if cand.is_file():
return cand
which = shutil.which("llama-server")
if which:
return Path(which)
return None
def _gguf_path(app: Any) -> Path:
cfg = app.config
root = Path(str(cfg.get("WESP_ASSISTANT_DIR") or ""))
name = os.getenv("WESP_LLM_GGUF_FILE", "").strip() or str(
cfg.get("WESP_LLM_MODEL") or ""
).strip()
if not name:
name = "qwen2.5-1.5b-instruct-q5_k_m.gguf"
return root / "models" / name
def schedule_llm_server_autostart(app: Any) -> None:
"""Запускает llama-server дочерним процессом; idempotent."""
global _proc
if _proc is not None:
return
if app.config.get("TESTING"):
return
if not app.config.get("WESP_LLM_AUTOSTART"):
return
if not app.config.get("WESP_ADMIN_LLM_ENABLED"):
logger.warning(
"WESP_LLM_AUTOSTART пропущен: LLM в админке выключен (галка или admin_llm_enabled в wesp_security_settings.json, либо WESP_ADMIN_LLM_ENABLED=0)."
)
return
if not _should_run_in_this_process(app):
return
base_dir = Path(str(app.config.get("BASE_DIR") or ".")).resolve()
gguf = _gguf_path(app)
if not gguf.is_file():
logger.warning(
"WESP_LLM_AUTOSTART пропущен: нет файла модели %s",
gguf,
)
return
bin_path = _resolve_llama_binary(base_dir)
if not bin_path:
logger.warning(
"WESP_LLM_AUTOSTART пропущен: не найден llama-server "
"(переменная LLAMA_SERVER или %s/tools/).",
base_dir,
)
return
host, port = _parse_base_url(str(app.config.get("WESP_LLM_BASE_URL") or ""))
args = [
str(bin_path),
"-m",
str(gguf),
"--host",
host,
"--port",
str(port),
"-c",
"8192",
]
cwd = str(bin_path.parent)
popen_kw: dict = {"cwd": cwd}
if sys.platform == "win32":
popen_kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
try:
_proc = subprocess.Popen(args, **popen_kw)
except OSError as exc:
logger.warning("WESP_LLM_AUTOSTART: не удалось запустить llama-server: %s", exc)
return
logger.info(
"WESP_LLM_AUTOSTART: llama-server PID=%s, %s:%s, модель %s",
_proc.pid,
host,
port,
gguf.name,
)
def stop_llm_server_process() -> None:
"""Завершить дочерний llama-server при выходе из процесса WESP."""
global _proc
if _proc is None:
return
p = _proc
_proc = None
try:
p.terminate()
p.wait(timeout=8)
except Exception:
try:
p.kill()
except Exception:
pass