"""Central configuration — all values from environment, zero hard-coded secrets.""" from __future__ import annotations import os from pathlib import Path from typing import Literal from pydantic import BaseModel, Field, field_validator class LLMConfig(BaseModel): """LLM worker configuration.""" base_url: str = Field(..., description="Base URL of the LLM provider (OpenAI-compatible).") model: str = Field(..., description="Model identifier to use for LLM calls.") max_concurrency: int = Field(default=3, ge=1, description="Max parallel LLM requests.") def get_secret(self) -> str: """Return the raw API key string if present.""" key = os.environ.get("NSCT_LLM_API_KEY", "") return key if key else "" class VisionConfig(BaseModel): """Vision (image/video) model configuration.""" base_url: str = Field(..., description="Base URL of the vision provider endpoint.") model: str = Field(..., description="Vision model identifier.") def get_secret(self) -> str: key = os.environ.get("NSCT_VISION_API_KEY", "") return key if key else "" class AudioConfig(BaseModel): """Audio / speech model configuration.""" base_url: str = Field(..., description="Base URL of the audio provider endpoint.") model: str = Field(default="default", description="Audio model identifier.") def get_secret(self) -> str: key = os.environ.get("NSCT_AUDIO_API_KEY", "") return key if key else "" class DatabaseConfig(BaseModel): """Database connection configuration.""" url: str = Field( ..., description="Database URL (postgresql+asyncpg://...) or a default SQLite fallback.", ) class AppSettings(BaseModel): """Top-level application settings.""" llm: LLMConfig = Field(default_factory=LLMConfig) vision: VisionConfig = Field(default_factory=VisionConfig) audio: AudioConfig = Field(default_factory=AudioConfig) postgres: DatabaseConfig = Field(default_factory=DatabaseConfig) debug: bool = Field(default=False) searxng_base_url: str | None = Field(default=None, description="SearXNG instance URL.") searxng_engines: tuple[str, ...] = Field( default=(), description="Optional allow-list of SearXNG engines for web searches.", ) @staticmethod def from_env() -> "AppSettings": """Build AppSettings entirely from environment variables.""" llm_base_url = os.environ.get("NSCT_LLM_BASE_URL", "") llm_model = os.environ.get("NSCT_LLM_MODEL", "") llm_concurrency = int(os.environ.get("NSCT_LLM_MAX_CONCURRENCY", "3")) vision_base_url = os.environ.get("NSCT_VISION_BASE_URL", "") vision_model = os.environ.get("NSCT_VISION_MODEL", "") audio_base_url = os.environ.get("NSCT_AUDIO_BASE_URL", "") audio_model = os.environ.get("NSCT_AUDIO_MODEL", "default") db_url = os.environ.get("NSCT_DB_URL", "") llm_cfg = LLMConfig( base_url=llm_base_url, model=llm_model, max_concurrency=llm_concurrency, ) vision_cfg = VisionConfig( base_url=vision_base_url, model=vision_model, ) audio_cfg = AudioConfig( base_url=audio_base_url, model=audio_model, ) postgres_cfg = DatabaseConfig(url=db_url) debug = os.environ.get("NSCT_DEBUG", "false").lower() == "true" searxng_url = os.environ.get("NSCT_SEARXNG_BASE_URL", None) searxng_engines = tuple( engine.strip() for engine in os.environ.get("NSCT_SEARXNG_ENGINES", "").split(",") if engine.strip() ) return AppSettings( llm=llm_cfg, vision=vision_cfg, audio=audio_cfg, postgres=postgres_cfg, debug=debug, searxng_base_url=searxng_url, searxng_engines=searxng_engines, ) @property def config_dir(self) -> Path: """Return a config directory path for persisting runtime data.""" data_dir = Path.home() / ".nsct" data_dir.mkdir(parents=True, exist_ok=True) return data_dir