Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
876d03a
feat(vllm): add audio transcription serving
ptarasiewiczNV Aug 20, 2026
5227769
chore: refresh transcription generated artifacts
ptarasiewiczNV Aug 20, 2026
02270ee
fix(vllm): address transcription review feedback
ptarasiewiczNV Aug 20, 2026
64484fb
chore: refresh runtime examples lockfile
ptarasiewiczNV Aug 20, 2026
769dc1c
fix(vllm): harden audio transcription serving
ptarasiewiczNV Aug 20, 2026
2f2ef91
fix(llm): harden transcription cancellation metrics
ptarasiewiczNV Aug 25, 2026
4dd85a3
fix(llm): use canonical transcription bool parser
ptarasiewiczNV Aug 31, 2026
73c6b77
fix(vllm): adapt transcription serving to main and vLLM 0.31.0
NurullahMorshed Oct 8, 2026
4a8cea3
fix(vllm): publish transcription worker metrics and clean up its temp…
NurullahMorshed Oct 13, 2026
00c4d3a
fix(llm): count the transcriptions route in model HTTP readiness
NurullahMorshed Oct 15, 2026
fca0ad4
Merge branch 'main' into nurullah/transcription-on-main
NurullahMorshed Oct 15, 2026
2f18485
Merge branch 'main' into nurullah/transcription-on-main
NurullahMorshed Oct 16, 2026
5c64dd2
Merge branch 'main' into nurullah/transcription-on-main
NurullahMorshed Oct 25, 2026
7eb91e9
fix(llm): build the transcription metrics collector from the request …
NurullahMorshed Oct 25, 2026
c27003b
Merge branch 'main' into nurullah/transcription-on-main
NurullahMorshed Oct 30, 2026
13cec37
fix(vllm): register the transcription worker's engine metric exporters
NurullahMorshed Oct 30, 2026
e190c32
fix(llm): return 413 for an oversized transcription upload
NurullahMorshed Oct 30, 2026
d6a8239
test(llm): keep register_model_occupying's doc comment on the helper
NurullahMorshed Oct 30, 2026
b55edfd
fix(vllm): let mypy skip the transcription handler's fastapi import
NurullahMorshed Oct 30, 2026
2568114
fix(llm): cap transcription uploads below the request-plane frame
NurullahMorshed Oct 30, 2026
bc0011a
fix(vllm): report vLLM transcription request errors as invalid arguments
NurullahMorshed Oct 30, 2026
17895bd
build: drop the workspace multipart feature of axum
NurullahMorshed Oct 30, 2026
ea59217
Merge branch 'main' into nurullah/transcription-on-main
NurullahMorshed Oct 31, 2026
435a8e2
Merge branch 'main' into nurullah/transcription-on-main
rmccorm4 Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions components/src/dynamo/vllm/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ def _unsupported_fpm_trace_role(dynamo_config: Config) -> Optional[str]:
return "embedding"
if dynamo_config.classify_worker:
return "classify"
if dynamo_config.transcription_worker:
return "transcription"
if dynamo_config.headless:
return "headless"
if dynamo_config.disaggregation_mode == DisaggregationMode.ENCODE:
Expand Down Expand Up @@ -368,6 +370,14 @@ def update_engine_config_with_dynamo(
"Pooling-family worker: defaulting --enable-prefix-caching to False"
)
engine_config.enable_prefix_caching = False
elif dynamo_config.transcription_worker:
# Whisper is an encoder-decoder model. vLLM does not support
# prefix caching for cross-attention KV caches, and enabling it
# causes the first transcription request to terminate EngineCore.
logger.debug(
"Transcription worker: defaulting --enable-prefix-caching to False"
)
engine_config.enable_prefix_caching = False
else:
logger.debug(
"--enable-prefix-caching or --no-enable-prefix-caching not specified. "
Expand Down
51 changes: 51 additions & 0 deletions components/src/dynamo/vllm/backend_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,15 @@ def add_arguments(self, parser) -> None:
"supported with more than one process.",
)

