| 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'}" |
| |
| 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" |
| |
| |
| |
| |
| |
| tuition_brain_provider: str = Field( |
| default="cloudflare_workers_ai", |
| validation_alias=AliasChoices("TUITION_BRAIN_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_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_model_heavy: str = "sarvam-30b" |
| sarvam_model_notes: str = "sarvam-105b" |
| sarvam_model_quiz: str = "sarvam-105b" |
| sarvam_model_flashcards: str = "sarvam-105b" |
| 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_api_key: str | None = Field( |
| default=None, |
| validation_alias=AliasChoices("OPENROUTER_API_KEY"), |
| ) |
| openrouter_base_url: str = "https://openrouter.ai/api/v1" |
| |
| |
| 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 |
| ai_fallback_to_mock: bool = True |
| mock_ai_model_name: str = "mock-exam-tutor-v1" |
| |
| 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" |
| |
| 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" |
| |
| 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" |
| |
| |
| |
| 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_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_api_key: str | None = Field(default=None, validation_alias=AliasChoices("DEEPGRAM_API_KEY")) |
| deepgram_model: str = "nova-2" |
| |
| |
| 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" |
| |
| |
| 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" |
| |
| |
| |
| |
| 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 |
| |
| 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 <support@docdoe.in>" |
| 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_cloud_name: str | None = None |
| cloudinary_api_key: str | None = None |
| cloudinary_api_secret: str | None = None |
| auth_provider: str = "jwt" |
| |
| |
| 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 |
| |
| |
| |
| 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 |
| |
| beta_access_enabled: bool = False |
| beta_invite_code: str | None = None |
| |
| rate_limit_enabled: bool = True |
| rate_limit_ai_requests_per_day: int = 50 |
| |
| admin_emails: str = "" |
| |
| pyq_discovery_enabled: bool = False |
| pyq_search_provider: str = "disabled" |
| 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_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_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" |
| |
| |
| |
| stripe_checkout_integration_identifier: str = "docdoe_web_psuqtgkx" |
| |
| stripe_price_ids: str = "" |
|
|
| 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()] |
| |
| |
| |
| |
| 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() |
|
|