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.

@@ -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,3 @@
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)