forked from Karylab-cklius/vllm
[Frontend] Port seed_oss to the streaming parser engine as a Qwen3 subclass (#46314)
Signed-off-by: EazyReal <8047065+EazyReal@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for the engine-based seed_oss parser.
|
||||
|
||||
seed_oss is Qwen3 with four overridden wrapper tokens, so the shared grammar
|
||||
(arg types, multiline values, parallel calls, streaming mechanics, …) is
|
||||
already covered by ``test_qwen3.py``/``test_qwen3_reasoning.py``. These tests
|
||||
cover only what is seed_oss-specific: that the ``seed:`` token overrides are
|
||||
wired through, the reasoning→tool boundary holds with them, the malformed
|
||||
header from #46314 no longer drops sibling calls, and the registered adapters
|
||||
resolve. Seed-specific budget-reflect tags inside reasoning are also covered
|
||||
here because the old dedicated parser tests exercised them.
|
||||
"""
|
||||
|
||||
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_reasoning_streaming,
|
||||
simulate_tool_streaming,
|
||||
)
|
||||
from vllm.parser.engine.registered_adapters import (
|
||||
SeedOssParserReasoningAdapter,
|
||||
SeedOssParserToolAdapter,
|
||||
)
|
||||
from vllm.parser.seed_oss import SeedOssParser
|
||||
|
||||
TOOL_CALL_START = "<seed:tool_call>"
|
||||
TOOL_CALL_END = "</seed:tool_call>"
|
||||
THINK_START = "<seed:think>"
|
||||
THINK_END = "</seed:think>"
|
||||
|
||||
_THINK_END_ID = 51
|
||||
_TOOL_CALL_ID = 60
|
||||
|
||||
_SEED_OSS_VOCAB = {
|
||||
THINK_START: 50,
|
||||
THINK_END: _THINK_END_ID,
|
||||
TOOL_CALL_START: _TOOL_CALL_ID,
|
||||
TOOL_CALL_END: 61,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tokenizer():
|
||||
return make_mock_tokenizer(_SEED_OSS_VOCAB)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_parser(mock_tokenizer):
|
||||
return SeedOssParser(
|
||||
mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser(mock_tokenizer):
|
||||
return SeedOssParser(mock_tokenizer)
|
||||
|
||||
|
||||
def test_token_overrides_wired(parser):
|
||||
assert parser.parser_engine_config.name == "seed_oss"
|
||||
assert parser.reasoning_start_str == THINK_START
|
||||
assert parser.reasoning_end_str == THINK_END
|
||||
|
||||
|
||||
def test_single_tool_call(tool_parser, mock_request):
|
||||
text = (
|
||||
f"{TOOL_CALL_START}\n<function=get_weather>\n"
|
||||
"<parameter=city>Tokyo</parameter>\n"
|
||||
f"</function>\n{TOOL_CALL_END}"
|
||||
)
|
||||
result = tool_parser.extract_tool_calls(text, mock_request)
|
||||
|
||||
assert result.tools_called is True
|
||||
assert result.tool_calls[0].function.name == "get_weather"
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == {"city": "Tokyo"}
|
||||
|
||||
|
||||
def test_malformed_function_end_does_not_drop_siblings(tool_parser, mock_request):
|
||||
"""Regression for #46314: a malformed ``</function>`` with no closing ``>``
|
||||
on the header must not discard the other, well-formed calls."""
|
||||
text = (
|
||||
f"{TOOL_CALL_START}\n<function=broken</function>\n{TOOL_CALL_END}"
|
||||
f"{TOOL_CALL_START}\n<function=get_weather>\n"
|
||||
"<parameter=city>Tokyo</parameter>\n"
|
||||
f"</function>\n{TOOL_CALL_END}"
|
||||
)
|
||||
result = tool_parser.extract_tool_calls(text, mock_request)
|
||||
|
||||
weather = next(tc for tc in result.tool_calls if tc.function.name == "get_weather")
|
||||
assert json.loads(weather.function.arguments) == {"city": "Tokyo"}
|
||||
|
||||
|
||||
def test_basic_streaming(tool_parser, mock_request):
|
||||
chunks = [
|
||||
f"{TOOL_CALL_START}\n",
|
||||
"<function=get_weather>\n",
|
||||
"<parameter=city>Tokyo",
|
||||
"</parameter>\n",
|
||||
"</function>\n",
|
||||
f"{TOOL_CALL_END}",
|
||||
]
|
||||
results = simulate_tool_streaming(tool_parser, mock_request, chunks)
|
||||
|
||||
assert collect_function_name(results) == "get_weather"
|
||||
assert json.loads(collect_tool_arguments(results)) == {"city": "Tokyo"}
|
||||
|
||||
|
||||
def test_reasoning_then_tool_call(parser):
|
||||
text = (
|
||||
f"{THINK_START}I need to read the file.{THINK_END}"
|
||||
f"{TOOL_CALL_START}\n<function=read>\n"
|
||||
"<parameter=path>/tmp/x</parameter>\n"
|
||||
f"</function>\n{TOOL_CALL_END}"
|
||||
)
|
||||
reasoning, _ = parser.extract_reasoning(text, None)
|
||||
assert reasoning == "I need to read the file."
|
||||
assert TOOL_CALL_START not in reasoning
|
||||
|
||||
|
||||
def test_streaming_think_end_and_tool_call_same_delta(parser):
|
||||
"""``</seed:think>`` and ``<seed:tool_call>`` arriving in one delta must
|
||||
not leak the terminal tokens into the reasoning text."""
|
||||
reasoning, content = simulate_reasoning_streaming(
|
||||
parser,
|
||||
[
|
||||
"Let me list the directory.",
|
||||
f"{THINK_END}{TOOL_CALL_START}",
|
||||
"<function=read>",
|
||||
],
|
||||
[(1,), (_THINK_END_ID, _TOOL_CALL_ID), (2,)],
|
||||
)
|
||||
assert reasoning == "Let me list the directory."
|
||||
assert THINK_END not in reasoning
|
||||
assert TOOL_CALL_START not in reasoning
|
||||
assert content is not None
|
||||
|
||||
|
||||
def test_end_to_end_through_registered_adapters(mock_tokenizer, mock_request):
|
||||
reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer)
|
||||
tool_parser = SeedOssParserToolAdapter(mock_tokenizer)
|
||||
text = (
|
||||
f"{THINK_START}Plan the call.{THINK_END}"
|
||||
f"{TOOL_CALL_START}\n<function=get_weather>\n"
|
||||
"<parameter=city>Tokyo</parameter>\n"
|
||||
f"</function>\n{TOOL_CALL_END}"
|
||||
)
|
||||
reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request)
|
||||
assert reasoning == "Plan the call."
|
||||
|
||||
tool_result = tool_parser.extract_tool_calls(remaining, mock_request)
|
||||
assert tool_result.tool_calls[0].function.name == "get_weather"
|
||||
assert json.loads(tool_result.tool_calls[0].function.arguments) == {"city": "Tokyo"}
|
||||
|
||||
|
||||
def test_budget_reflect_tags_do_not_break_adapter_pipeline(
|
||||
mock_tokenizer,
|
||||
mock_request,
|
||||
):
|
||||
reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer)
|
||||
tool_parser = SeedOssParserToolAdapter(mock_tokenizer)
|
||||
text = (
|
||||
f"{THINK_START}"
|
||||
"The user's current thinking budget is 512.</seed:cot_budget_reflect>\n"
|
||||
"I need the weather.\n"
|
||||
"<seed:cot_budget_reflect>I have used 131 tokens."
|
||||
"</seed:cot_budget_reflect>\n"
|
||||
f"{THINK_END}"
|
||||
f"{TOOL_CALL_START}\n<function=get_weather>\n"
|
||||
"<parameter=city>Barcelona</parameter>\n"
|
||||
f"</function>\n{TOOL_CALL_END}"
|
||||
)
|
||||
|
||||
reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request)
|
||||
assert reasoning is not None
|
||||
assert "current thinking budget is 512" in reasoning
|
||||
assert "<seed:cot_budget_reflect>" in reasoning
|
||||
assert "</seed:cot_budget_reflect>" in reasoning
|
||||
|
||||
tool_result = tool_parser.extract_tool_calls(remaining, mock_request)
|
||||
assert tool_result.tool_calls[0].function.name == "get_weather"
|
||||
assert json.loads(tool_result.tool_calls[0].function.arguments) == {
|
||||
"city": "Barcelona"
|
||||
}
|
||||
@@ -34,6 +34,7 @@ from vllm.parser.engine.registered_adapters import (
|
||||
MinimaxM2Parser,
|
||||
NemotronV3Parser,
|
||||
Qwen3Parser,
|
||||
SeedOssParser,
|
||||
)
|
||||
|
||||
# ── Data structures ──────────────────────────────────────────────────
|
||||
@@ -587,6 +588,61 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
|
||||
)
|
||||
|
||||
|
||||
# ── Seed-OSS (Qwen3 XML grammar with Seed wrapper tokens) ────────────
|
||||
|
||||
_SEED_OSS_VOCAB: dict[str, int] = {
|
||||
"<seed:think>": 50,
|
||||
"</seed:think>": 51,
|
||||
"<seed:tool_call>": 60,
|
||||
"</seed:tool_call>": 61,
|
||||
}
|
||||
|
||||
|
||||
def _seed_oss_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
|
||||
parts = [f"\n<function={tc.name}>"]
|
||||
for key, value in tc.arguments.items():
|
||||
parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
|
||||
parts.append("\n</function>\n")
|
||||
return [
|
||||
("<seed:tool_call>", True),
|
||||
("".join(parts), False),
|
||||
("</seed:tool_call>", True),
|
||||
]
|
||||
|
||||
|
||||
def _seed_oss_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 is not None:
|
||||
segs.append(("</seed:think>", True))
|
||||
if scenario.tool_calls is not None and not scenario.tool_calls:
|
||||
segs.append(("<seed:tool_call>", True))
|
||||
segs.append(("</seed:tool_call>", True))
|
||||
if scenario.content is not None:
|
||||
segs.append((scenario.content, False))
|
||||
if scenario.tool_calls:
|
||||
for tc in scenario.tool_calls:
|
||||
segs.extend(_seed_oss_tool_segments(tc))
|
||||
return segs
|
||||
|
||||
|
||||
def _build_seed_oss(scenario: Scenario, validate: bool = True) -> Sample:
|
||||
sample = _make_sample(
|
||||
sample_id=f"seed_oss-{scenario.id}",
|
||||
description=scenario.description,
|
||||
vocab=_SEED_OSS_VOCAB,
|
||||
segments=_seed_oss_segments(scenario),
|
||||
expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
|
||||
expected_content=_qwen3_expected_content(scenario),
|
||||
expected_tool_calls=_expected_tc(scenario),
|
||||
tools=_expected_tools(scenario),
|
||||
)
|
||||
if validate:
|
||||
_validate_sample(sample, SeedOssParser)
|
||||
return sample
|
||||
|
||||
|
||||
# ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────
|
||||
|
||||
_GLM47_MOE_VOCAB: dict[str, int] = {
|
||||
@@ -668,6 +724,7 @@ _BUILDERS: dict[str, Any] = {
|
||||
"gemma4": _build_gemma4,
|
||||
"minimax_m2": _build_minimax_m2,
|
||||
"nemotron_v3": _build_nemotron_v3,
|
||||
"seed_oss": _build_seed_oss,
|
||||
"glm47_moe": _build_glm47_moe,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from tests.reasoning.utils import run_reasoning_extraction
|
||||
from vllm.reasoning import ReasoningParser, ReasoningParserManager
|
||||
|
||||
parser_name = "seed_oss"
|
||||
start_token = "<seed:think>"
|
||||
end_token = "</seed:think>"
|
||||
|
||||
# Use a test model that contains our custom tokens
|
||||
REASONING_MODEL_NAME = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seedoss_tokenizer():
|
||||
tokenizer = AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
|
||||
# Add custom SeedOSS tokens if they don't exist
|
||||
if start_token not in tokenizer.get_vocab():
|
||||
tokenizer.add_tokens([start_token, end_token])
|
||||
return tokenizer
|
||||
|
||||
|
||||
SIMPLE_REASONING: dict[str, Any] = {
|
||||
"output": "This is a reasoning section</seed:think>This is the rest",
|
||||
"reasoning": "This is a reasoning section",
|
||||
"content": "This is the rest",
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
COMPLETE_REASONING: dict[str, Any] = {
|
||||
"output": "This is a reasoning section</seed:think>",
|
||||
"reasoning": "This is a reasoning section",
|
||||
"content": None,
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
NO_CONTENT: dict[str, Any] = {
|
||||
"output": "This is content",
|
||||
"reasoning": "This is content",
|
||||
"content": None,
|
||||
"is_reasoning_end": False,
|
||||
}
|
||||
NO_REASONING_STREAMING: dict[str, Any] = {
|
||||
"output": "This is a reasoning section",
|
||||
"reasoning": "This is a reasoning section",
|
||||
"content": None,
|
||||
"is_reasoning_end": False,
|
||||
}
|
||||
MULTIPLE_LINES: dict[str, Any] = {
|
||||
"output": "This\nThat</seed:think>This is the rest\nThat",
|
||||
"reasoning": "This\nThat",
|
||||
"content": "This is the rest\nThat",
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
WITH_START_TOKEN: dict[str, Any] = {
|
||||
"output": ("<seed:think>This is a reasoning section</seed:think>This is the rest"),
|
||||
"reasoning": "This is a reasoning section",
|
||||
"content": "This is the rest",
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
ONLY_END_TOKEN: dict[str, Any] = {
|
||||
"output": "Some reasoning</seed:think>This is the rest",
|
||||
"reasoning": "Some reasoning",
|
||||
"content": "This is the rest",
|
||||
"is_reasoning_end": True,
|
||||
}
|
||||
NO_TOKENS: dict[str, Any] = {
|
||||
"output": "This is just content without any reasoning tokens",
|
||||
"reasoning": "This is just content without any reasoning tokens",
|
||||
"content": None,
|
||||
"is_reasoning_end": False,
|
||||
}
|
||||
|
||||
|
||||
def test_seedoss_reasoning_parser_creation(seedoss_tokenizer):
|
||||
"""Test that the SeedOSS reasoning parser can be created and registered."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
assert isinstance(parser, ReasoningParser)
|
||||
assert parser.start_token == start_token
|
||||
assert parser.end_token == end_token
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_simple_reasoning(seedoss_tokenizer, streaming):
|
||||
"""Test basic reasoning extraction with both tokens."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, SIMPLE_REASONING["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == SIMPLE_REASONING["reasoning"]
|
||||
assert content == SIMPLE_REASONING["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_complete_reasoning(seedoss_tokenizer, streaming):
|
||||
"""Test reasoning extraction when there's no content after reasoning."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, COMPLETE_REASONING["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == COMPLETE_REASONING["reasoning"]
|
||||
assert content == COMPLETE_REASONING["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_no_content(seedoss_tokenizer, streaming):
|
||||
"""Test when there's no end token - everything is reasoning content."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, NO_CONTENT["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == NO_CONTENT["reasoning"]
|
||||
assert content == NO_CONTENT["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_multiple_lines(seedoss_tokenizer, streaming):
|
||||
"""Test reasoning extraction with multiline content."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, MULTIPLE_LINES["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == MULTIPLE_LINES["reasoning"]
|
||||
assert content == MULTIPLE_LINES["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_with_start_token(seedoss_tokenizer, streaming):
|
||||
"""Test reasoning extraction with both start and end tokens."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, WITH_START_TOKEN["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == WITH_START_TOKEN["reasoning"]
|
||||
assert content == WITH_START_TOKEN["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_only_end_token(seedoss_tokenizer, streaming):
|
||||
"""
|
||||
Test reasoning extraction with only end token
|
||||
(SeedOSS typical behavior).
|
||||
"""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, ONLY_END_TOKEN["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == ONLY_END_TOKEN["reasoning"]
|
||||
assert content == ONLY_END_TOKEN["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [True, False])
|
||||
def test_no_tokens(seedoss_tokenizer, streaming):
|
||||
"""Test when there are no reasoning tokens at all."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
reasoning, content = run_reasoning_extraction(
|
||||
parser, [cast(str, NO_TOKENS["output"])], streaming=streaming
|
||||
)
|
||||
|
||||
assert reasoning == NO_TOKENS["reasoning"]
|
||||
assert content == NO_TOKENS["content"]
|
||||
|
||||
|
||||
def test_is_reasoning_end(seedoss_tokenizer):
|
||||
"""Test the is_reasoning_end method."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
# Test with end token present
|
||||
end_token_id = parser.end_token_id
|
||||
assert parser.is_reasoning_end([1, 2, end_token_id, 4]) is True
|
||||
|
||||
# Test without end token
|
||||
assert parser.is_reasoning_end([1, 2, 3, 4]) is False
|
||||
|
||||
|
||||
def test_extract_content_ids(seedoss_tokenizer):
|
||||
"""Test the extract_content_ids method."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
end_token_id = parser.end_token_id
|
||||
|
||||
# Test with end token in the middle
|
||||
input_ids = [1, 2, end_token_id, 4, 5]
|
||||
content_ids = parser.extract_content_ids(input_ids)
|
||||
assert content_ids == [4, 5]
|
||||
|
||||
# Test with end token at the end
|
||||
input_ids = [1, 2, 3, end_token_id]
|
||||
content_ids = parser.extract_content_ids(input_ids)
|
||||
assert content_ids == []
|
||||
|
||||
# Test without end token
|
||||
input_ids = [1, 2, 3, 4]
|
||||
content_ids = parser.extract_content_ids(input_ids)
|
||||
assert content_ids == []
|
||||
|
||||
|
||||
def test_streaming_delta_processing(seedoss_tokenizer):
|
||||
"""Test streaming processing with small deltas."""
|
||||
parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
|
||||
parser = parser_cls(seedoss_tokenizer)
|
||||
|
||||
# Test streaming with incremental tokens
|
||||
deltas = ["Some ", "reasoning ", "content", "</seed:think>", "Final ", "answer"]
|
||||
|
||||
reasoning, content = run_reasoning_extraction(parser, deltas, streaming=True)
|
||||
|
||||
assert reasoning == "Some reasoning content"
|
||||
assert content == "Final answer"
|
||||
@@ -1,522 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# ruff: noqa: E501
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionToolsParam,
|
||||
)
|
||||
from vllm.entrypoints.openai.engine.protocol import (
|
||||
DeltaMessage,
|
||||
FunctionCall,
|
||||
ToolCall,
|
||||
)
|
||||
from vllm.tokenizers import TokenizerLike, get_tokenizer
|
||||
from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
|
||||
from vllm.tool_parsers.seed_oss_tool_parser import SeedOssToolParser
|
||||
|
||||
# Use a common model that is likely to be available
|
||||
MODEL = "ByteDance-Seed/Seed-OSS-36B-Instruct"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seed_oss_tokenizer():
|
||||
return get_tokenizer(tokenizer_name=MODEL, trust_remote_code=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_oss_tool_parser(seed_oss_tokenizer, sample_tools):
|
||||
return SeedOssToolParser(seed_oss_tokenizer, tools=sample_tools)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tools():
|
||||
return [
|
||||
ChatCompletionToolsParam(
|
||||
type="function",
|
||||
function={
|
||||
"name": "get_weather",
|
||||
"description": "Get current temperature for a given location.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City and country e.g. Bogotá, Colombia",
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "this is the unit of temperature",
|
||||
},
|
||||
},
|
||||
"required": ["location"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"description": "temperature in celsius",
|
||||
}
|
||||
},
|
||||
"required": ["temperature"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def assert_tool_calls(
|
||||
actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
|
||||
):
|
||||
assert len(actual_tool_calls) == len(expected_tool_calls)
|
||||
|
||||
for actual_tool_call, expected_tool_call in zip(
|
||||
actual_tool_calls, expected_tool_calls
|
||||
):
|
||||
# Seed-OSS tool call will not generate id
|
||||
assert actual_tool_call.type == "function"
|
||||
assert actual_tool_call.function == expected_tool_call.function
|
||||
|
||||
assert actual_tool_call.function.name == expected_tool_call.function.name
|
||||
assert (
|
||||
actual_tool_call.function.arguments == expected_tool_call.function.arguments
|
||||
)
|
||||
|
||||
|
||||
def test_extract_tool_calls_no_tools(seed_oss_tool_parser):
|
||||
model_output = "This is a test response without any tool calls"
|
||||
extracted_tool_calls = seed_oss_tool_parser.extract_tool_calls(
|
||||
model_output, request=None
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert not extracted_tool_calls.tools_called
|
||||
assert extracted_tool_calls.tool_calls == []
|
||||
assert extracted_tool_calls.content == model_output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
ids=[
|
||||
"tool_call_0_thinking_budget",
|
||||
"tool_call_512_thinking_budget",
|
||||
"tool_call_unlimited_thinking_budget",
|
||||
],
|
||||
argnames=["model_output", "expected_tool_calls", "expected_content"],
|
||||
argvalues=[
|
||||
(
|
||||
"""<seed:tool_call>\n<function=get_weather>\n"""
|
||||
"""<parameter=location>Barcelona, Spain</parameter>\n</function>\n</seed:tool_call>""",
|
||||
[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="get_weather",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"location": "Barcelona, Spain",
|
||||
},
|
||||
),
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"""<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
|
||||
"""question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
|
||||
"""there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
|
||||
"""check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
|
||||
"""optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
|
||||
"""country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
|
||||
"""</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
|
||||
"""is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
|
||||
"""the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
|
||||
"""user\'s input has a space, but the function might accept either; to be safe, using the standard format """
|
||||
"""with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
|
||||
"""use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n"""
|
||||
"""<seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, Spain</parameter>\n</function>"""
|
||||
"""\n</seed:tool_call>""",
|
||||
[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="get_weather",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"location": "Barcelona, Spain",
|
||||
},
|
||||
),
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
"""<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
|
||||
"""question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
|
||||
"""there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
|
||||
"""check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
|
||||
"""optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
|
||||
"""country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
|
||||
"""</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
|
||||
"""is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
|
||||
"""the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
|
||||
"""user\'s input has a space, but the function might accept either; to be safe, using the standard format """
|
||||
"""with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
|
||||
"""use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n""",
|
||||
),
|
||||
(
|
||||
"""<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
|
||||
"""First, I need to remember the function I can use: get_weather. The function requires a """
|
||||
"""location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
|
||||
"""the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
|
||||
"""let me check the function docstring again. Oh, the function says unit is optional, and """
|
||||
"""returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
|
||||
"""Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
|
||||
"""The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
|
||||
"""Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
|
||||
"""Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
|
||||
"""of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
|
||||
"""it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
|
||||
"""call should be as above. Then wait for the result to come back and tell the user the """
|
||||
"""temperature in Celsius.</seed:think><seed:tool_call>\n<function=get_weather>\n<parameter=location>"""
|
||||
"""Barcelona, Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>""",
|
||||
[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="get_weather",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"location": "Barcelona, Spain",
|
||||
"unit": "celsius",
|
||||
},
|
||||
),
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
"""<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
|
||||
"""First, I need to remember the function I can use: get_weather. The function requires a """
|
||||
"""location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
|
||||
"""the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
|
||||
"""let me check the function docstring again. Oh, the function says unit is optional, and """
|
||||
"""returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
|
||||
"""Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
|
||||
"""The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
|
||||
"""Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
|
||||
"""Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
|
||||
"""of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
|
||||
"""it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
|
||||
"""call should be as above. Then wait for the result to come back and tell the user the """
|
||||
"""temperature in Celsius.</seed:think>""",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extract_tool_calls(
|
||||
seed_oss_tool_parser,
|
||||
sample_tools,
|
||||
model_output,
|
||||
expected_tool_calls,
|
||||
expected_content,
|
||||
):
|
||||
request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
|
||||
extracted_tool_calls = seed_oss_tool_parser.extract_tool_calls(
|
||||
model_output, request=request
|
||||
) # type: ignore[arg-type]
|
||||
assert extracted_tool_calls.tools_called
|
||||
|
||||
assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
|
||||
|
||||
assert extracted_tool_calls.content == expected_content
|
||||
|
||||
|
||||
def test_streaming_tool_calls_no_tools(seed_oss_tool_parser):
|
||||
model_output = "This is a test response without any tool calls"
|
||||
|
||||
result = seed_oss_tool_parser.extract_tool_calls_streaming(
|
||||
previous_text="his is a test response",
|
||||
current_text=model_output,
|
||||
delta_text=" without any tool calls.",
|
||||
previous_token_ids=[],
|
||||
current_token_ids=[],
|
||||
delta_token_ids=[],
|
||||
request=None,
|
||||
)
|
||||
|
||||
# Should return the delta text as content
|
||||
assert result is not None
|
||||
assert hasattr(result, "content")
|
||||
assert result.content == " without any tool calls."
|
||||
|
||||
|
||||
def stream_delta_message_generator(
|
||||
seed_oss_tool_parser: SeedOssToolParser,
|
||||
seed_oss_tokenizer: TokenizerLike,
|
||||
model_output: str,
|
||||
request: ChatCompletionRequest | None = None,
|
||||
) -> Generator[DeltaMessage, None, None]:
|
||||
all_token_ids = seed_oss_tokenizer.encode(model_output, add_special_tokens=False)
|
||||
|
||||
previous_text = ""
|
||||
previous_tokens = None
|
||||
prefix_offset = 0
|
||||
read_offset = 0
|
||||
for i, delta_token in enumerate(all_token_ids):
|
||||
delta_token_ids = [delta_token]
|
||||
previous_token_ids = all_token_ids[:i]
|
||||
current_token_ids = all_token_ids[: i + 1]
|
||||
|
||||
(new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
|
||||
detokenize_incrementally(
|
||||
tokenizer=seed_oss_tokenizer,
|
||||
all_input_ids=current_token_ids,
|
||||
prev_tokens=previous_tokens,
|
||||
prefix_offset=prefix_offset,
|
||||
read_offset=read_offset,
|
||||
skip_special_tokens=False,
|
||||
spaces_between_special_tokens=True,
|
||||
)
|
||||
)
|
||||
|
||||
current_text = previous_text + delta_text
|
||||
|
||||
delta_message = seed_oss_tool_parser.extract_tool_calls_streaming(
|
||||
previous_text,
|
||||
current_text,
|
||||
delta_text,
|
||||
previous_token_ids,
|
||||
current_token_ids,
|
||||
delta_token_ids,
|
||||
request=request,
|
||||
)
|
||||
if delta_message:
|
||||
yield delta_message
|
||||
|
||||
previous_text = current_text
|
||||
previous_tokens = (
|
||||
previous_tokens + new_tokens if previous_tokens else new_tokens
|
||||
)
|
||||
prefix_offset = new_prefix_offset
|
||||
read_offset = new_read_offset
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
ids=[
|
||||
"tool_call_0_thinking_budget",
|
||||
"tool_call_512_thinking_budget",
|
||||
"tool_call_unlimited_thinking_budget",
|
||||
],
|
||||
argnames=["model_output", "expected_tool_calls", "expected_content"],
|
||||
argvalues=[
|
||||
(
|
||||
"""<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
|
||||
"""The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n"""
|
||||
"""<seed:tool_call>\n<function=get_weather>\n"""
|
||||
"""<parameter=location>Barcelona, Spain</parameter>\n</function>\n</seed:tool_call>""",
|
||||
[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="get_weather",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"location": "Barcelona, Spain",
|
||||
},
|
||||
),
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
"""<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
|
||||
"""The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n""",
|
||||
),
|
||||
(
|
||||
"""<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
|
||||
"""question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
|
||||
"""there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
|
||||
"""check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
|
||||
"""optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
|
||||
"""country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
|
||||
"""</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
|
||||
"""is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
|
||||
"""the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
|
||||
"""user\'s input has a space, but the function might accept either; to be safe, using the standard format """
|
||||
"""with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
|
||||
"""use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n"""
|
||||
"""<seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, Spain</parameter>\n</function>"""
|
||||
"""\n</seed:tool_call>""",
|
||||
[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="get_weather",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"location": "Barcelona, Spain",
|
||||
},
|
||||
),
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
"""<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
|
||||
"""question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
|
||||
"""there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
|
||||
"""check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
|
||||
"""optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
|
||||
"""country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
|
||||
"""</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
|
||||
"""is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
|
||||
"""the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
|
||||
"""user\'s input has a space, but the function might accept either; to be safe, using the standard format """
|
||||
"""with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
|
||||
"""use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n""",
|
||||
),
|
||||
(
|
||||
"""<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
|
||||
"""First, I need to remember the function I can use: get_weather. The function requires a """
|
||||
"""location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
|
||||
"""the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
|
||||
"""let me check the function docstring again. Oh, the function says unit is optional, and """
|
||||
"""returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
|
||||
"""Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
|
||||
"""The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
|
||||
"""Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
|
||||
"""Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
|
||||
"""of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
|
||||
"""it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
|
||||
"""call should be as above. Then wait for the result to come back and tell the user the """
|
||||
"""temperature in Celsius.</seed:think><seed:tool_call>\n<function=get_weather>\n<parameter=location>"""
|
||||
"""Barcelona, Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>""",
|
||||
[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="get_weather",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"location": "Barcelona, Spain",
|
||||
"unit": "celsius",
|
||||
},
|
||||
),
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
"""<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
|
||||
"""First, I need to remember the function I can use: get_weather. The function requires a """
|
||||
"""location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
|
||||
"""the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
|
||||
"""let me check the function docstring again. Oh, the function says unit is optional, and """
|
||||
"""returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
|
||||
"""Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
|
||||
"""The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
|
||||
"""Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
|
||||
"""Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
|
||||
"""of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
|
||||
"""it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
|
||||
"""call should be as above. Then wait for the result to come back and tell the user the """
|
||||
"""temperature in Celsius.</seed:think>""",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_streaming_tool_calls(
|
||||
seed_oss_tool_parser,
|
||||
seed_oss_tokenizer,
|
||||
sample_tools,
|
||||
model_output,
|
||||
expected_tool_calls,
|
||||
expected_content,
|
||||
):
|
||||
"""Test incremental streaming behavior"""
|
||||
request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
|
||||
|
||||
other_content = ""
|
||||
tool_states = {} # Track state per tool index
|
||||
|
||||
for delta_message in stream_delta_message_generator(
|
||||
seed_oss_tool_parser, seed_oss_tokenizer, model_output, request
|
||||
):
|
||||
# role should never be streamed from tool parser
|
||||
assert not delta_message.role
|
||||
|
||||
if delta_message.content:
|
||||
other_content += delta_message.content
|
||||
|
||||
if delta_message.tool_calls:
|
||||
for tool_call in delta_message.tool_calls:
|
||||
idx = tool_call.index
|
||||
|
||||
# Initialize state for new tool
|
||||
if idx not in tool_states:
|
||||
tool_states[idx] = {
|
||||
"id": None,
|
||||
"name": None,
|
||||
"arguments": "",
|
||||
"type": None,
|
||||
}
|
||||
|
||||
# First chunk should have id, name, and type
|
||||
if tool_call.id:
|
||||
tool_states[idx]["id"] = tool_call.id
|
||||
|
||||
if tool_call.type:
|
||||
assert tool_call.type == "function"
|
||||
tool_states[idx]["type"] = tool_call.type
|
||||
|
||||
if tool_call.function:
|
||||
if tool_call.function.name:
|
||||
# Should only be set once
|
||||
assert tool_states[idx]["name"] is None
|
||||
tool_states[idx]["name"] = tool_call.function.name
|
||||
|
||||
if tool_call.function.arguments is not None:
|
||||
# Accumulate arguments incrementally
|
||||
tool_states[idx]["arguments"] += tool_call.function.arguments
|
||||
|
||||
# Verify final content
|
||||
assert other_content == expected_content
|
||||
|
||||
# Verify we got all expected tool calls
|
||||
assert len(tool_states) == len(expected_tool_calls)
|
||||
|
||||
# Verify each tool call
|
||||
for idx, expected_tool in enumerate(expected_tool_calls):
|
||||
state = tool_states[idx]
|
||||
assert state["id"] is not None
|
||||
assert state["type"] == "function"
|
||||
assert state["name"] == expected_tool.function.name
|
||||
|
||||
# Parse accumulated arguments
|
||||
arguments_str = state["arguments"]
|
||||
assert arguments_str is not None
|
||||
actual_args = json.loads(arguments_str)
|
||||
expected_args = json.loads(expected_tool.function.arguments)
|
||||
assert actual_args == expected_args
|
||||
|
||||
|
||||
def test_streaming_tool_calls_non_ascii(
|
||||
seed_oss_tool_parser, seed_oss_tokenizer, sample_tools
|
||||
):
|
||||
request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
|
||||
model_output = (
|
||||
"""<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
|
||||
"""The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n"""
|
||||
"""<seed:tool_call>\n<function=get_weather>\n"""
|
||||
"""<parameter=location>北京</parameter>\n</function>\n</seed:tool_call>"""
|
||||
)
|
||||
|
||||
args = "".join(
|
||||
tool_call.function.arguments
|
||||
for delta_message in stream_delta_message_generator(
|
||||
seed_oss_tool_parser, seed_oss_tokenizer, model_output, request
|
||||
)
|
||||
if delta_message.tool_calls
|
||||
for tool_call in delta_message.tool_calls
|
||||
if tool_call.function and tool_call.function.arguments is not None
|
||||
)
|
||||
|
||||
assert "北京" in args
|
||||
assert "\\u" not in args
|
||||
@@ -13,6 +13,7 @@ from vllm.parser.glm47_moe import Glm47MoeParser
|
||||
from vllm.parser.minimax_m2 import MinimaxM2Parser
|
||||
from vllm.parser.nemotron_v3 import NemotronV3Parser
|
||||
from vllm.parser.qwen3 import Qwen3Parser
|
||||
from vllm.parser.seed_oss import SeedOssParser
|
||||
|
||||
(
|
||||
MinimaxM2ParserReasoningAdapter,
|
||||
@@ -34,6 +35,11 @@ from vllm.parser.qwen3 import Qwen3Parser
|
||||
Qwen3ParserToolAdapter,
|
||||
) = make_adapters(Qwen3Parser)
|
||||
|
||||
(
|
||||
SeedOssParserReasoningAdapter,
|
||||
SeedOssParserToolAdapter,
|
||||
) = make_adapters(SeedOssParser)
|
||||
|
||||
(
|
||||
Glm47MoeParserReasoningAdapter,
|
||||
Glm47MoeParserToolAdapter,
|
||||
|
||||
+40
-13
@@ -38,6 +38,8 @@ if TYPE_CHECKING:
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tool_parsers.abstract_tool_parser import Tool
|
||||
|
||||
THINK_START = "<think>"
|
||||
THINK_END = "</think>"
|
||||
TOOL_CALL_START = "<tool_call>"
|
||||
TOOL_CALL_END = "</tool_call>"
|
||||
FUNC_PREFIX = "<function="
|
||||
@@ -75,17 +77,25 @@ def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
|
||||
|
||||
|
||||
@functools.cache
|
||||
def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
|
||||
def qwen3_config(
|
||||
thinking: bool = True,
|
||||
*,
|
||||
name: str = "qwen3",
|
||||
think_start: str = THINK_START,
|
||||
think_end: str = THINK_END,
|
||||
tool_start: str = TOOL_CALL_START,
|
||||
tool_end: str = TOOL_CALL_END,
|
||||
) -> ParserEngineConfig:
|
||||
return ParserEngineConfig(
|
||||
name="qwen3",
|
||||
name=name,
|
||||
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
|
||||
terminals={
|
||||
# Reasoning terminals
|
||||
"THINK_START": "<think>",
|
||||
"THINK_END": "</think>",
|
||||
"THINK_START": think_start,
|
||||
"THINK_END": think_end,
|
||||
# Tool call terminals
|
||||
"TOOL_START": TOOL_CALL_START,
|
||||
"TOOL_END": TOOL_CALL_END,
|
||||
"TOOL_START": tool_start,
|
||||
"TOOL_END": tool_end,
|
||||
"FUNC_PREFIX": FUNC_PREFIX,
|
||||
"FUNC_END": FUNC_END,
|
||||
"PARAM_START": PARAM_START,
|
||||
@@ -93,10 +103,10 @@ def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
|
||||
"CLOSE_ANGLE": ">",
|
||||
},
|
||||
token_id_terminals={
|
||||
"THINK_START": "<think>",
|
||||
"THINK_END": "</think>",
|
||||
"TOOL_START": TOOL_CALL_START,
|
||||
"TOOL_END": TOOL_CALL_END,
|
||||
"THINK_START": think_start,
|
||||
"THINK_END": think_end,
|
||||
"TOOL_START": tool_start,
|
||||
"TOOL_END": tool_end,
|
||||
},
|
||||
transitions={
|
||||
# -- Reasoning transitions --
|
||||
@@ -185,8 +195,18 @@ class Qwen3Parser(ParserEngine):
|
||||
|
||||
- ``<tool_call>`` as implicit reasoning end
|
||||
- Unpaired ``<tool_call>`` token ID detection for ``is_reasoning_end``
|
||||
|
||||
Subclasses that share the grammar but differ only in the four wrapper
|
||||
token strings (reasoning + tool-call) override the class attributes
|
||||
below; everything else is inherited unchanged.
|
||||
"""
|
||||
|
||||
CONFIG_NAME = "qwen3"
|
||||
THINK_START = THINK_START
|
||||
THINK_END = THINK_END
|
||||
TOOL_START = TOOL_CALL_START
|
||||
TOOL_END = TOOL_CALL_END
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
@@ -197,7 +217,14 @@ class Qwen3Parser(ParserEngine):
|
||||
self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
|
||||
kwargs.setdefault(
|
||||
"parser_engine_config",
|
||||
qwen3_config(thinking=self.thinking_enabled),
|
||||
qwen3_config(
|
||||
thinking=self.thinking_enabled,
|
||||
name=self.CONFIG_NAME,
|
||||
think_start=self.THINK_START,
|
||||
think_end=self.THINK_END,
|
||||
tool_start=self.TOOL_START,
|
||||
tool_end=self.TOOL_END,
|
||||
),
|
||||
)
|
||||
super().__init__(
|
||||
tokenizer,
|
||||
@@ -205,8 +232,8 @@ class Qwen3Parser(ParserEngine):
|
||||
**kwargs,
|
||||
)
|
||||
vocab = self.vocab
|
||||
self._tool_call_token_id: int | None = vocab.get("<tool_call>")
|
||||
self._tool_call_end_token_id: int | None = vocab.get("</tool_call>")
|
||||
self._tool_call_token_id: int | None = vocab.get(self.TOOL_START)
|
||||
self._tool_call_end_token_id: int | None = vocab.get(self.TOOL_END)
|
||||
|
||||
def extract_reasoning(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""seed_oss parser for tool calls and reasoning.
|
||||
|
||||
seed_oss shares the Qwen3 XML grammar exactly; only the four wrapper
|
||||
token strings differ::
|
||||
|
||||
<think> -> <seed:think>
|
||||
</think> -> </seed:think>
|
||||
<tool_call> -> <seed:tool_call>
|
||||
</tool_call> -> </seed:tool_call>
|
||||
|
||||
``<function=...>`` and ``<parameter=...>`` are byte-identical, so the
|
||||
entire transition table and ``_qwen3_arg_converter`` are inherited from
|
||||
:class:`Qwen3Parser` unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from vllm.parser.qwen3 import Qwen3Parser
|
||||
|
||||
|
||||
class SeedOssParser(Qwen3Parser):
|
||||
CONFIG_NAME = "seed_oss"
|
||||
THINK_START = "<seed:think>"
|
||||
THINK_END = "</seed:think>"
|
||||
TOOL_START = "<seed:tool_call>"
|
||||
TOOL_END = "</seed:tool_call>"
|
||||
@@ -117,8 +117,8 @@ _REASONING_PARSERS_TO_REGISTER = {
|
||||
"Qwen3ParserReasoningAdapter",
|
||||
),
|
||||
"seed_oss": (
|
||||
"seedoss_reasoning_parser",
|
||||
"SeedOSSReasoningParser",
|
||||
"seed_oss_engine_reasoning_parser",
|
||||
"SeedOssParserReasoningAdapter",
|
||||
),
|
||||
"step3": (
|
||||
"step3_reasoning_parser",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.parser.engine.registered_adapters import SeedOssParserReasoningAdapter
|
||||
|
||||
__all__ = ["SeedOssParserReasoningAdapter"]
|
||||
@@ -1,27 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
|
||||
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
|
||||
|
||||
|
||||
class SeedOSSReasoningParser(BaseThinkingReasoningParser):
|
||||
"""
|
||||
Reasoning parser for SeedOSS model.
|
||||
|
||||
The SeedOSS model uses <seed:think>...</seed:think> tokens to
|
||||
denote reasoning content text. This parser extracts
|
||||
the reasoning content from the model output.
|
||||
Similar to DeepSeek R1, it supports cases
|
||||
where the model doesn't generate the start token.
|
||||
"""
|
||||
|
||||
@property
|
||||
def start_token(self) -> str:
|
||||
"""The token that starts reasoning content."""
|
||||
return "<seed:think>"
|
||||
|
||||
@property
|
||||
def end_token(self) -> str:
|
||||
"""The token that ends reasoning content."""
|
||||
return "</seed:think>"
|
||||
@@ -163,8 +163,8 @@ _TOOL_PARSERS_TO_REGISTER = {
|
||||
"Qwen3EngineToolParser",
|
||||
),
|
||||
"seed_oss": (
|
||||
"seed_oss_tool_parser",
|
||||
"SeedOssToolParser",
|
||||
"seed_oss_engine_tool_parser",
|
||||
"SeedOssEngineToolParser",
|
||||
),
|
||||
"step3": (
|
||||
"step3_tool_parser",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.parser.engine.registered_adapters import SeedOssParserToolAdapter
|
||||
|
||||
|
||||
class SeedOssEngineToolParser(SeedOssParserToolAdapter): # type: ignore[valid-type, misc]
|
||||
structural_tag_model = None
|
||||
@@ -1,633 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# Adapted from qwen3coder xml parser, All rights reserved.
|
||||
# ruff: noqa: E501
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class SeedOssToolParser(ToolParser):
|
||||
TOOL_CALL_START = "<seed:tool_call>"
|
||||
TOOL_CALL_END = "</seed:tool_call>"
|
||||
|
||||
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
|
||||
super().__init__(tokenizer, tools)
|
||||
|
||||
# --- streaming state ---
|
||||
self._reset_streaming_state()
|
||||
self.prev_tool_call_arr: list[dict] = []
|
||||
|
||||
self.tool_call_start_token: str = self.TOOL_CALL_START
|
||||
self.tool_call_end_token: str = self.TOOL_CALL_END
|
||||
# Sentinel tokens for streaming mode
|
||||
self.tool_call_prefix: str = "<function="
|
||||
self.function_end_token: str = "</function>"
|
||||
self.parameter_prefix: str = "<parameter="
|
||||
self.parameter_end_token: str = "</parameter>"
|
||||
self.think_start_token: str = "<seed:think>"
|
||||
self.think_end_token: str = "</seed:think>"
|
||||
self.is_tool_call_started: bool = False
|
||||
self.is_thinking_end: bool = False
|
||||
self.failed_count: int = 0
|
||||
self._reset_streaming_state()
|
||||
|
||||
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)
|
||||
self.think_end_token_id = self.vocab.get(self.think_end_token)
|
||||
|
||||
if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
|
||||
raise RuntimeError(
|
||||
"Seed_Oss XML parser: tokenizer did not include "
|
||||
"<seed:tool_call> or its closing tag."
|
||||
)
|
||||
|
||||
tool_start_re = re.escape(self.tool_call_start_token)
|
||||
tool_end_re = re.escape(self.tool_call_end_token)
|
||||
|
||||
self.tool_call_complete_regex = re.compile(
|
||||
rf"{tool_start_re}(.*?){tool_end_re}", re.DOTALL
|
||||
)
|
||||
self.tool_call_regex = re.compile(
|
||||
rf"{tool_start_re}(.*?){tool_end_re}|{tool_start_re}(.*?)$", re.DOTALL
|
||||
)
|
||||
|
||||
self.tool_call_function_regex = re.compile(
|
||||
r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL
|
||||
)
|
||||
self.tool_call_parameter_regex = re.compile(
|
||||
r"<parameter=(.*?)</parameter>|<parameter=(.*?)$", re.DOTALL
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"vLLM Seed-Oss XML tool parser loaded (%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 _reset_streaming_state(self):
|
||||
"""Reset all streaming state."""
|
||||
self.current_tool_index = 0
|
||||
self.is_tool_call_started = False
|
||||
self.header_sent = False
|
||||
self.current_tool_id = -1
|
||||
self.current_function_name = None
|
||||
self.current_param_name = None
|
||||
self.current_param_value = ""
|
||||
self.param_count = 0
|
||||
self.in_param = False
|
||||
self.in_function = False
|
||||
self.accumulated_text = ""
|
||||
self.json_started = False
|
||||
self.json_closed = False
|
||||
|
||||
def _parse_xml_function_call(
|
||||
self, function_call_str: str, tools: list[Tool] | None
|
||||
) -> ToolCall | None:
|
||||
# Extract function name
|
||||
end_index = function_call_str.index(">")
|
||||
function_name = function_call_str[:end_index]
|
||||
tool_properties = find_tool_properties(tools, function_name)
|
||||
parameters = function_call_str[end_index + 1 :]
|
||||
param_dict = {}
|
||||
for match in self.tool_call_parameter_regex.findall(parameters):
|
||||
match_text = match[0] if match[0] else match[1]
|
||||
idx = match_text.index(">")
|
||||
param_name = match_text[:idx]
|
||||
param_value = str(match_text[idx + 1 :])
|
||||
# Remove prefix and trailing \n
|
||||
if param_value.startswith("\n"):
|
||||
param_value = param_value[1:]
|
||||
if param_value.endswith("\n"):
|
||||
param_value = param_value[:-1]
|
||||
|
||||
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 _get_function_calls(self, model_output: str) -> list[str]:
|
||||
# Find all tool calls
|
||||
matched_ranges = self.tool_call_regex.findall(model_output)
|
||||
raw_tool_calls = [
|
||||
match[0] if match[0] else match[1] for match in matched_ranges
|
||||
]
|
||||
|
||||
# Back-off strategy if no tool_call tags found
|
||||
if len(raw_tool_calls) == 0:
|
||||
raw_tool_calls = [model_output]
|
||||
|
||||
raw_function_calls = []
|
||||
for tool_call in raw_tool_calls:
|
||||
raw_function_calls.extend(self.tool_call_function_regex.findall(tool_call))
|
||||
|
||||
function_calls = [
|
||||
match[0] if match[0] else match[1] for match in raw_function_calls
|
||||
]
|
||||
return function_calls
|
||||
|
||||
def extract_tool_calls(
|
||||
self,
|
||||
model_output: str,
|
||||
request: ChatCompletionRequest,
|
||||
) -> ExtractedToolCallInformation:
|
||||
# Quick check to avoid unnecessary processing
|
||||
if self.tool_call_prefix not in model_output:
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=False, tool_calls=[], content=model_output
|
||||
)
|
||||
|
||||
# Check if both think start and end tokens are present
|
||||
if (
|
||||
self.think_start_token in model_output
|
||||
and self.think_end_token in model_output
|
||||
):
|
||||
# Find the position of think end token
|
||||
think_end_index = model_output.find(self.think_end_token) + len(
|
||||
self.think_end_token
|
||||
)
|
||||
# Extract content after think end token
|
||||
result_content = model_output[think_end_index:]
|
||||
thinking_content = model_output[:think_end_index]
|
||||
else:
|
||||
thinking_content = ""
|
||||
result_content = model_output
|
||||
|
||||
try:
|
||||
function_calls = self._get_function_calls(result_content)
|
||||
if len(function_calls) == 0:
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=False, tool_calls=[], content=model_output
|
||||
)
|
||||
|
||||
tool_calls = [
|
||||
self._parse_xml_function_call(function_call_str, self.tools)
|
||||
for function_call_str in function_calls
|
||||
]
|
||||
|
||||
# Populate prev_tool_call_arr for serving layer to set finish_reason
|
||||
self.prev_tool_call_arr.clear() # Clear previous calls
|
||||
for tool_call in tool_calls:
|
||||
if tool_call:
|
||||
self.prev_tool_call_arr.append(
|
||||
{
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments,
|
||||
}
|
||||
)
|
||||
|
||||
# Extract content before tool calls
|
||||
tool_call_start_index = result_content.find(self.tool_call_start_token)
|
||||
tool_call_start_index = (
|
||||
tool_call_start_index
|
||||
if tool_call_start_index >= 0
|
||||
else result_content.find(self.tool_call_prefix)
|
||||
)
|
||||
content = thinking_content + result_content[:tool_call_start_index]
|
||||
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=(len(tool_calls) > 0),
|
||||
tool_calls=tool_calls,
|
||||
content=content if content else None,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error in extracting tool call from response.")
|
||||
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],
|
||||
current_token_ids: Sequence[int],
|
||||
delta_token_ids: Sequence[int],
|
||||
request: ChatCompletionRequest,
|
||||
) -> DeltaMessage | None:
|
||||
# If no delta text, return None unless
|
||||
# it's an EOS token after tool calls
|
||||
if not delta_text:
|
||||
# Check if this is an EOS token after all tool calls are complete
|
||||
# We check for tool calls in the text even if is_tool_call_started
|
||||
# is False because it might have been reset after processing all tools
|
||||
if delta_token_ids and self.tool_call_end_token_id not in delta_token_ids:
|
||||
# Count complete tool calls
|
||||
complete_calls = len(
|
||||
self.tool_call_complete_regex.findall(current_text)
|
||||
)
|
||||
|
||||
# If we have completed tool calls and populated prev_tool_call_arr
|
||||
if complete_calls > 0 and len(self.prev_tool_call_arr) > 0:
|
||||
# Check if all tool calls are closed
|
||||
open_calls = current_text.count(
|
||||
self.tool_call_start_token
|
||||
) - current_text.count(self.tool_call_end_token)
|
||||
if open_calls == 0:
|
||||
# Return empty delta message to allow finish_reason processing
|
||||
return DeltaMessage(content="")
|
||||
elif not self.is_tool_call_started and current_text:
|
||||
# This is a regular content response that's now complete
|
||||
return DeltaMessage(content="")
|
||||
return None
|
||||
|
||||
# Check if this is the first call (reset state if needed)
|
||||
if not previous_text:
|
||||
self._reset_streaming_state()
|
||||
|
||||
# Update accumulated text
|
||||
self.accumulated_text = current_text
|
||||
|
||||
# Check if we need to advance to next tool
|
||||
if self.json_closed and not self.in_function:
|
||||
# Check if this tool call has ended
|
||||
tool_ends = current_text.count(self.tool_call_end_token)
|
||||
if tool_ends > self.current_tool_index:
|
||||
# This tool has ended, advance to next
|
||||
self.current_tool_index += 1
|
||||
self.header_sent = False
|
||||
self.param_count = 0
|
||||
self.json_started = False
|
||||
self.json_closed = False
|
||||
|
||||
# Check if there are more tool calls
|
||||
if self.current_tool_index >= current_text.count(
|
||||
self.tool_call_start_token
|
||||
):
|
||||
# No more tool calls
|
||||
self.is_tool_call_started = False
|
||||
# Continue processing next tool
|
||||
return None
|
||||
|
||||
# Check if end thinking
|
||||
if not self.is_thinking_end and (
|
||||
self.think_end_token_id in delta_token_ids
|
||||
or self.think_end_token in delta_text
|
||||
):
|
||||
self.is_thinking_end = True
|
||||
|
||||
# If thinking hasn't ended yet, don't process any tool calls
|
||||
if not self.is_thinking_end:
|
||||
return DeltaMessage(content=delta_text)
|
||||
|
||||
# Handle normal content before tool calls
|
||||
if not self.is_tool_call_started:
|
||||
# Check if tool call is starting
|
||||
if (
|
||||
self.tool_call_start_token_id in delta_token_ids
|
||||
or self.tool_call_start_token in delta_text
|
||||
):
|
||||
self.is_tool_call_started = True
|
||||
# Return any content before the tool call
|
||||
if self.tool_call_start_token in delta_text:
|
||||
content_before = delta_text[
|
||||
: delta_text.index(self.tool_call_start_token)
|
||||
]
|
||||
if content_before:
|
||||
return DeltaMessage(content=content_before)
|
||||
return None
|
||||
else:
|
||||
# Check if we're between tool calls - skip whitespace
|
||||
if (
|
||||
current_text.rstrip().endswith(self.tool_call_end_token)
|
||||
and delta_text.strip() == ""
|
||||
):
|
||||
# We just ended a tool call, skip whitespace
|
||||
return None
|
||||
# Normal content, no tool call
|
||||
return DeltaMessage(content=delta_text)
|
||||
|
||||
# Check if we're between tool calls (waiting for next one)
|
||||
# Count tool calls we've seen vs processed
|
||||
tool_starts_count = current_text.count(self.tool_call_start_token)
|
||||
if self.current_tool_index >= tool_starts_count:
|
||||
# We're past all tool calls, shouldn't be here
|
||||
return None
|
||||
|
||||
# We're in a tool call, find the current tool call portion
|
||||
# Need to find the correct tool call based on current_tool_index
|
||||
# Only process tool calls after think_end_token
|
||||
think_end_index = (
|
||||
current_text.find(self.think_end_token) + len(self.think_end_token)
|
||||
if self.think_end_token in current_text
|
||||
else 0
|
||||
)
|
||||
tool_starts: list[int] = []
|
||||
idx = think_end_index
|
||||
while True:
|
||||
idx = current_text.find(self.tool_call_start_token, idx)
|
||||
if idx == -1:
|
||||
break
|
||||
tool_starts.append(idx)
|
||||
idx += len(self.tool_call_start_token)
|
||||
|
||||
if self.current_tool_index >= len(tool_starts):
|
||||
# No more tool calls to process yet
|
||||
return None
|
||||
|
||||
tool_start_idx = tool_starts[self.current_tool_index]
|
||||
# Find where this tool call ends (or current position if not ended yet)
|
||||
tool_end_idx = current_text.find(self.tool_call_end_token, tool_start_idx)
|
||||
if tool_end_idx == -1:
|
||||
tool_text = current_text[tool_start_idx:]
|
||||
else:
|
||||
tool_text = current_text[
|
||||
tool_start_idx : tool_end_idx + len(self.tool_call_end_token)
|
||||
]
|
||||
|
||||
# Looking for function header
|
||||
if not self.header_sent:
|
||||
if self.tool_call_prefix in tool_text:
|
||||
func_start = tool_text.find(self.tool_call_prefix) + len(
|
||||
self.tool_call_prefix
|
||||
)
|
||||
func_end = tool_text.find(">", func_start)
|
||||
|
||||
if func_end != -1:
|
||||
# Found complete function name
|
||||
self.current_function_name = tool_text[func_start:func_end]
|
||||
self.current_tool_id = self._generate_tool_call_id() # type: ignore
|
||||
self.header_sent = True
|
||||
self.in_function = True
|
||||
|
||||
# IMPORTANT: Add to prev_tool_call_arr immediately when we detect a tool call
|
||||
# This ensures finish_reason="tool_calls" even if parsing isn't complete
|
||||
already_added = any(
|
||||
tool.get("name") == self.current_function_name
|
||||
for tool in self.prev_tool_call_arr
|
||||
)
|
||||
if not already_added:
|
||||
self.prev_tool_call_arr.append(
|
||||
{
|
||||
"name": self.current_function_name,
|
||||
"arguments": "{}", # Placeholder, will be updated later
|
||||
}
|
||||
)
|
||||
|
||||
# Send header with function info
|
||||
return DeltaMessage(
|
||||
tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
id=self.current_tool_id,
|
||||
function=DeltaFunctionCall(
|
||||
name=self.current_function_name, arguments=""
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
]
|
||||
)
|
||||
return None
|
||||
|
||||
# We've sent header, now handle function body
|
||||
if self.in_function:
|
||||
# Send opening brace if not sent yet
|
||||
if not self.json_started and self.parameter_prefix not in delta_text:
|
||||
self.json_started = True
|
||||
return DeltaMessage(
|
||||
tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(arguments="{"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Make sure json_started is set if we're processing parameters
|
||||
if not self.json_started:
|
||||
self.json_started = True
|
||||
|
||||
# Check for function end in accumulated text
|
||||
if not self.json_closed and self.function_end_token in tool_text:
|
||||
# Close JSON
|
||||
self.json_closed = True
|
||||
|
||||
# Extract the complete tool call to update prev_tool_call_arr with final arguments
|
||||
# Find the function content
|
||||
func_start = tool_text.find(self.tool_call_prefix) + len(
|
||||
self.tool_call_prefix
|
||||
)
|
||||
func_content_end = tool_text.find(self.function_end_token, func_start)
|
||||
if func_content_end != -1:
|
||||
func_content = tool_text[func_start:func_content_end]
|
||||
# Parse to get the complete arguments
|
||||
try:
|
||||
parsed_tool = self._parse_xml_function_call(
|
||||
func_content, self.tools
|
||||
)
|
||||
if parsed_tool:
|
||||
# Update existing entry in prev_tool_call_arr with complete arguments
|
||||
for i, tool in enumerate(self.prev_tool_call_arr):
|
||||
if tool.get("name") == parsed_tool.function.name:
|
||||
self.prev_tool_call_arr[i]["arguments"] = (
|
||||
parsed_tool.function.arguments
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to parse tool arguments during streaming.",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
result = DeltaMessage(
|
||||
tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(arguments="}"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Reset state for next tool
|
||||
self.in_function = False
|
||||
self.json_closed = True
|
||||
|
||||
return result
|
||||
|
||||
# Look for parameters
|
||||
# Count how many complete parameters we have processed
|
||||
complete_params = tool_text.count(self.parameter_end_token)
|
||||
|
||||
# Check if we should start a new parameter
|
||||
if not self.in_param and self.param_count < complete_params:
|
||||
# Find the unprocessed parameter
|
||||
# Count parameter starts
|
||||
param_starts = []
|
||||
idx = 0
|
||||
while True:
|
||||
idx = tool_text.find(self.parameter_prefix, idx)
|
||||
if idx == -1:
|
||||
break
|
||||
param_starts.append(idx)
|
||||
idx += len(self.parameter_prefix)
|
||||
|
||||
if len(param_starts) > self.param_count:
|
||||
# Process the next parameter
|
||||
param_idx = param_starts[self.param_count]
|
||||
param_start = param_idx + len(self.parameter_prefix)
|
||||
remaining = tool_text[param_start:]
|
||||
|
||||
if ">" in remaining:
|
||||
# We have the complete parameter name
|
||||
name_end = remaining.find(">")
|
||||
self.current_param_name = remaining[:name_end]
|
||||
|
||||
# Find the parameter value
|
||||
value_start = param_start + name_end + 1
|
||||
value_text = tool_text[value_start:]
|
||||
if value_text.startswith("\n"):
|
||||
value_text = value_text[1:]
|
||||
|
||||
# Find where this parameter ends
|
||||
param_end_idx = value_text.find(self.parameter_end_token)
|
||||
if param_end_idx != -1:
|
||||
# Complete parameter found
|
||||
param_value = value_text[:param_end_idx]
|
||||
if param_value.endswith("\n"):
|
||||
param_value = param_value[:-1]
|
||||
|
||||
# Build complete JSON fragment for this parameter
|
||||
if self.param_count == 0:
|
||||
json_fragment = (
|
||||
'"'
|
||||
+ self.current_param_name
|
||||
+ '": "'
|
||||
+ json.dumps(param_value, ensure_ascii=False)[1:-1]
|
||||
+ '"'
|
||||
)
|
||||
else:
|
||||
json_fragment = (
|
||||
', "'
|
||||
+ self.current_param_name
|
||||
+ '": "'
|
||||
+ json.dumps(param_value, ensure_ascii=False)[1:-1]
|
||||
+ '"'
|
||||
)
|
||||
|
||||
self.param_count += 1
|
||||
|
||||
return DeltaMessage(
|
||||
tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(
|
||||
arguments=json_fragment
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Continue parameter value
|
||||
if self.in_param:
|
||||
if self.parameter_end_token in delta_text:
|
||||
# End of parameter
|
||||
end_idx = delta_text.find(self.parameter_end_token)
|
||||
value_chunk = delta_text[:end_idx]
|
||||
|
||||
# Skip past > if at start
|
||||
if not self.current_param_value and ">" in value_chunk:
|
||||
gt_idx = value_chunk.find(">")
|
||||
value_chunk = value_chunk[gt_idx + 1 :]
|
||||
|
||||
if not self.current_param_value and value_chunk.startswith("\n"):
|
||||
value_chunk = value_chunk[1:]
|
||||
|
||||
# Calculate incremental JSON
|
||||
full_value = self.current_param_value + value_chunk
|
||||
prev_escaped = (
|
||||
json.dumps(self.current_param_value, ensure_ascii=False)[1:-1]
|
||||
if self.current_param_value
|
||||
else ""
|
||||
)
|
||||
full_escaped = json.dumps(full_value, ensure_ascii=False)[1:-1]
|
||||
delta_escaped = full_escaped[len(prev_escaped) :]
|
||||
|
||||
self.in_param = False
|
||||
self.current_param_value = ""
|
||||
|
||||
return DeltaMessage(
|
||||
tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(
|
||||
arguments=delta_escaped + '"'
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Continue accumulating value
|
||||
value_chunk = delta_text
|
||||
|
||||
# Handle first chunk after param name
|
||||
if not self.current_param_value and ">" in value_chunk:
|
||||
gt_idx = value_chunk.find(">")
|
||||
value_chunk = value_chunk[gt_idx + 1 :]
|
||||
|
||||
if not self.current_param_value and value_chunk.startswith("\n"):
|
||||
value_chunk = value_chunk[1:]
|
||||
|
||||
if value_chunk:
|
||||
# Stream the escaped delta
|
||||
prev_escaped = (
|
||||
json.dumps(self.current_param_value, ensure_ascii=False)[
|
||||
1:-1
|
||||
]
|
||||
if self.current_param_value
|
||||
else ""
|
||||
)
|
||||
self.current_param_value += value_chunk
|
||||
full_escaped = json.dumps(
|
||||
self.current_param_value, ensure_ascii=False
|
||||
)[1:-1]
|
||||
delta_escaped = full_escaped[len(prev_escaped) :]
|
||||
|
||||
if delta_escaped:
|
||||
return DeltaMessage(
|
||||
tool_calls=[
|
||||
DeltaToolCall(
|
||||
index=self.current_tool_index,
|
||||
function=DeltaFunctionCall(
|
||||
arguments=delta_escaped
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user