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()