test_vision.py 22.3 KB
Newer Older
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3

4
5
import json

6
7
import openai
import pytest
8
import pytest_asyncio
pansicheng's avatar
pansicheng committed
9
from transformers import AutoProcessor
10

11
from vllm.multimodal.media import MediaWithBytes
12
from vllm.multimodal.utils import encode_image_url, fetch_image
13
from vllm.platforms import current_platform
14

15
from ...utils import ROCM_ENV_OVERRIDES, ROCM_EXTRA_ARGS, RemoteOpenAIServer
16

17
18
MODEL_NAME = "microsoft/Phi-3.5-vision-instruct"
MAXIMUM_IMAGES = 2
19

20
# Test different image extensions (JPG/PNG) and formats (gray/RGB/RGBA)
21
TEST_IMAGE_ASSETS = [
22
23
24
25
    "2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",  # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
    "Grayscale_8bits_palette_sample_image.png",  # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/Grayscale_8bits_palette_sample_image.png",
    "1280px-Venn_diagram_rgb.svg.png",  # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/1280px-Venn_diagram_rgb.svg.png",
    "RGBA_comp.png",  # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/RGBA_comp.png",
26
27
]

28
29
30
31
32
33
34
35
36
37
38
39
40
# Required terms for beam search validation
# Each entry is a list of term groups - ALL groups must match
# Each group is a list of alternatives - at least ONE term in the group must appear
# This provides semantic validation while allowing wording variation
REQUIRED_BEAM_SEARCH_TERMS = [
    # Boardwalk image: must have "boardwalk" AND ("wooden" or "wood")
    [["boardwalk"], ["wooden", "wood"]],
    # Parrots image: must have ("parrot" or "bird") AND "two"
    [["parrot", "bird"], ["two"]],
    # Venn diagram: must have "venn" AND "diagram"
    [["venn"], ["diagram"]],
    # Gradient image: must have "gradient" AND ("color" or "spectrum")
    [["gradient"], ["color", "spectrum"]],
41
42
]

43
44
45
46
47
48
49
50

def check_output_matches_terms(content: str, term_groups: list[list[str]]) -> bool:
    """
    Check if content matches all required term groups.
    Each term group requires at least one of its terms to be present.
    All term groups must be satisfied.
    """
    content_lower = content.lower()
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
    return all(
        any(term.lower() in content_lower for term in group) for group in term_groups
    )


def assert_non_empty_content(chat_completion, *, context: str = "") -> str:
    """Assert the first choice has non-empty string content; return it.

    Provides a detailed failure message including the full ChatCompletion
    response so flaky / model-quality issues are easy to diagnose.
    """
    prefix = f"[{context}] " if context else ""
    choice = chat_completion.choices[0]
    content = choice.message.content

    assert content is not None, (
        f"{prefix}Expected non-None content but got None. "
        f"finish_reason={choice.finish_reason!r}, "
        f"full message={choice.message!r}, "
        f"usage={chat_completion.usage!r}"
    )
    assert isinstance(content, str), (
        f"{prefix}Expected str content, got {type(content).__name__}: {content!r}"
    )
    assert len(content) > 0, (
        f"{prefix}Expected non-empty content but got empty string. "
        f"finish_reason={choice.finish_reason!r}, "
        f"full message={choice.message!r}, "
        f"usage={chat_completion.usage!r}"
    )
    return content
82

83

84
@pytest.fixture(scope="module")
85
def server():
86
    args = [
87
        "--runner",
88
        "generate",
89
90
91
92
93
94
95
        "--max-model-len",
        "2048",
        "--max-num-seqs",
        "5",
        "--enforce-eager",
        "--trust-remote-code",
        "--limit-mm-per-prompt",
96
        json.dumps({"image": MAXIMUM_IMAGES}),
97
        *ROCM_EXTRA_ARGS,
98
99
    ]

100
101
    # ROCm: Increase timeouts to handle potential network delays and slower
    # video processing when downloading multiple videos from external sources
102
103
104
105
106
107
108
109
110
111
112
    env_overrides = {
        **ROCM_ENV_OVERRIDES,
        **(
            {
                "VLLM_VIDEO_FETCH_TIMEOUT": "120",
                "VLLM_ENGINE_ITERATION_TIMEOUT_S": "300",
            }
            if current_platform.is_rocm()
            else {}
        ),
    }
113
114

    with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_overrides) as remote_server:
115
        yield remote_server
116
117


118
119
120
121
@pytest_asyncio.fixture
async def client(server):
    async with server.get_async_client() as async_client:
        yield async_client
122
123


