hutash-inference 0.1.0__tar.gz
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.
Potentially problematic release.
This version of hutash-inference might be problematic. Click here for more details.
- hutash_inference-0.1.0/PKG-INFO +10 -0
- hutash_inference-0.1.0/__init__.py +31 -0
- hutash_inference-0.1.0/base.py +312 -0
- hutash_inference-0.1.0/errors.py +26 -0
- hutash_inference-0.1.0/hutash_inference.egg-info/PKG-INFO +10 -0
- hutash_inference-0.1.0/hutash_inference.egg-info/SOURCES.txt +20 -0
- hutash_inference-0.1.0/hutash_inference.egg-info/dependency_links.txt +1 -0
- hutash_inference-0.1.0/hutash_inference.egg-info/requires.txt +3 -0
- hutash_inference-0.1.0/hutash_inference.egg-info/top_level.txt +1 -0
- hutash_inference-0.1.0/io_handlers.py +157 -0
- hutash_inference-0.1.0/logging.py +31 -0
- hutash_inference-0.1.0/pyproject.toml +42 -0
- hutash_inference-0.1.0/server.py +400 -0
- hutash_inference-0.1.0/setup.cfg +4 -0
- hutash_inference-0.1.0/validation.py +92 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hutash-inference
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared HTTP inference framework for Hutash models
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Appsork/hutash
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: uvicorn
|
|
10
|
+
Requires-Dist: python-multipart
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Aura Inference Library
|
|
2
|
+
|
|
3
|
+
Shared framework for Aura model containers. Provides:
|
|
4
|
+
- Inference base class that all model inference classes inherit from
|
|
5
|
+
- @capability decorator for marking methods as HTTP endpoint handlers
|
|
6
|
+
- FastAPI server boilerplate
|
|
7
|
+
- I/O helpers (audio, image, file)
|
|
8
|
+
- Validation (model.yaml / manifest.json <-> inference.py compatibility)
|
|
9
|
+
|
|
10
|
+
See docs/strategy/model-ssot-architecture.md for architecture details.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from hutash_inference.base import Inference, capability, resolve_local_weights_dir
|
|
14
|
+
from hutash_inference.errors import (
|
|
15
|
+
HutashInferenceError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
ModelLoadError,
|
|
18
|
+
GenerationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Inference",
|
|
25
|
+
"capability",
|
|
26
|
+
"resolve_local_weights_dir",
|
|
27
|
+
"HutashInferenceError",
|
|
28
|
+
"ValidationError",
|
|
29
|
+
"ModelLoadError",
|
|
30
|
+
"GenerationError",
|
|
31
|
+
]
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Inference base class and @capability decorator.
|
|
2
|
+
|
|
3
|
+
Every model's inference.py defines a class that inherits from Inference
|
|
4
|
+
and uses @capability to mark methods as capability handlers. The
|
|
5
|
+
hutash_inference server reads manifest.json, finds the @capability methods,
|
|
6
|
+
and wires them to HTTP endpoints.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def capability(capability_id: str) -> Callable:
|
|
16
|
+
"""Decorator marking a method as implementing a capability.
|
|
17
|
+
|
|
18
|
+
The capability_id must match a key under 'capabilities' in the
|
|
19
|
+
model's model.yaml file.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
@capability("voice-clone")
|
|
23
|
+
def clone_voice(self, text: str, reference_audio: bytes):
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
capability_id: ID matching a key in model.yaml capabilities
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The original function with metadata attached
|
|
31
|
+
"""
|
|
32
|
+
def decorator(func: Callable) -> Callable:
|
|
33
|
+
func._aura_capability = capability_id
|
|
34
|
+
return func
|
|
35
|
+
return decorator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_local_weights_dir(model_id: str) -> str:
|
|
39
|
+
"""Resolve the mounted weights snapshot dir for ``model_id``'s PINNED commit.
|
|
40
|
+
|
|
41
|
+
Weights-out: the model's weights are downloaded on the host and mounted at
|
|
42
|
+
``HF_HUB_CACHE`` (``/weights``). This returns
|
|
43
|
+
``<cache>/models--*/snapshots/<commit>/`` for the EXACT pinned commit so a
|
|
44
|
+
model can load from a local path (e.g. ``from_local(ckpt_dir=...)``) instead
|
|
45
|
+
of going through the HuggingFace cache / ``hf_hub_download`` (which needs a
|
|
46
|
+
blob/etag structure the staged cache does not have offline).
|
|
47
|
+
|
|
48
|
+
The pinned commit comes from ``HUTASH_HF_REVISION``, injected by
|
|
49
|
+
docker_manager from the catalogue's ``hf_revision``. This deliberately does
|
|
50
|
+
NOT fall back to "the first/only snapshot" — if the pinned commit's dir is
|
|
51
|
+
missing it raises, so a drifted/incomplete mount fails loudly instead of
|
|
52
|
+
silently loading the wrong weights.
|
|
53
|
+
"""
|
|
54
|
+
import glob
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
from hutash_inference.errors import ModelLoadError
|
|
58
|
+
|
|
59
|
+
cache = os.environ.get("HF_HUB_CACHE", "/weights")
|
|
60
|
+
revision = os.environ.get("HUTASH_HF_REVISION")
|
|
61
|
+
if not revision:
|
|
62
|
+
raise ModelLoadError(
|
|
63
|
+
f"resolve_local_weights_dir({model_id!r}): HUTASH_HF_REVISION is "
|
|
64
|
+
f"not set — cannot resolve the pinned weights snapshot. "
|
|
65
|
+
f"(docker_manager injects it from the catalogue hf_revision.)"
|
|
66
|
+
)
|
|
67
|
+
matches = [
|
|
68
|
+
m for m in glob.glob(os.path.join(cache, "models--*", "snapshots", revision))
|
|
69
|
+
if os.path.isdir(m)
|
|
70
|
+
]
|
|
71
|
+
if not matches:
|
|
72
|
+
raise ModelLoadError(
|
|
73
|
+
f"resolve_local_weights_dir({model_id!r}): pinned snapshot "
|
|
74
|
+
f"{revision} not found under {cache} — the weights mount is "
|
|
75
|
+
f"missing or not staged at this revision."
|
|
76
|
+
)
|
|
77
|
+
if len(matches) > 1:
|
|
78
|
+
raise ModelLoadError(
|
|
79
|
+
f"resolve_local_weights_dir({model_id!r}): multiple repos contain "
|
|
80
|
+
f"snapshot {revision}: {matches}"
|
|
81
|
+
)
|
|
82
|
+
return matches[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Inference:
|
|
86
|
+
"""Base class for all Aura model inference implementations.
|
|
87
|
+
|
|
88
|
+
Subclass this in your inference.py and override load() to initialize
|
|
89
|
+
your model. Use @capability decorator to mark methods as capability
|
|
90
|
+
handlers.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
class MyModelInference(Inference):
|
|
94
|
+
def load(self):
|
|
95
|
+
from my_library import MyModel
|
|
96
|
+
self.model = MyModel.from_pretrained("...")
|
|
97
|
+
|
|
98
|
+
@capability("my-capability")
|
|
99
|
+
def do_something(self, text: str) -> dict:
|
|
100
|
+
result = self.model.run(text)
|
|
101
|
+
return {"output": result}
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: Optional[dict] = None):
|
|
105
|
+
"""Initialize inference with model.yaml config.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
config: Parsed model.yaml contents, provided by server
|
|
109
|
+
"""
|
|
110
|
+
self.config = config or {}
|
|
111
|
+
self.model_id = self.config.get("model_id", self.__class__.__name__)
|
|
112
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
113
|
+
self._is_loaded = False
|
|
114
|
+
|
|
115
|
+
def load(self) -> None:
|
|
116
|
+
"""Default Tier-1 weights-out loader for standard HuggingFace models.
|
|
117
|
+
|
|
118
|
+
Called once at container startup. Models whose weights load via the
|
|
119
|
+
standard HF convention (``SomeClass.from_pretrained(dir)``) need NO
|
|
120
|
+
custom load(): they declare an ``hf_load`` spec in their
|
|
121
|
+
catalogue/manifest and the framework loads them here — offline, from
|
|
122
|
+
the mounted pinned snapshot. Non-standard models (chatterbox,
|
|
123
|
+
kokoro) override load() instead (Tier-2).
|
|
124
|
+
|
|
125
|
+
``hf_load`` spec::
|
|
126
|
+
|
|
127
|
+
{"library": "transformers", # default
|
|
128
|
+
"auto_class": "AutoModelForSpeechSeq2Seq", # required
|
|
129
|
+
"processor_class": "AutoProcessor", # optional
|
|
130
|
+
"tokenizer_class": "AutoTokenizer"} # optional
|
|
131
|
+
|
|
132
|
+
{"library": "diffusers",
|
|
133
|
+
"pipeline_class": "DiffusionPipeline"} # required
|
|
134
|
+
|
|
135
|
+
Everything loads with ``local_files_only=True`` from the mounted
|
|
136
|
+
snapshot — never the network, never a guessed class. Results land on
|
|
137
|
+
``self.model`` (+ ``self.processor`` / ``self.tokenizer`` when
|
|
138
|
+
declared).
|
|
139
|
+
"""
|
|
140
|
+
from hutash_inference.errors import ModelLoadError
|
|
141
|
+
|
|
142
|
+
spec = self.config.get("hf_load")
|
|
143
|
+
if not spec:
|
|
144
|
+
raise ModelLoadError(
|
|
145
|
+
f"{self.model_id!r} has no load() override and no 'hf_load' "
|
|
146
|
+
f"spec — a standard HuggingFace model needs an hf_load spec "
|
|
147
|
+
f"(Tier-1) in model.meta.yaml (flows into manifest.json); a "
|
|
148
|
+
f"non-standard model needs a custom load() override (Tier-2)."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
weights_dir = resolve_local_weights_dir(self.model_id)
|
|
152
|
+
library = spec.get("library", "transformers")
|
|
153
|
+
self.logger.info(
|
|
154
|
+
"Tier-1 load: %s via %s from %s", self.model_id, library, weights_dir
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if library == "transformers":
|
|
158
|
+
import transformers
|
|
159
|
+
|
|
160
|
+
auto_class = spec.get("auto_class")
|
|
161
|
+
if not auto_class:
|
|
162
|
+
raise ModelLoadError(
|
|
163
|
+
f"{self.model_id!r} hf_load.library='transformers' requires "
|
|
164
|
+
f"'auto_class' (e.g. AutoModelForSpeechSeq2Seq)."
|
|
165
|
+
)
|
|
166
|
+
self.model = self._hf_from_pretrained(transformers, auto_class, weights_dir)
|
|
167
|
+
if spec.get("processor_class"):
|
|
168
|
+
self.processor = self._hf_from_pretrained(
|
|
169
|
+
transformers, spec["processor_class"], weights_dir
|
|
170
|
+
)
|
|
171
|
+
if spec.get("tokenizer_class"):
|
|
172
|
+
self.tokenizer = self._hf_from_pretrained(
|
|
173
|
+
transformers, spec["tokenizer_class"], weights_dir
|
|
174
|
+
)
|
|
175
|
+
elif library == "diffusers":
|
|
176
|
+
import diffusers
|
|
177
|
+
|
|
178
|
+
pipeline_class = spec.get("pipeline_class")
|
|
179
|
+
if not pipeline_class:
|
|
180
|
+
raise ModelLoadError(
|
|
181
|
+
f"{self.model_id!r} hf_load.library='diffusers' requires "
|
|
182
|
+
f"'pipeline_class' (e.g. DiffusionPipeline)."
|
|
183
|
+
)
|
|
184
|
+
self.model = self._hf_from_pretrained(
|
|
185
|
+
diffusers, pipeline_class, weights_dir
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
raise ModelLoadError(
|
|
189
|
+
f"{self.model_id!r} hf_load.library={library!r} is not supported "
|
|
190
|
+
f"(use 'transformers' or 'diffusers')."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _hf_from_pretrained(self, module, class_name: str, weights_dir: str):
|
|
194
|
+
"""``getattr(module, class_name).from_pretrained(dir, local_files_only=True)``.
|
|
195
|
+
|
|
196
|
+
Offline by construction; raises a clear error if the class name is
|
|
197
|
+
not found in the library (never guesses a default).
|
|
198
|
+
"""
|
|
199
|
+
from hutash_inference.errors import ModelLoadError
|
|
200
|
+
|
|
201
|
+
cls = getattr(module, class_name, None)
|
|
202
|
+
if cls is None:
|
|
203
|
+
raise ModelLoadError(
|
|
204
|
+
f"{self.model_id!r}: {module.__name__}.{class_name} not found "
|
|
205
|
+
f"— check the hf_load spec's class name."
|
|
206
|
+
)
|
|
207
|
+
return cls.from_pretrained(weights_dir, local_files_only=True)
|
|
208
|
+
|
|
209
|
+
def unload(self) -> None:
|
|
210
|
+
"""Called on graceful shutdown.
|
|
211
|
+
|
|
212
|
+
Override for cleanup. Base implementation does nothing.
|
|
213
|
+
"""
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
def health_check(self) -> dict:
|
|
217
|
+
"""Return container health status.
|
|
218
|
+
|
|
219
|
+
Override for custom health logic. Default returns 'ok' if
|
|
220
|
+
load() has been called successfully.
|
|
221
|
+
"""
|
|
222
|
+
return {
|
|
223
|
+
"status": "ok" if self._is_loaded else "not_ready",
|
|
224
|
+
"loaded": self._is_loaded,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def mark_loaded(self) -> None:
|
|
228
|
+
"""Called by server after load() completes successfully."""
|
|
229
|
+
self._is_loaded = True
|
|
230
|
+
|
|
231
|
+
# ---- Helper methods for common I/O conversions ----
|
|
232
|
+
|
|
233
|
+
def _save_temp_audio(self, audio_bytes: bytes, suffix: str = ".wav") -> str:
|
|
234
|
+
"""Save audio bytes to a temporary file, return path.
|
|
235
|
+
|
|
236
|
+
Useful for libraries that require file paths rather than bytes.
|
|
237
|
+
Caller is responsible for cleanup.
|
|
238
|
+
"""
|
|
239
|
+
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
|
240
|
+
tmp.write(audio_bytes)
|
|
241
|
+
tmp.close()
|
|
242
|
+
return tmp.name
|
|
243
|
+
|
|
244
|
+
def _audio_to_wav_bytes(self, audio, sample_rate: int, channels: int = 1) -> bytes:
|
|
245
|
+
"""Convert audio array to WAV bytes.
|
|
246
|
+
|
|
247
|
+
Supports numpy arrays, torch tensors, lists.
|
|
248
|
+
"""
|
|
249
|
+
import soundfile as sf
|
|
250
|
+
import numpy as np
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
import torch
|
|
254
|
+
if isinstance(audio, torch.Tensor):
|
|
255
|
+
audio = audio.detach().cpu().numpy()
|
|
256
|
+
except ImportError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
if not isinstance(audio, np.ndarray):
|
|
260
|
+
audio = np.array(audio)
|
|
261
|
+
|
|
262
|
+
if channels == 1 and audio.ndim > 1:
|
|
263
|
+
audio = audio.squeeze()
|
|
264
|
+
|
|
265
|
+
buffer = io.BytesIO()
|
|
266
|
+
sf.write(buffer, audio, sample_rate, format="WAV")
|
|
267
|
+
return buffer.getvalue()
|
|
268
|
+
|
|
269
|
+
def _image_to_png_bytes(self, image) -> bytes:
|
|
270
|
+
"""Convert image to PNG bytes.
|
|
271
|
+
|
|
272
|
+
Supports PIL Images, numpy arrays, torch tensors.
|
|
273
|
+
"""
|
|
274
|
+
from PIL import Image
|
|
275
|
+
import numpy as np
|
|
276
|
+
|
|
277
|
+
if isinstance(image, Image.Image):
|
|
278
|
+
pil_image = image
|
|
279
|
+
else:
|
|
280
|
+
try:
|
|
281
|
+
import torch
|
|
282
|
+
if isinstance(image, torch.Tensor):
|
|
283
|
+
image = image.detach().cpu().numpy()
|
|
284
|
+
except ImportError:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
if image.dtype != np.uint8:
|
|
288
|
+
if image.max() <= 1.0:
|
|
289
|
+
image = (image * 255).astype(np.uint8)
|
|
290
|
+
else:
|
|
291
|
+
image = image.astype(np.uint8)
|
|
292
|
+
|
|
293
|
+
pil_image = Image.fromarray(image)
|
|
294
|
+
|
|
295
|
+
buffer = io.BytesIO()
|
|
296
|
+
pil_image.save(buffer, format="PNG")
|
|
297
|
+
return buffer.getvalue()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_capabilities(instance: Inference) -> dict[str, Callable]:
|
|
301
|
+
"""Discover @capability methods on an Inference instance.
|
|
302
|
+
|
|
303
|
+
Returns dict mapping capability_id to the bound method.
|
|
304
|
+
"""
|
|
305
|
+
capabilities: dict[str, Callable] = {}
|
|
306
|
+
for attr_name in dir(instance):
|
|
307
|
+
if attr_name.startswith("_"):
|
|
308
|
+
continue
|
|
309
|
+
attr = getattr(instance, attr_name)
|
|
310
|
+
if callable(attr) and hasattr(attr, "_aura_capability"):
|
|
311
|
+
capabilities[attr._aura_capability] = attr
|
|
312
|
+
return capabilities
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Standardized error types for Aura Inference library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HutashInferenceError(Exception):
|
|
5
|
+
"""Base class for all Aura inference errors."""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ValidationError(HutashInferenceError):
|
|
10
|
+
"""Raised when model.yaml and inference.py don't match."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelLoadError(HutashInferenceError):
|
|
15
|
+
"""Raised when model fails to load at container startup."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GenerationError(HutashInferenceError):
|
|
20
|
+
"""Raised when inference fails during a generation request."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CapabilityNotFoundError(HutashInferenceError):
|
|
25
|
+
"""Raised when a requested capability isn't implemented."""
|
|
26
|
+
pass
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hutash-inference
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared HTTP inference framework for Hutash models
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Appsork/hutash
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: uvicorn
|
|
10
|
+
Requires-Dist: python-multipart
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__init__.py
|
|
2
|
+
base.py
|
|
3
|
+
errors.py
|
|
4
|
+
io_handlers.py
|
|
5
|
+
logging.py
|
|
6
|
+
pyproject.toml
|
|
7
|
+
server.py
|
|
8
|
+
validation.py
|
|
9
|
+
./__init__.py
|
|
10
|
+
./base.py
|
|
11
|
+
./errors.py
|
|
12
|
+
./io_handlers.py
|
|
13
|
+
./logging.py
|
|
14
|
+
./server.py
|
|
15
|
+
./validation.py
|
|
16
|
+
hutash_inference.egg-info/PKG-INFO
|
|
17
|
+
hutash_inference.egg-info/SOURCES.txt
|
|
18
|
+
hutash_inference.egg-info/dependency_links.txt
|
|
19
|
+
hutash_inference.egg-info/requires.txt
|
|
20
|
+
hutash_inference.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hutash_inference
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""I/O handlers for converting between HTTP request data and Python values.
|
|
2
|
+
|
|
3
|
+
The server uses these to parse incoming request parameters according to
|
|
4
|
+
the types declared in manifest.json, and to serialize responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Module-level registry — types whose form value carries File bytes
|
|
13
|
+
# and that go through the sidecar's PCM-envelope decoder.
|
|
14
|
+
#
|
|
15
|
+
# Mirror of `api/services/models/schema_builder.py::INPUT_TYPE_MAP`'s
|
|
16
|
+
# `carries_file: True` rows. Ships baked into every model image, so
|
|
17
|
+
# this set is the in-container view; the sidecar reads its own SSOT.
|
|
18
|
+
# Models that introduce a new composite primitive call
|
|
19
|
+
# `register_carries_file_type()` at import time to extend the set
|
|
20
|
+
# without modifying this file.
|
|
21
|
+
_CARRIES_FILE_TYPES: set[str] = {
|
|
22
|
+
"audio_file",
|
|
23
|
+
"image_file",
|
|
24
|
+
"video_file",
|
|
25
|
+
"audio_with_transcript",
|
|
26
|
+
"text_file",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_carries_file_type(type_id: str) -> None:
|
|
31
|
+
"""Register a primitive input type as carrying File bytes.
|
|
32
|
+
|
|
33
|
+
Called at import time by model packages that introduce a new
|
|
34
|
+
composite primitive the in-container PCM decoder must recognise.
|
|
35
|
+
"""
|
|
36
|
+
_CARRIES_FILE_TYPES.add(type_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def carries_file(type_id: str) -> bool:
|
|
40
|
+
"""True if the type's form value carries File bytes (PCM envelope)."""
|
|
41
|
+
return type_id in _CARRIES_FILE_TYPES
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Backwards-compatible public name. Existing callers do
|
|
45
|
+
# `declared_type in CARRIES_FILE_TYPES`; `in` works identically on a
|
|
46
|
+
# set, so the rename to a set semantics is a drop-in.
|
|
47
|
+
CARRIES_FILE_TYPES = _CARRIES_FILE_TYPES
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_input(value: Any, declared_type: str) -> Any:
|
|
51
|
+
"""Parse input value according to declared type from manifest.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
value: Raw value from HTTP request
|
|
55
|
+
declared_type: Type string from manifest (e.g., "string", "audio_file")
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Parsed value ready to pass to inference method
|
|
59
|
+
"""
|
|
60
|
+
if declared_type in ("string", "text"):
|
|
61
|
+
return str(value)
|
|
62
|
+
|
|
63
|
+
if declared_type == "integer":
|
|
64
|
+
return int(value)
|
|
65
|
+
|
|
66
|
+
if declared_type == "number":
|
|
67
|
+
return float(value)
|
|
68
|
+
|
|
69
|
+
if declared_type == "boolean":
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
return value.lower() in ("true", "1", "yes")
|
|
72
|
+
return bool(value)
|
|
73
|
+
|
|
74
|
+
if declared_type in CARRIES_FILE_TYPES:
|
|
75
|
+
# Sidecar-preprocessed PCM envelope. Aura's sidecar decodes
|
|
76
|
+
# audio_file inputs via FFmpeg and forwards raw float32 PCM
|
|
77
|
+
# under this shape so containers never need torchaudio /
|
|
78
|
+
# torchcodec / FFmpeg for decoding.
|
|
79
|
+
#
|
|
80
|
+
# Shape:
|
|
81
|
+
# {
|
|
82
|
+
# "__pcm__": True,
|
|
83
|
+
# "pcm_b64": "<base64 raw float32 LE PCM>",
|
|
84
|
+
# "sample_rate": <int>,
|
|
85
|
+
# "channels": <int>, # 1 or 2
|
|
86
|
+
# }
|
|
87
|
+
#
|
|
88
|
+
# Returns 16-bit PCM WAV bytes (with header) — universal
|
|
89
|
+
# contract that works in every container (no torch import
|
|
90
|
+
# required) and matches the `file: bytes` signature every
|
|
91
|
+
# inference method already declares. Inference code that
|
|
92
|
+
# writes to a temp file and passes the path to its model
|
|
93
|
+
# keeps working unchanged.
|
|
94
|
+
if isinstance(value, dict) and value.get("__pcm__"):
|
|
95
|
+
import io
|
|
96
|
+
import wave
|
|
97
|
+
import numpy as np
|
|
98
|
+
|
|
99
|
+
pcm_bytes = base64.b64decode(value["pcm_b64"])
|
|
100
|
+
sample_rate = int(value.get("sample_rate", 16000))
|
|
101
|
+
channels = int(value.get("channels", 1)) or 1
|
|
102
|
+
# Convert float32 PCM → 16-bit WAV bytes
|
|
103
|
+
# Universal contract — works without torch, matches all inference.py signatures
|
|
104
|
+
f32 = np.frombuffer(pcm_bytes, dtype=np.float32)
|
|
105
|
+
s16 = (f32 * 32767.0).clip(-32768, 32767).astype(np.int16).tobytes()
|
|
106
|
+
buf = io.BytesIO()
|
|
107
|
+
with wave.open(buf, "wb") as w:
|
|
108
|
+
w.setnchannels(channels)
|
|
109
|
+
w.setsampwidth(2)
|
|
110
|
+
w.setframerate(sample_rate)
|
|
111
|
+
w.writeframes(s16)
|
|
112
|
+
return buf.getvalue()
|
|
113
|
+
|
|
114
|
+
# Legacy path: raw bytes or base64 string. Kept so any
|
|
115
|
+
# container that still receives undecoded bytes (e.g.
|
|
116
|
+
# a manifest input without target_sample_rate /
|
|
117
|
+
# target_channels declared) continues to work.
|
|
118
|
+
if isinstance(value, bytes):
|
|
119
|
+
return value
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
try:
|
|
122
|
+
return base64.b64decode(value)
|
|
123
|
+
except Exception:
|
|
124
|
+
return value.encode("utf-8")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
if declared_type == "reference_asset":
|
|
128
|
+
if isinstance(value, dict):
|
|
129
|
+
return value
|
|
130
|
+
return json.loads(value) if isinstance(value, str) else value
|
|
131
|
+
|
|
132
|
+
# Unknown type — pass through
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def serialize_output(value: Any, declared_type: str) -> Any:
|
|
137
|
+
"""Serialize output value according to declared type for HTTP response.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
value: Raw output from inference method
|
|
141
|
+
declared_type: Type string from manifest (e.g., "wav", "png")
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Serialized value for HTTP response (base64 for binary)
|
|
145
|
+
"""
|
|
146
|
+
if declared_type in ("wav", "mp3", "flac", "png", "jpg", "webp", "mp4", "webm"):
|
|
147
|
+
if isinstance(value, bytes):
|
|
148
|
+
return base64.b64encode(value).decode("utf-8")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
if declared_type == "text":
|
|
152
|
+
return str(value)
|
|
153
|
+
|
|
154
|
+
if declared_type == "json":
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
return value
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Logging configuration for Aura model containers.
|
|
2
|
+
|
|
3
|
+
Ensures consistent log format across all containers so Aura's Core API
|
|
4
|
+
can parse them uniformly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_logging(model_id: str, level: str = "INFO") -> None:
|
|
12
|
+
"""Set up logging for a model container.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
model_id: The model's ID (appears in log lines)
|
|
16
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
17
|
+
"""
|
|
18
|
+
log_format = (
|
|
19
|
+
f"%(asctime)s [{model_id}] %(name)s %(levelname)s: %(message)s"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=getattr(logging, level.upper(), logging.INFO),
|
|
24
|
+
format=log_format,
|
|
25
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
26
|
+
stream=sys.stdout,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Silence overly chatty third-party libraries
|
|
30
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
31
|
+
logging.getLogger("filelock").setLevel(logging.WARNING)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# hutash-inference — the shared HTTP inference framework every Hutash model
|
|
2
|
+
# imports. Making it a real, installable package (rather than a folder copied
|
|
3
|
+
# next to each model or put on sys.path) is what lets a model's inference.py do
|
|
4
|
+
#
|
|
5
|
+
# from hutash_inference import Inference, capability
|
|
6
|
+
#
|
|
7
|
+
# and have it resolve inside the model's own venv. hutashd installs this package
|
|
8
|
+
# into a model venv first, then the model's own dependencies — the BentoML /
|
|
9
|
+
# MLflow / TorchServe pattern: the framework is a package, models import it.
|
|
10
|
+
#
|
|
11
|
+
# The module ships as a flat layout (its .py files sit directly in this folder),
|
|
12
|
+
# so package-dir maps the package name `hutash_inference` to this directory. That
|
|
13
|
+
# keeps this pyproject.toml inside hutash_inference/ while still installing the
|
|
14
|
+
# folder as the importable `hutash_inference` package.
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[project]
|
|
21
|
+
name = "hutash-inference"
|
|
22
|
+
version = "0.1.0"
|
|
23
|
+
description = "Shared HTTP inference framework for Hutash models"
|
|
24
|
+
requires-python = ">=3.10"
|
|
25
|
+
license = { text = "Apache-2.0" }
|
|
26
|
+
dependencies = [
|
|
27
|
+
"fastapi",
|
|
28
|
+
"uvicorn",
|
|
29
|
+
# The server parses multipart form uploads (STT/audio models POST audio as
|
|
30
|
+
# multipart) via request.form(); FastAPI requires python-multipart for that
|
|
31
|
+
# path, so it is a hard framework dependency, not a per-model one.
|
|
32
|
+
"python-multipart",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/Appsork/hutash"
|
|
37
|
+
|
|
38
|
+
# Flat layout: the package `hutash_inference` lives in this directory (its .py
|
|
39
|
+
# files are siblings of this file), not a nested hutash_inference/ subfolder.
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
packages = ["hutash_inference"]
|
|
42
|
+
package-dir = { "hutash_inference" = "." }
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""FastAPI server for Aura model containers.
|
|
2
|
+
|
|
3
|
+
Every model container runs this server. It:
|
|
4
|
+
1. Reads /app/manifest.json to know what endpoints to expose
|
|
5
|
+
2. Imports /app/inference.py to find the Inference subclass
|
|
6
|
+
3. Instantiates and loads the model
|
|
7
|
+
4. Validates inference.py matches manifest.json
|
|
8
|
+
5. Wires @capability methods to HTTP endpoints from manifest
|
|
9
|
+
6. Starts listening on the configured port
|
|
10
|
+
|
|
11
|
+
Developers writing model inference code never need to touch this file.
|
|
12
|
+
Their only Python file is inference.py.
|
|
13
|
+
|
|
14
|
+
Container invocation (factory pattern — no module-level side effects):
|
|
15
|
+
uvicorn --factory hutash_inference.server:create_app \
|
|
16
|
+
--host 0.0.0.0 --port ${PORT}
|
|
17
|
+
|
|
18
|
+
The module has no top-level app instantiation, so importing it on the
|
|
19
|
+
host (tests, smoke checks) never triggers the container-only startup
|
|
20
|
+
path. `create_app()` is only invoked when explicitly called.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import importlib.util
|
|
24
|
+
import inspect
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
32
|
+
from fastapi.responses import JSONResponse, Response
|
|
33
|
+
|
|
34
|
+
from hutash_inference.base import Inference, get_capabilities
|
|
35
|
+
from hutash_inference.errors import (
|
|
36
|
+
HutashInferenceError,
|
|
37
|
+
GenerationError,
|
|
38
|
+
ModelLoadError,
|
|
39
|
+
)
|
|
40
|
+
from hutash_inference.io_handlers import parse_input, serialize_output
|
|
41
|
+
from hutash_inference.logging import configure_logging
|
|
42
|
+
from hutash_inference.validation import validate_inference_matches_manifest
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def model_dir() -> Path:
|
|
46
|
+
"""Directory holding this model's manifest.json + inference.py.
|
|
47
|
+
|
|
48
|
+
In a container these live at ``/app`` (the image bakes them there). In a
|
|
49
|
+
native venv there is no ``/app``; hutashd points ``HUTASH_MODEL_DIR`` at the
|
|
50
|
+
model's package directory instead. Resolution order:
|
|
51
|
+
``HUTASH_MODEL_DIR`` → ``HUTASH_APP_DIR`` → ``/app`` (the container default,
|
|
52
|
+
so existing images are unaffected).
|
|
53
|
+
"""
|
|
54
|
+
d = os.environ.get("HUTASH_MODEL_DIR") or os.environ.get("HUTASH_APP_DIR")
|
|
55
|
+
return Path(d) if d else Path("/app")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Model file locations, resolved at call time so the same server code runs both
|
|
59
|
+
# in a container (/app) and in a native venv (HUTASH_MODEL_DIR). Kept as
|
|
60
|
+
# module-level accessors for readability at the call sites below.
|
|
61
|
+
def _manifest_path() -> Path:
|
|
62
|
+
return model_dir() / "manifest.json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _inference_module_path() -> Path:
|
|
66
|
+
return model_dir() / "inference.py"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _third_party_licenses_path() -> Path:
|
|
70
|
+
return model_dir() / "THIRD_PARTY_LICENSES.txt"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Composite primitive fanout — when an input's declared type is a key
|
|
74
|
+
# in this map, the dispatcher also copies additional wire fields named
|
|
75
|
+
# `<input_name><suffix>` from raw_data into the method kwargs. Mirrors
|
|
76
|
+
# the frontend convention where a composite primitive (e.g. the React
|
|
77
|
+
# `AudioWithTranscript` component) writes ONE manifest input but TWO
|
|
78
|
+
# RHF form keys → splitFormValues sends both to the wire under
|
|
79
|
+
# `<input_name>` and `<input_name><suffix>`.
|
|
80
|
+
#
|
|
81
|
+
# Sibling to `CARRIES_FILE_TYPES` in io_handlers.py and `carriesFile`
|
|
82
|
+
# in src/ui/inputs/InputRegistry.ts. When adding a new composite
|
|
83
|
+
# primitive that fans out to multiple wire fields, add it here.
|
|
84
|
+
COMPOSITE_FANOUT: dict[str, tuple[str, ...]] = {
|
|
85
|
+
"audio_with_transcript": ("_transcript",),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_manifest() -> dict:
|
|
90
|
+
"""Load and parse the model's manifest.json (see model_dir())."""
|
|
91
|
+
manifest_path = _manifest_path()
|
|
92
|
+
if not manifest_path.exists():
|
|
93
|
+
raise ModelLoadError(f"Manifest not found at {manifest_path}")
|
|
94
|
+
with open(manifest_path) as f:
|
|
95
|
+
return json.load(f)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_inference_module():
|
|
99
|
+
"""Dynamically import the model's inference.py (see model_dir())."""
|
|
100
|
+
inference_path = _inference_module_path()
|
|
101
|
+
if not inference_path.exists():
|
|
102
|
+
raise ModelLoadError(f"inference.py not found at {inference_path}")
|
|
103
|
+
|
|
104
|
+
spec = importlib.util.spec_from_file_location(
|
|
105
|
+
"inference", inference_path
|
|
106
|
+
)
|
|
107
|
+
module = importlib.util.module_from_spec(spec)
|
|
108
|
+
sys.modules["inference"] = module
|
|
109
|
+
spec.loader.exec_module(module)
|
|
110
|
+
return module
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_inference_class(module) -> type[Inference]:
|
|
114
|
+
"""Find the Inference subclass in the given module."""
|
|
115
|
+
for name in dir(module):
|
|
116
|
+
obj = getattr(module, name)
|
|
117
|
+
if (
|
|
118
|
+
inspect.isclass(obj)
|
|
119
|
+
and issubclass(obj, Inference)
|
|
120
|
+
and obj is not Inference
|
|
121
|
+
):
|
|
122
|
+
return obj
|
|
123
|
+
raise ModelLoadError(
|
|
124
|
+
"No Inference subclass found in inference.py. "
|
|
125
|
+
"Define a class inheriting from hutash_inference.Inference."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_capability_handler(method, spec: dict):
|
|
130
|
+
"""Create an async HTTP handler for a capability method.
|
|
131
|
+
|
|
132
|
+
Parses request data according to declared inputs + controls,
|
|
133
|
+
calls the method, serializes the result.
|
|
134
|
+
|
|
135
|
+
Controls with implementation_status in {"stub", "planned"} are
|
|
136
|
+
UI-only: the manifest declares them, but inference.py does not
|
|
137
|
+
accept them as kwargs. We inspect the method's signature once
|
|
138
|
+
at wiring time and drop any incoming kwargs the method doesn't
|
|
139
|
+
accept — lets Core API forward stub values without erroring.
|
|
140
|
+
"""
|
|
141
|
+
inputs_spec = spec.get("inputs", {}) or {}
|
|
142
|
+
controls_spec = spec.get("controls", {}) or {}
|
|
143
|
+
outputs_spec = spec.get("outputs", {}) or {}
|
|
144
|
+
|
|
145
|
+
# If the capability declares exactly one output AND its type is a
|
|
146
|
+
# binary format, respond with raw bytes + the matching Content-Type
|
|
147
|
+
# instead of JSON-wrapping base64. Core API's _handle_audio_response
|
|
148
|
+
# does `response.content → write_bytes(...)` and has no decoding
|
|
149
|
+
# step, so JSON-wrapped binary lands on disk verbatim. Raw bytes
|
|
150
|
+
# restores pre-SSOT wire behavior for audio / image / video models
|
|
151
|
+
# with one binary output. Multi-field dicts (STT's text+segments+
|
|
152
|
+
# language+duration) and text/json outputs stay JSON.
|
|
153
|
+
_BINARY_TYPE_TO_MEDIA = {
|
|
154
|
+
"wav": "audio/wav",
|
|
155
|
+
"mp3": "audio/mpeg",
|
|
156
|
+
"flac": "audio/flac",
|
|
157
|
+
"png": "image/png",
|
|
158
|
+
"jpg": "image/jpeg",
|
|
159
|
+
"jpeg": "image/jpeg",
|
|
160
|
+
"webp": "image/webp",
|
|
161
|
+
"mp4": "video/mp4",
|
|
162
|
+
"webm": "video/webm",
|
|
163
|
+
}
|
|
164
|
+
single_binary_output = None
|
|
165
|
+
if len(outputs_spec) == 1:
|
|
166
|
+
only_key, only_spec = next(iter(outputs_spec.items()))
|
|
167
|
+
media_type = _BINARY_TYPE_TO_MEDIA.get(only_spec.get("type", ""))
|
|
168
|
+
if media_type is not None:
|
|
169
|
+
single_binary_output = (only_key, media_type)
|
|
170
|
+
|
|
171
|
+
sig = inspect.signature(method)
|
|
172
|
+
accepts_var_keyword = any(
|
|
173
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
174
|
+
)
|
|
175
|
+
accepted_params = {
|
|
176
|
+
name for name in sig.parameters if name != "self"
|
|
177
|
+
}
|
|
178
|
+
logger = logging.getLogger("hutash_inference.handler")
|
|
179
|
+
|
|
180
|
+
async def handler(request: Request):
|
|
181
|
+
try:
|
|
182
|
+
if request.headers.get("content-type", "").startswith("multipart/"):
|
|
183
|
+
form = await request.form()
|
|
184
|
+
raw_data = {}
|
|
185
|
+
for key in form.keys():
|
|
186
|
+
value = form[key]
|
|
187
|
+
# FastAPI UploadFile → read bytes for file-typed inputs.
|
|
188
|
+
# Detected via duck-typing on read() + filename so we
|
|
189
|
+
# don't have to import starlette.datastructures here.
|
|
190
|
+
if hasattr(value, "read") and hasattr(value, "filename"):
|
|
191
|
+
raw_data[key] = await value.read()
|
|
192
|
+
else:
|
|
193
|
+
raw_data[key] = value
|
|
194
|
+
else:
|
|
195
|
+
raw_data = await request.json()
|
|
196
|
+
|
|
197
|
+
# Core API's build_json_payload wraps controls under a
|
|
198
|
+
# `parameters` key: {"prompt": "...", "parameters": {...}}.
|
|
199
|
+
# hutash_inference expects flat top-level keys matching the
|
|
200
|
+
# manifest's input + control names. Unwrap `parameters`
|
|
201
|
+
# here so both shapes work — this is a transition bridge;
|
|
202
|
+
# once Core API sends SSOT-native flat bodies, the unwrap
|
|
203
|
+
# is a no-op (no `parameters` key present).
|
|
204
|
+
if isinstance(raw_data, dict) and isinstance(raw_data.get("parameters"), dict):
|
|
205
|
+
nested = raw_data.pop("parameters")
|
|
206
|
+
for k, v in nested.items():
|
|
207
|
+
raw_data.setdefault(k, v)
|
|
208
|
+
|
|
209
|
+
# Unwrap fields{} envelope (new wire format from Core API)
|
|
210
|
+
if isinstance(raw_data, dict) and isinstance(raw_data.get("fields"), dict):
|
|
211
|
+
nested = raw_data.pop("fields")
|
|
212
|
+
raw_data.update(nested)
|
|
213
|
+
|
|
214
|
+
parsed_args: dict = {}
|
|
215
|
+
for param_name, param_spec in inputs_spec.items():
|
|
216
|
+
if param_name in raw_data:
|
|
217
|
+
parsed_args[param_name] = parse_input(
|
|
218
|
+
raw_data[param_name], param_spec["type"],
|
|
219
|
+
)
|
|
220
|
+
elif param_spec.get("required", False):
|
|
221
|
+
raise HTTPException(
|
|
222
|
+
status_code=400,
|
|
223
|
+
detail=f"Missing required input: {param_name}",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Composite primitive fanout — see COMPOSITE_FANOUT
|
|
227
|
+
# above. Pure passthrough (no parse_input — the suffix
|
|
228
|
+
# fields are sibling values, not file-shaped). The
|
|
229
|
+
# inference method declares them in its signature
|
|
230
|
+
# (e.g. `voice_ref_transcript: str | None = None`).
|
|
231
|
+
input_type = param_spec.get("type", "")
|
|
232
|
+
if input_type in COMPOSITE_FANOUT:
|
|
233
|
+
for suffix in COMPOSITE_FANOUT[input_type]:
|
|
234
|
+
fanout_key = f"{param_name}{suffix}"
|
|
235
|
+
if fanout_key in raw_data:
|
|
236
|
+
parsed_args[fanout_key] = raw_data[fanout_key]
|
|
237
|
+
|
|
238
|
+
for param_name, param_spec in controls_spec.items():
|
|
239
|
+
if param_name in raw_data:
|
|
240
|
+
parsed_args[param_name] = parse_input(
|
|
241
|
+
raw_data[param_name], param_spec["type"],
|
|
242
|
+
)
|
|
243
|
+
# else: method uses its default
|
|
244
|
+
|
|
245
|
+
if not accepts_var_keyword:
|
|
246
|
+
dropped = [k for k in parsed_args if k not in accepted_params]
|
|
247
|
+
for k in dropped:
|
|
248
|
+
logger.debug(
|
|
249
|
+
"dropping non-accepted kwarg %r for capability method %s",
|
|
250
|
+
k, method.__qualname__,
|
|
251
|
+
)
|
|
252
|
+
parsed_args.pop(k, None)
|
|
253
|
+
|
|
254
|
+
result = method(**parsed_args)
|
|
255
|
+
|
|
256
|
+
# Single-binary-output shortcut: return raw bytes with
|
|
257
|
+
# the matching Content-Type. Lets Core API's
|
|
258
|
+
# _handle_audio_response write response.content directly
|
|
259
|
+
# to disk as a valid WAV/PNG/MP4/etc.
|
|
260
|
+
if single_binary_output is not None and isinstance(result, dict):
|
|
261
|
+
key, media_type = single_binary_output
|
|
262
|
+
value = result.get(key)
|
|
263
|
+
if isinstance(value, (bytes, bytearray)):
|
|
264
|
+
return Response(content=bytes(value), media_type=media_type)
|
|
265
|
+
# Fall through to JSON if the method returned something
|
|
266
|
+
# other than bytes under the declared key (shouldn't
|
|
267
|
+
# happen for a correctly-implemented capability).
|
|
268
|
+
|
|
269
|
+
if isinstance(result, dict):
|
|
270
|
+
serialized = {}
|
|
271
|
+
for key, value in result.items():
|
|
272
|
+
if key in outputs_spec:
|
|
273
|
+
serialized[key] = serialize_output(
|
|
274
|
+
value, outputs_spec[key]["type"]
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
serialized[key] = value
|
|
278
|
+
return JSONResponse(content=serialized)
|
|
279
|
+
return JSONResponse(content={"result": result})
|
|
280
|
+
|
|
281
|
+
except GenerationError as e:
|
|
282
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
283
|
+
except HutashInferenceError as e:
|
|
284
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
285
|
+
except HTTPException:
|
|
286
|
+
raise
|
|
287
|
+
except Exception as e: # noqa: BLE001 — boundary guard
|
|
288
|
+
raise HTTPException(
|
|
289
|
+
status_code=500,
|
|
290
|
+
detail=f"Unexpected error: {type(e).__name__}: {e}",
|
|
291
|
+
) from e
|
|
292
|
+
|
|
293
|
+
return handler
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def create_app() -> FastAPI:
|
|
297
|
+
"""Build the FastAPI app by reading manifest and wiring inference."""
|
|
298
|
+
manifest = load_manifest()
|
|
299
|
+
model_id = manifest.get("model_id", "unknown")
|
|
300
|
+
configure_logging(model_id)
|
|
301
|
+
|
|
302
|
+
module = load_inference_module()
|
|
303
|
+
InferenceClass = find_inference_class(module)
|
|
304
|
+
|
|
305
|
+
instance = InferenceClass(config=manifest)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
instance.load()
|
|
309
|
+
instance.mark_loaded()
|
|
310
|
+
except Exception as e:
|
|
311
|
+
raise ModelLoadError(f"Failed to load model: {e}") from e
|
|
312
|
+
|
|
313
|
+
validate_inference_matches_manifest(instance, manifest)
|
|
314
|
+
|
|
315
|
+
# Image in manifest is a fully-qualified registry string like
|
|
316
|
+
# "ghcr.io/appsork/aura-kokoro:v0.1.0" — the tag is after the last ':'.
|
|
317
|
+
image_ref = manifest.get("image", "")
|
|
318
|
+
image_tag = image_ref.rsplit(":", 1)[-1] if ":" in image_ref else "unknown"
|
|
319
|
+
app = FastAPI(
|
|
320
|
+
title=f"Aura Model: {model_id}",
|
|
321
|
+
version=image_tag,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
@app.get("/health")
|
|
325
|
+
def health():
|
|
326
|
+
"""Container readiness gate.
|
|
327
|
+
|
|
328
|
+
Returns 200 only when every data path this container exposes is
|
|
329
|
+
ready. Specifically:
|
|
330
|
+
(a) instance._is_loaded â€" the model finished load()
|
|
331
|
+
(b) THIRD_PARTY_LICENSES.txt readable on disk â€" backs
|
|
332
|
+
/third-party-licenses, which the host's install-time
|
|
333
|
+
cache populate fetches once and stores in SQLite KV
|
|
334
|
+
(c) manifest.json readable on disk â€" backs /manifest, which
|
|
335
|
+
the host's install-time manifest cache populate fetches
|
|
336
|
+
|
|
337
|
+
503 here causes the host backend's _check_health() probe in
|
|
338
|
+
docker_manager.py â€" and the Docker daemon's HEALTHCHECK â€" to
|
|
339
|
+
mark the container unhealthy. ensure_model_running() in the
|
|
340
|
+
host waits on exactly that 200 transition.
|
|
341
|
+
|
|
342
|
+
Contract: when /health returns 200, every other endpoint this
|
|
343
|
+
container serves is ready. A single-shot fetch from the host
|
|
344
|
+
backend will succeed.
|
|
345
|
+
"""
|
|
346
|
+
if not instance._is_loaded:
|
|
347
|
+
return JSONResponse(
|
|
348
|
+
status_code=503,
|
|
349
|
+
content={"status": "not_ready", "reason": "model_not_loaded"},
|
|
350
|
+
)
|
|
351
|
+
# THIRD_PARTY_LICENSES.txt is generated at container-build time and may
|
|
352
|
+
# be absent in a native venv install, so it does not gate readiness —
|
|
353
|
+
# /third-party-licenses simply 404s when it is missing. Model-loaded +
|
|
354
|
+
# manifest-present is the readiness contract that holds in both runtimes.
|
|
355
|
+
if not _manifest_path().exists():
|
|
356
|
+
return JSONResponse(
|
|
357
|
+
status_code=503,
|
|
358
|
+
content={"status": "not_ready", "reason": "manifest_missing"},
|
|
359
|
+
)
|
|
360
|
+
return {"status": "ok"}
|
|
361
|
+
|
|
362
|
+
@app.get("/manifest")
|
|
363
|
+
def get_manifest():
|
|
364
|
+
return manifest
|
|
365
|
+
|
|
366
|
+
@app.get("/third-party-licenses")
|
|
367
|
+
def get_third_party_licenses():
|
|
368
|
+
"""Return the bundled THIRD_PARTY_LICENSES.txt as plain text.
|
|
369
|
+
|
|
370
|
+
Proxied by Core API at GET /api/v1/models/{id}/third-party-licenses
|
|
371
|
+
so the desktop app's Settings → About / Licenses tab can display
|
|
372
|
+
per-container Python-package attributions for the live fleet.
|
|
373
|
+
"""
|
|
374
|
+
licenses_path = _third_party_licenses_path()
|
|
375
|
+
if not licenses_path.exists():
|
|
376
|
+
raise HTTPException(
|
|
377
|
+
status_code=404,
|
|
378
|
+
detail="THIRD_PARTY_LICENSES.txt not present in this install",
|
|
379
|
+
)
|
|
380
|
+
return Response(
|
|
381
|
+
content=licenses_path.read_text(encoding="utf-8"),
|
|
382
|
+
media_type="text/plain; charset=utf-8",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
capabilities = get_capabilities(instance)
|
|
386
|
+
declared_capabilities = manifest.get("capabilities", {}) or {}
|
|
387
|
+
|
|
388
|
+
for cap_id, cap_spec in declared_capabilities.items():
|
|
389
|
+
endpoint = cap_spec.get("endpoint", f"/{cap_id}")
|
|
390
|
+
method = capabilities[cap_id]
|
|
391
|
+
handler = make_capability_handler(method, cap_spec)
|
|
392
|
+
app.add_api_route(endpoint, handler, methods=["POST"], name=cap_id)
|
|
393
|
+
|
|
394
|
+
return app
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# Factory pattern only — no module-level app instantiation.
|
|
398
|
+
# Inside containers, uvicorn loads this via:
|
|
399
|
+
# uvicorn --factory hutash_inference.server:create_app --host 0.0.0.0 --port ${PORT}
|
|
400
|
+
# On dev machines and in tests, create_app() is called explicitly when needed.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Validate that inference.py matches model.yaml capabilities.
|
|
2
|
+
|
|
3
|
+
Runs at container startup. If mismatch, raises ValidationError with
|
|
4
|
+
clear message so operator knows what to fix.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
|
|
9
|
+
from hutash_inference.base import Inference, get_capabilities
|
|
10
|
+
from hutash_inference.errors import ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_inference_matches_manifest(
|
|
14
|
+
instance: Inference,
|
|
15
|
+
manifest: dict,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Verify Inference instance implements all capabilities in manifest.
|
|
18
|
+
|
|
19
|
+
Checks:
|
|
20
|
+
- Every capability declared in manifest has a @capability method
|
|
21
|
+
- Method parameter names match declared inputs + controls
|
|
22
|
+
- Extra @capability methods without manifest declaration are tolerated
|
|
23
|
+
(developer may be iterating)
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
instance: Loaded Inference subclass instance
|
|
27
|
+
manifest: Parsed manifest.json contents
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValidationError: If any capability mismatch detected
|
|
31
|
+
"""
|
|
32
|
+
declared_capabilities = manifest.get("capabilities", {}) or {}
|
|
33
|
+
implemented_capabilities = get_capabilities(instance)
|
|
34
|
+
|
|
35
|
+
# Every declared capability must have an implementation
|
|
36
|
+
missing = [
|
|
37
|
+
cap_id for cap_id in declared_capabilities
|
|
38
|
+
if cap_id not in implemented_capabilities
|
|
39
|
+
]
|
|
40
|
+
if missing:
|
|
41
|
+
raise ValidationError(
|
|
42
|
+
f"Manifest declares capabilities {missing} but inference.py "
|
|
43
|
+
f"has no @capability methods for them. "
|
|
44
|
+
f"Implemented: {list(implemented_capabilities.keys())}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Verify parameter names for each implemented capability
|
|
48
|
+
for cap_id, method in implemented_capabilities.items():
|
|
49
|
+
if cap_id not in declared_capabilities:
|
|
50
|
+
continue
|
|
51
|
+
validate_method_signature(cap_id, method, declared_capabilities[cap_id])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_method_signature(
|
|
55
|
+
capability_id: str,
|
|
56
|
+
method: callable,
|
|
57
|
+
spec: dict,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Verify method parameter names match declared inputs + wired controls.
|
|
60
|
+
|
|
61
|
+
Only controls whose implementation_status is "wired" (or unspecified)
|
|
62
|
+
must appear in the method signature. Stub and planned controls are
|
|
63
|
+
declarative-only — UI renders them, but inference.py need not accept
|
|
64
|
+
them as kwargs, and the server filters unexpected kwargs out before
|
|
65
|
+
calling the method.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValidationError: If required parameters are missing from the method
|
|
69
|
+
"""
|
|
70
|
+
expected_params: set[str] = set()
|
|
71
|
+
expected_params.update((spec.get("inputs") or {}).keys())
|
|
72
|
+
for ctrl_id, ctrl_spec in (spec.get("controls") or {}).items():
|
|
73
|
+
status = (ctrl_spec or {}).get("implementation_status", "wired")
|
|
74
|
+
if status == "wired":
|
|
75
|
+
expected_params.add(ctrl_id)
|
|
76
|
+
|
|
77
|
+
sig = inspect.signature(method)
|
|
78
|
+
actual_params: set[str] = set()
|
|
79
|
+
for param_name in sig.parameters:
|
|
80
|
+
if param_name == "self":
|
|
81
|
+
continue
|
|
82
|
+
actual_params.add(param_name)
|
|
83
|
+
|
|
84
|
+
missing = expected_params - actual_params
|
|
85
|
+
if missing:
|
|
86
|
+
raise ValidationError(
|
|
87
|
+
f"Capability '{capability_id}' method missing parameters: "
|
|
88
|
+
f"{missing}. Manifest declares inputs + wired controls: "
|
|
89
|
+
f"{expected_params}. Method signature has: {actual_params}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Extra actual params are OK (could be internal defaults)
|