File size: 12,357 Bytes
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7219507
 
 
 
 
 
 
 
26cad64
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
7219507
 
 
26cad64
 
 
 
 
 
 
 
 
 
 
7219507
 
 
 
26cad64
 
7219507
 
 
 
26cad64
7219507
 
26cad64
 
 
 
 
 
 
 
 
 
 
 
7219507
 
 
 
 
 
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7219507
 
 
 
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
 
7219507
 
 
 
 
 
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7219507
26cad64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""Lazy token-authenticated client for the private AutoML Space."""

from __future__ import annotations

import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Mapping

from gradio_client import Client, handle_file
from huggingface_hub import HfApi

from gateway_contract import PROTOCOL_VERSION, validate_request
from gateway_routing import VALID_PROFILES


class BackendUnavailable(RuntimeError):
    """The private backend cannot safely accept this request."""


class BackendTimeout(BackendUnavailable):
    """The private backend did not become ready before the wake deadline."""


class BackendProtocolMismatch(BackendUnavailable):
    """Gateway and backend contract versions differ."""


@dataclass(frozen=True)
class BackendResult:
    envelope: dict[str, Any]
    package_path: str | None = None
    cleanup_files: tuple[str, ...] = ()


def _default_api_factory(token: str) -> HfApi:
    return HfApi(token=token)


def _default_client_factory(repo_id: str, *, token: str) -> Client:
    return Client(repo_id, token=token, verbose=False)


def _remote_file_path(value: Any) -> str | None:
    if not value:
        return None
    if isinstance(value, (str, os.PathLike)):
        return str(value)
    if isinstance(value, Mapping):
        for key in ("path", "name", "orig_name"):
            if value.get(key):
                return str(value[key])
    for attribute in ("path", "name"):
        candidate = getattr(value, attribute, None)
        if candidate:
            return str(candidate)
    return None


class GatewayBackendClient:
    """Call one private Space without contacting it during construction."""

    TERMINAL_OPERATOR_STAGES = {
        "PAUSED",
        "BUILD_ERROR",
        "RUNTIME_ERROR",
        "CONFIG_ERROR",
        "NO_APP_FILE",
    }
    READY_STAGES = {"RUNNING"}
    SPACE_ID_ENV = {
        "cpu": "AUTOML_CPU_BACKEND_SPACE_ID",
        "gpu": "AUTOML_GPU_BACKEND_SPACE_ID",
    }
    SPACE_ID_DEFAULT = {
        "cpu": "automl-team/AutoML-cpu",
        "gpu": "automl-team/AutoML-core",
    }

    def __init__(
        self,
        *,
        repo_id: str,
        token: str,
        expected_profile: str,
        wake_timeout_seconds: float = 900,
        poll_interval_seconds: float = 2,
        api_factory: Callable[[str], Any] = _default_api_factory,
        client_factory: Callable[..., Any] = _default_client_factory,
        file_wrapper: Callable[[str], Any] = handle_file,
        clock: Callable[[], float] = time.monotonic,
        sleeper: Callable[[float], None] = time.sleep,
    ) -> None:
        self.repo_id = str(repo_id or "").strip()
        self._token = str(token or "").strip()
        self.expected_profile = str(expected_profile or "").strip().lower()
        if self.expected_profile not in VALID_PROFILES:
            raise BackendUnavailable("Private backend execution profile is invalid.")
        self.wake_timeout_seconds = max(0.1, float(wake_timeout_seconds))
        self.poll_interval_seconds = max(0.05, float(poll_interval_seconds))
        self._api_factory = api_factory
        self._client_factory = client_factory
        self._file_wrapper = file_wrapper
        self._clock = clock
        self._sleeper = sleeper
        self._api_instance: Any = None
        self._client_instance: Any = None

    def __repr__(self) -> str:
        return (
            f"GatewayBackendClient(repo_id={self.repo_id!r}, "
            f"expected_profile={self.expected_profile!r})"
        )

    @classmethod
    def from_env(cls, profile: str) -> "GatewayBackendClient":
        normalized_profile = str(profile or "").strip().lower()
        if normalized_profile not in VALID_PROFILES:
            raise BackendUnavailable("Private backend execution profile is invalid.")
        repo_id = os.getenv(
            cls.SPACE_ID_ENV[normalized_profile],
            cls.SPACE_ID_DEFAULT[normalized_profile],
        ).strip()
        token = os.getenv("HF_BACKEND_TOKEN", "").strip()
        if not repo_id:
            raise BackendUnavailable("Private backend Space ID is not configured.")
        if not token:
            raise BackendUnavailable("Private backend access is not configured.")
        try:
            timeout = float(
                os.getenv("AUTOML_BACKEND_WAKE_TIMEOUT_SECONDS", "900")
            )
        except ValueError:
            timeout = 900
        return cls(
            repo_id=repo_id,
            token=token,
            expected_profile=normalized_profile,
            wake_timeout_seconds=timeout,
        )

    def _api(self) -> Any:
        if self._api_instance is None:
            self._api_instance = self._api_factory(self._token)
        return self._api_instance

    def _client(self) -> Any:
        if self._client_instance is None:
            self._client_instance = self._client_factory(
                self.repo_id, token=self._token
            )
        return self._client_instance

    @staticmethod
    def _stage(value: Any) -> str:
        normalized = str(value or "UNKNOWN").strip().upper()
        if "." in normalized:
            normalized = normalized.rsplit(".", 1)[-1]
        return normalized

    def _runtime_stage(self) -> str:
        try:
            runtime = self._api().get_space_runtime(
                self.repo_id, token=self._token
            )
        except Exception as exc:
            detail = str(exc).lower()
            if any(marker in detail for marker in ("401", "403", "404", "unauthorized", "forbidden")):
                raise BackendUnavailable(
                    "Private backend access was rejected. Check the gateway token and Space visibility."
                ) from None
            raise
        return self._stage(getattr(runtime, "stage", None))

    @staticmethod
    def _emit(progress: Callable[[str], None] | None, message: str) -> None:
        if progress is not None:
            progress(message)

    def _verify_health(self, value: Any) -> dict[str, Any]:
        if not isinstance(value, Mapping):
            raise BackendUnavailable("Private backend returned an invalid health response.")
        response = dict(value)
        if response.get("protocol_version") != PROTOCOL_VERSION:
            raise BackendProtocolMismatch(
                "Gateway and backend versions differ. Deploy matching revisions before running jobs."
            )
        if response.get("ok") is not True:
            raise BackendUnavailable("Private backend health verification failed.")
        if response.get("execution_profile") != self.expected_profile:
            raise BackendProtocolMismatch(
                "Private backend execution profile does not match the selected route."
            )
        capabilities = set(response.get("capabilities") or [])
        required = {"existing_training", "uploaded_training", "package_prediction"}
        if not required.issubset(capabilities):
            raise BackendProtocolMismatch(
                "Gateway and backend capabilities differ. Deploy matching revisions before running jobs."
            )
        return response

    def ensure_ready(
        self, progress: Callable[[str], None] | None = None
    ) -> dict[str, Any]:
        """Wake with health traffic and poll only before a model endpoint is submitted."""

        deadline = self._clock() + self.wake_timeout_seconds
        profile_label = self.expected_profile.upper()
        announced_wake = False
        while True:
            if self._clock() >= deadline:
                raise BackendTimeout(
                    f"Private {profile_label} backend did not become ready before the wake timeout."
                )
            try:
                stage = self._runtime_stage()
            except BackendUnavailable:
                raise
            except Exception:
                stage = "UNKNOWN"
            if stage in self.TERMINAL_OPERATOR_STAGES:
                raise BackendUnavailable(
                    "Private backend needs operator attention before it can accept jobs."
                )
            if stage not in self.READY_STAGES and not announced_wake:
                self._emit(progress, f"{profile_label} backend is waking up…")
                announced_wake = True
            try:
                response = self._client().predict(
                    PROTOCOL_VERSION, api_name="/v1/health"
                )
                verified = self._verify_health(response)
                self._emit(progress, f"{profile_label} backend ready—starting job…")
                return verified
            except BackendProtocolMismatch:
                raise
            except Exception:
                self._client_instance = None
            remaining = deadline - self._clock()
            if remaining <= 0:
                raise BackendTimeout(
                    f"Private {profile_label} backend did not become ready before the wake timeout."
                )
            self._sleeper(min(self.poll_interval_seconds, remaining))

    def _submit_once(self, api_name: str, *args: Any) -> Any:
        try:
            return self._client().predict(*args, api_name=api_name)
        except Exception:
            raise BackendUnavailable(
                "The private backend job failed. It was not automatically retried."
            ) from None

    def _verify_request_profile(self, payload: Mapping[str, Any]) -> None:
        if payload.get("execution_profile") != self.expected_profile:
            raise BackendProtocolMismatch(
                "Request execution profile does not match the selected private backend profile."
            )

    @staticmethod
    def _training_result(value: Any) -> BackendResult:
        if not isinstance(value, (tuple, list)) or len(value) != 2:
            raise BackendUnavailable("Private backend returned an invalid training response.")
        envelope, package = value
        if not isinstance(envelope, Mapping):
            raise BackendUnavailable("Private backend returned an invalid result envelope.")
        package_path = _remote_file_path(package)
        cleanup = (package_path,) if package_path else ()
        return BackendResult(dict(envelope), package_path, cleanup)

    def run_existing(
        self,
        request: Mapping[str, Any] | str,
        progress: Callable[[str], None] | None = None,
    ) -> BackendResult:
        payload = validate_request("existing", request)
        self._verify_request_profile(payload)
        self.ensure_ready(progress)
        result = self._submit_once("/v1/train/existing", payload)
        return self._training_result(result)

    def run_uploaded(
        self,
        request: Mapping[str, Any] | str,
        train_path: str,
        test_path: str | None = None,
        progress: Callable[[str], None] | None = None,
    ) -> BackendResult:
        payload = validate_request("uploaded", request)
        self._verify_request_profile(payload)
        self.ensure_ready(progress)
        result = self._submit_once(
            "/v1/train/uploaded",
            payload,
            self._file_wrapper(str(train_path)),
            self._file_wrapper(str(test_path)) if test_path else None,
        )
        return self._training_result(result)

    def predict_package(
        self,
        request: Mapping[str, Any] | str,
        package_path: str,
        *,
        tabular_path: str | None = None,
        image_path: str | None = None,
        progress: Callable[[str], None] | None = None,
    ) -> BackendResult:
        payload = validate_request("predict", request)
        self._verify_request_profile(payload)
        self.ensure_ready(progress)
        result = self._submit_once(
            "/v1/predict/package",
            payload,
            self._file_wrapper(str(package_path)),
            self._file_wrapper(str(tabular_path)) if tabular_path else None,
            self._file_wrapper(str(image_path)) if image_path else None,
        )
        if not isinstance(result, Mapping):
            raise BackendUnavailable("Private backend returned an invalid prediction response.")
        return BackendResult(dict(result))


__all__ = [
    "BackendUnavailable",
    "BackendTimeout",
    "BackendProtocolMismatch",
    "BackendResult",
    "GatewayBackendClient",
]