"""Centralized application configuration. Every environment-dependent value is read here, once, via pydantic-settings. Application code should depend on `get_settings()`, never on `os.environ` directly - that's what keeps provider selection (LLM/search/notifications/auth) swappable from a single place. """ from __future__ import annotations from functools import lru_cache from typing import Literal from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") # --- App --- app_env: Literal["development", "test", "production"] = "development" app_name: str = "CI Agent" frontend_url: str = "http://localhost:3000" backend_url: str = "http://localhost:8000" # --- Reverse proxy --- # Empty (default) = trust only the direct TCP connection for client-IP # resolution (app.core.security.get_client_ip) - correct today, no proxy # exists. Set to "CF-Connecting-IP" once deployed behind Cloudflare's # proxy so IP-based localhost detection and the ban/throttle system read # the real visitor IP instead of the proxy's own address. Only ever set # this when it's actually known a trusted proxy sits in front and strips # this header from untrusted clients - see KNOWN_LIMITATIONS.md. trusted_proxy_ip_header: str = "" # --- Local-dev convenience --- # Comma-separated extra IPs that `is_localhost` treats as equivalent to # real loopback, on top of 127.0.0.1/::1. Needed because Docker # Desktop's bridge networking means even traffic that originates on the # host machine itself (dev tooling driving a browser against # localhost:3000/8000) arrives at the container from the bridge # gateway address, not literal loopback - so without this, # is_localhost is always false for that traffic, which both re-exposes # the admin-only API-keys/logs boxes' loopback gate and forces the # Turnstile widget to render (a real crash risk for automated browser # tooling - see KNOWN_LIMITATIONS.md). Empty by default (no bypass); # only ever populate this with IPs you know are your own dev host, # never in a real deployment. additional_trusted_local_ips: str = "" # --- Auth --- auth_mode: Literal["local", "jwt"] = "local" jwt_secret: str = "dev-only-change-me-32-characters-minimum" jwt_access_token_minutes: int = 15 jwt_refresh_token_days: int = 7 # Encrypts each user's own stored API keys at rest (app/core/crypto.py) # - a Fernet key (44-char urlsafe-base64). This dev-only default is # fixed/insecure by design (same precedent as jwt_secret above); a real # deployment must set its own via `Fernet.generate_key()`. Rotating # this value makes every already-stored user key undecryptable, so # treat it like any other production secret - never regenerate it # casually once real keys exist. api_key_encryption_secret: str = "_wYtsm3nJ070987snBFp2eWVI5pyC0H9gGFUb6Cy4cQ=" # --- Database --- database_url: str = "sqlite+aiosqlite:///./ciagent_dev.db" # --- Redis / Celery --- redis_url: str = "redis://localhost:6379/0" celery_task_always_eager: bool = False # --- LLM --- llm_provider: Literal["mock", "anthropic", "ollama", "gemini"] = "mock" anthropic_api_key: str = "" anthropic_model: str = "claude-sonnet-5" ollama_base_url: str = "http://localhost:11434" ollama_model: str = "llama3.1" # Gemini has an actual free rate-limited tier (unlike OpenAI's expiring # trial credits), so it's the production option this app ships wired up. gemini_api_key: str = "" gemini_model: str = "gemini-2.0-flash" llm_max_tokens_per_request: int = 4000 llm_max_retries: int = 2 # --- Search --- search_provider: Literal["mock", "brave"] = "mock" brave_search_api_key: str = "" serpapi_api_key: str = "" bing_search_api_key: str = "" # --- Patents --- # Free key via account registration at data.uspto.gov/apis/getting-started. # Unset by default - PatentSourceCollector falls back to its existing # honest disabled/fixture behavior when this is empty. uspto_api_key: str = "" # --- Company enrichment (NinjaPear / nubela.co) --- # Paid, per-credit API - unset by default. Enrichment only ever fires # once, at company-creation time (never on a recurring schedule), and # the enqueue itself is skipped entirely when this is empty - see # company_service.create_company. ninjapear_api_key: str = "" ninjapear_max_leadership_lookups: int = 5 # --- Email --- smtp_host: str = "localhost" smtp_port: int = 1025 smtp_username: str = "" smtp_password: str = "" smtp_from_email: str = "alerts@ci-agent.local" smtp_use_tls: bool = False # --- Resend (transactional security email: verification/reset/lockout) --- # Unset by default - security_email_service falls back to the SMTP # provider above (Mailpit locally) when this is empty, so the whole # verification/reset flow is testable with zero Resend account needed. # Deliberately separate from the alert-notification path (smtp_from_email # above) - a different sender identity for account-security mail. resend_api_key: str = "" resend_security_from_email: str = "security@ciagent.org" # --- Cloudflare Turnstile (CAPTCHA on register/login/password-reset) --- # Unset by default - skipped entirely for register/login/password-reset # when either the caller is on loopback (see is_localhost) or no secret # is configured, matching this app's usual optional-provider convention. turnstile_site_key: str = "" turnstile_secret: str = "" # --- SMS --- notification_sms_enabled: bool = False sms_provider: Literal["twilio", "telnyx"] = "twilio" twilio_account_sid: str = "" twilio_auth_token: str = "" twilio_from_number: str = "" telnyx_api_key: str = "" telnyx_from_number: str = "" sms_monthly_cap: int = 50 # --- GitHub --- github_token: str = "" # --- Scheduling --- default_timezone: str = "America/New_York" default_monitoring_frequency: str = "weekly" minimum_monitoring_interval_minutes: int = 60 # --- Scraper --- scraper_user_agent: str = "CIAgentBot/1.0 (+https://ci-agent.local/bot)" max_pages_per_domain: int = 25 scraper_request_timeout_seconds: int = 30 scraper_domain_delay_seconds: float = 2.0 # --- Cost controls --- max_companies_per_user: int = 25 max_manual_runs_per_day: int = 10 # --- Retention / logging --- data_retention_days: int = 365 log_level: str = "INFO" @property def is_production(self) -> bool: return self.app_env == "production" @model_validator(mode="after") def _forbid_local_auth_in_production(self) -> Settings: if self.app_env == "production" and self.auth_mode == "local": raise ValueError( "AUTH_MODE=local is a development convenience and must not be used " "when APP_ENV=production. Set AUTH_MODE=jwt." ) return self @lru_cache def get_settings() -> Settings: return Settings()