tensorplate-python 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tensorplate/__init__.py +71 -0
- tensorplate/client.py +278 -0
- tensorplate/conventions.py +29 -0
- tensorplate/errors.py +87 -0
- tensorplate/postprocess.py +142 -0
- tensorplate/preprocess.py +165 -0
- tensorplate/py.typed +0 -0
- tensorplate/serving.py +291 -0
- tensorplate/tensors.py +250 -0
- tensorplate/vision.py +121 -0
- tensorplate_python-0.1.3.dist-info/METADATA +83 -0
- tensorplate_python-0.1.3.dist-info/RECORD +15 -0
- tensorplate_python-0.1.3.dist-info/WHEEL +5 -0
- tensorplate_python-0.1.3.dist-info/licenses/LICENSE +203 -0
- tensorplate_python-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Client-side image preprocessing for detector models.
|
|
2
|
+
|
|
3
|
+
Decodes an image (path, bytes, or HWC uint8 ndarray), letterboxes it to
|
|
4
|
+
the model's input size, normalizes, orders channels, and lays it out as an
|
|
5
|
+
NCHW float32 input tensor. A :class:`LetterboxTransform` records the scale
|
|
6
|
+
and padding so detections map back to source-image pixels.
|
|
7
|
+
|
|
8
|
+
numpy and Pillow (the ``tensorplate-python[vision]`` extra) are imported
|
|
9
|
+
lazily so the core package stays importable without them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from tensorplate.tensors import DType, Layout, TensorInput
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _clamp(value: float, low: float, high: float) -> float:
|
|
26
|
+
return max(low, min(high, value))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class LetterboxTransform:
|
|
31
|
+
"""Maps model-input pixel coordinates back to source-image pixels."""
|
|
32
|
+
|
|
33
|
+
src_height: int
|
|
34
|
+
src_width: int
|
|
35
|
+
scale_x: float
|
|
36
|
+
scale_y: float
|
|
37
|
+
pad_x: float
|
|
38
|
+
pad_y: float
|
|
39
|
+
input_height: int
|
|
40
|
+
input_width: int
|
|
41
|
+
|
|
42
|
+
def map_box_to_source(
|
|
43
|
+
self, x1: float, y1: float, x2: float, y2: float
|
|
44
|
+
) -> tuple[float, float, float, float]:
|
|
45
|
+
"""Map a box from model-input pixels to source pixels (clamped to bounds)."""
|
|
46
|
+
return (
|
|
47
|
+
_clamp((x1 - self.pad_x) / self.scale_x, 0.0, float(self.src_width)),
|
|
48
|
+
_clamp((y1 - self.pad_y) / self.scale_y, 0.0, float(self.src_height)),
|
|
49
|
+
_clamp((x2 - self.pad_x) / self.scale_x, 0.0, float(self.src_width)),
|
|
50
|
+
_clamp((y2 - self.pad_y) / self.scale_y, 0.0, float(self.src_height)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class PreprocessConfig:
|
|
56
|
+
"""Preprocessing options. Defaults target YOLO-style NCHW float32 input."""
|
|
57
|
+
|
|
58
|
+
input_size: tuple[int, int] = (640, 640) # (height, width)
|
|
59
|
+
letterbox: bool = True
|
|
60
|
+
channel_order: str = "rgb" # "rgb" or "bgr"
|
|
61
|
+
channels_first: bool = True # NCHW when True, else NHWC
|
|
62
|
+
scale: float = 1.0 / 255.0
|
|
63
|
+
mean: tuple[float, ...] | None = None
|
|
64
|
+
std: tuple[float, ...] | None = None
|
|
65
|
+
dtype: DType = DType.FLOAT32
|
|
66
|
+
pad_value: int = 114
|
|
67
|
+
input_name: str = "images"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def preprocess(
|
|
71
|
+
image: str | bytes | Path | np.ndarray,
|
|
72
|
+
config: PreprocessConfig | None = None,
|
|
73
|
+
) -> tuple[TensorInput, LetterboxTransform]:
|
|
74
|
+
"""Preprocess ``image`` into a model input tensor and a back-mapping transform.
|
|
75
|
+
|
|
76
|
+
``image`` may be a filesystem path, encoded image bytes, or an HWC
|
|
77
|
+
uint8 ndarray (assumed RGB). Decoding path/bytes requires Pillow.
|
|
78
|
+
"""
|
|
79
|
+
import numpy as np
|
|
80
|
+
|
|
81
|
+
cfg = config if config is not None else PreprocessConfig()
|
|
82
|
+
if cfg.channel_order not in ("rgb", "bgr"):
|
|
83
|
+
raise ValueError(f"channel_order must be 'rgb' or 'bgr', got {cfg.channel_order!r}")
|
|
84
|
+
|
|
85
|
+
source = _decode_to_hwc_rgb_uint8(image)
|
|
86
|
+
input_height, input_width = cfg.input_size
|
|
87
|
+
resized, scale_x, scale_y, pad_x, pad_y = _resize(source, cfg)
|
|
88
|
+
transform = LetterboxTransform(
|
|
89
|
+
src_height=int(source.shape[0]),
|
|
90
|
+
src_width=int(source.shape[1]),
|
|
91
|
+
scale_x=scale_x,
|
|
92
|
+
scale_y=scale_y,
|
|
93
|
+
pad_x=pad_x,
|
|
94
|
+
pad_y=pad_y,
|
|
95
|
+
input_height=input_height,
|
|
96
|
+
input_width=input_width,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
array = resized.astype("float32")
|
|
100
|
+
if cfg.channel_order == "bgr":
|
|
101
|
+
array = array[:, :, ::-1]
|
|
102
|
+
array = array * cfg.scale
|
|
103
|
+
if cfg.mean is not None:
|
|
104
|
+
array = array - np.asarray(cfg.mean, dtype="float32")
|
|
105
|
+
if cfg.std is not None:
|
|
106
|
+
array = array / np.asarray(cfg.std, dtype="float32")
|
|
107
|
+
if cfg.channels_first:
|
|
108
|
+
array = np.transpose(array, (2, 0, 1))
|
|
109
|
+
array = array[np.newaxis, ...]
|
|
110
|
+
|
|
111
|
+
tensor = TensorInput.from_numpy(cfg.input_name, array, dtype=cfg.dtype, layout=Layout.ROW_MAJOR)
|
|
112
|
+
return tensor, transform
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _decode_to_hwc_rgb_uint8(image: str | bytes | Path | np.ndarray) -> np.ndarray:
|
|
116
|
+
if isinstance(image, (str, Path)):
|
|
117
|
+
return _decode_pil(image)
|
|
118
|
+
if isinstance(image, (bytes, bytearray)):
|
|
119
|
+
return _decode_pil(io.BytesIO(bytes(image)))
|
|
120
|
+
import numpy as np
|
|
121
|
+
|
|
122
|
+
array = np.asarray(image)
|
|
123
|
+
if array.ndim != 3 or array.shape[2] != 3:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"ndarray image must be HWC with 3 channels, got shape {tuple(array.shape)}"
|
|
126
|
+
)
|
|
127
|
+
if array.dtype != np.uint8:
|
|
128
|
+
raise ValueError(f"ndarray image must have dtype uint8, got {array.dtype}")
|
|
129
|
+
return np.ascontiguousarray(array)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _decode_pil(source: str | Path | io.BytesIO) -> np.ndarray:
|
|
133
|
+
try:
|
|
134
|
+
from PIL import Image
|
|
135
|
+
except ModuleNotFoundError as exc: # pragma: no cover - exercised only without Pillow
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"image decoding requires Pillow; install `tensorplate-python[vision]`"
|
|
138
|
+
) from exc
|
|
139
|
+
import numpy as np
|
|
140
|
+
|
|
141
|
+
with Image.open(source) as handle:
|
|
142
|
+
return np.asarray(handle.convert("RGB"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _resize(
|
|
146
|
+
source: np.ndarray, cfg: PreprocessConfig
|
|
147
|
+
) -> tuple[np.ndarray, float, float, float, float]:
|
|
148
|
+
import numpy as np
|
|
149
|
+
from PIL import Image
|
|
150
|
+
|
|
151
|
+
src_height, src_width = int(source.shape[0]), int(source.shape[1])
|
|
152
|
+
input_height, input_width = cfg.input_size
|
|
153
|
+
pil = Image.fromarray(source)
|
|
154
|
+
if cfg.letterbox:
|
|
155
|
+
scale = min(input_width / src_width, input_height / src_height)
|
|
156
|
+
new_width = max(1, round(src_width * scale))
|
|
157
|
+
new_height = max(1, round(src_height * scale))
|
|
158
|
+
resized = np.asarray(pil.resize((new_width, new_height), Image.Resampling.BILINEAR))
|
|
159
|
+
canvas = np.full((input_height, input_width, 3), cfg.pad_value, dtype="uint8")
|
|
160
|
+
pad_x = (input_width - new_width) // 2
|
|
161
|
+
pad_y = (input_height - new_height) // 2
|
|
162
|
+
canvas[pad_y : pad_y + new_height, pad_x : pad_x + new_width] = resized
|
|
163
|
+
return canvas, float(scale), float(scale), float(pad_x), float(pad_y)
|
|
164
|
+
resized = np.asarray(pil.resize((input_width, input_height), Image.Resampling.BILINEAR))
|
|
165
|
+
return resized, input_width / src_width, input_height / src_height, 0.0, 0.0
|
tensorplate/py.typed
ADDED
|
File without changes
|
tensorplate/serving.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Synchronous client for the TensorPlate v0.1 serving HTTP envelope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from tensorplate.client import (
|
|
11
|
+
DEFAULT_TIMEOUT_S,
|
|
12
|
+
ResolvedEndpoint,
|
|
13
|
+
http_request,
|
|
14
|
+
resolve_serving_url,
|
|
15
|
+
)
|
|
16
|
+
from tensorplate.errors import (
|
|
17
|
+
ErrorCode,
|
|
18
|
+
ProtocolError,
|
|
19
|
+
ServingError,
|
|
20
|
+
UnsupportedSchemaVersionError,
|
|
21
|
+
)
|
|
22
|
+
from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
|
|
23
|
+
|
|
24
|
+
#: Serving envelope schema_version this SDK speaks.
|
|
25
|
+
SCHEMA_VERSION = "0.1"
|
|
26
|
+
|
|
27
|
+
_HEALTH_PATH = "/health"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Timing:
|
|
32
|
+
"""Optional per-request timing reported by the worker."""
|
|
33
|
+
|
|
34
|
+
queue_latency_ns: int | None = None
|
|
35
|
+
execution_latency_ns: int | None = None
|
|
36
|
+
total_latency_ns: int | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class InferResult:
|
|
41
|
+
"""Parsed v0.1 ``success`` response."""
|
|
42
|
+
|
|
43
|
+
request_id: str
|
|
44
|
+
outputs: tuple[TensorOutput, ...]
|
|
45
|
+
correlation_id: str | None = None
|
|
46
|
+
timing: Timing | None = None
|
|
47
|
+
|
|
48
|
+
def output(self, name: str) -> TensorOutput:
|
|
49
|
+
"""Return the output tensor named ``name`` or raise ``KeyError``."""
|
|
50
|
+
for tensor in self.outputs:
|
|
51
|
+
if tensor.name == name:
|
|
52
|
+
return tensor
|
|
53
|
+
raise KeyError(f"no output named {name!r}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class HealthSnapshot:
|
|
58
|
+
"""Parsed ``GET /health`` response."""
|
|
59
|
+
|
|
60
|
+
state: str
|
|
61
|
+
endpoint: str
|
|
62
|
+
backend: str
|
|
63
|
+
active_model_id: str | None = None
|
|
64
|
+
last_error_code: ErrorCode | None = None
|
|
65
|
+
last_error_message: str | None = None
|
|
66
|
+
queue_depth: int | None = None
|
|
67
|
+
in_flight: int | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_ready(self) -> bool:
|
|
71
|
+
"""True only for the ``ready`` state (``degraded`` is not ready)."""
|
|
72
|
+
return self.state == "ready"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ServingClient:
|
|
76
|
+
"""Synchronous client for the v0.1 ``/infer`` serving HTTP envelope.
|
|
77
|
+
|
|
78
|
+
The endpoint is resolved once at construction with the same precedence
|
|
79
|
+
as ``tensorplate infer`` (explicit URL, then CLI profile, then
|
|
80
|
+
read-only agent discovery, then the loopback default). The client is
|
|
81
|
+
model-class-neutral so higher-level helpers can build on it.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
serving_url: str | None = None,
|
|
87
|
+
*,
|
|
88
|
+
profile: str | None = None,
|
|
89
|
+
config_path: str | None = None,
|
|
90
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
91
|
+
discover: bool = True,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._endpoint = resolve_serving_url(
|
|
94
|
+
serving_url,
|
|
95
|
+
profile=profile,
|
|
96
|
+
config_path=config_path,
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
discover=discover,
|
|
99
|
+
)
|
|
100
|
+
self._timeout = timeout
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def endpoint(self) -> ResolvedEndpoint:
|
|
104
|
+
"""The resolved serving endpoint and how it was resolved."""
|
|
105
|
+
return self._endpoint
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def tensor_input(
|
|
109
|
+
name: str,
|
|
110
|
+
data: bytes,
|
|
111
|
+
dtype: DType | str,
|
|
112
|
+
shape: Sequence[int],
|
|
113
|
+
*,
|
|
114
|
+
layout: Layout | str = Layout.ROW_MAJOR,
|
|
115
|
+
) -> TensorInput:
|
|
116
|
+
"""Build a :class:`TensorInput` from raw bytes and metadata."""
|
|
117
|
+
return TensorInput(
|
|
118
|
+
name=name,
|
|
119
|
+
dtype=DType(dtype),
|
|
120
|
+
shape=tuple(shape),
|
|
121
|
+
data=data,
|
|
122
|
+
layout=Layout(layout),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def infer(
|
|
126
|
+
self,
|
|
127
|
+
endpoint: str,
|
|
128
|
+
inputs: Sequence[TensorInput],
|
|
129
|
+
*,
|
|
130
|
+
deadline_ms: int | None = None,
|
|
131
|
+
correlation_id: str | None = None,
|
|
132
|
+
) -> InferResult:
|
|
133
|
+
"""Run a single synchronous inference against ``endpoint``.
|
|
134
|
+
|
|
135
|
+
Raises :class:`~tensorplate.errors.ServingError` for a typed
|
|
136
|
+
``failure`` envelope, :class:`~tensorplate.errors.TransportError`
|
|
137
|
+
/ :class:`~tensorplate.errors.RequestTimeoutError` for transport
|
|
138
|
+
failures, and :class:`~tensorplate.errors.ProtocolError` /
|
|
139
|
+
:class:`~tensorplate.errors.UnsupportedSchemaVersionError` for a
|
|
140
|
+
malformed or unsupported response.
|
|
141
|
+
"""
|
|
142
|
+
if not endpoint:
|
|
143
|
+
raise ValueError("infer endpoint must be non-empty")
|
|
144
|
+
if not inputs:
|
|
145
|
+
raise ValueError("infer requires at least one input tensor")
|
|
146
|
+
if deadline_ms is not None and (
|
|
147
|
+
not isinstance(deadline_ms, int) or isinstance(deadline_ms, bool) or deadline_ms < 1
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("deadline_ms must be an integer greater than or equal to 1")
|
|
150
|
+
if correlation_id is not None and not correlation_id:
|
|
151
|
+
raise ValueError("correlation_id must be non-empty when provided")
|
|
152
|
+
request_id = str(uuid.uuid4())
|
|
153
|
+
request_body: dict[str, object] = {
|
|
154
|
+
"schema_version": SCHEMA_VERSION,
|
|
155
|
+
"request_id": request_id,
|
|
156
|
+
"endpoint": endpoint,
|
|
157
|
+
"inputs": [tensor.to_named_input() for tensor in inputs],
|
|
158
|
+
}
|
|
159
|
+
if deadline_ms is not None:
|
|
160
|
+
request_body["deadline_ms"] = deadline_ms
|
|
161
|
+
headers: dict[str, str] = {}
|
|
162
|
+
if correlation_id is not None:
|
|
163
|
+
request_body["metadata"] = {"correlation_id": correlation_id}
|
|
164
|
+
headers["X-Correlation-Id"] = correlation_id
|
|
165
|
+
payload = json.dumps(request_body).encode("utf-8")
|
|
166
|
+
status_code, raw = http_request(
|
|
167
|
+
"POST", self._endpoint.url, body=payload, headers=headers, timeout=self._timeout
|
|
168
|
+
)
|
|
169
|
+
return _parse_infer_response(raw, status_code, self._endpoint.url)
|
|
170
|
+
|
|
171
|
+
def health(self) -> HealthSnapshot:
|
|
172
|
+
"""Read ``GET /health`` and parse the worker's readiness snapshot.
|
|
173
|
+
|
|
174
|
+
The body is parsed regardless of the HTTP status (``ready`` and
|
|
175
|
+
``degraded`` return 200; the rest return 503), so inspect
|
|
176
|
+
:attr:`HealthSnapshot.state`, not just reachability.
|
|
177
|
+
"""
|
|
178
|
+
url = f"{self._endpoint.origin}{_HEALTH_PATH}"
|
|
179
|
+
_status_code, raw = http_request("GET", url, timeout=self._timeout)
|
|
180
|
+
payload = _decode_json_object(raw, url)
|
|
181
|
+
_require_supported_schema(payload)
|
|
182
|
+
return _parse_health(payload, url)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode_json_object(raw: bytes, url: str) -> dict[str, object]:
|
|
186
|
+
try:
|
|
187
|
+
parsed = json.loads(raw)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise ProtocolError(f"serving response from {url!r} is not valid JSON: {exc}") from exc
|
|
190
|
+
if not isinstance(parsed, dict):
|
|
191
|
+
raise ProtocolError(f"serving response from {url!r} is not a JSON object")
|
|
192
|
+
return parsed
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _require_supported_schema(payload: dict[str, object]) -> None:
|
|
196
|
+
version = payload.get("schema_version")
|
|
197
|
+
if version != SCHEMA_VERSION:
|
|
198
|
+
raise UnsupportedSchemaVersionError(
|
|
199
|
+
version if isinstance(version, str) else None, SCHEMA_VERSION
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_infer_response(raw: bytes, status_code: int, url: str) -> InferResult:
|
|
204
|
+
payload = _decode_json_object(raw, url)
|
|
205
|
+
_require_supported_schema(payload)
|
|
206
|
+
status = payload.get("status")
|
|
207
|
+
if status == "failure":
|
|
208
|
+
raise _serving_error(payload)
|
|
209
|
+
if status != "success":
|
|
210
|
+
raise ProtocolError(
|
|
211
|
+
f"serving response from {url!r} has unexpected status {status!r} (HTTP {status_code})"
|
|
212
|
+
)
|
|
213
|
+
outputs_obj = payload.get("outputs")
|
|
214
|
+
if not isinstance(outputs_obj, list) or not outputs_obj:
|
|
215
|
+
raise ProtocolError(f"serving success response from {url!r} is missing non-empty 'outputs'")
|
|
216
|
+
outputs = tuple(TensorOutput.from_named_output(item) for item in outputs_obj)
|
|
217
|
+
request_id = payload.get("request_id")
|
|
218
|
+
if not isinstance(request_id, str):
|
|
219
|
+
raise ProtocolError(f"serving success response from {url!r} is missing 'request_id'")
|
|
220
|
+
correlation_id = payload.get("correlation_id")
|
|
221
|
+
return InferResult(
|
|
222
|
+
request_id=request_id,
|
|
223
|
+
outputs=outputs,
|
|
224
|
+
correlation_id=correlation_id if isinstance(correlation_id, str) else None,
|
|
225
|
+
timing=_parse_timing(payload.get("timing")),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _serving_error(payload: dict[str, object]) -> ServingError:
|
|
230
|
+
request_id = payload.get("request_id")
|
|
231
|
+
request_id = request_id if isinstance(request_id, str) else None
|
|
232
|
+
error_obj = payload.get("error")
|
|
233
|
+
if not isinstance(error_obj, dict):
|
|
234
|
+
return ServingError(
|
|
235
|
+
ErrorCode.INTERNAL,
|
|
236
|
+
"serving worker returned a failure without a typed error",
|
|
237
|
+
request_id=request_id,
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
code = ErrorCode(error_obj.get("code"))
|
|
241
|
+
except ValueError:
|
|
242
|
+
code = ErrorCode.INTERNAL
|
|
243
|
+
message_obj = error_obj.get("message")
|
|
244
|
+
context_obj = error_obj.get("context")
|
|
245
|
+
return ServingError(
|
|
246
|
+
code,
|
|
247
|
+
message_obj if isinstance(message_obj, str) else "serving worker returned an error",
|
|
248
|
+
context=context_obj if isinstance(context_obj, str) else None,
|
|
249
|
+
request_id=request_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_health(payload: dict[str, object], url: str) -> HealthSnapshot:
|
|
254
|
+
state = payload.get("state")
|
|
255
|
+
if not isinstance(state, str):
|
|
256
|
+
raise ProtocolError(f"/health response from {url!r} is missing 'state'")
|
|
257
|
+
endpoint = payload.get("endpoint")
|
|
258
|
+
backend = payload.get("backend")
|
|
259
|
+
code_obj = payload.get("last_error_code")
|
|
260
|
+
try:
|
|
261
|
+
last_error_code = ErrorCode(code_obj) if code_obj is not None else None
|
|
262
|
+
except ValueError:
|
|
263
|
+
last_error_code = None
|
|
264
|
+
return HealthSnapshot(
|
|
265
|
+
state=state,
|
|
266
|
+
endpoint=endpoint if isinstance(endpoint, str) else "",
|
|
267
|
+
backend=backend if isinstance(backend, str) else "",
|
|
268
|
+
active_model_id=_optional_str(payload.get("active_model_id")),
|
|
269
|
+
last_error_code=last_error_code,
|
|
270
|
+
last_error_message=_optional_str(payload.get("last_error_message")),
|
|
271
|
+
queue_depth=_optional_int(payload.get("queue_depth")),
|
|
272
|
+
in_flight=_optional_int(payload.get("in_flight")),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_timing(obj: object) -> Timing | None:
|
|
277
|
+
if not isinstance(obj, dict):
|
|
278
|
+
return None
|
|
279
|
+
return Timing(
|
|
280
|
+
queue_latency_ns=_optional_int(obj.get("queue_latency_ns")),
|
|
281
|
+
execution_latency_ns=_optional_int(obj.get("execution_latency_ns")),
|
|
282
|
+
total_latency_ns=_optional_int(obj.get("total_latency_ns")),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _optional_int(value: object) -> int | None:
|
|
287
|
+
return value if isinstance(value, int) else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _optional_str(value: object) -> str | None:
|
|
291
|
+
return value if isinstance(value, str) else None
|