img2threejs / app /config.py
Mike0021's picture
Increase reasoning-spec generation headroom
f074dc6 verified
Raw
History Blame Contribute Delete
7.67 kB
"""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()