A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 12, 06:00 UTC
[Frontend] add 'verbose_json' and 'timestamp' feature on Whisper Transcription/Translation (#24209)
Signed-off-by: sangbumlikeagod <oironese@naver.com> Signed-off-by: sangbumlikeagod <98077576+sangbumlikeagod@users.noreply.github.com>
sangbumlikeagod committed
Dec 2, 2025 at 02:19 UTC
092bb73b8a36ccdb6d6bbac897ba3aa79f660e36
8 files changed
+224
-23
docs/serving/openai_compatible_server.md
+1
@@ -456,6 +456,7 @@ For `verbose_json` response format:
456
]
457
}
458
```
459
+Currently “verbose_json” response format doesn’t support avg_logprob, compression_ratio, no_speech_prob.
460
461
#### Extra Parameters
462
tests/entrypoints/openai/test_transcription_validation_whisper.py
+13
@@ -235,3 +235,16 @@ async def test_audio_prompt(mary_had_lamb, whisper_client):
235
)
236
out_prompt = json.loads(transcription_wprompt)["text"]
237
assert prefix in out_prompt
238
+
239
+
240
+@pytest.mark.asyncio
241
+async def test_audio_with_timestamp(mary_had_lamb, whisper_client):
242
+ transcription = await whisper_client.audio.transcriptions.create(
243
+ model=MODEL_NAME,
244
+ file=mary_had_lamb,
245
+ language="en",
246
+ response_format="verbose_json",
247
+ temperature=0.0,
248
+ )
249
+ assert transcription.segments is not None
250
+ assert len(transcription.segments) > 0
vllm/entrypoints/openai/api_server.py
+4
-4
@@ -68,9 +68,9 @@ from vllm.entrypoints.openai.protocol import (
68
TokenizeRequest,
69
TokenizeResponse,
70
TranscriptionRequest,
71
- TranscriptionResponse,
71
+ TranscriptionResponseVariant,
72
TranslationRequest,
73
- TranslationResponse,
73
+ TranslationResponseVariant,
74
)
75
from vllm.entrypoints.openai.serving_chat import OpenAIServingChat
76
from vllm.entrypoints.openai.serving_completion import OpenAIServingCompletion
@@ -809,7 +809,7 @@ async def create_transcriptions(
809
content=generator.model_dump(), status_code=generator.error.code
810
)
811
812
- elif isinstance(generator, TranscriptionResponse):
812
+ elif isinstance(generator, TranscriptionResponseVariant):
813
return JSONResponse(content=generator.model_dump())
814
815
return StreamingResponse(content=generator, media_type="text/event-stream")
@@ -848,7 +848,7 @@ async def create_translations(
848
content=generator.model_dump(), status_code=generator.error.code
849
)
850
851
- elif isinstance(generator, TranslationResponse):
851
+ elif isinstance(generator, TranslationResponseVariant):
852
return JSONResponse(content=generator.model_dump())
853
854
return StreamingResponse(content=generator, media_type="text/event-stream")
vllm/entrypoints/openai/protocol.py
+14
-6
@@ -2126,13 +2126,13 @@ class TranscriptionSegment(OpenAIBaseModel):
2126
id: int
2127
"""Unique identifier of the segment."""
2128
2129
- avg_logprob: float
2129
+ avg_logprob: float | None = None
2130
"""Average logprob of the segment.
2131
2132
If the value is lower than -1, consider the logprobs failed.
2133
"""
2134
2135
- compression_ratio: float
2135
+ compression_ratio: float | None = None
2136
"""Compression ratio of the segment.
2137
2138
If the value is greater than 2.4, consider the compression failed.
@@ -2141,7 +2141,7 @@ class TranscriptionSegment(OpenAIBaseModel):
2141
end: float
2142
"""End time of the segment in seconds."""
2143
2144
- no_speech_prob: float
2144
+ no_speech_prob: float | None = None
2145
"""Probability of no speech in the segment.
2146
2147
If the value is higher than 1.0 and the `avg_logprob` is below -1, consider
@@ -2181,6 +2181,11 @@ class TranscriptionResponseVerbose(OpenAIBaseModel):
2181
"""Extracted words and their corresponding timestamps."""
2182
2183
2184
+TranscriptionResponseVariant: TypeAlias = (
2185
+ TranscriptionResponse | TranscriptionResponseVerbose
2186
+)
2187
+
2188
+
2189
class TranslationResponseStreamChoice(OpenAIBaseModel):
2190
delta: DeltaMessage
2191
finish_reason: str | None = None
@@ -2325,13 +2330,13 @@ class TranslationSegment(OpenAIBaseModel):
2330
id: int
2331
"""Unique identifier of the segment."""
2332
2328
- avg_logprob: float
2333
+ avg_logprob: float | None = None
2334
"""Average logprob of the segment.
2335
2336
If the value is lower than -1, consider the logprobs failed.
2337
"""
2338
2334
- compression_ratio: float
2339
+ compression_ratio: float | None = None
2340
"""Compression ratio of the segment.
2341
2342
If the value is greater than 2.4, consider the compression failed.
@@ -2340,7 +2345,7 @@ class TranslationSegment(OpenAIBaseModel):
2345
end: float
2346
"""End time of the segment in seconds."""
2347
2343
- no_speech_prob: float
2348
+ no_speech_prob: float | None = None
2349
"""Probability of no speech in the segment.
2350
2351
If the value is higher than 1.0 and the `avg_logprob` is below -1, consider
@@ -2380,6 +2385,9 @@ class TranslationResponseVerbose(OpenAIBaseModel):
2385
"""Extracted words and their corresponding timestamps."""
2386
2387
2388
+TranslationResponseVariant: TypeAlias = TranslationResponse | TranslationResponseVerbose
2389
+
2390
+
2391
####### Tokens IN <> Tokens OUT #######
2392
class GenerateRequest(BaseModel):
2393
request_id: str = Field(
vllm/entrypoints/openai/serving_transcription.py
+24
-4
@@ -12,10 +12,12 @@ from vllm.entrypoints.openai.protocol import (
12
TranscriptionRequest,
13
TranscriptionResponse,
14
TranscriptionResponseStreamChoice,
15
+ TranscriptionResponseVerbose,
16
TranscriptionStreamResponse,
17
TranslationRequest,
18
TranslationResponse,
19
TranslationResponseStreamChoice,
20
+ TranslationResponseVerbose,
21
TranslationStreamResponse,
22
)
23
from vllm.entrypoints.openai.serving_models import OpenAIServingModels
@@ -51,7 +53,12 @@ class OpenAIServingTranscription(OpenAISpeechToText):
53
54
async def create_transcription(
55
self, audio_data: bytes, request: TranscriptionRequest, raw_request: Request
54
- ) -> TranscriptionResponse | AsyncGenerator[str, None] | ErrorResponse:
56
+ ) -> (
57
+ TranscriptionResponse
58
+ | TranscriptionResponseVerbose
59
+ | AsyncGenerator[str, None]
60
+ | ErrorResponse
61
+ ):
62
"""Transcription API similar to OpenAI's API.
63
64
See https://platform.openai.com/docs/api-reference/audio/createTranscription
@@ -61,7 +68,11 @@ class OpenAIServingTranscription(OpenAISpeechToText):
68
audio_data=audio_data,
69
request=request,
70
raw_request=raw_request,
64
- response_class=TranscriptionResponse,
71
+ response_class=(
72
+ TranscriptionResponseVerbose
73
+ if request.response_format == "verbose_json"
74
+ else TranscriptionResponse
75
+ ),
76
stream_generator_method=self.transcription_stream_generator,
77
)
78
@@ -112,7 +123,12 @@ class OpenAIServingTranslation(OpenAISpeechToText):
123
124
async def create_translation(
125
self, audio_data: bytes, request: TranslationRequest, raw_request: Request
115
- ) -> TranslationResponse | AsyncGenerator[str, None] | ErrorResponse:
126
+ ) -> (
127
+ TranslationResponse
128
+ | TranslationResponseVerbose
129
+ | AsyncGenerator[str, None]
130
+ | ErrorResponse
131
+ ):
132
"""Translation API similar to OpenAI's API.
133
134
See https://platform.openai.com/docs/api-reference/audio/createTranslation
@@ -122,7 +138,11 @@ class OpenAIServingTranslation(OpenAISpeechToText):
138
audio_data=audio_data,
139
request=request,
140
raw_request=raw_request,
125
- response_class=TranslationResponse,
141
+ response_class=(
142
+ TranslationResponseVerbose
143
+ if request.response_format == "verbose_json"
144
+ else TranslationResponse
145
+ ),
146
stream_generator_method=self.translation_stream_generator,
147
)
148
vllm/entrypoints/openai/speech_to_text.py
+163
-9
@@ -10,6 +10,7 @@ from typing import Literal, TypeAlias, TypeVar, cast
10
11
import numpy as np
12
from fastapi import Request
13
+from transformers import PreTrainedTokenizerBase
14
15
import vllm.envs as envs
16
from vllm.engine.protocol import EngineClient
@@ -20,9 +21,13 @@ from vllm.entrypoints.openai.protocol import (
21
RequestResponseMetadata,
22
TranscriptionResponse,
23
TranscriptionResponseStreamChoice,
24
+ TranscriptionResponseVerbose,
25
+ TranscriptionSegment,
26
TranscriptionStreamResponse,
27
TranslationResponse,
28
TranslationResponseStreamChoice,
29
+ TranslationResponseVerbose,
30
+ TranslationSegment,
31
TranslationStreamResponse,
32
UsageInfo,
33
)
@@ -32,6 +37,7 @@ from vllm.inputs.data import PromptType
37
from vllm.logger import init_logger
38
from vllm.model_executor.models import SupportsTranscription
39
from vllm.outputs import RequestOutput
40
+from vllm.transformers_utils.tokenizer import get_tokenizer
41
from vllm.utils.import_utils import PlaceholderModule
42
43
try:
@@ -40,7 +46,20 @@ except ImportError:
46
librosa = PlaceholderModule("librosa") # type: ignore[assignment]
47
48
SpeechToTextResponse: TypeAlias = TranscriptionResponse | TranslationResponse
49
+SpeechToTextResponseVerbose: TypeAlias = (
50
+ TranscriptionResponseVerbose | TranslationResponseVerbose
51
+)
52
+SpeechToTextSegment: TypeAlias = TranscriptionSegment | TranslationSegment
53
T = TypeVar("T", bound=SpeechToTextResponse)
54
+V = TypeVar("V", bound=SpeechToTextResponseVerbose)
55
+S = TypeVar("S", bound=SpeechToTextSegment)
56
+
57
+ResponseType: TypeAlias = (
58
+ TranscriptionResponse
59
+ | TranslationResponse
60
+ | TranscriptionResponseVerbose
61
+ | TranslationResponseVerbose
62
+)
63
64
logger = init_logger(__name__)
65
@@ -78,6 +97,14 @@ class OpenAISpeechToText(OpenAIServing):
97
self.enable_force_include_usage = enable_force_include_usage
98
99
self.max_audio_filesize_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB
100
+ if self.model_cls.supports_segment_timestamp:
101
+ self.tokenizer = cast(
102
+ PreTrainedTokenizerBase,
103
+ get_tokenizer(
104
+ tokenizer_name=self.model_config.tokenizer,
105
+ tokenizer_mode=self.model_config.tokenizer_mode,
106
+ ),
107
+ )
108
109
if self.default_sampling_params:
110
logger.info(
@@ -133,17 +160,87 @@ class OpenAISpeechToText(OpenAIServing):
160
request_prompt=request.prompt,
161
to_language=to_language,
162
)
163
+ if request.response_format == "verbose_json":
164
+ if not isinstance(prompt, dict):
165
+ raise ValueError(f"Expected prompt to be a dict,got {type(prompt)}")
166
+ prompt_dict = cast(dict, prompt)
167
+ decoder_prompt = prompt.get("decoder_prompt")
168
+ if not isinstance(decoder_prompt, str):
169
+ raise ValueError(
170
+ f"Expected decoder_prompt to bestr, got {type(decoder_prompt)}"
171
+ )
172
+ prompt_dict["decoder_prompt"] = decoder_prompt.replace(
173
+ "<|notimestamps|>", "<|0.00|>"
174
+ )
175
prompts.append(prompt)
176
return prompts, duration
177
178
+ def _get_verbose_segments(
179
+ self,
180
+ tokens: tuple,
181
+ request: SpeechToTextRequest,
182
+ segment_class: type[SpeechToTextSegment],
183
+ start_time: float = 0,
184
+ ) -> list[SpeechToTextSegment]:
185
+ """
186
+ Convert tokens to verbose segments.
187
+
188
+ This method expects the model to produce
189
+ timestamps as tokens (similar to Whisper).
190
+ If the tokens do not include timestamp information,
191
+ the segments may not be generated correctly.
192
+
193
+ Note: Fields like avg_logprob, compression_ratio,
194
+ and no_speech_prob are not supported
195
+ in this implementation and will be None. See docs for details.
196
+ """
197
+ BASE_OFFSET = 0.02
198
+ init_token = self.tokenizer.encode("<|0.00|>", add_special_tokens=False)[0]
199
+ if tokens[-1] == self.tokenizer.eos_token_id:
200
+ tokens = tokens[:-1]
201
+
202
+ tokens_with_start = (init_token,) + tokens
203
+ segments: list[SpeechToTextSegment] = []
204
+ last_timestamp_start = 0
205
+
206
+ if tokens_with_start[-2] < init_token and tokens_with_start[-1] >= init_token:
207
+ tokens_with_start = tokens_with_start + (tokens_with_start[-1],)
208
+ for idx, token in enumerate(tokens_with_start):
209
+ # Timestamp tokens (e.g., <|0.00|>) are assumed to be sorted.
210
+ # If the ordering is violated, this slicing may produce incorrect results.
211
+ if (
212
+ token >= init_token
213
+ and idx != 0
214
+ and tokens_with_start[idx - 1] >= init_token
215
+ ):
216
+ sliced_timestamp_tokens = tokens_with_start[last_timestamp_start:idx]
217
+ start_timestamp = sliced_timestamp_tokens[0] - init_token
218
+ end_timestamp = sliced_timestamp_tokens[-1] - init_token
219
+
220
+ casting_segment = cast(
221
+ SpeechToTextSegment,
222
+ segment_class(
223
+ id=len(segments),
224
+ seek=start_time,
225
+ start=start_time + BASE_OFFSET * start_timestamp,
226
+ end=start_time + BASE_OFFSET * end_timestamp,
227
+ temperature=request.temperature,
228
+ text=self.tokenizer.decode(sliced_timestamp_tokens[1:-1]),
229
+ tokens=sliced_timestamp_tokens[1:-1],
230
+ ),
231
+ )
232
+ segments.append(casting_segment)
233
+ last_timestamp_start = idx
234
+ return segments
235
+
236
async def _create_speech_to_text(
237
self,
238
audio_data: bytes,
239
request: SpeechToTextRequest,
240
raw_request: Request,
144
- response_class: type[T],
241
+ response_class: type[T | V],
242
stream_generator_method: Callable[..., AsyncGenerator[str, None]],
146
- ) -> T | AsyncGenerator[str, None] | ErrorResponse:
243
+ ) -> T | V | AsyncGenerator[str, None] | ErrorResponse:
244
"""Base method for speech-to-text operations like transcription and
245
translation."""
246
error_check_ret = await self._check_model(request)
@@ -156,11 +253,24 @@ class OpenAISpeechToText(OpenAIServing):
253
if self.engine_client.errored:
254
raise self.engine_client.dead_error
255
159
- if request.response_format not in ["text", "json"]:
256
+ if request.response_format not in ["text", "json", "verbose_json"]:
257
return self.create_error_response(
161
- "Currently only support response_format `text` or `json`"
258
+ ("Currently only support response_format")
259
+ + ("`text`, `json` or `verbose_json`")
260
)
261
262
+ if (
263
+ request.response_format == "verbose_json"
264
+ and not self.model_cls.supports_segment_timestamp
265
+ ):
266
+ return self.create_error_response(
267
+ f"Currently do not support verbose_json for {request.model}"
268
+ )
269
+
270
+ if request.response_format == "verbose_json" and request.stream:
271
+ return self.create_error_response(
272
+ "verbose_json format doesn't support streaming case"
273
+ )
274
request_id = f"{self.task_type}-{self._base_request_id(raw_request)}"
275
276
request_metadata = RequestResponseMetadata(request_id=request_id)
@@ -215,25 +325,69 @@ class OpenAISpeechToText(OpenAIServing):
325
request, list_result_generator, request_id, request_metadata, duration_s
326
)
327
# Non-streaming response.
328
+ total_segments = []
329
+ text_parts = []
330
try:
331
assert list_result_generator is not None
332
+ segments_types: dict[str, type[SpeechToTextSegment]] = {
333
+ "transcribe": TranscriptionSegment,
334
+ "translate": TranslationSegment,
335
+ }
336
+ segment_class: type[SpeechToTextSegment] = segments_types[self.task_type]
337
text = ""
221
- for result_generator in list_result_generator:
338
+ for idx, result_generator in enumerate(list_result_generator):
339
async for op in result_generator:
223
- text += op.outputs[0].text
340
+ if request.response_format == "verbose_json":
341
+ segments: list[SpeechToTextSegment] = (
342
+ self._get_verbose_segments(
343
+ tokens=tuple(op.outputs[0].token_ids),
344
+ segment_class=segment_class,
345
+ request=request,
346
+ start_time=idx * self.asr_config.max_audio_clip_s,
347
+ )
348
+ )
349
350
+ total_segments.extend(segments)
351
+ text_parts.extend([seg.text for seg in segments])
352
+ else:
353
+ text_parts.append(op.outputs[0].text)
354
+ text = "".join(text_parts)
355
if self.task_type == "transcribe":
356
+ final_response: ResponseType
357
# add usage in TranscriptionResponse.
358
usage = {
359
"type": "duration",
360
# rounded up as per openAI specs
361
"seconds": int(math.ceil(duration_s)),
362
}
232
- final_response = cast(T, response_class(text=text, usage=usage))
363
+ if request.response_format != "verbose_json":
364
+ final_response = cast(
365
+ T, TranscriptionResponse(text=text, usage=usage)
366
+ )
367
+ else:
368
+ final_response = cast(
369
+ V,
370
+ TranscriptionResponseVerbose(
371
+ text=text,
372
+ language=request.language,
373
+ duration=str(duration_s),
374
+ segments=total_segments,
375
+ ),
376
+ )
377
else:
378
# no usage in response for translation task
235
- final_response = cast(T, response_class(text=text)) # type: ignore[call-arg]
236
-
379
+ if request.response_format != "verbose_json":
380
+ final_response = cast(T, TranslationResponse(text=text))
381
+ else:
382
+ final_response = cast(
383
+ V,
384
+ TranslationResponseVerbose(
385
+ text=text,
386
+ language=request.language,
387
+ duration=str(duration_s),
388
+ segments=total_segments,
389
+ ),
390
+ )
391
return final_response
392
except asyncio.CancelledError:
393
return self.create_error_response("Client disconnected")
vllm/model_executor/models/interfaces.py
+4
@@ -837,6 +837,10 @@ class SupportsTranscription(Protocol):
837
Transcription models can opt out of text generation by setting this to
838
`True`.
839
"""
840
+ supports_segment_timestamp: ClassVar[bool] = False
841
+ """
842
+ Enables the segment timestamp option for supported models by setting this to `True`.
843
+ """
844
845
def __init_subclass__(cls, **kwargs):
846
super().__init_subclass__(**kwargs)
vllm/model_executor/models/whisper.py
+1
@@ -791,6 +791,7 @@ class WhisperForConditionalGeneration(
791
792
# Whisper only supports audio-conditioned generation.
793
supports_transcription_only = True
794
+ supports_segment_timestamp = True
795
supported_languages = ISO639_1_SUPPORTED_LANGS
796
797
@classmethod