124
@pytest.fixture(scope="session")
125
def url_encoded_image(local_asset_server) -> dict[str, str]:
126
    return {
127
        image_asset: encode_image_url(local_asset_server.get_image_asset(image_asset))
128
        for image_asset in TEST_IMAGE_ASSETS
129
130
131
    }


132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def dummy_messages_from_image_url(
    image_urls: str | list[str],
    content_text: str = "What's in this image?",
):
    if isinstance(image_urls, str):
        image_urls = [image_urls]

    return [
        {
            "role": "user",
            "content": [
                *(
                    {"type": "image_url", "image_url": {"url": image_url}}
                    for image_url in image_urls
                ),
                {"type": "text", "text": content_text},
            ],
        }
    ]


153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def describe_image_messages(
    image_url: str, *, extra_image_fields: dict | None = None
) -> list[dict]:
    """Build the system + user messages used by the completions-with-image
    family of tests. *extra_image_fields* is merged into the top-level
    image content block (for uuid / bad-key tests)."""
    image_block: dict = {
        "type": "image_url",
        "image_url": {"url": image_url},
    }
    if extra_image_fields:
        image_block.update(extra_image_fields)

    return [
        {"role": "system", "content": "You are a helpful assistant."},
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "Describe this image."},
                image_block,
            ],
        },
    ]


async def complete_and_check(
    client: openai.AsyncOpenAI,
    model_name: str,
    messages: list[dict],
    *,
    context: str,
    max_completion_tokens: int = 50,
    temperature: float = 0.0,
) -> str:
    """Run a chat completion and assert the output is non-empty.
    Returns the content string."""
    chat_completion = await client.chat.completions.create(
        model=model_name,
        messages=messages,
        max_completion_tokens=max_completion_tokens,
        temperature=temperature,
    )
    return assert_non_empty_content(chat_completion, context=context)


pansicheng's avatar
pansicheng committed
198
def get_hf_prompt_tokens(model_name, content, image_url):
199
200
201
    processor = AutoProcessor.from_pretrained(
        model_name, trust_remote_code=True, num_crops=4
    )
pansicheng's avatar
pansicheng committed
202
203

    placeholder = "<|image_1|>\n"
204
205
206
207
208
209
    messages = [
        {
            "role": "user",
            "content": f"{placeholder}{content}",
        }
    ]
210
211
212
213
214
    image = fetch_image(image_url)
    # Unwrap MediaWithBytes if present
    if isinstance(image, MediaWithBytes):
        image = image.media
    images = [image]
pansicheng's avatar
pansicheng committed
215
216

    prompt = processor.tokenizer.apply_chat_template(
217
218
        messages, tokenize=False, add_generation_prompt=True
    )
pansicheng's avatar
pansicheng committed
219
220
221
222
223
    inputs = processor(prompt, images, return_tensors="pt")

    return inputs.input_ids.shape[1]


224
225
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
226
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
227
228
229
async def test_single_chat_session_image(
    client: openai.AsyncOpenAI, model_name: str, image_url: str
):
pansicheng's avatar
pansicheng committed
230
    content_text = "What's in this image?"
231
    messages = dummy_messages_from_image_url(image_url, content_text)
232

pansicheng's avatar
pansicheng committed
233
    max_completion_tokens = 10
234
235
236
    chat_completion = await client.chat.completions.create(
        model=model_name,
        messages=messages,
pansicheng's avatar
pansicheng committed
237
        max_completion_tokens=max_completion_tokens,
238
        logprobs=True,
239
        temperature=0.0,
240
241
        top_logprobs=5,
    )
242
243
244
    assert len(chat_completion.choices) == 1, (
        f"Expected 1 choice, got {len(chat_completion.choices)}"
    )
245
246

    choice = chat_completion.choices[0]
247
248
249
250
251
252
    assert choice.finish_reason == "length", (
        f"Expected finish_reason='length' (capped at {max_completion_tokens} "
        f"tokens), got {choice.finish_reason!r}. "
        f"content={choice.message.content!r}"
    )

253
    hf_prompt_tokens = get_hf_prompt_tokens(model_name, content_text, image_url)
254
    expected_usage = openai.types.CompletionUsage(
pansicheng's avatar
pansicheng committed
255
256
        completion_tokens=max_completion_tokens,
        prompt_tokens=hf_prompt_tokens,
257
258
        total_tokens=hf_prompt_tokens + max_completion_tokens,
    )
259
260
261
    assert chat_completion.usage == expected_usage, (
        f"Usage mismatch: got {chat_completion.usage!r}, expected {expected_usage!r}"
    )
262
263

    message = choice.message
