main
py 711 lines 25.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # Copyright 2026 The Qwen team.
4 # Copyright 2023 The vLLM team.
5 # Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
6 #
7 # This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
8 # and OPT implementations in this library. It has been modified from its
9 # original forms to accommodate minor architectural differences compared
10 # to GPT-NeoX and OPT used by the Meta AI team that trained the model.
11 #
12 # Licensed under the Apache License, Version 2.0 (the "License");
13 # you may not use this file except in compliance with the License.
14 # You may obtain a copy of the License at
15 #
16 # http://www.apache.org/licenses/LICENSE-2.0
17 #
18 # Unless required by applicable law or agreed to in writing, software
19 # distributed under the License is distributed on an "AS IS" BASIS,
20 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 # See the License for the specific language governing permissions and
22 # limitations under the License.
23 """Inference-only Qwen3-ASR model."""
24
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
32 from transformers.models.whisper import WhisperFeatureExtractor
33
34 from vllm.config import ModelConfig, SpeechToTextConfig, VllmConfig
35 from vllm.config.multimodal import MultiModalDummyOptions
36 from vllm.config.speech_to_text import SpeechToTextParams
37 from vllm.inputs import ModalityData, MultiModalDataDict, PromptType, TokensPrompt
38 from vllm.logger import init_logger
39 from vllm.model_executor.models.interfaces import (
40 MultiModalEmbeddings,
41 StreamingTranscriptionPostProcessor,
42 SupportsLoRA,
43 SupportsMRoPE,
44 SupportsMultiModal,
45 SupportsPP,
46 SupportsTranscription,
47 )
48 from vllm.model_executor.models.module_mapping import MultiModelKeys
49 from vllm.model_executor.models.qwen2_5_omni_thinker import (
50 Qwen2_5OmniAudioFeatureInputs,
51 unpad_and_flat_audio_features,
52 )
53 from vllm.model_executor.models.qwen3 import Qwen3ForCausalLM
54 from vllm.model_executor.models.qwen3_omni_moe_thinker import (
55 Qwen3OmniMoeAudioEncoder,
56 Qwen3OmniMoeThinkerMultiModalProcessor,
57 )
58 from vllm.model_executor.models.utils import (
59 AutoWeightsLoader,
60 WeightsMapper,
61 _merge_multimodal_embeddings,
62 maybe_prefix,
63 )
64 from vllm.model_executor.models.whisper import ISO639_1_SUPPORTED_LANGS
65 from vllm.multimodal import MULTIMODAL_REGISTRY
66 from vllm.multimodal.inputs import (
67 AudioItem,
68 MultiModalFeatureSpec,
69 MultiModalFieldConfig,
70 MultiModalKwargsItem,
71 MultiModalKwargsItems,
72 )
73 from vllm.multimodal.parse import (
74 AudioProcessorItems,
75 DictEmbeddingItems,
76 ModalityDataItems,
77 MultiModalDataItems,
78 MultiModalDataParser,
79 )
80 from vllm.multimodal.processing import (
81 BaseDummyInputsBuilder,
82 BaseProcessingInfo,
83 PromptReplacement,
84 PromptUpdate,
85 cached_encode,
86 )
87 from vllm.sequence import IntermediateTensors
88 from vllm.tokenizers import cached_tokenizer_from_config
89 from vllm.transformers_utils.configs.qwen3_asr import (
90 Qwen3ASRConfig,
91 Qwen3ASRThinkerConfig,
92 )
93 from vllm.transformers_utils.processor import cached_processor_from_config
94 from vllm.transformers_utils.processors.qwen3_asr import (
95 Qwen3ASRProcessor,
96 )
97 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
98 from vllm.utils.torch_utils import async_tensor_h2d
99
100 logger = init_logger(__name__)
101 _ASR_TEXT_TAG = "<asr_text>"
102 # User-supplied `prompt` / `response_prefix` must not inject extra ChatML turns.
103 _CHATML_LIKE_TOKEN = re.compile(r"<\|[^|]+\|>")
104 _LANGUAGE_PREFIX = "language "
105 _MAX_STREAMING_PREFIX_CHARS = 50
106
107
108 def _post_process_qwen3_asr_output(text: str) -> str:
109 if not text or _ASR_TEXT_TAG not in text:
110 return text
111
112 _, text_part = text.rsplit(_ASR_TEXT_TAG, 1)
113 return text_part
114
115
116 class Qwen3ASRStreamingPostProcessor(StreamingTranscriptionPostProcessor):
117 def __init__(self) -> None:
118 self.raw_text = ""
119 self.emitted_text = ""
120 self.is_structured_output: bool | None = None
121
122 def process_delta(self, text_delta: str, finished: bool) -> str:
123 self.raw_text += text_delta
124
125 if self.is_structured_output is None:
126 text_without_leading_space = self.raw_text.lstrip()
127 maybe_structured_output = (
128 text_without_leading_space == ""
129 or _LANGUAGE_PREFIX.startswith(text_without_leading_space)
130 or (
131 text_without_leading_space.startswith(_LANGUAGE_PREFIX)
132 and len(text_without_leading_space) < _MAX_STREAMING_PREFIX_CHARS
133 and "\n" not in text_without_leading_space
134 )
135 )
136 if maybe_structured_output and _ASR_TEXT_TAG not in self.raw_text:
137 if not finished:
138 return ""
139 self.is_structured_output = False
140 else:
141 self.is_structured_output = _ASR_TEXT_TAG in self.raw_text
142
143 processed_text = (
144 _post_process_qwen3_asr_output(self.raw_text)
145 if self.is_structured_output
146 else self.raw_text
147 )
148 new_text = processed_text[len(self.emitted_text) :]
149 self.emitted_text = processed_text
150 return new_text
151
152
153 def _sanitize_transcription_user_text(text: str) -> str:
154 """Strip ChatML-style special tokens from user-controlled transcription fields.
155
156 Applies the regex / ``<asr_text>`` substitutions to a fixpoint so nested
157 payloads cannot reconstruct a valid token after a single pass:
158
159 - ``<|im<|x|>_end|>`` would, with a single ``re.sub``, leave ``<|im_end|>``
160 (a real ChatML control token).
161 - ``<asr_te<asr_text>xt>`` would, with a single ``str.replace``, leave
162 ``<asr_text>`` (the model-significant assistant-prefix delimiter).
163
164 Looping both substitutions until the string stabilises eliminates these
165 reconstruction attacks.
166 """
167 if not text:
168 return ""
169 prev = None
170 while prev != text:
171 prev = text
172 text = _CHATML_LIKE_TOKEN.sub("", text).replace(_ASR_TEXT_TAG, "")
173 return text
174
175
176 def _get_feat_extract_output_lengths(input_lengths: torch.Tensor):
177 input_lengths_leave = input_lengths % 100
178 feat_lengths = (input_lengths_leave - 1) // 2 + 1
179 output_lengths = (
180 ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 + (input_lengths // 100) * 13
181 )
182 return output_lengths
183
184
185 class Qwen3ASRProcessingInfo(BaseProcessingInfo):
186 def get_hf_config(self):
187 return self.ctx.get_hf_config(Qwen3ASRConfig).thinker_config
188
189 def get_hf_processor(self, **kwargs: object) -> Qwen3ASRProcessor:
190 processor = self.ctx.get_hf_processor(
191 Qwen3ASRProcessor,
192 **kwargs,
193 )
194 if not hasattr(processor, "audio_token"):
195 processor.audio_token = "<|audio_pad|>"
196 return processor
197
198 def get_feature_extractor(self, **kwargs: object) -> WhisperFeatureExtractor:
199 hf_processor = self.get_hf_processor(**kwargs)
200 feature_extractor = hf_processor.feature_extractor
201 assert isinstance(feature_extractor, WhisperFeatureExtractor)
202 return feature_extractor
203
204 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
205 return {"audio": None}
206
207 def get_data_parser(self) -> MultiModalDataParser:
208 feature_extractor = self.get_feature_extractor()
209 return Qwen3ASRMultiModalDataParser(
210 target_sr=feature_extractor.sampling_rate,
211 expected_hidden_size=self._get_expected_hidden_size(),
212 )
213
214
215 class Qwen3ASRDummyInputsBuilder(BaseDummyInputsBuilder[Qwen3ASRProcessingInfo]):
216 def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
217 num_audios = mm_counts.get("audio", 0)
218
219 hf_processor = self.info.get_hf_processor()
220 audio_token = hf_processor.audio_token
221
222 return audio_token * num_audios
223
224 def get_dummy_mm_data(
225 self,
226 seq_len: int,
227 mm_counts: Mapping[str, int],
228 mm_options: MultiModalDummyOptions,
229 ) -> MultiModalDataDict:
230 feature_extractor = self.info.get_feature_extractor()
231
232 target_audio_length = (
233 min(
234 feature_extractor.chunk_length,
235 30,
236 )
237 * feature_extractor.sampling_rate
238 )
239
240 return {
241 "audio": self._get_dummy_audios(
242 length=target_audio_length,
243 num_audios=mm_counts.get("audio", 0),
244 overrides=mm_options.get("audio"),
245 ),
246 }
247
248
249 def _qwen3asr_field_config(hf_inputs: Mapping[str, torch.Tensor]):
250 audio_feature_lengths = hf_inputs.get("audio_feature_lengths", torch.empty((0,)))
251 return dict(
252 input_audio_features=MultiModalFieldConfig.flat_from_sizes(
253 "audio", audio_feature_lengths, dim=1
254 ),
255 feature_attention_mask=MultiModalFieldConfig.batched("audio", keep_on_cpu=True),
256 audio_feature_lengths=MultiModalFieldConfig.batched("audio", keep_on_cpu=True),
257 )
258
259
260 class Qwen3ASRMultiModalDataParser(MultiModalDataParser):
261 def _parse_audio_data(
262 self,
263 data: dict[str, torch.Tensor] | ModalityData[AudioItem],
264 ) -> ModalityDataItems[Any, Any] | None:
265 if isinstance(data, dict):
266 return DictEmbeddingItems(
267 data,
268 modality="audio",
269 required_fields={"input_audio_features", "audio_feature_lengths"},
270 fields_factory=_qwen3asr_field_config,
271 )
272
273 return super()._parse_audio_data(data)
274
275
276 class Qwen3ASRMultiModalProcessor(
277 Qwen3OmniMoeThinkerMultiModalProcessor[Qwen3ASRProcessingInfo]
278 ):
279 def _get_mm_fields_config(
280 self,
281 hf_inputs: BatchFeature,
282 hf_processor_mm_kwargs: Mapping[str, object],
283 ) -> Mapping[str, MultiModalFieldConfig]:
284 return _qwen3asr_field_config(hf_inputs)
285
286 def _get_prompt_updates(
287 self,
288 mm_items: MultiModalDataItems,
289 hf_processor_mm_kwargs: Mapping[str, Any],
290 out_mm_kwargs: MultiModalKwargsItems,
291 ) -> Sequence[PromptUpdate]:
292 processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
293 tokenizer = self.info.get_tokenizer()
294 vocab = tokenizer.get_vocab()
295
296 audio_token = processor.audio_token
297 audio_token_id = vocab[audio_token]
298
299 out_mm_data = out_mm_kwargs.get_data()
300 audio_feature_lengths = out_mm_data.get("audio_feature_lengths")
301 feature_attention_mask = out_mm_data.get("feature_attention_mask")
302 if audio_feature_lengths is None and feature_attention_mask is None:
303 audio_output_lengths = []
304 elif audio_feature_lengths is not None:
305 audio_output_lens = _get_feat_extract_output_lengths(audio_feature_lengths)
306 audio_output_lengths = audio_output_lens.tolist()
307 elif feature_attention_mask is not None:
308 assert isinstance(feature_attention_mask, torch.Tensor)
309 audio_output_lens = _get_feat_extract_output_lengths(
310 feature_attention_mask.sum(-1)
311 )
312 audio_output_lengths = audio_output_lens.tolist()
313
314 def get_replacement_qwen2_audio(item_idx: int):
315 num_features = audio_output_lengths[item_idx]
316 if num_features == 0:
317 audios = mm_items.get_items("audio", AudioProcessorItems)
318 audio = audios.get(item_idx)
319 assert audio is not None
320 raise ValueError(
321 f"The audio {audio} (len={len(audio)}) is too short "
322 "to be represented inside the model"
323 )
324
325 return [audio_token_id] * num_features
326
327 return [
328 PromptReplacement(
329 modality="audio",
330 target=[audio_token_id],
331 replacement=get_replacement_qwen2_audio,
332 ),
333 ]
334
335
336 @MULTIMODAL_REGISTRY.register_processor(
337 Qwen3ASRMultiModalProcessor,
338 info=Qwen3ASRProcessingInfo,
339 dummy_inputs=Qwen3ASRDummyInputsBuilder,
340 )
341 class Qwen3ASRForConditionalGeneration(
342 nn.Module,
343 SupportsMultiModal,
344 SupportsPP,
345 SupportsMRoPE,
346 SupportsTranscription,
347 SupportsLoRA,
348 ):
349 # LoRA support
350 packed_modules_mapping = {
351 "qkv_proj": [
352 "q_proj",
353 "k_proj",
354 "v_proj",
355 ],
356 "gate_up_proj": [
357 "gate_proj",
358 "up_proj",
359 ],
360 }
361
362 supported_languages = ISO639_1_SUPPORTED_LANGS
363 supports_tower_connector_lora = True
364
365 hf_to_vllm_mapper = WeightsMapper(
366 orig_to_new_prefix={
367 "thinker.lm_head.": "language_model.lm_head.",
368 "thinker.model.": "language_model.model.",
369 "thinker.": "",
370 # HF format mapper
371 "model.audio_tower.": "audio_tower.",
372 "model.language_model.": "language_model.model.",
373 "model.multi_modal_projector.linear_1.": "audio_tower.proj1.",
374 "model.multi_modal_projector.linear_2.": "audio_tower.proj2.",
375 "talker.": None,
376 "code2wav.": None,
377 }
378 )
379
380 @classmethod
381 def get_placeholder_str(cls, modality: str, i: int) -> str | None:
382 if modality.startswith("audio"):
383 return "<|audio_start|><|audio_pad|><|audio_end|>"
384
385 raise ValueError("Only audio modality is supported")
386
387 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
388 super().__init__()
389 self.vllm_config = vllm_config # needed for torch compile forward context
390 thinker_config: Qwen3ASRThinkerConfig = (
391 vllm_config.model_config.hf_config.thinker_config
392 )
393 quant_config = vllm_config.quant_config
394 multimodal_config = vllm_config.model_config.multimodal_config
395 self.config = thinker_config
396 self.multimodal_config = multimodal_config
397 self.quant_config = quant_config
398
399 with self._mark_tower_model(vllm_config, "audio"):
400 self.audio_tower = Qwen3OmniMoeAudioEncoder(
401 thinker_config.audio_config,
402 prefix=maybe_prefix(prefix, "audio_tower"),
403 )
404
405 with self._mark_language_model(vllm_config):
406 self.language_model = Qwen3ForCausalLM(
407 vllm_config=vllm_config.with_hf_config(
408 thinker_config.text_config, architectures=["Qwen3ForCausalLM"]
409 ),
410 prefix=maybe_prefix(prefix, "language_model"),
411 )
412
413 self.make_empty_intermediate_tensors = (
414 self.language_model.make_empty_intermediate_tensors
415 )
416
417 def _parse_and_validate_audio_input(
418 self, **kwargs: object
419 ) -> Qwen2_5OmniAudioFeatureInputs | None:
420 input_audio_features = kwargs.pop("input_audio_features", None)
421 audio_feature_lengths = kwargs.pop("audio_feature_lengths", None)
422 feature_attention_mask = kwargs.pop("feature_attention_mask", None)
423 if input_audio_features is None:
424 return None
425
426 # inputs features from rust frontend is batched and padded
427 # with shape [batch_size, n_mels, padded_seq_len], different
428 # from python's shape [n_mels, batch_size * seq_len]
429 if (
430 isinstance(input_audio_features, torch.Tensor)
431 and input_audio_features.dim() == 3
432 ):
433 input_audio_features = unpad_and_flat_audio_features(
434 input_audio_features, audio_feature_lengths
435 )
436
437 return Qwen2_5OmniAudioFeatureInputs(
438 type="audio_features",
439 input_features=input_audio_features,
440 audio_feature_lengths=audio_feature_lengths,
441 feature_attention_mask=feature_attention_mask,
442 )
443
444 def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict:
445 mm_input_by_modality = {}
446
447 # Preserve the order of modalities if there are multiple of them
448 # from the order of kwargs.
449 for input_key in kwargs:
450 if (
451 input_key in ("input_audio_features")
452 and "audio" not in mm_input_by_modality
453 ):
454 mm_input_by_modality["audio"] = self._parse_and_validate_audio_input(
455 **kwargs
456 )
457 return mm_input_by_modality
458
459 def _process_audio_input(
460 self,
461 audio_input: Qwen2_5OmniAudioFeatureInputs,
462 ) -> torch.Tensor:
463 input_features = audio_input["input_features"]
464 # audio_feature_lengths is keep_on_cpu; the audio tower derives
465 # device placement from feature_lens, so move it explicitly.
466 audio_feature_lengths = async_tensor_h2d(
467 audio_input["audio_feature_lengths"], input_features.device
468 )
469
470 audio_output_lengths = _get_feat_extract_output_lengths(audio_feature_lengths)
471
472 audio_features = self.audio_tower(
473 input_features.to(self.audio_tower.dtype),
474 feature_lens=audio_feature_lengths,
475 aftercnn_lens=audio_output_lengths,
476 )
477 with gpu_sync_allowed():
478 split_sizes = audio_output_lengths.tolist()
479 return audio_features.split(split_sizes)
480
481 def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings | None:
482 mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs)
483 if not mm_input_by_modality:
484 return []
485
486 # The result multimodal_embeddings is tuple of tensors, with each
487 # tensor correspoending to a multimodal data item (image or video).
488 multimodal_embeddings: tuple[torch.Tensor, ...] = ()
489
490 # NOTE: It is important to iterate over the keys in this dictionary
491 # to preserve the order of the modalities.
492 for modality in mm_input_by_modality:
493 multimodal_input = mm_input_by_modality[modality]
494 if modality == "audio":
495 audio_embeddings = self._process_audio_input(multimodal_input)
496 multimodal_embeddings += tuple(audio_embeddings)
497 return multimodal_embeddings
498
499 def embed_input_ids(
500 self,
501 input_ids: torch.Tensor,
502 multimodal_embeddings: MultiModalEmbeddings | None = None,
503 *,
504 is_multimodal: torch.Tensor | None = None,
505 ) -> torch.Tensor:
506 inputs_embeds = self._embed_text_input_ids(
507 input_ids,
508 self.language_model.embed_input_ids,
509 is_multimodal=is_multimodal,
510 )
511
512 if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
513 return inputs_embeds
514
515 inputs_embeds = _merge_multimodal_embeddings(
516 inputs_embeds=inputs_embeds,
517 multimodal_embeddings=multimodal_embeddings,
518 is_multimodal=is_multimodal,
519 )
520
521 return inputs_embeds
522
523 def forward(
524 self,
525 input_ids: torch.Tensor,
526 positions: torch.Tensor,
527 intermediate_tensors: IntermediateTensors | None = None,
528 inputs_embeds: torch.Tensor | None = None,
529 **kwargs: object,
530 ) -> torch.Tensor | IntermediateTensors:
531 if intermediate_tensors is not None:
532 inputs_embeds = None
533
534 hidden_states = self.language_model.model(
535 input_ids,
536 positions,
537 intermediate_tensors,
538 inputs_embeds=inputs_embeds,
539 )
540
541 return hidden_states
542
543 def compute_logits(
544 self,
545 hidden_states: torch.Tensor,
546 ) -> torch.Tensor | None:
547 return self.language_model.compute_logits(hidden_states)
548
549 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
550 loader = AutoWeightsLoader(self)
551 loaded_weights = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
552
553 return loaded_weights
554
555 def get_mrope_input_positions(
556 self,
557 input_tokens: list[int],
558 mm_features: list[MultiModalFeatureSpec],
559 ) -> tuple[torch.Tensor, int]:
560 seq_len = len(input_tokens)
561
562 if not mm_features:
563 # No audio features, just return linear positions
564 llm_positions = (
565 torch.arange(seq_len, dtype=torch.long).view(1, -1).expand(3, -1)
566 )
567 return llm_positions.clone(), 0
568
569 llm_pos_ids_list: list[torch.Tensor] = []
570 st = 0
571
572 for mm_feature in sorted(mm_features, key=lambda f: f.mm_position.offset):
573 offset = mm_feature.mm_position.offset
574
575 # The placeholder already contains one token per audio embedding.
576 # Use its length so M-RoPE can be reconstructed even when a covered
577 # prefix no longer carries the audio processor tensors.
578 audio_len = mm_feature.mm_position.length
579
580 # Text segment before audio (includes audio_start token)
581 text_len = offset - st
582 st_idx = llm_pos_ids_list[-1].max() + 1 if llm_pos_ids_list else 0
583 text_positions = (
584 torch.arange(text_len, dtype=torch.long).view(1, -1).expand(3, -1)
585 + st_idx
586 )
587 llm_pos_ids_list.append(text_positions)
588 st_idx = st_idx + text_len
589
590 # Audio token segment
591 audio_positions = (
592 torch.arange(audio_len, dtype=torch.long).view(1, -1).expand(3, -1)
593 + st_idx
594 )
595 llm_pos_ids_list.append(audio_positions)
596
597 st = offset + audio_len
598
599 # Handle remaining text (includes audio_end and any trailing text)
600 if st < seq_len:
601 st_idx = llm_pos_ids_list[-1].max() + 1 if llm_pos_ids_list else 0
602 text_len = seq_len - st
603 final_text_positions = (
604 torch.arange(text_len, dtype=torch.long).view(1, -1).expand(3, -1)
605 + st_idx
606 )
607 llm_pos_ids_list.append(final_text_positions)
608
609 llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
610 if llm_positions.shape[1] != seq_len:
611 raise RuntimeError("Position ids length mismatch with input ids length")
612
613 mrope_position_delta = (llm_positions.max() + 1 - seq_len).item()
614 return llm_positions, mrope_position_delta
615
616 def get_mm_mapping(self) -> MultiModelKeys:
617 """Get the module prefix in multimodal models."""
618 return MultiModelKeys.from_string_field(
619 language_model="language_model",
620 tower_model=["audio_tower."],
621 )
622
623 def get_mm_lora_token_counts(
624 self,
625 *,
626 modality: str,
627 mm_kwargs: MultiModalKwargsItem | None,
628 num_mm_embeds: int,
629 ) -> tuple[int, int | None]:
630 del modality, mm_kwargs
631 # For Qwen3-ASR, the audio tower produces one embedding per audio
632 # placeholder token inserted into the prompt (no additional
633 # merge/downsample step like vision towers). Therefore, the encoder
634 # token budget is identity.
635 return num_mm_embeds, None
636
637 @classmethod
638 def get_speech_to_text_config(
639 cls, model_config: ModelConfig, task_type: str
640 ) -> SpeechToTextConfig:
641 processor = cached_processor_from_config(model_config)
642 feature_extractor: WhisperFeatureExtractor = processor.feature_extractor
643 return SpeechToTextConfig(
644 max_audio_clip_s=feature_extractor.chunk_length,
645 sample_rate=feature_extractor.sampling_rate,
646 )
647
648 @classmethod
649 def get_generation_prompt(cls, stt_params: SpeechToTextParams) -> PromptType:
650 """Get the generation prompt to be used for transcription requests.
651
652 Matches the official Qwen3-ASR SDK prompt format. The ``system`` turn
653 is only emitted when the caller supplied a ``prompt``, mirroring the
654 SDK's ``_build_messages`` (which omits the system role when context is
655 empty) and preserving the prior no-prompt behavior:
656
657 [system: {context}] # only when prompt given
658 user: {audio}
659 assistant: [language {Lang}<asr_text>] # when language is forced
660 """
661 audio = stt_params.audio
662 model_config = stt_params.model_config
663 language = stt_params.language
664 task_type = stt_params.task_type
665 request_prompt = stt_params.request_prompt
666 to_language = stt_params.to_language
667
668 tokenizer = cached_tokenizer_from_config(model_config)
669 audio_placeholder = cls.get_placeholder_str("audio", 0)
670
671 if task_type not in ("transcribe", "translate"):
672 raise ValueError(
673 f"Unsupported task_type '{task_type}'. "
674 "Supported task types are 'transcribe' and 'translate'."
675 )
676
677 context = _sanitize_transcription_user_text(request_prompt)
678 system_turn = f"<|im_start|>system\n{context}<|im_end|>\n" if context else ""
679
680 prompt = (
681 f"{system_turn}"
682 f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n"
683 f"<|im_start|>assistant\n"
684 )
685
686 lang_code = to_language if task_type == "translate" else language
687 if lang_code is not None:
688 full_lang_name = cls.supported_languages.get(lang_code, lang_code)
689 prompt += f"language {full_lang_name}{_ASR_TEXT_TAG}"
690
691 prompt_token_ids = cached_encode(tokenizer, prompt)
692
693 return TokensPrompt(
694 prompt_token_ids=prompt_token_ids,
695 multi_modal_data={"audio": audio},
696 )
697
698 @classmethod
699 def post_process_output(cls, text: str) -> str:
700 """Post-process Qwen3-ASR raw output to extract clean transcription.
701
702 The model outputs in format: "language {lang}<asr_text>{transcription}"
703 This method strips the language prefix and asr_text tags.
704 """
705 return _post_process_qwen3_asr_output(text)
706
707 @classmethod
708 def get_streaming_post_processor_cls(
709 cls,
710 ) -> type[StreamingTranscriptionPostProcessor]:
711 return Qwen3ASRStreamingPostProcessor