downshift-server 0.2.0__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.
downshift/loading.py ADDED
@@ -0,0 +1,166 @@
1
+ """Turn a CLI model argument into something the export layer can use.
2
+
3
+ Accepted forms:
4
+ model.onnx pre-built ONNX, served as-is (UNVERIFIED without --reference)
5
+ pkg.module:attr import spec; attr is a module instance or a zero-arg factory.
6
+ A sibling `make_inputs` in the same module is picked up automatically.
7
+ weights.pt state dict; needs --model-class pkg.module:Class. Loaded with
8
+ weights_only=True. A pickled full module needs --unsafe-load.
9
+ org/repo Hugging Face hub id (needs the [hf] extra)
10
+ path/to/repo/dir Locally downloaded Hugging Face repo, i.e. a directory containing
11
+ a config.json (needs the [hf] extra)
12
+ """
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from importlib import import_module
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import torch
21
+ from torch import nn
22
+
23
+ _IMPORT_SPEC = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
24
+ _STATE_DICT_SUFFIXES = {".pt", ".pth", ".bin", ".ckpt"}
25
+
26
+
27
+ class LoadError(ValueError):
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class LoadedModel:
33
+ source: str
34
+ model: nn.Module | None = None
35
+ onnx_path: Path | None = None
36
+ example_inputs: tuple | None = None
37
+ adapter_hint: str | None = None
38
+
39
+ @property
40
+ def source_path(self) -> Path | None:
41
+ path = Path(self.source)
42
+ return path if path.exists() else None
43
+
44
+
45
+ def import_object(spec: str) -> Any:
46
+ if not _IMPORT_SPEC.match(spec):
47
+ raise LoadError(f"{spec!r} is not an import spec of the form package.module:attr")
48
+ module_name, _, attr = spec.partition(":")
49
+ try:
50
+ module = import_module(module_name)
51
+ except ImportError as exc:
52
+ raise LoadError(f"can't import {module_name!r}: {exc}") from exc
53
+ try:
54
+ return getattr(module, attr)
55
+ except AttributeError as exc:
56
+ raise LoadError(f"{module_name!r} has no attribute {attr!r}") from exc
57
+
58
+
59
+ def _instantiate(obj: Any) -> nn.Module:
60
+ if isinstance(obj, nn.Module):
61
+ return obj
62
+ if callable(obj):
63
+ model = obj()
64
+ if isinstance(model, nn.Module):
65
+ return model
66
+ raise LoadError(f"{obj!r}() returned {type(model).__name__}, not an nn.Module")
67
+ raise LoadError(f"{obj!r} is neither an nn.Module nor a callable that builds one")
68
+
69
+
70
+ def load_inputs(spec: str) -> tuple:
71
+ inputs = import_object(spec)
72
+ if callable(inputs):
73
+ inputs = inputs()
74
+ return inputs if isinstance(inputs, tuple) else (inputs,)
75
+
76
+
77
+ def _load_from_import_spec(spec: str, inputs_spec: str | None) -> LoadedModel:
78
+ model = _instantiate(import_object(spec))
79
+ inputs: tuple | None = None
80
+ if inputs_spec:
81
+ inputs = load_inputs(inputs_spec)
82
+ else:
83
+ module_name = spec.partition(":")[0]
84
+ if callable(getattr(import_module(module_name), "make_inputs", None)):
85
+ inputs = load_inputs(f"{module_name}:make_inputs")
86
+ return LoadedModel(source=spec, model=model, example_inputs=inputs)
87
+
88
+
89
+ def _load_checkpoint(path: Path, model_class: str | None, unsafe_load: bool) -> nn.Module:
90
+ try:
91
+ payload = torch.load(path, map_location="cpu", weights_only=not unsafe_load)
92
+ except Exception as exc: # torch raises a few different types here
93
+ if unsafe_load:
94
+ raise LoadError(f"failed to load {path}: {exc}") from exc
95
+ raise LoadError(
96
+ f"{path} isn't loadable with weights_only=True ({type(exc).__name__}). If it "
97
+ "holds a pickled nn.Module from a source you trust, re-run with --unsafe-load."
98
+ ) from exc
99
+
100
+ if isinstance(payload, nn.Module):
101
+ return payload
102
+ if isinstance(payload, dict):
103
+ state = payload.get("state_dict", payload)
104
+ if model_class is None:
105
+ raise LoadError(
106
+ f"{path} is a state dict; pass --model-class package.module:Class so it can "
107
+ "be instantiated and the weights loaded into it."
108
+ )
109
+ model = _instantiate(import_object(model_class))
110
+ model.load_state_dict(state)
111
+ return model
112
+ raise LoadError(f"{path} contained {type(payload).__name__}, expected a state dict")
113
+
114
+
115
+ def load_model(
116
+ spec: str,
117
+ inputs: str | None = None,
118
+ model_class: str | None = None,
119
+ unsafe_load: bool = False,
120
+ ) -> LoadedModel:
121
+ path = Path(spec)
122
+ if path.suffix == ".onnx":
123
+ if not path.exists():
124
+ raise LoadError(f"{path} does not exist")
125
+ return LoadedModel(source=spec, onnx_path=path)
126
+
127
+ if path.exists() and path.suffix in _STATE_DICT_SUFFIXES:
128
+ model = _load_checkpoint(path, model_class, unsafe_load)
129
+ return LoadedModel(
130
+ source=spec, model=model, example_inputs=load_inputs(inputs) if inputs else None
131
+ )
132
+
133
+ if _IMPORT_SPEC.match(spec):
134
+ return _load_from_import_spec(spec, inputs)
135
+
136
+ if path.is_dir():
137
+ if not (path / "config.json").exists():
138
+ raise LoadError(f"{path} has no config.json — not a Hugging Face repo")
139
+ return _load_hf(spec, inputs)
140
+
141
+ if path.exists():
142
+ raise LoadError(f"don't know how to load {path} (suffix {path.suffix!r})")
143
+ if path.suffix or path.is_absolute():
144
+ raise LoadError(f"{path} does not exist")
145
+
146
+ return _load_hf(spec, inputs)
147
+
148
+
149
+ def _load_hf(spec: str, inputs: str | None) -> LoadedModel:
150
+ try:
151
+ from downshift.adapters import hf
152
+ except ImportError as exc:
153
+ raise LoadError(
154
+ f"{spec!r} isn't a file or an import spec; loading it as a Hugging Face repo "
155
+ "needs the [hf] extra: pip install 'downshift-server[hf]'"
156
+ ) from exc
157
+ try:
158
+ model = hf.load_pretrained(spec)
159
+ except (OSError, ValueError) as exc: # hub errors and bad local repos surface as either
160
+ raise LoadError(f"can't load {spec!r} as a Hugging Face repo: {exc}") from exc
161
+ return LoadedModel(
162
+ source=spec,
163
+ model=model,
164
+ example_inputs=load_inputs(inputs) if inputs else None,
165
+ adapter_hint="hf",
166
+ )
File without changes
downshift/serve/app.py ADDED
@@ -0,0 +1,93 @@
1
+ """FastAPI app over a ServingState. The same routes regardless of which backend is behind it."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.responses import JSONResponse
9
+
10
+ import downshift
11
+ from downshift.serve.engine import ServingState
12
+ from downshift.serve.middleware import load_middleware
13
+ from downshift.serve.schemas import (
14
+ GraphPredictRequest,
15
+ HealthResponse,
16
+ MetadataResponse,
17
+ PredictRequest,
18
+ PredictResponse,
19
+ ReadyResponse,
20
+ to_numpy,
21
+ )
22
+
23
+
24
+ def run_predict(state: ServingState, inputs: dict[str, Any]) -> PredictResponse:
25
+ """Validate, convert, infer. Raises HTTPException(400) for anything the client got wrong."""
26
+ missing = [n for n in state.input_names if n not in inputs]
27
+ if missing:
28
+ raise HTTPException(400, f"missing inputs: {missing}")
29
+
30
+ declared = {spec.name: spec.dtype for spec in state.backend.metadata().inputs}
31
+ try:
32
+ feeds = {n: to_numpy(n, inputs[n], declared.get(n)) for n in state.input_names}
33
+ outputs = state.backend.infer(feeds)
34
+ except HTTPException:
35
+ raise
36
+ except Exception as exc: # shape/dtype errors from ORT or torch are the client's problem
37
+ raise HTTPException(400, str(exc)) from exc
38
+
39
+ arrays = {name: np.asarray(arr) for name, arr in outputs.items()}
40
+ return PredictResponse(
41
+ outputs={name: arr.tolist() for name, arr in arrays.items()},
42
+ shapes={name: list(arr.shape) for name, arr in arrays.items()},
43
+ dtypes={name: arr.dtype.name for name, arr in arrays.items()},
44
+ )
45
+
46
+
47
+ def build_app(state: ServingState, middleware: Sequence[str] = ()) -> FastAPI:
48
+ app = FastAPI(title="downshift", version=downshift.__version__)
49
+ app.state.serving = state
50
+ load_middleware(app, middleware)
51
+
52
+ @app.get("/health", response_model=HealthResponse)
53
+ def health() -> HealthResponse:
54
+ return HealthResponse()
55
+
56
+ @app.get("/ready", response_model=ReadyResponse)
57
+ def ready() -> JSONResponse:
58
+ status = 200 if state.ready else 503
59
+ return JSONResponse({"ready": state.ready}, status_code=status)
60
+
61
+ @app.get("/metadata", response_model=MetadataResponse)
62
+ def metadata() -> MetadataResponse:
63
+ return MetadataResponse(
64
+ model=state.source,
65
+ family=state.verdict.model_family,
66
+ verdict=state.verdict.to_dict(),
67
+ backend=state.backend.metadata().to_dict(),
68
+ input_names=list(state.input_names),
69
+ notes=list(state.notes),
70
+ version=downshift.__version__,
71
+ )
72
+
73
+ @app.post("/predict", response_model=PredictResponse)
74
+ def predict(req: PredictRequest) -> PredictResponse:
75
+ return run_predict(state, req.inputs)
76
+
77
+ @app.post("/predict/graph", response_model=PredictResponse)
78
+ def predict_graph(req: GraphPredictRequest) -> PredictResponse:
79
+ if not {"x", "edge_index"} <= set(state.input_names):
80
+ raise HTTPException(
81
+ 400,
82
+ f"model is not graph-shaped: inputs are {list(state.input_names)}, "
83
+ "expected at least 'x' and 'edge_index'",
84
+ )
85
+ inputs: dict[str, Any] = {
86
+ "x": req.x,
87
+ "edge_index": {"data": req.edge_index, "dtype": "int64"},
88
+ }
89
+ if req.edge_attr is not None:
90
+ inputs["edge_attr"] = req.edge_attr
91
+ return run_predict(state, inputs)
92
+
93
+ return app
@@ -0,0 +1,181 @@
1
+ """Inference backends. Both take and return dicts of numpy arrays keyed by input name,
2
+ so the HTTP layer doesn't care which one is behind it.
3
+ """
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+ import torch
12
+ from torch import nn
13
+
14
+ from downshift.export.verdict import BackendName
15
+
16
+ _CUDA_EP = "CUDAExecutionProvider"
17
+ _CPU_EP = "CPUExecutionProvider"
18
+
19
+
20
+ @dataclass
21
+ class IOSpec:
22
+ name: str
23
+ dtype: str | None
24
+ shape: list[int | str | None] | None
25
+
26
+ def to_dict(self) -> dict:
27
+ return asdict(self)
28
+
29
+
30
+ @dataclass
31
+ class BackendMeta:
32
+ name: BackendName
33
+ device: str
34
+ inputs: list[IOSpec]
35
+ outputs: list[IOSpec]
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "name": self.name,
40
+ "device": self.device,
41
+ "inputs": [i.to_dict() for i in self.inputs],
42
+ "outputs": [o.to_dict() for o in self.outputs],
43
+ }
44
+
45
+
46
+ class Backend(Protocol):
47
+ name: BackendName
48
+ input_names: list[str]
49
+
50
+ def infer(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: ...
51
+
52
+ def metadata(self) -> BackendMeta: ...
53
+
54
+
55
+ def output_names(count: int) -> list[str]:
56
+ return [f"output_{i}" for i in range(count)]
57
+
58
+
59
+ def resolve_device(device: str) -> str:
60
+ if device == "auto":
61
+ return "cuda" if torch.cuda.is_available() else "cpu"
62
+ return device
63
+
64
+
65
+ def _ort_providers(device: str) -> list[str]:
66
+ available = ort.get_available_providers()
67
+ if device == "cuda" and _CUDA_EP in available:
68
+ return [_CUDA_EP, _CPU_EP]
69
+ return [_CPU_EP]
70
+
71
+
72
+ def _session_options(intra_op_threads: int, inter_op_threads: int) -> ort.SessionOptions:
73
+ """Max graph optimization always on. Thread counts of 0 mean "let ONNX Runtime choose",
74
+ which is also its own default, so this is safe to set unconditionally."""
75
+ options = ort.SessionOptions()
76
+ options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
77
+ options.intra_op_num_threads = intra_op_threads
78
+ options.inter_op_num_threads = inter_op_threads
79
+ return options
80
+
81
+
82
+ class OnnxRuntimeBackend:
83
+ name = BackendName.onnxruntime
84
+
85
+ def __init__(
86
+ self,
87
+ model: bytes | str | Path,
88
+ device: str = "auto",
89
+ intra_op_threads: int = 0,
90
+ inter_op_threads: int = 0,
91
+ ) -> None:
92
+ source = model if isinstance(model, bytes) else str(model)
93
+ providers = _ort_providers(resolve_device(device))
94
+ options = _session_options(intra_op_threads, inter_op_threads)
95
+ self.session = ort.InferenceSession(source, sess_options=options, providers=providers)
96
+ self.provider = self.session.get_providers()[0]
97
+ self.input_names = [i.name for i in self.session.get_inputs()]
98
+ self.onnx_output_names = [o.name for o in self.session.get_outputs()]
99
+ # Outputs are keyed positionally so responses look the same from either backend.
100
+ self.output_names = output_names(len(self.onnx_output_names))
101
+
102
+ def infer(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
103
+ outputs = self.session.run(self.onnx_output_names, inputs)
104
+ return dict(zip(self.output_names, outputs, strict=True))
105
+
106
+ def metadata(self) -> BackendMeta:
107
+ def spec(name: str, node) -> IOSpec:
108
+ return IOSpec(name, node.type, list(node.shape) if node.shape else None)
109
+
110
+ return BackendMeta(
111
+ name=self.name,
112
+ device=self.provider,
113
+ inputs=[spec(i.name, i) for i in self.session.get_inputs()],
114
+ outputs=[
115
+ spec(name, o)
116
+ for name, o in zip(self.output_names, self.session.get_outputs(), strict=True)
117
+ ],
118
+ )
119
+
120
+
121
+ class TorchBackend:
122
+ """Eager PyTorch. The fallback path, and a first-class one: same contract as ORT."""
123
+
124
+ name = BackendName.torch
125
+
126
+ def __init__(
127
+ self,
128
+ module: nn.Module,
129
+ input_names: tuple[str, ...],
130
+ device: str = "auto",
131
+ example_inputs: tuple | None = None,
132
+ ) -> None:
133
+ self.device = resolve_device(device)
134
+ self.module = module.eval().to(self.device)
135
+ self.input_names = list(input_names)
136
+ self._input_specs = [IOSpec(n, None, None) for n in self.input_names]
137
+ self._output_specs: list[IOSpec] = []
138
+ if example_inputs is not None:
139
+ # One pass over the example fills in dtypes and shapes for /metadata.
140
+ self._input_specs = [
141
+ _spec_from_tensor(n, t)
142
+ for n, t in zip(self.input_names, example_inputs, strict=True)
143
+ ]
144
+ feeds = {n: t.numpy() for n, t in zip(self.input_names, example_inputs, strict=True)}
145
+ self._output_specs = [_spec_from_array(n, a) for n, a in self.infer(feeds).items()]
146
+
147
+ def infer(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
148
+ missing = [n for n in self.input_names if n not in inputs]
149
+ if missing:
150
+ raise KeyError(f"missing inputs: {missing}")
151
+ args = [
152
+ torch.from_numpy(np.ascontiguousarray(inputs[n])).to(self.device)
153
+ for n in self.input_names
154
+ ]
155
+ with torch.inference_mode():
156
+ out = self.module(*args)
157
+ if isinstance(out, torch.Tensor):
158
+ tensors = [out]
159
+ else:
160
+ tensors = [t for t in out if isinstance(t, torch.Tensor)]
161
+ return {
162
+ name: t.detach().cpu().numpy()
163
+ for name, t in zip(output_names(len(tensors)), tensors, strict=True)
164
+ }
165
+
166
+ def metadata(self) -> BackendMeta:
167
+ return BackendMeta(self.name, self.device, self._input_specs, self._output_specs)
168
+
169
+
170
+ def _dynamic_shape(shape: tuple[int, ...]) -> list[int | str | None]:
171
+ # Axis 0 is dynamic for anything we serve; report it the way ORT does.
172
+ return ["batch", *shape[1:]] if shape else []
173
+
174
+
175
+ def _spec_from_tensor(name: str, t: torch.Tensor) -> IOSpec:
176
+ dtype = str(t.dtype).removeprefix("torch.")
177
+ return IOSpec(name, f"tensor({dtype})", _dynamic_shape(tuple(t.shape)))
178
+
179
+
180
+ def _spec_from_array(name: str, a: np.ndarray) -> IOSpec:
181
+ return IOSpec(name, f"tensor({a.dtype.name})", _dynamic_shape(a.shape))
@@ -0,0 +1,154 @@
1
+ """From a loaded model to a warmed-up backend. The CLI's `serve` is render(prepare_serving())."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import torch
10
+
11
+ from downshift.export.prevalidated import intake
12
+ from downshift.export.verdict import BackendName, ExportVerdict, build_verdict, prepare_model
13
+ from downshift.loading import LoadedModel
14
+ from downshift.serve.backends import Backend, OnnxRuntimeBackend, TorchBackend
15
+
16
+
17
+ class BackendChoice(str, Enum):
18
+ """What the caller asked for; "auto" defers to the verdict's recommendation."""
19
+
20
+ auto = "auto"
21
+ onnxruntime = "onnxruntime"
22
+ torch = "torch"
23
+
24
+
25
+ @dataclass
26
+ class ServeOptions:
27
+ backend: BackendChoice = BackendChoice.auto
28
+ force_onnx: bool = False # serve a DEGRADED graph via ORT anyway
29
+ device: str = "auto"
30
+ warmup: int = 3
31
+ k: int = 8
32
+ adapter: str | None = None
33
+ dynamic: dict[str, list[int]] | None = None
34
+ intra_op_threads: int = 0 # ORT SessionOptions; 0 = let ONNX Runtime choose
35
+ inter_op_threads: int = 0
36
+
37
+
38
+ @dataclass
39
+ class ServingState:
40
+ source: str
41
+ verdict: ExportVerdict
42
+ backend: Backend
43
+ input_names: tuple[str, ...]
44
+ options: ServeOptions
45
+ example_inputs: tuple | None = None
46
+ ready: bool = False
47
+ notes: list[str] = field(default_factory=list) # things the banner should say
48
+
49
+ @property
50
+ def backend_auto_selected(self) -> bool:
51
+ return self.options.backend == BackendChoice.auto and not self.forced_onnx
52
+
53
+ @property
54
+ def forced_onnx(self) -> bool:
55
+ return self.options.force_onnx and self.verdict.status == "DEGRADED"
56
+
57
+
58
+ def _verdict_for(
59
+ loaded: LoadedModel, reference: LoadedModel | None, opts: ServeOptions
60
+ ) -> ExportVerdict:
61
+ adapter = opts.adapter or loaded.adapter_hint
62
+ if loaded.onnx_path is not None:
63
+ ref_model = reference.model if reference else None
64
+ ref_inputs = reference.example_inputs if reference else None
65
+ return intake(
66
+ loaded.onnx_path, ref_model, ref_inputs, adapter, k=opts.k, dynamic=opts.dynamic
67
+ )
68
+ assert loaded.model is not None
69
+ prepared = prepare_model(loaded.model, loaded.example_inputs, adapter, opts.dynamic)
70
+ if opts.backend == BackendChoice.torch:
71
+ # Skip the export entirely; the user asked for eager.
72
+ return ExportVerdict(
73
+ status="UNVERIFIED",
74
+ model_family=prepared.family,
75
+ capture_strategy=None,
76
+ opset=None,
77
+ op_types=[],
78
+ numerics=None,
79
+ recommended_backend=BackendName.torch,
80
+ reason="--backend torch: export skipped",
81
+ input_names=prepared.input_names,
82
+ dynamic_dims=prepared.dynamic_dims,
83
+ prepared=prepared,
84
+ )
85
+ return build_verdict(prepared, k=opts.k)
86
+
87
+
88
+ def choose_backend(verdict: ExportVerdict, opts: ServeOptions) -> tuple[BackendName, list[str]]:
89
+ """Return (backend name, notes for the banner)."""
90
+ notes: list[str] = []
91
+ wanted: BackendName = (
92
+ verdict.recommended_backend
93
+ if opts.backend == BackendChoice.auto
94
+ else BackendName(opts.backend)
95
+ )
96
+ if opts.force_onnx and verdict.status == "DEGRADED":
97
+ wanted = BackendName.onnxruntime
98
+ notes.append("--force-onnx: serving a DEGRADED graph; outputs may be wrong")
99
+
100
+ has_onnx = verdict.onnx_program is not None or verdict.onnx_path is not None
101
+ has_torch = verdict.prepared is not None
102
+ if wanted == BackendName.onnxruntime and not has_onnx:
103
+ notes.append("no ONNX graph available; falling back to torch")
104
+ wanted = BackendName.torch
105
+ if wanted == BackendName.torch and not has_torch:
106
+ raise ValueError("torch backend requested but there is no PyTorch model to run")
107
+ return wanted, notes
108
+
109
+
110
+ def _build_backend(name: BackendName, verdict: ExportVerdict, opts: ServeOptions) -> Backend:
111
+ if name == BackendName.onnxruntime:
112
+ if verdict.onnx_path is not None:
113
+ source: bytes | Path = verdict.onnx_path
114
+ else:
115
+ program: Any = verdict.onnx_program
116
+ source = program.model_proto.SerializeToString()
117
+ return OnnxRuntimeBackend(source, opts.device, opts.intra_op_threads, opts.inter_op_threads)
118
+ prepared = verdict.prepared
119
+ assert prepared is not None
120
+ return TorchBackend(prepared.model, prepared.input_names, opts.device, prepared.inputs)
121
+
122
+
123
+ def prepare_serving(
124
+ loaded: LoadedModel, opts: ServeOptions | None = None, reference: LoadedModel | None = None
125
+ ) -> ServingState:
126
+ opts = opts or ServeOptions()
127
+ verdict = _verdict_for(loaded, reference, opts)
128
+ name, notes = choose_backend(verdict, opts)
129
+ backend = _build_backend(name, verdict, opts)
130
+
131
+ if verdict.prepared is not None:
132
+ input_names = verdict.prepared.input_names
133
+ example_inputs = verdict.prepared.inputs
134
+ else:
135
+ input_names = tuple(backend.input_names)
136
+ example_inputs = None
137
+
138
+ state = ServingState(
139
+ loaded.source, verdict, backend, input_names, opts, example_inputs, notes=notes
140
+ )
141
+ warmup(state, opts.warmup)
142
+ return state
143
+
144
+
145
+ def warmup(state: ServingState, n: int) -> None:
146
+ """Run a few inferences before /ready flips; first-call costs shouldn't hit users."""
147
+ if state.example_inputs is not None:
148
+ feeds = {
149
+ name: t.numpy() if isinstance(t, torch.Tensor) else np.asarray(t)
150
+ for name, t in zip(state.input_names, state.example_inputs, strict=True)
151
+ }
152
+ for _ in range(n):
153
+ state.backend.infer(feeds)
154
+ state.ready = True
@@ -0,0 +1,24 @@
1
+ """Attach user-supplied middleware given as "pkg.module:attr" import specs."""
2
+
3
+ import inspect
4
+ from collections.abc import Sequence
5
+
6
+ from fastapi import FastAPI
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+
9
+ from downshift.loading import import_object
10
+
11
+
12
+ def load_middleware(app: FastAPI, specs: Sequence[str]) -> None:
13
+ """Each spec names a BaseHTTPMiddleware subclass or an async (request, call_next) function."""
14
+ for spec in specs:
15
+ obj = import_object(spec)
16
+ if inspect.isclass(obj) and issubclass(obj, BaseHTTPMiddleware):
17
+ app.add_middleware(obj)
18
+ elif inspect.iscoroutinefunction(obj):
19
+ app.middleware("http")(obj)
20
+ else:
21
+ raise ValueError(
22
+ f"{spec!r} is not middleware: expected a BaseHTTPMiddleware subclass or an "
23
+ f"async function taking (request, call_next), got {type(obj).__name__}"
24
+ )