Compare commits
3
Commits
a0d48d8cbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3afef3f9ad | ||
|
|
7a12b3525a | ||
|
|
38bb238651 |
+6
-2
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user