From b10ed102aa7620973132d70d2f0fc1869dc9b9b8 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 7 Jul 2026 10:51:16 +0300 Subject: [PATCH] added proxy --- docker-compose.yml | 33 +++++++ main.py | 4 +- requirements-docker.txt | 8 ++ routers/debug.py | 4 +- services/llm.py | 190 ++++++++++++++++++++++++++++++---------- static/js/chat.js | 2 +- 6 files changed, 192 insertions(+), 49 deletions(-) create mode 100644 docker-compose.yml create mode 100644 requirements-docker.txt diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f152314 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + app: + build: . + ports: + - "${APP_PORT:-8201}:${APP_PORT:-8201}" + env_file: + - .env + environment: + APP_PORT: ${APP_PORT:-8201} + DB_PATH: /app/data/chat.db + IMAGES_DIR: /app/static/images + LLM_BASE_URL: ${LLM_BASE_URL:-} + LLM_API_KEY: ${LLM_API_KEY:-} + ROUTER_KEY: ${ROUTER_KEY:-} + LLM_PROXY: ${LLM_PROXY:-} + LLM_SOCKS5: ${LLM_SOCKS5:-} + LLM_SOCKS5_PROXY: ${LLM_SOCKS5_PROXY:-} + CHAT_MODEL: ${CHAT_MODEL:-} + SYSTEM_MODEL: ${SYSTEM_MODEL:-} + LLM_FALLBACK_MODEL: ${LLM_FALLBACK_MODEL:-} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + # Папки на хосте — та же БД и static, что при запуске без Docker + - ./data:/app/data + - ./static:/app/static + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "python -c \"import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('APP_PORT','8201')+'/health')\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s \ No newline at end of file diff --git a/main.py b/main.py index 869840c..8278fa1 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from routers import chat, personas, sessions, characters, images, translate, debug from database.db import init_db +from services.llm import get_llm_settings, log_llm_startup_config from services.persona_seed import seed_default_personas from services.system_message_migration import migrate_static_system_messages @@ -13,6 +14,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(messag @asynccontextmanager async def lifespan(app: FastAPI): + log_llm_startup_config() await init_db() await seed_default_personas() await migrate_static_system_messages() @@ -44,4 +46,4 @@ async def debug_page(): @app.get("/health") async def health(): - return {"status": "ok"} + return {"status": "ok", "llm": get_llm_settings().public_status()} diff --git a/requirements-docker.txt b/requirements-docker.txt new file mode 100644 index 0000000..923b7d4 --- /dev/null +++ b/requirements-docker.txt @@ -0,0 +1,8 @@ +fastapi==0.118.0 +uvicorn==0.38.0 +aiosqlite==0.22.1 +httpx[socks]==0.28.1 +pydantic==2.12.5 +python-dotenv==1.2.2 +python-multipart==0.0.20 +aiofiles==25.1.0 diff --git a/routers/debug.py b/routers/debug.py index af4532f..e9eb525 100644 --- a/routers/debug.py +++ b/routers/debug.py @@ -11,6 +11,7 @@ from services.llm import ( LLM_FALLBACK_MODEL, LLMError, SYSTEM_MODEL, + get_llm_settings, send_message, send_message_with_model, ) @@ -78,7 +79,8 @@ async def debug_config(): "sd_checkpoint": sd_service.SD_CHECKPOINT, "sd_steps": sd_service.SD_STEPS, "sd_cfg": sd_service.SD_CFG, - "router_key_set": bool(os.getenv("ROUTER_KEY")), + "router_key_set": bool(os.getenv("ROUTER_KEY") or os.getenv("LLM_API_KEY")), + "llm": get_llm_settings().public_status(), } diff --git a/services/llm.py b/services/llm.py index 81d95de..ea92bf5 100644 --- a/services/llm.py +++ b/services/llm.py @@ -2,46 +2,120 @@ import httpx import json import logging import os +import re +from dataclasses import dataclass +from urllib.parse import urlparse, urlunparse + from dotenv import load_dotenv load_dotenv() logger = logging.getLogger(__name__) -# ── Provider / transport configuration ───────────────────────────── -# По умолчанию работаем через OpenRouter, но базовый URL и ключ -# можно переопределить для любого совместимого провайдера. -LLM_BASE_URL = ( - os.getenv("LLM_BASE_URL") - or "https://openrouter.ai/api/v1/chat/completions" -) -LLM_API_KEY = os.getenv("LLM_API_KEY") or os.getenv("ROUTER_KEY") -LLM_AUTH_HEADER = os.getenv("LLM_AUTH_HEADER", "Authorization") -LLM_AUTH_PREFIX = os.getenv("LLM_AUTH_PREFIX", "Bearer ") +DEFAULT_LLM_BASE_URL = "https://openrouter.ai/api/v1/chat/completions" -# SOCKS5 / HTTP‑прокси (например: socks5://user:pass@host:1080) -LLM_PROXY_URL = ( - os.getenv("LLM_PROXY") - or os.getenv("LLM_SOCKS5") - or os.getenv("LLM_SOCKS5_PROXY") -) -PROXIES = {"all": LLM_PROXY_URL} if LLM_PROXY_URL else None -# ── Модели ───────────────────────────────────────────────────────── -CHAT_MODEL = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo") -SYSTEM_MODEL = os.getenv("SYSTEM_MODEL", "google/gemini-2.5-flash") -# Softer model when primary returns content_filter / empty / API errors (default: CHAT_MODEL). -LLM_FALLBACK_MODEL = (os.getenv("LLM_FALLBACK_MODEL") or "").strip() or CHAT_MODEL +def _normalize_proxy_url(raw: str | None) -> str | None: + """Приводит socks5://... к виду, который понимает httpx.""" + if not raw: + return None + url = raw.strip() + url = re.sub(r"^(socks5:)+//", "socks5://", url, flags=re.IGNORECASE) + if url.lower().startswith("socks5:") and not url.lower().startswith("socks5://"): + url = "socks5://" + url.split(":", 1)[1].lstrip("/") + return url or None -HEADERS = { - LLM_AUTH_HEADER: f"{LLM_AUTH_PREFIX}{LLM_API_KEY}" if LLM_API_KEY else "", - "Content-Type": "application/json", - "HTTP-Referer": "http://localhost:8000", -} + +def _redact_proxy(url: str) -> str: + parsed = urlparse(url) + if not parsed.hostname: + return "***" + host = parsed.hostname + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunparse((parsed.scheme, f"***@{host}", "", "", "", "")) + + +@dataclass(frozen=True) +class LLMSettings: + base_url: str + api_key: str + auth_header: str + auth_prefix: str + proxy_url: str | None + chat_model: str + system_model: str + fallback_model: str + + @property + def headers(self) -> dict[str, str]: + return { + self.auth_header: f"{self.auth_prefix}{self.api_key}" if self.api_key else "", + "Content-Type": "application/json", + "HTTP-Referer": "http://localhost:8000", + } + + def public_status(self) -> dict: + return { + "base_url": self.base_url, + "api_key_set": bool(self.api_key), + "proxy": _redact_proxy(self.proxy_url) if self.proxy_url else None, + "chat_model": self.chat_model, + "system_model": self.system_model, + "fallback_model": self.fallback_model, + } + + +def get_llm_settings() -> LLMSettings: + """Читает конфиг из os.environ при каждом вызове (важно для Docker env_file).""" + chat_model = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo") + fallback = (os.getenv("LLM_FALLBACK_MODEL") or "").strip() or chat_model + return LLMSettings( + base_url=(os.getenv("LLM_BASE_URL") or DEFAULT_LLM_BASE_URL).strip(), + api_key=(os.getenv("LLM_API_KEY") or os.getenv("ROUTER_KEY") or "").strip(), + auth_header=os.getenv("LLM_AUTH_HEADER", "Authorization"), + auth_prefix=os.getenv("LLM_AUTH_PREFIX", "Bearer "), + proxy_url=_normalize_proxy_url( + os.getenv("LLM_PROXY") + or os.getenv("LLM_SOCKS5") + or os.getenv("LLM_SOCKS5_PROXY") + ), + chat_model=chat_model, + system_model=os.getenv("SYSTEM_MODEL", "google/gemini-2.5-flash"), + fallback_model=fallback, + ) + + +def _llm_client(settings: LLMSettings, **kwargs) -> httpx.AsyncClient: + """httpx 0.28+: proxy= (не proxies=); для socks5 нужен пакет socksio.""" + if settings.proxy_url: + kwargs["proxy"] = settings.proxy_url + return httpx.AsyncClient(**kwargs) + + +def log_llm_startup_config() -> None: + settings = get_llm_settings() + status = settings.public_status() + logger.info( + "LLM config: url=%s key_set=%s proxy=%s chat_model=%s", + status["base_url"], + status["api_key_set"], + status["proxy"] or "disabled", + status["chat_model"], + ) + + +# Обратная совместимость для импортов из других модулей. +_settings = get_llm_settings() +CHAT_MODEL = _settings.chat_model +SYSTEM_MODEL = _settings.system_model +LLM_FALLBACK_MODEL = _settings.fallback_model + +log_llm_startup_config() class LLMError(Exception): - """OpenRouter returned an error or an unexpected response shape.""" + """LLM provider returned an error or an unexpected response shape.""" def _parse_completion_body(data: dict) -> str: @@ -57,17 +131,17 @@ def _parse_completion_body(data: dict) -> str: msg = str(err) code = None suffix = f" (code={code})" if code is not None else "" - raise LLMError(f"OpenRouter error{suffix}: {msg}") + raise LLMError(f"LLM provider error{suffix}: {msg}") choices = data.get("choices") if not choices: preview = str(data)[:400] - raise LLMError(f"OpenRouter response has no 'choices'. Body preview: {preview}") + raise LLMError(f"LLM response has no 'choices'. Body preview: {preview}") first = choices[0] if isinstance(choices[0], dict) else {} message = first.get("message") or {} if not isinstance(message, dict): - raise LLMError("OpenRouter choice has no message object") + raise LLMError("LLM choice has no message object") finish = first.get("finish_reason") or "" native_finish = first.get("native_finish_reason") or "" @@ -91,10 +165,10 @@ def _parse_completion_body(data: dict) -> str: if finish and finish not in ("stop", "length", "tool_calls", "function_call"): raise LLMError( - f"OpenRouter finished without content (finish_reason={finish}, native={native_finish})" + f"LLM finished without content (finish_reason={finish}, native={native_finish})" ) - raise LLMError("OpenRouter returned empty message content") + raise LLMError("LLM returned empty message content") def _clean(messages: list) -> list: @@ -103,12 +177,13 @@ def _clean(messages: list) -> list: async def _post_once(model: str, messages: list, extra: dict | None = None) -> str: - if not LLM_API_KEY: - raise LLMError("LLM_API_KEY / ROUTER_KEY is not set in environment") + settings = get_llm_settings() + if not settings.api_key: + raise LLMError("LLM_API_KEY (or ROUTER_KEY) is not set in environment") payload = {"model": model, "messages": _clean(messages), **(extra or {})} - async with httpx.AsyncClient(timeout=90, proxies=PROXIES) as client: - r = await client.post(LLM_BASE_URL, headers=HEADERS, json=payload) + async with _llm_client(settings, timeout=90) as client: + r = await client.post(settings.base_url, headers=settings.headers, json=payload) try: data = r.json() except Exception as e: @@ -125,7 +200,8 @@ async def _post_once(model: str, messages: list, extra: dict | None = None) -> s return _parse_completion_body(data) except LLMError: logger.warning( - "LLM completion failed model=%s status=%s body=%.500s", + "LLM completion failed url=%s model=%s status=%s body=%.500s", + settings.base_url, model, r.status_code, data, @@ -135,10 +211,11 @@ async def _post_once(model: str, messages: list, extra: dict | None = None) -> s async def _post(model: str, messages: list, extra: dict | None = None) -> str: """POST completion; on failure retries once with LLM_FALLBACK_MODEL (usually CHAT_MODEL).""" + settings = get_llm_settings() try: return await _post_once(model, messages, extra) except LLMError as primary_err: - fallback = LLM_FALLBACK_MODEL + fallback = settings.fallback_model if not fallback or fallback == model: raise logger.info( @@ -157,7 +234,7 @@ async def _post(model: str, messages: list, extra: dict | None = None) -> str: async def send_message(messages: list) -> str: """SYSTEM_MODEL with automatic fallback to LLM_FALLBACK_MODEL.""" - return await _post(SYSTEM_MODEL, messages) + return await _post(get_llm_settings().system_model, messages) async def send_message_with_model(messages: list, model: str) -> str: @@ -167,17 +244,33 @@ async def send_message_with_model(messages: list, model: str) -> str: async def stream_message(messages: list): """Chat model stream — roleplay dialogue.""" + settings = get_llm_settings() + if not settings.api_key: + raise LLMError("LLM_API_KEY (or ROUTER_KEY) is not set in environment") + payload = { - "model": CHAT_MODEL, + "model": settings.chat_model, "messages": _clean(messages), "stream": True, } timeout = httpx.Timeout(connect=10, read=120, write=10, pool=5) chunk_count = 0 - async with httpx.AsyncClient(timeout=timeout, proxies=PROXIES) as client: + async with _llm_client(settings, timeout=timeout) as client: try: - async with client.stream("POST", LLM_BASE_URL, headers=HEADERS, json=payload) as response: - response.raise_for_status() + async with client.stream( + "POST", settings.base_url, headers=settings.headers, json=payload + ) as response: + if response.status_code >= 400: + body = await response.aread() + try: + data = json.loads(body) + _parse_completion_body(data) + except LLMError: + raise + except Exception: + raise LLMError( + f"HTTP {response.status_code}: {body.decode('utf-8', errors='replace')[:300]}" + ) buf = "" async for raw in response.aiter_bytes(): text = raw.decode("utf-8", errors="replace") @@ -197,7 +290,7 @@ async def stream_message(messages: list): if chunk.get("error"): err = chunk["error"] msg = err.get("message", err) if isinstance(err, dict) else err - raise LLMError(f"OpenRouter stream error: {msg}") + raise LLMError(f"LLM stream error: {msg}") choices = chunk.get("choices") or [] if not choices: continue @@ -210,7 +303,12 @@ async def stream_message(messages: list): except Exception: continue except Exception as e: - logger.error("stream_message error after %d chunks: %s", chunk_count, e) + logger.error( + "stream_message error url=%s after %d chunks: %s", + settings.base_url, + chunk_count, + e, + ) raise finally: logger.info("stream_message finished: %d chunks", chunk_count) diff --git a/static/js/chat.js b/static/js/chat.js index 0ee2bfb..8e46b2d 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -295,7 +295,7 @@ function showNarratorActivityHint(wrapper, meta) { wrapper.querySelector('.narrator-activity-hint')?.remove(); const parts = []; if (meta.post_ok === false && meta.pre_ok === false) { - parts.push('⚠️ Narrator LLM не ответил — проверьте ROUTER_KEY / RPG_NARRATOR_MODEL'); + parts.push('⚠️ Narrator LLM не ответил — проверьте LLM_API_KEY / RPG_NARRATOR_MODEL'); } else { if (meta.dice) parts.push('🎲 бросок'); if (meta.directives_count > 0) parts.push(`📋 ${meta.directives_count} указаний`);