add_negatable_bool_argument(
g,
flag_name="--transcription-worker",
env_var="DYN_VLLM_TRANSCRIPTION_WORKER",
default=False,
help="Run as an aggregated speech-to-text worker using vLLM's native "
"OpenAI transcription serving layer.",
)

add_negatable_bool_argument(
g,
flag_name="--realtime",
Expand Down Expand Up @@ -619,6 +628,7 @@ class DynamoVllmConfig(ConfigBase):
embedding_worker: bool = False
embedding_frontend_tokenization: bool = False
embedding_worker_processes: int = 1
transcription_worker: bool = False
realtime: bool = False
classify_worker: bool = False

Expand Down Expand Up @@ -684,6 +694,7 @@ def validate(self) -> None:
self._validate_embedding_frontend_tokenization()
self._validate_embedding_worker_exclusivity()
self._validate_embedding_worker_processes()
self._validate_transcription_worker_exclusivity()
self._validate_realtime_worker_exclusivity()
self._validate_classify_worker_exclusivity()
self._validate_custom_encoder()
Expand Down Expand Up @@ -1064,6 +1075,46 @@ def _validate_realtime_worker_exclusivity(self) -> None:
if getattr(getattr(self, "engine_args", None), "enable_lora", False):
raise ValueError("--realtime cannot be combined with --enable-lora.")

def _validate_transcription_worker_exclusivity(self) -> None:
"""Transcription serving uses a dedicated aggregated ASR worker."""
if not self.transcription_worker:
return
if self.disaggregation_mode != DisaggregationMode.AGGREGATED:
mode = (
self.disaggregation_mode.value
if isinstance(self.disaggregation_mode, DisaggregationMode)
else self.disaggregation_mode
)
raise ValueError(
"--transcription-worker is only valid with "
f"--disaggregation-mode=agg (got {mode})."
)
for enabled, option in (
(self.embedding_worker, "--embedding-worker"),
(self.classify_worker, "--classify-worker"),
(self.realtime, "--realtime"),
(bool(self.custom_encoder_class), "--custom-encoder-class"),
(self.gms_shadow_mode, "--gms-shadow-mode"),
(self.enable_rl, "--enable-rl"),
(self.headless, "--headless"),
):
if enabled:
raise ValueError(
f"--transcription-worker cannot be combined with {option}."
)
if self.enable_multimodal:
raise ValueError(
"--transcription-worker cannot be combined with multimodal worker flags."
)
if self.benchmark_mode is not None:
raise ValueError(
"--transcription-worker cannot be combined with --benchmark-mode."
)
if getattr(getattr(self, "engine_args", None), "enable_lora", False):
raise ValueError(
"--transcription-worker cannot be combined with --enable-lora."
)

def _validate_classify_worker_exclusivity(self) -> None:
"""Classify worker is aggregated-only and exclusive of multimodal /
embedding roles. Mirrors the embedding-worker constraints — both are
Expand Down
31 changes: 31 additions & 0 deletions components/src/dynamo/vllm/health_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
This module defines the default health check payload for vLLM backends.
"""

import base64
import io
import logging
import wave
from typing import TYPE_CHECKING, Any, Optional

from dynamo.health_check import HEALTH_CHECK_KEY, HealthCheckPayload
Expand Down Expand Up @@ -152,6 +155,34 @@ def to_dict(self) -> dict[str, Any]:
return _layer_probe_marker(super().to_dict())


def _silent_wav_b64() -> str:
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16_000)
wav.writeframes(b"\x00" * 3_200)
return base64.b64encode(buffer.getvalue()).decode("ascii")


class VllmTranscriptionHealthCheckPayload(HealthCheckPayload):
"""Real, short ASR probe matching TranscriptionWorkerHandler input."""

def __init__(self, model_name: Optional[str] = None):
self.default_payload: dict[str, Any] = {
"audio_b64": _silent_wav_b64(),
"filename": "health-check.wav",
"language": "en",
"response_format": "json",
}
if model_name is not None:
self.default_payload["model"] = model_name
super().__init__()

def to_dict(self) -> dict[str, Any]:
return _layer_probe_marker(super().to_dict())


