mirror of
https://github.com/vllm-project/vllm.git
synced 2026-08-24 06:30:19 +00:00
[Frontend] Add Streaming Parser Engine and new MinimaxM2 Parser (#45701)
Signed-off-by: chaunceyjiang <[email protected]>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.parser.engine.conftest import make_mock_tokenizer
|
||||
from tests.parser.engine.streaming_helpers import (
|
||||
collect_function_name,
|
||||
collect_tool_arguments,
|
||||
simulate_tool_streaming,
|
||||
)
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionToolsParam,
|
||||
FunctionDefinition,
|
||||
)
|
||||
from vllm.parser.minimax_m2 import (
|
||||
THINK_END,
|
||||
TOOL_CALL_END,
|
||||
TOOL_CALL_START,
|
||||
MinimaxM2Parser,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tokenizer():
|
||||
return make_mock_tokenizer(
|
||||
{
|
||||
THINK_END: 99,
|
||||
TOOL_CALL_START: 100,
|
||||
TOOL_CALL_END: 101,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser(mock_tokenizer):
|
||||
return MinimaxM2Parser(mock_tokenizer)
|
||||
|
||||
|
||||
def make_tools(*names: str):
|
||||
return [
|
||||
ChatCompletionToolsParam(
|
||||
function=FunctionDefinition(
|
||||
name=name,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
),
|
||||
)
|
||||
for name in names
|
||||
]
|
||||
|
||||
|
||||
class TestNonStreaming:
|
||||
def test_no_tool_calls(self, parser, mock_request):
|
||||
result = parser.extract_tool_calls(
|
||||
"</think>This is a regular response without tool calls.",
|
||||
mock_request,
|
||||
)
|
||||
assert result.tools_called is False
|
||||
assert result.tool_calls == []
|
||||
assert result.content == "This is a regular response without tool calls."
|
||||
|
||||
def test_single_tool_call(self, parser, mock_request):
|
||||
result = parser.extract_tool_calls(
|
||||
'<minimax:tool_call><invoke name="get_weather">'
|
||||
'<parameter name="city">Seattle</parameter>'
|
||||
"</invoke></minimax:tool_call>",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert result.tools_called is True
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].function.name == "get_weather"
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == {
|
||||
"city": "Seattle",
|
||||
}
|
||||
|
||||
def test_multiple_invokes(self, parser, mock_request):
|
||||
result = parser.extract_tool_calls(
|
||||
"<minimax:tool_call>"
|
||||
'<invoke name="search"><parameter name="q">OpenAI</parameter></invoke>'
|
||||
'<invoke name="search"><parameter name="q">vLLM</parameter></invoke>'
|
||||
"</minimax:tool_call>",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert result.tools_called is True
|
||||
assert [tc.function.name for tc in result.tool_calls] == ["search", "search"]
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == {"q": "OpenAI"}
|
||||
assert json.loads(result.tool_calls[1].function.arguments) == {"q": "vLLM"}
|
||||
|
||||
def test_schema_type_coercion(self, mock_tokenizer, mock_request):
|
||||
tools = [
|
||||
ChatCompletionToolsParam(
|
||||
function=FunctionDefinition(
|
||||
name="forecast",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {"type": "integer"},
|
||||
"include_hourly": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
]
|
||||
parser = MinimaxM2Parser(mock_tokenizer, tools=tools)
|
||||
mock_request.tools = tools
|
||||
|
||||
result = parser.extract_tool_calls(
|
||||
'<minimax:tool_call><invoke name="forecast">'
|
||||
'<parameter name="days">5</parameter>'
|
||||
'<parameter name="include_hourly">true</parameter>'
|
||||
"</invoke></minimax:tool_call>",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == {
|
||||
"days": 5,
|
||||
"include_hourly": True,
|
||||
}
|
||||
|
||||
def test_invalid_tool_name_is_rejected(self, mock_tokenizer, mock_request):
|
||||
tools = make_tools("search")
|
||||
parser = MinimaxM2Parser(mock_tokenizer)
|
||||
mock_request.tools = tools
|
||||
|
||||
result = parser.extract_tool_calls(
|
||||
'<minimax:tool_call><invoke name="img_gen">'
|
||||
'<parameter name="prompt">a cat</parameter>'
|
||||
"</invoke></minimax:tool_call>",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert result.tools_called is False
|
||||
assert result.tool_calls == []
|
||||
|
||||
def test_mixed_tool_names_only_return_valid(self, mock_tokenizer, mock_request):
|
||||
tools = make_tools("search")
|
||||
parser = MinimaxM2Parser(mock_tokenizer)
|
||||
mock_request.tools = tools
|
||||
|
||||
result = parser.extract_tool_calls(
|
||||
"<minimax:tool_call>"
|
||||
'<invoke name="img_gen"><parameter name="prompt">cat</parameter></invoke>'
|
||||
'<invoke name="search"><parameter name="query">news</parameter></invoke>'
|
||||
"</minimax:tool_call>",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert result.tools_called is True
|
||||
assert [tc.function.name for tc in result.tool_calls] == ["search"]
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == {
|
||||
"query": "news",
|
||||
}
|
||||
|
||||
|
||||
class TestStreaming:
|
||||
def test_streaming_single_tool_call(self, parser, mock_request):
|
||||
results = simulate_tool_streaming(
|
||||
parser,
|
||||
mock_request,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
'<invoke name="get_weather">',
|
||||
'<parameter name="city">Seattle</parameter>',
|
||||
"</invoke></minimax:tool_call>",
|
||||
],
|
||||
)
|
||||
|
||||
assert collect_function_name(results) == "get_weather"
|
||||
assert json.loads(collect_tool_arguments(results)) == {
|
||||
"city": "Seattle",
|
||||
}
|
||||
|
||||
def test_streaming_multiple_invokes(self, parser, mock_request):
|
||||
results = simulate_tool_streaming(
|
||||
parser,
|
||||
mock_request,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
'<invoke name="a"><parameter name="x">1</parameter></invoke>',
|
||||
'<invoke name="b"><parameter name="y">2</parameter></invoke>',
|
||||
"</minimax:tool_call>",
|
||||
],
|
||||
)
|
||||
|
||||
tool_names = [
|
||||
tc.function.name
|
||||
for delta, _ in results
|
||||
if delta and delta.tool_calls
|
||||
for tc in delta.tool_calls
|
||||
if tc.function and tc.function.name
|
||||
]
|
||||
assert tool_names == ["a", "b"]
|
||||
|
||||
def test_streaming_invoke_prefix_split_before_quote(self, parser, mock_request):
|
||||
results = simulate_tool_streaming(
|
||||
parser,
|
||||
mock_request,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
"<invoke name=",
|
||||
'"get_weather">',
|
||||
'<parameter name="city">Seattle</parameter>',
|
||||
"</invoke></minimax:tool_call>",
|
||||
],
|
||||
)
|
||||
|
||||
assert collect_function_name(results) == "get_weather"
|
||||
assert json.loads(collect_tool_arguments(results)) == {
|
||||
"city": "Seattle",
|
||||
}
|
||||
|
||||
def test_streaming_invalid_tool_name_is_rejected(
|
||||
self, mock_tokenizer, mock_request
|
||||
):
|
||||
tools = make_tools("search")
|
||||
parser = MinimaxM2Parser(mock_tokenizer)
|
||||
mock_request.tools = tools
|
||||
|
||||
results = simulate_tool_streaming(
|
||||
parser,
|
||||
mock_request,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
'<invoke name="img_gen">',
|
||||
'<parameter name="prompt">cat</parameter>',
|
||||
"</invoke></minimax:tool_call>",
|
||||
],
|
||||
)
|
||||
|
||||
assert collect_function_name(results) is None
|
||||
assert collect_tool_arguments(results) == ""
|
||||
|
||||
|
||||
class TestReasoning:
|
||||
def test_extract_reasoning_without_start_token(self, parser, mock_request):
|
||||
reasoning, content = parser.extract_reasoning(
|
||||
"This is reasoning</think>This is content",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert reasoning == "This is reasoning"
|
||||
assert content == "This is content"
|
||||
|
||||
def test_extract_reasoning_without_end_token(self, parser, mock_request):
|
||||
reasoning, content = parser.extract_reasoning(
|
||||
"This is still reasoning",
|
||||
mock_request,
|
||||
)
|
||||
|
||||
assert reasoning == "This is still reasoning"
|
||||
assert content is None
|
||||
|
||||
def test_extract_content_ids_without_end_token(self, parser):
|
||||
assert parser.extract_content_ids([1, 2, 3]) == []
|
||||
|
||||
def test_extract_content_ids_after_end_token(self, parser):
|
||||
assert parser.extract_content_ids([1, 99, 2, 3]) == [2, 3]
|
||||
@@ -30,6 +30,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
)
|
||||
from vllm.parser.engine.registered_adapters import (
|
||||
Gemma4Parser,
|
||||
MinimaxM2Parser,
|
||||
NemotronV3Parser,
|
||||
Qwen3Parser,
|
||||
)
|
||||
@@ -393,6 +394,79 @@ def _build_qwen3(
|
||||
return sample
|
||||
|
||||
|
||||
# ── MiniMax M2 (XML invoke format, starts in REASONING) ──────────────
|
||||
|
||||
_MINIMAX_M2_VOCAB: dict[str, int] = {
|
||||
"<think>": 50,
|
||||
"</think>": 51,
|
||||
"<minimax:tool_call>": 60,
|
||||
"</minimax:tool_call>": 61,
|
||||
}
|
||||
|
||||
|
||||
def _minimax_m2_arg_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _minimax_m2_tool_segments(tool_calls: list[ToolCallSpec]) -> list[tuple[str, bool]]:
|
||||
segs: list[tuple[str, bool]] = [("<minimax:tool_call>", True)]
|
||||
for tc in tool_calls:
|
||||
segs.append((f'<invoke name="{tc.name}">', False))
|
||||
for key, value in tc.arguments.items():
|
||||
segs.append(
|
||||
(
|
||||
f'<parameter name="{key}">'
|
||||
f"{_minimax_m2_arg_value(value)}"
|
||||
"</parameter>",
|
||||
False,
|
||||
)
|
||||
)
|
||||
segs.append(("</invoke>", False))
|
||||
segs.append(("</minimax:tool_call>", True))
|
||||
return segs
|
||||
|
||||
|
||||
def _minimax_m2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
|
||||
segs: list[tuple[str, bool]] = []
|
||||
if scenario.reasoning is not None:
|
||||
segs.append((scenario.reasoning, False))
|
||||
if scenario.content is not None or scenario.tool_calls:
|
||||
segs.append(("</think>", True))
|
||||
if scenario.content is not None:
|
||||
segs.append((scenario.content, False))
|
||||
if scenario.tool_calls:
|
||||
segs.extend(_minimax_m2_tool_segments(scenario.tool_calls))
|
||||
return segs
|
||||
|
||||
|
||||
def _build_minimax_m2(scenario: Scenario, validate: bool = True) -> Sample:
|
||||
expected_reasoning: str | None
|
||||
if scenario.reasoning is not None:
|
||||
expected_reasoning = scenario.reasoning.rstrip()
|
||||
else:
|
||||
expected_reasoning = ""
|
||||
|
||||
sample = _make_sample(
|
||||
sample_id=f"minimax_m2-{scenario.id}",
|
||||
description=scenario.description,
|
||||
vocab=_MINIMAX_M2_VOCAB,
|
||||
segments=_minimax_m2_segments(scenario),
|
||||
expected_reasoning=expected_reasoning,
|
||||
expected_content=_qwen3_expected_content(scenario),
|
||||
expected_tool_calls=_expected_tc(scenario),
|
||||
tools=_expected_tools(scenario),
|
||||
)
|
||||
if validate:
|
||||
_validate_sample(sample, MinimaxM2Parser)
|
||||
return sample
|
||||
|
||||
|
||||
# ── Gemma4 (channel reasoning, custom arg format) ────────────────────
|
||||
|
||||
_GEMMA4_VOCAB: dict[str, int] = {
|
||||
@@ -502,6 +576,7 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
|
||||
_BUILDERS: dict[str, Any] = {
|
||||
"qwen3": _build_qwen3,
|
||||
"gemma4": _build_gemma4,
|
||||
"minimax_m2": _build_minimax_m2,
|
||||
"nemotron_v3": _build_nemotron_v3,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,14 +59,6 @@ MULTIPLE_LINES = {
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
|
||||
# Case: only end token (empty reasoning, immediate response)
|
||||
SHORTEST_REASONING_NO_STREAMING = {
|
||||
"output": "</think>This is the response",
|
||||
"reasoning": "",
|
||||
"content": "This is the response",
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
|
||||
# Case: only end token streaming (reasoning is None because it's just the token)
|
||||
SHORTEST_REASONING_STREAMING = {
|
||||
"output": "</think>This is the response",
|
||||
@@ -75,14 +67,6 @@ SHORTEST_REASONING_STREAMING = {
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
|
||||
# Case: empty output
|
||||
EMPTY = {
|
||||
"output": "",
|
||||
"reasoning": "",
|
||||
"content": None,
|
||||
"is_reasoning_end": False,
|
||||
}
|
||||
|
||||
# Case: empty streaming
|
||||
EMPTY_STREAMING = {
|
||||
"output": "",
|
||||
@@ -149,21 +133,11 @@ TEST_CASES = [
|
||||
MULTIPLE_LINES,
|
||||
id="multiple_lines_streaming",
|
||||
),
|
||||
pytest.param(
|
||||
False,
|
||||
SHORTEST_REASONING_NO_STREAMING,
|
||||
id="shortest_reasoning",
|
||||
),
|
||||
pytest.param(
|
||||
True,
|
||||
SHORTEST_REASONING_STREAMING,
|
||||
id="shortest_reasoning_streaming",
|
||||
),
|
||||
pytest.param(
|
||||
False,
|
||||
EMPTY,
|
||||
id="empty",
|
||||
),
|
||||
pytest.param(
|
||||
True,
|
||||
EMPTY_STREAMING,
|
||||
|
||||
@@ -18,7 +18,6 @@ pytestmark = pytest.mark.cpu_test
|
||||
# Token IDs matching FakeTokenizer.vocab
|
||||
TC_START_ID = 1
|
||||
TC_END_ID = 2
|
||||
EOS_ID = 99
|
||||
|
||||
|
||||
class FakeTokenizer:
|
||||
@@ -34,6 +33,10 @@ class FakeTokenizer:
|
||||
def get_vocab(self):
|
||||
return self.vocab
|
||||
|
||||
def decode(self, token_ids):
|
||||
id_to_token = {v: k for k, v in self.vocab.items()}
|
||||
return "".join(id_to_token.get(token_id, "") for token_id in token_ids)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
@@ -121,7 +124,6 @@ class TestContentStreaming:
|
||||
"""No tool call tokens — all text is streamed as content."""
|
||||
results = _feed(parser, ["Hello ", "world"])
|
||||
assert _collect_content(results) == "Hello world"
|
||||
assert not parser.prev_tool_call_arr
|
||||
|
||||
def test_content_before_tool_call(self, parser):
|
||||
"""Text before <minimax:tool_call> is streamed as content."""
|
||||
@@ -135,7 +137,6 @@ class TestContentStreaming:
|
||||
],
|
||||
)
|
||||
assert _collect_content(results) == "Let me check. "
|
||||
assert len(parser.prev_tool_call_arr) == 1
|
||||
|
||||
def test_empty_delta_no_crash(self, parser):
|
||||
"""Empty delta_text with no token IDs returns None."""
|
||||
@@ -262,45 +263,6 @@ class TestMultipleInvokes:
|
||||
assert tc[1]["name"] == "get_stock"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state: prev_tool_call_arr
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInternalState:
|
||||
"""Verify prev_tool_call_arr is correct."""
|
||||
|
||||
def test_prev_tool_call_arr_single(self, parser):
|
||||
_feed(
|
||||
parser,
|
||||
[
|
||||
'<minimax:tool_call><invoke name="fn">'
|
||||
'<parameter name="a">1</parameter>'
|
||||
"</invoke></minimax:tool_call>",
|
||||
],
|
||||
)
|
||||
assert len(parser.prev_tool_call_arr) == 1
|
||||
assert parser.prev_tool_call_arr[0]["name"] == "fn"
|
||||
assert parser.prev_tool_call_arr[0]["arguments"] == {"a": "1"}
|
||||
|
||||
def test_prev_tool_call_arr_multiple(self, parser):
|
||||
"""prev_tool_call_arr records each invoke with correct arguments."""
|
||||
_feed(
|
||||
parser,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
'<invoke name="search"><parameter name="q">hello</parameter></invoke>',
|
||||
'<invoke name="search"><parameter name="q">world</parameter></invoke>',
|
||||
"</minimax:tool_call>",
|
||||
],
|
||||
)
|
||||
assert len(parser.prev_tool_call_arr) == 2
|
||||
assert parser.prev_tool_call_arr[0]["name"] == "search"
|
||||
assert parser.prev_tool_call_arr[0]["arguments"] == {"q": "hello"}
|
||||
assert parser.prev_tool_call_arr[1]["name"] == "search"
|
||||
assert parser.prev_tool_call_arr[1]["arguments"] == {"q": "world"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeltaMessage structure
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -324,7 +286,7 @@ class TestDeltaMessageFormat:
|
||||
tc = tc_deltas[0]
|
||||
assert tc.index == 0
|
||||
assert tc.type == "function"
|
||||
assert tc.id is not None and tc.id.startswith("call_")
|
||||
assert tc.id is not None
|
||||
assert tc.function.name == "fn"
|
||||
assert json.loads(tc.function.arguments) == {"k": "v"}
|
||||
|
||||
@@ -344,72 +306,6 @@ class TestDeltaMessageFormat:
|
||||
assert indices == [0, 1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: EOS handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEOSHandling:
|
||||
"""Tests for the end-of-stream phase."""
|
||||
|
||||
def test_eos_after_tool_calls(self, parser):
|
||||
"""EOS token (empty delta, non-special token id) returns content=''."""
|
||||
results = _feed(
|
||||
parser,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
'<invoke name="fn"><parameter name="k">v</parameter></invoke>',
|
||||
"</minimax:tool_call>",
|
||||
# EOS: empty delta_text, non-special token id
|
||||
("", [EOS_ID]),
|
||||
],
|
||||
)
|
||||
# Last result should be the EOS empty-content signal
|
||||
assert results[-1].content == ""
|
||||
|
||||
def test_end_token_ignored(self, parser):
|
||||
"""</minimax:tool_call> special token should NOT trigger EOS."""
|
||||
results = _feed(
|
||||
parser,
|
||||
[
|
||||
"<minimax:tool_call>",
|
||||
'<invoke name="fn"><parameter name="k">v</parameter></invoke>',
|
||||
# </minimax:tool_call> arrives as special token
|
||||
("", [TC_END_ID]),
|
||||
],
|
||||
)
|
||||
# The tool call delta should be emitted, but no EOS signal
|
||||
assert not any(r.content == "" and r.tool_calls is None for r in results)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start token detection via token IDs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpecialTokenDetection:
|
||||
"""Start token arrives as a special token (not in delta_text)."""
|
||||
|
||||
def test_start_token_via_id(self, parser):
|
||||
"""<minimax:tool_call> detected via delta_token_ids, not text."""
|
||||
results = _feed(parser, ["Hello "])
|
||||
assert _collect_content(results) == "Hello "
|
||||
|
||||
# Start token as special token (empty delta_text)
|
||||
previous = "Hello "
|
||||
result = parser.extract_tool_calls_streaming(
|
||||
previous_text=previous,
|
||||
current_text=previous,
|
||||
delta_text="",
|
||||
previous_token_ids=[],
|
||||
current_token_ids=[],
|
||||
delta_token_ids=[TC_START_ID],
|
||||
request=None,
|
||||
)
|
||||
assert result is None # no content to emit
|
||||
assert parser.is_tool_call_started is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Large chunks (stream_interval > 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -419,7 +315,7 @@ class TestLargeChunks:
|
||||
"""Simulate stream_interval > 1 where many tokens arrive at once."""
|
||||
|
||||
def test_header_and_params_in_separate_chunks(self, parser):
|
||||
"""Header in chunk 1, all params + close in chunk 2, then EOS."""
|
||||
"""Header in chunk 1, all params + close in chunk 2."""
|
||||
chunk1 = '<minimax:tool_call><invoke name="get_weather">'
|
||||
chunk2 = (
|
||||
'<parameter name="city">Seattle</parameter>'
|
||||
@@ -432,7 +328,6 @@ class TestLargeChunks:
|
||||
[
|
||||
chunk1,
|
||||
chunk2,
|
||||
("", [EOS_ID]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -441,12 +336,6 @@ class TestLargeChunks:
|
||||
parsed = json.loads(tc[0]["arguments"])
|
||||
assert parsed == {"city": "Seattle", "days": "5"}
|
||||
|
||||
assert len(parser.prev_tool_call_arr) == 1
|
||||
assert parser.prev_tool_call_arr[0]["arguments"] == {
|
||||
"city": "Seattle",
|
||||
"days": "5",
|
||||
}
|
||||
|
||||
|
||||
class TestAnyOfNullableParam:
|
||||
"""Regression: anyOf nullable parameter parsing (PR #32342)."""
|
||||
|
||||
@@ -127,12 +127,14 @@ class IncrementalLexer:
|
||||
|
||||
def flush(self) -> list[LexToken]:
|
||||
tokens: list[LexToken] = []
|
||||
if self.buffer:
|
||||
tokens.extend(self._drain(final=True))
|
||||
if self.buffer:
|
||||
tokens.append(LexToken(self.content_terminal, self.buffer))
|
||||
self.buffer = ""
|
||||
return tokens
|
||||
|
||||
def _drain(self) -> list[LexToken]:
|
||||
def _drain(self, *, final: bool = False) -> list[LexToken]:
|
||||
tokens: list[LexToken] = []
|
||||
first_chars = self._literal_first_chars
|
||||
content_terminal = self.content_terminal
|
||||
@@ -161,11 +163,22 @@ class IncrementalLexer:
|
||||
):
|
||||
best_match = (name, lit, len(lit))
|
||||
|
||||
if self.buffer in prefix_set:
|
||||
# If the current buffer is both a complete literal and the prefix
|
||||
# of a longer literal, wait for the next chunk. For example,
|
||||
# "<invoke name=" should not be emitted before the next chunk
|
||||
# proves whether this is the quoted form '<invoke name="'.
|
||||
if self.buffer in prefix_set and not final:
|
||||
if best_match is not None:
|
||||
tokens.append(LexToken(best_match[0], best_match[1]))
|
||||
self.buffer = self.buffer[best_match[2] :]
|
||||
continue
|
||||
longer_match = False
|
||||
for lit, _ in literals_by_first.get(first, ()):
|
||||
if len(lit) > best_match[2] and lit.startswith(self.buffer):
|
||||
longer_match = True
|
||||
break
|
||||
if not longer_match:
|
||||
tokens.append(LexToken(best_match[0], best_match[1]))
|
||||
self.buffer = self.buffer[best_match[2] :]
|
||||
continue
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
|
||||
from vllm.tool_parsers.utils import (
|
||||
coerce_to_schema_type,
|
||||
extract_types_from_schema,
|
||||
find_tool_name,
|
||||
find_tool_properties,
|
||||
)
|
||||
|
||||
@@ -319,15 +320,24 @@ class ParserEngine(Parser):
|
||||
return json.dumps(args, ensure_ascii=False)
|
||||
return args_json
|
||||
|
||||
def _is_valid_tool_name(self, name: str) -> bool:
|
||||
if not self.parser_engine_config.validate_tool_names:
|
||||
return True
|
||||
if not self._tools:
|
||||
return True
|
||||
return find_tool_name(self._tools, name)
|
||||
|
||||
# ── Private helpers ─────────────────────────────────────────────
|
||||
|
||||
def _check_skip_tool_parsing(
|
||||
self,
|
||||
request: ChatCompletionRequest | ResponsesRequest,
|
||||
) -> None:
|
||||
tools = getattr(request, "tools", None)
|
||||
if tools:
|
||||
self._tools = tools
|
||||
if not self.skip_tool_parsing:
|
||||
tool_choice = getattr(request, "tool_choice", None)
|
||||
tools = getattr(request, "tools", None)
|
||||
if tool_choice == "none" and tools:
|
||||
self.skip_tool_parsing = True
|
||||
|
||||
@@ -467,6 +477,7 @@ class ParserEngine(Parser):
|
||||
output, this method starts the parser engine in ``CONTENT`` state
|
||||
so it can parse content that has already had reasoning stripped.
|
||||
"""
|
||||
self._check_skip_tool_parsing(request)
|
||||
_, parsed_content, tool_call_info = self._single_pass_parse(
|
||||
content,
|
||||
[],
|
||||
@@ -586,6 +597,7 @@ class ParserEngine(Parser):
|
||||
enable_auto_tools: bool = False,
|
||||
model_output_token_ids: Sequence[int] = (),
|
||||
) -> tuple[str | None, str | None, list[FunctionCall] | None]:
|
||||
self._check_skip_tool_parsing(request)
|
||||
reasoning, content, tool_call_info = self._single_pass_parse(
|
||||
model_output,
|
||||
model_output_token_ids,
|
||||
@@ -705,7 +717,7 @@ class ParserEngine(Parser):
|
||||
deltas: list[DeltaToolCall],
|
||||
name: str | None,
|
||||
) -> None:
|
||||
if not name:
|
||||
if not name or not self._is_valid_tool_name(name):
|
||||
return
|
||||
slot = self._tool_slots[idx]
|
||||
slot.name = name
|
||||
@@ -762,7 +774,7 @@ class ParserEngine(Parser):
|
||||
|
||||
if not slot.name_sent:
|
||||
name = slot.name or self._try_extract_name(idx)
|
||||
if name:
|
||||
if name and self._is_valid_tool_name(name):
|
||||
slot.name = name
|
||||
slot.name_sent = True
|
||||
self._ensure_tool_id(slot, name)
|
||||
@@ -934,7 +946,7 @@ class ParserEngine(Parser):
|
||||
else:
|
||||
args_json = "{}"
|
||||
|
||||
if name:
|
||||
if name and self._is_valid_tool_name(name):
|
||||
self._ensure_tool_id(slot, name)
|
||||
args_json = self._fix_arg_types(args_json, name)
|
||||
tool_calls.append(
|
||||
|
||||
@@ -99,6 +99,9 @@ class ParserEngineConfig:
|
||||
# .strip() content text when tool calls are present.
|
||||
strip_content_whitespace_with_tools: bool = True
|
||||
|
||||
# Reject tool calls whose names are absent from the request tools.
|
||||
validate_tool_names: bool = False
|
||||
|
||||
drop_tokens: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -9,9 +9,15 @@ names so that :class:`ReasoningParserManager` and
|
||||
|
||||
from vllm.parser.engine.adapters import make_adapters
|
||||
from vllm.parser.gemma4 import Gemma4Parser
|
||||
from vllm.parser.minimax_m2 import MinimaxM2Parser
|
||||
from vllm.parser.nemotron_v3 import NemotronV3Parser
|
||||
from vllm.parser.qwen3 import Qwen3Parser
|
||||
|
||||
(
|
||||
MinimaxM2ParserReasoningAdapter,
|
||||
MinimaxM2ParserToolAdapter,
|
||||
) = make_adapters(MinimaxM2Parser)
|
||||
|
||||
(
|
||||
Gemma4ParserReasoningAdapter,
|
||||
Gemma4ParserToolAdapter,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""MiniMax M2 parser for XML-style tool calls.
|
||||
|
||||
MiniMax M2 tool call format::
|
||||
|
||||
<minimax:tool_call><invoke name="get_weather">
|
||||
<parameter name="city">Seattle</parameter>
|
||||
</invoke></minimax:tool_call>
|
||||
|
||||
Each ``<invoke>`` block becomes one tool call. The argument body consists
|
||||
of ``<parameter name="...">...</parameter>`` tags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
|
||||
import regex as re
|
||||
|
||||
from vllm.parser.engine.events import EventType
|
||||
from vllm.parser.engine.parser_engine import ParserEngine
|
||||
from vllm.parser.engine.parser_engine_config import (
|
||||
ParserEngineConfig,
|
||||
ParserState,
|
||||
Transition,
|
||||
)
|
||||
|
||||
TOOL_CALL_START = "<minimax:tool_call>"
|
||||
TOOL_CALL_END = "</minimax:tool_call>"
|
||||
THINK_START = "<think>"
|
||||
THINK_END = "</think>"
|
||||
INVOKE_PREFIX_DQ = '<invoke name="'
|
||||
INVOKE_PREFIX_SQ = "<invoke name='"
|
||||
INVOKE_PREFIX_UNQUOTED = "<invoke name="
|
||||
INVOKE_END = "</invoke>"
|
||||
NAME_END_DQ = '">'
|
||||
NAME_END_SQ = "'>"
|
||||
NAME_END_UNQUOTED = ">"
|
||||
|
||||
_PARAM_RE = re.compile(
|
||||
r"<\s*parameter\s+name\s*=\s*"
|
||||
r"(?:\"(?P<dq_name>[^\"]*)\"|'(?P<sq_name>[^']*)'|(?P<bare_name>[^>\s]+))"
|
||||
r"\s*>"
|
||||
r"(?P<value>.*?)"
|
||||
r"<\s*/\s*parameter\s*>",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str:
|
||||
params: dict[str, object] = {}
|
||||
|
||||
for match in _PARAM_RE.finditer(raw_args):
|
||||
name = (
|
||||
match.group("dq_name")
|
||||
or match.group("sq_name")
|
||||
or match.group("bare_name")
|
||||
or ""
|
||||
).strip()
|
||||
if not name:
|
||||
continue
|
||||
params[name] = match.group("value").strip()
|
||||
|
||||
return json.dumps(params, ensure_ascii=False)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def minimax_m2_config() -> ParserEngineConfig:
|
||||
return ParserEngineConfig(
|
||||
name="minimax_m2",
|
||||
initial_state=ParserState.REASONING,
|
||||
terminals={
|
||||
"THINK_START": THINK_START,
|
||||
"THINK_END": THINK_END,
|
||||
"TOOL_START": TOOL_CALL_START,
|
||||
"TOOL_END": TOOL_CALL_END,
|
||||
"INVOKE_PREFIX_DQ": INVOKE_PREFIX_DQ,
|
||||
"INVOKE_PREFIX_SQ": INVOKE_PREFIX_SQ,
|
||||
"INVOKE_PREFIX_UNQUOTED": INVOKE_PREFIX_UNQUOTED,
|
||||
"INVOKE_END": INVOKE_END,
|
||||
"NAME_END_DQ": NAME_END_DQ,
|
||||
"NAME_END_SQ": NAME_END_SQ,
|
||||
"NAME_END_UNQUOTED": NAME_END_UNQUOTED,
|
||||
},
|
||||
token_id_terminals={
|
||||
"THINK_START": THINK_START,
|
||||
"THINK_END": THINK_END,
|
||||
"TOOL_START": TOOL_CALL_START,
|
||||
"TOOL_END": TOOL_CALL_END,
|
||||
},
|
||||
transitions={
|
||||
(ParserState.REASONING, "THINK_START"): Transition(
|
||||
ParserState.REASONING,
|
||||
(),
|
||||
),
|
||||
(ParserState.REASONING, "THINK_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(EventType.REASONING_END,),
|
||||
),
|
||||
(ParserState.CONTENT, "THINK_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(),
|
||||
),
|
||||
(ParserState.REASONING, "TOOL_START"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
(EventType.REASONING_END,),
|
||||
),
|
||||
(ParserState.CONTENT, "TOOL_START"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
(),
|
||||
),
|
||||
(ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(),
|
||||
),
|
||||
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(),
|
||||
),
|
||||
(ParserState.CONTENT, "TOOL_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(),
|
||||
),
|
||||
(ParserState.TOOL_ARGS, "INVOKE_END"): Transition(
|
||||
ParserState.TOOL_BETWEEN,
|
||||
(EventType.TOOL_CALL_END,),
|
||||
),
|
||||
**{
|
||||
(state, terminal): Transition(
|
||||
ParserState.TOOL_NAME,
|
||||
(EventType.TOOL_CALL_START,),
|
||||
)
|
||||
for state in (
|
||||
ParserState.CONTENT,
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
ParserState.TOOL_BETWEEN,
|
||||
)
|
||||
for terminal in (
|
||||
"INVOKE_PREFIX_DQ",
|
||||
"INVOKE_PREFIX_SQ",
|
||||
"INVOKE_PREFIX_UNQUOTED",
|
||||
)
|
||||
},
|
||||
**{
|
||||
(ParserState.TOOL_NAME, terminal): Transition(
|
||||
ParserState.TOOL_ARGS,
|
||||
(),
|
||||
)
|
||||
for terminal in (
|
||||
"NAME_END_DQ",
|
||||
"NAME_END_SQ",
|
||||
"NAME_END_UNQUOTED",
|
||||
)
|
||||
},
|
||||
},
|
||||
arg_converter=_minimax_m2_arg_converter,
|
||||
stream_arg_deltas=True,
|
||||
tool_args_json=False,
|
||||
validate_tool_names=True,
|
||||
)
|
||||
|
||||
|
||||
class MinimaxM2Parser(ParserEngine):
|
||||
"""MiniMax M2 parser backed by the declarative parser engine."""
|
||||
|
||||
def __init__(self, tokenizer, tools=None, **kwargs) -> None:
|
||||
kwargs.setdefault("parser_engine_config", minimax_m2_config())
|
||||
super().__init__(tokenizer, tools, **kwargs)
|
||||
self._think_end_token_id = self.vocab.get(THINK_END)
|
||||
|
||||
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
|
||||
end_id = self._think_end_token_id
|
||||
if end_id is None:
|
||||
return []
|
||||
for i in range(len(input_ids) - 1, -1, -1):
|
||||
if input_ids[i] == end_id:
|
||||
return input_ids[i + 1 :]
|
||||
return []
|
||||
@@ -8,8 +8,8 @@ from vllm.entrypoints.openai.engine.protocol import (
|
||||
DeltaMessage,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.parser.engine.registered_adapters import MinimaxM2ParserReasoningAdapter
|
||||
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
|
||||
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
|
||||
class MiniMaxM2ReasoningParser(MinimaxM2ParserReasoningAdapter): # type: ignore[valid-type, misc]
|
||||
"""
|
||||
Reasoning parser for MiniMax M2 model.
|
||||
|
||||
@@ -28,55 +28,6 @@ class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
|
||||
actual response.
|
||||
"""
|
||||
|
||||
@property
|
||||
def start_token(self) -> str:
|
||||
"""The token that starts reasoning content."""
|
||||
return "<think>"
|
||||
|
||||
@property
|
||||
def end_token(self) -> str:
|
||||
"""The token that ends reasoning content."""
|
||||
return "</think>"
|
||||
|
||||
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:
|
||||
"""
|
||||
Extract reasoning content from a delta message for streaming.
|
||||
|
||||
MiniMax M2 models don't generate <think> start token, so we assume
|
||||
all content is reasoning until we encounter the </think> end token.
|
||||
"""
|
||||
# Skip single end token
|
||||
if len(delta_token_ids) == 1 and delta_token_ids[0] == self.end_token_id:
|
||||
return None
|
||||
|
||||
# Check if end token has already appeared in previous tokens
|
||||
# meaning we're past the reasoning phase
|
||||
if self.end_token_id in previous_token_ids:
|
||||
# We're past the reasoning phase, this is content
|
||||
return DeltaMessage(content=delta_text)
|
||||
|
||||
# Check if end token is in delta tokens
|
||||
if self.end_token_id in delta_token_ids:
|
||||
# End token in delta, split reasoning and content
|
||||
end_index = delta_text.find(self.end_token)
|
||||
reasoning = delta_text[:end_index]
|
||||
content = delta_text[end_index + len(self.end_token) :]
|
||||
return DeltaMessage(
|
||||
reasoning=reasoning if reasoning else None,
|
||||
content=content if content else None,
|
||||
)
|
||||
|
||||
# No end token yet, all content is reasoning
|
||||
return DeltaMessage(reasoning=delta_text)
|
||||
|
||||
|
||||
class MiniMaxM2AppendThinkReasoningParser(ReasoningParser):
|
||||
"""
|
||||
|
||||
@@ -1,284 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
|
||||
import regex as re
|
||||
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionRequest,
|
||||
)
|
||||
from vllm.entrypoints.openai.engine.protocol import (
|
||||
DeltaFunctionCall,
|
||||
DeltaMessage,
|
||||
DeltaToolCall,
|
||||
ExtractedToolCallInformation,
|
||||
FunctionCall,
|
||||
ToolCall,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tool_parsers.abstract_tool_parser import (
|
||||
Tool,
|
||||
ToolParser,
|
||||
)
|
||||
from vllm.tool_parsers.utils import (
|
||||
coerce_to_schema_type,
|
||||
extract_types_from_schema,
|
||||
find_tool_properties,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
from vllm.parser.engine.registered_adapters import MinimaxM2ParserToolAdapter
|
||||
|
||||
|
||||
class MinimaxM2ToolParser(ToolParser):
|
||||
class MinimaxM2ToolParser(MinimaxM2ParserToolAdapter): # type: ignore[valid-type, misc]
|
||||
structural_tag_model = "minimax"
|
||||
|
||||
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
|
||||
super().__init__(tokenizer, tools)
|
||||
|
||||
self.prev_tool_call_arr: list[dict] = []
|
||||
|
||||
# Sentinel tokens
|
||||
self.tool_call_start_token: str = "<minimax:tool_call>"
|
||||
self.tool_call_end_token: str = "</minimax:tool_call>"
|
||||
|
||||
# Streaming state
|
||||
self.is_tool_call_started: bool = False
|
||||
self.current_tool_index: int = 0
|
||||
|
||||
# Regex patterns for complete parsing
|
||||
self.tool_call_complete_regex = re.compile(
|
||||
r"<minimax:tool_call>(.*?)</minimax:tool_call>", re.DOTALL
|
||||
)
|
||||
self.invoke_complete_regex = re.compile(
|
||||
r"<invoke name=(.*?)</invoke>", re.DOTALL
|
||||
)
|
||||
self.parameter_complete_regex = re.compile(
|
||||
r"<parameter name=(.*?)</parameter>", re.DOTALL
|
||||
)
|
||||
|
||||
if not self.model_tokenizer:
|
||||
raise ValueError(
|
||||
"The model tokenizer must be passed to the ToolParser "
|
||||
"constructor during construction."
|
||||
)
|
||||
|
||||
self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
|
||||
self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
|
||||
|
||||
if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
|
||||
raise RuntimeError(
|
||||
"MiniMax M2 Tool parser could not locate tool call start/end "
|
||||
"tokens in the tokenizer!"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"vLLM Successfully import tool parser %s !", self.__class__.__name__
|
||||
)
|
||||
|
||||
def _generate_tool_call_id(self) -> str:
|
||||
"""Generate a unique tool call ID."""
|
||||
return f"call_{uuid.uuid4().hex[:24]}"
|
||||
|
||||
def _extract_name(self, name_str: str) -> str:
|
||||
"""Extract name from quoted string."""
|
||||
name_str = name_str.strip()
|
||||
if (name_str.startswith('"') and name_str.endswith('"')) or (
|
||||
name_str.startswith("'") and name_str.endswith("'")
|
||||
):
|
||||
return name_str[1:-1]
|
||||
return name_str
|
||||
|
||||
def _parse_single_invoke(
|
||||
self, invoke_str: str, tools: list | None
|
||||
) -> ToolCall | None:
|
||||
"""Parse a single <invoke> block."""
|
||||
# Extract function name
|
||||
name_match = re.search(r"^([^>]+)", invoke_str)
|
||||
if not name_match:
|
||||
return None
|
||||
|
||||
function_name = self._extract_name(name_match.group(1))
|
||||
tool_properties = find_tool_properties(tools, function_name)
|
||||
|
||||
# Extract parameters
|
||||
param_dict = {}
|
||||
for match in self.parameter_complete_regex.findall(invoke_str):
|
||||
param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
|
||||
if param_match:
|
||||
param_name = self._extract_name(param_match.group(1))
|
||||
param_value = param_match.group(2).strip()
|
||||
param_types = extract_types_from_schema(
|
||||
tool_properties.get(param_name, {})
|
||||
)
|
||||
param_dict[param_name] = coerce_to_schema_type(param_value, param_types)
|
||||
|
||||
return ToolCall(
|
||||
type="function",
|
||||
function=FunctionCall(
|
||||
name=function_name,
|
||||
arguments=json.dumps(param_dict, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
def _extract_delta_tool_calls(
|
||||
self,
|
||||
current_text: str,
|
||||
request: ChatCompletionRequest | None,
|
||||
) -> list[DeltaToolCall]:
|
||||
"""Extract DeltaToolCalls from newly completed <invoke> blocks.
|
||||
|
||||
Tracks progress via ``current_tool_index`` so each block is
|
||||
extracted exactly once across successive streaming calls.
|
||||
"""
|
||||
complete_invokes = self.invoke_complete_regex.findall(current_text)
|
||||
delta_tool_calls: list[DeltaToolCall] = []
|
||||
|
||||
while len(complete_invokes) > self.current_tool_index:
|
||||
invoke_str = complete_invokes[self.current_tool_index]
|
||||
tool_call = self._parse_single_invoke(
|
||||
invoke_str,
|
||||
self.tools,
|
||||
)
|
||||
if not tool_call:
|
||||
self.current_tool_index += 1
|
||||
continue
|
||||
|
||||
args_json = tool_call.function.arguments
|
||||
idx = self.current_tool_index
|
||||
self.current_tool_index += 1
|
||||
|
||||
self.prev_tool_call_arr.append(
|
||||
{
|
||||
"name": tool_call.function.name,
|
||||
"arguments": json.loads(args_json),
|
||||
}
|
||||
)
|
||||
self.streamed_args_for_tool.append(args_json)
|
||||
delta_tool_calls.append(
|
||||
DeltaToolCall(
|
||||
index=idx,
|
||||
id=self._generate_tool_call_id(),
|
||||
function=DeltaFunctionCall(
|
||||
name=tool_call.function.name,
|
||||
arguments=args_json,
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
)
|
||||
|
||||
return delta_tool_calls
|
||||
|
||||
def extract_tool_calls(
|
||||
self,
|
||||
model_output: str,
|
||||
request: ChatCompletionRequest,
|
||||
) -> ExtractedToolCallInformation:
|
||||
"""Extract tool calls from complete model output (non-streaming)."""
|
||||
# Quick check
|
||||
if self.tool_call_start_token not in model_output:
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=False, tool_calls=[], content=model_output
|
||||
)
|
||||
|
||||
try:
|
||||
tool_calls = []
|
||||
|
||||
# Find all complete tool_call blocks
|
||||
for tool_call_match in self.tool_call_complete_regex.findall(model_output):
|
||||
# Find all invokes within this tool_call
|
||||
for invoke_match in self.invoke_complete_regex.findall(tool_call_match):
|
||||
tool_call = self._parse_single_invoke(invoke_match, self.tools)
|
||||
if tool_call:
|
||||
tool_calls.append(tool_call)
|
||||
|
||||
if not tool_calls:
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=False, tool_calls=[], content=model_output
|
||||
)
|
||||
|
||||
# Update prev_tool_call_arr
|
||||
self.prev_tool_call_arr.clear()
|
||||
for tool_call in tool_calls:
|
||||
self.prev_tool_call_arr.append(
|
||||
{
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments,
|
||||
}
|
||||
)
|
||||
|
||||
# Extract content before first tool call
|
||||
first_tool_idx = model_output.find(self.tool_call_start_token)
|
||||
content = model_output[:first_tool_idx] if first_tool_idx > 0 else None
|
||||
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=True, tool_calls=tool_calls, content=content
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error extracting tool calls")
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=False, tool_calls=[], content=model_output
|
||||
)
|
||||
|
||||
def extract_tool_calls_streaming(
|
||||
self,
|
||||
previous_text: str,
|
||||
current_text: str,
|
||||
delta_text: str,
|
||||
previous_token_ids: Sequence[int], # pylint: disable=unused-argument
|
||||
current_token_ids: Sequence[int], # pylint: disable=unused-argument
|
||||
delta_token_ids: Sequence[int],
|
||||
request: ChatCompletionRequest,
|
||||
) -> DeltaMessage | None:
|
||||
"""Extract tool calls from streaming model output.
|
||||
|
||||
Uses a buffer-until-complete-invoke strategy: tokens are buffered
|
||||
until a complete ``<invoke>...</invoke>`` block is available, then
|
||||
parsed and emitted in one shot.
|
||||
"""
|
||||
|
||||
start_in_text = self.tool_call_start_token in delta_text
|
||||
start_in_ids = self.tool_call_start_token_id in delta_token_ids
|
||||
tool_call_starting = start_in_text or start_in_ids
|
||||
# Reset state on new request (parser is reused) or new tool-call block.
|
||||
if not previous_text or tool_call_starting:
|
||||
self.current_tool_index = 0
|
||||
self.prev_tool_call_arr.clear()
|
||||
self.streamed_args_for_tool.clear()
|
||||
self.is_tool_call_started = tool_call_starting
|
||||
|
||||
# Pass through content before any tool call.
|
||||
if not self.is_tool_call_started:
|
||||
return DeltaMessage(content=delta_text) if delta_text else None
|
||||
|
||||
# Capture content before the start token.
|
||||
content_before = None
|
||||
if start_in_text:
|
||||
before = delta_text[: delta_text.index(self.tool_call_start_token)]
|
||||
content_before = before or None
|
||||
|
||||
# Extract newly completed <invoke> blocks as DeltaToolCalls.
|
||||
delta_tool_calls = self._extract_delta_tool_calls(current_text, request)
|
||||
|
||||
if delta_tool_calls or content_before:
|
||||
return DeltaMessage(
|
||||
content=content_before,
|
||||
tool_calls=delta_tool_calls,
|
||||
)
|
||||
|
||||
# EOS and </minimax:tool_call> both arrive as special tokens with
|
||||
# no decoded text. Return non-None for EOS so the serving framework
|
||||
# reaches the finish-reason handling path instead of skipping.
|
||||
if (
|
||||
not delta_text
|
||||
and delta_token_ids
|
||||
and self.prev_tool_call_arr
|
||||
and self.tool_call_end_token_id not in delta_token_ids
|
||||
):
|
||||
return DeltaMessage(content="")
|
||||
|
||||
return None
|
||||
|
||||
@@ -182,6 +182,22 @@ def find_tool_properties(
|
||||
return {}
|
||||
|
||||
|
||||
def find_tool_name(
|
||||
tools: list[Tool] | None,
|
||||
tool_name: str,
|
||||
) -> bool:
|
||||
"""Return whether a function tool with *tool_name* exists."""
|
||||
if not tools:
|
||||
return False
|
||||
for tool in tools:
|
||||
if not _is_function_tool(tool):
|
||||
continue
|
||||
name, _ = _extract_tool_info(tool)
|
||||
if name == tool_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_tool_schema_from_tool(tool: Tool) -> dict:
|
||||
name, params = _extract_tool_info(tool)
|
||||
params = params if params else {"type": "object", "properties": {}}
|
||||
|
||||
Reference in New Issue
Block a user