[Bugfix] Detect OpenAI content format when message.content is passed through macro parameters (#53824)
Signed-off-by: wuhangxian <1391938827@qq.com> Signed-off-by: wuhangxian <wuhangxian@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: wuhangxian <wuhangxian@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
wuhangxian committed
Sep 10, 2026 at 04:10 UTC
56d001faf0f53c72fcedbbdd77e5418f68fe7494
2 files changed
+118
-3
tests/renderers/test_hf.py
+63
@@ -11,6 +11,7 @@ from vllm.exceptions import VLLMValidationError
11
from vllm.renderers.hf import (
12
_consolidate_system_messages,
13
_convert_developer_to_system,
14
+ _detect_content_format,
15
_detect_developer_role_support,
16
_get_hf_base_chat_template_params,
17
_template_error_reason,
@@ -361,6 +362,66 @@ def test_resolve_chat_template_kwargs_with_template_name():
362
assert "unknown_param" not in resolved
363
364
365
+@pytest.mark.parametrize(
366
+ ("chat_template", "expected_format"),
367
+ [
368
+ (
369
+ """
370
+ {% macro render_message(role, message_content) %}
371
+ {% for item in message_content %}{{ item['type'] }}{% endfor %}
372
+ {% endmacro %}
373
+ {% for message in messages %}
374
+ {{ render_message(message['role'], message['content']) }}
375
+ {% endfor %}
376
+ """,
377
+ "openai",
378
+ ),
379
+ (
380
+ """
381
+ {% macro render_message(role, message_content) %}
382
+ {% for item in message_content %}{{ item['type'] }}{% endfor %}
383
+ {% endmacro %}
384
+ {% for message in messages %}
385
+ {{ render_message(
386
+ role=message['role'],
387
+ message_content=message['content'],
388
+ ) }}
389
+ {% endfor %}
390
+ """,
391
+ "openai",
392
+ ),
393
+ (
394
+ """
395
+ {% for message in messages %}
396
+ {% for item in message['content'] %}{{ item['type'] }}{% endfor %}
397
+ {% endfor %}
398
+ """,
399
+ "openai",
400
+ ),
401
+ (
402
+ """
403
+ {% macro render_message(content) %}
404
+ {% for item in content %}{{ item['type'] }}{% endfor %}
405
+ {% endmacro %}
406
+ {% for message in messages %}
407
+ {{ render_message(content=['text only']) }}
408
+ {% endfor %}
409
+ """,
410
+ "string",
411
+ ),
412
+ (
413
+ """
414
+ {% for message in messages %}{{ message['content'] }}{% endfor %}
415
+ """,
416
+ "string",
417
+ ),
418
+ ],
419
+)
420
+def test_detect_content_format(chat_template, expected_format):
421
+ """Detect content format when content is passed through a macro."""
422
+ assert _detect_content_format(chat_template, default="string") == expected_format
423
+
424
+
425
# NOTE: Qwen2-Audio default chat template is specially defined inside
426
# processor class instead of using `tokenizer_config.json`
427
@pytest.mark.parametrize(
@@ -373,9 +434,11 @@ def test_resolve_chat_template_kwargs_with_template_name():
434
("fixie-ai/ultravox-v0_5-llama-3_2-1b", "string"),
435
("Qwen/Qwen2-Audio-7B-Instruct", "openai"),
436
("meta-llama/Llama-Guard-3-1B", "openai"),
437
+ ("XiaomiMiMo/MiMo-V2.5-Pro", "openai"),
438
],
439
)
440
def test_resolve_content_format_hf_defined(model, expected_format):
441
+ """Detect the chat template content format for built-in HF models."""
442
model_info = HF_EXAMPLE_MODELS.find_hf_info(model)
443
model_info.check_available_online(on_fail="skip")
444
vllm/renderers/hf.py
+55
-3
@@ -397,10 +397,46 @@ def _iter_nodes_assign_messages_item(root: jinja2.nodes.Node):
397
398
399
def _iter_nodes_assign_content_item(root: jinja2.nodes.Node):
400
+ """Yield loops that iterate over message content or macro-bound content."""
401
message_varnames = [
402
varname for _, varname in _iter_nodes_assign_messages_item(root)
403
]
404
405
+ # Track macro parameters that receive message.content as an argument.
406
+ # Some templates pass message.content through a macro parameter whose
407
+ # name is not literally "content".
408
+ macro_content_params_by_loop: dict[int, set[str]] = {}
409
+ loops_in_macros: set[int] = set()
410
+ for macro_node in root.find_all(jinja2.nodes.Macro):
411
+ macro_param_names = {arg.name for arg in macro_node.args}
412
+ macro_content_params: set[str] = set()
413
+ for call_node in root.find_all(jinja2.nodes.Call):
414
+ if (
415
+ isinstance(call_node.node, jinja2.nodes.Name)
416
+ and call_node.node.name == macro_node.name
417
+ ):
418
+ for i, arg in enumerate(call_node.args):
419
+ if i < len(macro_node.args) and any(
420
+ _is_var_or_elems_access(arg, varname, "content")
421
+ for varname in message_varnames
422
+ ):
423
+ macro_content_params.add(macro_node.args[i].name)
424
+ for kwarg in call_node.kwargs:
425
+ if (
426
+ isinstance(kwarg, jinja2.nodes.Keyword)
427
+ and kwarg.key in macro_param_names
428
+ and any(
429
+ _is_var_or_elems_access(kwarg.value, varname, "content")
430
+ for varname in message_varnames
431
+ )
432
+ ):
433
+ macro_content_params.add(kwarg.key)
434
+
435
+ for loop_ast in macro_node.find_all(jinja2.nodes.For):
436
+ loops_in_macros.add(id(loop_ast))
437
+ if macro_content_params:
438
+ macro_content_params_by_loop[id(loop_ast)] = macro_content_params
439
+
440
# Search for {%- for content in message['content'] -%} loops
441
# or {%- for item in content -%} loops
442
for loop_ast in root.find_all(jinja2.nodes.For):
@@ -412,10 +448,26 @@ def _iter_nodes_assign_content_item(root: jinja2.nodes.Node):
448
assert isinstance(loop_target, jinja2.nodes.Name)
449
yield loop_ast, loop_target.name
450
break
451
+ else:
452
+ macro_content_params_for_loop = macro_content_params_by_loop.get(
453
+ id(loop_ast)
454
+ )
455
+ if (
456
+ isinstance(loop_iter, jinja2.nodes.Name)
457
+ and macro_content_params_for_loop is not None
458
+ and loop_iter.name in macro_content_params_for_loop
459
+ ):
460
+ assert isinstance(loop_target, jinja2.nodes.Name)
461
+ yield loop_ast, loop_target.name
462
+ continue
463
416
- if isinstance(loop_iter, jinja2.nodes.Name) and loop_iter.name == "content":
417
- assert isinstance(loop_target, jinja2.nodes.Name)
418
- yield loop_ast, loop_target.name
464
+ if (
465
+ id(loop_ast) not in loops_in_macros
466
+ and isinstance(loop_iter, jinja2.nodes.Name)
467
+ and loop_iter.name == "content"
468
+ ):
469
+ assert isinstance(loop_target, jinja2.nodes.Name)
470
+ yield loop_ast, loop_target.name
471
472
473
def _try_extract_ast(chat_template: str) -> jinja2.nodes.Template | None: