"""Application settings (env-driven). Model slugs are configuration, not code.""" from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict BASE_DIR = Path(__file__).resolve().parents[2] # backend/ REPO_DIR = BASE_DIR.parent class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=(REPO_DIR / ".env", BASE_DIR / ".env"), extra="ignore") # --- App --- APP_ENV: str = "development" APP_NAME: str = "UQO-Chat" APP_URL: str = "https://www.uqo-chat.app" CORS_ORIGINS: str = "https://www.uqo-chat.app,http://localhost:5173,http://localhost:8190" LOG_LEVEL: str = "INFO" PORT: int = 8190 DATA_DIR: Path = REPO_DIR / "data" FRONTEND_DIST: Path = REPO_DIR / "frontend" / "dist" COURSES: str = "IMM1003,IMM1033" TERM_LABEL: str = "Automne 2026" # --- LLM (OpenRouter) --- OPENROUTER_API_KEY: SecretStr = SecretStr("") OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1" OPENROUTER_APP_NAME: str = "UQO-Chat" OPENROUTER_APP_URL: str = "https://www.uqo-chat.app" # Slugs — verify on https://openrouter.ai/models before changing (checked 2026-09-05). MODEL_TUTOR_PRIMARY: str = "anthropic/claude-fable-5.1" MODEL_TUTOR_FALLBACK: str = "openai/gpt-5.5" MODEL_REASONING: str = "anthropic/claude-opus-4.6" MODEL_FAST: str = "openai/gpt-5.4-nano" MODEL_VISION: str = "anthropic/claude-sonnet-4.6" # OpenRouter exposes no embedding model (2026-09): optional OpenAI-compatible endpoint. EMBEDDINGS_BASE_URL: str = "" EMBEDDINGS_API_KEY: SecretStr = SecretStr("") MODEL_EMBEDDINGS: str = "" LLM_MAX_TOOL_ITERATIONS: int = 12 LLM_TIMEOUT_SECONDS: int = 120 LLM_MAX_OUTPUT_TOKENS: int = 16000 LLM_MONTHLY_BUDGET_USD: float = 2000.0 LLM_TURN_TIMEOUT_SECONDS: int = 300 # --- Web search --- FIRECRAWL_API_KEY: SecretStr = SecretStr("") FIRECRAWL_BASE_URL: str = "https://api.firecrawl.dev/v1" WEB_SEARCH_CACHE_TTL_S: int = 6 * 3600 # --- Data --- DATABASE_URL: str = "" # default: sqlite in DATA_DIR REDIS_URL: str = "" # optional; in-memory fallback # --- Sandbox --- SANDBOX_URL: str = "http://127.0.0.1:8191" SANDBOX_TIMEOUT_S: int = 30 SANDBOX_HEAVY_TIMEOUT_S: int = 60 SANDBOX_TOKEN: SecretStr = SecretStr("") # --- Auth --- JWT_SECRET: SecretStr = SecretStr("change-me-in-production") JWT_TTL_HOURS: int = 24 REFRESH_TTL_DAYS: int = 30 ALLOWED_EMAIL_DOMAINS: str = "uqo.ca" INVITED_EMAILS: str = "" PROFESSOR_EMAILS: str = "" ADMIN_EMAILS: str = "" INVITE_TTL_DAYS: int = 14 # "choose your password" link sent to newly added students RESET_TTL_MINUTES: int = 60 # "forgot my password" link # --- Mail (Resend first; SMTP kept as fallback) --- RESEND_API_KEY: SecretStr = SecretStr("") RESEND_BASE_URL: str = "https://api.resend.com" MAIL_FROM: str = "UQO-Chat " MAIL_REPLY_TO: str = "" SMTP_HOST: str = "" SMTP_PORT: int = 587 SMTP_USER: str = "" SMTP_PASSWORD: SecretStr = SecretStr("") SMTP_FROM: str = "no-reply@uqo-chat.app" # --- Limits --- RATE_MESSAGES_PER_HOUR: int = 60 RATE_MESSAGES_PER_DAY: int = 400 RATE_SANDBOX_PER_HOUR: int = 10 RATE_WEBSEARCH_PER_DAY: int = 30 RATE_UPLOADS_PER_DAY: int = 20 UPLOAD_MAX_MB: int = 20 FILE_TTL_DAYS: int = 7 FILE_PINNED_TTL_DAYS: int = 30 MESSAGE_RETENTION_MONTHS: int = 12 @field_validator("DATA_DIR", mode="before") @classmethod def _expand(cls, v: object) -> object: if isinstance(v, str): return Path(v).expanduser() return v @property def database_url(self) -> str: if self.DATABASE_URL: return self.DATABASE_URL return f"sqlite+aiosqlite:///{self.DATA_DIR / 'uqochat.db'}" @property def cors_origins(self) -> list[str]: return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] @property def courses(self) -> list[str]: return [c.strip().upper() for c in self.COURSES.split(",") if c.strip()] def _csv_lower(self, value: str) -> set[str]: return {e.strip().lower() for e in value.split(",") if e.strip()} @property def allowed_domains(self) -> set[str]: return self._csv_lower(self.ALLOWED_EMAIL_DOMAINS) @property def invited_emails(self) -> set[str]: return self._csv_lower(self.INVITED_EMAILS) @property def professor_emails(self) -> set[str]: return self._csv_lower(self.PROFESSOR_EMAILS) @property def admin_emails(self) -> set[str]: return self._csv_lower(self.ADMIN_EMAILS) @property def smtp_enabled(self) -> bool: return bool(self.SMTP_HOST) @property def resend_enabled(self) -> bool: return bool(self.RESEND_API_KEY.get_secret_value()) @property def mail_enabled(self) -> bool: return self.resend_enabled or self.smtp_enabled @property def is_dev(self) -> bool: return self.APP_ENV != "production" @lru_cache def get_settings() -> Settings: s = Settings() s.DATA_DIR.mkdir(parents=True, exist_ok=True) (s.DATA_DIR / "files").mkdir(exist_ok=True) return s