Add Muse Glimmer model support (#51655)

Signed-off-by: Tiezhen Wang <tiezhen@inferact.ai>
Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
Signed-off-by: zixi-qi <zixi@inferact.ai>
Co-authored-by: Beto de Paola <betodepaola@meta.com>
Co-authored-by: Giancarlo Delfin <gdelfin@inferact.ai>
Co-authored-by: Jee Jee Li <jeejeelee@inferact.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>
Co-authored-by: zixi-qi <zixi@inferact.ai>
This commit is contained in:
Tiezhen WANG
2026-08-14 12:28:56 +08:00
committed by GitHub
co-authored by Beto de Paola Giancarlo Delfin Jee Jee Li Claude Opus 5 Jee Jee Li zixi-qi
parent 59d1af5698
commit 6adad08767
21 changed files with 4333 additions and 16 deletions
File diff suppressed because one or more lines are too long
+18
View File
@@ -1192,6 +1192,12 @@ _MULTIMODAL_EXAMPLE_MODELS = {
# required by current PrefixLM implementation
max_num_batched_tokens=31872,
),
"MuseGlimmerForConditionalGeneration": _HfExamplesInfo(
"meta-models/Muse-Glimmer-30B",
),
"MuseGlimmerForCausalLM": _HfExamplesInfo(
"meta-models/Muse-Glimmer-30B",
),
"NVLM_D": _HfExamplesInfo("nvidia/NVLM-D-72B", trust_remote_code=True),
"Llama_Nemotron_Nano_VL": _HfExamplesInfo(
"nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1",
@@ -1489,6 +1495,18 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
max_num_seqs=32,
min_transformers_version="4.56.3", # Required for Qwen3Next
),
"MuseGlimmerAssistantModel": _HfExamplesInfo(
"meta-models/Muse-Glimmer-30B",
speculative_model="meta-models/Muse-Glimmer-30B-assistant",
max_model_len=8192, # Reduce max len to ensure test runs in low-VRAM CI env
max_num_seqs=32,
),
"DFlashMuseGlimmerAssistantModel": _HfExamplesInfo(
"meta-models/Muse-Glimmer-30B",
speculative_model="meta-models/Muse-Glimmer-30B-assistant",
max_model_len=8192, # Reduce max len to ensure test runs in low-VRAM CI env
max_num_seqs=32,
),
# [DSpark]
"DSparkDraftModel": _HfExamplesInfo(
"deepseek-ai/DeepSeek-V4-Pro-DSpark",
+7 -2
View File
@@ -564,15 +564,20 @@ def dummy_hf_overrides(
)
if hasattr(hf_config, "vision_config"):
hf_config.vision_config.update(
vision_config = hf_config.vision_config
vision_config.update(
{
"num_layers": 1,
"num_hidden_layers": 1,
}
)
# Keep per-layer metadata consistent with the reduced layer count.
if layer_types := getattr(vision_config, "layer_types", None):
vision_config.update({"layer_types": layer_types[:1]})
if model_arch in ("Moondream3ForCausalLM", "HfMoondream"):
hf_config.vision_config.update({"enc_n_layers": 1})
vision_config.update({"enc_n_layers": 1})
# e.g.: ibm-granite/granite-speech-3.3-2b
if hasattr(hf_config, "encoder_config"):
+386
View File
@@ -0,0 +1,386 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the MuseGlimmer ATEM tool parser and reasoning parser.
MuseGlimmer writes every turn as a sequence of channel-scoped messages rather
than JSON, so the two parsers are tested together: the reasoning parser strips
the reasoning span and forwards the remaining channels as content, and the tool
parser reads ATEM markup out of those channels.
Four areas, in order:
1. non-streaming tool-call extraction, including channel scoping (an
``<atem:invoke>`` echoed inside reasoning must never become a call);
2. the reasoning -> tool-parser handoff, which regressed once by returning
``content=None`` and starving the tool parser;
3. streaming, where markers routinely straddle chunk boundaries, plus
truncation isolation for an unterminated ``to=self`` block;
4. tool-name normalization against the tools registered on the request.
These drive the parsers directly and need no checkpoint. The tests that require
a real tokenizer live in ``test_muse_glimmer_parse_delta.py``.
"""
import json
from types import SimpleNamespace
import pytest
from vllm.reasoning.muse_glimmer_reasoning_parser import MuseGlimmerReasoningParser
from vllm.tool_parsers.muse_glimmer_tool_parser import MuseGlimmerToolParser
R: MuseGlimmerReasoningParser
T: MuseGlimmerToolParser
@pytest.fixture(autouse=True)
def _fresh_parsers():
"""Give each test request-scoped parser state through the real constructors."""
global R, T
R = MuseGlimmerReasoningParser(object())
T = MuseGlimmerToolParser(object())
# Any framing token that must NEVER appear in surfaced reasoning/content.
_FRAMING = [
"<|start|>",
"<|message|>",
"<|eom|>",
"<|eot|>",
"to=self",
"to=user",
"to=read.read",
"<atem:",
]
class _FakeReq:
"""Minimal ChatCompletionRequest stand-in (no registered tools)."""
tools = None
def _req(*names):
"""A request with ``names`` registered as tools."""
return SimpleNamespace(
tools=[SimpleNamespace(function=SimpleNamespace(name=n)) for n in names]
)
def _call(name):
"""One tool-call turn invoking ``name``."""
return (
f"<|start|>assistant to={name}<|message|>"
f'<atem:function_calls>\n<atem:invoke name="{name}">\n'
f'<atem:parameter name="city">Paris</atem:parameter>\n'
f"</atem:invoke>\n</atem:function_calls>"
)
# ---------------------------------------------------------------- tool calls
def test_single_tool_call_after_reasoning():
raw = (
"to=self<|message|>Let me check the weather.<|eom|>"
"<|start|>assistant to=weather.get<|message|>"
'<atem:function_calls>\n<atem:invoke name="weather.get">\n'
'<atem:parameter name="city">Paris</atem:parameter>\n'
'<atem:parameter name="units">celsius</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls><|eot|>"
)
out = MuseGlimmerToolParser.extract_tool_calls(T, raw, None)
assert out.tools_called and len(out.tool_calls) == 1
assert out.tool_calls[0].function.name == "weather.get"
assert json.loads(out.tool_calls[0].function.arguments) == {
"city": "Paris",
"units": "celsius",
}
def test_parallel_calls_across_eom_boundaries():
raw = (
"<|start|>assistant to=math.add<|message|>"
'<atem:function_calls>\n<atem:invoke name="math.add">\n'
'<atem:parameter name="a">1</atem:parameter>\n'
'<atem:parameter name="b">2</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls><|eom|>"
"<|start|>assistant to=math.mul<|message|>"
'<atem:function_calls>\n<atem:invoke name="math.mul">\n'
'<atem:parameter name="a">3</atem:parameter>\n'
'<atem:parameter name="b">4</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls><|eot|>"
)
out = MuseGlimmerToolParser.extract_tool_calls(T, raw, None)
assert out.tools_called and len(out.tool_calls) == 2, len(out.tool_calls)
assert [t.function.name for t in out.tool_calls] == ["math.add", "math.mul"]
# JSON-typed values decode to ints
assert json.loads(out.tool_calls[0].function.arguments) == {"a": 1, "b": 2}
def test_echoed_invoke_in_reasoning_is_not_parsed():
"""Channel scoping: an invoke quoted inside reasoning is not a call."""
raw = (
'to=self<|message|>I could call <atem:invoke name="evil.fn">'
'<atem:parameter name="x">1</atem:parameter></atem:invoke> '
"but I will not.<|eom|>"
"<|start|>assistant to=user<|message|>The answer is 42.<|eot|>"
)
out = MuseGlimmerToolParser.extract_tool_calls(T, raw, None)
assert not out.tools_called, "channel scoping failed -- echoed invoke parsed!"
assert out.content == "The answer is 42.", repr(out.content)
def test_plain_answer_yields_no_tool_calls():
out = MuseGlimmerToolParser.extract_tool_calls(
T, "to=user<|message|>Just a plain answer.<|eot|>", None
)
assert not out.tools_called
def test_json_object_array_and_bool_params_decode():
raw = (
"<|start|>assistant to=api.call<|message|>"
'<atem:function_calls>\n<atem:invoke name="api.call">\n'
'<atem:parameter name="payload">{"nested": [1, 2, 3]}</atem:parameter>\n'
'<atem:parameter name="flag">true</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls><|eot|>"
)
out = MuseGlimmerToolParser.extract_tool_calls(T, raw, None)
assert json.loads(out.tool_calls[0].function.arguments) == {
"payload": {"nested": [1, 2, 3]},
"flag": True,
}
# ------------------------------------------------- reasoning -> tool handoff
def test_reasoning_to_toolcall_handoff():
"""The regression: content=None here starved the tool parser."""
raw = (
" to=self<|message|>Let me call the tool.<|eom|>"
"<|start|>assistant to=weather.get<|message|>"
'<atem:function_calls>\n<atem:invoke name="weather.get">\n'
'<atem:parameter name="city">Paris</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls>"
)
reasoning, content = MuseGlimmerReasoningParser.extract_reasoning(R, raw, None)
assert reasoning == "Let me call the tool.", repr(reasoning)
assert content is not None and "<atem:invoke" in content, repr(content)
out = MuseGlimmerToolParser.extract_tool_calls(T, content, None)
assert out.tools_called and len(out.tool_calls) == 1
assert out.tool_calls[0].function.name == "weather.get"
def test_reasoning_then_user_answer():
raw = (
" to=self<|message|>thinking<|eom|>"
"<|start|>assistant to=user<|message|>The answer is 42.<|eot|>"
)
reasoning, content = MuseGlimmerReasoningParser.extract_reasoning(R, raw, None)
assert reasoning == "thinking", repr(reasoning)
assert content == "The answer is 42.", repr(content)
assert not MuseGlimmerToolParser.extract_tool_calls(T, content, None).tools_called
def test_plain_content_without_framing_passes_through():
reasoning, content = MuseGlimmerReasoningParser.extract_reasoning(
R, "Just a direct answer.", None
)
assert reasoning is None and content == "Just a direct answer.", (
reasoning,
content,
)
def test_reasoning_then_parallel_calls():
raw = (
" to=self<|message|>need two calls<|eom|>"
"<|start|>assistant to=math.add<|message|>"
'<atem:function_calls>\n<atem:invoke name="math.add">\n'
'<atem:parameter name="a">1</atem:parameter>\n</atem:invoke>\n'
"</atem:function_calls><|eom|>"
"<|start|>assistant to=math.mul<|message|>"
'<atem:function_calls>\n<atem:invoke name="math.mul">\n'
'<atem:parameter name="a">3</atem:parameter>\n</atem:invoke>\n'
"</atem:function_calls><|eot|>"
)
reasoning, content = MuseGlimmerReasoningParser.extract_reasoning(R, raw, None)
assert reasoning == "need two calls", repr(reasoning)
out = MuseGlimmerToolParser.extract_tool_calls(T, content, None)
assert [t.function.name for t in out.tool_calls] == ["math.add", "math.mul"], (
out.tool_calls
)
# ----------------------------------------------------------------- streaming
def _stream(raw: str, chunk: int):
"""Feed ``raw`` incrementally in ``chunk``-char steps through BOTH streaming
parsers; return (reasoning, content, tool_calls)."""
reasoning, content, toolcalls = [], [], []
prev = ""
i = 0
while i < len(raw):
cur = raw[: i + chunk]
delta = cur[len(prev) :]
dm = MuseGlimmerReasoningParser.extract_reasoning_streaming(
R, prev, cur, delta, [], [], []
)
if dm is not None:
if getattr(dm, "reasoning", None):
reasoning.append(dm.reasoning)
content_delta = getattr(dm, "content", None)
# Tool-channel content is an internal handoff to the tool parser,
# not client-visible content from the unified parser.
if content_delta and "<atem:function_calls>" not in content_delta:
content.append(content_delta)
dt = MuseGlimmerToolParser.extract_tool_calls_streaming(
T, prev, cur, delta, [], [], [], _FakeReq()
)
if dt is not None and dt.tool_calls:
toolcalls.extend(dt.tool_calls)
prev = cur
i += chunk
return "".join(reasoning), "".join(content), toolcalls
def _fn_of(tc):
fn = tc.function
if isinstance(fn, dict):
return fn.get("name"), fn.get("arguments")
return fn.name, fn.arguments
RAW_TOOLCALL = (
" to=self<|message|>I should read the hostname file to answer.<|eom|>"
"<|start|>assistant to=read.read<|message|>"
'<atem:function_calls>\n<atem:invoke name="read.read">\n'
'<atem:parameter name="path">/etc/hostname</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls>"
)
RAW_ANSWER = (
" to=self<|message|>Think about it.<|eom|>"
"<|start|>assistant to=user<|message|>The answer is 42.<|eot|>"
)
# NO closing <|eom|> -> truncated CoT
RAW_TRUNCATED = (
" to=self<|message|>Maybe I should call "
'<atem:function_calls>\n<atem:invoke name="read.read">\n'
'<atem:parameter name="path">/etc/hostname</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls> but wait"
)
def _check_toolcall_stream(chunk):
reasoning, content, tcs = _stream(RAW_TOOLCALL, chunk)
# (a) no framing token leaks into reasoning or content
for f in _FRAMING:
assert f not in reasoning, (
f"framing {f!r} leaked into reasoning (chunk={chunk})"
)
assert f not in content, f"framing {f!r} leaked into content (chunk={chunk})"
# (b) exactly one tool_call with correct name + args
assert len(tcs) == 1, f"expected 1 tool_call, got {len(tcs)} (chunk={chunk})"
name, args = _fn_of(tcs[0])
assert name == "read.read", name
assert json.loads(args) == {"path": "/etc/hostname"}, args
assert tcs[0].index == 0 and tcs[0].type == "function" and tcs[0].id
# (c) reasoning captured separately and clean
assert reasoning == "I should read the hostname file to answer.", repr(reasoning)
assert content == "", repr(content)
def test_streaming_toolcall_chunk3():
_check_toolcall_stream(3)
def test_streaming_toolcall_charwise():
# worst case: markers arrive one char at a time (mid-marker deltas)
_check_toolcall_stream(1)
def test_streaming_toolcall_bigchunks():
_check_toolcall_stream(17)
def test_streaming_reasoning_then_content():
reasoning, content, tcs = _stream(RAW_ANSWER, 3)
for f in _FRAMING:
assert f not in reasoning and f not in content, f
assert reasoning == "Think about it.", repr(reasoning)
assert content == "The answer is 42.", repr(content)
assert tcs == []
def test_truncated_cot_no_toolcall_nonstreaming():
out = MuseGlimmerToolParser.extract_tool_calls(T, RAW_TRUNCATED, _FakeReq())
assert not out.tools_called and out.tool_calls == []
# partial reasoning must still be recovered by the reasoning parser
reasoning, _ = MuseGlimmerReasoningParser.extract_reasoning(
R, RAW_TRUNCATED, _FakeReq()
)
assert reasoning and "Maybe I should call" in reasoning, repr(reasoning)
def test_truncated_cot_no_toolcall_streaming():
_, _, tcs = _stream(RAW_TRUNCATED, 3)
assert tcs == [], f"truncated CoT invoke leaked as streaming tool call: {tcs}"
# ------------------------------------------------------- name normalization
#
# MuseGlimmer emits `get_weather.get_weather` for a bare-registered
# `get_weather`, and `weather.get` verbatim for a namespaced one. The parser
# normalizes against the tools actually registered on the request.
def test_doubled_bare_name_collapses():
out = MuseGlimmerToolParser.extract_tool_calls(
T, _call("get_weather.get_weather"), _req("get_weather")
)
assert out.tools_called and out.tool_calls[0].function.name == "get_weather", (
out.tool_calls[0].function.name
)
def test_namespaced_name_preserved():
out = MuseGlimmerToolParser.extract_tool_calls(
T, _call("weather.get"), _req("weather.get")
)
assert out.tool_calls[0].function.name == "weather.get"
def test_unregistered_namespace_is_preserved():
# Suffix-only matching can silently dispatch a tool from the wrong namespace.
out = MuseGlimmerToolParser.extract_tool_calls(
T, _call("foo.get_weather"), _req("get_weather")
)
assert out.tool_calls[0].function.name == "foo.get_weather"
def test_trailing_segment_ambiguous_left_alone():
# two registered tools share leaf 'get' -> ambiguous -> do NOT rewrite
out = MuseGlimmerToolParser.extract_tool_calls(
T, _call("x.get"), _req("weather.get", "time.get")
)
assert out.tool_calls[0].function.name == "x.get"
def test_no_registered_tools_passthrough():
out = MuseGlimmerToolParser.extract_tool_calls(
T, _call("get_weather.get_weather"), None
)
assert out.tool_calls[0].function.name == "get_weather.get_weather"
def test_exact_match_kept():
out = MuseGlimmerToolParser.extract_tool_calls(
T, _call("get_weather"), _req("get_weather")
)
assert out.tool_calls[0].function.name == "get_weather"
@@ -0,0 +1,187 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""parse_delta-level regression tests for the MuseGlimmer parsers.
These drive the UNIFIED parser-engine streaming API the serving layer actually
uses (``DelegatingParser.parse_delta``), NOT the ``extract_*_streaming`` methods
directly. This is the coverage that was missing when a live no-tools streaming
request leaked raw harmony framing into ``content``: the phase machine marked
``reasoning_ended=True`` at the prompt boundary (because ``is_reasoning_end`` on
a prompt with no ``to=self`` wrongly returned True), skipping the reasoning
phase for the whole generation.
Requires a real MuseGlimmer tokenizer; skipped if the checkpoint is unavailable.
"""
import json
import os
import pytest
CKPT = os.environ.get("MUSE_GLIMMER_CKPT", "")
pytestmark = pytest.mark.skipif(
not os.path.isdir(CKPT), reason=f"MuseGlimmer checkpoint not found at {CKPT}"
)
_FRAMING = [
"<|start|>",
"<|message|>",
"<|eom|>",
"<|eot|>",
"to=self",
"to=user",
"<atem:",
]
PROMPT = "<|start|>user<|message|>hi<|eom|><|start|>assistant"
class _Req:
tools = None
tool_choice = None
include_reasoning = True
@pytest.fixture(scope="module")
def tok():
from transformers import AutoTokenizer
return AutoTokenizer.from_pretrained(CKPT, trust_remote_code=True)
@pytest.fixture(scope="module")
def parser_cls():
from vllm.parser import ParserManager
return ParserManager.get_parser(
tool_parser_name="muse_glimmer",
reasoning_parser_name="muse_glimmer",
enable_auto_tools=True,
)
def _drive(parser_cls, tok, gen_text, req=None):
"""Feed gen_text token-by-token through parse_delta (as serving does)."""
from vllm.parser.abstract_parser import StreamState
if req is None:
req = _Req()
parser = parser_cls(tok)
parser._stream_state = StreamState()
prompt_ids = tok.encode(PROMPT, add_special_tokens=False)
gen_ids = tok.encode(gen_text, add_special_tokens=False)
reasoning, content, tools = [], [], []
for i, tid in enumerate(gen_ids):
dm = parser.parse_delta(
tok.decode([tid]),
[tid],
req,
prompt_token_ids=prompt_ids if i == 0 else None,
finished=(i == len(gen_ids) - 1),
)
if dm is None:
continue
if getattr(dm, "reasoning", None):
reasoning.append(dm.reasoning)
if getattr(dm, "content", None):
content.append(dm.content)
for tc in getattr(dm, "tool_calls", None) or []:
fn = tc.function
name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None)
args = (
fn.get("arguments")
if isinstance(fn, dict)
else getattr(fn, "arguments", None)
)
tools.append((tc.index, name, args))
return "".join(reasoning), "".join(content), tools
def _assert_no_framing(text):
for f in _FRAMING:
assert f not in text, f"framing {f!r} leaked: {text!r}"
def test_parse_delta_no_tools_reasoning_then_answer(parser_cls, tok):
# The core regression: reasoning phase must stay active for a no-tools turn.
reasoning, content, tools = _drive(
parser_cls,
tok,
" to=self<|message|>Let me think step by step about the sum.<|eom|>"
"<|start|>assistant to=user<|message|>The answer is 42.<|eot|>",
)
_assert_no_framing(content)
assert reasoning == "Let me think step by step about the sum.", repr(reasoning)
assert content == "The answer is 42.", repr(content)
assert tools == []
def test_parse_delta_content_only(parser_cls, tok):
reasoning, content, tools = _drive(
parser_cls,
tok,
" to=user<|message|>Just a direct answer.<|eot|>",
)
_assert_no_framing(content)
assert content == "Just a direct answer.", repr(content)
assert tools == []
def test_parse_delta_tool_call(parser_cls, tok):
reasoning, content, tools = _drive(
parser_cls,
tok,
" to=self<|message|>I should read the hostname.<|eom|>"
"<|start|>assistant to=read.read<|message|>"
'<atem:function_calls>\n<atem:invoke name="read.read">\n'
'<atem:parameter name="path">/etc/hostname</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls>",
)
_assert_no_framing(content)
assert reasoning == "I should read the hostname.", repr(reasoning)
assert len(tools) == 1, tools
idx, name, args = tools[0]
assert idx == 0 and name == "read.read"
assert json.loads(args) == {"path": "/etc/hostname"}
def test_parse_delta_truncated_cot_no_toolcall(parser_cls, tok):
# Contemplated invoke inside an unterminated to=self block: no tool call,
# no framing leak into content, partial reasoning recovered.
reasoning, content, tools = _drive(
parser_cls,
tok,
" to=self<|message|>Maybe I should call "
'<atem:function_calls>\n<atem:invoke name="read.read">\n'
'<atem:parameter name="path">/etc/hostname</atem:parameter>\n'
"</atem:invoke>\n</atem:function_calls> but wait",
)
_assert_no_framing(content)
assert tools == [], f"contemplated invoke leaked as tool call: {tools}"
assert "Maybe I should call" in reasoning, repr(reasoning)
def test_parse_delta_reasoning_suppressed_when_not_requested(parser_cls, tok):
class _NoReasonReq:
tools = None
tool_choice = None
include_reasoning = False
reasoning, content, tools = _drive(
parser_cls,
tok,
" to=self<|message|>secret thoughts<|eom|>"
"<|start|>assistant to=user<|message|>Public answer.<|eot|>",
req=_NoReasonReq(),
)
assert reasoning == "", repr(reasoning) # suppressed
assert content == "Public answer.", repr(content)
_assert_no_framing(content)
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,174 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MuseGlimmer config normalization.
Two schema pairs have to converge on the same model, and each has produced a
silent-wrong-output bug:
1. flat (legacy converter) vs nested (canonical) ``config.json``. A FLAT
config once deserialized to an all-default text config, silently dropping
every checkpoint value.
2. native vs modular attention config. The modular HF text_config OMITS
use_qk_norm / use_attn_output_gate (read as None) and ships a PRE-FOLDED
qk_scale_factor (43.784/sqrt(128)=3.87). Missing flags must read as True
(MuseGlimmer always applies QK-norm + output gate) and the query pre-scale
must normalize so native and modular land on the same number.
"""
import math
from typing import Any
from vllm.model_executor.models.muse_glimmer import (
_muse_glimmer_query_prescale,
_muse_glimmer_use_attn_output_gate,
_muse_glimmer_use_qk_norm,
)
from vllm.transformers_utils.configs.muse_glimmer import MuseGlimmerConfig
# A representative FLAT config (Ruan rl_v1/hf shape), trimmed.
FLAT: dict[str, Any] = {
"architectures": ["MuseGlimmerForCausalLM"],
"model_type": "muse_glimmer",
"has_vision": True,
"bos_token_id": 200000,
"eos_token_id": 200001,
"vocab_size": 202048,
"hidden_size": 6656,
"intermediate_size": 19968,
"num_hidden_layers": 52,
"num_attention_heads": 32,
"num_key_value_heads": 2,
"head_dim": 128,
"hidden_act": "silu",
"max_position_embeddings": 16384,
"rms_norm_eps": 1e-5,
"post_norm_eps": 1e-8,
"qk_scale_factor": 43.7840518911,
"use_qk_norm": True,
"use_attn_output_gate": True,
"output_multiplier": 0.19611613513818404,
"output_soft_cap_temp": 20.0,
"normalize_tok_embeddings": True,
"rope_theta": 500000.0,
"sliding_window": 2048,
"patch_token_id": 200092,
"vision_latent_dim": 1536,
"vision_heads": 16,
"vision_layers": 50,
"vision_output_dim": 6144,
"vision_patch_size": 14,
"vision_patch_temporal": 2,
"vision_adapter_dim": 4096,
"vision_pos_emb_grid_h": 32,
"vision_pos_emb_grid_w": 32,
}
NESTED: dict[str, Any] = {
"architectures": ["MuseGlimmerForCausalLM"],
"model_type": "muse_glimmer",
"image_token_id": 200092,
"text_config": {
"model_type": "muse_glimmer_text",
"vocab_size": 202048,
"hidden_size": 6656,
"num_hidden_layers": 52,
"hidden_activation": "silu",
"final_logit_softcapping": 20.0,
"qk_scale_factor": 43.7840518911,
"rope_parameters": {"rope_type": "default", "rope_theta": 500000.0},
},
"vision_config": {
"model_type": "muse_glimmer_vision",
"hidden_size": 1536,
"num_hidden_layers": 50,
},
}
HEAD_DIM = 128
SQRT_HD = math.sqrt(HEAD_DIM)
NATIVE = 43.7840518911
FOLDED = NATIVE / SQRT_HD # 3.8700...
class Cfg:
"""Attention-config stand-in carrying only the fields under test."""
def __init__(self, **kw):
self.head_dim = HEAD_DIM
for k, v in kw.items():
setattr(self, k, v)
# ------------------------------------------------------------ flat vs nested
def test_flat_config_values_respected():
c = MuseGlimmerConfig(**FLAT)
t = c.text_config
assert t.hidden_size == 6656
assert t.num_hidden_layers == 52
assert t.vocab_size == 202048
assert t.head_dim == 128
assert t.hidden_activation == "silu" # renamed from hidden_act
assert t.final_logit_softcapping == 20.0 # renamed from output_soft_cap_temp
assert abs(t.output_multiplier - 0.19611613513818404) < 1e-12
assert abs(t.qk_scale_factor - 43.7840518911) < 1e-9
assert t.rope_parameters["rope_theta"] == 500000.0 # from flat rope_theta
# vision hoisted + renamed
assert c.vision_config.hidden_size == 1536
assert c.vision_config.num_hidden_layers == 50
assert c.vision_config.output_dim == 6144
# flat patch_token_id -> image_token_id
assert c.image_token_id == 200092
def test_flat_config_no_silent_default():
# The regression: a non-default value MUST be honored, not silently dropped.
flat = dict(FLAT)
flat["hidden_size"] = 4096
flat["num_hidden_layers"] = 40
c = MuseGlimmerConfig(**flat)
assert c.text_config.hidden_size == 4096, "flat hidden_size silently ignored!"
assert c.text_config.num_hidden_layers == 40, "flat num_hidden_layers ignored!"
def test_nested_config_unchanged():
c = MuseGlimmerConfig(**NESTED)
assert c.text_config.hidden_size == 6656
assert c.text_config.hidden_activation == "silu"
assert c.text_config.final_logit_softcapping == 20.0
assert c.vision_config.hidden_size == 1536
assert c.vision_config.num_hidden_layers == 50
assert c.image_token_id == 200092
# -------------------------------------------------------- native vs modular
def test_qk_norm_missing_defaults_true():
assert _muse_glimmer_use_qk_norm(Cfg(use_qk_norm=None)) is True # modular
assert _muse_glimmer_use_qk_norm(Cfg()) is True # absent
assert _muse_glimmer_use_qk_norm(Cfg(use_qk_norm=True)) is True # native
assert _muse_glimmer_use_qk_norm(Cfg(use_qk_norm=False)) is False # explicit off
def test_output_gate_missing_defaults_true():
assert _muse_glimmer_use_attn_output_gate(Cfg(use_attn_output_gate=None)) is True
assert _muse_glimmer_use_attn_output_gate(Cfg()) is True
assert _muse_glimmer_use_attn_output_gate(Cfg(use_attn_output_gate=False)) is False
def test_query_prescale_native_and_modular_converge():
# Both schemas must yield the SAME final scale_query_by (~3.87).
assert (
abs(_muse_glimmer_query_prescale(Cfg(qk_scale_factor=NATIVE)) - FOLDED) < 1e-9
)
assert (
abs(_muse_glimmer_query_prescale(Cfg(qk_scale_factor=FOLDED)) - FOLDED) < 1e-9
)
def test_query_prescale_explicit_wins():
c = Cfg(scale_query_by=FOLDED, qk_scale_factor=NATIVE)
assert abs(_muse_glimmer_query_prescale(c) - FOLDED) < 1e-9
+5 -1
View File
@@ -943,7 +943,11 @@ class SpeculativeConfig:
self.method = "eagle"
elif "eagle3" in self.draft_model_config.model.lower():
self.method = "eagle3"
elif "dflash" in self.draft_model_config.model.lower():
elif (
"dflash" in self.draft_model_config.model.lower()
or "MuseGlimmerAssistantModel"
in self.draft_model_config.architectures
):
self.method = "dflash"
elif (
"dspark" in self.draft_model_config.model.lower()
+11 -10
View File
@@ -1567,13 +1567,15 @@ class SupportsEagle3(SupportsEagleBase, Protocol):
parent_ref = self.get_language_model()
elif hasattr(self, "language_model"):
parent_ref = self.language_model
assert hasattr(parent_ref, "model"), (
"Model instance must have 'model' attribute to set number of layers"
)
assert isinstance(parent_ref.model, EagleModelMixin), (
# A multimodal model that builds its decoder inside
# `_mark_language_model` has get_language_model() return the inner
# decoder itself, which IS the EagleModelMixin and has no further
# `.model`. Unwrap only when there is something to unwrap.
holder = getattr(parent_ref, "model", parent_ref)
assert isinstance(holder, EagleModelMixin), (
"Model instance must inherit from EagleModelMixin to set auxiliary layers"
)
parent_ref.model._set_aux_hidden_state_layers(layers)
holder._set_aux_hidden_state_layers(layers)
def get_eagle3_default_aux_hidden_state_layers(self) -> tuple[int, ...]:
"""
@@ -1590,13 +1592,12 @@ class SupportsEagle3(SupportsEagleBase, Protocol):
parent_ref = self.get_language_model()
elif hasattr(self, "language_model"):
parent_ref = self.language_model
assert hasattr(parent_ref, "model"), (
"Model instance must have 'model' attribute to get number of layers"
)
assert hasattr(parent_ref.model, "layers"), (
# Same unwrap-only-if-needed rule as set_aux_hidden_state_layers.
holder = getattr(parent_ref, "model", parent_ref)
assert hasattr(holder, "layers"), (
"Model instance must have 'layers' attribute to get number of layers"
)
num_layers = len(parent_ref.model.layers)
num_layers = len(holder.layers)
return (2, num_layers // 2, num_layers - 3)
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -72,6 +72,26 @@ def dflash_has_any_non_causal(config: Qwen3Config) -> bool:
)
def dflash_target_rope_is_neox_style(target_model: nn.Module) -> bool | None:
"""The target's RoPE layout, from its first attention layer.
A DFlash head must rotate Q/K the way the target it was distilled against
does, and a mismatch is silent — acceptance collapses but nothing errors and
the output stays correct. Draft checkpoints do not carry this, so take it
from the target. None if the target uses no RoPE.
"""
language_model = (
target_model.get_language_model()
if hasattr(target_model, "get_language_model")
else target_model
)
for module in language_model.modules():
style = getattr(module, "is_neox_style", None)
if isinstance(style, bool):
return style
return None
def _get_dflash_fc_input_size(vllm_config: VllmConfig) -> int:
spec_config = vllm_config.speculative_config
config = spec_config.draft_model_config.hf_config
@@ -166,6 +186,7 @@ class DFlashQwen3Attention(nn.Module):
add_swa_attention_sink_bias: bool = False,
sliding_window: int | None = None,
causal: bool = False,
is_neox_style: bool = True,
cache_config: CacheConfig | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
@@ -209,6 +230,7 @@ class DFlashQwen3Attention(nn.Module):
self.rotary_emb = get_rope(
self.head_dim,
max_position=max_position,
is_neox_style=is_neox_style,
rope_parameters=rope_parameters,
)
@@ -291,6 +313,13 @@ class DFlashQwen3DecoderLayer(nn.Module):
# non-causal) from the draft config.
sliding_window, causal = _resolve_layer_attention(config, layer_idx)
# RoPE layout, copied off the target at load time by the draft loader
# (see `dflash_target_rope_is_neox_style`). Checkpoints do not carry it:
# a head distilled from an interleaved-RoPE target must rotate the way
# that target does, or every drafted Q/K is wrong and acceptance
# collapses with no error raised.
is_neox_style = getattr(config, "is_neox_style", True)
self.self_attn = DFlashQwen3Attention(
hidden_size=self.hidden_size,
num_heads=config.num_attention_heads,
@@ -301,6 +330,7 @@ class DFlashQwen3DecoderLayer(nn.Module):
add_swa_attention_sink_bias=add_swa_attention_sink_bias,
sliding_window=sliding_window,
causal=causal,
is_neox_style=is_neox_style,
head_dim=getattr(config, "head_dim", None),
cache_config=cache_config,
quant_config=quant_config,
@@ -345,7 +375,14 @@ class DFlashQwen3DecoderLayer(nn.Module):
@support_torch_compile
class DFlashQwen3Model(nn.Module):
hf_to_vllm_mapper = WeightsMapper(
orig_to_new_substr={"midlayer.": "layers.0."},
orig_to_new_substr={
"midlayer.": "layers.0.",
# Muse-Glimmer-30B-assistant names the aux-hidden-state encoder
# `encoder.fc` / `encoder.output_norm_enc`; this head calls them
# `fc` / `hidden_norm`. Same tensors and shapes, different names.
"encoder.output_norm_enc.": "hidden_norm.",
"encoder.fc.": "fc.",
},
orig_to_new_stacked={
".q_proj": (".qkv_proj", "q"),
".k_proj": (".qkv_proj", "k"),
+10
View File
@@ -183,6 +183,7 @@ _TEXT_GENERATION_MODELS = {
"NemotronHForCausalLM": ("nemotron_h", "NemotronHForCausalLM"),
"NemotronHPuzzleForCausalLM": ("nemotron_h", "NemotronHForCausalLM"),
"Olmo3ForCausalLM": ("olmo3", "Olmo3ForCausalLM"),
"MuseGlimmerForCausalLM": ("muse_glimmer", "MuseGlimmerForCausalLM"),
"OlmoHybridForCausalLM": ("olmo_hybrid", "OlmoHybridForCausalLM"),
"OlmoeForCausalLM": ("olmoe", "OlmoeForCausalLM"),
"OPTForCausalLM": ("opt", "OPTForCausalLM"),
@@ -530,6 +531,7 @@ _MULTIMODAL_MODELS = {
"NemotronH_Nano_Omni_Reasoning_V3": ("nano_nemotron_vl", "NemotronH_Nano_VL_V2"),
"NemotronH_Super_Omni_Reasoning_V3": ("nano_nemotron_vl", "NemotronH_Nano_VL_V2"),
"NVLM_D": ("nvlm_d", "NVLM_D_Model"),
"MuseGlimmerForConditionalGeneration": ("muse_glimmer", "MuseGlimmerForCausalLM"),
"OpenCUAForConditionalGeneration": ("opencua", "OpenCUAForConditionalGeneration"),
"OpenPanguVLForConditionalGeneration": (
"openpangu_vl",
@@ -623,6 +625,14 @@ _SPECULATIVE_DECODING_MODELS = {
"EagleLlama4ForCausalLM": ("llama4_eagle", "EagleLlama4ForCausalLM"),
"EagleMiniCPMForCausalLM": ("minicpm_eagle", "EagleMiniCPMForCausalLM"),
"DFlashDraftModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
# Muse Glimmer's DFlash draft head, reusing the generic qwen3_dflash
# implementation. EAGLEConfig rewrites a dflash draft's architecture to
# DFlash{arch} unless it already starts or ends with "DFlash" (see
# transformers_utils/configs/eagle.py), so the name the registry is asked
# for is DFlashMuseGlimmerAssistantModel -- same convention as
# DFlashLagunaForCausalLM. The bare name is kept as a defensive alias.
"MuseGlimmerAssistantModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
"DFlashMuseGlimmerAssistantModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
"DSparkDraftModel": ("vllm.models.deepseek_v4", "DSparkDeepseekV4ForCausalLM"),
"Qwen3DSparkModel": ("qwen3_dspark", "Qwen3DSparkForCausalLM"),
"K3DSparkModel": (
+4
View File
@@ -120,6 +120,10 @@ _REASONING_PARSERS_TO_REGISTER = {
"olmo3_reasoning_parser",
"Olmo3ReasoningParser",
),
"muse_glimmer": (
"muse_glimmer_reasoning_parser",
"MuseGlimmerReasoningParser",
),
"qwen3": (
"qwen3_engine_reasoning_parser",
"Qwen3ParserReasoningAdapter",
@@ -0,0 +1,327 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Reasoning-content parser for MuseGlimmer.
Port of the ``reasoning_content`` rule from the HuggingFace MuseGlimmer
``MUSE_GLIMMER_RESPONSE_SCHEMA`` (synced with internal master). MuseGlimmer emits
chain-of-thought in ``to=self`` channels delimited by ``<|message|>`` ... ``<|eom|>``:
to=self<|message|>...reasoning...<|eom|>
A turn may contain several ``to=self`` blocks interleaved with tool calls, and a
tool call or final answer follows in its own channel.
Because MuseGlimmer's framing markers (``<|message|>``, ``<|eom|>``) are not guaranteed
to be single vocab tokens across every checkpoint's tokenizer, this parser works
on the decoded text with regexes rather than the single start/end-token base class.
Usage: ``--reasoning-parser muse_glimmer``
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
import regex as re
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
_EOM = "<|eom|>"
_EOT = "<|eot|>"
_FUNCTION_CALLS_OPEN = "<atem:function_calls>"
_REASONING_OPEN = "to=self<|message|>"
_ASSISTANT_TURN_OPEN = "<|start|>assistant"
# A channel header: ``to=<recipient><|message|>`` where recipient is ``self``
# (reasoning), ``user`` (final answer) or ``<tool>[.<fn>]`` (tool call).
_CHANNEL_HEADER_RE = re.compile(r"to=(?P<recipient>[^\s<]+)<\|message\|>")
_HEADER_PAT = r"to=[^\s<]+<\|message\|>"
# Collapse the gap between reasoning blocks so multiple to=self spans join.
_COLLAPSE_RE = re.compile(
r"<\|eom\|>(?:(?!to=self<\|message\|>).)*?to=self<\|message\|>", re.DOTALL
)
_REASONING_RE = re.compile(r"to=self<\|message\|>(.*?)<\|eom\|>", re.DOTALL)
_CONTENT_RE = re.compile(
r"to=user<\|message\|>(.*?)(?=<\|eot\|>|<\|eom\|>|$)", re.DOTALL
)
# Strip a CLOSED reasoning span (header .. <|eom|>).
_STRIP_REASONING_RE = re.compile(
r"(?:<\|start\|>assistant\s*)?to=self<\|message\|>.*?<\|eom\|>", re.DOTALL
)
# An UNTERMINATED trailing reasoning span. The model sometimes leaves the
# analysis channel WITHOUT emitting <|eom|>, writing a bare
# ``to=<tool><|message|>`` header instead (observed deterministically for a call
# with EMPTY arguments on a tool that has optional parameters; reproduced on
# other engines too, so it is a model-side defect, not engine-specific).
#
# These two patterns MUST therefore stop at the next channel header rather than
# running to end-of-text. An unbounded ``...$`` version consumes the real tool
# call along with the reasoning: `is_reasoning_end` then never fires, the parser
# never leaves the reasoning phase, the tool parser is never invoked, and the
# entire generation is dropped (empty reasoning, empty content, no tool call).
_STRIP_OPEN_REASONING_RE = re.compile(
r"(?:<\|start\|>assistant\s*)?to=self<\|message\|>"
r"(?:(?!<\|eom\|>)(?!" + _HEADER_PAT + r").)*"
r"(?=" + _HEADER_PAT + r"|$)",
re.DOTALL,
)
_OPEN_REASONING_RE = re.compile(
r"to=self<\|message\|>((?:(?!<\|eom\|>)(?!" + _HEADER_PAT + r").)*)"
r"(?=" + _HEADER_PAT + r"|$)",
re.DOTALL,
)
# Markers whose PREFIX could appear at the tail of an OPEN (still-streaming) body.
_HOLDBACK_MARKERS = (_EOM, _EOT, "<|start|>", "<|message|>")
# A trailing fragment that could still grow into a channel header (" t", " to",
# " to=", " to=skill"). Without this the recipient name leaks into reasoning and
# then has to be un-emitted once ``<|message|>`` arrives.
_OPEN_TAIL_HEADER_RE = re.compile(r"[\s](?:t|to|to=[^\s<]*)$")
def _current_assistant_turn(text: str) -> str:
"""Return only the text generated in the current assistant turn.
``is_reasoning_end`` is evaluated on the PROMPT token-ids at stream start,
and an MuseGlimmer prompt legitimately contains ATEM markers (``render_tool_defs``
writes a literal ``<atem:function_calls>`` example into the system message,
and prior assistant turns may carry real tool calls). Anchoring on the last
channel-open keeps prompt text from deciding the phase.
"""
idx = text.rfind(_ASSISTANT_TURN_OPEN)
return text[idx + len(_ASSISTANT_TURN_OPEN) :] if idx != -1 else text
def _trim_open_body(body: str) -> str:
"""Hold back any tail of a still-growing body that could still be framing.
Iterated to a fixpoint because the two cases compose: `" to=skill<"` needs
the partial-marker trim (``<``) before the partial-header trim can see
`" to=skill"`. Trimming only once leaks the recipient name as reasoning.
"""
while True:
trimmed = body
for marker in _HOLDBACK_MARKERS:
for k in range(min(len(marker) - 1, len(trimmed)), 0, -1):
if trimmed.endswith(marker[:k]):
trimmed = trimmed[:-k]
break
else:
continue
break
header_tail = _OPEN_TAIL_HEADER_RE.search(trimmed)
if header_tail is not None:
trimmed = trimmed[: header_tail.start()]
if trimmed == body:
return body
body = trimmed
class MuseGlimmerReasoningParser(ReasoningParser):
def __init__(self, tokenizer, *args, **kwargs) -> None:
super().__init__(tokenizer, *args, **kwargs)
# Cursors over what was ACTUALLY emitted. Diffing a freshly reclassified
# `previous_text` is unsafe: a classified body legitimately shrinks when
# a partial header becomes recognisable, and diffing against the shrunken
# value re-emits text that already went out.
self._emitted_reasoning: str = ""
self._emitted_content: str = ""
self._tool_handoff_done: bool = False
def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
"""Preserve MuseGlimmer's ATEM framing tokens in the decoded output.
vLLM's serving default is ``skip_special_tokens=True``, which strips
``<|start|>`` / ``<|message|>`` / ``<|eom|>`` / ``<|eot|>`` before the
parsers run, collapsing reasoning into content and breaking channel
scoping. Unlike the base tool-parser hook we do NOT touch
``structured_outputs`` -- MuseGlimmer emits native ATEM, not JSON.
"""
request.skip_special_tokens = False
return request
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
"""Whether the model has left reasoning and opened a TOOL channel.
A ``to=user`` answer is NOT a reason to leave the reasoning phase -- this
parser surfaces that content itself. Only a real tool channel switches
the ``DelegatingParser`` phase machine over to the tool parser.
Both closed and unterminated reasoning spans are stripped before the
check, so an ``<atem:invoke>`` the model merely echoes inside its CoT
never flips the phase.
"""
try:
text = self.model_tokenizer.decode(input_ids)
except Exception:
return False
remainder = self._tool_channel_remainder(text)
return _FUNCTION_CALLS_OPEN in remainder or "<atem:invoke" in remainder
def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
) -> bool:
return self.is_reasoning_end(list(input_ids))
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
# Content-id slicing is unreliable for multi-token markers; the serving
# path uses extract_reasoning() for the final split.
return []
@classmethod
def _scoped_turn(cls, text: str) -> str:
"""Current assistant turn with reasoning spans removed."""
scoped = _current_assistant_turn(text)
scoped = _STRIP_REASONING_RE.sub("", scoped)
return _STRIP_OPEN_REASONING_RE.sub("", scoped)
@classmethod
def _tool_channel_remainder(cls, text: str) -> str:
"""Text from the first tool-channel header onward, framing INCLUDED.
``DelegatingParser.parse_delta`` rebuilds ``current_text`` from whatever
this parser returns as ``.content`` on the transition delta and commits
it; anything not returned is destroyed. It must start AT the
``to=<name><|message|>`` header -- handing over the text after the header
loses the recipient, and the tool parser then sees a bare ``<|message|>``,
classifies it as the content channel, and leaks the ATEM markup.
"""
scoped = cls._scoped_turn(text)
for match in _CHANNEL_HEADER_RE.finditer(scoped):
if match.group("recipient") not in ("self", "user"):
return scoped[match.start() :]
return ""
@staticmethod
def _classify_bodies(text: str) -> tuple[str, str]:
"""Split ``text`` into (reasoning_body, content_body), channel-aware.
Framing markers and tool channels contribute nothing -- the tool parser
owns those. A body ends at ``<|eom|>`` / ``<|eot|>``, at the next channel
header, or at end-of-text (an OPEN body, which is held back).
"""
reasoning_parts: list[str] = []
content_parts: list[str] = []
pos = 0
n = len(text)
while pos < n:
match = _CHANNEL_HEADER_RE.search(text, pos)
if not match:
break
recipient = match.group("recipient")
body_start = match.end()
eom = text.find(_EOM, body_start)
eot = text.find(_EOT, body_start)
terminators = [p for p in (eom, eot) if p != -1]
next_header = _CHANNEL_HEADER_RE.search(text, body_start)
if next_header is not None:
terminators.append(next_header.start())
body_end = min(terminators) if terminators else n
body = text[body_start:body_end]
if not terminators:
body = _trim_open_body(body)
if recipient == "self":
reasoning_parts.append(body)
elif (
# Never surface tool XML echoed into a user channel.
recipient == "user"
and _FUNCTION_CALLS_OPEN not in body
and "<atem:invoke" not in body
):
content_parts.append(body)
if terminators and body_end in (eom, eot):
pos = body_end + len(_EOM if body_end == eom else _EOT)
else:
pos = body_end
return "".join(reasoning_parts), "".join(content_parts)
def get_streaming_fallback_content(
self,
previous_text: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> str | None:
"""Promote un-surfaced content when the stream ends mid-reasoning.
``DelegatingParser.finalize_generation`` calls this when
``reasoning_ended`` is still False. Returns only the channel-classified
``to=user`` body, and only the portion not already streamed.
"""
_, content_body = self._classify_bodies(previous_text)
remainder = content_body[len(self._emitted_content) :]
return remainder or None
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
collapsed = _COLLAPSE_RE.sub("\n", model_output)
matches = _REASONING_RE.findall(collapsed)
reasoning = "\n".join(matches) if matches else None
# Truncation fallback: generation stopped inside a to=self block, so
# there is no closing <|eom|>. Bounded at the next channel header so a
# real tool call that follows a header-less channel switch is not
# absorbed into the reasoning field.
open_match = _OPEN_REASONING_RE.search(model_output)
if open_match and open_match.group(1):
partial = open_match.group(1)
reasoning = f"{reasoning}\n{partial}" if reasoning else partial
# Content is everything that is not a reasoning block. In a
# reasoning+tool-call turn there is no to=user answer, but the tool
# channels MUST be forwarded -- the unified parser runs the tool parser
# on this returned `content`, not on the original model_output.
remainder = _STRIP_REASONING_RE.sub("", model_output)
remainder = _STRIP_OPEN_REASONING_RE.sub("", remainder)
if "<atem:invoke" in remainder or _FUNCTION_CALLS_OPEN in remainder:
return reasoning, (remainder or None)
content_match = _CONTENT_RE.search(model_output)
if content_match:
content = content_match.group(1) or None
elif _REASONING_OPEN in model_output:
content = None
else:
content = model_output or None
reasoning = None
return reasoning, 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:
"""Channel-aware streaming split of reasoning vs content.
Classifies the full ``current_text`` and emits only what has not been
emitted yet, so no framing token is ever surfaced and a delta straddling
a channel boundary only contributes the portion inside a real body.
"""
curr_reason, curr_content = self._classify_bodies(current_text)
reasoning_delta = ""
if curr_reason.startswith(self._emitted_reasoning) and len(curr_reason) > len(
self._emitted_reasoning
):
reasoning_delta = curr_reason[len(self._emitted_reasoning) :]
self._emitted_reasoning = curr_reason
content_delta = ""
if curr_content.startswith(self._emitted_content) and len(curr_content) > len(
self._emitted_content
):
content_delta = curr_content[len(self._emitted_content) :]
self._emitted_content = curr_content
# Hand the tool channel to the tool parser exactly once, starting at its
# header. parse_delta discards anything not returned here.
#
# This MUST fire on the same delta where is_reasoning_end() flips, i.e.
# only once the tool channel actually contains ATEM. Emitting it earlier
# -- when only the bare `to=<name><|message|>` header has arrived -- keeps
# the parser in the reasoning phase, so parse_delta never replaces this
# DeltaMessage with the tool parser's and the header is delivered to the
# client as visible content.
handoff = ""
if not self._tool_handoff_done:
remainder = self._tool_channel_remainder(current_text)
if _FUNCTION_CALLS_OPEN in remainder or "<atem:invoke" in remainder:
handoff = remainder
self._tool_handoff_done = True
if handoff:
return DeltaMessage(reasoning=reasoning_delta or None, content=handoff)
if reasoning_delta and content_delta:
return DeltaMessage(reasoning=reasoning_delta, content=content_delta)
if reasoning_delta:
return DeltaMessage(reasoning=reasoning_delta)
if content_delta:
return DeltaMessage(content=content_delta)
return None
+4
View File
@@ -154,6 +154,10 @@ _TOOL_PARSERS_TO_REGISTER = {
"olmo3_tool_parser",
"Olmo3PythonicToolParser",
),
"muse_glimmer": (
"muse_glimmer_tool_parser",
"MuseGlimmerToolParser",
),
"openai": (
"gptoss_tool_parser",
"GptOssToolParser",
@@ -0,0 +1,501 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""ATEM tool-call parser for MuseGlimmer.
Faithful port of the MuseGlimmer ``response_schema`` tool-call contract from the
HuggingFace MuseGlimmer export (``convert_muse_glimmer_weights_to_hf.py``:
``MUSE_GLIMMER_RESPONSE_SCHEMA``).
MuseGlimmer emits tool calls in an XML-ish ATEM format inside channel-scoped messages:
<|start|>assistant to=self<|message|>...reasoning...<|eom|>
<|start|>assistant to=<tool>.<fn><|message|>
<atem:function_calls>
<atem:invoke name="tool.fn">
<atem:parameter name="arg">value</atem:parameter>
</atem:invoke>
</atem:function_calls><|eom|> # non-final call
<|start|>assistant to=user<|message|>...final answer...<|eot|>
Channel scoping is essential: an ``<atem:invoke>`` echoed inside a ``to=self``
reasoning block or a ``to=user`` final answer must NOT be parsed as a real tool
call.
Rather than *subtracting* reasoning/answer spans with regex substitutions (the
approach the HF ``response_schema`` uses, which is safe only on a complete,
well-formed turn), this parser *segments* the output into messages and then
*selects* the tool-channel bodies. On a complete turn the two are equivalent;
on a truncated or damaged turn, subtraction can delete a valid tool call
(an unterminated ``to=self`` block makes the non-greedy strip run to the next
``<|eom|>``, which belongs to the tool-call message) whereas selection cannot.
Usage: ``--enable-auto-tool-choice --tool-call-parser muse_glimmer``
"""
from __future__ import annotations
import json
from collections.abc import Iterator, Sequence
import regex as re
from openai.types.responses import ToolChoiceFunction
from transformers import PreTrainedTokenizerBase
from vllm.entrypoints.chat_utils import make_tool_call_id
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionNamedToolChoiceParam,
ChatCompletionRequest,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.logger import init_logger
from vllm.tool_parsers.abstract_tool_parser import (
Tool,
ToolParser,
)
logger = init_logger(__name__)
# --- Message framing -------------------------------------------------------
# An assistant message header. All three parts are optional except the
# <|message|> terminator:
# "<|start|>assistant to=get_weather<|message|>" -- after an <|eom|> boundary
# " to=self<|message|>" -- first message of a turn
# (the prompt already ended
# with "<|start|>assistant")
# "<|message|>" -- bare recipient (public CoT
# / untagged content)
_MSG_HEADER_RE = re.compile(
r"(?:<\|start\|>\s*assistant)?[^\S\n]*(?:to=(?P<rcpt>[A-Za-z0-9_.\-]+))?<\|message\|>"
)
_MSG_END_RE = re.compile(r"<\|eom\|>|<\|eot\|>")
# Recipients whose bodies are NOT tool calls.
_REASONING_RECIPIENT = "self"
_USER_RECIPIENT = "user"
# Structural markers that must never reach the client. A streamed body is held
# back by up to len(marker)-1 characters so a marker split across two chunks is
# not emitted as content.
_STRUCTURAL_MARKERS = ("<|eom|>", "<|eot|>", "<|start|>", "<|message|>")
_MAX_MARKER_LEN = max(len(m) for m in _STRUCTURAL_MARKERS)
# A trailing " to=NAME" that could still grow into a bare message header (the
# first message of a turn has no <|start|> prefix -- the prompt ends with
# "<|start|>assistant", so the model's first emitted text is " to=self<|message|>").
_OPEN_TAIL_TO_RE = re.compile(r"[^\S\n]+to=[A-Za-z0-9_.\-]*$")
# --- Tool-call extraction (unchanged from MUSE_GLIMMER_RESPONSE_SCHEMA) -------------
_INVOKE_RE = re.compile(r"(<atem:invoke\b.*?</atem:invoke>)", re.DOTALL)
_NAME_RE = re.compile(r'<atem:invoke\b[^>]*?\bname="([^"]+)"')
_PARAM_RE = re.compile(
r'<atem:parameter\b[^>]*?\bname="(?P<key>[^"]+)"[^>]*?>(?P<value>.*?)</atem:parameter>',
re.DOTALL,
)
_FUNCTION_CALLS_OPEN = "<atem:function_calls>"
def _decode_value(raw: str):
"""JSON-decode a parameter value when possible, else keep the raw string.
Mirrors the schema's ``x-parser: json`` with ``allow_non_json: True``.
"""
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError):
return raw
def _iter_messages(text: str) -> Iterator[tuple[str | None, str, bool]]:
"""Segment *text* into assistant messages.
Yields ``(recipient, body, closed)`` per message, where ``recipient`` is
``None`` for a bare ``<|message|>`` header and ``closed`` is False for a
message that has not (yet) seen ``<|eom|>`` / ``<|eot|>``.
A message is also terminated by the start of the NEXT header. Without that,
a reasoning block whose ``<|eom|>`` is missing (truncation, or a chunk
dropped at the reasoning -> tool transition) would absorb the tool-call
message that follows it and the call would be lost -- the same defect the
subtractive regexes have.
"""
pos = 0
while pos < len(text):
header = _MSG_HEADER_RE.search(text, pos)
if header is None:
return
body_start = header.end()
end = _MSG_END_RE.search(text, body_start)
nxt = _MSG_HEADER_RE.search(text, body_start)
body_end = end.start() if end is not None else len(text)
closed = end is not None
if nxt is not None and nxt.start() < body_end:
body_end = nxt.start()
closed = False
next_pos = nxt.start()
else:
next_pos = end.end() if end is not None else len(text)
body = text[body_start:body_end]
# A body can never legitimately contain <|start|>. Seeing one means the
# next header is only partially generated (its <|message|> has not
# arrived), so the regex above could not recognise it yet. Cut there,
# otherwise the streamed body would grow to include the next header and
# then shrink back once it completes.
start_tok = body.find("<|start|>")
if start_tok != -1:
body = body[:start_tok]
closed = False
yield header.group("rcpt"), body, closed
pos = next_pos
def _trailing_partial_marker_len(text: str) -> int:
"""Length of the longest suffix of *text* that prefixes a structural marker."""
max_overlap = min(len(text), _MAX_MARKER_LEN - 1)
for overlap in range(max_overlap, 0, -1):
suffix = text[-overlap:]
if any(marker.startswith(suffix) for marker in _STRUCTURAL_MARKERS):
return overlap
return 0
def _safe_open_body(body: str) -> str:
"""Trim the tail of a still-growing body to what is safe to emit now.
Holds back anything that could still turn out to be structural, so the
emitted prefix only ever grows. Chunks under speculative decoding are large
enough that markers routinely straddle them.
"""
tail_to = _OPEN_TAIL_TO_RE.search(body)
if tail_to is not None:
return body[: tail_to.start()]
partial = _trailing_partial_marker_len(body)
return body[: len(body) - partial] if partial else body
class MuseGlimmerToolParser(ToolParser):
# MuseGlimmer emits ATEM markup around tool-call arguments. The generic
# named/required tool_choice path in vllm/parser/abstract_parser.py assigns
# the raw model text straight to FunctionCall.arguments, which leaks that
# framing and yields invalid JSON; the "required" branch then silently
# swallows the ValidationError and returns tool_calls=null. Opting out
# routes named/required through extract_tool_calls /
# extract_tool_calls_streaming -- the same path "auto" already uses.
supports_required_and_named = False
def __init__(
self,
tokenizer: PreTrainedTokenizerBase,
tools: list[Tool] | None = None,
) -> None:
super().__init__(tokenizer, tools)
# Streaming cursors. vLLM constructs one ToolParser per request, so
# instance state is per-stream.
self._streamed_content_len: int = 0
self._streamed_reasoning_len: int = 0
self._emitted_tool_calls: int = 0
def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
"""Force special tokens through, and keep JSON guided decoding off.
``skip_special_tokens`` defaults to True on both ChatCompletionRequest
and ResponsesRequest. Every rule in this parser keys off ``<|message|>``
/ ``<|eom|>`` / ``<|eot|>`` / ``<|start|>``, so with the default the
channel framing is stripped before we see it: message segmentation
finds nothing, reasoning-channel invokes are indistinguishable from real
ones, and the raw ATEM markup falls through to the client as content.
Set it unconditionally and FIRST -- not only for tools requests, and not
relying on the reasoning parser to have set it.
For required/named ``tool_choice`` the base hook installs a JSON schema
constraint (ToolParser.adjust_request -> get_json_schema_from_tools).
MuseGlimmer emits ATEM XML, so under that constraint it writes JSON *inside*
the tool channel -- ``<atem:function_calls>[{"name": ...`` -- with no
``<atem:invoke>`` for this parser to find. Skip the base hook so those
choices decode natively, the same as "auto".
"""
request.skip_special_tokens = False
tool_choice = getattr(request, "tool_choice", None)
if request.tools and (
tool_choice == "required"
or isinstance(
tool_choice, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction)
)
):
return request
return super().adjust_request(request)
# ---------------- channel selection ----------------
@classmethod
def _tool_channel_text(cls, text: str) -> str:
"""Concatenate the bodies of messages addressed to a tool.
Falls back to the whole text when no message header is present at all --
that means the framing never reached us (``skip_special_tokens`` was on,
or the chunk carrying the header was dropped upstream), and scanning
everything is strictly better than returning nothing.
"""
bodies = [
body
for rcpt, body, _closed in _iter_messages(text)
if rcpt is not None
and rcpt != _REASONING_RECIPIENT
and rcpt != _USER_RECIPIENT
]
if bodies:
return "\n".join(bodies)
if _MSG_HEADER_RE.search(text) is None and (
_FUNCTION_CALLS_OPEN in text or "<atem:invoke" in text
):
logger.warning(
"MuseGlimmer: ATEM markup with no channel framing; "
"is skip_special_tokens enabled upstream?"
)
return text
return ""
@classmethod
def _visible_channels(cls, text: str) -> tuple[str, str, bool, bool]:
"""Return ``(content, reasoning, content_open, reasoning_open)``.
The ``*_open`` flags say whether that channel's LAST message is still
being generated; only then must the caller hold back a partial
structural marker. Tracking them per channel matters: a closed
reasoning block whose text happens to end in ``<`` would otherwise stay
permanently truncated while a later content message is open.
"""
content_parts: list[str] = []
reasoning_parts: list[str] = []
content_open = False
reasoning_open = False
for rcpt, body, closed in _iter_messages(text):
if rcpt == _REASONING_RECIPIENT:
reasoning_parts.append(body)
reasoning_open = not closed
elif rcpt is None or rcpt == _USER_RECIPIENT:
content_parts.append(body)
content_open = not closed
return (
"".join(content_parts),
"".join(reasoning_parts),
content_open,
reasoning_open,
)
# ---------------- tool name binding ----------------
@staticmethod
def _registered_names(request: ChatCompletionRequest | None) -> set[str]:
"""Names of the tools the client registered on this request."""
names: set[str] = set()
tools = getattr(request, "tools", None) if request is not None else None
for t in tools or []:
fn = getattr(t, "function", None) or t
name = getattr(fn, "name", None)
if name is None and isinstance(fn, dict):
name = fn.get("name")
if name:
names.add(name)
return names
@staticmethod
def _normalize_name(emitted: str, registered: set[str]) -> str:
"""Map an emitted ATEM invoke name back to a registered tool name.
When a client registers a BARE name (e.g. ``get_weather``) the shipped
chat template renders the valid recipient as ``"get_weather.*"``, and
the model duly emits ``get_weather.get_weather``. Collapsing that
doubled form is safe: head and tail are identical and the collapsed
name is registered.
Anything else is passed through unchanged. Matching on the trailing
segment alone is NOT safe -- an emitted ``weather.get`` against a
registered ``{calendar.get}`` has a unique leaf match and would silently
dispatch the wrong tool.
"""
if not registered or emitted in registered:
return emitted
head, sep, tail = emitted.partition(".")
if sep and head == tail and head in registered:
return head
logger.warning(
"MuseGlimmer: emitted tool name %r does not match any registered tool; "
"passing through unchanged.",
emitted,
)
return emitted
@classmethod
def _parse_tool_calls(
cls, text: str, registered: set[str] | None = None
) -> list[ToolCall]:
registered = registered or set()
scoped = cls._tool_channel_text(text)
tool_calls: list[ToolCall] = []
for invoke in _INVOKE_RE.findall(scoped):
name_m = _NAME_RE.search(invoke)
if not name_m:
continue
name = cls._normalize_name(name_m.group(1), registered)
args: dict = {}
for pm in _PARAM_RE.finditer(invoke):
args[pm.group("key")] = _decode_value(pm.group("value"))
tool_calls.append(
ToolCall(
function=FunctionCall(
name=name,
arguments=json.dumps(args, ensure_ascii=False),
)
)
)
return tool_calls
@classmethod
def _extract_content(cls, text: str) -> str | None:
"""Return the user-facing body, or the raw text when unframed."""
content, reasoning, _c_open, _r_open = cls._visible_channels(text)
if content:
return content
# No framing at all -> the whole thing is plain content.
if not reasoning and _MSG_HEADER_RE.search(text) is None:
return text or None
return None
# ---------------- non-streaming ----------------
def extract_tool_calls(
self, model_output: str, request: ChatCompletionRequest
) -> ExtractedToolCallInformation:
if (
_FUNCTION_CALLS_OPEN not in model_output
and "<atem:invoke" not in model_output
):
return ExtractedToolCallInformation(
tools_called=False,
tool_calls=[],
content=self._extract_content(model_output),
)
try:
registered = self._registered_names(request)
tool_calls = self._parse_tool_calls(model_output, registered)
if not tool_calls:
# A tool block was opened but no COMPLETE
# <atem:invoke>...</atem:invoke> parsed -- typically a truncated
# call (finish_reason='length'/abort). Log it: silently
# returning "no tool call" here is indistinguishable from the
# model choosing not to call one, which makes this failure mode
# invisible in production.
logger.warning(
"MuseGlimmer: tool channel opened but no complete <atem:invoke> "
"parsed (truncated tool call?); returning content only."
)
return ExtractedToolCallInformation(
tools_called=False,
tool_calls=[],
content=self._extract_content(model_output),
)
return ExtractedToolCallInformation(
tools_called=True,
tool_calls=tool_calls,
content=self._extract_content(model_output),
)
except Exception:
logger.exception("Error extracting MuseGlimmer ATEM tool calls.")
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
# ---------------- streaming ----------------
def extract_tool_calls_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],
request: ChatCompletionRequest,
) -> DeltaMessage | None:
"""Incremental ATEM streaming for tool calls AND content.
This parser owns every delta once reasoning has ended: in
vllm/parser/abstract_parser.py::parse_delta the "pass through as
content" fallback is guarded by ``not self._in_tool_call_phase(state)``,
and ``_in_tool_call_phase`` is simply ``tool_parser is not None and
state.reasoning_ended``. So with a tool parser loaded that fallback is
dead code, and returning None here DISCARDS the delta. Anything we do
not emit -- including the ``to=user`` final answer -- never reaches the
client. Hence content is emitted here, not left to the reasoning parser.
Tool calls are surfaced only when an ``<atem:invoke>`` block becomes
complete: the XML is opaque until closed and MuseGlimmer parameters are not
incremental JSON, so there is nothing meaningful to stream before then.
"""
if not previous_text:
# First delta of the tool phase (parse_delta resets previous_text
# to "" when it hands the stream over). Reset the cursors.
self._streamed_content_len = 0
self._streamed_reasoning_len = 0
self._emitted_tool_calls = 0
try:
registered = self._registered_names(request)
calls = self._parse_tool_calls(current_text, registered)
content, reasoning, content_open, reasoning_open = self._visible_channels(
current_text
)
# Trim the tail of a channel that is still growing, so the emitted
# prefix never shrinks between deltas.
if content_open:
content = _safe_open_body(content)
if reasoning_open:
reasoning = _safe_open_body(reasoning)
content_delta = content[self._streamed_content_len :]
reasoning_delta = reasoning[self._streamed_reasoning_len :]
tool_deltas: list[DeltaToolCall] = []
for i in range(self._emitted_tool_calls, len(calls)):
fn = calls[i].function
tool_deltas.append(
DeltaToolCall(
index=i,
type="function",
id=make_tool_call_id(),
function=DeltaFunctionCall(
name=fn.name,
arguments=fn.arguments,
).model_dump(exclude_none=True),
)
)
if not content_delta and not reasoning_delta and not tool_deltas:
return None
self._streamed_content_len = len(content)
self._streamed_reasoning_len = len(reasoning)
self._emitted_tool_calls = len(calls)
message = DeltaMessage()
if content_delta:
message.content = content_delta
if reasoning_delta:
message.reasoning = reasoning_delta
if tool_deltas:
message.tool_calls = tool_deltas
return message
except Exception:
logger.exception("Error extracting MuseGlimmer ATEM streaming tool calls.")
return None
+4
View File
@@ -102,6 +102,10 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
kimi_linear="KimiLinearConfig",
kimi_vl="KimiVLConfig",
kimi_k25="KimiK25Config",
muse_glimmer="MuseGlimmerConfig",
muse_glimmer_text="MuseGlimmerTextConfig",
muse_glimmer_vision="MuseGlimmerVisionConfig",
muse_glimmer_assistant="MuseGlimmerAssistantConfig",
kimi_k3="KimiK3Config",
RefinedWeb="RWConfig", # For tiiuae/falcon-40b(-instruct)
RefinedWebModel="RWConfig", # For tiiuae/falcon-7b(-instruct)
@@ -74,6 +74,10 @@ _CLASS_TO_MODULE: dict[str, str] = {
"KimiLinearConfig": "vllm.transformers_utils.configs.kimi_linear",
"KimiVLConfig": "vllm.transformers_utils.configs.kimi_vl",
"KimiK25Config": "vllm.transformers_utils.configs.kimi_k25",
"MuseGlimmerConfig": "vllm.transformers_utils.configs.muse_glimmer",
"MuseGlimmerTextConfig": "vllm.transformers_utils.configs.muse_glimmer",
"MuseGlimmerVisionConfig": "vllm.transformers_utils.configs.muse_glimmer",
"MuseGlimmerAssistantConfig": "vllm.transformers_utils.configs.muse_glimmer",
"KimiK3Config": "vllm.transformers_utils.configs.kimi_k3",
"KimiK3VisionConfig": "vllm.transformers_utils.configs.kimi_k3",
"NemotronConfig": "vllm.transformers_utils.configs.nemotron",
@@ -162,6 +166,10 @@ __all__ = [
"KimiLinearConfig",
"KimiVLConfig",
"KimiK25Config",
"MuseGlimmerConfig",
"MuseGlimmerTextConfig",
"MuseGlimmerVisionConfig",
"MuseGlimmerAssistantConfig",
"KimiK3Config",
"KimiK3VisionConfig",
"NemotronConfig",
@@ -0,0 +1,389 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MuseGlimmer model configuration for vLLM.
Native vLLM copy of the MuseGlimmer HuggingFace configs
(``configuration_muse_glimmer.py``). MuseGlimmer's
``model_type`` is not yet registered in released transformers, so vLLM ships
this config so partners can serve MuseGlimmer checkpoints *without* trust_remote_code.
The field set and defaults are kept byte-for-byte in sync with the HF reference
so a checkpoint's ``config.json`` deserializes identically here. Both the text
and vision configs are consumed by the native multimodal serving path.
"""
from __future__ import annotations
from transformers import Qwen3Config
from transformers.configuration_utils import PretrainedConfig
def _default_no_rope_layers(num_hidden_layers: int) -> list[int]:
# iRoPE mask: NoPE every 4 layers, counted backward from the last layer.
stride = 4
return [
0 if (num_hidden_layers - 1 - i) % stride == 0 else 1
for i in range(num_hidden_layers)
]
class MuseGlimmerTextConfig(PretrainedConfig):
model_type = "muse_glimmer_text"
keys_to_ignore_at_inference = ["past_key_values"]
def __init__(
self,
vocab_size: int = 202_048,
hidden_size: int = 6656,
intermediate_size: int = 19968,
num_hidden_layers: int = 52,
num_attention_heads: int = 32,
num_key_value_heads: int = 2,
head_dim: int = 128,
hidden_activation: str = "silu",
max_position_embeddings: int = 16_384,
initializer_range: float = 0.02,
rms_norm_eps: float = 1e-5,
use_cache: bool = True,
pad_token_id: int | None = None,
eos_token_id: int | list[int] | None = 200_001,
bos_token_id: int | None = 200_000,
tie_word_embeddings: bool = False,
rope_parameters: dict | None = None,
rope_theta: float | None = None,
attention_bias: bool = False,
attention_dropout: float = 0.0,
query_pre_attn_scalar: int = 256,
sliding_window: int | None = 2048,
layer_types: list[str] | None = None,
final_logit_softcapping: float | None = 20.0,
attn_logit_softcapping: float | None = None,
use_bidirectional_attention: bool | None = None,
# MuseGlimmer-specific
qk_scale_factor: float = 43.7840518911,
use_qk_norm: bool = True,
use_attn_output_gate: bool = True,
output_multiplier: float = 0.19611613513818404,
normalize_tok_embeddings: bool = True,
post_norm_eps: float = 1e-8,
no_rope_layers: list[int] | None = None,
**kwargs,
) -> None:
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.intermediate_size = intermediate_size
self.num_hidden_layers = num_hidden_layers
self.num_attention_heads = num_attention_heads
self.num_key_value_heads = num_key_value_heads
self.head_dim = head_dim
self.hidden_activation = hidden_activation
self.max_position_embeddings = max_position_embeddings
self.initializer_range = initializer_range
self.rms_norm_eps = rms_norm_eps
self.use_cache = use_cache
self.attention_bias = attention_bias
self.attention_dropout = attention_dropout
self.query_pre_attn_scalar = query_pre_attn_scalar
self.sliding_window = sliding_window
self.final_logit_softcapping = final_logit_softcapping
self.attn_logit_softcapping = attn_logit_softcapping
self.use_bidirectional_attention = use_bidirectional_attention
# RoPE: accept either an explicit rope_parameters dict (HF 5.x) or a
# bare rope_theta; normalize to rope_parameters for vLLM's get_rope.
if rope_parameters is None:
theta = rope_theta if rope_theta is not None else 500_000.0
rope_parameters = {"rope_type": "default", "rope_theta": theta}
self.rope_parameters = rope_parameters
# vLLM reads rope_theta off the config in some codepaths.
self.rope_theta = rope_parameters.get("rope_theta", 500_000.0)
# MuseGlimmer-specific fields
self.qk_scale_factor = qk_scale_factor
self.use_qk_norm = use_qk_norm
self.use_attn_output_gate = use_attn_output_gate
self.output_multiplier = output_multiplier
self.normalize_tok_embeddings = normalize_tok_embeddings
self.post_norm_eps = post_norm_eps
self.no_rope_layers = (
no_rope_layers
if no_rope_layers is not None
else _default_no_rope_layers(num_hidden_layers)
)
if layer_types is None:
layer_types = [
"full_attention" if self.no_rope_layers[i] == 0 else "sliding_attention"
for i in range(num_hidden_layers)
]
self.layer_types = layer_types
super().__init__(
pad_token_id=pad_token_id,
bos_token_id=bos_token_id,
eos_token_id=eos_token_id,
tie_word_embeddings=tie_word_embeddings,
**kwargs,
)
class MuseGlimmerVisionConfig(PretrainedConfig):
model_type = "muse_glimmer_vision"
def __init__(
self,
patch_size: int = 14,
pos_emb_height: int = 32,
pos_emb_width: int = 32,
num_attention_heads: int = 16,
num_hidden_layers: int = 50,
hidden_size: int = 1536,
intermediate_size: int = 8960,
hidden_act: str = "gelu",
merge_kernel_size: int = 2,
rope_parameters: dict | None = None,
max_position_embeddings: int = 32 * 32,
output_dim: int = 6144,
patch_temporal: int = 2,
adapter_dim: int = 4096,
layer_norm_eps: float = 1e-5,
layer_types: list[str] | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.patch_size = patch_size
self.pos_emb_height = pos_emb_height
self.pos_emb_width = pos_emb_width
self.num_attention_heads = num_attention_heads
self.num_hidden_layers = num_hidden_layers
self.hidden_size = hidden_size
self.intermediate_size = intermediate_size
self.hidden_act = hidden_act
self.merge_kernel_size = merge_kernel_size
self.rope_parameters = rope_parameters
self.max_position_embeddings = max_position_embeddings
self.output_dim = output_dim
self.patch_temporal = patch_temporal
self.adapter_dim = adapter_dim
self.layer_norm_eps = layer_norm_eps
if layer_types is None:
stride = 4
layer_types = [
"full_attention"
if (i + 1) % stride == 0 or i == num_hidden_layers - 1
else "sliding_attention"
for i in range(num_hidden_layers)
]
self.layer_types = layer_types
class MuseGlimmerConfig(PretrainedConfig):
model_type = "muse_glimmer"
sub_configs = {
"text_config": MuseGlimmerTextConfig,
"vision_config": MuseGlimmerVisionConfig,
}
# --- Flat (legacy-converter) -> canonical normalization -------------------
# MuseGlimmer checkpoints exist in two config layouts in the wild:
#
# * CANONICAL (current HF converter, transformers 5.15): nested
# ``text_config`` / ``vision_config`` sub-dicts with canonical field
# names (``hidden_activation``, ``final_logit_softcapping``,
# ``rope_parameters``, ``vision_config.hidden_size`` ...).
#
# * FLAT (older converter, e.g. Ruan's ``rl_v1/hf``, transformers 5.9):
# every field is a top-level key, with different names
# (``hidden_act``, ``output_soft_cap_temp``, ``rope_theta``,
# ``vision_latent_dim`` ...) and NO ``text_config`` nesting.
#
# Without normalization a flat config silently deserializes to an
# ALL-DEFAULT text config (every checkpoint value ignored) — a dangerous
# correctness bug: a checkpoint whose arch differs from the defaults would
# load into a wrong-shaped model with no error. So when we detect a flat
# config we hoist the flat fields into ``text_config`` / ``vision_config``
# and rename them to canonical names.
# flat text field name -> canonical MuseGlimmerTextConfig field name
_FLAT_TEXT_RENAMES = {
"hidden_act": "hidden_activation",
"output_soft_cap_temp": "final_logit_softcapping",
}
# canonical MuseGlimmerTextConfig constructor params that may appear flat
_FLAT_TEXT_KEYS = frozenset(
{
"vocab_size",
"hidden_size",
"intermediate_size",
"num_hidden_layers",
"num_attention_heads",
"num_key_value_heads",
"head_dim",
"hidden_activation",
"hidden_act",
"max_position_embeddings",
"initializer_range",
"rms_norm_eps",
"use_cache",
"tie_word_embeddings",
"rope_parameters",
"rope_theta",
"attention_bias",
"attention_dropout",
"query_pre_attn_scalar",
"sliding_window",
"layer_types",
"final_logit_softcapping",
"output_soft_cap_temp",
"attn_logit_softcapping",
"use_bidirectional_attention",
"qk_scale_factor",
"use_qk_norm",
"use_attn_output_gate",
"output_multiplier",
"normalize_tok_embeddings",
"post_norm_eps",
"no_rope_layers",
}
)
# flat vision field name -> canonical MuseGlimmerVisionConfig field name
_FLAT_VISION_RENAMES = {
"vision_latent_dim": "hidden_size",
"vision_heads": "num_attention_heads",
"vision_layers": "num_hidden_layers",
"vision_output_dim": "output_dim",
"vision_patch_size": "patch_size",
"vision_patch_temporal": "patch_temporal",
"vision_adapter_dim": "adapter_dim",
"vision_pos_emb_grid_h": "pos_emb_height",
"vision_pos_emb_grid_w": "pos_emb_width",
"vision_downsample_factor": "merge_kernel_size",
}
@classmethod
def _looks_flat(cls, kwargs: dict) -> bool:
# Flat if there is no explicit text_config but there ARE text-level
# fields at the top level (e.g. hidden_size / num_hidden_layers).
if kwargs.get("text_config") is not None:
return False
return any(k in kwargs for k in ("hidden_size", "num_hidden_layers"))
@classmethod
def _normalize_flat(cls, kwargs: dict) -> dict:
kwargs = dict(kwargs)
text: dict = {}
for key in list(kwargs.keys()):
if key in cls._FLAT_TEXT_KEYS:
canon = cls._FLAT_TEXT_RENAMES.get(key, key)
text.setdefault(canon, kwargs.pop(key))
vision_mlp_ratio = kwargs.pop("vision_mlp_ratio", None)
sparse_factor = kwargs.pop("vision_sparse_attention_factor", None)
vision: dict = {}
for key in list(kwargs.keys()):
if key in cls._FLAT_VISION_RENAMES:
vision.setdefault(cls._FLAT_VISION_RENAMES[key], kwargs.pop(key))
if vision_mlp_ratio is not None:
hidden_size = vision.get("hidden_size", 1536)
vision["intermediate_size"] = int(vision_mlp_ratio * hidden_size)
if sparse_factor is not None:
sparse_factor = int(sparse_factor)
if sparse_factor <= 0:
raise ValueError("vision_sparse_attention_factor must be positive")
num_layers = vision.get("num_hidden_layers", 50)
vision["layer_types"] = [
"full_attention"
if (layer_idx + 1) % sparse_factor == 0 or layer_idx == num_layers - 1
else "sliding_attention"
for layer_idx in range(num_layers)
]
if text:
kwargs["text_config"] = text
if vision:
kwargs["vision_config"] = vision
return kwargs
def __init__(
self,
text_config: dict | MuseGlimmerTextConfig | None = None,
vision_config: dict | MuseGlimmerVisionConfig | None = None,
image_token_id: int = 200092,
video_token_id: int = 200091,
**kwargs,
) -> None:
# Detect + fold a flat config into nested text/vision before building
# the sub-configs. (image/video token ids are handled below; a flat
# ``patch_token_id`` alias is mapped too.)
if text_config is None and self._looks_flat(kwargs):
kwargs = self._normalize_flat(kwargs)
text_config = kwargs.pop("text_config", None)
vision_config = kwargs.pop("vision_config", vision_config)
# flat image-token alias
if "patch_token_id" in kwargs and "image_token_id" not in kwargs:
image_token_id = kwargs.pop("patch_token_id")
if text_config is None:
self.text_config = MuseGlimmerTextConfig()
elif isinstance(text_config, dict):
self.text_config = MuseGlimmerTextConfig(**text_config)
else:
self.text_config = text_config
if vision_config is None:
self.vision_config = MuseGlimmerVisionConfig()
elif isinstance(vision_config, dict):
self.vision_config = MuseGlimmerVisionConfig(**vision_config)
else:
self.vision_config = vision_config
self.image_token_id = image_token_id
self.video_token_id = video_token_id
self.patch_token_id = image_token_id
super().__init__(**kwargs)
class MuseGlimmerAssistantConfig(Qwen3Config):
"""Config for the Muse Glimmer DFlash draft head.
The head is Qwen3-shaped and runs on vLLM's generic ``qwen3_dflash``
implementation, so this derives from ``Qwen3Config``. It cannot BE
``Qwen3Config``, because two of the checkpoint's values do not survive that
class:
* ``sliding_window`` is gated behind ``use_sliding_window``, which defaults
to False -- ``Qwen3Config(sliding_window=2048).sliding_window`` is None.
The checkpoint declares ``sliding_window: 2048`` and five
``sliding_attention`` layers, so the window silently disappears and the
DFlash path then raises "sliding attention requires a window size".
* ``vocab_size`` is absent from the checkpoint, so Qwen3's default of
151936 applies instead of Muse Glimmer's 202048. That one is *silent*: it
builds an all-zero ``draft_id_to_target_id`` remap, and also puts
pad/bos/eos/mask token ids out of range.
Both defaults are set here so an unmodified checkpoint loads correctly.
"""
model_type = "muse_glimmer_assistant"
def __init__(
self,
vocab_size: int = 202048,
use_sliding_window: bool = True,
dflash_config: dict | None = None,
**kwargs,
) -> None:
super().__init__(
vocab_size=vocab_size, use_sliding_window=use_sliding_window, **kwargs
)
# muse_glimmer_assistant uses non-causal attention.
self.dflash_config = {"causal": False} | (dflash_config or {})
__all__ = [
"MuseGlimmerTextConfig",
"MuseGlimmerVisionConfig",
"MuseGlimmerConfig",
"MuseGlimmerAssistantConfig",
]
@@ -0,0 +1,582 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""HuggingFace processor for the MuseGlimmer multimodal model (text + image + video).
This packages MuseGlimmer's vision preprocessing and token-span expansion the standard
HF way, so you can do:
processor = AutoProcessor.from_pretrained(hf_dir, trust_remote_code=True)
messages = [{"role": "user", "content": [
{"type": "image"},
{"type": "text", "text": "Describe this image."},
]}]
text = processor.apply_chat_template(messages, add_generation_prompt=True)
inputs = processor(text=text, images=[pil_image], return_tensors="pt")
out = model.generate(**inputs, max_new_tokens=256)
It reproduces the exact token layout the model expects:
image -> <|image_start|> + <|patch|> * N + <|image_end|>
video -> <|vid_start|>
( "Time: X.Xs" + <|video|> * P [+ <|vid_frame_separator|>] )*
+ <|vid_end|>
where N = floor(H/(patch*ds)) * floor(W/(patch*ds)) (<= 4096) for images and
P = the same grid count per temporal frame-group (<= 144) for video, with the
target (H, W) chosen by MuseGlimmerImageProcessor.compute_image_size /
MuseGlimmerVideoProcessor.compute_video_frame_size (the same grid logic as
modeling_muse_glimmer.MuseGlimmerVisionEncoder).
The chat template emits one sentinel per media item -- ``<|image|>`` for images,
``<|video|>`` for videos -- which ``MuseGlimmerProcessor.__call__`` expands into the
spans above using each media item's computed token count. Vision features are
returned in ``pixel_values`` (a list of per-image / per-frame-group tensors) in
the order the sentinels appear, matching how ``MuseGlimmerModel`` merges them at the
``<|patch|>`` / ``<|video|>`` positions.
"""
from __future__ import annotations
import itertools
import math
from pathlib import Path
import torch
from PIL import Image
from torchvision import transforms as T
from transformers.feature_extraction_utils import BatchFeature
from transformers.image_processing_utils import BaseImageProcessor
from transformers.processing_utils import ProcessorMixin
from transformers.video_processing_utils import BaseVideoProcessor
# Single-token sentinels emitted by the chat template, one per media item.
IMAGE_SENTINEL = "<|image|>" # id 200090; absent from the image/video spans
VIDEO_SENTINEL = "<|video|>" # id 200091; also the per-frame token (see __call__)
# Jinja chat template (multimodal superset of convert_muse_glimmer_to_hf.CHAT_TEMPLATE).
# Message content may be a plain string OR a list of parts
# ({"type": "text"|"image"|"video", ...}); image/video parts render as a single
# sentinel that __call__ expands.
MUSE_GLIMMER_MM_CHAT_TEMPLATE = (
"{{- bos_token -}}"
"{%- macro render_parts(content) -%}"
"{%- if content is string -%}{{- content -}}"
"{%- else -%}"
"{%- for part in content -%}"
"{%- if part['type'] == 'image' -%}{{- '<|image|>' -}}"
"{%- elif part['type'] == 'video' -%}{{- '<|video|>' -}}"
"{%- elif part['type'] == 'text' -%}{{- part['text'] -}}"
"{%- endif -%}"
"{%- endfor -%}"
"{%- endif -%}"
"{%- endmacro -%}"
# At inference (add_generation_prompt), inject a default system message when
# the caller supplied none. Full-transcript rendering
# (add_generation_prompt=False) is left unchanged.
"{%- set ns = namespace(has_system=false) -%}"
"{%- for m in messages -%}"
"{%- if m['role'] == 'system' -%}{%- set ns.has_system = true -%}{%- endif -%}"
"{%- endfor -%}"
"{%- if add_generation_prompt and not ns.has_system -%}"
"{{- '<|start|>system<|message|>You are a helpful assistant.<|eot|>' -}}"
"{%- endif -%}"
"{%- for message in messages -%}"
"{%- set role = message['role'] -%}"
"{%- if role == 'assistant' -%}"
"{%- set recipient = message.get('recipient') -%}"
"{%- set end_turn = message.get('end_turn') -%}"
"{%- if end_turn is none -%}"
"{%- set end_turn = not (recipient and recipient != 'user') -%}"
"{%- endif -%}"
"{{- '<|start|>assistant' -}}"
"{%- if recipient -%}{{- ' to=' + recipient -}}{%- endif -%}"
"{{- '<|message|>' -}}{{- render_parts(message['content']) -}}"
"{{- ('<|eot|>' if end_turn else '<|eom|>') -}}"
"{%- elif role == 'tool' -%}"
"{%- set name = message.get('name', '') -%}"
# Tool content is emitted as-is (string body or interleaved image/text parts).
# Any <tool_output ...> wrapper is baked into the SFT data content; the
# tokenizer does not add it, so the template must not either.
"{{- '<|start|>tool ' + name + '<|message|>' -}}"
"{{- render_parts(message['content']) -}}"
"{{- '<|eot|>' -}}"
"{%- else -%}"
"{%- set header = role -%}"
"{%- if message.get('name') -%}"
"{%- set header = role + ' ' + message['name'] -%}"
"{%- endif -%}"
"{{- '<|start|>' + header + '<|message|>' -}}"
"{{- render_parts(message['content']) -}}"
"{{- '<|eot|>' -}}"
"{%- endif -%}"
"{%- endfor -%}"
"{%- if add_generation_prompt -%}{{- '<|start|>assistant' -}}{%- endif -%}"
)
def _grid_size(
img_w: int, img_h: int, patch_hw: int, max_tokens: int
) -> tuple[int, int, int]:
"""Pick the integer (H, W) grid closest to the aspect ratio under the token cap.
Replicates MuseGlimmerVisionEncoder._compute_grid_size
(modeling_muse_glimmer.py) so the processor needs no torch model import.
Returns (target_h, target_w, n_tokens).
"""
i_nph = img_h / patch_hw
i_npw = img_w / patch_hw
ratio = i_npw / i_nph if i_nph > 0 else 1.0
if i_nph * i_npw > max_tokens:
i_nph = (max_tokens / ratio) ** 0.5
i_npw = i_nph * ratio
candidates = list(
set(
itertools.product(
[math.floor(i_nph), math.ceil(i_nph)],
[math.floor(i_npw), math.ceil(i_npw)],
)
)
)
candidates = [
(nph, npw)
for nph, npw in candidates
if nph >= 1 and npw >= 1 and nph * npw <= max_tokens
]
if not candidates:
candidates = [(max(1, round(i_nph)), max(1, round(i_npw)))]
nph, npw = min(candidates, key=lambda c: abs(c[0] / c[1] - img_h / img_w))
return nph * patch_hw, npw * patch_hw, nph * npw
class MuseGlimmerImageProcessor(BaseImageProcessor):
"""Resize + normalize MuseGlimmer images and compute patch-token counts.
Variable-resolution: each image is resized to the grid that best matches its
aspect ratio under the per-image token cap, then normalized with mean/std 0.5.
Returns per-image tensors (not stacked) because MuseGlimmer consumes a list of
variable-size images. Video frames are handled by MuseGlimmerVideoProcessor.
"""
model_input_names = ["pixel_values"]
def __init__(
self,
patch_size: int = 14,
downsample_factor: int = 2,
max_image_tokens: int = 4096,
image_mean: float = 0.5,
image_std: float = 0.5,
**kwargs,
):
super().__init__(**kwargs)
self.patch_size = patch_size
self.downsample_factor = downsample_factor
self.max_image_tokens = max_image_tokens
self.image_mean = image_mean
self.image_std = image_std
def _to_norm_tensor(self, image: Image.Image) -> torch.Tensor:
# Functional normalize -- no stored Normalize object, so the processor
# stays JSON-serializable for to_dict() / save_pretrained.
return T.functional.normalize(
T.functional.to_tensor(image),
[self.image_mean] * 3,
[self.image_std] * 3,
)
# -- size computation (mirrors modeling_muse_glimmer.MuseGlimmerVisionEncoder) --
def compute_image_size(self, img_w: int, img_h: int) -> tuple[int, int, int]:
ph = self.patch_size * self.downsample_factor
return _grid_size(img_w, img_h, ph, self.max_image_tokens)
# -- preprocessing ---------------------------------------------------------
def preprocess_image(self, image: Image.Image) -> tuple[torch.Tensor, int]:
"""Return (pixel tensor [3, H, W], n_patch_tokens) for one image."""
image = image.convert("RGB")
target_h, target_w, n_tokens = self.compute_image_size(
image.width, image.height
)
image = image.resize((target_w, target_h), Image.LANCZOS)
return self._to_norm_tensor(image), n_tokens
# torchcodec is the training-faithful video decoder; other decoders (torchvision /
# PyAV) seek + color-convert differently and diverge from training. Optional at
# import time so text/image paths load without it; decode_video raises if used.
try:
import torchcodec
except Exception:
torchcodec = None
class MuseGlimmerVideoProcessor(BaseVideoProcessor):
"""MuseGlimmer video preprocessing behind the standard HF
``AutoVideoProcessor`` API.
Exposes MuseGlimmer's training-faithful video handling as a first-class video
processor so you can call ``processor(videos="clip.mp4")`` and downstream
users can discover it via the standard HF ``AutoVideoProcessor`` API plus a
``video_preprocessor_config.json``. It wraps:
* torchcodec decode (matches training; other decoders diverge),
* uniform frame sampling to a whole multiple of ``patch_temporal``,
* real per-group PTS timestamps (rendered as ``Time: X.Xs`` by
MuseGlimmerProcessor),
* ``patch_temporal`` frame-grouping (frames cat on the channel axis ->
``[patch_temporal * 3, H, W]``; the encoder detects video by channel count).
It deliberately overrides ``preprocess`` instead of the BaseVideoProcessor fast
pipeline (group_videos_by_shape / smart_resize / single stacked tensor): MuseGlimmer
consumes a LIST of variable-size group tensors and needs the real per-group PTS,
neither of which the stacked fast path models.
"""
model_input_names = ["pixel_values"]
def __init__(
self,
patch_size: int = 14,
downsample_factor: int = 2,
patch_temporal: int = 2,
max_video_frame_tokens: int = 144,
image_mean: float = 0.5,
image_std: float = 0.5,
video_num_frames: int = 96,
video_sampling_fps: float = 2.0,
**kwargs,
):
super().__init__(**kwargs)
self.patch_size = patch_size
self.downsample_factor = downsample_factor
self.patch_temporal = patch_temporal
self.max_video_frame_tokens = max_video_frame_tokens
self.image_mean = image_mean
self.image_std = image_std
self.video_num_frames = video_num_frames
self.video_sampling_fps = video_sampling_fps
def _to_norm_tensor(self, image: Image.Image) -> torch.Tensor:
return T.functional.normalize(
T.functional.to_tensor(image),
[self.image_mean] * 3,
[self.image_std] * 3,
)
def decode_video(self, video_path: str) -> tuple[list[Image.Image], list[float]]:
"""Sample frames + per-group PTS with torchcodec (the training decode path).
``timestamps[g]`` is the ACTUAL decoded PTS of the first frame in temporal
group ``g``; ``len(frames)`` is a whole multiple of ``patch_temporal``.
"""
if torchcodec is None:
raise RuntimeError(
"torchcodec is required for video decoding (it matches the training "
"decode path). See the Environment Setup steps in hf/README.md."
)
pt = self.patch_temporal
reader = torchcodec.decoders.VideoDecoder(video_path)
total = len(reader)
assert reader.metadata.average_fps is not None, (
f"Video has no FPS metadata: {video_path}"
)
fps = reader.metadata.average_fps
assert self.video_sampling_fps and self.video_sampling_fps > 0, (
f"video_sampling_fps must be positive, got {self.video_sampling_fps}"
)
n = min(
int(total * self.video_sampling_fps / fps), self.video_num_frames, total
)
n = max(pt, (n // pt) * pt)
n = min(n, total)
if n < pt:
raise ValueError(
f"Video has only {total} decodable frame(s) but needs at least "
f"{pt} (one temporal patch): {video_path}"
)
indices = torch.linspace(0, total - 1, n).long().tolist()
frames: list[Image.Image] = []
timestamps: list[float] = []
for j, i in enumerate(indices):
fr = reader[i]
frames.append(
Image.fromarray(fr.data.permute(1, 2, 0).numpy()).convert("RGB")
)
if j % pt == 0:
pts = getattr(fr, "pts_seconds", None)
timestamps.append(float(pts) if pts is not None else i / fps)
return frames, timestamps
def compute_video_frame_size(self, img_w: int, img_h: int) -> tuple[int, int, int]:
"""Pick (H, W, tokens_per_frame) for a video frame under the per-frame cap.
Mirrors MuseGlimmerImageProcessor.compute_image_size but uses the (smaller)
per-frame video token budget; shares the same ``_grid_size`` grid logic as
modeling_muse_glimmer.MuseGlimmerVisionEncoder.
"""
ph = self.patch_size * self.downsample_factor
return _grid_size(img_w, img_h, ph, self.max_video_frame_tokens)
def _group_frames(
self, frames: list[Image.Image]
) -> tuple[list[torch.Tensor], int, int]:
"""Resize/normalize frames and cat ``patch_temporal`` frames per group.
Returns (group_tensors, n_groups, tokens_per_group).
"""
pt = self.patch_temporal
if pt <= 0:
raise ValueError(f"patch_temporal must be positive, got {pt}")
if not frames:
raise ValueError("video must contain at least one frame")
if padding := (-len(frames)) % pt:
frames = [*frames, *([frames[-1]] * padding)]
first = frames[0].convert("RGB")
target_h, target_w, n_tokens = self.compute_video_frame_size(
first.width, first.height
)
groups: list[torch.Tensor] = []
for i in range(0, len(frames), pt):
grp = [
self._to_norm_tensor(
frames[i + j]
.convert("RGB")
.resize((target_w, target_h), Image.LANCZOS)
)
for j in range(pt)
]
groups.append(torch.cat(grp, dim=0))
return groups, len(groups), n_tokens
@staticmethod
def _normalize_videos(videos) -> list:
"""Normalize the ``videos`` arg to a list of per-video items.
A video item is a path (str/Path) or a list of PIL frames. Accepts a
single video (one path, or one list of frames) or a list of those.
"""
if isinstance(videos, (str, Path)):
return [videos]
if videos and not isinstance(videos[0], (list, tuple, str, Path)):
return [videos] # a single list of PIL frames
return list(videos or [])
def preprocess_one(
self,
video: str | Path | list[Image.Image],
timestamps: list[float] | None = None,
) -> tuple[list[torch.Tensor], int, int, list[float]]:
"""Decode (if a path) + group ONE video.
Returns (group_tensors, n_groups, tokens_per_group, group_timestamps). This
is the single per-video implementation shared by ``preprocess`` (the HF
AutoVideoProcessor batch API) and ``MuseGlimmerProcessor.__call__`` (prompt
building), so the two paths cannot drift. For a path, frames + real
per-group PTS come from ``decode_video``; for pre-decoded frames the given
``timestamps`` are used verbatim (empty if not supplied).
"""
if isinstance(video, (str, Path)):
frames, ts = self.decode_video(str(video))
else:
frames = [f.convert("RGB") for f in video]
ts = list(timestamps) if timestamps is not None else []
groups, n_groups, tokens_per_group = self._group_frames(frames)
return groups, n_groups, tokens_per_group, ts
def preprocess(
self,
videos,
video_timestamps: list[list[float]] | None = None,
return_tensors: str | None = "pt",
**kwargs,
) -> BatchFeature:
"""Preprocess video file path(s) or pre-decoded frame list(s).
Accepts a single video (path str, or list of PIL frames) or a list of
those. Returns a BatchFeature carrying, in video order:
* ``pixel_values`` -- flat list of [pt*3, H, W] group tensors,
* ``video_num_groups`` -- groups per video,
* ``video_tokens_per_group`` -- patch-token count per group per video,
* ``video_timestamps`` -- per-group PTS per video,
i.e. exactly what MuseGlimmerProcessor needs to build the
``<|vid_start|> ( Time: X.Xs <|video|>*P [sep] )* <|vid_end|>`` block.
"""
videos = self._normalize_videos(videos)
pixel_values: list[torch.Tensor] = []
num_groups: list[int] = []
tokens_per_group: list[int] = []
out_ts: list[list[float]] = []
for idx, v in enumerate(videos):
ts_in = video_timestamps[idx] if video_timestamps else None
groups, ng, tpg, ts = self.preprocess_one(v, ts_in)
pixel_values += groups
num_groups.append(ng)
tokens_per_group.append(tpg)
out_ts.append(ts)
batch = BatchFeature(
data={
"video_num_groups": num_groups,
"video_tokens_per_group": tokens_per_group,
"video_timestamps": out_ts,
},
tensor_type=None,
)
# Variable-size list -- BatchFeature would fail to stack into one tensor.
batch["pixel_values"] = pixel_values
return batch
class MuseGlimmerProcessor(ProcessorMixin):
"""Bundle MuseGlimmerImageProcessor + MuseGlimmerVideoProcessor + tokenizer;
expand media sentinels into spans.
Images go through ``image_processor`` (MuseGlimmerImageProcessor) and videos through
``video_processor`` (MuseGlimmerVideoProcessor) -- one preprocessing implementation
each, no overlap.
"""
attributes = ["image_processor", "video_processor", "tokenizer"]
image_processor_class = "AutoImageProcessor"
video_processor_class = "AutoVideoProcessor"
tokenizer_class = "PreTrainedTokenizerFast"
def __init__(
self,
image_processor=None,
video_processor=None,
tokenizer=None,
chat_template=None,
**kwargs,
):
if image_processor is None:
image_processor = MuseGlimmerImageProcessor()
if video_processor is None:
video_processor = MuseGlimmerVideoProcessor()
super().__init__(
image_processor,
video_processor,
tokenizer,
chat_template=chat_template or MUSE_GLIMMER_MM_CHAT_TEMPLATE,
**kwargs,
)
def _sid(self, token: str) -> int:
return self.tokenizer.convert_tokens_to_ids(token)
def _image_block(self, n_tokens: int) -> list[int]:
return (
[self._sid("<|image_start|>")]
+ [self._sid("<|patch|>")] * n_tokens
+ [self._sid("<|image_end|>")]
)
def _video_block(
self,
n_groups: int,
tokens_per_group: int,
timestamps: list[float] | None = None,
) -> list[int]:
"""Per-group ``Time: X.Xs`` + <|video|>*P, separated/terminated.
``timestamps`` (one per temporal group, in seconds) should be the ACTUAL
decoded frame times -- training renders the real factored frame PTS, not a
uniform grid. Falls back to ``g*patch_temporal/fps`` only when timestamps
are not supplied (e.g. callers that don't track frame times); that
approximation can differ from training by the per-frame jitter.
"""
vid = self._sid("<|video|>")
sep = self._sid("<|vid_frame_separator|>")
pt = self.video_processor.patch_temporal
fps = self.video_processor.video_sampling_fps
block = [self._sid("<|vid_start|>")]
for g in range(n_groups):
ts = timestamps[g] if timestamps is not None else g * pt / fps
block += self.tokenizer.encode(f"Time: {ts:.1f}s", add_special_tokens=False)
block += [vid] * tokens_per_group
block.append(sep if g < n_groups - 1 else self._sid("<|vid_end|>"))
return block
def __call__(
self,
text: str | list[str] | None = None,
images: list[Image.Image] | None = None,
videos: list[list[Image.Image] | str] | None = None,
video_timestamps: list[list[float]] | None = None,
return_tensors: str | None = "pt",
**kwargs,
) -> BatchFeature:
if text is None:
raise ValueError(
"`text` is required (use apply_chat_template to build it)."
)
if isinstance(text, (list, tuple)):
if len(text) != 1:
raise ValueError(
"MuseGlimmerProcessor supports a single text sample per call."
)
text = text[0]
images = list(images or [])
videos = list(videos or [])
image_sentinel = self._sid(IMAGE_SENTINEL)
video_sentinel = self._sid(VIDEO_SENTINEL)
# Preprocess media up front so we can expand sentinels in document order.
# Images via image_processor; videos via video_processor (each video item
# is a path -- decoded here -- or a pre-decoded list of PIL frames).
prepped_images = [self.image_processor.preprocess_image(im) for im in images]
prepped_videos = [
self.video_processor.preprocess_one(
v, video_timestamps[i] if video_timestamps else None
)
for i, v in enumerate(videos)
]
ids = self.tokenizer.encode(text, add_special_tokens=False)
n_img = sum(1 for t in ids if t == image_sentinel)
n_vid = sum(1 for t in ids if t == video_sentinel)
if n_img != len(prepped_images):
raise ValueError(
f"{n_img} image sentinel(s) in text but "
f"{len(prepped_images)} image(s) given."
)
if n_vid != len(prepped_videos):
raise ValueError(
f"{n_vid} video sentinel(s) in text but "
f"{len(prepped_videos)} video(s) given."
)
out_ids: list[int] = []
pixel_values: list[torch.Tensor] = []
img_i = vid_i = 0
for tid in ids:
if tid == image_sentinel:
tensor, n_tokens = prepped_images[img_i]
img_i += 1
out_ids += self._image_block(n_tokens)
pixel_values.append(tensor)
elif tid == video_sentinel:
groups, n_groups, tokens_per_group, ts = prepped_videos[vid_i]
vid_i += 1
out_ids += self._video_block(n_groups, tokens_per_group, ts or None)
pixel_values += groups
else:
out_ids.append(tid)
data: dict = {
"input_ids": [out_ids],
"attention_mask": [[1] * len(out_ids)],
}
batch = BatchFeature(data=data, tensor_type=return_tensors)
# Keep pixel_values as a list of variable-size tensors (MuseGlimmerModel
# consumes a list); BatchFeature would fail to stack them into one tensor.
if pixel_values:
batch["pixel_values"] = pixel_values
return batch
__all__ = [
"MuseGlimmerImageProcessor",
"MuseGlimmerVideoProcessor",
"MuseGlimmerProcessor",
"MUSE_GLIMMER_MM_CHAT_TEMPLATE",
]
+15
View File
@@ -80,6 +80,21 @@ class DFlashProposer(SpecDecodeBaseProposer):
self.draft_model_config.hf_config
)
@override
def load_model(self, target_model: torch.nn.Module) -> None:
from vllm.model_executor.models.qwen3_dflash import (
dflash_target_rope_is_neox_style,
)
draft_model_config = self.speculative_config.draft_model_config
assert draft_model_config is not None
# The drafter must rotate Q/K the way its target does. Take that from the
# built target before super() constructs the draft.
is_neox_style = dflash_target_rope_is_neox_style(target_model)
if is_neox_style is not None:
draft_model_config.hf_config.is_neox_style = is_neox_style
super().load_model(target_model)
@override
def _create_draft_vllm_config(self) -> VllmConfig:
base = super()._create_draft_vllm_config()
+13 -2
View File
@@ -13,11 +13,19 @@ from vllm.v1.worker.gpu.spec_decode.eagle.utils import (
def load_dflash_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module:
from vllm.compilation.backends import set_model_tag
from vllm.model_executor.models.qwen3_dflash import dflash_has_any_non_causal
from vllm.model_executor.models.qwen3_dflash import (
dflash_has_any_non_causal,
dflash_target_rope_is_neox_style,
)
speculative_config = vllm_config.speculative_config
assert speculative_config is not None
draft_model_config = speculative_config.draft_model_config
# The drafter must rotate Q/K the way its target does. Take that from the
# built target before super() constructs the draft.
is_neox_style = dflash_target_rope_is_neox_style(target_model)
if is_neox_style is not None:
draft_model_config.hf_config.is_neox_style = is_neox_style
# Select an attention backend that supports the drafter's attention: mixing
# a non-causal layer onto a causal-only backend would fail.
draft_vllm_config = replace(
@@ -46,7 +54,10 @@ def load_dflash_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
if hasattr(target_model, "get_language_model")
else target_model
)
target_inner = target_language_model.model
# MuseGlimmerForCausalLM marks its inner MuseGlimmerModel as the language
# model, so get_language_model() already returns the inner module and has
# no .model of its own.
target_inner = getattr(target_language_model, "model", target_language_model)
draft_inner = dflash_model.model
# Skip embedding sharing under PP — each rank owns its own embedding.