[Model] Support MiniCPM-V 4.7 (#58674)

Signed-off-by: tc-mb <tianchi_cai@icloud.com>
Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
This commit is contained in:
tc-mb
2026-10-11 10:21:10 +00:00
committed by GitHub
co-authored by Cyrus Leung
parent f425a21208
commit c85616efc6
13 changed files with 2366 additions and 62 deletions
+1 -1
View File
@@ -592,7 +592,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen
| `MiDashengLMModel` | MiDashengLM | T + A<sup>+</sup> | `mispeech/midashenglm-7b` | | ✅︎ |
| `MiMoV2OmniForCausalLM` | MiMo-V2.5-Omni | T + I<sup>E+</sup> + V<sup>E+</sup> + A<sup>+</sup> | `XiaomiMiMo/MiMo-V2.5-Omni` | | ✅︎ |
| `MiniCPMO` | MiniCPM-O | T + I<sup>E+</sup> + V<sup>E+</sup> + A<sup>E+</sup> | `openbmb/MiniCPM-o-2_6`, etc. | ✅︎ | ✅︎ |
| `MiniCPMV` | MiniCPM-V | T + I<sup>E+</sup> + V<sup>E+</sup> | `openbmb/MiniCPM-V-2` (see note), `openbmb/MiniCPM-Llama3-V-2_5`, `openbmb/MiniCPM-V-2_6`, `openbmb/MiniCPM-V-4`, `openbmb/MiniCPM-V-4_5`, `openbmb/MiniCPM-V-4_6`, etc. | ✅︎ | |
| `MiniCPMV` | MiniCPM-V | T + I<sup>E+</sup> + V<sup>E+</sup> | `openbmb/MiniCPM-V-2` (see note), `openbmb/MiniCPM-Llama3-V-2_5`, `openbmb/MiniCPM-V-2_6`, `openbmb/MiniCPM-V-4`, `openbmb/MiniCPM-V-4_5`, `openbmb/MiniCPM-V-4_6`, `openbmb/MiniCPM-V-4_7`, etc. | ✅︎ | |
| `MiniMaxM3SparseForConditionalGeneration` | MiniMax-M3 | T + I<sup>+</sup> + V<sup>+</sup> | `MiniMaxAI/MiniMax-M3`, `MiniMaxAI/MiniMax-M3-MXFP8`, etc. | | ✅︎ |
| `MiniMaxVL01ForConditionalGeneration` | MiniMax-VL | T + I<sup>E+</sup> | `MiniMaxAI/MiniMax-VL-01`, etc. | | ✅︎ |
| `Mistral3ForConditionalGeneration` | Mistral3 (HF Transformers) | T + I<sup>+</sup> | `mistralai/Mistral-Small-3.1-24B-Instruct-2503`, etc. | ✅︎ | ✅︎ |
+114
View File
@@ -6,6 +6,9 @@ import torch
from vllm.model_executor.models.minicpmv import MiniCPMV2_6
from vllm.model_executor.models.minicpmv4_6 import (
MiniCPMV4_6ForConditionalGeneration,
_expand_flags,
_select_vision_items,
_vision_downsample_mode,
)
pytestmark = pytest.mark.skip_global_cleanup
@@ -54,3 +57,114 @@ def test_image_and_video_embeds_stay_in_their_own_modality():
assert torch.equal(modalities["images"]["image_embeds"], image_embeds)
assert torch.equal(modalities["videos"]["image_embeds"], video_embeds)
class TestPerItemMergerFlags:
"""The merger flag is per request but items from several requests can be
batched together, so it has to be applied per item."""
def test_expand_flags_broadcasts_a_single_value(self):
assert _expand_flags(None, 3) == [None, None, None]
assert _expand_flags(torch.tensor([True]), 3) == [True, True, True]
assert _expand_flags(True, 2) == [True, True]
def test_expand_flags_keeps_per_item_values(self):
flags = [torch.tensor([True]), torch.tensor([False])]
assert _expand_flags(flags, 2) == [True, False]
def test_expand_flags_falls_back_on_a_shape_mismatch(self):
# Too many values to map onto the items: keep one decision for the
# batch rather than failing the request.
assert _expand_flags([True, False, True], 2) == [True, True]
def test_vision_downsample_mode(self):
assert _vision_downsample_mode(None) is None
assert _vision_downsample_mode(True) == "16x"
assert _vision_downsample_mode(False) == "4x"
def test_select_vision_items_slices_by_item(self):
# 3 items holding 2, 1 and 2 slices; one channel, one patch, width 4.
pixel_values = [torch.full((1, 1, 4), float(i)) for i in range(5)]
tgt_sizes = torch.arange(10, dtype=torch.long).reshape(5, 2)
image_input = {
"pixel_values": pixel_values,
"tgt_sizes": tgt_sizes,
"num_slices": torch.tensor([2, 1, 2]),
}
selected = _select_vision_items(image_input, [2], [2, 1, 2])
assert len(selected["pixel_values"]) == 2
assert bool(selected["pixel_values"][0][0, 0, 0] == 3.0)
assert bool(selected["pixel_values"][1][0, 0, 0] == 4.0)
assert selected["num_slices"].tolist() == [2]
assert torch.equal(selected["tgt_sizes"], tgt_sizes[3:5])
def test_select_vision_items_preserves_order(self):
pixel_values = [torch.full((1, 1, 4), float(i)) for i in range(3)]
image_input = {
"pixel_values": pixel_values,
"tgt_sizes": torch.zeros(3, 2, dtype=torch.long),
"num_slices": torch.tensor([1, 1, 1]),
}
selected = _select_vision_items(image_input, [2, 0], [1, 1, 1])
assert selected["num_slices"].tolist() == [1, 1]
assert bool(selected["pixel_values"][0][0, 0, 0] == 2.0)
assert bool(selected["pixel_values"][1][0, 0, 0] == 0.0)
def test_process_vision_input_applies_flags_per_item(self):
calls: list[str | None] = []
def fake_get_vision_hidden_states(data, downsample_mode=None):
calls.append(downsample_mode)
tag = 1.0 if downsample_mode == "16x" else 0.0
return [torch.full((1,), tag) for _ in data["pixel_values"]]
model = object.__new__(MiniCPMV4_6ForConditionalGeneration)
model.get_vision_hidden_states = fake_get_vision_hidden_states
image_input = {
"type": "pixel_values",
"pixel_values": [torch.zeros(1, 1, 4) for _ in range(3)],
"tgt_sizes": torch.zeros(3, 2, dtype=torch.long),
"num_slices": torch.tensor([1, 1, 1]),
}
result = model._process_vision_input(
image_input,
use_vit_merger=[
torch.tensor([True]),
torch.tensor([False]),
torch.tensor([True]),
],
)
# Each item keeps its own mode even though they share one call.
assert [float(r[0]) for r in result] == [1.0, 0.0, 1.0]
# Mixed flags are encoded as separate groups, not one collapsed batch.
assert calls == ["16x", "4x"]
def test_process_vision_input_uses_one_group_for_uniform_flags(self):
calls: list[str | None] = []
def fake_get_vision_hidden_states(data, downsample_mode=None):
calls.append(downsample_mode)
return [torch.zeros(1) for _ in data["pixel_values"]]
model = object.__new__(MiniCPMV4_6ForConditionalGeneration)
model.get_vision_hidden_states = fake_get_vision_hidden_states
image_input = {
"type": "pixel_values",
"pixel_values": [torch.zeros(1, 1, 4) for _ in range(2)],
"tgt_sizes": torch.zeros(2, 2, dtype=torch.long),
"num_slices": torch.tensor([1, 1]),
}
model._process_vision_input(image_input, use_vit_merger=torch.tensor([True]))
# The common case still makes a single encoder call.
assert calls == ["16x"]
+310
View File
@@ -0,0 +1,310 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
from vllm.model_executor.models.minicpmv4_6 import _stack_vit_merger_qkv
from vllm.model_executor.models.minicpmv4_7 import (
MiniCPMV4_7MultiModalProcessor,
_compute_canvas_single,
build_image_bounds,
canvas_rope_delta,
)
pytestmark = pytest.mark.skip_global_cleanup
def test_canvas_mrope_sizes_survive_prefix_cache_stripping():
"""A cached image must keep `tgt_sizes`, or canvas M-RoPE falls back.
`strip_covered_mm_data` drops the payload of a prefix-cache-covered item and
keeps only CPU-side metadata. Canvas M-RoPE places slices from `tgt_sizes`,
so 4.7 marks it `keep_on_cpu` while the shared 4.6 config stays as it was.
"""
from vllm.model_executor.models.minicpmv4_6 import _minicpmv4_6_field_config
shared = _minicpmv4_6_field_config({})
assert shared["tgt_sizes"].field.keep_on_cpu is False
assert shared["video_tgt_sizes"].field.keep_on_cpu is False
processor = MiniCPMV4_7MultiModalProcessor.__new__(MiniCPMV4_7MultiModalProcessor)
fields = processor._get_mm_fields_config({}, {})
assert fields["tgt_sizes"].field.keep_on_cpu is True
assert fields["video_tgt_sizes"].field.keep_on_cpu is True
# Other fields keep their previous setting.
assert fields["pixel_values"].field.keep_on_cpu is False
assert fields["video_image_sizes"].field.keep_on_cpu is True
def test_modality_mm_kwargs_resolution():
"""Flat keys reach both modalities; scopes stay scoped, and win on conflict.
`_merge_and_resolve_mm_processor_kwargs` routes a flat key into every scope
whose HF processor declares it, so reading is a plain scope lookup. The
routing itself is upstream's, tested here against the MiniCPM-V schema to
pin the precedence rule (`scoped > flat`).
"""
from vllm.model_executor.models.minicpmv4_6 import (
_flat_processor_kwargs,
_scoped_mm_kwarg,
)
from vllm.multimodal.processing.context import _resolve_mm_processor_kwargs
# What MiniCPMV4_6{Image,Video}ProcessorKwargs declare.
processor_keys = {"downsample_mode", "max_slice_nums"}
schema = {
"text_kwargs": set(),
"images_kwargs": set(processor_keys),
"videos_kwargs": set(processor_keys),
"audio_kwargs": set(),
}
def read(mm_kwargs, modality, key):
merged = _resolve_mm_processor_kwargs(dict(mm_kwargs), schema)
return _scoped_mm_kwarg(merged, modality, key)
assert read({}, "image", "downsample_mode") is None
# A flat key is routed into both scopes.
assert read({"downsample_mode": "4x"}, "image", "downsample_mode") == "4x"
assert read({"downsample_mode": "4x"}, "video", "downsample_mode") == "4x"
# A scoped key stays in its scope.
assert (
read({"images_kwargs": {"downsample_mode": "16x"}}, "image", "downsample_mode")
== "16x"
)
assert (
read({"images_kwargs": {"downsample_mode": "16x"}}, "video", "downsample_mode")
is None
)
videos_only = {"videos_kwargs": {"max_slice_nums": 1}}
assert read(videos_only, "video", "max_slice_nums") == 1
# Scoped to videos, so image must not see it.
assert read(videos_only, "image", "max_slice_nums") is None
# An explicit scope wins over the flat key it is merged with.
assert (
read(
{"downsample_mode": "16x", "images_kwargs": {"downsample_mode": "4x"}},
"image",
"downsample_mode",
)
== "4x"
)
# A non-mapping scope is ignored rather than raising.
assert read({"images_kwargs": None}, "image", "downsample_mode") is None
# `_flat_processor_kwargs` drops the scoped keys and pins the mode.
flat = _flat_processor_kwargs(
{
"downsample_mode": "4x",
"images_kwargs": {"a": 1},
"videos_kwargs": {"b": 2},
"audio_kwargs": {"c": 3},
},
"16x",
)
assert flat == {"downsample_mode": "16x"}
def test_vit_merger_qkv_is_stacked_before_load():
mapped = list(
_stack_vit_merger_qkv(
[
(
"model.vision_tower.vit_merger.self_attn.q_proj.weight",
torch.zeros(1),
),
(
"model.vision_tower.vit_merger.self_attn.k_proj.weight",
torch.zeros(1),
),
(
"model.vision_tower.vit_merger.self_attn.v_proj.weight",
torch.zeros(1),
),
("vit_merger.self_attn.qkv_proj.weight", torch.zeros(1)),
]
)
)
names = [name for name, _ in mapped]
shards = [getattr(tensor, "shard_id", None) for _, tensor in mapped]
assert names == [
"model.vision_tower.vit_merger.self_attn.qkv_proj.weight",
"model.vision_tower.vit_merger.self_attn.qkv_proj.weight",
"model.vision_tower.vit_merger.self_attn.qkv_proj.weight",
"vit_merger.self_attn.qkv_proj.weight",
]
assert shards == ["q", "k", "v", None]
# Canvas M-RoPE goldens, cross-checked against the reference implementation in
# transformers PR 48979 (`modular_minicpmv4_7.py`). Synthetic ids keep the token
# sequences readable: the canvas math only compares ids for equality.
_IM_START = 10
_IM_END = 11
_SLICE_START = 12
_SLICE_END = 13
_NEWLINE = 14
_IMAGE_PAD = 20
_VIDEO_PAD = 21
_TEXT = 100
_CANVAS_IDS = {
"im_start_id": _IM_START,
"im_end_id": _IM_END,
"slice_start_id": _SLICE_START,
"slice_end_id": _SLICE_END,
"newline_id": _NEWLINE,
}
def _canvas_positions(tokens, target_sizes):
input_ids = torch.tensor([tokens], dtype=torch.long)
targets = torch.tensor(target_sizes, dtype=torch.long)
bounds = build_image_bounds(input_ids[0], _CANVAS_IDS)
positions = _compute_canvas_single(
input_ids[0],
torch.arange(len(tokens), dtype=torch.long),
bounds,
targets,
_CANVAS_IDS,
)
return positions.tolist(), canvas_rope_delta(positions, len(tokens))
def test_canvas_mrope_trailing_newline_stays_inside_the_canvas():
"""A newline right after `</image>` belongs to the canvas, not to the text.
The chat template joins content parts with ``\\n``, so an image followed by
text always leaves one there. The transformers reference closes a frame over
every marker including newlines; leaving it out pushes every later position
one step forward and makes `rope_delta` differ by one.
"""
tokens = [_TEXT, _IM_START, _IMAGE_PAD, _IM_END, _NEWLINE, _TEXT, _TEXT]
positions, delta = _canvas_positions(tokens, [[4, 4]])
assert positions == [
[0, 1, 1, 1, 1, 3, 4],
[0, 0, 1, 2, 1, 3, 4],
[0, 0, 1, 2, 1, 3, 4],
]
assert delta == -2
def test_canvas_mrope_thumbnail_matches_reference():
# <text> <image> PAD </image> <text>; 16x turns a 4x4 patch grid into 1 token.
tokens = [_TEXT, _IM_START, _IMAGE_PAD, _IM_END, _TEXT]
positions, delta = _canvas_positions(tokens, [[4, 4]])
assert positions == [
[0, 1, 1, 1, 3],
[0, 0, 1, 2, 3],
[0, 0, 1, 2, 3],
]
assert delta == -1
def test_canvas_mrope_slice_row_matches_reference():
# Thumbnail then a 1x2 slice row; the slices tile the thumbnail's canvas.
tokens = [
_TEXT,
_IM_START,
_IMAGE_PAD,
_IM_END,
_SLICE_START,
_IMAGE_PAD,
_SLICE_END,
_SLICE_START,
_IMAGE_PAD,
_SLICE_END,
_TEXT,
]
positions, delta = _canvas_positions(tokens, [[4, 4], [4, 4], [4, 4]])
assert positions == [
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4],
[0, 0, 1, 2, 1, 1, 1, 1, 1, 1, 4],
[0, 0, 1, 3, 1, 1, 1, 2, 2, 2, 4],
]
assert delta == -6
def test_canvas_mrope_resamples_thumbnail_over_canvas():
# A 8x8 patch grid is 2x2 LLM tokens, so the canvas is resampled (not arange).
tokens = [_TEXT, _IM_START] + [_IMAGE_PAD] * 4 + [_IM_END, _TEXT]
positions, delta = _canvas_positions(tokens, [[8, 8]])
assert positions == [
[0, 1, 1, 1, 1, 1, 1, 4],
[0, 0, 1, 1, 2, 2, 3, 4],
[0, 0, 1, 2, 1, 2, 3, 4],
]
assert delta == -3
def test_canvas_mrope_video_frames_share_one_canvas():
tokens = [
_TEXT,
_IM_START,
_VIDEO_PAD,
_IM_END,
_IM_START,
_VIDEO_PAD,
_IM_END,
_TEXT,
]
positions, delta = _canvas_positions(tokens, [[4, 4], [4, 4]])
assert positions == [
[0, 1, 1, 1, 3, 3, 3, 5],
[0, 0, 1, 2, 2, 3, 4, 5],
[0, 0, 1, 2, 2, 3, 4, 5],
]
assert delta == -2
def test_canvas_mrope_interleaved_image_and_video():
tokens = (
[_TEXT, _IM_START]
+ [_IMAGE_PAD] * 4
+ [_IM_END, _IM_START]
+ [_VIDEO_PAD] * 4
+ [_IM_END, _TEXT]
)
positions, delta = _canvas_positions(tokens, [[8, 8], [8, 8]])
assert positions == [
[0, 1, 1, 1, 1, 1, 1, 4, 4, 4, 4, 4, 4, 7],
[0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 7],
[0, 0, 1, 2, 1, 2, 3, 3, 4, 5, 4, 5, 6, 7],
]
assert delta == -6
def test_canvas_span_is_shared_by_image_and_video():
"""A video frame and a same-geometry image must place identical offsets.
Both paths go through `_assign_canvas_span`; this pins that they cannot
drift apart now that the duplicated geometry is gone.
"""
thumb = [_IM_START] + [_IMAGE_PAD] * 4 + [_IM_END]
img_pos, _ = _canvas_positions([_TEXT, *thumb, _TEXT], [[8, 8]])
vid_pos, _ = _canvas_positions([_TEXT, *thumb, *thumb, _TEXT], [[8, 8], [8, 8]])
def offsets(pos, start):
# The <im_start> halo is one below the canvas base.
base = pos[1][start] + 1
return [[c[start + k] - base for k in range(3)] for c in pos]
assert offsets(img_pos, 0) == offsets(vid_pos, 0)
def test_canvas_mrope_delta_leaves_room_for_the_first_decoded_token():
# First decoded token must land on max_prefill_position + 1, matching
# transformers and the other native M-RoPE models.
tokens = [_TEXT, _IM_START, _IMAGE_PAD, _IM_END, _TEXT]
positions, delta = _canvas_positions(tokens, [[4, 4]])
max_position = max(max(channel) for channel in positions)
assert delta == max_position + 1 - len(tokens)
+5
View File
@@ -1118,6 +1118,11 @@ _MULTIMODAL_EXAMPLE_MODELS = {
"MiniCPMV4_6ForConditionalGeneration": _HfExamplesInfo(
"openbmb/MiniCPM-V-4_6",
),
"MiniCPMV4_7ForConditionalGeneration": _HfExamplesInfo(
"openbmb/MiniCPM-V-4_7",
min_transformers_version="5.18.0",
is_available_online=False,
),
"MiniMaxM3SparseForConditionalGeneration": _HfExamplesInfo(
"MiniMaxAI/MiniMax-M3",
trust_remote_code=True,
@@ -0,0 +1,459 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the MiniCPM-V family parser."""
import pytest
from tests.parser.engine.conftest import make_mock_tokenizer
from tests.parser.engine.streaming_helpers import simulate_reasoning_streaming
from vllm.entrypoints.generate.base.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
ChatCompletionToolsParam,
)
from vllm.parser.minicpmv import (
EscapedNewlineNormalizer,
MiniCPMVOutputNormalizer,
recover_newlines,
)
from vllm.parser.parser_manager import ParserManager
from vllm.reasoning import ReasoningParserManager
from vllm.tool_parsers import ToolParserManager
_VOCAB = {
"<think>": 10,
"</think>": 11,
"<|im_end|>": 12,
"<tool_call>": 13,
"</tool_call>": 14,
}
_TOOLS = [
ChatCompletionToolsParam(
type="function",
function={
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
)
]
@pytest.fixture
def mock_tokenizer():
return make_mock_tokenizer(_VOCAB)
@pytest.fixture
def request_obj():
return ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
def _make_parser(tokenizer, *, enable_thinking=None, tools=None):
"""Build the parser the serving stack resolves for MiniCPM-V.
Everything is built through ``ParserManager`` so the tests exercise the
``DelegatingParser`` the server actually drives, not the parser engine on
its own. Tool calling goes through ``qwen3_coder``: MiniCPM-V emits that
XML syntax verbatim and has no tool parser of its own.
"""
parser_cls = ParserManager.get_parser(
tool_parser_name="qwen3_coder" if tools else None,
reasoning_parser_name="minicpmv",
enable_auto_tools=bool(tools),
)
assert parser_cls is not None
kwargs = {}
if enable_thinking is not None:
kwargs["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
return parser_cls(tokenizer, tools, **kwargs)
def test_reasoning_parser_is_registered():
parser_cls = ReasoningParserManager.get_reasoning_parser("minicpmv")
assert parser_cls.__name__ == "MiniCPMVParserReasoningAdapter"
def test_thinking_defaults_to_the_chat_template(mock_tokenizer):
"""Without `enable_thinking`, the grammar must wait for reasoning.
The chat template ends the assistant turn with `<think>` unless the flag is
explicitly false, so the model reasons by default. This is the flag
`serving.py` latches into the structured-output gate, so a non-thinking
default both disables the grammar and leaks the thinking into `content`.
"""
def reasoning_pending(**kwargs):
return not _make_parser(mock_tokenizer, **kwargs).is_reasoning_end([1, 2])
assert reasoning_pending() is True # default matches the template
assert reasoning_pending(enable_thinking=True) is True
assert reasoning_pending(enable_thinking=False) is False
def test_no_minicpmv_tool_parser_is_registered():
"""MiniCPM-V deliberately registers no tool parser.
The family emits the Qwen3-Coder XML syntax verbatim, so
``--tool-call-parser qwen3_coder`` already covers it. Registering a
``minicpmv`` tool parser would duplicate that name for no parsing gain.
"""
with pytest.raises(KeyError):
ToolParserManager.get_tool_parser("minicpmv")
def test_composes_with_qwen3_coder_tool_parser(mock_tokenizer):
"""`--tool-call-parser qwen3_coder` must work through the serving Parser."""
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
tools=_TOOLS,
)
parser_cls = ParserManager.get_parser(
tool_parser_name="qwen3_coder",
reasoning_parser_name="minicpmv",
enable_auto_tools=True,
)
assert parser_cls is not None
parser = parser_cls(mock_tokenizer, request.tools)
_, _, tool_calls = parser.parse(
"<tool_call><function=get_weather>"
"<parameter=city>SF</parameter></function></tool_call>",
request,
enable_auto_tools=True,
)
assert tool_calls is not None
assert tool_calls[0].name == "get_weather"
assert "SF" in tool_calls[0].arguments
class TestNonThinking:
@pytest.fixture
def parser(self, mock_tokenizer):
return _make_parser(mock_tokenizer, enable_thinking=False)
def test_plain_content_is_unchanged(self, parser):
reasoning, content = parser.extract_reasoning("The answer is 42.", None)
assert reasoning is None
assert content == "The answer is 42."
def test_reserved_markers_are_removed(self, parser):
output = (
"<reserved_12>reasoning<reserved_13><|reserved_14|>tool call<|reserved_15|>"
)
reasoning, content = parser.extract_reasoning(output, None)
assert reasoning is None
assert content == "reasoningtool call"
def test_unrelated_reserved_text_is_unchanged(self, parser):
reasoning, content = parser.extract_reasoning(
"<reserved_11>content<|reserved_16|>",
None,
)
assert reasoning is None
assert content == "<reserved_11>content<|reserved_16|>"
def test_dangling_standard_think_end_is_removed(self, parser):
output = "first part </think>\n\nfinal part"
reasoning, content = parser.extract_reasoning(output, None)
assert reasoning is None
assert content == "first part \n\nfinal part"
def test_truncated_standard_reasoning_does_not_leak(self, parser):
reasoning, content = parser.extract_reasoning(
"prefix<think>private reasoning",
None,
)
assert reasoning is None
assert content == "prefix"
def test_streaming_split_standard_markers(self, parser):
reasoning, content = simulate_reasoning_streaming(
parser,
[
"<",
"think>",
"private reasoning",
"</",
"think>",
"final answer",
],
)
assert reasoning == ""
assert content == "final answer"
def test_streaming_split_reserved_markers(self, parser):
reasoning, content = simulate_reasoning_streaming(
parser,
[
"<reser",
"ved_12>",
"reasoning",
"<|reserved",
"_13|>",
"final answer",
],
)
assert reasoning == ""
assert content == "reasoningfinal answer"
def test_tool_calls_are_parsed(self, mock_tokenizer):
"""Thinking is off by default, and tool calls must still be parsed."""
parser = _make_parser(mock_tokenizer, enable_thinking=False, tools=_TOOLS)
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
tools=_TOOLS,
)
reasoning, content, tool_calls = parser.parse(
"<tool_call><function=get_weather>"
"<parameter=city>SF</parameter></function></tool_call>",
request,
enable_auto_tools=True,
)
assert reasoning is None
assert not content
assert tool_calls is not None
assert tool_calls[0].name == "get_weather"
assert "SF" in tool_calls[0].arguments
def test_streaming_tool_calls_are_parsed(self, mock_tokenizer):
"""Regression: tool calls must stream when thinking is explicitly off.
The reasoning parser used to report ``reasoning_ended=False`` in
non-thinking mode, which kept the delegating parser in its reasoning
phase and streamed the raw ``<tool_call>`` XML to the client as content.
"""
parser = _make_parser(mock_tokenizer, enable_thinking=False, tools=_TOOLS)
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
tools=_TOOLS,
)
delta = parser.parse_delta(
"<tool_call><function=get_weather>"
"<parameter=city>SF</parameter></function></tool_call>",
[],
request,
prompt_token_ids=[],
finished=True,
)
assert delta is not None
assert delta.tool_calls
assert delta.tool_calls[0].function is not None
assert delta.tool_calls[0].function.name == "get_weather"
assert "SF" in (delta.tool_calls[0].function.arguments or "")
assert not delta.content
class TestThinking:
def test_standard_think_tags_are_parsed(self, mock_tokenizer):
parser = _make_parser(mock_tokenizer, enable_thinking=True)
reasoning, content = parser.extract_reasoning(
"<think>private reasoning</think>final answer",
None,
)
assert reasoning == "private reasoning"
assert content == "final answer"
def test_reserved_markers_are_removed(self, mock_tokenizer):
parser = _make_parser(mock_tokenizer, enable_thinking=True)
reasoning, content = parser.extract_reasoning(
"<think><reserved_12>private reasoning</think><|reserved_13|>final answer",
None,
)
assert reasoning == "private reasoning"
assert content == "final answer"
def test_streaming_im_end_is_removed(self):
tokenizer = make_mock_tokenizer(_VOCAB, special_tokens=[])
parser = _make_parser(tokenizer, enable_thinking=True)
reasoning, content = simulate_reasoning_streaming(
parser,
["Answer: 111", "<|im_end|>"],
)
assert reasoning == "Answer: 111"
assert content == ""
class TestNewlineRecovery:
"""The tokenizer writes a newline as the two characters ``\\`` and ``n``."""
def test_recover_newlines(self):
text = (
"first\\nsecond\\rthird\\r\\nfourth "
"`code\\nvalue` "
"```python\\nvalue = '\\\\n'\\n``` "
"$x\\ny$ $$x\\ny$$ \\(x\\ny\\) \\[x\\ny\\] "
"escaped \\\\n"
)
assert recover_newlines(text) == (
"first\nsecond\nthird\nfourth "
"`code\\nvalue` "
"```python\\nvalue = '\\\\n'\\n``` "
"$x\\ny$ $$x\\ny$$ \\(x\\ny\\) \\[x\\ny\\] "
"escaped \\\\n"
)
def test_streaming_matches_complete_recovery(self):
text = (
"first\\nsecond `code\\nvalue` $x\\ny$ ```text\\nvalue\\n``` last\\r\\nline"
)
expected = recover_newlines(text)
for split in range(len(text) + 1):
normalizer = EscapedNewlineNormalizer()
actual = normalizer.feed(text[:split])
actual += normalizer.feed(text[split:], final=True)
assert actual == expected
normalizer = EscapedNewlineNormalizer()
actual = "".join(normalizer.feed(char) for char in text)
actual += normalizer.feed("", final=True)
assert actual == expected
def test_backslash_runs_stay_consistent_across_splits(self):
# A run of backslashes can be split over chunks. Its parity decides
# whether a following `n` is a newline, so streaming and non-streaming
# must agree for every split point.
for count in range(1, 7):
text = "\\" * count + "n"
expected = recover_newlines(text)
for split in range(len(text) + 1):
normalizer = EscapedNewlineNormalizer()
actual = normalizer.feed(text[:split])
actual += normalizer.feed(text[split:], final=True)
assert actual == expected, (count, split)
def test_streaming_recovers_reasoning_and_content(self):
normalizer = MiniCPMVOutputNormalizer()
first = normalizer.normalize_delta(
DeltaMessage(reasoning="reason\\"),
finished=False,
)
second = normalizer.normalize_delta(
DeltaMessage(reasoning="nnext\\", content="answer\\"),
finished=False,
)
final = normalizer.normalize_delta(
DeltaMessage(content="nend"),
finished=True,
)
assert first == DeltaMessage(reasoning="reason")
assert second == DeltaMessage(reasoning="\nnext\\", content="answer")
assert final == DeltaMessage(content="\nend")
def test_streaming_does_not_modify_tool_arguments(self):
normalizer = MiniCPMVOutputNormalizer()
arguments = '{"value":"first\\\\nsecond"}'
delta = DeltaMessage(
tool_calls=[
DeltaToolCall(
index=0,
function=DeltaFunctionCall(arguments=arguments),
)
]
)
result = normalizer.normalize_delta(delta, finished=True)
assert result is not None
assert result.tool_calls[0].function is not None
assert result.tool_calls[0].function.arguments == arguments
def test_parse_recovers_content(self, mock_tokenizer, request_obj):
parser = _make_parser(mock_tokenizer, enable_thinking=False)
reasoning, content, tool_calls = parser.parse(
"first\\nsecond",
request_obj,
)
assert reasoning is None
assert content == "first\nsecond"
assert not tool_calls
def test_parse_delta_recovers_content(self, mock_tokenizer, request_obj):
parser = _make_parser(mock_tokenizer, enable_thinking=False)
chunk = parser.parse_delta(
"first\\",
[],
request_obj,
finished=False,
)
rest = parser.parse_delta(
"nsecond",
[],
request_obj,
finished=True,
)
# The trailing half of the escape is held back until it is complete.
assert chunk is not None
assert chunk.content == "first"
assert rest is not None
assert rest.content == "\nsecond"
def test_buffered_delta_is_dropped_not_sent_empty(
self, mock_tokenizer, request_obj
):
parser = _make_parser(mock_tokenizer, enable_thinking=False)
parser.parse_delta("hello", [], request_obj, finished=False)
# The lone backtick is held back for the next chunk. Reporting it as an
# all-None DeltaMessage would reach the client as an empty SSE chunk.
assert parser.parse_delta("`", [], request_obj, finished=False) is None
def test_normalizer_drops_empty_delta(self):
normalizer = MiniCPMVOutputNormalizer()
assert (
normalizer.normalize_delta(DeltaMessage(content="`"), finished=False)
is None
)
def test_normalizer_flushes_buffered_content_when_finished(self):
normalizer = MiniCPMVOutputNormalizer()
normalizer.normalize_delta(DeltaMessage(content="`"), finished=False)
final = normalizer.normalize_delta(DeltaMessage(), finished=True)
assert final is not None
assert final.content == "`"
+270 -61
View File
@@ -45,6 +45,7 @@ from .interfaces import (
HasInnerState,
IsHybrid,
MultiModalEmbeddings,
SupportsEagle3,
SupportsLoRA,
SupportsMRoPE,
SupportsMultiModal,
@@ -87,35 +88,186 @@ def _minicpmv4_6_field_config(hf_inputs: Mapping[str, torch.Tensor]):
fields["use_vit_merger"] = MultiModalFieldConfig.batched(
"image", keep_on_cpu=True
)
if "video_use_vit_merger" in hf_inputs:
fields["video_use_vit_merger"] = MultiModalFieldConfig.batched(
"video", keep_on_cpu=True
)
return fields
def _expand_flags(value: object, num_items: int) -> list[bool | None]:
"""Broadcast a batched per-item flag to exactly one entry per item."""
if value is None:
return [None] * num_items
raw: list[object]
if isinstance(value, torch.Tensor):
raw = value.reshape(-1).tolist()
elif isinstance(value, list | tuple):
raw = []
for item in value:
if isinstance(item, torch.Tensor):
raw.extend(item.reshape(-1).tolist())
else:
raw.append(item)
else:
raw = [value]
if len(raw) == num_items:
return [bool(v) for v in raw]
if len(raw) == 1:
return [bool(raw[0])] * num_items
# Unexpected shape: keep one decision for the whole batch instead of
# failing the request.
return [any(bool(v) for v in raw)] * num_items
def _vision_downsample_mode(flag: bool | None) -> str | None:
if flag is None:
return None
return "16x" if flag else "4x"
def _select_vision_items(
image_input: MiniCPMVImagePixelInputs,
item_indices: list[int],
num_slices: list[int],
) -> MiniCPMVImagePixelInputs:
"""Slice a vision input down to the given items, keeping their order."""
offsets = [0]
for n in num_slices:
offsets.append(offsets[-1] + n)
pixel_values: list[torch.Tensor] = []
tgt_sizes: list[torch.Tensor] = []
sizes: list[int] = []
for i in item_indices:
start, end = offsets[i], offsets[i + 1]
pixel_values.extend(image_input["pixel_values"][start:end])
tgt_sizes.append(image_input["tgt_sizes"][start:end])
sizes.append(end - start)
return MiniCPMVImagePixelInputs(
type="pixel_values",
pixel_values=pixel_values,
tgt_sizes=torch.cat(tgt_sizes, dim=0),
num_slices=torch.tensor(sizes),
)
# HF-style modality-scoped keys inside `mm_processor_kwargs`. They scope a value
# to a single modality and must not be forwarded to the processors, which take
# flat arguments only.
_MODALITY_SCOPED_MM_KWARGS = ("images_kwargs", "videos_kwargs", "audio_kwargs")
def _flat_processor_kwargs(
mm_kwargs: Mapping[str, object],
downsample_mode: str,
) -> dict[str, object]:
"""Drop the modality-scoped nesting and pin the resolved downsample mode.
The placeholder count and the vision tower must read the same mode, so the
value resolved for this modality is passed down explicitly rather than
leaving the processor to fall back to its own default.
"""
kwargs = {
key: value
for key, value in mm_kwargs.items()
if key not in _MODALITY_SCOPED_MM_KWARGS
}
kwargs["downsample_mode"] = downsample_mode
return kwargs
_MODALITY_TO_MM_KWARGS_SCOPE = {
"image": "images_kwargs",
"video": "videos_kwargs",
}
def _scoped_mm_kwarg(
merged: Mapping[str, object],
modality: str,
key: str,
) -> Any:
"""Read a processor kwarg for one modality.
``merged`` is what ``_merge_and_resolve_mm_processor_kwargs`` returns: it
routes a flat key into every scope whose processor declares it, and a value
scoped to the other modality stays there, so it is not visible here.
"""
scoped = merged.get(_MODALITY_TO_MM_KWARGS_SCOPE[modality])
if isinstance(scoped, Mapping):
return scoped.get(key)
return None
class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
def _resolve_downsample_mode(
self,
mm_kwargs: Mapping[str, object],
modality: str = "image",
) -> str:
ds = mm_kwargs.get("downsample_mode")
if ds is not None:
return str(ds)
# A flat `downsample_mode` applies to both modalities; the nested
# `images_kwargs` / `videos_kwargs` forms scope it to one, matching
# MiniCPMV4_6Processor.
info = self.info
assert isinstance(info, MiniCPMV4_6ProcessingInfo)
merged = info._merge_and_resolve_mm_processor_kwargs(mm_kwargs)
downsample_mode = _scoped_mm_kwarg(merged, modality, "downsample_mode")
if downsample_mode is not None:
return str(downsample_mode)
return info._get_downsample_mode()
def _resolve_max_slice_nums(
self,
mm_kwargs: Mapping[str, object],
modality: str = "image",
) -> int | None:
# Per-request max_slice_nums. None keeps the processor default.
# Resolved like `downsample_mode` so the nested per-modality form
# (`images_kwargs` / `videos_kwargs`) is honoured too. This must match
# what `process_images` passes to the image processor: the placeholder
# count and the visual token count are derived from it separately, so
# they diverge (and the engine aborts) if the two sides read different
# values.
info = self.info
assert isinstance(info, MiniCPMV4_6ProcessingInfo)
merged = info._merge_and_resolve_mm_processor_kwargs(mm_kwargs)
max_slice = _scoped_mm_kwarg(merged, modality, "max_slice_nums")
if max_slice is None:
return None
return int(max_slice)
def get_image_prompt_texts(
self,
image_size,
image_idx: int = 0,
downsample_mode: str | None = None,
max_slice_nums: int | None = None,
) -> str:
info = self.info
assert isinstance(info, MiniCPMV4_6ProcessingInfo)
return info.get_slice_image_placeholder(
image_size,
image_idx=image_idx,
max_slice_nums=max_slice_nums,
downsample_mode=downsample_mode,
)
def _video_local_id_prefix(self, video_idx: int) -> str:
"""`<image_id>{idx}</image_id>` prefix emitted in front of a video placeholder.
MiniCPM-V 4.6 numbers each video with a local id, matching
MiniCPMV4_6Processor. 4.7 treats a video as one temporal sequence and is
trained without these ids, so it overrides this to return an empty string.
"""
tokenizer = self.info.get_tokenizer()
id_start = getattr(tokenizer, "image_id_start_token", "<image_id>")
id_end = getattr(tokenizer, "image_id_end_token", "</image_id>")
return f"{id_start}{video_idx}{id_end}"
def get_video_prompt_texts(
self,
image_size,
@@ -144,8 +296,6 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
image_end = getattr(tokenizer, "image_end_token", "</image>")
slice_start = getattr(tokenizer, "slice_start_token", "<slice>")
slice_end = getattr(tokenizer, "slice_end_token", "</slice>")
id_start = getattr(tokenizer, "image_id_start_token", "<image_id>")
id_end = getattr(tokenizer, "image_id_end_token", "</image_id>")
per_frame = image_start + video_token * source_tokens + image_end
if grids[0] > 0 and grids[1] > 0 and patch_tokens > 0:
@@ -154,7 +304,7 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
per_frame += "\n".join(rows)
body = per_frame * num_frames
return f"{id_start}{video_idx}{id_end}" + body
return self._video_local_id_prefix(video_idx) + body
def process_images(
self,
@@ -179,10 +329,18 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
n_images = len(parsed_images)
image_processor = self.info.get_image_processor()
patch_size = image_processor.patch_size
ds_mode = self._resolve_downsample_mode(mm_kwargs, modality="image")
image_proc_kwargs = _flat_processor_kwargs(mm_kwargs, ds_mode)
# `_flat_processor_kwargs` drops the nested form, so the value resolved
# from it has to be pinned back for the processor and the prompt text
# to agree on the slice count.
image_max_slice = self._resolve_max_slice_nums(mm_kwargs, modality="image")
if image_max_slice is not None:
image_proc_kwargs["max_slice_nums"] = image_max_slice
per_image_pixel_values: list[torch.Tensor] = []
per_image_tgt_sizes: list[torch.Tensor] = []
for image in parsed_images:
ip_out = image_processor([image], **mm_kwargs)
ip_out = image_processor([image], **image_proc_kwargs)
pv = ip_out["pixel_values"] # (1, C, P, sum_W)
ts = ip_out["target_sizes"] # (n_slices, 2)
if pv.ndim == 4 and pv.shape[0] == 1:
@@ -210,7 +368,6 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
"tgt_sizes": per_image_tgt_sizes,
}
ds_mode = self._resolve_downsample_mode(mm_kwargs)
insert_layer_id = getattr(
self.info.get_hf_config(),
"insert_layer_id",
@@ -245,7 +402,11 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
image_processor = self.info.get_image_processor()
patch_size = image_processor.patch_size
video_max_slice = self.info.get_video_max_slice_num()
video_mm_kwargs = {**mm_kwargs, "max_slice_nums": video_max_slice}
video_ds_mode = self._resolve_downsample_mode(mm_kwargs, modality="video")
video_mm_kwargs = {
**_flat_processor_kwargs(mm_kwargs, video_ds_mode),
"max_slice_nums": video_max_slice,
}
per_video_pixel_values: list[torch.Tensor] = []
per_video_tgt_sizes: list[torch.Tensor] = []
@@ -312,10 +473,17 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
if not per_video_pixel_values:
return {}
insert_layer_id = getattr(self.info.get_hf_config(), "insert_layer_id", -1)
merger_flag = video_ds_mode != "4x" and insert_layer_id >= 0
return {
"video_pixel_values": per_video_pixel_values,
"video_tgt_sizes": per_video_tgt_sizes,
"video_image_sizes": per_video_image_sizes,
"video_use_vit_merger": [
torch.tensor([merger_flag], dtype=torch.bool)
for _ in per_video_pixel_values
],
}
def _get_prompt_updates(
@@ -324,7 +492,13 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
hf_processor_mm_kwargs: Mapping[str, object],
out_mm_kwargs,
):
ds_mode = self._resolve_downsample_mode(hf_processor_mm_kwargs)
image_ds_mode = self._resolve_downsample_mode(
hf_processor_mm_kwargs, modality="image"
)
video_ds_mode = self._resolve_downsample_mode(
hf_processor_mm_kwargs, modality="video"
)
image_max_slice = self._resolve_max_slice_nums(hf_processor_mm_kwargs)
placeholders = [
("image", self.info.image_pattern),
@@ -360,7 +534,8 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
self.get_image_prompt_texts(
image_size,
item_idx,
downsample_mode=ds_mode,
downsample_mode=image_ds_mode,
max_slice_nums=image_max_slice,
),
add_special_tokens=False,
),
@@ -389,7 +564,7 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
self.get_video_prompt_texts(
frame_size,
num_frames,
downsample_mode=ds_mode,
downsample_mode=video_ds_mode,
video_idx=item_idx,
),
add_special_tokens=False,
@@ -409,7 +584,7 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
self.get_video_prompt_texts(
frame_size,
num_frames,
downsample_mode=ds_mode,
downsample_mode=video_ds_mode,
video_idx=item_idx,
),
add_special_tokens=False,
@@ -437,31 +612,28 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
new_update = super()._recompute_cached_prompt_update(
cached_update, new_item_idx
)
# MiniCPM-V 4.6 prefixes video placeholders with `<image_id>{idx}</image_id>`
# (the base class only rewrites the image modality).
# Video placeholders carry an `<image_id>{idx}</image_id>` prefix that
# the base class does not rewrite. 4.7 has no prefix, so this is a
# no-op there.
if cached_update.modality == "video":
tokenizer = self.info.get_tokenizer()
id_start = getattr(tokenizer, "image_id_start_token", "<image_id>")
id_end = getattr(tokenizer, "image_id_end_token", "</image_id>")
video_token = getattr(tokenizer, "video_token", "<|video_pad|>")
old_prefix = self._video_local_id_prefix(cached_update.item_idx)
new_prefix = self._video_local_id_prefix(new_item_idx)
if old_prefix != new_prefix:
tokenizer = self.info.get_tokenizer()
video_token = getattr(tokenizer, "video_token", "<|video_pad|>")
text = tokenizer.decode(cached_update.content.full)
prev_item_idx = cached_update.item_idx
text = tokenizer.decode(cached_update.content.full)
new_update = new_update.with_content(
PromptUpdateDetails.select_token_ids(
cached_encode(
tokenizer,
text.replace(
f"{id_start}{prev_item_idx}{id_end}",
f"{id_start}{new_item_idx}{id_end}",
1,
new_update = new_update.with_content(
PromptUpdateDetails.select_token_ids(
cached_encode(
tokenizer,
text.replace(old_prefix, new_prefix, 1),
add_special_tokens=False,
),
add_special_tokens=False,
),
cached_encode(tokenizer, video_token, add_special_tokens=False),
cached_encode(tokenizer, video_token, add_special_tokens=False),
)
)
)
return new_update
def _get_mm_fields_config(
@@ -676,6 +848,31 @@ class MiniCPMV4_6ProcessingInfo(MiniCPMVProcessingInfo):
return source_tokens + grids[0] * grids[1] * patch_tokens
def _stack_vit_merger_qkv(
weights: Iterable[tuple[str, torch.Tensor]],
) -> Iterable[tuple[str, torch.Tensor]]:
"""Fold separate vit_merger q/k/v projections into the fused qkv_proj module.
HF checkpoints store the window-attention projections separately, while
vLLM keeps them fused. The shard_id mechanism on the submodule's
WeightsMapper cannot do this: AutoWeightsLoader still recurses over every
incoming key after calling a submodule's load_weights, so a "k_proj" key
with no matching child raises before the mapping is honoured.
"""
pairs = (
("vit_merger.self_attn.q_proj.", "q"),
("vit_merger.self_attn.k_proj.", "k"),
("vit_merger.self_attn.v_proj.", "v"),
)
for name, tensor in weights:
for src, shard in pairs:
if src in name:
name = name.replace(src, "vit_merger.self_attn.qkv_proj.", 1)
tensor.shard_id = shard
break
yield name, tensor
class MiniCPMV4_6ViTWindowAttentionSelfAttn(nn.Module):
def __init__(
self,
@@ -957,6 +1154,7 @@ class MiniCPMV4_6ForConditionalGeneration(
SupportsPP,
HasInnerState,
IsHybrid,
SupportsEagle3,
SupportsMRoPE,
):
supports_encoder_tp_data = True
@@ -1031,7 +1229,7 @@ class MiniCPMV4_6ForConditionalGeneration(
apply_encoder_attention_mask=True,
prefix=maybe_prefix(prefix, "vpm"),
)
if config.drop_vision_last_layer:
if self._drop_vision_last_layer():
self.vpm.encoder.layers = self.vpm.encoder.layers[:-1]
self.vit_merger = MiniCPMV4_6ViTWindowAttentionMerger(
@@ -1062,6 +1260,14 @@ class MiniCPMV4_6ForConditionalGeneration(
self.language_model.make_empty_intermediate_tensors
)
def _drop_vision_last_layer(self) -> bool:
"""Whether the last vision encoder layer is dropped.
MiniCPM-V 4.6 checkpoints always define this. 4.7's config no longer
carries the field, so it overrides this.
"""
return self.config.drop_vision_last_layer
# ----- Multimodal parsing -----
def _parse_and_validate_vision_input(
@@ -1174,35 +1380,35 @@ class MiniCPMV4_6ForConditionalGeneration(
if image_input["type"] == "image_embeds":
return image_input["image_embeds"]
downsample_mode = None
if use_vit_merger is not None:
downsample_mode = "16x" if use_vit_merger else "4x"
image_features = self.get_vision_hidden_states(
image_input,
downsample_mode=downsample_mode,
)
num_slices = image_input["num_slices"]
results = []
idx = 0
for n in num_slices.tolist():
group = image_features[idx : idx + n]
results.append(torch.cat(group, dim=0))
idx += n
return results
num_slices = image_input["num_slices"].tolist()
flags = _expand_flags(use_vit_merger, len(num_slices))
# The merger flag is per request, but the model runner may batch items
# from several concurrent requests. Encoding such a mixed batch would
# apply one request's downsample mode to another request's items, so
# group by flag and encode each group separately.
encoded: dict[int, torch.Tensor] = {}
for flag in dict.fromkeys(flags):
item_indices = [i for i, f in enumerate(flags) if f == flag]
group_input = _select_vision_items(image_input, item_indices, num_slices)
group_features = self.get_vision_hidden_states(
group_input,
downsample_mode=_vision_downsample_mode(flag),
)
idx = 0
for i in item_indices:
n = num_slices[i]
encoded[i] = torch.cat(group_features[idx : idx + n], dim=0)
idx += n
return [encoded[i] for i in range(len(num_slices))]
# ----- Multimodal embedding interface -----
def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
use_vit_merger_tensors = kwargs.pop("use_vit_merger", None)
use_vit_merger = None
if use_vit_merger_tensors is not None:
if isinstance(use_vit_merger_tensors, torch.Tensor):
use_vit_merger = bool(use_vit_merger_tensors.any().item())
elif isinstance(use_vit_merger_tensors, list | tuple):
use_vit_merger = any(
bool(t.any().item()) if isinstance(t, torch.Tensor) else bool(t)
for t in use_vit_merger_tensors
)
use_vit_merger = kwargs.pop("use_vit_merger", None)
video_use_vit_merger = kwargs.pop("video_use_vit_merger", None)
image_kwargs = {
k: v
@@ -1235,7 +1441,7 @@ class MiniCPMV4_6ForConditionalGeneration(
multimodal_embeddings += tuple(
self._process_vision_input(
video_input,
use_vit_merger=use_vit_merger,
use_vit_merger=video_use_vit_merger,
)
)
@@ -1298,7 +1504,10 @@ class MiniCPMV4_6ForConditionalGeneration(
weights: Iterable[tuple[str, torch.Tensor]],
) -> set[str]:
loader = AutoWeightsLoader(self)
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
return loader.load_weights(
_stack_vit_merger_qkv(weights),
mapper=self.hf_to_vllm_mapper,
)
def get_mm_mapping(self) -> MultiModelKeys:
return MultiModelKeys.from_string_field(
+764
View File
@@ -0,0 +1,764 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Inference-only MiniCPM-V 4.7 model.
Same vision/LLM stack as MiniCPM-V 4.6, plus canvas 3D M-RoPE.
"""
import math
from collections.abc import Mapping
from typing import Any, NamedTuple
import torch
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.multimodal.inputs import MultiModalFeatureSpec, MultiModalFieldConfig
from .minicpmv import MiniCPMVDummyInputsBuilder
from .minicpmv4_6 import (
MiniCPMV4_6ForConditionalGeneration,
MiniCPMV4_6MultiModalProcessor,
MiniCPMV4_6ProcessingInfo,
_scoped_mm_kwarg,
)
logger = init_logger(__name__)
class MiniCPMV4_7ProcessingInfo(MiniCPMV4_6ProcessingInfo):
def _mm_max_slice_nums(self, **kwargs: object) -> int | None:
# No schema resolution here: it would call back into `get_hf_processor`
# below, which resolves this same value.
merged = self.ctx.get_merged_mm_kwargs(kwargs)
max_slice = _scoped_mm_kwarg(merged, "image", "max_slice_nums")
if max_slice is None:
max_slice = merged.get("max_slice_nums")
if max_slice is None:
return None
return int(max_slice)
def _configured_max_slice_nums(self) -> int | None:
# Configured (server-level) value only: per-request values are passed
# explicitly to the image processor call, so they must not be written
# back to the shared processor instance.
return self._mm_max_slice_nums()
def get_hf_processor(self, **kwargs: object):
hf_processor = super().get_hf_processor(**kwargs)
image_processor = getattr(hf_processor, "image_processor", None)
configured = self._configured_max_slice_nums()
if image_processor is not None and configured is not None:
image_processor.max_slice_nums = configured
return hf_processor
def get_image_max_slice_num(self) -> int:
max_slice = self._configured_max_slice_nums()
if max_slice is not None:
return max_slice
return super().get_image_max_slice_num()
class MiniCPMV4_7MultiModalProcessor(MiniCPMV4_6MultiModalProcessor):
def _get_mm_fields_config(
self,
hf_inputs,
hf_processor_mm_kwargs: Mapping[str, object],
) -> Mapping[str, MultiModalFieldConfig]:
fields = dict(super()._get_mm_fields_config(hf_inputs, hf_processor_mm_kwargs))
# Canvas M-RoPE places slices from `tgt_sizes`. Stripping the payload of
# a prefix-cache-covered item only keeps CPU-side metadata, so without
# this a cached image loses its target sizes, `image_bounds` and
# `target_sizes` disagree, and the whole request silently falls back to
# sequential positions.
for key, modality in (("tgt_sizes", "image"), ("video_tgt_sizes", "video")):
if key in fields:
fields[key] = MultiModalFieldConfig.batched(modality, keep_on_cpu=True)
return fields
def _video_local_id_prefix(self, video_idx: int) -> str:
# MiniCPMV4_7Processor emits no local `<image_id>` in front of video
# placeholders: a video is one temporal sequence and 4.7 was trained
# without them.
return ""
def get_image_prompt_texts(
self,
image_size,
image_idx: int = 0,
downsample_mode: str | None = None,
max_slice_nums: int | None = None,
) -> str:
info = self.info
assert isinstance(info, MiniCPMV4_6ProcessingInfo)
if max_slice_nums is None:
max_slice_nums = info.get_image_max_slice_num()
return info.get_slice_image_placeholder(
image_size,
image_idx=image_idx,
max_slice_nums=max_slice_nums,
downsample_mode=downsample_mode,
)
def _as_hw_pairs(tensor: torch.Tensor) -> torch.Tensor:
data = tensor.to(torch.long)
if data.numel() == 0:
return data.reshape(0, 2)
if data.shape[-1] != 2:
raise ValueError(f"expected last dim 2, got {tuple(data.shape)}")
return data.reshape(-1, 2)
def _sequential_positions(seq_len: int) -> torch.Tensor:
"""Positions for the fallback where canvas M-RoPE cannot be computed."""
return torch.arange(seq_len).unsqueeze(0).expand(3, -1)
@MULTIMODAL_REGISTRY.register_processor(
MiniCPMV4_7MultiModalProcessor,
info=MiniCPMV4_7ProcessingInfo,
dummy_inputs=MiniCPMVDummyInputsBuilder,
)
class MiniCPMV4_7ForConditionalGeneration(MiniCPMV4_6ForConditionalGeneration):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__(vllm_config=vllm_config, prefix=prefix)
self._init_canvas_mrope(vllm_config)
def _drop_vision_last_layer(self) -> bool:
# MiniCPMV4_7Config no longer carries this field (transformers marks it
# removed), and 4.7 keeps every vision encoder layer.
return False
def _init_canvas_mrope(self, vllm_config: VllmConfig) -> None:
config = self.config
uses_canvas = bool(getattr(config, "uses_mrope_canvas", False))
if not uses_canvas:
mrope_mode = getattr(config, "mrope_mode", None)
uses_canvas = mrope_mode is not None and "canvas" in str(mrope_mode).lower()
self._uses_canvas_mrope = uses_canvas
self._canvas_special_ids: dict[str, int] | None = None
if not uses_canvas:
return
# MiniCPMV4_7Config pins the structural ids in config.json; the
# conversion script resolves them from the tokenizer. Prefer them so the
# canvas matches training, and fall back to the tokenizer for checkpoints
# whose config predates these fields.
configured_ids: dict[str, int | None] = {
"im_start_id": getattr(config, "image_start_id", None),
"im_end_id": getattr(config, "image_end_id", None),
"slice_start_id": getattr(config, "slice_start_id", None),
"slice_end_id": getattr(config, "slice_end_id", None),
"newline_id": getattr(config, "newline_id", None),
}
if all(value is not None for value in configured_ids.values()):
self._canvas_special_ids = {
name: int(value)
for name, value in configured_ids.items()
if value is not None
}
return
from vllm.tokenizers.registry import get_tokenizer
model_config = vllm_config.model_config
tokenizer = get_tokenizer(
model_config.tokenizer,
revision=model_config.tokenizer_revision,
trust_remote_code=model_config.trust_remote_code,
)
self._canvas_special_ids = {
"im_start_id": tokenizer.convert_tokens_to_ids(
getattr(tokenizer, "image_start_token", "<image>")
),
"im_end_id": tokenizer.convert_tokens_to_ids(
getattr(tokenizer, "image_end_token", "</image>")
),
"slice_start_id": tokenizer.convert_tokens_to_ids(
getattr(tokenizer, "slice_start_token", "<slice>")
),
"slice_end_id": tokenizer.convert_tokens_to_ids(
getattr(tokenizer, "slice_end_token", "</slice>")
),
"newline_id": tokenizer.encode("\n", add_special_tokens=False)[0],
}
def get_mrope_input_positions(
self,
input_tokens: list[int],
mm_features: list[MultiModalFeatureSpec],
) -> tuple[torch.Tensor, int]:
seq_len = len(input_tokens)
if (
not getattr(self, "_uses_canvas_mrope", False)
or self._canvas_special_ids is None
or not mm_features
):
return _sequential_positions(seq_len), 0
input_ids = torch.tensor(input_tokens, dtype=torch.long)
image_bounds = build_image_bounds(input_ids, self._canvas_special_ids)
target_sizes_list: list[torch.Tensor] = []
for mm_feature in sorted(mm_features, key=lambda f: f.mm_position.offset):
if mm_feature.data is None:
continue
for key in ("tgt_sizes", "video_tgt_sizes"):
tgt = mm_feature.data.get(key)
if tgt is None or not isinstance(tgt.data, torch.Tensor):
continue
try:
target_sizes_list.append(_as_hw_pairs(tgt.data))
except ValueError:
# Not an (N, 2) size table. The bounds/sizes match below
# turns this into a sequential fallback.
continue
if not target_sizes_list or image_bounds.numel() == 0:
logger.warning(
"MiniCPM-V 4.7 canvas M-RoPE missing inputs: "
"tgt_chunks=%s image_bounds=%s; using sequential positions.",
len(target_sizes_list),
tuple(image_bounds.shape) if image_bounds is not None else None,
)
return _sequential_positions(seq_len), 0
target_sizes = torch.cat(target_sizes_list, dim=0)
if target_sizes.ndim != 2 or target_sizes.shape[-1] != 2:
logger.warning(
"MiniCPM-V 4.7 canvas M-RoPE unexpected tgt_sizes shape %s; "
"using sequential positions.",
tuple(target_sizes.shape),
)
return _sequential_positions(seq_len), 0
if image_bounds.shape[0] != target_sizes.shape[0]:
logger.warning(
"MiniCPM-V 4.7 canvas M-RoPE size mismatch: "
"image_bounds=%s target_sizes=%s; using sequential positions.",
tuple(image_bounds.shape),
tuple(target_sizes.shape),
)
return _sequential_positions(seq_len), 0
try:
pos3d = _compute_canvas_single(
input_ids,
torch.arange(seq_len, dtype=torch.long),
image_bounds,
target_sizes,
self._canvas_special_ids,
)
except Exception:
logger.warning(
"MiniCPM-V 4.7 canvas M-RoPE failed; "
"falling back to sequential positions.",
exc_info=True,
)
return _sequential_positions(seq_len), 0
return pos3d, canvas_rope_delta(pos3d, seq_len)
# Canvas 3D M-RoPE: position ids for the slice canvas.
def compute_llm_grid(
n_tokens: int, vision_grid_height: int, vision_grid_width: int
) -> tuple[int, int]:
if n_tokens <= 0 or vision_grid_height <= 0 or vision_grid_width <= 0:
return 0, 0
vit_total = vision_grid_height * vision_grid_width
if vit_total == n_tokens:
return vision_grid_height, vision_grid_width
if vit_total < n_tokens:
return 0, 0
factor = int(round(math.sqrt(vit_total / n_tokens)))
if (
factor > 0
and vision_grid_height % factor == 0
and vision_grid_width % factor == 0
):
height, width = vision_grid_height // factor, vision_grid_width // factor
if height * width == n_tokens:
return height, width
ratio = vision_grid_height / vision_grid_width
width = max(1, int(round(math.sqrt(n_tokens / ratio))))
height = n_tokens // width
if height * width == n_tokens and height > 0:
return height, width
return 0, 0
def build_image_bounds(
input_ids: torch.LongTensor, special_token_ids: dict
) -> torch.LongTensor:
start_ids = [
x
for x in (
special_token_ids.get("im_start_id"),
special_token_ids.get("slice_start_id"),
)
if x is not None
]
end_ids = [
x
for x in (
special_token_ids.get("im_end_id"),
special_token_ids.get("slice_end_id"),
)
if x is not None
]
if not start_ids or not end_ids:
return torch.zeros(0, 2, dtype=torch.long, device=input_ids.device)
start_cond = torch.zeros_like(input_ids, dtype=torch.bool)
end_cond = torch.zeros_like(input_ids, dtype=torch.bool)
for sid in start_ids:
start_cond |= input_ids == sid
for eid in end_ids:
end_cond |= input_ids == eid
starts = torch.where(start_cond)[0] + 1
ends = torch.where(end_cond)[0]
n = min(len(starts), len(ends))
if n == 0:
return torch.zeros(0, 2, dtype=torch.long, device=input_ids.device)
return torch.stack([starts[:n], ends[:n]], dim=-1)
def _compute_canvas_single(
input_ids, position_ids_2d, image_bound, target_sizes, special_token_ids
):
# Inference always handles one unpadded sequence. The packed (cu_seqlens)
# form below is kept only to mirror the transformers reference
# implementation of its canvas position computation.
seq_len = input_ids.shape[0]
cu_seqlens = torch.tensor([0, seq_len], device=input_ids.device, dtype=torch.long)
return _compute_canvas_packed(
position_ids_2d.unsqueeze(0),
cu_seqlens,
image_bound,
target_sizes,
input_ids.unsqueeze(0),
special_token_ids,
)[:, 0, :]
def _target_hw(target_sizes: torch.Tensor, index: int) -> tuple[int, int]:
"""Patch-grid height/width recorded for one image or slice."""
return int(target_sizes[index, 0]), int(target_sizes[index, 1])
class _CanvasLayout(NamedTuple):
"""Grid of one canvas measured in LLM tokens.
A canvas is a thumbnail plus the slices cut from it, tiled row-major.
"""
thumb_h: int
thumb_w: int
"""Thumbnail grid."""
slice_h: int
slice_w: int
"""Per-slice grid. Zero when the canvas has no slices."""
cols: int
"""Slices sharing a canvas row. Zero when the canvas has no slices."""
height: int
width: int
"""Assembled canvas."""
def _canvas_layout(
thumbnail: tuple[int, int, int],
slices: list[tuple[int, int, int]],
target_sizes: torch.Tensor,
) -> _CanvasLayout:
"""Measure the canvas for one ``(thumbnail, slices)`` group.
``thumbnail`` and each slice are ``(start, end, target_index)`` token spans.
"""
t_bs, t_be, t_gi = thumbnail
thumb_h, thumb_w = compute_llm_grid(t_be - t_bs, *_target_hw(target_sizes, t_gi))
if not slices:
return _CanvasLayout(
thumb_h, thumb_w, 0, 0, 0, max(thumb_h, 1), max(thumb_w, 1)
)
cols = len(slices)
for k in range(len(slices) - 1):
if slices[k + 1][0] - slices[k][1] > 2:
cols = k + 1
break
rows = len(slices) // cols if cols > 0 else 1
if rows * cols != len(slices):
rows, cols = 1, len(slices)
s0_bs, s0_be, s0_gi = slices[0]
slice_h, slice_w = compute_llm_grid(s0_be - s0_bs, *_target_hw(target_sizes, s0_gi))
return _CanvasLayout(
thumb_h, thumb_w, slice_h, slice_w, cols, rows * slice_h, cols * slice_w
)
def _assign_canvas_span(
pos3d: torch.Tensor,
*,
thumbnail: tuple[int, int, int],
span_end: int,
slices: list[tuple[int, int, int]],
base: int,
target_sizes: torch.Tensor,
) -> int:
"""Place one canvas at ``base``, writing its 3-D positions into ``pos3d``.
Shared by the video-frame and single-image paths; they differ only in how
the per-canvas base advances, not in the geometry placed here.
Mutates ``pos3d`` in place, as the packed canvas computation has always
done. Returns the distance to the next canvas base, so each caller keeps
only its own base advancement.
"""
t_bs, t_be, _ = thumbnail
device = pos3d.device
span_start = t_bs - 1 # the <im_start> token
n_thumb = t_be - t_bs
layout = _canvas_layout(thumbnail, slices, target_sizes)
cols, canvas_H, canvas_W = layout.cols, layout.height, layout.width
# base coat
pos3d[0, 0, span_start:span_end] = base
pos3d[1, 0, span_start:span_end] = base
pos3d[2, 0, span_start:span_end] = base
# <im_start> -> halo
halo_lo = max(base - 1, 0)
pos3d[1, 0, span_start] = halo_lo
pos3d[2, 0, span_start] = halo_lo
# <im_end> -> halo (right after thumbnail)
if t_be < span_end:
pos3d[1, 0, t_be] = base + canvas_H
pos3d[2, 0, t_be] = base + canvas_W
# thumbnail visual tokens
llm_th, llm_tw = layout.thumb_h, layout.thumb_w
if llm_th > 0 and llm_tw > 0 and llm_th * llm_tw == n_thumb:
if slices and canvas_H > 0 and canvas_W > 0:
h_c = torch.linspace(0, canvas_H - 1, llm_th, device=device).round().long()
w_c = torch.linspace(0, canvas_W - 1, llm_tw, device=device).round().long()
else:
h_c = torch.arange(llm_th, device=device)
w_c = torch.arange(llm_tw, device=device)
h_idx = h_c.view(-1, 1).expand(-1, llm_tw).reshape(-1)
w_idx = w_c.view(1, -1).expand(llm_th, -1).reshape(-1)
pos3d[0, 0, t_bs:t_be] = base
pos3d[1, 0, t_bs:t_be] = h_idx + base
pos3d[2, 0, t_bs:t_be] = w_idx + base
else:
fallback = torch.arange(n_thumb, device=device, dtype=torch.long) + base
for d in range(3):
pos3d[d, 0, t_bs:t_be] = fallback
# slice visual tokens + slice specials
for k, (s_bs, s_be, s_gi) in enumerate(slices):
n_s = s_be - s_bs
sh_k, sw_k = compute_llm_grid(n_s, *_target_hw(target_sizes, s_gi))
h_off = (k // cols) * layout.slice_h
w_off = (k % cols) * layout.slice_w
slice_start_pos = s_bs - 1
if slice_start_pos >= span_start:
pos3d[1, 0, slice_start_pos] = base + h_off
pos3d[2, 0, slice_start_pos] = base + w_off
slice_end_pos = s_be
if sh_k > 0 and sw_k > 0:
end_h = h_off + sh_k - 1
end_w = w_off + sw_k - 1
else:
end_h = h_off
end_w = w_off
if slice_end_pos < span_end:
pos3d[1, 0, slice_end_pos] = base + end_h
pos3d[2, 0, slice_end_pos] = base + end_w
if sh_k > 0 and sw_k > 0 and sh_k * sw_k == n_s:
h_idx = (
torch.arange(sh_k, device=device)
.view(-1, 1)
.expand(-1, sw_k)
.reshape(-1)
)
w_idx = (
torch.arange(sw_k, device=device)
.view(1, -1)
.expand(sh_k, -1)
.reshape(-1)
)
pos3d[0, 0, s_bs:s_be] = base
pos3d[1, 0, s_bs:s_be] = h_idx + h_off + base
pos3d[2, 0, s_bs:s_be] = w_idx + w_off + base
else:
fallback = torch.arange(n_s, device=device, dtype=torch.long) + base
for d in range(3):
pos3d[d, 0, s_bs:s_be] = fallback
# \n between slice rows -> W = right edge + 1.
# H stays within the ended row (does NOT cross to next row): the \n sits at
# the right-outside of the row that just ended, at H = that row's last row
# and W = one column past the rightmost slice.
for k in range(len(slices) - 1):
gap_s = slices[k][1]
gap_e = slices[k + 1][0]
if gap_e - gap_s <= 2:
continue
boundary_h = ((k // cols) + 1) * layout.slice_h - 1
right_edge_w = cols * layout.slice_w
for nl_pos in range(gap_s + 1, gap_e - 1):
pos3d[1, 0, nl_pos] = base + boundary_h
pos3d[2, 0, nl_pos] = base + right_edge_w
return max(canvas_H, canvas_W) + 1
def _compute_canvas_packed(
position_ids_2d,
cu_seqlens,
image_bound,
target_sizes,
input_ids,
special_token_ids,
):
if position_ids_2d.ndim == 1:
position_ids_2d = position_ids_2d.unsqueeze(0)
if input_ids.ndim == 1:
input_ids = input_ids.unsqueeze(0)
has_images = (
isinstance(image_bound, torch.Tensor)
and image_bound.numel() > 0
and isinstance(target_sizes, torch.Tensor)
and target_sizes.numel() > 0
)
if not has_images:
return position_ids_2d.unsqueeze(0).expand(3, -1, -1).contiguous()
device = position_ids_2d.device
pos3d = position_ids_2d.unsqueeze(0).expand(3, -1, -1).clone()
ids_flat = input_ids.view(-1)
im_start_id = special_token_ids.get("im_start_id")
im_end_id = special_token_ids.get("im_end_id")
slice_start_id = special_token_ids.get("slice_start_id")
slice_end_id = special_token_ids.get("slice_end_id")
structural_ids = {
v
for v in (im_start_id, im_end_id, slice_start_id, slice_end_id)
if v is not None
}
num_seqs = len(cu_seqlens) - 1
img_ptr = 0
num_imgs = len(image_bound)
newline_id = special_token_ids.get("newline_id")
gap_ok_ids = structural_ids | ({newline_id} if newline_id is not None else set())
def _group_end_pos(g, s_end):
"""First position after a group's trailing structural tokens.
Matches the transformers reference, which closes a span over every
marker in ``markers`` -- newlines included. The chat template joins
content parts with ``\\n``, so an image followed by text always ends with
one; leaving it out moves that newline out of the canvas and pushes
every later position one step forward.
"""
le = g["slices"][-1][1] if g["slices"] else g["thumbnail"][1]
ge = le
while ge < s_end and ids_flat[ge].item() in gap_ok_ids:
ge += 1
return min(ge, s_end)
def _is_tight_gap(g_prev, g_next, s_end):
"""Check if only structural/nl tokens sit between two groups."""
ge = _group_end_pos(g_prev, s_end)
ns = g_next["thumbnail"][0] - 1 # im_start of next group
return all(ids_flat[p].item() in gap_ok_ids for p in range(ge, ns))
for si in range(num_seqs):
s_start = cu_seqlens[si].item()
s_end = cu_seqlens[si + 1].item()
if s_start >= s_end:
continue
seq_imgs = []
while img_ptr < num_imgs:
bs_ = image_bound[img_ptr, 0].item()
be_ = image_bound[img_ptr, 1].item()
if bs_ >= s_start and be_ <= s_end:
seq_imgs.append((bs_, be_, img_ptr))
img_ptr += 1
else:
break
if not seq_imgs:
continue
# ---- group images: thumbnail + following slices ----
# merge consecutive no-slice frames into one video group
raw_groups = []
cur_group: dict[str, Any] | None = None
for bs_, be_, gi in seq_imgs:
marker_pos = bs_ - 1
is_slice = (
slice_start_id is not None
and marker_pos >= s_start
and ids_flat[marker_pos].item() == slice_start_id
)
if is_slice and cur_group is not None:
cur_group["slices"].append((bs_, be_, gi))
else:
if cur_group is not None:
raw_groups.append(cur_group)
cur_group = {"thumbnail": (bs_, be_, gi), "slices": []}
if cur_group is not None:
raw_groups.append(cur_group)
# merge consecutive tightly-adjacent groups into video groups.
# Two groups are "tightly adjacent" if the gap between them
# contains only structural tokens and \n (no real text).
# This handles both no-slice frames and frames-with-slices.
groups = []
i_g = 0
while i_g < len(raw_groups):
video_frames = [raw_groups[i_g]]
j_g = i_g + 1
while j_g < len(raw_groups) and _is_tight_gap(
raw_groups[j_g - 1], raw_groups[j_g], s_end
):
video_frames.append(raw_groups[j_g])
j_g += 1
if len(video_frames) == 1:
groups.append(raw_groups[i_g])
else:
groups.append({"video_frames": video_frames})
i_g = j_g
# ---- assign 3-D positions ----
pos = 0
cursor = s_start
for group in groups:
is_video_group = "video_frames" in group
if is_video_group:
# === Video group: multiple frames merged ===
# Each frame gets its own base_f so different frames have
# distinct spatial positions. Inter-frame gap tokens (\n
# etc.) are pure 1D text. After each gap, pos advances
# by 1 extra so halo_lo of the next frame does not
# collide with the last gap token.
frames = group["video_frames"]
first_frame = frames[0]
last_frame = frames[-1]
# group_start = <im_start> of first frame
group_start = first_frame["thumbnail"][0] - 1
# group_end = after last structural token of last frame
group_end = _group_end_pos(last_frame, s_end)
# text before video group
text_len = group_start - cursor
if text_len > 0:
t_pos = (
torch.arange(text_len, device=device, dtype=torch.long) + pos
)
for d in range(3):
pos3d[d, 0, cursor:group_start] = t_pos
pos += text_len
# process each frame with its own per-frame base
frame_cursor = group_start
for vf in frames:
t_bs = vf["thumbnail"][0]
vf_slices = vf["slices"]
im_start_pos = t_bs - 1
# --- inter-frame gap: pure 1D text T=H=W=pos ---
gap_len = im_start_pos - frame_cursor
if gap_len > 0:
t_pos = (
torch.arange(gap_len, device=device, dtype=torch.long) + pos
)
for d in range(3):
pos3d[d, 0, frame_cursor:im_start_pos] = t_pos
pos += gap_len
pos += 1 # buffer: prevents halo_lo collision
# --- per-frame base ---
base_f = pos
frame_end = _group_end_pos(vf, group_end)
pos = base_f + _assign_canvas_span(
pos3d,
thumbnail=vf["thumbnail"],
span_end=frame_end,
slices=vf_slices,
base=base_f,
target_sizes=target_sizes,
)
frame_cursor = frame_end
# trailing tokens after last frame (if any)
if frame_cursor < group_end:
trail_len = group_end - frame_cursor
t_pos = (
torch.arange(trail_len, device=device, dtype=torch.long) + pos
)
for d in range(3):
pos3d[d, 0, frame_cursor:group_end] = t_pos
pos += trail_len
cursor = group_end
else:
# === Single image group (thumbnail + optional slices) ===
slices = group["slices"]
group_start = group["thumbnail"][0] - 1
group_end = _group_end_pos(group, s_end)
# text before group
text_len = group_start - cursor
if text_len > 0:
t_pos = (
torch.arange(text_len, device=device, dtype=torch.long) + pos
)
for d in range(3):
pos3d[d, 0, cursor:group_start] = t_pos
pos += text_len
base = pos
pos = base + _assign_canvas_span(
pos3d,
thumbnail=group["thumbnail"],
span_end=group_end,
slices=slices,
base=base,
target_sizes=target_sizes,
)
cursor = group_end
# remaining text
rem = s_end - cursor
if rem > 0:
t_pos = torch.arange(rem, device=device, dtype=torch.long) + pos
for d in range(3):
pos3d[d, 0, cursor:s_end] = t_pos
return pos3d
def canvas_rope_delta(position_ids: torch.Tensor, seq_len: int) -> int:
"""Offset applied to the 1-D positions of tokens decoded after the prompt.
The first decoded token sits one past the last prefill position.
"""
return int(position_ids.amax().item()) + 1 - seq_len
+4
View File
@@ -514,6 +514,10 @@ _MULTIMODAL_MODELS = {
"minicpmv4_6",
"MiniCPMV4_6ForConditionalGeneration",
),
"MiniCPMV4_7ForConditionalGeneration": (
"minicpmv4_7",
"MiniCPMV4_7ForConditionalGeneration",
),
"Mistral3ForConditionalGeneration": (
"mistral3",
"Mistral3ForConditionalGeneration",
+405
View File
@@ -0,0 +1,405 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniCPM-V family parser.
Wraps the Qwen3 grammar (``<think>`` reasoning + XML tool calls) with two
MiniCPM-V specifics:
- ``<|im_end|>`` and the reserved marker tokens are passthrough terminals,
so they never leak into ``content``.
- The tokenizer decodes a newline as the two characters ``\\`` and ``n``
(e.g. token id 1639) instead of ``\\n``, so the decoded text is recovered
before it reaches the client. Code blocks, inline code and LaTeX are left
alone, as are tool call arguments.
"""
from __future__ import annotations
import functools
from collections.abc import Sequence
from dataclasses import replace
from typing import TYPE_CHECKING
from vllm.entrypoints.generate.base.protocol import DeltaMessage
from vllm.parser.abstract_parser import DelegatingParser
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.qwen3 import Qwen3Parser, qwen3_config
if TYPE_CHECKING:
from vllm.entrypoints.generate.base.protocol import FunctionCall
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
_RESERVED_MARKER_IDS = range(12, 16)
_IM_END = "<|im_end|>"
@functools.cache
def minicpmv_config(thinking: bool) -> ParserEngineConfig:
"""MiniCPM-V grammar: the qwen3 grammar plus MiniCPM-V markers.
Both modes keep the qwen3 tool call terminals and transitions, so
``<tool_call>`` output is parsed whether or not thinking is enabled.
"""
name = "minicpmv" if thinking else "minicpmv_no_thinking"
base = qwen3_config(thinking=thinking, name=name)
transitions = dict(base.transitions)
if not thinking:
# Default mode: the model answers directly, so `<think>` must not open
# a reasoning span that would swallow the answer. A stray
# `<think>...</think>` in the output is still dropped, not printed.
transitions[(ParserState.CONTENT, "THINK_START")] = Transition(
ParserState.REASONING
)
base = replace(base, strip_trailing_reasoning_whitespace=False)
terminals = dict(base.terminals)
token_id_terminals = dict(base.token_id_terminals)
terminals["IM_END"] = _IM_END
token_id_terminals["IM_END"] = _IM_END
for state in (ParserState.CONTENT, ParserState.REASONING):
transitions[(state, "IM_END")] = Transition(state)
for index in _RESERVED_MARKER_IDS:
text_name = f"RESERVED_TEXT_{index}"
token_name = f"RESERVED_TOKEN_{index}"
terminals[text_name] = f"<reserved_{index}>"
terminals[token_name] = f"<|reserved_{index}|>"
token_id_terminals[token_name] = f"<|reserved_{index}|>"
for state in (ParserState.CONTENT, ParserState.REASONING):
transitions[(state, text_name)] = Transition(state)
transitions[(state, token_name)] = Transition(state)
return replace(
base,
terminals=terminals,
token_id_terminals=token_id_terminals,
transitions=transitions,
)
class EscapedNewlineNormalizer:
"""Recover literal escape sequences written by the model as real text.
Scans character by character so that a backslash inside a code block,
inline code, LaTeX span or a doubled escape is left untouched. A trailing
half of an escape is held in ``_pending`` until the next chunk arrives,
which keeps streaming output identical to the non-streaming result.
"""
def __init__(self) -> None:
self._closing_marker: str | None = None
self._pending = ""
def feed(self, text: str, *, final: bool = False) -> str:
text = self._pending + text
self._pending = ""
output: list[str] = []
index = 0
while index < len(text):
if self._closing_marker is not None:
marker = self._closing_marker
remaining = text[index:]
if remaining.startswith(marker):
output.append(marker)
index += len(marker)
self._closing_marker = None
continue
if not final and marker.startswith(remaining):
self._pending = remaining
break
output.append(text[index])
index += 1
continue
remaining = text[index:]
if text[index] == "`":
if not final and remaining in ("`", "``"):
self._pending = remaining
break
if remaining.startswith("```"):
output.append("```")
index += 3
self._closing_marker = "```"
else:
output.append("`")
index += 1
self._closing_marker = "`"
continue
if text[index] == "$":
if not final and remaining == "$":
self._pending = remaining
break
if remaining.startswith("$$"):
output.append("$$")
index += 2
self._closing_marker = "$$"
else:
output.append("$")
index += 1
self._closing_marker = "$"
continue
if text[index] != "\\":
output.append(text[index])
index += 1
continue
slash_end = index + 1
while slash_end < len(text) and text[slash_end] == "\\":
slash_end += 1
if slash_end - index > 1:
if not final and slash_end == len(text):
# The run may still grow, and its parity decides whether a
# following `n` is a newline or stays literal. Hold it until
# the run is complete.
self._pending = text[index:slash_end]
break
output.append(text[index:slash_end])
index = slash_end
continue
if not final and remaining in ("\\", "\\r", "\\r\\"):
self._pending = remaining
break
if remaining.startswith("\\r\\n"):
output.append("\n")
index += 4
elif remaining.startswith(("\\n", "\\r")):
output.append("\n")
index += 2
elif remaining.startswith("\\("):
output.append("\\(")
index += 2
self._closing_marker = "\\)"
elif remaining.startswith("\\["):
output.append("\\[")
index += 2
self._closing_marker = "\\]"
else:
output.append("\\")
index += 1
if final and self._pending:
output.append(self._pending)
self._pending = ""
return "".join(output)
def recover_newlines(text: str | None) -> str | None:
if text is None or "\\" not in text:
return text
return EscapedNewlineNormalizer().feed(text, final=True)
class MiniCPMVOutputNormalizer:
"""Apply :class:`EscapedNewlineNormalizer` to a whole stream.
Reasoning and content are tracked separately so that a half-written escape
at the end of a reasoning chunk does not swallow the first characters of
the content that follows.
"""
def __init__(self) -> None:
self._reasoning = EscapedNewlineNormalizer()
self._content = EscapedNewlineNormalizer()
def normalize_delta(
self,
delta: DeltaMessage | None,
*,
finished: bool,
) -> DeltaMessage | None:
reasoning_finished = finished or (
delta is not None and (delta.content is not None or bool(delta.tool_calls))
)
reasoning = self._reasoning.feed(
delta.reasoning if delta and delta.reasoning else "",
final=reasoning_finished,
)
content = self._content.feed(
delta.content if delta and delta.content else "",
final=finished,
)
if delta is None:
if not reasoning and not content:
return None
return DeltaMessage(
reasoning=reasoning or None,
content=content or None,
)
if reasoning:
delta.reasoning = reasoning
elif delta.reasoning is not None:
delta.reasoning = None
if content:
delta.content = content
elif delta.content is not None:
delta.content = None
# A chunk that normalizes to nothing must be dropped, not returned as an
# all-None DeltaMessage: the engine only skips on `None`, so anything
# else reaches the client as an empty delta.
if not delta.content and not delta.reasoning and not delta.tool_calls:
return None
return delta
class MiniCPMVParser(Qwen3Parser):
"""Parse MiniCPM-V family output in thinking and non-thinking modes."""
CONFIG_NAME = "minicpmv"
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
**kwargs,
) -> None:
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
# The chat template emits `<think>` unless `enable_thinking` is
# explicitly false, so the grammar must default to thinking too, or the
# reasoning span is misread as content.
thinking_enabled = chat_kwargs.get("enable_thinking", True)
kwargs.setdefault("parser_engine_config", minicpmv_config(thinking_enabled))
super().__init__(tokenizer, tools, **kwargs)
# ``Qwen3Parser.__init__`` reads the same key with a default of True,
# which does not apply here: MiniCPM-V defaults to thinking disabled.
# Keep the attribute in sync with the grammar built above.
self.thinking_enabled = thinking_enabled
@property
def reasoning_ended(self) -> bool:
# The engine derives this from the presence of THINK terminals, which
# both MiniCPM-V configs keep, so the base property stays False in
# non-thinking mode. There is no reasoning span to end there.
if not self.thinking_enabled:
return True
return super().reasoning_ended
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
if self.thinking_enabled:
return super().extract_reasoning(model_output, request)
_, content = ParserEngine.extract_reasoning(self, model_output, request)
return None, content
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
if self.thinking_enabled:
return super().extract_reasoning_streaming(
previous_text,
current_text,
delta_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
)
delta = ParserEngine.extract_reasoning_streaming(
self,
previous_text,
current_text,
delta_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
)
if delta is None:
return None
delta.reasoning = None
if delta.content is None and not delta.tool_calls:
return None
return delta
def get_streaming_fallback_content(
self,
text: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> str | None:
if self.thinking_enabled:
return super().get_streaming_fallback_content(text, request)
delta = ParserEngine.finish_streaming(self)
return delta.content if delta is not None else None
class MiniCPMVUnifiedParser(DelegatingParser):
"""MiniCPM-V serving parser: the adapters plus newline recovery.
Recovery cannot live in :class:`MiniCPMVParser`: the serving stack drives
the reasoning/tool adapters, never ``ParserEngine.parse`` /
``ParserEngine.parse_delta``, so a rewrite applied there would only be
visible to callers that construct the engine directly.
"""
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
*args,
**kwargs,
) -> None:
super().__init__(tokenizer, tools, *args, **kwargs)
self._output_normalizer = MiniCPMVOutputNormalizer()
def parse(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
enable_auto_tools: bool = False,
model_output_token_ids: Sequence[int] = (),
) -> tuple[str | None, str | None, list[FunctionCall] | None]:
reasoning, content, tool_calls = super().parse(
model_output,
request,
enable_auto_tools,
model_output_token_ids,
)
# Tool call arguments are JSON, where a literal `\n` is meaningful and
# must survive as written.
return (
recover_newlines(reasoning),
recover_newlines(content),
tool_calls,
)
def parse_delta(
self,
delta_text: str,
delta_token_ids: list[int],
request: ChatCompletionRequest | ResponsesRequest,
prompt_token_ids: list[int] | None = None,
*,
finished: bool,
) -> DeltaMessage | None:
delta = super().parse_delta(
delta_text,
delta_token_ids,
request,
prompt_token_ids,
finished=finished,
)
return self._output_normalizer.normalize_delta(delta, finished=finished)
+13
View File
@@ -242,6 +242,19 @@ class ParserManager:
return _CohereCommandParser
if "minicpmv" in {reasoning_parser_name, tool_parser_name}:
from vllm.parser.minicpmv import MiniCPMVUnifiedParser
r_cls = reasoning_parser_cls
t_cls = tool_parser_cls
class _MiniCPMVParser(MiniCPMVUnifiedParser):
reasoning_parser_cls = r_cls
tool_parser_cls = t_cls
tool_strict_level = strict_level
return _MiniCPMVParser
from vllm.parser.abstract_parser import DelegatingParser
r_cls = reasoning_parser_cls
+4
View File
@@ -116,6 +116,10 @@ _REASONING_PARSERS_TO_REGISTER = {
"mimo_engine_reasoning_parser",
"MiMoParserReasoningAdapter",
),
"minicpmv": (
"minicpmv_reasoning_parser",
"MiniCPMVParserReasoningAdapter",
),
"minimax_m2": (
"minimax_m2_reasoning_parser",
"MiniMaxM2ReasoningParser",
@@ -0,0 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.engine.adapters import ParserEngineReasoningAdapter
from vllm.parser.minicpmv import MiniCPMVParser
class MiniCPMVParserReasoningAdapter(ParserEngineReasoningAdapter):
# Reasoning-only on purpose: this family registers no tool parser, because
# MiniCPM-V emits the Qwen3-Coder XML syntax verbatim and `qwen3_coder`
# already covers it. ``make_adapters`` only builds reasoning/tool pairs, so
# the two halves are declared explicitly here.
_parser_engine_cls = MiniCPMVParser
__all__ = ["MiniCPMVParserReasoningAdapter"]
@@ -32,6 +32,7 @@ _MODEL_TYPE_TO_CHAT_TEMPLATE_FALLBACK: dict[str, ChatTemplatePath] = {
"deepseek_vl_v2": CHAT_TEMPLATES_DIR / "template_deepseek_vl2.jinja",
"minicpmv": _get_minicpmv_chat_template_fallback,
"minicpmv4_6": _get_minicpmv_chat_template_fallback,
"minicpmv4_7": _get_minicpmv_chat_template_fallback,
"paligemma": CHAT_TEMPLATES_DIR / "template_basic.jinja",
"siglip": CHAT_TEMPLATES_DIR / "template_basic.jinja",
"siglip2": CHAT_TEMPLATES_DIR / "template_basic.jinja",