""" Hold Slayer Gateway — Configuration All settings loaded from environment variables / .env file. """ from pydantic import Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict class SIPTrunkSettings(BaseSettings): """SIP trunk provider configuration.""" model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_", env_file=".env", extra="ignore") host: str = "sip.provider.com" port: int = 5060 username: str = "" password: SecretStr = SecretStr("") transport: str = "udp" # udp, tcp, tls did: str = "" # Your phone number (E.164) class GatewaySIPSettings(BaseSettings): """Gateway SIP listener for device registration.""" model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_", env_file=".env", extra="ignore") host: str = "0.0.0.0" port: int = 5060 domain: str = "gateway.local" class SpeachesSettings(BaseSettings): """Speaches STT service configuration.""" model_config = SettingsConfigDict(env_prefix="SPEACHES_", env_file=".env", extra="ignore") url: str = "http://localhost:22070" model: str = "whisper-large-v3" class ClassifierSettings(BaseSettings): """Audio classifier thresholds.""" model_config = SettingsConfigDict(env_prefix="CLASSIFIER_", env_file=".env", extra="ignore") music_threshold: float = 0.7 speech_threshold: float = 0.6 silence_threshold: float = 0.85 window_seconds: float = 3.0 class LLMSettings(BaseSettings): """LLM service configuration (OpenAI-compatible API).""" model_config = SettingsConfigDict(env_prefix="LLM_", env_file=".env", extra="ignore") base_url: str = "http://localhost:11434/v1" model: str = "llama3" api_key: SecretStr = SecretStr("not-needed") timeout: float = 30.0 max_tokens: int = 1024 temperature: float = 0.3 class HoldSlayerSettings(BaseSettings): """Hold Slayer behavior settings.""" model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True, env_file=".env", extra="ignore") default_transfer_device: str = Field( default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE" ) max_hold_time: int = Field(default=7200, validation_alias="MAX_HOLD_TIME") hold_check_interval: float = Field(default=2.0, validation_alias="HOLD_CHECK_INTERVAL") class TTSSettings(BaseSettings): """Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech).""" model_config = SettingsConfigDict(env_prefix="TTS_", env_file=".env", extra="ignore") base_url: str = "http://localhost:8000" model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX" voice: str = "af_heart" api_key: SecretStr = SecretStr("") timeout: float = 30.0 sample_rate: int = 16000 class CasdoorSettings(BaseSettings): """Casdoor SSO (OIDC) configuration. When `enabled` is true the browser authenticates via Casdoor and every surface is gated to the owner; the SDK is used only for the OAuth2 code exchange in the /auth/callback route (JWTs are validated against the endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only). """ model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore") enabled: bool = False endpoint: str = "https://id.ouranos.helu.ca" client_id: str = "" client_secret: SecretStr = SecretStr("") org_name: str = "heluca" app_name: str = "" class ReceptionistSettings(BaseSettings): """AI Receptionist behavior settings.""" model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_", env_file=".env", extra="ignore") enabled: bool = True greeting_template: str = ( "Hi, you've reached Robert's line. Who's calling, and what's this about?" ) message_prompt: str = "Please leave your message after the tone." listen_timeout_s: float = 15.0 end_of_utterance_silence_s: float = 1.2 message_max_seconds: int = 90 llm_persona: str = ( "You are a helpful, concise phone receptionist. Decide whether to ring " "the owner, take a message, or politely decline." ) class Settings(BaseSettings): """Root application settings.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) # Database — no default credentials; must be set in the environment database_url: str = "" # Server host: str = "0.0.0.0" port: int = 8000 debug: bool = False log_level: str = "info" # Log rendering: "text" (human-readable, for a terminal) or "json" (one # object per line, for Loki). Text is the default so local dev is readable; # the container sets LOG_FORMAT=json. See core/logging_config.py. log_format: str = "text" # Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI, # gated to a single owner. `owner_name` is the Casdoor username that owns # this gateway (everyone else gets 403). `public_base_url` seeds the OAuth # discovery URLs; blank derives them from request headers. Both cross-cut # every surface, so they live on the root model (like DATABASE_URL); the # Casdoor connection knobs live under the CASDOOR_ prefix. owner_name: str = "" public_base_url: str = "" # Outbound-call safety cap (REST + MCP make_call) max_concurrent_calls: int = 4 # Explicit engine mode — the mock engine must be asked for. An # unconfigured trunk without this flag fails startup instead of # silently degrading to a gateway that can't place real calls. use_mock_sip: bool = False # SIP stack: "sippy" (signalling only — the classifier gets no audio) or # "pjsua2" (call control + media, the only path where audio reaches the # classifier). Opt-in while the PJSUA2 engine is proven against the lab; # see docs/architecture.md → "Media plane: why PJSUA2 places the call". sip_engine: str = "sippy" # Notifications notify_sms_number: str = "" # Sub-configs sip_trunk: SIPTrunkSettings = Field(default_factory=SIPTrunkSettings) gateway_sip: GatewaySIPSettings = Field(default_factory=GatewaySIPSettings) speaches: SpeachesSettings = Field(default_factory=SpeachesSettings) classifier: ClassifierSettings = Field(default_factory=ClassifierSettings) llm: LLMSettings = Field(default_factory=LLMSettings) hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings) tts: TTSSettings = Field(default_factory=TTSSettings) receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings) casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings) # Singleton _settings: Settings | None = None def get_settings() -> Settings: """Get cached application settings.""" global _settings if _settings is None: _settings = Settings() return _settings