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:
co-authored by
Beto de Paola
Giancarlo Delfin
Jee Jee Li
Claude Opus 5
Jee Jee Li
zixi-qi
parent
59d1af5698
commit
6adad08767
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user