class VllmPrefillHealthCheckPayload(HealthCheckPayload):
"""
vLLM-specific health check payload for prefill workers in disaggregated mode.
Expand Down
38 changes: 38 additions & 0 deletions components/src/dynamo/vllm/tests/test_vllm_backend_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ def create_config() -> DynamoVllmConfig:
config.embedding_worker = False
config.embedding_frontend_tokenization = False
config.embedding_worker_processes = 1
config.transcription_worker = False
config.realtime = False
config.classify_worker = False
config.custom_encoder_class = None
config.gms_shadow_mode = False
config.enable_rl = False
config.headless = False
config.benchmark_mode = None
config.use_vllm_tokenizer = False
Expand Down Expand Up @@ -318,6 +324,38 @@ def test_environment_rejects_invalid_value(self, monkeypatch):
DynamoVllmArgGroup().add_arguments(parser)


class TestTranscriptionWorkerExclusivity:
def test_baseline_aggregated_is_accepted(self):
config = create_config()
config.transcription_worker = True
config.disaggregation_mode = DisaggregationMode.AGGREGATED
config._validate_transcription_worker_exclusivity()

def test_non_aggregated_mode_is_rejected(self):
config = create_config()
config.transcription_worker = True
config.disaggregation_mode = DisaggregationMode.DECODE
with pytest.raises(ValueError, match="disaggregation-mode=agg"):
config._validate_transcription_worker_exclusivity()

@pytest.mark.parametrize(
("option", "value"),
[
("embedding_worker", True),
("classify_worker", True),
("realtime", True),
("headless", True),
],
)
def test_standalone_role_combinations_are_rejected(self, option, value):
config = create_config()
config.transcription_worker = True
config.disaggregation_mode = DisaggregationMode.AGGREGATED
setattr(config, option, value)
with pytest.raises(ValueError, match=option.replace("_", "-")):
config._validate_transcription_worker_exclusivity()


class TestRealtimeWorkerExclusivity:
def test_baseline_aggregated_is_accepted(self):
config = create_config()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
parity with trtllm/sglang for any future marker-gated behavior.
"""

import base64
import io
import json
import wave

import pytest

Expand All @@ -19,11 +22,13 @@
VllmHealthCheckPayload,
VllmOmniHealthCheckPayload,
VllmPrefillHealthCheckPayload,
VllmTranscriptionHealthCheckPayload,
)

pytestmark = [
pytest.mark.unit,
pytest.mark.vllm,
pytest.mark.core,
pytest.mark.gpu_0,
pytest.mark.pre_merge,
]
Expand All @@ -32,6 +37,10 @@
pytest.param(VllmHealthCheckPayload, id="VllmHealthCheckPayload"),
pytest.param(VllmPrefillHealthCheckPayload, id="VllmPrefillHealthCheckPayload"),
pytest.param(VllmOmniHealthCheckPayload, id="VllmOmniHealthCheckPayload"),
pytest.param(
VllmTranscriptionHealthCheckPayload,
id="VllmTranscriptionHealthCheckPayload",
),
pytest.param(
VllmEmbeddingHealthCheckPayload,
id="VllmEmbeddingHealthCheckPayload",
Expand Down Expand Up @@ -89,3 +98,19 @@ def test_embedding_payload_omits_model_when_no_name():
assert "model" not in payload
assert payload.get("input") == "probe"
assert payload.get(HEALTH_CHECK_KEY) is True


def test_transcription_payload_contains_valid_short_wav():
payload = VllmTranscriptionHealthCheckPayload(model_name="whisper").to_dict()

assert payload.get("model") == "whisper"
assert payload.get("language") == "en"
assert payload.get("response_format") == "json"
assert payload.get(HEALTH_CHECK_KEY) is True

audio = base64.b64decode(payload["audio_b64"], validate=True)
with wave.open(io.BytesIO(audio), "rb") as wav:
assert wav.getnchannels() == 1
assert wav.getsampwidth() == 2
assert wav.getframerate() == 16_000
assert wav.getnframes() == 1_600
Loading
Loading