"""Runtime configuration for the img2threejs Space. All values come from environment variables. On Hugging Face Spaces the ``LLM_*`` variables are meant to be set as *Space Secrets* (Settings -> Secrets); they are injected into the process environment at runtime. The conventional ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_MODEL`` names are honoured as fallbacks so the Space also works when a deployer only sets those. Nothing in this module may ever log or return the API key value. """ from __future__ import annotations import math import os from dataclasses import dataclass, field def _first(*names: str) -> str | None: for name in names: value = os.environ.get(name) if value and value.strip(): return value.strip() return None def _int( name: str, default: int, *, minimum: int | None = None, maximum: int | None = None, ) -> int: """Read an integer setting without allowing malformed or extreme input. Environment variables are an operational boundary, not trusted Python values. Falling back on parse failure keeps the app bootable; clamping keeps an accidental value such as ``MAX_CONCURRENT_JOBS=-1`` from disabling a guard or allocating an unreasonable amount of work. """ raw = os.environ.get(name) value = default if raw is not None: try: value = int(raw.strip()) except (TypeError, ValueError): value = default if minimum is not None: value = max(minimum, value) if maximum is not None: value = min(maximum, value) return value def _float( name: str, default: float, *, minimum: float | None = None, maximum: float | None = None, ) -> float: """Read a finite, optionally clamped floating-point setting.""" raw = os.environ.get(name) value = default if raw is not None: try: parsed = float(raw.strip()) value = parsed if math.isfinite(parsed) else default except (TypeError, ValueError): value = default if minimum is not None: value = max(minimum, value) if maximum is not None: value = min(maximum, value) return value @dataclass(frozen=True) class Settings: """Immutable runtime settings snapshot.""" # --- LLM provider (Space Secrets) ------------------------------------- llm_api_key: str | None = field( default_factory=lambda: _first("LLM_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN") ) llm_base_url: str = field( default_factory=lambda: _first("LLM_BASE_URL", "ANTHROPIC_BASE_URL") or "https://api.anthropic.com" ) llm_model: str | None = field( default_factory=lambda: _first("LLM_MODEL", "ANTHROPIC_MODEL") ) # "anthropic" = Messages API only, "openai" = chat/completions only, # "auto" = anthropic first, fall back to openai on HTTP 404. llm_api_style: str = field( default_factory=lambda: (os.environ.get("LLM_API_STYLE") or "auto").strip().lower() ) # Reasoning models (e.g. kimi-k3) spend thinking tokens inside this # budget. A real 16k response can truncate before the JSON closes, so the # hosted default leaves enough headroom for both reasoning and the spec. llm_max_tokens: int = field( default_factory=lambda: _int( "LLM_MAX_TOKENS", 32768, minimum=256, maximum=131_072 ) ) llm_timeout_s: float = field( default_factory=lambda: _float( "LLM_TIMEOUT_S", 180.0, minimum=1.0, maximum=1800.0 ) ) llm_max_retries: int = field( default_factory=lambda: _int("LLM_MAX_RETRIES", 2, minimum=0, maximum=10) ) llm_referer: str | None = field(default_factory=lambda: _first("LLM_REFERER")) llm_title: str | None = field(default_factory=lambda: _first("LLM_TITLE")) # --- pipeline behaviour ------------------------------------------------ spec_repair_rounds: int = field( default_factory=lambda: _int("SPEC_REPAIR_ROUNDS", 3, minimum=0, maximum=10) ) # --- HTTP / server ------------------------------------------------------ port: int = field( default_factory=lambda: _int("PORT", 7860, minimum=1, maximum=65_535) ) runs_dir: str = field(default_factory=lambda: os.environ.get("RUNS_DIR", "/tmp/i2t-runs")) # Gallery items are disk-backed and survive process restarts. Deployments # that need persistence across Space rebuilds should point GALLERY_DIR at # a mounted persistent volume (for example /data/gallery). gallery_dir: str = field( default_factory=lambda: _first("GALLERY_DIR") or "/tmp/i2t-gallery" ) max_upload_bytes: int = field( default_factory=lambda: _int( "MAX_UPLOAD_BYTES", 10 * 1024 * 1024, minimum=64 * 1024, maximum=50 * 1024 * 1024, ) ) max_image_pixels: int = field( default_factory=lambda: _int( "MAX_IMAGE_PIXELS", 40_000_000, minimum=4096, maximum=100_000_000 ) ) # Longest-side pixel cap for the normalised image handed to the forge # scripts (pure-Python per-pixel readers) and to the LLM. normalize_max_side: int = field( default_factory=lambda: _int( "NORMALIZE_MAX_SIDE", 1024, minimum=64, maximum=8192 ) ) job_ttl_s: int = field( default_factory=lambda: _int( "JOB_TTL_S", 2 * 60 * 60, minimum=60, maximum=7 * 24 * 60 * 60 ) ) # Wall-clock bound from job acceptance through the completed browser # bundle. Queue time is included. Optional Bucket publication has its own # shorter bound below so a storage outage cannot consume a worker forever. job_timeout_s: float = field( default_factory=lambda: _float( "JOB_TIMEOUT_S", 1800.0, minimum=30.0, maximum=3600.0 ) ) gallery_publish_timeout_s: float = field( default_factory=lambda: _float( "GALLERY_PUBLISH_TIMEOUT_S", 120.0, minimum=5.0, maximum=600.0 ) ) max_concurrent_jobs: int = field( default_factory=lambda: _int( "MAX_CONCURRENT_JOBS", 2, minimum=1, maximum=16 ) ) # Hard cap on queued+running jobs (each pins its upload bytes in memory). max_in_flight_jobs: int = field( default_factory=lambda: _int( "MAX_IN_FLIGHT_JOBS", 8, minimum=1, maximum=64 ) ) rate_limit_jobs_per_hour: int = field( default_factory=lambda: _int( "RATE_LIMIT_JOBS_PER_HOUR", 10, minimum=1, maximum=10_000 ) ) # --- tooling ------------------------------------------------------------ # esbuild 0.25+ ships a statically-linked native binary (no node needed # at runtime); the .bin path is an npm-managed symlink to it. esbuild_entry: str = field( default_factory=lambda: os.environ.get("ESBUILD_ENTRY", "node_modules/.bin/esbuild") ) # --- informational ------------------------------------------------------- space_id: str | None = field(default_factory=lambda: _first("SPACE_ID")) space_host: str | None = field(default_factory=lambda: _first("SPACE_HOST")) @property def llm_configured(self) -> bool: return bool(self.llm_api_key and self.llm_model) @property def missing_llm_vars(self) -> list[str]: missing: list[str] = [] if not self.llm_api_key: missing.append("LLM_API_KEY") if not self.llm_model: missing.append("LLM_MODEL") return missing def load_settings() -> Settings: return Settings()