264
265
266
267
268
269
270
    assert message.content is not None and len(message.content) >= 10, (
        f"Expected content with >=10 chars, got {message.content!r}"
    )
    assert message.role == "assistant", (
        f"Expected role='assistant', got {message.role!r}"
    )

271
272
273
274
    messages.append({"role": "assistant", "content": message.content})

    # test multi-turn dialogue
    messages.append({"role": "user", "content": "express your result in json"})
275
276
277
278
279
    await complete_and_check(
        client,
        model_name,
        messages,
        context=f"multi-turn follow-up for {image_url}",
280
        max_completion_tokens=10,
281
282
283
    )


284
285
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
286
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
287
288
289
async def test_error_on_invalid_image_url_type(
    client: openai.AsyncOpenAI, model_name: str, image_url: str
):
290
    content_text = "What's in this image?"
291
292
293
294
295
296
297
298
299
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "image_url", "image_url": image_url},
                {"type": "text", "text": content_text},
            ],
        }
    ]
300
301
302

    # image_url should be a dict {"url": "some url"}, not directly a string
    with pytest.raises(openai.BadRequestError):
303
        await client.chat.completions.create(
304
305
306
307
308
            model=model_name,
            messages=messages,
            max_completion_tokens=10,
            temperature=0.0,
        )
309
310


311
312
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
313
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
314
315
316
async def test_single_chat_session_image_beamsearch(
    client: openai.AsyncOpenAI, model_name: str, image_url: str
):
317
318
    content_text = "What's in this image?"
    messages = dummy_messages_from_image_url(image_url, content_text)
319
320
321
322
323

    chat_completion = await client.chat.completions.create(
        model=model_name,
        messages=messages,
        n=2,
324
        max_completion_tokens=10,
325
326
        logprobs=True,
        top_logprobs=5,
327
328
        extra_body=dict(use_beam_search=True),
    )
329
330
331
332
333
334
335
336
337
    assert len(chat_completion.choices) == 2, (
        f"Expected 2 beam search choices, got {len(chat_completion.choices)}"
    )

    content_0 = chat_completion.choices[0].message.content
    content_1 = chat_completion.choices[1].message.content
    assert content_0 != content_1, (
        f"Beam search should produce different outputs for {image_url}, "
        f"but both returned: {content_0!r}"
338
    )
339
340


341
342
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
343
344
@pytest.mark.parametrize("raw_image_url", TEST_IMAGE_ASSETS)
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
345
async def test_single_chat_session_image_base64encoded(
346
347
348
349
    client: openai.AsyncOpenAI,
    model_name: str,
    raw_image_url: str,
    image_url: str,
350
    url_encoded_image: dict[str, str],
351
):
pansicheng's avatar
pansicheng committed
352
    content_text = "What's in this image?"
353
    messages = dummy_messages_from_image_url(
354
        url_encoded_image[raw_image_url],
355
356
        content_text,
    )
357

pansicheng's avatar
pansicheng committed
358
    max_completion_tokens = 10
359
    # test single completion
360
361
362
    chat_completion = await client.chat.completions.create(
        model=model_name,
        messages=messages,
pansicheng's avatar
pansicheng committed
363
        max_completion_tokens=max_completion_tokens,
364
        logprobs=True,
365
        temperature=0.0,
366
367
        top_logprobs=5,
    )
368
369
370
    assert len(chat_completion.choices) == 1, (
        f"Expected 1 choice, got {len(chat_completion.choices)}"
    )
371
372

    choice = chat_completion.choices[0]
373
374
375
376
377
    assert choice.finish_reason == "length", (
        f"Expected finish_reason='length', got {choice.finish_reason!r}. "
        f"content={choice.message.content!r}"
    )

378
    hf_prompt_tokens = get_hf_prompt_tokens(model_name, content_text, image_url)
379
    expected_usage = openai.types.CompletionUsage(
pansicheng's avatar
pansicheng committed
380
381
        completion_tokens=max_completion_tokens,
        prompt_tokens=hf_prompt_tokens,
382
383
        total_tokens=hf_prompt_tokens + max_completion_tokens,
    )
384
385
386
    assert chat_completion.usage == expected_usage, (
        f"Usage mismatch: got {chat_completion.usage!r}, expected {expected_usage!r}"
    )
387
388

    message = choice.message
389
390
391
392
393
394
395
    assert message.content is not None and len(message.content) >= 10, (
        f"Expected content with >=10 chars, got {message.content!r}"
    )
    assert message.role == "assistant", (
        f"Expected role='assistant', got {message.role!r}"
    )

