[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: