Skip to content

Commit 12610a4

Browse files
author
Yadan Wei
committed
fix(whisperx): serialize GPU inference, one model per container, admission control + readiness
Addresses code-review findings on the WhisperX serving container. BREAKING (request API): the `model` request field is removed. The container serves exactly one model, chosen at launch via WHISPERX_DEFAULT_MODEL. FastAPI ignores an undeclared `model` field, so OpenAI-SDK clients that always send it still work — the value is a no-op. /v1/models still advertises the served id. Concurrency + safety: - Serialize GPU inference with an anyio CapacityLimiter (default 1, WHISPERX_MAX_CONCURRENT_REQUESTS). WhisperX's pipeline mutates shared state mid-call and the diarization pipeline is not thread-safe, so concurrent inference on one instance is unsafe; excess requests wait on the event loop so /ping stays responsive. - Remove per-request model selection and the model LRU; cache a single model under a lock. This makes it structurally impossible to hold two Whisper models on one GPU. Align LRU (keyed by language) retained; it now frees CUDA memory on eviction. Correctness + operations: - Fix the response `task` field to reflect WHISPERX_TASK (was hardcoded "transcribe", so translate mode reported the wrong task). - Admission control: 503 when the inference queue is full (WHISPERX_MAX_QUEUE), 413 when an upload exceeds WHISPERX_MAX_UPLOAD_BYTES (default 1 GiB). - Readiness health: /ping returns 503 after a fatal CUDA/device fault corrupts the process GPU context, so orchestration can drain/replace the host. Transient failures (CUDA OOM, unsupported-language degrade, 4xx) do not flip health. This is a passive signal — it reflects faults observed by the inference path, not idle GPU death. - Validate request params at the boundary (min/max_speakers, line widths) with 422 instead of opaque downstream errors. Security: allowlist CVE-2025-55551 (torch 2.8.0 torch.linalg.lu DoS; fix needs torch>=2.9.0 which breaks the whisperx 3.8.6 pin; off the ASR inference path). Tests: GPU-free unit coverage for inference serialization, readiness/fatal-CUDA classification, admission control, task-from-env, and param validation; update the EC2/SageMaker model tests for the model-field-ignored contract. Signed-off-by: Yadan Wei <weiyadan@amazon.com>
1 parent 8e4bd65 commit 12610a4

9 files changed

Lines changed: 535 additions & 318 deletions

File tree

scripts/docker/whisperx/server.py

Lines changed: 212 additions & 139 deletions
Large diffs are not rendered by default.

