Python 64.6%
TypeScript 33.7%
CSS 0.8%
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-fable-5.1"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 = 1246 LLM_TIMEOUT_SECONDS: int = 12047 LLM_MAX_OUTPUT_TOKENS: int = 1600048 LLM_MONTHLY_BUDGET_USD: float = 2000.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 INVITE_TTL_DAYS: int = 14 # "choose your password" link sent to newly added students75 RESET_TTL_MINUTES: int = 60 # "forgot my password" link76 # --- Mail (Resend first; SMTP kept as fallback) ---77 RESEND_API_KEY: SecretStr = SecretStr("")78 RESEND_BASE_URL: str = "https://api.resend.com"79 MAIL_FROM: str = "UQO-Chat <no-reply@uqo-chat.app>"80 MAIL_REPLY_TO: str = ""81 SMTP_HOST: str = ""82 SMTP_PORT: int = 58783 SMTP_USER: str = ""84 SMTP_PASSWORD: SecretStr = SecretStr("")85 SMTP_FROM: str = "no-reply@uqo-chat.app"8687 # --- Limits ---88 RATE_MESSAGES_PER_HOUR: int = 6089 RATE_MESSAGES_PER_DAY: int = 40090 RATE_SANDBOX_PER_HOUR: int = 1091 RATE_WEBSEARCH_PER_DAY: int = 3092 RATE_UPLOADS_PER_DAY: int = 2093 UPLOAD_MAX_MB: int = 2094 FILE_TTL_DAYS: int = 795 FILE_PINNED_TTL_DAYS: int = 3096 MESSAGE_RETENTION_MONTHS: int = 129798 @field_validator("DATA_DIR", mode="before")99 @classmethod100 def _expand(cls, v: object) -> object:101 if isinstance(v, str):102 return Path(v).expanduser()103 return v104105 @property106 def database_url(self) -> str:107 if self.DATABASE_URL:108 return self.DATABASE_URL109 return f"sqlite+aiosqlite:///{self.DATA_DIR / 'uqochat.db'}"110111 @property112 def cors_origins(self) -> list[str]:113 return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]114115 @property116 def courses(self) -> list[str]:117 return [c.strip().upper() for c in self.COURSES.split(",") if c.strip()]118119 def _csv_lower(self, value: str) -> set[str]:120 return {e.strip().lower() for e in value.split(",") if e.strip()}121122 @property123 def allowed_domains(self) -> set[str]:124 return self._csv_lower(self.ALLOWED_EMAIL_DOMAINS)125126 @property127 def invited_emails(self) -> set[str]:128 return self._csv_lower(self.INVITED_EMAILS)129130 @property131 def professor_emails(self) -> set[str]:132 return self._csv_lower(self.PROFESSOR_EMAILS)133134 @property135 def admin_emails(self) -> set[str]:136 return self._csv_lower(self.ADMIN_EMAILS)137138 @property139 def smtp_enabled(self) -> bool:140 return bool(self.SMTP_HOST)141142 @property143 def resend_enabled(self) -> bool:144 return bool(self.RESEND_API_KEY.get_secret_value())145146 @property147 def mail_enabled(self) -> bool:148 return self.resend_enabled or self.smtp_enabled149150 @property151 def is_dev(self) -> bool:152 return self.APP_ENV != "production"153154155@lru_cache156def get_settings() -> Settings:157 s = Settings()158 s.DATA_DIR.mkdir(parents=True, exist_ok=True)159 (s.DATA_DIR / "files").mkdir(exist_ok=True)160 return s161