diff --git a/.env.example b/.env.example index 8884702..e7da0d4 100644 --- a/.env.example +++ b/.env.example @@ -42,11 +42,15 @@ CI_TOKEN= # (secret) токен Gitea с чтением р # RUNNER_USER=tgit-deploy # RUNNER_SSH_KEY_B64= # (secret) base64 ключа, прибитого к tgit-runner в authorized_keys -# --- LLM (#17) --- +# --- LLM (#17, #52): агент логов --- LLM_PROVIDER=mock # mock | openai_compat; при mock агент логов выключен (#18) +# OpenRouter (как в meditron): модель обязана поддерживать structured output (json_schema) +# LLM_BASE_URL=https://openrouter.ai/api/v1 +# LLM_MODEL=google/gemini-2.5-flash-lite LLM_BASE_URL= LLM_MODEL= -LLM_API_KEY= # (secret) +LLM_API_KEY= # (secret) или OPENROUTER_API_KEY — то же самое +LLM_PROXY_URL= # (secret) http://user:pass@host:port — OpenRouter не пускает IP из РФ; на сервере тот же прокси, что TELEGRAM_PROXY_URL # --- Для Claude Code: трекер (не нужно боту) --- GITEA_URL=https://gitea.24studbank.ru diff --git a/README.md b/README.md index 76f081a..f62893f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,8 @@ CD из Gitea Actions разворачивает `dev` на сервер скр | `RUNNER_HOST`, `RUNNER_SSH_KEY_B64` | нет | `tgit-runner` на хосте; без него сервисы не разворачиваются | | `DATABASE_URL` | нет | по умолчанию SQLite; в Compose — PostgreSQL | | `DOCKER_URL` | нет | docker-socket-proxy; без него «Статус», «Логи» и алерты отвечают «Docker недоступен» | -| `LLM_PROVIDER`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` | нет | LLM для агента; при `mock` агент выключен | +| `LLM_PROVIDER`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` | нет | LLM для агента (OpenAI-совместимый, например OpenRouter); при `mock` агент выключен | +| `LLM_PROXY_URL` | нет | прокси до LLM — OpenRouter не принимает запросы с IP из РФ | ### Проверки diff --git a/app/config.py b/app/config.py index 82e81a0..f0d675e 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Annotated, Literal -from pydantic import Field, SecretStr, field_validator +from pydantic import AliasChoices, Field, SecretStr, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict _ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -35,13 +35,19 @@ class AccessSettings(BaseSettings): class LLMSettings(BaseSettings): - model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_") + model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_", populate_by_name=True) - # mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008) + # mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008), + # e.g. OpenRouter: LLM_BASE_URL=https://openrouter.ai/api/v1 provider: Literal["mock", "openai_compat"] = "mock" base_url: str = "" model: str = "" - api_key: SecretStr = SecretStr("") + # OPENROUTER_API_KEY is accepted too — the name meditron and Vobot use for the same key + api_key: SecretStr = Field( + default=SecretStr(""), validation_alias=AliasChoices("LLM_API_KEY", "OPENROUTER_API_KEY") + ) + # OpenRouter refuses RF IPs: the server goes through an HTTP/SOCKS proxy (as meditron does) + proxy_url: SecretStr | None = None timeout: float = Field(default=30.0, gt=0) max_attempts: int = Field(default=3, ge=1) temperature: float = Field(default=0.0, ge=0, le=2) diff --git a/app/llm/openai_compat.py b/app/llm/openai_compat.py index 0e9f9de..4fd403b 100644 --- a/app/llm/openai_compat.py +++ b/app/llm/openai_compat.py @@ -1,4 +1,4 @@ -"""Any OpenAI-style `/chat/completions` endpoint: OpenAI, YandexGPT compatibility API, a local model server.""" +"""Any OpenAI-style `/chat/completions` endpoint: OpenRouter, OpenAI, YandexGPT compatibility API, a local server.""" import asyncio import logging @@ -34,6 +34,7 @@ class OpenAICompatProvider: headers={"Authorization": f"Bearer {settings.api_key.get_secret_value()}"}, timeout=settings.timeout, transport=transport, + proxy=settings.proxy_url.get_secret_value() if settings.proxy_url else None, ) async def aclose(self) -> None: diff --git a/docs/dev/DEPLOY.md b/docs/dev/DEPLOY.md index db6f578..a575e8b 100644 --- a/docs/dev/DEPLOY.md +++ b/docs/dev/DEPLOY.md @@ -13,6 +13,7 @@ CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` → | Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) | | Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью | | CI загрузок | Gitea Actions `HSE/tgit-uploads` (`check.yml`); боту в `.env` — `CI_PROVIDER=gitea`, `CI_TOKEN` (чтение репозитория загрузок). Без них деплой сервисов идёт сразу после push, без CI | +| Агент логов | `LLM_PROVIDER=openai_compat`, `LLM_BASE_URL=https://openrouter.ai/api/v1`, `LLM_MODEL`, `LLM_API_KEY`, `LLM_PROXY_URL` (тот же прокси, что `TELEGRAM_PROXY_URL`) — OpenRouter блокирует IP из РФ | Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`). diff --git a/tests/llm/test_providers.py b/tests/llm/test_providers.py index db38204..3c266f5 100644 --- a/tests/llm/test_providers.py +++ b/tests/llm/test_providers.py @@ -137,3 +137,39 @@ def test_factory_picks_provider() -> None: ) def test_parse_json_object(text: str, expected: dict[str, Any] | None) -> None: assert parse_json_object(text) == expected + + +def test_openrouter_key_name_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + assert LLMSettings(_env_file=None).api_key.get_secret_value() == "or-key" # type: ignore[call-arg] + + monkeypatch.setenv("LLM_API_KEY", "llm-key") # the project's own name wins + assert LLMSettings(_env_file=None).api_key.get_secret_value() == "llm-key" # type: ignore[call-arg] + + +async def test_requests_go_through_the_proxy() -> None: + seen: list[str] = [] + + class Proxy(httpx.AsyncBaseTransport): + """Stands in for the HTTP proxy: httpx routes requests to the transport mounted for the proxy.""" + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + return httpx.Response(200, json=completion("через прокси")) + + provider = OpenAICompatProvider(settings(proxy_url="http://user:pass@proxy.example.test:3128")) + mounts = provider._http._mounts # httpx keeps the proxy transport here, no public accessor + assert mounts, "no proxy transport mounted" + provider._http._mounts = {pattern: Proxy() for pattern in mounts} + + result = await provider.complete([ChatMessage("user", "x")]) + await provider.aclose() + + assert result.text == "через прокси" and seen == [f"{BASE}/chat/completions"] + + +def test_proxy_is_a_secret() -> None: + s = settings(proxy_url="http://user:pass@proxy.example.test:3128") + assert "pass" not in repr(s)