test/security/data/ecr_scan_allowlist/whisperx/framework_allowlist.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
"reason": "torch is pinned to 2.8.0 by whisperx 3.8.6 (torch~=2.8.0); the fix requires torch>=2.9.0 and cannot be taken without breaking the whisperx contract. The reported torch.rot90/torch.randn_like interaction is not on the ASR inference path. Tracked for a whisperx version that raises its torch cap.",
2525
"review_by": "2026-10-13"
2626
},
27+
{
28+
"vulnerability_id": "CVE-2025-55551",
29+
"reason": "torch is pinned to 2.8.0 by whisperx 3.8.6 (torch~=2.8.0); the fix requires torch>=2.9.0 and cannot be taken without breaking the whisperx contract. The reported torch.linalg.lu slice denial-of-service is not on the ASR inference path (transcription/alignment/diarization do not call torch.linalg.lu). Tracked for a whisperx version that raises its torch cap.",
30+
"review_by": "2026-10-13"
31+
},
2732
{
2833
"vulnerability_id": "CVE-2026-54283",
2934
"reason": "starlette is a transitive dependency of fastapi 0.121.0, which pins starlette<0.50.0; the fix requires starlette>=1.3.1 and cannot be taken without a major fastapi bump that breaks the serving stack. Tracked for a fastapi release that allows starlette 1.x.",

test/whisperx/ec2/test_ec2_gpu.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,20 +161,17 @@ def test_response_formats(container, aws_session, tmp_path):
161161

162162

163163
def test_errors(container, tmp_path):
164-
"""Contract errors: unknown model -> 404, empty upload -> 400, bad format -> 400.
164+
"""Contract errors: bad response_format -> 400, empty upload -> 400.
165165
166-
The server validates the model, then the response_format, then reads the
167-
file, so the model/format cases need only a present (unread) file part.
166+
The server validates response_format before reading the file, so the format
167+
case needs only a present (unread) file part.
168168
"""
169169
port = container["port"]
170170

171171
# A present-but-never-read file part (validation short-circuits before read).
172172
dummy = tmp_path / "dummy.wav"
173173
dummy.write_bytes(b"RIFF0000WAVE")
174174

175-
resp = post_transcription(port, str(dummy), model="does-not-exist-xyz")
176-
assert resp.status_code == 404, resp.text
177-
178175
resp = post_transcription(port, str(dummy), response_format="xml")
179176
assert resp.status_code == 400, resp.text
180177

test/whisperx/ec2/test_ec2_model_config.py

Lines changed: 35 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,23 @@
1313
"""EC2 model-launch-config tests for the WhisperX ASR DLC (GPU).
1414
1515
Group A: these tests validate how the server's launch-time env vars shape the
16-
model-resolution contract — WHISPERX_DEFAULT_MODEL (the pinned/served model),
17-
WHISPERX_SERVED_MODEL_NAME (a client-facing alias), and
18-
WHISPERX_ALLOW_MODEL_OVERRIDE (per-request on-demand loading). Each case needs a
19-
container launched with different env, so unlike test_ec2_gpu.py they do NOT use
20-
the shared default `container` fixture; instead each spins up (and tears down) a
21-
custom-env container via common.run_container_with_env.
22-
23-
WHISPERX_DEFAULT_MODEL=tiny is used throughout so the startup warm-load (and any
24-
on-demand override load) finishes in seconds — tiny is a valid faster-whisper
25-
model that downloads far faster than the large-v2 default.
26-
27-
Assertions target the resolution contract only (served model id, 200 vs 404, and
28-
the 404 error-body shape) — never exact transcript text, which is nondeterministic.
16+
served-model contract — WHISPERX_DEFAULT_MODEL (the one model per container) and
17+
WHISPERX_SERVED_MODEL_NAME (a client-facing alias advertised by /v1/models).
18+
Each case needs a container launched with different env, so unlike test_ec2_gpu.py
19+
they do NOT use the shared default `container` fixture; instead each spins up (and
20+
tears down) a custom-env container via common.run_container_with_env.
21+
22+
The Whisper model is one-per-container: the request `model` field is accepted but
23+
ignored (an OpenAI-compat no-op), so every transcription runs the launched model
24+
regardless of the `model` value sent.
25+
26+
WHISPERX_DEFAULT_MODEL=tiny is used throughout so the startup warm-load finishes
27+
in seconds — tiny is a valid faster-whisper model that downloads far faster than
28+
the large-v2 default.
29+
30+
Assertions target the served contract only (advertised model id, and that any
31+
`model` value transcribes 200) — never exact transcript text, which is
32+
nondeterministic.
2933
"""
3034

3135
import requests
@@ -38,17 +42,18 @@
3842

3943
DEVICE = "gpu"
4044
DOCKER_RUN_FLAGS = ["--gpus", "all"]
41-
# Small, fast-downloading Whisper model so warm-load and on-demand override load
42-
# take seconds, not the minutes large-v2 needs. tiny is a valid faster-whisper id.
45+
# Small, fast-downloading Whisper model so the startup warm-load takes seconds,
46+
# not the minutes large-v2 needs. tiny is a valid faster-whisper id.
4347
FAST_MODEL = "tiny"
4448

4549

4650
def test_custom_default_model(image_uri, aws_session, tmp_path):
47-
"""WHISPERX_DEFAULT_MODEL pins the served id; only that id (or none) resolves.
51+
"""WHISPERX_DEFAULT_MODEL sets the one served model; the `model` field is ignored.
4852
4953
Launches with WHISPERX_DEFAULT_MODEL=tiny and asserts /v1/models advertises
5054
`tiny`, that omitting `model` and naming `tiny` both transcribe (200), and
51-
that a different real model (`large-v2`) is rejected 404 (override off).
55+
that naming a *different* id (`large-v2`) also transcribes 200 — the field is
56+
an ignored no-op, so the launched model runs regardless.
5257
"""
5358
audio = download_fixture(aws_session, AUDIO_EN, str(tmp_path / AUDIO_EN))
5459
with run_container_with_env(
@@ -61,27 +66,29 @@ def test_custom_default_model(image_uri, aws_session, tmp_path):
6166
data = resp.json()["data"]
6267
assert data[0]["id"] == FAST_MODEL, f"expected served id {FAST_MODEL!r}, got {data}"
6368

64-
# Omitted model -> resolves to the pinned default -> 200 with real text.
69+
# Omitted model -> serves the launched model -> 200 with real text.
6570
resp = post_transcription(port, audio, response_format="json")
6671
assert resp.status_code == 200, resp.text
6772
assert resp.json().get("text", "").strip(), "expected non-empty text with default model"
6873

69-
# Naming the pinned model explicitly -> 200.
74+
# Naming the launched model explicitly -> 200.
7075
resp = post_transcription(port, audio, model=FAST_MODEL, response_format="json")
7176
assert resp.status_code == 200, resp.text
7277

73-
# A different real model with override off -> 404.
78+
# A different id -> still 200: the `model` field is ignored, so the
79+
# launched model serves the request instead of being rejected.
7480
resp = post_transcription(port, audio, model="large-v2", response_format="json")
75-
assert resp.status_code == 404, resp.text
81+
assert resp.status_code == 200, resp.text
82+
assert resp.json().get("text", "").strip(), "expected launched model to serve any model id"
7683

7784

7885
def test_served_model_alias(image_uri, aws_session, tmp_path):
79-
"""WHISPERX_SERVED_MODEL_NAME exposes an alias; the 404 detail names that alias.
86+
"""WHISPERX_SERVED_MODEL_NAME sets the id /v1/models advertises for OpenAI clients.
8087
8188
Launches tiny behind the alias `whisper-1` and asserts /v1/models reports
82-
`whisper-1`, that both the alias and the real backing id `tiny` transcribe
83-
(200), and that an unknown id is rejected 404 with a detail that names the
84-
served alias (not the internal `tiny`).
89+
`whisper-1`, and that the alias, the real backing id `tiny`, and an unrelated
90+
id all transcribe (200) — the `model` field is an ignored no-op, so every
91+
request runs the launched model.
8592
"""
8693
audio = download_fixture(aws_session, AUDIO_EN, str(tmp_path / AUDIO_EN))
8794
env = {"WHISPERX_DEFAULT_MODEL": FAST_MODEL, "WHISPERX_SERVED_MODEL_NAME": "whisper-1"}
@@ -93,52 +100,14 @@ def test_served_model_alias(image_uri, aws_session, tmp_path):
93100
data = resp.json()["data"]
94101
assert data[0]["id"] == "whisper-1", f"expected alias id 'whisper-1', got {data}"
95102

96-
# The alias resolves to the pinned model -> 200.
103+
# The advertised alias transcribes -> 200.
97104
resp = post_transcription(port, audio, model="whisper-1", response_format="json")
98105
assert resp.status_code == 200, resp.text
99106

100-
# The real backing model id still resolves too -> 200.
107+
# The real backing model id transcribes too -> 200.
101108
resp = post_transcription(port, audio, model=FAST_MODEL, response_format="json")
102109
assert resp.status_code == 200, resp.text
103110

104-
# An unknown id -> 404 whose detail advertises the served alias.
111+
# An unrelated id -> still 200: the `model` field is ignored.
105112
resp = post_transcription(port, audio, model="whisper-large", response_format="json")
106-
assert resp.status_code == 404, resp.text
107-
detail = resp.json().get("detail", "")
108-
assert "whisper-1" in detail, f"404 detail should name the served alias: {detail!r}"
109-
110-
111-
def test_model_override_allowed(image_uri, aws_session, tmp_path):
112-
"""WHISPERX_ALLOW_MODEL_OVERRIDE=true loads an unlisted model on demand.
113-
114-
Launches tiny with override enabled and asserts that naming a *different*
115-
valid model (`base`) transcribes 200 — loaded on demand — rather than 404.
116-
Kept to `base` (small, fast) so the on-demand load stays quick.
117-
"""
118-
audio = download_fixture(aws_session, AUDIO_EN, str(tmp_path / AUDIO_EN))
119-
env = {"WHISPERX_DEFAULT_MODEL": FAST_MODEL, "WHISPERX_ALLOW_MODEL_OVERRIDE": "true"}
120-
with run_container_with_env(image_uri, env, device=DEVICE, flags=DOCKER_RUN_FLAGS) as c:
121-
# `base` is not the pinned model, but override loads it on demand -> 200.
122-
resp = post_transcription(c["port"], audio, model="base", response_format="json")
123113
assert resp.status_code == 200, resp.text
124-
assert resp.json().get("text", "").strip(), "expected non-empty text from override model"
125-
126-
127-
def test_model_override_denied_response(image_uri, tmp_path):
128-
"""Override off: an unknown model yields a 404 whose body names the served id.
129-
130-
Asserts the error *shape* (FastAPI's {"detail": ...}), not just the status:
131-
the detail must say the model "does not exist" and name the served id
132-
(`tiny`). The model is resolved before the upload is read, so a present but
133-
unread dummy file part is enough — no S3 audio needed.
134-
"""
135-
dummy = tmp_path / "dummy.wav"
136-
dummy.write_bytes(b"RIFF0000WAVE")
137-
with run_container_with_env(
138-
image_uri, {"WHISPERX_DEFAULT_MODEL": FAST_MODEL}, device=DEVICE, flags=DOCKER_RUN_FLAGS
139-
) as c:
140-
resp = post_transcription(c["port"], str(dummy), model="nonexistent-model")
141-
assert resp.status_code == 404, resp.text
142-
detail = resp.json().get("detail", "")
143-
assert "does not exist" in detail, f"expected 'does not exist' in 404 detail: {detail!r}"
144-
assert FAST_MODEL in detail, f"expected served id {FAST_MODEL!r} in 404 detail: {detail!r}"

test/whisperx/sagemaker/test_sm_endpoint.py

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import uuid
2424

2525
import pytest
26-
from botocore.exceptions import ClientError
2726
from sagemaker.core.resources import Endpoint, EndpointConfig, Model
2827
from sagemaker.core.shapes import ContainerDefinition, ProductionVariant
2928
from test_utils import random_suffix_name
@@ -46,11 +45,6 @@
4645
AUDIO_EN = "asr_en.wav" # English ~15s
4746
AUDIO_DIARIZE = "asr_diarize_2spk.wav" # 2 speakers ~21s
4847

49-
# The model this (no-env) endpoint pins and serves: the server's DEFAULT_MODEL,
50-
# since WHISPERX_DEFAULT_MODEL is not overridden here. The override-denied 404
51-
# names this id.
52-
SERVED_MODEL_ID = "large-v2"
53-
5448

5549
def _cleanup(resources):
5650
"""Best-effort delete for a list of v3 resource objects (None-safe).
@@ -227,47 +221,3 @@ def test_invocations_diarization(model_endpoint, audio_cache):
227221
assert speakers, f"verbose_json missing non-empty 'speakers': {body!r}"
228222
assert len(speakers) >= 2, f"Expected >=2 speakers, got {speakers!r}"
229223
LOGGER.info(f"Diarization detected {len(speakers)} speakers: {speakers}")
230-
231-
232-
def test_invocations_unknown_model_404(model_endpoint, audio_cache):
233-
"""An unknown `model` is rejected — the model pin holds through SageMaker invoke.
234-
235-
Model override is off by default, so the server validates the request `model`
236-
field against the pinned served id and 404s any other value (server contract:
237-
``_resolve_model``). This proves that contract survives the SageMaker
238-
/invocations hop rather than only over a local HTTP call.
239-
240-
Error path: sagemaker-core ``Endpoint.invoke`` calls sagemaker-runtime
241-
``invoke_endpoint`` with no try/except, so a container 4xx surfaces as a
242-
``ModelError`` — a ``botocore.exceptions.ClientError`` subclass raised as HTTP
243-
424 that wraps the container's status and body. We therefore assert on the
244-
raised ClientError (and, defensively, on a returned body should a runtime path
245-
hand one back instead of raising). Validation short-circuits before the audio
246-
is read, so no model load happens and no retry/cold-start handling is needed.
247-
"""
248-
endpoint = model_endpoint
249-
audio = audio_cache(AUDIO_EN)
250-
251-
# A `file` part is required by the route signature (FastAPI 422s without it),
252-
# but `_resolve_model` rejects the unknown id before the upload is ever read.
253-
body, content_type = _build_multipart(audio, {"model": "does-not-exist", "language": "en"})
254-
255-
try:
256-
result = endpoint.invoke(body=body, content_type=content_type)
257-
except ClientError as e:
258-
err = f"{e}\n{json.dumps(e.response, default=str)}"
259-
lowered = err.lower()
260-
assert "does not exist" in lowered or SERVED_MODEL_ID in lowered, (
261-
f"ModelError did not surface the 404 detail / served model id: {err!r}"
262-
)
263-
LOGGER.info(f"Unknown model correctly rejected via ClientError/ModelError: {e}")
264-
return
265-
266-
# Fallback: some runtime paths may return the container body rather than raise.
267-
raw = result.body.read()
268-
text = raw.decode(errors="replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
269-
lowered = text.lower()
270-
assert "does not exist" in lowered or SERVED_MODEL_ID in lowered, (
271-
f"Expected a 404 naming the served model `{SERVED_MODEL_ID}`, got: {text!r}"
272-
)
273-
LOGGER.info(f"Unknown model rejected in returned body: {text[:200]!r}")

0 commit comments

Comments
 (0)