Spaces:
Running
Running
| """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 | |
| 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")) | |
| def llm_configured(self) -> bool: | |
| return bool(self.llm_api_key and self.llm_model) | |
| 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() | |