1"""Application settings (env-driven). Model slugs are configuration, not code."""23from __future__ import annotations45from functools import lru_cache6from pathlib import Path78from pydantic import SecretStr, field_validator9from pydantic_settings import BaseSettings, SettingsConfigDict1011BASE_DIR = Path(__file__).resolve().parents[2] # backend/12REPO_DIR = BASE_DIR.parent131415class Settings(BaseSettings):16 model_config = SettingsConfigDict(env_file=(REPO_DIR / ".env", BASE_DIR / ".env"), extra="ignore")1718 # --- App ---19 APP_ENV: str = "development"20 APP_NAME: str = "UQO-Chat"21 APP_URL: str = "https://www.uqo-chat.app"22 CORS_ORIGINS: str = "https://www.uqo-chat.app,http://localhost:5173,http://localhost:8190"23 LOG_LEVEL: str = "INFO"24 PORT: int = 819025 DATA_DIR: Path = REPO_DIR / "data"26 FRONTEND_DIST: Path = REPO_DIR / "frontend" / "dist"27 COURSES: str = "IMM1003,IMM1033"28 TERM_LABEL: str = "Automne 2026"2930 # --- LLM (OpenRouter) ---31 OPENROUTER_API_KEY: SecretStr = SecretStr("")32 OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"33 OPENROUTER_APP_NAME: str = "UQO-Chat"34 OPENROUTER_APP_URL: str = "https://www.uqo-chat.app"35 # Slugs — verify on https://openrouter.ai/models before changing (checked 2026-09-05).36 MODEL_TUTOR_PRIMARY: str = "anthropic/claude-sonnet-4.6"37 MODEL_TUTOR_FALLBACK: str = "openai/gpt-5.5"38 MODEL_REASONING: str = "anthropic/claude-opus-4.6"39 MODEL_FAST: str = "openai/gpt-5.4-nano"40 MODEL_VISION: str = "anthropic/claude-sonnet-4.6"41 # OpenRouter exposes no embedding model (2026-09): optional OpenAI-compatible endpoint.42 EMBEDDINGS_BASE_URL: str = ""43 EMBEDDINGS_API_KEY: SecretStr = SecretStr("")44 MODEL_EMBEDDINGS: str = ""45 LLM_MAX_TOOL_ITERATIONS: int = 846 LLM_TIMEOUT_SECONDS: int = 12047 LLM_MAX_OUTPUT_TOKENS: int = 409648 LLM_MONTHLY_BUDGET_USD: float = 300.049 LLM_TURN_TIMEOUT_SECONDS: int = 3005051 # --- Web search ---52 FIRECRAWL_API_KEY: SecretStr = SecretStr("")53 FIRECRAWL_BASE_URL: str = "https://api.firecrawl.dev/v1"54 WEB_SEARCH_CACHE_TTL_S: int = 6 * 36005556 # --- Data ---57 DATABASE_URL: str = "" # default: sqlite in DATA_DIR58 REDIS_URL: str = "" # optional; in-memory fallback5960 # --- Sandbox ---61 SANDBOX_URL: str = "http://127.0.0.1:8191"62 SANDBOX_TIMEOUT_S: int = 3063 SANDBOX_HEAVY_TIMEOUT_S: int = 6064 SANDBOX_TOKEN: SecretStr = SecretStr("")6566 # --- Auth ---67 JWT_SECRET: SecretStr = SecretStr("change-me-in-production")68 JWT_TTL_HOURS: int = 2469 REFRESH_TTL_DAYS: int = 3070 ALLOWED_EMAIL_DOMAINS: str = "uqo.ca"71 INVITED_EMAILS: str = ""72 PROFESSOR_EMAILS: str = ""73 ADMIN_EMAILS: str = ""74 ACCESS_CODE: str = "" # course access code: lets users log in without SMTP75 SMTP_HOST: str = ""76 SMTP_PORT: int = 58777 SMTP_USER: str = ""78 SMTP_PASSWORD: SecretStr = SecretStr("")79 SMTP_FROM: str = "no-reply@uqo-chat.app"8081 # --- Limits ---82 RATE_MESSAGES_PER_HOUR: int = 6083 RATE_MESSAGES_PER_DAY: int = 40084 RATE_SANDBOX_PER_HOUR: int = 1085 RATE_WEBSEARCH_PER_DAY: int = 3086 RATE_UPLOADS_PER_DAY: int = 2087 UPLOAD_MAX_MB: int = 2088 FILE_TTL_DAYS: int = 789 FILE_PINNED_TTL_DAYS: int = 3090 MESSAGE_RETENTION_MONTHS: int = 129192 @field_validator("DATA_DIR", mode="before")93 @classmethod94 def _expand(cls, v: object) -> object:95 if isinstance(v, str):96 return Path(v).expanduser()97 return v9899 @property100 def database_url(self) -> str:101 if self.DATABASE_URL:102 return self.DATABASE_URL103 return f"sqlite+aiosqlite:///{self.DATA_DIR / 'uqochat.db'}"104105 @property106 def cors_origins(self) -> list[str]:107 return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]108109 @property110 def courses(self) -> list[str]:111 return [c.strip().upper() for c in self.COURSES.split(",") if c.strip()]112113 def _csv_lower(self, value: str) -> set[str]:114 return {e.strip().lower() for e in value.split(",") if e.strip()}115116 @property117 def allowed_domains(self) -> set[str]:118 return self._csv_lower(self.ALLOWED_EMAIL_DOMAINS)119120 @property121 def invited_emails(self) -> set[str]:122 return self._csv_lower(self.INVITED_EMAILS)123124 @property125 def professor_emails(self) -> set[str]:126 return self._csv_lower(self.PROFESSOR_EMAILS)127128 @property129 def admin_emails(self) -> set[str]:130 return self._csv_lower(self.ADMIN_EMAILS)131132 @property133 def smtp_enabled(self) -> bool:134 return bool(self.SMTP_HOST)135136 @property137 def is_dev(self) -> bool:138 return self.APP_ENV != "production"139140141@lru_cache142def get_settings() -> Settings:143 s = Settings()144 s.DATA_DIR.mkdir(parents=True, exist_ok=True)145 (s.DATA_DIR / "files").mkdir(exist_ok=True)146 return s147