Spaces:
Running
Running
File size: 7,666 Bytes
39ff632 f074dc6 39ff632 f074dc6 39ff632 37e3d5a 39ff632 bf1fb5f 39ff632 f074dc6 39ff632 bf1fb5f 39ff632 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | """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()
|