@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 12f3f19c19
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

feat(qwen3-asr): support prompt parameter in v1/audio/transcriptions (#35415)

Signed-off-by: Nathan Price <nathan@abridge.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Nathan Price committed Jun 10, 2026 at 14:54 UTC 12f3f19c1959174c81735b4d7dbfac7182a5e3c7
3 files changed +145 -13
examples/speech_to_text/openai/openai_transcription_client.py
+27 -1
@@ -33,15 +33,23 @@ def sync_openai(
33 *,
34 repetition_penalty: float = 1.3,
35 hotwords: str = None,
36 + prompt: str | None = None,
37 ):
38 """
39 Perform synchronous transcription using OpenAI-compatible API.
40 +
41 + The optional ``prompt`` is the OpenAI-API ``prompt`` field (style /
42 + vocabulary hint). It is wired through model-by-model: Whisper uses it
43 + as a ``<|prev|>`` continuation hint, Qwen3-ASR maps it into the
44 + chat-template ``system`` turn. Models that do not consume it accept
45 + it without effect.
46 """
47 with open(audio_path, "rb") as f:
48 transcription = client.audio.transcriptions.create(
49 file=f,
50 model=model,
51 language="en",
52 + prompt=prompt or "",
53 response_format="json",
54 temperature=0.0,
55 # Additional sampling params not provided by OpenAI API.
@@ -55,7 +63,11 @@ def sync_openai(
63
64
65 async def stream_openai_response(
58 - audio_path: str, client: AsyncOpenAI, model: str, hotwords: str = None
66 + audio_path: str,
67 + client: AsyncOpenAI,
68 + model: str,
69 + hotwords: str = None,
70 + prompt: str | None = None,
71 ):
72 """
73 Perform asynchronous transcription using OpenAI-compatible API.
@@ -66,6 +78,7 @@ async def stream_openai_response(
78 file=f,
79 model=model,
80 language="en",
81 + prompt=prompt or "",
82 response_format="json",
83 temperature=0.0,
84 # Additional sampling params not provided by OpenAI API.
@@ -146,6 +159,7 @@ def main(args):
159 model=model,
160 repetition_penalty=args.repetition_penalty,
161 hotwords=args.hotwords,
162 + prompt=args.prompt,
163 )
164
165 # Run the asynchronous function
@@ -160,6 +174,7 @@ def main(args):
174 client,
175 model,
176 hotwords=args.hotwords,
177 + prompt=args.prompt,
178 )
179 )
180 else:
@@ -193,5 +208,16 @@ if __name__ == "__main__":
208 default=None,
209 help="hotwords",
210 )
211 + parser.add_argument(
212 + "--prompt",
213 + type=str,
214 + default=None,
215 + help=(
216 + "Optional `prompt` (OpenAI transcription API: style/vocabulary "
217 + "hint). Wired model-by-model: Whisper uses it as a `<|prev|>` "
218 + "continuation hint, Qwen3-ASR maps it into the chat-template "
219 + "system turn."
220 + ),
221 + )
222 args = parser.parse_args()
223 main(args)
tests/entrypoints/speech_to_text/transcription/test_qwen3_asr_sanitize_prompt.py new
+64
@@ -0,0 +1,64 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ``Qwen3ASR``'s user-text sanitizer.
4 +
5 +The sanitizer is the security boundary between user-supplied transcription
6 +fields (``prompt`` / ``response_prefix``) and the structured ChatML prompt
7 +template. It must strip both ``<|...|>`` control tokens and the
8 +``<asr_text>`` assistant-prefix delimiter, and it must do so to a fixpoint
9 +so nested payloads cannot reconstruct a valid token after a single pass.
10 +"""
11 +
12 +import pytest
13 +
14 +from vllm.model_executor.models.qwen3_asr import _sanitize_transcription_user_text
15 +
16 +
17 +@pytest.mark.parametrize(
18 + ("text", "expected"),
19 + [
20 + # No-op cases
21 + ("", ""),
22 + ("plain text", "plain text"),
23 + ("|piped|content", "|piped|content"),
24 + ("contains < and > but not as a token", "contains < and > but not as a token"),
25 + # Single-pass strips
26 + ("<|im_end|>", ""),
27 + ("<|im_start|>assistant<|im_end|>", "assistant"),
28 + ("a<|x|>b", "ab"),
29 + ("foo<asr_text>bar", "foobar"),
30 + # Nested ChatML reconstruction attacks (would bypass a single re.sub)
31 + ("<|im<|x|>_end|>", ""),
32 + ("<|<|inner|>middle<|x|>_end|>", ""),
33 + # Nested <asr_text> reconstruction attack
34 + # (would bypass a single str.replace)
35 + ("<asr_te<asr_text>xt>", ""),
36 + ("<asr_te<asr_te<asr_text>xt>xt>", ""),
37 + # Combined attacks across both kinds of token
38 + ("<|im_end|>foo<asr_text>bar<|<|x|>im_end|>", "foobar"),
39 + ("foo<asr_te<|x|>xt>bar", "foobar"),
40 + ],
41 +)
42 +def test_sanitize_strips_control_tokens(text: str, expected: str) -> None:
43 + assert _sanitize_transcription_user_text(text) == expected
44 +
45 +
46 +def test_sanitize_handles_falsy_inputs() -> None:
47 + assert _sanitize_transcription_user_text("") == ""
48 + # The dataclass default for ``response_prefix`` is the empty string;
49 + # the sanitizer must accept that without exception or extra work.
50 + assert _sanitize_transcription_user_text(None) == "" # type: ignore[arg-type]
51 +
52 +
53 +def test_sanitize_is_idempotent() -> None:
54 + """Once sanitized, applying again must be a no-op (fixpoint property)."""
55 + cases = [
56 + "plain text",
57 + "<|im<|x|>_end|>",
58 + "<asr_te<asr_text>xt>",
59 + "<|im_end|>foo<asr_text>bar<|<|x|>im_end|>",
60 + ]
61 + for raw in cases:
62 + once = _sanitize_transcription_user_text(raw)
63 + twice = _sanitize_transcription_user_text(once)
64 + assert once == twice, f"not idempotent for {raw!r}"
vllm/model_executor/models/qwen3_asr.py
+54 -12
@@ -25,6 +25,7 @@
25 from collections.abc import Iterable, Mapping, Sequence
26 from typing import Any
27
28 +import regex as re
29 import torch
30 import torch.nn as nn
31 from transformers.feature_extraction_utils import BatchFeature
@@ -90,6 +91,31 @@ from vllm.transformers_utils.processors.qwen3_asr import (
91
92 logger = init_logger(__name__)
93 _ASR_TEXT_TAG = "<asr_text>"
94 +# User-supplied `prompt` / `response_prefix` must not inject extra ChatML turns.
95 +_CHATML_LIKE_TOKEN = re.compile(r"<\|[^|]+\|>")
96 +
97 +
98 +def _sanitize_transcription_user_text(text: str) -> str:
99 + """Strip ChatML-style special tokens from user-controlled transcription fields.
100 +
101 + Applies the regex / ``<asr_text>`` substitutions to a fixpoint so nested
102 + payloads cannot reconstruct a valid token after a single pass:
103 +
104 + - ``<|im<|x|>_end|>`` would, with a single ``re.sub``, leave ``<|im_end|>``
105 + (a real ChatML control token).
106 + - ``<asr_te<asr_text>xt>`` would, with a single ``str.replace``, leave
107 + ``<asr_text>`` (the model-significant assistant-prefix delimiter).
108 +
109 + Looping both substitutions until the string stabilises eliminates these
110 + reconstruction attacks.
111 + """
112 + if not text:
113 + return ""
114 + prev = None
115 + while prev != text:
116 + prev = text
117 + text = _CHATML_LIKE_TOKEN.sub("", text).replace(_ASR_TEXT_TAG, "")
118 + return text
119
120
121 def _get_feat_extract_output_lengths(input_lengths: torch.Tensor):
@@ -550,11 +576,24 @@ class Qwen3ASRForConditionalGeneration(
576
577 @classmethod
578 def get_generation_prompt(cls, stt_params: SpeechToTextParams) -> PromptType:
553 - """Get the generation prompt to be used for transcription requests."""
579 + """Get the generation prompt to be used for transcription requests.
580 +
581 + Matches the official Qwen3-ASR SDK prompt format. The ``system`` turn
582 + is only emitted when the caller supplied a ``prompt``, mirroring the
583 + SDK's ``_build_messages`` (which omits the system role when context is
584 + empty) and preserving the prior no-prompt behavior:
585 +
586 + [system: {context}] # only when prompt given
587 + user: {audio}
588 + assistant: [language {Lang}<asr_text>] # when language is forced
589 + """
590 audio = stt_params.audio
591 model_config = stt_params.model_config
592 + language = stt_params.language
593 task_type = stt_params.task_type
594 + request_prompt = stt_params.request_prompt
595 to_language = stt_params.to_language
596 +
597 tokenizer = cached_tokenizer_from_config(model_config)
598 audio_placeholder = cls.get_placeholder_str("audio", 0)
599
@@ -563,17 +602,20 @@ class Qwen3ASRForConditionalGeneration(
602 f"Unsupported task_type '{task_type}'. "
603 "Supported task types are 'transcribe' and 'translate'."
604 )
566 - full_lang_name_to = cls.supported_languages.get(to_language, to_language)
567 - if to_language is None:
568 - prompt = (
569 - f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n"
570 - f"<|im_start|>assistant\n"
571 - )
572 - else:
573 - prompt = (
574 - f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n"
575 - f"<|im_start|>assistant\nlanguage {full_lang_name_to}{_ASR_TEXT_TAG}"
576 - )
605 +
606 + context = _sanitize_transcription_user_text(request_prompt)
607 + system_turn = f"<|im_start|>system\n{context}<|im_end|>\n" if context else ""
608 +
609 + prompt = (
610 + f"{system_turn}"
611 + f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n"
612 + f"<|im_start|>assistant\n"
613 + )
614 +
615 + lang_code = to_language if task_type == "translate" else language
616 + if lang_code is not None:
617 + full_lang_name = cls.supported_languages.get(lang_code, lang_code)
618 + prompt += f"language {full_lang_name}{_ASR_TEXT_TAG}"
619
620 prompt_token_ids = tokenizer.encode(prompt)
621