Author SHA1 Message Date
Eleboba 3afef3f9ad Merge pull request 'release: dev → main' (#54) from dev into main
CD / deploy (push) Successful in 1m3s
CI / ruff · mypy · pytest (push) Successful in 17m2s
2026-10-22 17:50:43 +03:00
Eleboba 7a12b3525a Merge pull request 'feat(llm): OpenRouter через прокси — агент логов' (#53) from feat/52-openrouter into dev
CD / deploy (push) Successful in 55s
CI / ruff · mypy · pytest (push) Successful in 1m44s
CI / ruff · mypy · pytest (pull_request) Successful in 16m15s
Reviewed-on: #53
2026-10-22 17:42:30 +03:00
ElebobaandClaude Opus 5.5 38bb238651 feat(llm): прокси до LLM и ключ OPENROUTER_API_KEY — OpenRouter для агента логов
CI / ruff · mypy · pytest (pull_request) Successful in 2m44s
- LLM_PROXY_URL (SecretStr): запросы к LLM через HTTP/SOCKS-прокси — OpenRouter
  не принимает IP из РФ, как в meditron (OPENROUTER_PROXY_URL)
- ключ читается из LLM_API_KEY или OPENROUTER_API_KEY (первый важнее)
- .env.example, README, DEPLOY: настройка OpenRouter

Refs #52

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:09:19 +03:00
Eleboba a0d48d8cbc Merge pull request 'docs: презентация и сценарий защиты' (#51) from docs/20-presentation into dev
CD / deploy (push) Successful in 25s
CI / ruff · mypy · pytest (push) Successful in 1m50s
Reviewed-on: #51
2026-10-19 13:48:39 +03:00
6 changed files with 57 additions and 8 deletions
+6 -2
View File
@@ -42,11 +42,15 @@ CI_TOKEN= # (secret) токен Gitea с чтением р
# RUNNER_USER=tgit-deploy # RUNNER_USER=tgit-deploy
# RUNNER_SSH_KEY_B64= # (secret) base64 ключа, прибитого к tgit-runner в authorized_keys # RUNNER_SSH_KEY_B64= # (secret) base64 ключа, прибитого к tgit-runner в authorized_keys
# --- LLM (#17) --- # --- LLM (#17, #52): агент логов ---
LLM_PROVIDER=mock # mock | openai_compat; при mock агент логов выключен (#18) 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_BASE_URL=
LLM_MODEL= 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: трекер (не нужно боту) --- # --- Для Claude Code: трекер (не нужно боту) ---
GITEA_URL=https://gitea.24studbank.ru GITEA_URL=https://gitea.24studbank.ru
+2 -1
View File
@@ -114,7 +114,8 @@ CD из Gitea Actions разворачивает `dev` на сервер скр
| `RUNNER_HOST`, `RUNNER_SSH_KEY_B64` | нет | `tgit-runner` на хосте; без него сервисы не разворачиваются | | `RUNNER_HOST`, `RUNNER_SSH_KEY_B64` | нет | `tgit-runner` на хосте; без него сервисы не разворачиваются |
| `DATABASE_URL` | нет | по умолчанию SQLite; в Compose — PostgreSQL | | `DATABASE_URL` | нет | по умолчанию SQLite; в Compose — PostgreSQL |
| `DOCKER_URL` | нет | docker-socket-proxy; без него «Статус», «Логи» и алерты отвечают «Docker недоступен» | | `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 из РФ |
### Проверки ### Проверки
+10 -4
View File
@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Annotated, Literal 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 from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
_ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") _ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@@ -35,13 +35,19 @@ class AccessSettings(BaseSettings):
class LLMSettings(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" provider: Literal["mock", "openai_compat"] = "mock"
base_url: str = "" base_url: str = ""
model: 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) timeout: float = Field(default=30.0, gt=0)
max_attempts: int = Field(default=3, ge=1) max_attempts: int = Field(default=3, ge=1)
temperature: float = Field(default=0.0, ge=0, le=2) temperature: float = Field(default=0.0, ge=0, le=2)
+2 -1
View File
@@ -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 asyncio
import logging import logging
@@ -34,6 +34,7 @@ class OpenAICompatProvider:
headers={"Authorization": f"Bearer {settings.api_key.get_secret_value()}"}, headers={"Authorization": f"Bearer {settings.api_key.get_secret_value()}"},
timeout=settings.timeout, timeout=settings.timeout,
transport=transport, transport=transport,
proxy=settings.proxy_url.get_secret_value() if settings.proxy_url else None,
) )
async def aclose(self) -> None: async def aclose(self) -> None:
+1
View File
@@ -13,6 +13,7 @@ CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` →
| Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) | | Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) |
| Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью | | Репозиторий загрузок | временно `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 | | 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`). Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`).
+36
View File
@@ -137,3 +137,39 @@ def test_factory_picks_provider() -> None:
) )
def test_parse_json_object(text: str, expected: dict[str, Any] | None) -> None: def test_parse_json_object(text: str, expected: dict[str, Any] | None) -> None:
assert parse_json_object(text) == expected 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)