396
397
398
399
    messages.append({"role": "assistant", "content": message.content})

    # test multi-turn dialogue
    messages.append({"role": "user", "content": "express your result in json"})
400
401
402
403
404
    await complete_and_check(
        client,
        model_name,
        messages,
        context=f"multi-turn base64 follow-up for {raw_image_url}",
405
        max_completion_tokens=10,
406
        temperature=0.0,
407
408
409
    )


410
411
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
412
@pytest.mark.parametrize("image_idx", list(range(len(TEST_IMAGE_ASSETS))))
413
async def test_single_chat_session_image_base64encoded_beamsearch(
414
415
416
    client: openai.AsyncOpenAI,
    model_name: str,
    image_idx: int,
417
    url_encoded_image: dict[str, str],
418
):
419
    # NOTE: This test validates that we pass MM data through beam search
420
    raw_image_url = TEST_IMAGE_ASSETS[image_idx]
421
    required_terms = REQUIRED_BEAM_SEARCH_TERMS[image_idx]
422

423
    messages = dummy_messages_from_image_url(url_encoded_image[raw_image_url])
424

425
426
427
428
    chat_completion = await client.chat.completions.create(
        model=model_name,
        messages=messages,
        n=2,
429
        max_completion_tokens=10,
430
        temperature=0.0,
431
432
        extra_body=dict(use_beam_search=True),
    )
433
434
435
436
    assert len(chat_completion.choices) == 2, (
        f"Expected 2 beam search choices for image {image_idx} "
        f"({raw_image_url}), got {len(chat_completion.choices)}"
    )
437
438
439
440
441
442
443
444
445
446
447

    # Verify beam search produces two different non-empty outputs
    content_0 = chat_completion.choices[0].message.content
    content_1 = chat_completion.choices[1].message.content

    # Emit beam search outputs for debugging
    print(
        f"Beam search outputs for image {image_idx} ({raw_image_url}): "
        f"Output 0: {content_0!r}, Output 1: {content_1!r}"
    )

448
449
450
451
452
453
454
455
456
457
458
459
460
    assert content_0, (
        f"First beam output is empty for image {image_idx} ({raw_image_url}). "
        f"finish_reason={chat_completion.choices[0].finish_reason!r}"
    )
    assert content_1, (
        f"Second beam output is empty for image {image_idx} "
        f"({raw_image_url}). "
        f"finish_reason={chat_completion.choices[1].finish_reason!r}"
    )
    assert content_0 != content_1, (
        f"Beam search produced identical outputs for image {image_idx} "
        f"({raw_image_url}): {content_0!r}"
    )
461
462
463

    # Verify each output contains the required terms for this image
    for i, content in enumerate([content_0, content_1]):
464
465
466
467
468
469
        assert check_output_matches_terms(content, required_terms), (
            f"Beam output {i} for image {image_idx} ({raw_image_url}) "
            f"doesn't match required terms.\n"
            f"  content: {content!r}\n"
            f"  required (all groups, >=1 per group): {required_terms}"
        )
470
471


472
473
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
474
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
475
476
477
async def test_chat_streaming_image(
    client: openai.AsyncOpenAI, model_name: str, image_url: str
):
478
    messages = dummy_messages_from_image_url(image_url)
479
480
481
482
483

    # test single completion
    chat_completion = await client.chat.completions.create(
        model=model_name,
        messages=messages,
484
        max_completion_tokens=10,
485
486
487
488
489
490
491
492
493
        temperature=0.0,
    )
    output = chat_completion.choices[0].message.content
    stop_reason = chat_completion.choices[0].finish_reason

    # test streaming
    stream = await client.chat.completions.create(
        model=model_name,
        messages=messages,
494
        max_completion_tokens=10,
495
496
497
        temperature=0.0,
        stream=True,
    )
498
    chunks: list[str] = []
499
500
501
502
    finish_reason_count = 0
    async for chunk in stream:
        delta = chunk.choices[0].delta
        if delta.role:
503
504
505
            assert delta.role == "assistant", (
                f"Expected role='assistant' in stream delta, got {delta.role!r}"
            )
506
507
508
509
510
        if delta.content:
            chunks.append(delta.content)
        if chunk.choices[0].finish_reason is not None:
            finish_reason_count += 1
    # finish reason should only return in last block
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
    assert finish_reason_count == 1, (
        f"Expected exactly 1 finish_reason across stream chunks, "
        f"got {finish_reason_count}"
    )
    assert chunk.choices[0].finish_reason == stop_reason, (
        f"Stream finish_reason={chunk.choices[0].finish_reason!r} "
        f"doesn't match non-stream finish_reason={stop_reason!r}"
    )

    streamed_text = "".join(chunks)
    assert streamed_text == output, (
        f"Streamed output doesn't match non-streamed for {image_url}.\n"
        f"  streamed:     {streamed_text!r}\n"
        f"  non-streamed: {output!r}"
    )
