main
py 211 lines 6.76 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5
6 import pytest
7 from mistral_common.protocol.instruct.chunk import AudioChunk, TextChunk
8 from mistral_common.protocol.instruct.messages import UserMessage
9 from mistral_common.tokens.tokenizers.audio import Audio
10 from transformers import VoxtralForConditionalGeneration
11
12 from vllm.tokenizers.mistral import MistralTokenizer
13
14 from ....conftest import AudioTestAssets
15 from ....utils import RemoteOpenAIServer
16 from ...utils import check_logprobs_close
17 from .test_ultravox import MULTI_AUDIO_PROMPT, run_multi_audio_test
18 from .vlm_utils import model_utils
19
20 MODEL_NAME = "mistralai/Voxtral-Mini-3B-2507"
21 MISTRAL_FORMAT_ARGS = [
22 "--tokenizer_mode",
23 "mistral",
24 "--config_format",
25 "mistral",
26 "--load_format",
27 "mistral",
28 ]
29
30
31 def _get_prompt(audio_assets: AudioTestAssets, question: str) -> list[int]:
32 """Build a token-ID prompt via mistral_common for vLLM offline inference."""
33 tokenizer = MistralTokenizer.from_pretrained(MODEL_NAME)
34
35 audios = [
36 Audio.from_file(str(asset.get_local_path()), strict=False)
37 for asset in audio_assets
38 ]
39 audio_chunks = [AudioChunk.from_audio(audio) for audio in audios]
40
41 messages = [
42 UserMessage(content=[*audio_chunks, TextChunk(text=question)]).to_openai()
43 ]
44 return tokenizer.apply_chat_template(messages=messages)
45
46
47 @pytest.mark.core_model
48 @pytest.mark.parametrize("dtype", ["half"])
49 @pytest.mark.parametrize("max_tokens", [128])
50 @pytest.mark.parametrize("num_logprobs", [5])
51 def test_models_with_multiple_audios(
52 vllm_runner,
53 audio_assets: AudioTestAssets,
54 dtype: str,
55 max_tokens: int,
56 num_logprobs: int,
57 ) -> None:
58 vllm_prompt = _get_prompt(audio_assets, MULTI_AUDIO_PROMPT)
59 run_multi_audio_test(
60 vllm_runner,
61 [(vllm_prompt, [a.audio_and_sample_rate for a in audio_assets])], # type: ignore[list-item]
62 MODEL_NAME,
63 dtype=dtype,
64 max_tokens=max_tokens,
65 num_logprobs=num_logprobs,
66 tokenizer_mode="mistral",
67 )
68
69
70 def test_online_serving(vllm_runner, audio_assets: AudioTestAssets):
71 """Two-layer accuracy and serving validation using Mistral format.
72
73 1. Offline vLLM greedy output (runs first to avoid CUDA fork issues
74 with multiprocessing - see vlm_utils/core.py).
75 2. Online OpenAI-compatible API output must match offline — validates
76 that the serving path (chat template, audio encoding, tokenization)
77 does not corrupt anything.
78
79 Steps run sequentially so each releases the GPU before the next starts.
80 """
81 question = f"What's happening in these {len(audio_assets)} audio clips?"
82 max_tokens = 10
83 audio_data = [asset.audio_and_sample_rate for asset in audio_assets]
84
85 vllm_prompt = _get_prompt(audio_assets, question)
86 with vllm_runner(
87 MODEL_NAME,
88 dtype="half",
89 enforce_eager=True,
90 tokenizer_mode="mistral",
91 config_format="mistral",
92 load_format="mistral",
93 limit_mm_per_prompt={"audio": len(audio_assets)},
94 ) as vllm_model:
95 offline_outputs = vllm_model.generate_greedy(
96 [vllm_prompt],
97 max_tokens,
98 audios=[audio_data],
99 )
100
101 offline_text = offline_outputs[0][1]
102 assert offline_text, "Offline vLLM inference produced empty output"
103
104 def _asset_to_openai_chunk(asset):
105 audio = Audio.from_file(str(asset.get_local_path()), strict=False)
106 audio.format = "wav"
107 return AudioChunk.from_audio(audio).to_openai()
108
109 messages = [
110 {
111 "role": "user",
112 "content": [
113 *[_asset_to_openai_chunk(a) for a in audio_assets],
114 {"type": "text", "text": question},
115 ],
116 }
117 ]
118
119 server_args = [
120 "--enforce-eager",
121 "--limit-mm-per-prompt",
122 json.dumps({"audio": len(audio_assets)}),
123 *MISTRAL_FORMAT_ARGS,
124 ]
125
126 with RemoteOpenAIServer(
127 MODEL_NAME,
128 server_args,
129 env_dict={"VLLM_AUDIO_FETCH_TIMEOUT": "30"},
130 ) as remote_server:
131 client = remote_server.get_client()
132 completion = client.chat.completions.create(
133 model=MODEL_NAME,
134 messages=messages,
135 max_tokens=max_tokens,
136 temperature=0,
137 )
138
139 assert len(completion.choices) == 1
140 choice = completion.choices[0]
141 assert choice.finish_reason == "length"
142 assert choice.message.content == offline_text, (
143 f"Online serving output does not match offline inference.\n"
144 f" Online: {choice.message.content!r}\n"
145 f" Offline: {offline_text!r}"
146 )
147
148
149 @pytest.mark.skip(
150 reason="VoxtralProcessor.apply_chat_template() in transformers v5 "
151 "doesn't resolve chat_template=None to the default template"
152 )
153 def test_hf_reference(hf_runner, vllm_runner, audio_assets: AudioTestAssets):
154 """Compare vLLM Mistral-format output against HF Transformers reference.
155
156 Instead of requiring an exact text match (which is brittle across
157 attention backends), we compare per-token logprobs using the standard
158 check_logprobs_close helper: when tokens diverge at a position, each
159 runner's chosen token must appear in the other's top-k logprobs.
160
161 Marked xfail(strict=False) so remaining edge-case mismatches
162 don't block CI.
163 """
164 question = f"What's happening in these {len(audio_assets)} audio clips?"
165 max_tokens = 10
166 num_logprobs = 5
167 audio_data = [asset.audio_and_sample_rate for asset in audio_assets]
168
169 vllm_prompt = _get_prompt(audio_assets, question)
170 with vllm_runner(
171 MODEL_NAME,
172 dtype="half",
173 enforce_eager=True,
174 tokenizer_mode="mistral",
175 config_format="mistral",
176 load_format="mistral",
177 limit_mm_per_prompt={"audio": len(audio_assets)},
178 ) as vllm_model:
179 vllm_outputs = vllm_model.generate_greedy_logprobs(
180 [vllm_prompt],
181 max_tokens,
182 num_logprobs,
183 audios=[audio_data],
184 )
185 assert vllm_outputs[0][1], "vLLM inference produced empty output"
186
187 with hf_runner(
188 MODEL_NAME,
189 dtype="half",
190 auto_cls=VoxtralForConditionalGeneration,
191 ) as hf_model:
192 hf_model = model_utils.voxtral_patch_hf_runner(hf_model)
193 hf_outputs = hf_model.generate_greedy_logprobs_limit(
194 [question],
195 max_tokens,
196 num_logprobs,
197 audios=[audio_data],
198 )
199 assert hf_outputs[0][1], "HF Transformers produced empty output"
200
201 print(
202 f"HF Reference Comparison\n"
203 f" vLLM: {vllm_outputs[0][1]!r}\n"
204 f" HF: {hf_outputs[0][1]!r}"
205 )
206 check_logprobs_close(
207 outputs_0_lst=vllm_outputs,
208 outputs_1_lst=hf_outputs,
209 name_0="vllm",
210 name_1="hf",
211 )