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>
162 lines
7.1 KiB
Python
162 lines
7.1 KiB
Python
"""Typed configuration from the environment / `.env` — one settings class per subsystem (ARCHITECTURE §2.11)."""
|
|
|
|
from pathlib import Path
|
|
from typing import Annotated, Literal
|
|
|
|
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")
|
|
|
|
|
|
class TelegramSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
|
|
|
|
bot_token: SecretStr
|
|
# http://user:pass@host:port or socks5://… — the RF server has no route to api.telegram.org (same as meditron)
|
|
proxy_url: SecretStr | None = None
|
|
|
|
|
|
class AccessSettings(BaseSettings):
|
|
model_config = _ENV
|
|
|
|
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
|
|
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
|
|
invite_ttl_hours: float = Field(default=24.0, gt=0)
|
|
# More failed /start <token> per hour than this — silence (SPEC A-6)
|
|
max_failed_attempts: int = Field(default=5, ge=1)
|
|
|
|
@field_validator("admin_ids", mode="before")
|
|
@classmethod
|
|
def _split_ids(cls, value: object) -> object:
|
|
if isinstance(value, str):
|
|
return frozenset(int(part) for part in value.replace(" ", "").split(",") if part)
|
|
return value
|
|
|
|
|
|
class LLMSettings(BaseSettings):
|
|
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),
|
|
# e.g. OpenRouter: LLM_BASE_URL=https://openrouter.ai/api/v1
|
|
provider: Literal["mock", "openai_compat"] = "mock"
|
|
base_url: str = ""
|
|
model: str = ""
|
|
# 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)
|
|
|
|
|
|
class DatabaseSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="DATABASE_")
|
|
|
|
# Compose sets a PostgreSQL URL; the default lets the bot run locally without Docker.
|
|
url: str = "sqlite+aiosqlite:///./tgit.db"
|
|
|
|
|
|
class SourceCraftSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="SOURCECRAFT_")
|
|
|
|
token: SecretStr = SecretStr("")
|
|
api_url: str = "https://api.sourcecraft.tech"
|
|
uploads_repo: str = "rogachevegorushka/tgit-uploads"
|
|
# Web link to a CI run. The SPA answers 200 on any path, so the format is unverified — adjust if it 404s in UI.
|
|
run_url_template: str = "https://sourcecraft.dev/{repo}/cicd/runs/{run}"
|
|
timeout: float = Field(default=30.0, gt=0)
|
|
max_attempts: int = Field(default=5, ge=1)
|
|
|
|
|
|
class CISettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="CI_")
|
|
|
|
# CI of the uploads repo the bot waits for before deploying (#11). none — deploy right after push, no CI status.
|
|
provider: Literal["none", "gitea"] = "none"
|
|
api_url: str = "https://gitea.24studbank.ru"
|
|
# Read access to the uploads repo's Actions is enough
|
|
token: SecretStr = SecretStr("")
|
|
repo: str = "HSE/tgit-uploads"
|
|
workflow: str = "check.yml"
|
|
poll_interval: float = Field(default=10.0, gt=0)
|
|
max_poll_interval: float = Field(default=60.0, gt=0) # backoff while the API is failing
|
|
run_timeout_minutes: float = Field(default=30.0, gt=0)
|
|
timeout: float = Field(default=30.0, gt=0)
|
|
max_attempts: int = Field(default=5, ge=1)
|
|
|
|
|
|
class GitSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="GIT_")
|
|
|
|
# The bot's own deploy key with write access to tgit-uploads only (#7)
|
|
uploads_url: str = "git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git"
|
|
ssh_key_path: Path | None = None
|
|
# Alternative for containers: the private key itself, base64 — a mounted host file is unreadable for the
|
|
# container user (uid 10001 vs the owner's 0600). Written to a private temp file at startup.
|
|
ssh_key_b64: SecretStr | None = None
|
|
branch: str = "main"
|
|
# Web link to a commit in the uploads repo, e.g. https://sourcecraft.dev/{repo}/commit/{sha}; empty — no link
|
|
commit_url_template: str = ""
|
|
clone_dir: Path = Path("data/uploads")
|
|
# Browsing re-fetches at most this often; a push always fetches first (#9)
|
|
fetch_interval: float = Field(default=10.0, ge=0)
|
|
timeout: float = Field(default=60.0, gt=0)
|
|
|
|
|
|
class DockerSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="DOCKER_")
|
|
|
|
# docker-socket-proxy (GET only, ADR-006); empty — the «Статус» section reports Docker as unavailable
|
|
url: str = ""
|
|
# Only containers with this label are shown: the host runs other projects too
|
|
label: str = "tgit.managed=true"
|
|
timeout: float = Field(default=10.0, gt=0)
|
|
snapshot_interval: float = Field(default=15.0, gt=0)
|
|
# One logs request reads at most this much (SPEC L-5: 5 MB); the newest lines win
|
|
max_log_bytes: int = Field(default=5 * 1024 * 1024, gt=0)
|
|
max_log_lines: int = Field(default=50_000, gt=0)
|
|
|
|
|
|
class RunnerSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(**_ENV, env_prefix="RUNNER_")
|
|
|
|
# SSH to tgit-runner on the host (ADR-006); empty host — services are not deployed from the bot
|
|
host: str = ""
|
|
port: int = 22
|
|
user: str = "tgit-deploy"
|
|
# The bot's runner key, base64; authorized_keys pins it to `command="tgit-runner"` (infra/runner/)
|
|
ssh_key_b64: SecretStr | None = None
|
|
timeout: float = Field(default=300.0, gt=0)
|
|
|
|
|
|
class RuntimeSettings(BaseSettings):
|
|
model_config = _ENV
|
|
|
|
log_level: str = "INFO"
|
|
# Non-secret configs (texts, thresholds, prompts). Relative to the working dir: /srv/tgit in Docker.
|
|
config_dir: Path = Path("config")
|
|
# The heartbeat file is touched while the event loop is alive; the Docker healthcheck reads its age.
|
|
heartbeat_file: Path = Path("/tmp/tgit-heartbeat")
|
|
heartbeat_interval: float = Field(default=10.0, gt=0)
|
|
heartbeat_max_age: float = Field(default=30.0, gt=0)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = _ENV
|
|
|
|
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
|
|
access: AccessSettings = Field(default_factory=AccessSettings)
|
|
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
|
llm: LLMSettings = Field(default_factory=LLMSettings)
|
|
sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings)
|
|
git: GitSettings = Field(default_factory=GitSettings)
|
|
# not `ci`: Settings has no env prefix, and CI runners export CI=true
|
|
uploads_ci: CISettings = Field(default_factory=CISettings)
|
|
docker: DockerSettings = Field(default_factory=DockerSettings)
|
|
runner: RunnerSettings = Field(default_factory=RunnerSettings)
|
|
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|