526
527
528
529


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
530
531
@pytest.mark.parametrize(
    "image_urls",
532
    [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
533
534
535
536
537
    indirect=True,
)
async def test_multi_image_input(
    client: openai.AsyncOpenAI, model_name: str, image_urls: list[str]
):
538
    messages = dummy_messages_from_image_url(image_urls)
539

540
541
542
543
544
    if len(image_urls) > MAXIMUM_IMAGES:
        with pytest.raises(openai.BadRequestError):  # test multi-image input
            await client.chat.completions.create(
                model=model_name,
                messages=messages,
545
                max_completion_tokens=10,
546
547
548
549
550
551
552
553
554
555
                temperature=0.0,
            )

        # the server should still work afterwards
        completion = await client.completions.create(
            model=model_name,
            prompt=[0, 0, 0, 0, 0],
            max_tokens=5,
            temperature=0.0,
        )
556
557
558
559
        assert completion.choices[0].text is not None, (
            "Server failed to produce output after rejecting over-limit "
            "multi-image request"
        )
560
    else:
561
562
563
564
565
        await complete_and_check(
            client,
            model_name,
            messages,
            context=f"multi-image input ({len(image_urls)} images)",
566
            max_completion_tokens=10,
567
568
            temperature=0.0,
        )
569
570
571
572
573
574
575


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
@pytest.mark.parametrize(
    "image_urls",
    [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
576
577
    indirect=True,
)
578
579
580
581
582
583
async def test_completions_with_image(
    client: openai.AsyncOpenAI,
    model_name: str,
    image_urls: list[str],
):
    for image_url in image_urls:
584
585
586
587
588
589
        messages = describe_image_messages(image_url)
        await complete_and_check(
            client,
            model_name,
            messages,
            context=f"completions_with_image url={image_url}",
590
591
592
593
594
595
596
597
        )


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
@pytest.mark.parametrize(
    "image_urls",
    [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
598
599
    indirect=True,
)
600
601
602
603
604
605
async def test_completions_with_image_with_uuid(
    client: openai.AsyncOpenAI,
    model_name: str,
    image_urls: list[str],
):
    for image_url in image_urls:
606
607
608
        messages = describe_image_messages(
            image_url,
            extra_image_fields={"uuid": image_url},
609
        )
610
611
612
613
614
        await complete_and_check(
            client,
            model_name,
            messages,
            context=f"uuid first request url={image_url}",
615
        )
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631

        cached_messages: list[dict] = [
            {"role": "system", "content": "You are a helpful assistant."},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe this image."},
                    {"type": "image_url", "image_url": {}, "uuid": image_url},
                ],
            },
        ]
        await complete_and_check(
            client,
            model_name,
            cached_messages,
            context=f"uuid cached (empty image) uuid={image_url}",
632
        )
633
634
635
636
637
638
639
640
641


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_completions_with_empty_image_with_uuid_without_cache_hit(
    client: openai.AsyncOpenAI,
    model_name: str,
):
    with pytest.raises(openai.BadRequestError):
642
        await client.chat.completions.create(
643
            messages=[
644
                {"role": "system", "content": "You are a helpful assistant."},
645
                {
646
                    "role": "user",
647
                    "content": [
648
                        {"type": "text", "text": "Describe this image."},
649
650
651
                        {
                            "type": "image_url",
                            "image_url": {},
652
                            "uuid": "uuid_not_previously_seen",
653
654
655
656
657
658
659
                        },
                    ],
                },
            ],
            model=model_name,
        )

660
661
662
663
664
665

@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
@pytest.mark.parametrize(
    "image_urls",
    [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
666
667
    indirect=True,
)
668
669
670
671
672
673
async def test_completions_with_image_with_incorrect_uuid_format(
    client: openai.AsyncOpenAI,
    model_name: str,
    image_urls: list[str],
):
    for image_url in image_urls:
674
675
676
677
678
679
680
681
682
683
684
685
686
687
        messages = describe_image_messages(
            image_url,
            extra_image_fields={
                "also_incorrect_uuid_key": image_url,
            },
        )
        # Inject the bad key inside image_url dict too
        messages[1]["content"][1]["image_url"]["incorrect_uuid_key"] = image_url

        await complete_and_check(
            client,
            model_name,
            messages,
            context=f"incorrect uuid format url={image_url}",
688
        )