[Bugfix][MiniCPM-o] Align structured chat content with native omni rendering (#7344)

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 authored and GitHub committed 2026-09-10 11:38:11 +08:00
1 parent 4b8dcac681
commit 333f14e97e
7 files changed
+204 -11

No files matched your search

+1
View File
@@ -155,6 +155,7 @@ steps:
source_file_dependencies:
- tests/e2e/offline_inference/test_minicpmo_4_5.py
- tests/e2e/online_serving/test_minicpmo_4_5.py
- vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
- vllm_omni/model_executor/models/minicpmo_4_5/
- vllm_omni/model_executor/models/step_audio2/
- vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
+1
View File
@@ -188,6 +188,7 @@ steps:
- label: "Omni · MiniCPM-o 4.5 Online Test"
source_file_dependencies:
- tests/e2e/online_serving/test_minicpmo_4_5.py
- vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
- vllm_omni/model_executor/models/minicpmo_4_5/
- vllm_omni/model_executor/models/step_audio2/
- vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
+1 -1
View File
@@ -161,7 +161,7 @@ where = ["."]
include = ["vllm_omni*"]
[tool.setuptools.package-data]
"vllm_omni" = ["_version.py", "py.typed"]
"vllm_omni" = ["_version.py", "py.typed", "transformers_utils/chat_templates/*.jinja"]
[tool.setuptools_scm]
# no extra settings needed, presence enables setuptools-scm
+17 -4
View File
@@ -28,13 +28,13 @@ also provided.
- Default deploy configs (auto-loaded by HF `model_type=minicpmo` +
`hf_config.version="4.5"`):
- Default single-GPU compatibility layout (auto-loaded):
- Default single-GPU compatibility layout (auto-loaded):
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
- Recommended 2-GPU continuous-batching layout:
- Recommended 2-GPU continuous-batching layout:
[`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml),
- 3-GPU layout:
- 3-GPU layout:
[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml)
- 8x RTX 4090 layout:
- 8x RTX 4090 layout:
[`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml)
- Online example + Gradio demo:
[`examples/online_serving/minicpmo/`](../../examples/online_serving/minicpmo/)
@@ -126,9 +126,20 @@ video. Use the two-GPU profile for production throughput.
```bash
vllm serve openbmb/MiniCPM-o-4_5 --omni \
--trust-remote-code \
--chat-template vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja \
--chat-template-content-format openai \
--host 0.0.0.0 --port 8099
```
Run these commands from the repository root. The bundled chat template
matches the Hugging Face `chat(omni_mode=True)` content layout: media and text
parts are concatenated without adding separators, while whitespace explicitly
included in a text part is preserved. This matters for speech generation because
an inserted newline changes the Thinker condition passed to Talker. For image
questions, place the image before the question, as in the native image-chat
layout. Continue to request `use_tts_template: true` and
`enable_thinking: false` for spoken answers.
The deploy config is auto-loaded by the model registry — no
`--deploy-config` flag needed for this default single-GPU layout.
@@ -271,6 +282,8 @@ uses GPU 5. GPUs 6–7 are left free.
vllm serve openbmb/MiniCPM-o-4_5 --omni \
--deploy-config vllm_omni/deploy/minicpmo_4_5_8x4090.yaml \
--trust-remote-code \
--chat-template vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja \
--chat-template-content-format openai \
--host 0.0.0.0 --port 8099
```
+23 -6
View File
@@ -24,6 +24,9 @@ from tests.helpers.stage_config import get_deploy_config_path, modify_stage_conf
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
_MODEL = "openbmb/MiniCPM-o-4_5"
_NATIVE_CHAT_TEMPLATE = (
Path(__file__).resolve().parents[3] / "vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja"
)
_CI_DEPLOY = modify_stage_config(
get_deploy_config_path("minicpmo_4_5.yaml"),
updates={
@@ -63,7 +66,14 @@ test_params = [
model=_MODEL,
stage_config_path=_CI_DEPLOY,
use_stage_cli=False,
server_args=["--trust-remote-code", "--async-chunk"],
server_args=[
"--trust-remote-code",
"--async-chunk",
"--chat-template",
str(_NATIVE_CHAT_TEMPLATE),
"--chat-template-content-format",
"openai",
],
env_dict={
"VLLM_CONFIGURE_LOGGING": "1",
"VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH),
@@ -265,11 +275,18 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None:
Input Setting: stream=True
"""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(24, 24)['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
image_data_url=image_data_url,
content_text=get_prompt("text_image"),
)
# Match the native image-chat layout explicitly; do not rely on the
# renderer moving media placeholders ahead of text.
messages = [
get_system_prompt(),
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_data_url}},
{"type": "text", "text": get_prompt("text_image")},
],
},
]
request_config = {
"model": omni_server.model,
@@ -0,0 +1,45 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniCPM structured-content rendering must preserve native part boundaries."""
from pathlib import Path
import pytest
from jinja2 import TemplateError
from transformers.utils.chat_template_utils import _compile_jinja_template
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
TEMPLATE = Path(__file__).resolve().parents[4] / "vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja"
@pytest.mark.parametrize(
"parts,expected",
[
([{"type": "image"}, {"type": "text", "text": "Question?"}], "(<image>./</image>)Question?"),
([{"type": "text", "text": "Question?"}, {"type": "image"}], "Question?(<image>./</image>)"),
([{"type": "image"}, {"type": "text", "text": "\n\nQuestion?"}], "(<image>./</image>)\n\nQuestion?"),
([{"type": "text", "text": " A "}, {"type": "text", "text": " B "}], " A B "),
([{"type": "audio"}, {"type": "text", "text": "Transcribe."}], "(<audio>./</audio>)Transcribe."),
([{"type": "video"}, {"type": "image"}], "(<video>./</video>)(<image>./</image>)"),
("\nKeep this whitespace.\n", "\nKeep this whitespace.\n"),
],
)
def test_native_content_concatenation(parts, expected):
rendered = _compile_jinja_template(TEMPLATE.read_text()).render(
messages=[{"role": "user", "content": parts}],
add_generation_prompt=True,
enable_thinking=False,
use_tts_template=True,
)
assert rendered == (
f"<|im_start|>user\n{expected}<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n<|tts_bos|>"
)
def test_unsupported_part_is_not_silently_discarded():
with pytest.raises(TemplateError, match="Unsupported MiniCPM-o content part"):
_compile_jinja_template(TEMPLATE.read_text()).render(
messages=[{"role": "user", "content": [{"type": "unknown"}]}],
add_generation_prompt=True,
)
@@ -0,0 +1,116 @@
{# SPDX-License-Identifier: Apache-2.0
Adapted from OpenBMB/MiniCPM-o-4_5 tokenizer_config.json.
Match chat(omni_mode=True): concatenate structured content parts without
injecting separators, preserving whitespace explicitly supplied as text.
#}
{%- set normalized = namespace(messages=[]) -%}
{%- for message in messages -%}
{%- if message.content is string or message.content is none -%}
{%- set normalized.messages = normalized.messages + [message] -%}
{%- else -%}
{%- set content = namespace(text='') -%}
{%- for part in message.content -%}
{%- if part.type == 'text' -%}
{%- set content.text = content.text + part.text -%}
{%- elif part.type == 'image' -%}
{%- set content.text = content.text + '(<image>./</image>)' -%}
{%- elif part.type == 'audio' -%}
{%- set content.text = content.text + '(<audio>./</audio>)' -%}
{%- elif part.type == 'video' -%}
{%- set content.text = content.text + '(<video>./</video>)' -%}
{%- else -%}
{{- raise_exception('Unsupported MiniCPM-o content part: ' + part.type) -}}
{%- endif -%}
{%- endfor -%}
{%- set normalized.messages = normalized.messages + [dict(message, content=content.text)] -%}
{%- endif -%}
{%- endfor -%}
{%- set messages = normalized.messages -%}
{%- if tools %}
{{- '<|im_start|>system\n' }}
{%- if messages[0].role == 'system' %}
{{- messages[0].content + '\n\n' }}
{%- endif %}
{{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{{- tool | tojson }}
{%- endfor %}
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
{%- else %}
{%- if messages[0].role == 'system' %}
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endfor %}
{%- for message in messages %}
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{%- set content = message.content %}
{%- set reasoning_content = '' %}
{%- if message.reasoning_content is defined and message.reasoning_content is not none %}
{%- set reasoning_content = message.reasoning_content %}
{%- else %}
{%- if '</think>' in message.content %}
{%- set content = message.content.split('</think>')[-1].lstrip('\n') %}
{%- set reasoning_content = message.content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
{%- endif %}
{%- endif %}
{%- if loop.index0 > ns.last_query_index %}
{%- if loop.last or (not loop.last and reasoning_content) %}
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- if message.tool_calls %}
{%- for tool_call in message.tool_calls %}
{%- if (loop.first and content) or (not loop.first) %}
{{- '\n' }}
{%- endif %}
{%- if tool_call.function %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{{- '<tool_call>\n{"name": "' }}
{{- tool_call.name }}
{{- '", "arguments": ' }}
{%- if tool_call.arguments is string %}
{{- tool_call.arguments }}
{%- else %}
{{- tool_call.arguments | tojson }}
{%- endif %}
{{- '}\n</tool_call>' }}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
{{- '<|im_start|>user' }}
{%- endif %}
{{- '\n<tool_response>\n' }}
{{- message.content }}
{{- '\n</tool_response>' }}
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- endif %}
{%- if use_tts_template is defined and use_tts_template is true %}
{{- '<|tts_bos|>' }}
{%- endif %}
{%- endif -%}