from functools import lru_cache from pathlib import Path from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict BACKEND_DIR = Path(__file__).resolve().parents[2] PROJECT_ROOT = BACKEND_DIR.parent class Settings(BaseSettings): app_name: str = "AI Exam Success API" app_version: str = "0.1.0" environment: str = "development" database_url: str = f"sqlite:///{BACKEND_DIR / 'exam_success_dev.db'}" # Dev defaults stay small; production .env should raise these for 1k+ concurrent students. database_pool_size: int = Field( default=2, validation_alias=AliasChoices("DATABASE_POOL_SIZE"), ) database_max_overflow: int = Field( default=3, validation_alias=AliasChoices("DATABASE_MAX_OVERFLOW"), ) database_pool_timeout_seconds: int = Field( default=10, validation_alias=AliasChoices("DATABASE_POOL_TIMEOUT_SECONDS"), ) database_pool_recycle_seconds: int = Field( default=1800, validation_alias=AliasChoices("DATABASE_POOL_RECYCLE_SECONDS"), ) cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:3003,http://127.0.0.1:3003" upload_dir: str = "uploads" ai_provider: str = "openai" # The tuition brain uses its OWN text provider so it can run on a cheap # text-adapter (Cloudflare Workers AI) without forcing the rest of the app # off its main provider. Must be a text-adapter name # (cloudflare_workers_ai | openrouter | nvidia_nim); anything else -> the # brain falls back to its deterministic plan. tuition_brain_provider: str = Field( default="cloudflare_workers_ai", validation_alias=AliasChoices("TUITION_BRAIN_PROVIDER"), ) # --- OpenAI (fast + high-quality primary provider) --- openai_api_key: str | None = Field( default=None, validation_alias=AliasChoices("OPENAI_API_KEY"), ) openai_base_url: str = "https://api.openai.com/v1" openai_model_main: str = "gpt-5-mini" openai_model_heavy: str = "gpt-5.2" openai_timeout_seconds: int = 60 openai_max_retries: int = 1 openai_reasoning_effort_fast: str = "minimal" openai_reasoning_effort_heavy: str = "low" # --- Sarvam AI (primary production provider) --- sarvam_api_key: str | None = Field( default=None, validation_alias=AliasChoices("SARVAM_API_KEY"), ) sarvam_base_url: str = "https://api.sarvam.ai/v1" sarvam_model_main: str = "sarvam-30b" # sarvam-m deprecated; 30b is the fast reasoning model sarvam_model_heavy: str = "sarvam-30b" # reasoning model for deep analysis sarvam_model_notes: str = "sarvam-105b" # upgraded: more tokens for richer notes sarvam_model_quiz: str = "sarvam-105b" # upgraded: more questions, better quality sarvam_model_flashcards: str = "sarvam-105b" # upgraded: more cards, richer back-sides sarvam_model_exam_mode: str = "sarvam-105b" sarvam_model_pyq_analysis: str = "sarvam-105b" sarvam_model_video_script: str = "sarvam-105b" sarvam_timeout_seconds: int = 90 sarvam_max_retries: int = 2 # --- OpenRouter (legacy provider — kept for backward compat) --- openrouter_api_key: str | None = Field( default=None, validation_alias=AliasChoices("OPENROUTER_API_KEY"), ) openrouter_base_url: str = "https://openrouter.ai/api/v1" # User-facing fallbacks must support structured outputs and avoid unstable # free-tier routing. The old DeepSeek free slug now returns HTTP 404. openrouter_model_main: str = "deepseek/deepseek-v4-flash" openrouter_model_llama: str = "qwen/qwen3-next-80b-a3b-instruct" openrouter_model_gpt_oss: str = "openai/gpt-oss-120b:free" openrouter_model_nemotron: str = "nvidia/nemotron-3-nano-30b-a3b:free" openrouter_site_url: str = "https://docdoe.ai" openrouter_app_name: str = "DocDoe AI" ai_max_retries: int = 2 ai_timeout_seconds: int = 120 # 60s is too short for llama-3.3-70b free tier (~90s p50) ai_fallback_to_mock: bool = True mock_ai_model_name: str = "mock-exam-tutor-v1" # --- Cost-control provider router --- ai_router_enabled: bool = False ai_router_default_tier: str = "cheap" ai_router_daily_budget_usd: float = 0 ai_router_allow_free_providers: bool = True ai_router_provider_daily_units_default: int = 0 generation_cache_prompt_version: str = "v1" # --- Legacy Gemini fields (kept so old .env files don't crash) --- gemini_api_key: str | None = Field( default=None, validation_alias=AliasChoices("GEMINI_API_KEY", "GEMINI_API"), ) gemini_model_flash: str = "gemini-1.5-flash" gemini_model_pro: str = "gemini-1.5-pro" # --- Additional router provider keys/config --- groq_api_key: str | None = Field(default=None, validation_alias=AliasChoices("GROQ_API_KEY")) groq_base_url: str = "https://api.groq.com/openai/v1" groq_model_main: str = "llama-3.1-8b-instant" # --- Z.AI / GLM (free text + vision Flash models) --- # OpenAI-compatible: https://api.z.ai/api/paas/v4/ # Free: glm-4.7-flash (text), glm-4.6v-flash (vision / image understanding). zai_api_key: str | None = Field( default=None, validation_alias=AliasChoices("ZAI_API_KEY", "Z_AI_API_KEY", "GLM_API_KEY"), ) zai_base_url: str = Field( default="https://api.z.ai/api/paas/v4", validation_alias=AliasChoices("ZAI_BASE_URL", "Z_AI_BASE_URL"), ) zai_model_text: str = Field( default="glm-4.7-flash", validation_alias=AliasChoices("ZAI_MODEL_TEXT", "GLM_MODEL_TEXT"), ) zai_model_vision: str = Field( default="glm-4.6v-flash", validation_alias=AliasChoices("ZAI_MODEL_VISION", "GLM_MODEL_VISION"), ) zai_timeout_seconds: int = Field( default=90, validation_alias=AliasChoices("ZAI_TIMEOUT_SECONDS"), ) nvidia_nim_api_key: str | None = Field(default=None, validation_alias=AliasChoices("NVIDIA_NIM_API_KEY")) nvidia_nim_base_url: str = "https://integrate.api.nvidia.com/v1" nvidia_nim_model_main: str = "meta/llama-3.1-8b-instruct" # fast (~3-4s), good for quick answers # When true, fast tasks (notes/simple/quiz/flashcards) try NVIDIA NIM first # for ~3-4s answers; deep tasks (exam/pyq/video) still use Sarvam. fast_provider_enabled: bool = Field(default=False, validation_alias=AliasChoices("FAST_PROVIDER_ENABLED")) cloudflare_account_id: str | None = Field(default=None, validation_alias=AliasChoices("CLOUDFLARE_ACCOUNT_ID")) cloudflare_api_token: str | None = Field(default=None, validation_alias=AliasChoices("CLOUDFLARE_API_TOKEN")) cloudflare_workers_ai_text_model: str = "@cf/meta/llama-3.1-8b-instruct" cloudflare_workers_ai_image_model: str = "@cf/black-forest-labs/flux-1-schnell" huggingface_api_key: str | None = Field(default=None, validation_alias=AliasChoices("HUGGINGFACE_API_KEY")) huggingface_text_model: str = "mistralai/Mistral-7B-Instruct-v0.3" huggingface_image_model: str = "stabilityai/stable-diffusion-xl-base-1.0" replicate_api_token: str | None = Field(default=None, validation_alias=AliasChoices("REPLICATE_API_TOKEN")) # Deepgram powers Record Lecture (speech-to-text). Absent key => the # transcription route returns an honest "not configured" result instead of # failing, exactly like the other optional providers. deepgram_api_key: str | None = Field(default=None, validation_alias=AliasChoices("DEEPGRAM_API_KEY")) deepgram_model: str = "nova-2" # AI4Bharat Indic Parler is the default teacher voice: it supports Indian # English and Malayalam in one local, commercially permissive model. tts_provider: str = "ai4bharat" tts_default_provider: str = "ai4bharat" video_tts_provider: str = "ai4bharat" tts_default_voice: str = "nila" tts_output_dir: str = "public/generated/audio" audio_scene_timeout_seconds: int = 45 audio_total_timeout_seconds: int = 600 audio_max_retries: int = 2 audio_max_concurrency: int = 5 kokoro_voice: str = "af_heart" kokoro_speed: float = 0.95 ai4bharat_tts_model: str = "ai4bharat/indic-parler-tts" ai4bharat_tts_speaker: str = "Anjali" ai4bharat_tts_english_speaker: str = "Mary" ai4bharat_tts_device: str = "auto" # Full precision is validated on the RTX 3060 12 GB and is materially # clearer for Malayalam than half precision with Indic Parler-TTS. ai4bharat_tts_precision: str = "float32" ai4bharat_tts_description: str = ( "Mary speaks like a warm and patient Kerala secondary-school teacher at a " "slightly slow conversational pace. Her voice is clear, close-sounding, " "slightly expressive, and recorded with very clear audio and no background noise." ) ai4bharat_tts_malayalam_description: str = ( "Anjali speaks with a warm, clear and neutral Kerala teacher voice at a " "normal calm pace in a close-sounding environment. Her recording has " "excellent, very clear audio with no background noise." ) indic_tts_model: str | None = None indic_tts_language: str = "ml" # Default English voice tuned for natural teaching cadence. # AvaMultilingualNeural is a warmer, slower-paced US neural voice that # carries explanation content far better than the older Indian newsreader. # Negative rate gives space between sentences so concepts land. edge_tts_voice_en: str = "en-US-AvaMultilingualNeural" edge_tts_voice_ml: str = "ml-IN-SobhanaNeural" edge_tts_rate: str = "-4%" elevenlabs_api_key: str | None = None elevenlabs_voice_id: str | None = None audio_normalize: bool = True # Distributed state + observability (env-gated; in-memory/no-op when unset) redis_url: str | None = None sentry_dsn: str | None = None sentry_traces_sample_rate: float = 0.1 email_provider: str = "disabled" resend_api_key: str | None = Field( default=None, validation_alias=AliasChoices("RESEND_API_KEY"), ) resend_base_url: str = "https://api.resend.com" email_from: str = "DocDoe " password_reset_token_minutes: int = 30 password_reset_request_cooldown_seconds: int = 60 generated_video_jobs_dir: str = "generated-video-jobs" generated_video_output_dir: str = "generated-videos" video_render_timeout_seconds: int = 3600 video_min_output_bytes: int = 4096 video_beta_max_scenes: int = 40 video_beta_max_duration_seconds: int = 1800 video_scene_text_max_chars: int = 700 video_max_concurrent_render_jobs_per_user: int = 1 video_stuck_job_timeout_minutes: int = 30 video_enforce_beta_limits_in_development: bool = False storage_provider: str = "local" storage_bucket: str | None = None storage_region: str | None = None storage_endpoint_url: str | None = None storage_access_key_id: str | None = None storage_secret_access_key: str | None = None storage_public_base_url: str | None = None storage_upload_prefix: str = "uploads" storage_audio_prefix: str = "generated-audio" storage_video_prefix: str = "generated-videos" # Cloudinary (used when storage_provider == "cloudinary") cloudinary_cloud_name: str | None = None cloudinary_api_key: str | None = None cloudinary_api_secret: str | None = None auth_provider: str = "jwt" # Local preview is an explicit opt-in so a non-production deployment cannot # become a shared demo account by accident. auth_enabled: bool = True allow_insecure_dev_auth: bool = False frontend_base_url: str = "http://127.0.0.1:3000" google_client_id: str | None = None google_client_secret: str | None = None google_oauth_redirect_uri: str | None = None supabase_url: str | None = None supabase_jwt_secret: str | None = None supabase_anon_key: str | None = None # Backend-only key used for destructive Supabase Auth administration, such # as honoring an authenticated account-deletion request. Never expose this # value through a NEXT_PUBLIC_* variable or a frontend response. supabase_service_role_key: str | None = Field( default=None, validation_alias=AliasChoices("SUPABASE_SERVICE_ROLE_KEY"), ) jwt_secret_key: str | None = None jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 10080 # 7 days — keeps users logged in across sessions # --- Beta invite gate --- beta_access_enabled: bool = False beta_invite_code: str | None = None # --- Per-user AI rate limiting --- rate_limit_enabled: bool = True rate_limit_ai_requests_per_day: int = 50 # --- Admin access --- admin_emails: str = "" # comma-separated list of admin email addresses # --- PYQ discovery (official-source-first, safe-disabled by default) --- pyq_discovery_enabled: bool = False pyq_search_provider: str = "disabled" # disabled | google_cse | bing | serpapi | manual pyq_search_api_key: str | None = Field( default=None, validation_alias=AliasChoices("PYQ_SEARCH_API_KEY"), ) pyq_search_engine_id: str | None = Field( default=None, validation_alias=AliasChoices("PYQ_SEARCH_ENGINE_ID"), ) pyq_discovery_max_candidates: int = 12 pyq_discovery_min_confidence_for_auto_import: float = 0.85 # --- Exa web search (explicit/current-information questions only) --- # The key is read by the backend only. It must never be exposed through a # NEXT_PUBLIC_* variable or returned by an API response. exa_api_key: str | None = Field( default=None, validation_alias=AliasChoices("EXA_API_KEY"), ) exa_base_url: str = "https://api.exa.ai" exa_search_timeout_seconds: float = 15.0 exa_search_max_results: int = 6 # --- Stripe Billing (real SaaS upgrades) --- stripe_secret_key: str | None = Field( default=None, validation_alias=AliasChoices("STRIPE_SECRET_KEY") ) stripe_webhook_secret: str | None = Field( default=None, validation_alias=AliasChoices("STRIPE_WEBHOOK_SECRET") ) stripe_api_version: str = "2026-06-24.dahlia" # Stable integration label registered for this checkout flow. The suffix # was generated once (rather than per request) so Stripe idempotency keys # always see identical request parameters. stripe_checkout_integration_identifier: str = "docdoe_web_psuqtgkx" # Map the two student-facing subscription keys to recurring Stripe Prices. stripe_price_ids: str = "" # e.g. popular_299=price_xxx,premium_599=price_yyy model_config = SettingsConfigDict( env_file=BACKEND_DIR / ".env", env_file_encoding="utf-8", extra="ignore", ) @property def is_production(self) -> bool: """True when ENVIRONMENT is production (case-insensitive).""" return self.environment.strip().casefold() == "production" @property def cors_origin_list(self) -> list[str]: origins = [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] # SECURITY: with allow_credentials=True a "*" makes Starlette reflect ANY # origin (echoing the caller's Origin header), so any website could make # authenticated requests. Never allow a wildcard in production — drop it # and fall back to the known app domains if nothing valid remains. if self.environment == "production": origins = [o for o in origins if o != "*" and not o.startswith("http://")] if not origins: origins = ["https://www.docdoe.in", "https://docdoe.in"] return origins @property def resolved_upload_dir(self) -> Path: upload_path = Path(self.upload_dir) if upload_path.is_absolute(): return upload_path return BACKEND_DIR / upload_path @property def resolved_tts_output_dir(self) -> Path: output_path = Path(self.tts_output_dir) if output_path.is_absolute(): return output_path return PROJECT_ROOT / output_path @property def stripe_enabled(self) -> bool: return bool(self.stripe_secret_key) def get_stripe_price_id(self, plan_key: str) -> str | None: raw = (self.stripe_price_ids or "").strip() if not raw: return None for pair in raw.split(","): if "=" in pair: k, v = pair.split("=", 1) if k.strip().lower() == plan_key.lower(): return v.strip() or None return None @property def resolved_generated_video_jobs_dir(self) -> Path: jobs_path = Path(self.generated_video_jobs_dir) if jobs_path.is_absolute(): return jobs_path return PROJECT_ROOT / jobs_path @property def resolved_generated_video_output_dir(self) -> Path: output_path = Path(self.generated_video_output_dir) if output_path.is_absolute(): return output_path return PROJECT_ROOT / output_path @lru_cache def get_settings() -> Settings: return Settings()