[Frontend] Port seed_oss to the streaming parser engine as a Qwen3 subclass (#46314)

Signed-off-by: EazyReal <8047065+EazyReal@users.noreply.github.com>

Maxwill Lin committed Jun 24, 2026 at 17:08 UTC cd347298e86c83d43d3d404b05899b1be07489d1
13 files changed +338 -1435
tests/parser/engine/test_seed_oss.py new
+189
@@ -0,0 +1,189 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for the engine-based seed_oss parser.
4 +
5 +seed_oss is Qwen3 with four overridden wrapper tokens, so the shared grammar
6 +(arg types, multiline values, parallel calls, streaming mechanics, …) is
7 +already covered by ``test_qwen3.py``/``test_qwen3_reasoning.py``. These tests
8 +cover only what is seed_oss-specific: that the ``seed:`` token overrides are
9 +wired through, the reasoning→tool boundary holds with them, the malformed
10 +header from #46314 no longer drops sibling calls, and the registered adapters
11 +resolve. Seed-specific budget-reflect tags inside reasoning are also covered
12 +here because the old dedicated parser tests exercised them.
13 +"""
14 +
15 +import json
16 +
17 +import pytest
18 +
19 +from tests.parser.engine.conftest import make_mock_tokenizer
20 +from tests.parser.engine.streaming_helpers import (
21 + collect_function_name,
22 + collect_tool_arguments,
23 + simulate_reasoning_streaming,
24 + simulate_tool_streaming,
25 +)
26 +from vllm.parser.engine.registered_adapters import (
27 + SeedOssParserReasoningAdapter,
28 + SeedOssParserToolAdapter,
29 +)
30 +from vllm.parser.seed_oss import SeedOssParser
31 +
32 +TOOL_CALL_START = "<seed:tool_call>"
33 +TOOL_CALL_END = "</seed:tool_call>"
34 +THINK_START = "<seed:think>"
35 +THINK_END = "</seed:think>"
36 +
37 +_THINK_END_ID = 51
38 +_TOOL_CALL_ID = 60
39 +
40 +_SEED_OSS_VOCAB = {
41 + THINK_START: 50,
42 + THINK_END: _THINK_END_ID,
43 + TOOL_CALL_START: _TOOL_CALL_ID,
44 + TOOL_CALL_END: 61,
45 +}
46 +
47 +
48 +@pytest.fixture
49 +def mock_tokenizer():
50 + return make_mock_tokenizer(_SEED_OSS_VOCAB)
51 +
52 +
53 +@pytest.fixture
54 +def tool_parser(mock_tokenizer):
55 + return SeedOssParser(
56 + mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
57 + )
58 +
59 +
60 +@pytest.fixture
61 +def parser(mock_tokenizer):
62 + return SeedOssParser(mock_tokenizer)
63 +
64 +
65 +def test_token_overrides_wired(parser):
66 + assert parser.parser_engine_config.name == "seed_oss"
67 + assert parser.reasoning_start_str == THINK_START
68 + assert parser.reasoning_end_str == THINK_END
69 +
70 +
71 +def test_single_tool_call(tool_parser, mock_request):
72 + text = (
73 + f"{TOOL_CALL_START}\n<function=get_weather>\n"
74 + "<parameter=city>Tokyo</parameter>\n"
75 + f"</function>\n{TOOL_CALL_END}"
76 + )
77 + result = tool_parser.extract_tool_calls(text, mock_request)
78 +
79 + assert result.tools_called is True
80 + assert result.tool_calls[0].function.name == "get_weather"
81 + assert json.loads(result.tool_calls[0].function.arguments) == {"city": "Tokyo"}
82 +
83 +
84 +def test_malformed_function_end_does_not_drop_siblings(tool_parser, mock_request):
85 + """Regression for #46314: a malformed ``</function>`` with no closing ``>``
86 + on the header must not discard the other, well-formed calls."""
87 + text = (
88 + f"{TOOL_CALL_START}\n<function=broken</function>\n{TOOL_CALL_END}"
89 + f"{TOOL_CALL_START}\n<function=get_weather>\n"
90 + "<parameter=city>Tokyo</parameter>\n"
91 + f"</function>\n{TOOL_CALL_END}"
92 + )
93 + result = tool_parser.extract_tool_calls(text, mock_request)
94 +
95 + weather = next(tc for tc in result.tool_calls if tc.function.name == "get_weather")
96 + assert json.loads(weather.function.arguments) == {"city": "Tokyo"}
97 +
98 +
99 +def test_basic_streaming(tool_parser, mock_request):
100 + chunks = [
101 + f"{TOOL_CALL_START}\n",
102 + "<function=get_weather>\n",
103 + "<parameter=city>Tokyo",
104 + "</parameter>\n",
105 + "</function>\n",
106 + f"{TOOL_CALL_END}",
107 + ]
108 + results = simulate_tool_streaming(tool_parser, mock_request, chunks)
109 +
110 + assert collect_function_name(results) == "get_weather"
111 + assert json.loads(collect_tool_arguments(results)) == {"city": "Tokyo"}
112 +
113 +
114 +def test_reasoning_then_tool_call(parser):
115 + text = (
116 + f"{THINK_START}I need to read the file.{THINK_END}"
117 + f"{TOOL_CALL_START}\n<function=read>\n"
118 + "<parameter=path>/tmp/x</parameter>\n"
119 + f"</function>\n{TOOL_CALL_END}"
120 + )
121 + reasoning, _ = parser.extract_reasoning(text, None)
122 + assert reasoning == "I need to read the file."
123 + assert TOOL_CALL_START not in reasoning
124 +
125 +
126 +def test_streaming_think_end_and_tool_call_same_delta(parser):
127 + """``</seed:think>`` and ``<seed:tool_call>`` arriving in one delta must
128 + not leak the terminal tokens into the reasoning text."""
129 + reasoning, content = simulate_reasoning_streaming(
130 + parser,
131 + [
132 + "Let me list the directory.",
133 + f"{THINK_END}{TOOL_CALL_START}",
134 + "<function=read>",
135 + ],
136 + [(1,), (_THINK_END_ID, _TOOL_CALL_ID), (2,)],
137 + )
138 + assert reasoning == "Let me list the directory."
139 + assert THINK_END not in reasoning
140 + assert TOOL_CALL_START not in reasoning
141 + assert content is not None
142 +
143 +
144 +def test_end_to_end_through_registered_adapters(mock_tokenizer, mock_request):
145 + reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer)
146 + tool_parser = SeedOssParserToolAdapter(mock_tokenizer)
147 + text = (
148 + f"{THINK_START}Plan the call.{THINK_END}"
149 + f"{TOOL_CALL_START}\n<function=get_weather>\n"
150 + "<parameter=city>Tokyo</parameter>\n"
151 + f"</function>\n{TOOL_CALL_END}"
152 + )
153 + reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request)
154 + assert reasoning == "Plan the call."
155 +
156 + tool_result = tool_parser.extract_tool_calls(remaining, mock_request)
157 + assert tool_result.tool_calls[0].function.name == "get_weather"
158 + assert json.loads(tool_result.tool_calls[0].function.arguments) == {"city": "Tokyo"}
159 +
160 +
161 +def test_budget_reflect_tags_do_not_break_adapter_pipeline(
162 + mock_tokenizer,
163 + mock_request,
164 +):
165 + reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer)
166 + tool_parser = SeedOssParserToolAdapter(mock_tokenizer)
167 + text = (
168 + f"{THINK_START}"
169 + "The user's current thinking budget is 512.</seed:cot_budget_reflect>\n"
170 + "I need the weather.\n"
171 + "<seed:cot_budget_reflect>I have used 131 tokens."
172 + "</seed:cot_budget_reflect>\n"
173 + f"{THINK_END}"
174 + f"{TOOL_CALL_START}\n<function=get_weather>\n"
175 + "<parameter=city>Barcelona</parameter>\n"
176 + f"</function>\n{TOOL_CALL_END}"
177 + )
178 +
179 + reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request)
180 + assert reasoning is not None
181 + assert "current thinking budget is 512" in reasoning
182 + assert "<seed:cot_budget_reflect>" in reasoning
183 + assert "</seed:cot_budget_reflect>" in reasoning
184 +
185 + tool_result = tool_parser.extract_tool_calls(remaining, mock_request)
186 + assert tool_result.tool_calls[0].function.name == "get_weather"
187 + assert json.loads(tool_result.tool_calls[0].function.arguments) == {
188 + "city": "Barcelona"
189 + }
tests/parser/engine/trace_builder.py
+57
@@ -34,6 +34,7 @@ from vllm.parser.engine.registered_adapters import (
34 MinimaxM2Parser,
35 NemotronV3Parser,
36 Qwen3Parser,
37 + SeedOssParser,
38 )
39
40 # ── Data structures ──────────────────────────────────────────────────
@@ -587,6 +588,61 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
588 )
589
590
591 +# ── Seed-OSS (Qwen3 XML grammar with Seed wrapper tokens) ────────────
592 +
593 +_SEED_OSS_VOCAB: dict[str, int] = {
594 + "<seed:think>": 50,
595 + "</seed:think>": 51,
596 + "<seed:tool_call>": 60,
597 + "</seed:tool_call>": 61,
598 +}
599 +
600 +
601 +def _seed_oss_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
602 + parts = [f"\n<function={tc.name}>"]
603 + for key, value in tc.arguments.items():
604 + parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
605 + parts.append("\n</function>\n")
606 + return [
607 + ("<seed:tool_call>", True),
608 + ("".join(parts), False),
609 + ("</seed:tool_call>", True),
610 + ]
611 +
612 +
613 +def _seed_oss_segments(scenario: Scenario) -> list[tuple[str, bool]]:
614 + segs: list[tuple[str, bool]] = []
615 + if scenario.reasoning is not None:
616 + segs.append((scenario.reasoning, False))
617 + if scenario.content is not None or scenario.tool_calls is not None:
618 + segs.append(("</seed:think>", True))
619 + if scenario.tool_calls is not None and not scenario.tool_calls:
620 + segs.append(("<seed:tool_call>", True))
621 + segs.append(("</seed:tool_call>", True))
622 + if scenario.content is not None:
623 + segs.append((scenario.content, False))
624 + if scenario.tool_calls:
625 + for tc in scenario.tool_calls:
626 + segs.extend(_seed_oss_tool_segments(tc))
627 + return segs
628 +
629 +
630 +def _build_seed_oss(scenario: Scenario, validate: bool = True) -> Sample:
631 + sample = _make_sample(
632 + sample_id=f"seed_oss-{scenario.id}",
633 + description=scenario.description,
634 + vocab=_SEED_OSS_VOCAB,
635 + segments=_seed_oss_segments(scenario),
636 + expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
637 + expected_content=_qwen3_expected_content(scenario),
638 + expected_tool_calls=_expected_tc(scenario),
639 + tools=_expected_tools(scenario),
640 + )
641 + if validate:
642 + _validate_sample(sample, SeedOssParser)
643 + return sample
644 +
645 +
646 # ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────
647
648 _GLM47_MOE_VOCAB: dict[str, int] = {
@@ -668,6 +724,7 @@ _BUILDERS: dict[str, Any] = {
724 "gemma4": _build_gemma4,
725 "minimax_m2": _build_minimax_m2,
726 "nemotron_v3": _build_nemotron_v3,
727 + "seed_oss": _build_seed_oss,
728 "glm47_moe": _build_glm47_moe,
729 }
730
tests/reasoning/test_seedoss_reasoning_parser.py deleted
-236
@@ -1,236 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -from typing import Any, cast
5 -
6 -import pytest
7 -from transformers import AutoTokenizer
8 -
9 -from tests.reasoning.utils import run_reasoning_extraction
10 -from vllm.reasoning import ReasoningParser, ReasoningParserManager
11 -
12 -parser_name = "seed_oss"
13 -start_token = "<seed:think>"
14 -end_token = "</seed:think>"
15 -
16 -# Use a test model that contains our custom tokens
17 -REASONING_MODEL_NAME = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
18 -
19 -
20 -@pytest.fixture(scope="module")
21 -def seedoss_tokenizer():
22 - tokenizer = AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
23 - # Add custom SeedOSS tokens if they don't exist
24 - if start_token not in tokenizer.get_vocab():
25 - tokenizer.add_tokens([start_token, end_token])
26 - return tokenizer
27 -
28 -
29 -SIMPLE_REASONING: dict[str, Any] = {
30 - "output": "This is a reasoning section</seed:think>This is the rest",
31 - "reasoning": "This is a reasoning section",
32 - "content": "This is the rest",
33 - "is_reasoning_end": True,
34 -}
35 -COMPLETE_REASONING: dict[str, Any] = {
36 - "output": "This is a reasoning section</seed:think>",
37 - "reasoning": "This is a reasoning section",
38 - "content": None,
39 - "is_reasoning_end": True,
40 -}
41 -NO_CONTENT: dict[str, Any] = {
42 - "output": "This is content",
43 - "reasoning": "This is content",
44 - "content": None,
45 - "is_reasoning_end": False,
46 -}
47 -NO_REASONING_STREAMING: dict[str, Any] = {
48 - "output": "This is a reasoning section",
49 - "reasoning": "This is a reasoning section",
50 - "content": None,
51 - "is_reasoning_end": False,
52 -}
53 -MULTIPLE_LINES: dict[str, Any] = {
54 - "output": "This\nThat</seed:think>This is the rest\nThat",
55 - "reasoning": "This\nThat",
56 - "content": "This is the rest\nThat",
57 - "is_reasoning_end": True,
58 -}
59 -WITH_START_TOKEN: dict[str, Any] = {
60 - "output": ("<seed:think>This is a reasoning section</seed:think>This is the rest"),
61 - "reasoning": "This is a reasoning section",
62 - "content": "This is the rest",
63 - "is_reasoning_end": True,
64 -}
65 -ONLY_END_TOKEN: dict[str, Any] = {
66 - "output": "Some reasoning</seed:think>This is the rest",
67 - "reasoning": "Some reasoning",
68 - "content": "This is the rest",
69 - "is_reasoning_end": True,
70 -}
71 -NO_TOKENS: dict[str, Any] = {
72 - "output": "This is just content without any reasoning tokens",
73 - "reasoning": "This is just content without any reasoning tokens",
74 - "content": None,
75 - "is_reasoning_end": False,
76 -}
77 -
78 -
79 -def test_seedoss_reasoning_parser_creation(seedoss_tokenizer):
80 - """Test that the SeedOSS reasoning parser can be created and registered."""
81 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
82 - parser = parser_cls(seedoss_tokenizer)
83 - assert isinstance(parser, ReasoningParser)
84 - assert parser.start_token == start_token
85 - assert parser.end_token == end_token
86 -
87 -
88 -@pytest.mark.parametrize("streaming", [True, False])
89 -def test_simple_reasoning(seedoss_tokenizer, streaming):
90 - """Test basic reasoning extraction with both tokens."""
91 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
92 - parser = parser_cls(seedoss_tokenizer)
93 -
94 - reasoning, content = run_reasoning_extraction(
95 - parser, [cast(str, SIMPLE_REASONING["output"])], streaming=streaming
96 - )
97 -
98 - assert reasoning == SIMPLE_REASONING["reasoning"]
99 - assert content == SIMPLE_REASONING["content"]
100 -
101 -
102 -@pytest.mark.parametrize("streaming", [True, False])
103 -def test_complete_reasoning(seedoss_tokenizer, streaming):
104 - """Test reasoning extraction when there's no content after reasoning."""
105 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
106 - parser = parser_cls(seedoss_tokenizer)
107 -
108 - reasoning, content = run_reasoning_extraction(
109 - parser, [cast(str, COMPLETE_REASONING["output"])], streaming=streaming
110 - )
111 -
112 - assert reasoning == COMPLETE_REASONING["reasoning"]
113 - assert content == COMPLETE_REASONING["content"]
114 -
115 -
116 -@pytest.mark.parametrize("streaming", [True, False])
117 -def test_no_content(seedoss_tokenizer, streaming):
118 - """Test when there's no end token - everything is reasoning content."""
119 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
120 - parser = parser_cls(seedoss_tokenizer)
121 -
122 - reasoning, content = run_reasoning_extraction(
123 - parser, [cast(str, NO_CONTENT["output"])], streaming=streaming
124 - )
125 -
126 - assert reasoning == NO_CONTENT["reasoning"]
127 - assert content == NO_CONTENT["content"]
128 -
129 -
130 -@pytest.mark.parametrize("streaming", [True, False])
131 -def test_multiple_lines(seedoss_tokenizer, streaming):
132 - """Test reasoning extraction with multiline content."""
133 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
134 - parser = parser_cls(seedoss_tokenizer)
135 -
136 - reasoning, content = run_reasoning_extraction(
137 - parser, [cast(str, MULTIPLE_LINES["output"])], streaming=streaming
138 - )
139 -
140 - assert reasoning == MULTIPLE_LINES["reasoning"]
141 - assert content == MULTIPLE_LINES["content"]
142 -
143 -
144 -@pytest.mark.parametrize("streaming", [True, False])
145 -def test_with_start_token(seedoss_tokenizer, streaming):
146 - """Test reasoning extraction with both start and end tokens."""
147 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
148 - parser = parser_cls(seedoss_tokenizer)
149 -
150 - reasoning, content = run_reasoning_extraction(
151 - parser, [cast(str, WITH_START_TOKEN["output"])], streaming=streaming
152 - )
153 -
154 - assert reasoning == WITH_START_TOKEN["reasoning"]
155 - assert content == WITH_START_TOKEN["content"]
156 -
157 -
158 -@pytest.mark.parametrize("streaming", [True, False])
159 -def test_only_end_token(seedoss_tokenizer, streaming):
160 - """
161 - Test reasoning extraction with only end token
162 - (SeedOSS typical behavior).
163 - """
164 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
165 - parser = parser_cls(seedoss_tokenizer)
166 -
167 - reasoning, content = run_reasoning_extraction(
168 - parser, [cast(str, ONLY_END_TOKEN["output"])], streaming=streaming
169 - )
170 -
171 - assert reasoning == ONLY_END_TOKEN["reasoning"]
172 - assert content == ONLY_END_TOKEN["content"]
173 -
174 -
175 -@pytest.mark.parametrize("streaming", [True, False])
176 -def test_no_tokens(seedoss_tokenizer, streaming):
177 - """Test when there are no reasoning tokens at all."""
178 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
179 - parser = parser_cls(seedoss_tokenizer)
180 -
181 - reasoning, content = run_reasoning_extraction(
182 - parser, [cast(str, NO_TOKENS["output"])], streaming=streaming
183 - )
184 -
185 - assert reasoning == NO_TOKENS["reasoning"]
186 - assert content == NO_TOKENS["content"]
187 -
188 -
189 -def test_is_reasoning_end(seedoss_tokenizer):
190 - """Test the is_reasoning_end method."""
191 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
192 - parser = parser_cls(seedoss_tokenizer)
193 -
194 - # Test with end token present
195 - end_token_id = parser.end_token_id
196 - assert parser.is_reasoning_end([1, 2, end_token_id, 4]) is True
197 -
198 - # Test without end token
199 - assert parser.is_reasoning_end([1, 2, 3, 4]) is False
200 -
201 -
202 -def test_extract_content_ids(seedoss_tokenizer):
203 - """Test the extract_content_ids method."""
204 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
205 - parser = parser_cls(seedoss_tokenizer)
206 -
207 - end_token_id = parser.end_token_id
208 -
209 - # Test with end token in the middle
210 - input_ids = [1, 2, end_token_id, 4, 5]
211 - content_ids = parser.extract_content_ids(input_ids)
212 - assert content_ids == [4, 5]
213 -
214 - # Test with end token at the end
215 - input_ids = [1, 2, 3, end_token_id]
216 - content_ids = parser.extract_content_ids(input_ids)
217 - assert content_ids == []
218 -
219 - # Test without end token
220 - input_ids = [1, 2, 3, 4]
221 - content_ids = parser.extract_content_ids(input_ids)
222 - assert content_ids == []
223 -
224 -
225 -def test_streaming_delta_processing(seedoss_tokenizer):
226 - """Test streaming processing with small deltas."""
227 - parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
228 - parser = parser_cls(seedoss_tokenizer)
229 -
230 - # Test streaming with incremental tokens
231 - deltas = ["Some ", "reasoning ", "content", "</seed:think>", "Final ", "answer"]
232 -
233 - reasoning, content = run_reasoning_extraction(parser, deltas, streaming=True)
234 -
235 - assert reasoning == "Some reasoning content"
236 - assert content == "Final answer"
tests/tool_parsers/test_seed_oss_tool_parser.py deleted
-522
@@ -1,522 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -# ruff: noqa: E501
4 -
5 -import json
6 -from collections.abc import Generator
7 -
8 -import pytest
9 -
10 -from vllm.entrypoints.openai.chat_completion.protocol import (
11 - ChatCompletionRequest,
12 - ChatCompletionToolsParam,
13 -)
14 -from vllm.entrypoints.openai.engine.protocol import (
15 - DeltaMessage,
16 - FunctionCall,
17 - ToolCall,
18 -)
19 -from vllm.tokenizers import TokenizerLike, get_tokenizer
20 -from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
21 -from vllm.tool_parsers.seed_oss_tool_parser import SeedOssToolParser
22 -
23 -# Use a common model that is likely to be available
24 -MODEL = "ByteDance-Seed/Seed-OSS-36B-Instruct"
25 -
26 -
27 -@pytest.fixture(scope="module")
28 -def seed_oss_tokenizer():
29 - return get_tokenizer(tokenizer_name=MODEL, trust_remote_code=True)
30 -
31 -
32 -@pytest.fixture
33 -def seed_oss_tool_parser(seed_oss_tokenizer, sample_tools):
34 - return SeedOssToolParser(seed_oss_tokenizer, tools=sample_tools)
35 -
36 -
37 -@pytest.fixture
38 -def sample_tools():
39 - return [
40 - ChatCompletionToolsParam(
41 - type="function",
42 - function={
43 - "name": "get_weather",
44 - "description": "Get current temperature for a given location.",
45 - "parameters": {
46 - "type": "object",
47 - "properties": {
48 - "location": {
49 - "type": "string",
50 - "description": "City and country e.g. Bogotá, Colombia",
51 - },
52 - "unit": {
53 - "type": "string",
54 - "description": "this is the unit of temperature",
55 - },
56 - },
57 - "required": ["location"],
58 - "additionalProperties": False,
59 - },
60 - "returns": {
61 - "type": "object",
62 - "properties": {
63 - "temperature": {
64 - "type": "number",
65 - "description": "temperature in celsius",
66 - }
67 - },
68 - "required": ["temperature"],
69 - "additionalProperties": False,
70 - },
71 - "strict": True,
72 - },
73 - ),
74 - ]
75 -
76 -
77 -def assert_tool_calls(
78 - actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
79 -):
80 - assert len(actual_tool_calls) == len(expected_tool_calls)
81 -
82 - for actual_tool_call, expected_tool_call in zip(
83 - actual_tool_calls, expected_tool_calls
84 - ):
85 - # Seed-OSS tool call will not generate id
86 - assert actual_tool_call.type == "function"
87 - assert actual_tool_call.function == expected_tool_call.function
88 -
89 - assert actual_tool_call.function.name == expected_tool_call.function.name
90 - assert (
91 - actual_tool_call.function.arguments == expected_tool_call.function.arguments
92 - )
93 -
94 -
95 -def test_extract_tool_calls_no_tools(seed_oss_tool_parser):
96 - model_output = "This is a test response without any tool calls"
97 - extracted_tool_calls = seed_oss_tool_parser.extract_tool_calls(
98 - model_output, request=None
99 - ) # type: ignore[arg-type]
100 -
101 - assert not extracted_tool_calls.tools_called
102 - assert extracted_tool_calls.tool_calls == []
103 - assert extracted_tool_calls.content == model_output
104 -
105 -
106 -@pytest.mark.parametrize(
107 - ids=[
108 - "tool_call_0_thinking_budget",
109 - "tool_call_512_thinking_budget",
110 - "tool_call_unlimited_thinking_budget",
111 - ],
112 - argnames=["model_output", "expected_tool_calls", "expected_content"],
113 - argvalues=[
114 - (
115 - """<seed:tool_call>\n<function=get_weather>\n"""
116 - """<parameter=location>Barcelona, Spain</parameter>\n</function>\n</seed:tool_call>""",
117 - [
118 - ToolCall(
119 - function=FunctionCall(
120 - name="get_weather",
121 - arguments=json.dumps(
122 - {
123 - "location": "Barcelona, Spain",
124 - },
125 - ),
126 - ),
127 - type="function",
128 - )
129 - ],
130 - None,
131 - ),
132 - (
133 - """<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
134 - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
135 - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
136 - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
137 - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
138 - """country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
139 - """</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
140 - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
141 - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
142 - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """
143 - """with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
144 - """use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n"""
145 - """<seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, Spain</parameter>\n</function>"""
146 - """\n</seed:tool_call>""",
147 - [
148 - ToolCall(
149 - function=FunctionCall(
150 - name="get_weather",
151 - arguments=json.dumps(
152 - {
153 - "location": "Barcelona, Spain",
154 - },
155 - ),
156 - ),
157 - type="function",
158 - )
159 - ],
160 - """<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
161 - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
162 - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
163 - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
164 - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
165 - """country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
166 - """</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
167 - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
168 - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
169 - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """
170 - """with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
171 - """use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n""",
172 - ),
173 - (
174 - """<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
175 - """First, I need to remember the function I can use: get_weather. The function requires a """
176 - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
177 - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
178 - """let me check the function docstring again. Oh, the function says unit is optional, and """
179 - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
180 - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
181 - """The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
182 - """Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
183 - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
184 - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
185 - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
186 - """call should be as above. Then wait for the result to come back and tell the user the """
187 - """temperature in Celsius.</seed:think><seed:tool_call>\n<function=get_weather>\n<parameter=location>"""
188 - """Barcelona, Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>""",
189 - [
190 - ToolCall(
191 - function=FunctionCall(
192 - name="get_weather",
193 - arguments=json.dumps(
194 - {
195 - "location": "Barcelona, Spain",
196 - "unit": "celsius",
197 - },
198 - ),
199 - ),
200 - type="function",
201 - )
202 - ],
203 - """<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
204 - """First, I need to remember the function I can use: get_weather. The function requires a """
205 - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
206 - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
207 - """let me check the function docstring again. Oh, the function says unit is optional, and """
208 - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
209 - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
210 - """The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
211 - """Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
212 - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
213 - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
214 - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
215 - """call should be as above. Then wait for the result to come back and tell the user the """
216 - """temperature in Celsius.</seed:think>""",
217 - ),
218 - ],
219 -)
220 -def test_extract_tool_calls(
221 - seed_oss_tool_parser,
222 - sample_tools,
223 - model_output,
224 - expected_tool_calls,
225 - expected_content,
226 -):
227 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
228 - extracted_tool_calls = seed_oss_tool_parser.extract_tool_calls(
229 - model_output, request=request
230 - ) # type: ignore[arg-type]
231 - assert extracted_tool_calls.tools_called
232 -
233 - assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
234 -
235 - assert extracted_tool_calls.content == expected_content
236 -
237 -
238 -def test_streaming_tool_calls_no_tools(seed_oss_tool_parser):
239 - model_output = "This is a test response without any tool calls"
240 -
241 - result = seed_oss_tool_parser.extract_tool_calls_streaming(
242 - previous_text="his is a test response",
243 - current_text=model_output,
244 - delta_text=" without any tool calls.",
245 - previous_token_ids=[],
246 - current_token_ids=[],
247 - delta_token_ids=[],
248 - request=None,
249 - )
250 -
251 - # Should return the delta text as content
252 - assert result is not None
253 - assert hasattr(result, "content")
254 - assert result.content == " without any tool calls."
255 -
256 -
257 -def stream_delta_message_generator(
258 - seed_oss_tool_parser: SeedOssToolParser,
259 - seed_oss_tokenizer: TokenizerLike,
260 - model_output: str,
261 - request: ChatCompletionRequest | None = None,
262 -) -> Generator[DeltaMessage, None, None]:
263 - all_token_ids = seed_oss_tokenizer.encode(model_output, add_special_tokens=False)
264 -
265 - previous_text = ""
266 - previous_tokens = None
267 - prefix_offset = 0
268 - read_offset = 0
269 - for i, delta_token in enumerate(all_token_ids):
270 - delta_token_ids = [delta_token]
271 - previous_token_ids = all_token_ids[:i]
272 - current_token_ids = all_token_ids[: i + 1]
273 -
274 - (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
275 - detokenize_incrementally(
276 - tokenizer=seed_oss_tokenizer,
277 - all_input_ids=current_token_ids,
278 - prev_tokens=previous_tokens,
279 - prefix_offset=prefix_offset,
280 - read_offset=read_offset,
281 - skip_special_tokens=False,
282 - spaces_between_special_tokens=True,
283 - )
284 - )
285 -
286 - current_text = previous_text + delta_text
287 -
288 - delta_message = seed_oss_tool_parser.extract_tool_calls_streaming(
289 - previous_text,
290 - current_text,
291 - delta_text,
292 - previous_token_ids,
293 - current_token_ids,
294 - delta_token_ids,
295 - request=request,
296 - )
297 - if delta_message:
298 - yield delta_message
299 -
300 - previous_text = current_text
301 - previous_tokens = (
302 - previous_tokens + new_tokens if previous_tokens else new_tokens
303 - )
304 - prefix_offset = new_prefix_offset
305 - read_offset = new_read_offset
306 -
307 -
308 -@pytest.mark.parametrize(
309 - ids=[
310 - "tool_call_0_thinking_budget",
311 - "tool_call_512_thinking_budget",
312 - "tool_call_unlimited_thinking_budget",
313 - ],
314 - argnames=["model_output", "expected_tool_calls", "expected_content"],
315 - argvalues=[
316 - (
317 - """<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
318 - """The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n"""
319 - """<seed:tool_call>\n<function=get_weather>\n"""
320 - """<parameter=location>Barcelona, Spain</parameter>\n</function>\n</seed:tool_call>""",
321 - [
322 - ToolCall(
323 - function=FunctionCall(
324 - name="get_weather",
325 - arguments=json.dumps(
326 - {
327 - "location": "Barcelona, Spain",
328 - },
329 - ),
330 - ),
331 - type="function",
332 - )
333 - ],
334 - """<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
335 - """The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n""",
336 - ),
337 - (
338 - """<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
339 - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
340 - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
341 - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
342 - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
343 - """country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
344 - """</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
345 - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
346 - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
347 - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """
348 - """with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
349 - """use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n"""
350 - """<seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, Spain</parameter>\n</function>"""
351 - """\n</seed:tool_call>""",
352 - [
353 - ToolCall(
354 - function=FunctionCall(
355 - name="get_weather",
356 - arguments=json.dumps(
357 - {
358 - "location": "Barcelona, Spain",
359 - },
360 - ),
361 - ),
362 - type="function",
363 - )
364 - ],
365 - """<seed:think>The user\'s current thinking budget is 512.</seed:cot_budget_reflect>\nLet me analyze the """
366 - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """
367 - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """
368 - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """
369 - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """
370 - """country). \n<seed:cot_budget_reflect>I have used 131 tokens, and there are 381 tokens remaining for use."""
371 - """</seed:cot_budget_reflect>\n Since the unit isn\'t specified, the function will default to Celsius, which """
372 - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """
373 - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """
374 - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """
375 - """with a comma).\n<seed:cot_budget_reflect>I have used 257 tokens, and there are 255 tokens remaining for """
376 - """use.</seed:cot_budget_reflect>\n The unit parameter can be omitted since it\'s optional.</seed:think>\n""",
377 - ),
378 - (
379 - """<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
380 - """First, I need to remember the function I can use: get_weather. The function requires a """
381 - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
382 - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
383 - """let me check the function docstring again. Oh, the function says unit is optional, and """
384 - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
385 - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
386 - """The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
387 - """Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
388 - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
389 - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
390 - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
391 - """call should be as above. Then wait for the result to come back and tell the user the """
392 - """temperature in Celsius.</seed:think><seed:tool_call>\n<function=get_weather>\n<parameter=location>"""
393 - """Barcelona, Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>""",
394 - [
395 - ToolCall(
396 - function=FunctionCall(
397 - name="get_weather",
398 - arguments=json.dumps(
399 - {
400 - "location": "Barcelona, Spain",
401 - "unit": "celsius",
402 - },
403 - ),
404 - ),
405 - type="function",
406 - )
407 - ],
408 - """<seed:think>\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """
409 - """First, I need to remember the function I can use: get_weather. The function requires a """
410 - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """
411 - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """
412 - """let me check the function docstring again. Oh, the function says unit is optional, and """
413 - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """
414 - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """
415 - """The format is <seed:tool_call>\n<function=get_weather>\n<parameter=location>Barcelona, """
416 - """Spain</parameter>\n<parameter=unit>celsius</parameter>\n</function>\n</seed:tool_call>. """
417 - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """
418 - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """
419 - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """
420 - """call should be as above. Then wait for the result to come back and tell the user the """
421 - """temperature in Celsius.</seed:think>""",
422 - ),
423 - ],
424 -)
425 -def test_streaming_tool_calls(
426 - seed_oss_tool_parser,
427 - seed_oss_tokenizer,
428 - sample_tools,
429 - model_output,
430 - expected_tool_calls,
431 - expected_content,
432 -):
433 - """Test incremental streaming behavior"""
434 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
435 -
436 - other_content = ""
437 - tool_states = {} # Track state per tool index
438 -
439 - for delta_message in stream_delta_message_generator(
440 - seed_oss_tool_parser, seed_oss_tokenizer, model_output, request
441 - ):
442 - # role should never be streamed from tool parser
443 - assert not delta_message.role
444 -
445 - if delta_message.content:
446 - other_content += delta_message.content
447 -
448 - if delta_message.tool_calls:
449 - for tool_call in delta_message.tool_calls:
450 - idx = tool_call.index
451 -
452 - # Initialize state for new tool
453 - if idx not in tool_states:
454 - tool_states[idx] = {
455 - "id": None,
456 - "name": None,
457 - "arguments": "",
458 - "type": None,
459 - }
460 -
461 - # First chunk should have id, name, and type
462 - if tool_call.id:
463 - tool_states[idx]["id"] = tool_call.id
464 -
465 - if tool_call.type:
466 - assert tool_call.type == "function"
467 - tool_states[idx]["type"] = tool_call.type
468 -
469 - if tool_call.function:
470 - if tool_call.function.name:
471 - # Should only be set once
472 - assert tool_states[idx]["name"] is None
473 - tool_states[idx]["name"] = tool_call.function.name
474 -
475 - if tool_call.function.arguments is not None:
476 - # Accumulate arguments incrementally
477 - tool_states[idx]["arguments"] += tool_call.function.arguments
478 -
479 - # Verify final content
480 - assert other_content == expected_content
481 -
482 - # Verify we got all expected tool calls
483 - assert len(tool_states) == len(expected_tool_calls)
484 -
485 - # Verify each tool call
486 - for idx, expected_tool in enumerate(expected_tool_calls):
487 - state = tool_states[idx]
488 - assert state["id"] is not None
489 - assert state["type"] == "function"
490 - assert state["name"] == expected_tool.function.name
491 -
492 - # Parse accumulated arguments
493 - arguments_str = state["arguments"]
494 - assert arguments_str is not None
495 - actual_args = json.loads(arguments_str)
496 - expected_args = json.loads(expected_tool.function.arguments)
497 - assert actual_args == expected_args
498 -
499 -
500 -def test_streaming_tool_calls_non_ascii(
501 - seed_oss_tool_parser, seed_oss_tokenizer, sample_tools
502 -):
503 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
504 - model_output = (
505 - """<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
506 - """The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n"""
507 - """<seed:tool_call>\n<function=get_weather>\n"""
508 - """<parameter=location>北京</parameter>\n</function>\n</seed:tool_call>"""
509 - )
510 -
511 - args = "".join(
512 - tool_call.function.arguments
513 - for delta_message in stream_delta_message_generator(
514 - seed_oss_tool_parser, seed_oss_tokenizer, model_output, request
515 - )
516 - if delta_message.tool_calls
517 - for tool_call in delta_message.tool_calls
518 - if tool_call.function and tool_call.function.arguments is not None
519 - )
520 -
521 - assert "北京" in args
522 - assert "\\u" not in args
vllm/parser/engine/registered_adapters.py
+6
@@ -13,6 +13,7 @@ from vllm.parser.glm47_moe import Glm47MoeParser
13 from vllm.parser.minimax_m2 import MinimaxM2Parser
14 from vllm.parser.nemotron_v3 import NemotronV3Parser
15 from vllm.parser.qwen3 import Qwen3Parser
16 +from vllm.parser.seed_oss import SeedOssParser
17
18 (
19 MinimaxM2ParserReasoningAdapter,
@@ -34,6 +35,11 @@ from vllm.parser.qwen3 import Qwen3Parser
35 Qwen3ParserToolAdapter,
36 ) = make_adapters(Qwen3Parser)
37
38 +(
39 + SeedOssParserReasoningAdapter,
40 + SeedOssParserToolAdapter,
41 +) = make_adapters(SeedOssParser)
42 +
43 (
44 Glm47MoeParserReasoningAdapter,
45 Glm47MoeParserToolAdapter,
vllm/parser/qwen3.py
+40 -13
@@ -38,6 +38,8 @@ if TYPE_CHECKING:
38 from vllm.tokenizers import TokenizerLike
39 from vllm.tool_parsers.abstract_tool_parser import Tool
40
41 +THINK_START = "<think>"
42 +THINK_END = "</think>"
43 TOOL_CALL_START = "<tool_call>"
44 TOOL_CALL_END = "</tool_call>"
45 FUNC_PREFIX = "<function="
@@ -75,17 +77,25 @@ def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
77
78
79 @functools.cache
78 -def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
80 +def qwen3_config(
81 + thinking: bool = True,
82 + *,
83 + name: str = "qwen3",
84 + think_start: str = THINK_START,
85 + think_end: str = THINK_END,
86 + tool_start: str = TOOL_CALL_START,
87 + tool_end: str = TOOL_CALL_END,
88 +) -> ParserEngineConfig:
89 return ParserEngineConfig(
80 - name="qwen3",
90 + name=name,
91 initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
92 terminals={
93 # Reasoning terminals
84 - "THINK_START": "<think>",
85 - "THINK_END": "</think>",
94 + "THINK_START": think_start,
95 + "THINK_END": think_end,
96 # Tool call terminals
87 - "TOOL_START": TOOL_CALL_START,
88 - "TOOL_END": TOOL_CALL_END,
97 + "TOOL_START": tool_start,
98 + "TOOL_END": tool_end,
99 "FUNC_PREFIX": FUNC_PREFIX,
100 "FUNC_END": FUNC_END,
101 "PARAM_START": PARAM_START,
@@ -93,10 +103,10 @@ def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
103 "CLOSE_ANGLE": ">",
104 },
105 token_id_terminals={
96 - "THINK_START": "<think>",
97 - "THINK_END": "</think>",
98 - "TOOL_START": TOOL_CALL_START,
99 - "TOOL_END": TOOL_CALL_END,
106 + "THINK_START": think_start,
107 + "THINK_END": think_end,
108 + "TOOL_START": tool_start,
109 + "TOOL_END": tool_end,
110 },
111 transitions={
112 # -- Reasoning transitions --
@@ -185,8 +195,18 @@ class Qwen3Parser(ParserEngine):
195
196 - ``<tool_call>`` as implicit reasoning end
197 - Unpaired ``<tool_call>`` token ID detection for ``is_reasoning_end``
198 +
199 + Subclasses that share the grammar but differ only in the four wrapper
200 + token strings (reasoning + tool-call) override the class attributes
201 + below; everything else is inherited unchanged.
202 """
203
204 + CONFIG_NAME = "qwen3"
205 + THINK_START = THINK_START
206 + THINK_END = THINK_END
207 + TOOL_START = TOOL_CALL_START
208 + TOOL_END = TOOL_CALL_END
209 +
210 def __init__(
211 self,
212 tokenizer: TokenizerLike,
@@ -197,7 +217,14 @@ class Qwen3Parser(ParserEngine):
217 self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
218 kwargs.setdefault(
219 "parser_engine_config",
200 - qwen3_config(thinking=self.thinking_enabled),
220 + qwen3_config(
221 + thinking=self.thinking_enabled,
222 + name=self.CONFIG_NAME,
223 + think_start=self.THINK_START,
224 + think_end=self.THINK_END,
225 + tool_start=self.TOOL_START,
226 + tool_end=self.TOOL_END,
227 + ),
228 )
229 super().__init__(
230 tokenizer,
@@ -205,8 +232,8 @@ class Qwen3Parser(ParserEngine):
232 **kwargs,
233 )
234 vocab = self.vocab
208 - self._tool_call_token_id: int | None = vocab.get("<tool_call>")
209 - self._tool_call_end_token_id: int | None = vocab.get("</tool_call>")
235 + self._tool_call_token_id: int | None = vocab.get(self.TOOL_START)
236 + self._tool_call_end_token_id: int | None = vocab.get(self.TOOL_END)
237
238 def extract_reasoning(
239 self,
vllm/parser/seed_oss.py new
+28
@@ -0,0 +1,28 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""seed_oss parser for tool calls and reasoning.
4 +
5 +seed_oss shares the Qwen3 XML grammar exactly; only the four wrapper
6 +token strings differ::
7 +
8 + <think> -> <seed:think>
9 + </think> -> </seed:think>
10 + <tool_call> -> <seed:tool_call>
11 + </tool_call> -> </seed:tool_call>
12 +
13 +``<function=...>`` and ``<parameter=...>`` are byte-identical, so the
14 +entire transition table and ``_qwen3_arg_converter`` are inherited from
15 +:class:`Qwen3Parser` unchanged.
16 +"""
17 +
18 +from __future__ import annotations
19 +
20 +from vllm.parser.qwen3 import Qwen3Parser
21 +
22 +
23 +class SeedOssParser(Qwen3Parser):
24 + CONFIG_NAME = "seed_oss"
25 + THINK_START = "<seed:think>"
26 + THINK_END = "</seed:think>"
27 + TOOL_START = "<seed:tool_call>"
28 + TOOL_END = "</seed:tool_call>"
vllm/reasoning/__init__.py
+2 -2
@@ -117,8 +117,8 @@ _REASONING_PARSERS_TO_REGISTER = {
117 "Qwen3ParserReasoningAdapter",
118 ),
119 "seed_oss": (
120 - "seedoss_reasoning_parser",
121 - "SeedOSSReasoningParser",
120 + "seed_oss_engine_reasoning_parser",
121 + "SeedOssParserReasoningAdapter",
122 ),
123 "step3": (
124 "step3_reasoning_parser",
vllm/reasoning/seed_oss_engine_reasoning_parser.py new
+6
@@ -0,0 +1,6 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import SeedOssParserReasoningAdapter
5 +
6 +__all__ = ["SeedOssParserReasoningAdapter"]
vllm/reasoning/seedoss_reasoning_parser.py deleted
-27
@@ -1,27 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -
5 -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
6 -
7 -
8 -class SeedOSSReasoningParser(BaseThinkingReasoningParser):
9 - """
10 - Reasoning parser for SeedOSS model.
11 -
12 - The SeedOSS model uses <seed:think>...</seed:think> tokens to
13 - denote reasoning content text. This parser extracts
14 - the reasoning content from the model output.
15 - Similar to DeepSeek R1, it supports cases
16 - where the model doesn't generate the start token.
17 - """
18 -
19 - @property
20 - def start_token(self) -> str:
21 - """The token that starts reasoning content."""
22 - return "<seed:think>"
23 -
24 - @property
25 - def end_token(self) -> str:
26 - """The token that ends reasoning content."""
27 - return "</seed:think>"
vllm/tool_parsers/__init__.py
+2 -2
@@ -163,8 +163,8 @@ _TOOL_PARSERS_TO_REGISTER = {
163 "Qwen3EngineToolParser",
164 ),
165 "seed_oss": (
166 - "seed_oss_tool_parser",
167 - "SeedOssToolParser",
166 + "seed_oss_engine_tool_parser",
167 + "SeedOssEngineToolParser",
168 ),
169 "step3": (
170 "step3_tool_parser",
vllm/tool_parsers/seed_oss_engine_tool_parser.py new
+8
@@ -0,0 +1,8 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import SeedOssParserToolAdapter
5 +
6 +
7 +class SeedOssEngineToolParser(SeedOssParserToolAdapter): # type: ignore[valid-type, misc]
8 + structural_tag_model = None
vllm/tool_parsers/seed_oss_tool_parser.py deleted
-633
@@ -1,633 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -# Adapted from qwen3coder xml parser, All rights reserved.
4 -# ruff: noqa: E501
5 -
6 -import json
7 -import uuid
8 -from collections.abc import Sequence
9 -
10 -import regex as re
11 -
12 -from vllm.entrypoints.openai.chat_completion.protocol import (
13 - ChatCompletionRequest,
14 -)
15 -from vllm.entrypoints.openai.engine.protocol import (
16 - DeltaFunctionCall,
17 - DeltaMessage,
18 - DeltaToolCall,
19 - ExtractedToolCallInformation,
20 - FunctionCall,
21 - ToolCall,
22 -)
23 -from vllm.logger import init_logger
24 -from vllm.tokenizers import TokenizerLike
25 -from vllm.tool_parsers.abstract_tool_parser import (
26 - Tool,
27 - ToolParser,
28 -)
29 -from vllm.tool_parsers.utils import (
30 - coerce_to_schema_type,
31 - extract_types_from_schema,
32 - find_tool_properties,
33 -)
34 -
35 -logger = init_logger(__name__)
36 -
37 -
38 -class SeedOssToolParser(ToolParser):
39 - TOOL_CALL_START = "<seed:tool_call>"
40 - TOOL_CALL_END = "</seed:tool_call>"
41 -
42 - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
43 - super().__init__(tokenizer, tools)
44 -
45 - # --- streaming state ---
46 - self._reset_streaming_state()
47 - self.prev_tool_call_arr: list[dict] = []
48 -
49 - self.tool_call_start_token: str = self.TOOL_CALL_START
50 - self.tool_call_end_token: str = self.TOOL_CALL_END
51 - # Sentinel tokens for streaming mode
52 - self.tool_call_prefix: str = "<function="
53 - self.function_end_token: str = "</function>"
54 - self.parameter_prefix: str = "<parameter="
55 - self.parameter_end_token: str = "</parameter>"
56 - self.think_start_token: str = "<seed:think>"
57 - self.think_end_token: str = "</seed:think>"
58 - self.is_tool_call_started: bool = False
59 - self.is_thinking_end: bool = False
60 - self.failed_count: int = 0
61 - self._reset_streaming_state()
62 -
63 - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
64 - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
65 - self.think_end_token_id = self.vocab.get(self.think_end_token)
66 -
67 - if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
68 - raise RuntimeError(
69 - "Seed_Oss XML parser: tokenizer did not include "
70 - "<seed:tool_call> or its closing tag."
71 - )
72 -
73 - tool_start_re = re.escape(self.tool_call_start_token)
74 - tool_end_re = re.escape(self.tool_call_end_token)
75 -
76 - self.tool_call_complete_regex = re.compile(
77 - rf"{tool_start_re}(.*?){tool_end_re}", re.DOTALL
78 - )
79 - self.tool_call_regex = re.compile(
80 - rf"{tool_start_re}(.*?){tool_end_re}|{tool_start_re}(.*?)$", re.DOTALL
81 - )
82 -
83 - self.tool_call_function_regex = re.compile(
84 - r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL
85 - )
86 - self.tool_call_parameter_regex = re.compile(
87 - r"<parameter=(.*?)</parameter>|<parameter=(.*?)$", re.DOTALL
88 - )
89 -
90 - logger.info(
91 - "vLLM Seed-Oss XML tool parser loaded (%s).", self.__class__.__name__
92 - )
93 -
94 - def _generate_tool_call_id(self) -> str:
95 - """Generate a unique tool call ID."""
96 - return f"call_{uuid.uuid4().hex[:24]}"
97 -
98 - def _reset_streaming_state(self):
99 - """Reset all streaming state."""
100 - self.current_tool_index = 0
101 - self.is_tool_call_started = False
102 - self.header_sent = False
103 - self.current_tool_id = -1
104 - self.current_function_name = None
105 - self.current_param_name = None
106 - self.current_param_value = ""
107 - self.param_count = 0
108 - self.in_param = False
109 - self.in_function = False
110 - self.accumulated_text = ""
111 - self.json_started = False
112 - self.json_closed = False
113 -
114 - def _parse_xml_function_call(
115 - self, function_call_str: str, tools: list[Tool] | None
116 - ) -> ToolCall | None:
117 - # Extract function name
118 - end_index = function_call_str.index(">")
119 - function_name = function_call_str[:end_index]
120 - tool_properties = find_tool_properties(tools, function_name)
121 - parameters = function_call_str[end_index + 1 :]
122 - param_dict = {}
123 - for match in self.tool_call_parameter_regex.findall(parameters):
124 - match_text = match[0] if match[0] else match[1]
125 - idx = match_text.index(">")
126 - param_name = match_text[:idx]
127 - param_value = str(match_text[idx + 1 :])
128 - # Remove prefix and trailing \n
129 - if param_value.startswith("\n"):
130 - param_value = param_value[1:]
131 - if param_value.endswith("\n"):
132 - param_value = param_value[:-1]
133 -
134 - param_types = extract_types_from_schema(tool_properties.get(param_name, {}))
135 - param_dict[param_name] = coerce_to_schema_type(param_value, param_types)
136 - return ToolCall(
137 - type="function",
138 - function=FunctionCall(
139 - name=function_name, arguments=json.dumps(param_dict, ensure_ascii=False)
140 - ),
141 - )
142 -
143 - def _get_function_calls(self, model_output: str) -> list[str]:
144 - # Find all tool calls
145 - matched_ranges = self.tool_call_regex.findall(model_output)
146 - raw_tool_calls = [
147 - match[0] if match[0] else match[1] for match in matched_ranges
148 - ]
149 -
150 - # Back-off strategy if no tool_call tags found
151 - if len(raw_tool_calls) == 0:
152 - raw_tool_calls = [model_output]
153 -
154 - raw_function_calls = []
155 - for tool_call in raw_tool_calls:
156 - raw_function_calls.extend(self.tool_call_function_regex.findall(tool_call))
157 -
158 - function_calls = [
159 - match[0] if match[0] else match[1] for match in raw_function_calls
160 - ]
161 - return function_calls
162 -
163 - def extract_tool_calls(
164 - self,
165 - model_output: str,
166 - request: ChatCompletionRequest,
167 - ) -> ExtractedToolCallInformation:
168 - # Quick check to avoid unnecessary processing
169 - if self.tool_call_prefix not in model_output:
170 - return ExtractedToolCallInformation(
171 - tools_called=False, tool_calls=[], content=model_output
172 - )
173 -
174 - # Check if both think start and end tokens are present
175 - if (
176 - self.think_start_token in model_output
177 - and self.think_end_token in model_output
178 - ):
179 - # Find the position of think end token
180 - think_end_index = model_output.find(self.think_end_token) + len(
181 - self.think_end_token
182 - )
183 - # Extract content after think end token
184 - result_content = model_output[think_end_index:]
185 - thinking_content = model_output[:think_end_index]
186 - else:
187 - thinking_content = ""
188 - result_content = model_output
189 -
190 - try:
191 - function_calls = self._get_function_calls(result_content)
192 - if len(function_calls) == 0:
193 - return ExtractedToolCallInformation(
194 - tools_called=False, tool_calls=[], content=model_output
195 - )
196 -
197 - tool_calls = [
198 - self._parse_xml_function_call(function_call_str, self.tools)
199 - for function_call_str in function_calls
200 - ]
201 -
202 - # Populate prev_tool_call_arr for serving layer to set finish_reason
203 - self.prev_tool_call_arr.clear() # Clear previous calls
204 - for tool_call in tool_calls:
205 - if tool_call:
206 - self.prev_tool_call_arr.append(
207 - {
208 - "name": tool_call.function.name,
209 - "arguments": tool_call.function.arguments,
210 - }
211 - )
212 -
213 - # Extract content before tool calls
214 - tool_call_start_index = result_content.find(self.tool_call_start_token)
215 - tool_call_start_index = (
216 - tool_call_start_index
217 - if tool_call_start_index >= 0
218 - else result_content.find(self.tool_call_prefix)
219 - )
220 - content = thinking_content + result_content[:tool_call_start_index]
221 -
222 - return ExtractedToolCallInformation(
223 - tools_called=(len(tool_calls) > 0),
224 - tool_calls=tool_calls,
225 - content=content if content else None,
226 - )
227 -
228 - except Exception:
229 - logger.exception("Error in extracting tool call from response.")
230 - return ExtractedToolCallInformation(
231 - tools_called=False, tool_calls=[], content=model_output
232 - )
233 -
234 - def extract_tool_calls_streaming(
235 - self,
236 - previous_text: str,
237 - current_text: str,
238 - delta_text: str,
239 - previous_token_ids: Sequence[int],
240 - current_token_ids: Sequence[int],
241 - delta_token_ids: Sequence[int],
242 - request: ChatCompletionRequest,
243 - ) -> DeltaMessage | None:
244 - # If no delta text, return None unless
245 - # it's an EOS token after tool calls
246 - if not delta_text:
247 - # Check if this is an EOS token after all tool calls are complete
248 - # We check for tool calls in the text even if is_tool_call_started
249 - # is False because it might have been reset after processing all tools
250 - if delta_token_ids and self.tool_call_end_token_id not in delta_token_ids:
251 - # Count complete tool calls
252 - complete_calls = len(
253 - self.tool_call_complete_regex.findall(current_text)
254 - )
255 -
256 - # If we have completed tool calls and populated prev_tool_call_arr
257 - if complete_calls > 0 and len(self.prev_tool_call_arr) > 0:
258 - # Check if all tool calls are closed
259 - open_calls = current_text.count(
260 - self.tool_call_start_token
261 - ) - current_text.count(self.tool_call_end_token)
262 - if open_calls == 0:
263 - # Return empty delta message to allow finish_reason processing
264 - return DeltaMessage(content="")
265 - elif not self.is_tool_call_started and current_text:
266 - # This is a regular content response that's now complete
267 - return DeltaMessage(content="")
268 - return None
269 -
270 - # Check if this is the first call (reset state if needed)
271 - if not previous_text:
272 - self._reset_streaming_state()
273 -
274 - # Update accumulated text
275 - self.accumulated_text = current_text
276 -
277 - # Check if we need to advance to next tool
278 - if self.json_closed and not self.in_function:
279 - # Check if this tool call has ended
280 - tool_ends = current_text.count(self.tool_call_end_token)
281 - if tool_ends > self.current_tool_index:
282 - # This tool has ended, advance to next
283 - self.current_tool_index += 1
284 - self.header_sent = False
285 - self.param_count = 0
286 - self.json_started = False
287 - self.json_closed = False
288 -
289 - # Check if there are more tool calls
290 - if self.current_tool_index >= current_text.count(
291 - self.tool_call_start_token
292 - ):
293 - # No more tool calls
294 - self.is_tool_call_started = False
295 - # Continue processing next tool
296 - return None
297 -
298 - # Check if end thinking
299 - if not self.is_thinking_end and (
300 - self.think_end_token_id in delta_token_ids
301 - or self.think_end_token in delta_text
302 - ):
303 - self.is_thinking_end = True
304 -
305 - # If thinking hasn't ended yet, don't process any tool calls
306 - if not self.is_thinking_end:
307 - return DeltaMessage(content=delta_text)
308 -
309 - # Handle normal content before tool calls
310 - if not self.is_tool_call_started:
311 - # Check if tool call is starting
312 - if (
313 - self.tool_call_start_token_id in delta_token_ids
314 - or self.tool_call_start_token in delta_text
315 - ):
316 - self.is_tool_call_started = True
317 - # Return any content before the tool call
318 - if self.tool_call_start_token in delta_text:
319 - content_before = delta_text[
320 - : delta_text.index(self.tool_call_start_token)
321 - ]
322 - if content_before:
323 - return DeltaMessage(content=content_before)
324 - return None
325 - else:
326 - # Check if we're between tool calls - skip whitespace
327 - if (
328 - current_text.rstrip().endswith(self.tool_call_end_token)
329 - and delta_text.strip() == ""
330 - ):
331 - # We just ended a tool call, skip whitespace
332 - return None
333 - # Normal content, no tool call
334 - return DeltaMessage(content=delta_text)
335 -
336 - # Check if we're between tool calls (waiting for next one)
337 - # Count tool calls we've seen vs processed
338 - tool_starts_count = current_text.count(self.tool_call_start_token)
339 - if self.current_tool_index >= tool_starts_count:
340 - # We're past all tool calls, shouldn't be here
341 - return None
342 -
343 - # We're in a tool call, find the current tool call portion
344 - # Need to find the correct tool call based on current_tool_index
345 - # Only process tool calls after think_end_token
346 - think_end_index = (
347 - current_text.find(self.think_end_token) + len(self.think_end_token)
348 - if self.think_end_token in current_text
349 - else 0
350 - )
351 - tool_starts: list[int] = []
352 - idx = think_end_index
353 - while True:
354 - idx = current_text.find(self.tool_call_start_token, idx)
355 - if idx == -1:
356 - break
357 - tool_starts.append(idx)
358 - idx += len(self.tool_call_start_token)
359 -
360 - if self.current_tool_index >= len(tool_starts):
361 - # No more tool calls to process yet
362 - return None
363 -
364 - tool_start_idx = tool_starts[self.current_tool_index]
365 - # Find where this tool call ends (or current position if not ended yet)
366 - tool_end_idx = current_text.find(self.tool_call_end_token, tool_start_idx)
367 - if tool_end_idx == -1:
368 - tool_text = current_text[tool_start_idx:]
369 - else:
370 - tool_text = current_text[
371 - tool_start_idx : tool_end_idx + len(self.tool_call_end_token)
372 - ]
373 -
374 - # Looking for function header
375 - if not self.header_sent:
376 - if self.tool_call_prefix in tool_text:
377 - func_start = tool_text.find(self.tool_call_prefix) + len(
378 - self.tool_call_prefix
379 - )
380 - func_end = tool_text.find(">", func_start)
381 -
382 - if func_end != -1:
383 - # Found complete function name
384 - self.current_function_name = tool_text[func_start:func_end]
385 - self.current_tool_id = self._generate_tool_call_id() # type: ignore
386 - self.header_sent = True
387 - self.in_function = True
388 -
389 - # IMPORTANT: Add to prev_tool_call_arr immediately when we detect a tool call
390 - # This ensures finish_reason="tool_calls" even if parsing isn't complete
391 - already_added = any(
392 - tool.get("name") == self.current_function_name
393 - for tool in self.prev_tool_call_arr
394 - )
395 - if not already_added:
396 - self.prev_tool_call_arr.append(
397 - {
398 - "name": self.current_function_name,
399 - "arguments": "{}", # Placeholder, will be updated later
400 - }
401 - )
402 -
403 - # Send header with function info
404 - return DeltaMessage(
405 - tool_calls=[
406 - DeltaToolCall(
407 - index=self.current_tool_index,
408 - id=self.current_tool_id,
409 - function=DeltaFunctionCall(
410 - name=self.current_function_name, arguments=""
411 - ),
412 - type="function",
413 - )
414 - ]
415 - )
416 - return None
417 -
418 - # We've sent header, now handle function body
419 - if self.in_function:
420 - # Send opening brace if not sent yet
421 - if not self.json_started and self.parameter_prefix not in delta_text:
422 - self.json_started = True
423 - return DeltaMessage(
424 - tool_calls=[
425 - DeltaToolCall(
426 - index=self.current_tool_index,
427 - function=DeltaFunctionCall(arguments="{"),
428 - )
429 - ]
430 - )
431 -
432 - # Make sure json_started is set if we're processing parameters
433 - if not self.json_started:
434 - self.json_started = True
435 -
436 - # Check for function end in accumulated text
437 - if not self.json_closed and self.function_end_token in tool_text:
438 - # Close JSON
439 - self.json_closed = True
440 -
441 - # Extract the complete tool call to update prev_tool_call_arr with final arguments
442 - # Find the function content
443 - func_start = tool_text.find(self.tool_call_prefix) + len(
444 - self.tool_call_prefix
445 - )
446 - func_content_end = tool_text.find(self.function_end_token, func_start)
447 - if func_content_end != -1:
448 - func_content = tool_text[func_start:func_content_end]
449 - # Parse to get the complete arguments
450 - try:
451 - parsed_tool = self._parse_xml_function_call(
452 - func_content, self.tools
453 - )
454 - if parsed_tool:
455 - # Update existing entry in prev_tool_call_arr with complete arguments
456 - for i, tool in enumerate(self.prev_tool_call_arr):
457 - if tool.get("name") == parsed_tool.function.name:
458 - self.prev_tool_call_arr[i]["arguments"] = (
459 - parsed_tool.function.arguments
460 - )
461 - break
462 - except Exception:
463 - logger.warning(
464 - "Failed to parse tool arguments during streaming.",
465 - exc_info=True,
466 - )
467 -
468 - result = DeltaMessage(
469 - tool_calls=[
470 - DeltaToolCall(
471 - index=self.current_tool_index,
472 - function=DeltaFunctionCall(arguments="}"),
473 - )
474 - ]
475 - )
476 -
477 - # Reset state for next tool
478 - self.in_function = False
479 - self.json_closed = True
480 -
481 - return result
482 -
483 - # Look for parameters
484 - # Count how many complete parameters we have processed
485 - complete_params = tool_text.count(self.parameter_end_token)
486 -
487 - # Check if we should start a new parameter
488 - if not self.in_param and self.param_count < complete_params:
489 - # Find the unprocessed parameter
490 - # Count parameter starts
491 - param_starts = []
492 - idx = 0
493 - while True:
494 - idx = tool_text.find(self.parameter_prefix, idx)
495 - if idx == -1:
496 - break
497 - param_starts.append(idx)
498 - idx += len(self.parameter_prefix)
499 -
500 - if len(param_starts) > self.param_count:
501 - # Process the next parameter
502 - param_idx = param_starts[self.param_count]
503 - param_start = param_idx + len(self.parameter_prefix)
504 - remaining = tool_text[param_start:]
505 -
506 - if ">" in remaining:
507 - # We have the complete parameter name
508 - name_end = remaining.find(">")
509 - self.current_param_name = remaining[:name_end]
510 -
511 - # Find the parameter value
512 - value_start = param_start + name_end + 1
513 - value_text = tool_text[value_start:]
514 - if value_text.startswith("\n"):
515 - value_text = value_text[1:]
516 -
517 - # Find where this parameter ends
518 - param_end_idx = value_text.find(self.parameter_end_token)
519 - if param_end_idx != -1:
520 - # Complete parameter found
521 - param_value = value_text[:param_end_idx]
522 - if param_value.endswith("\n"):
523 - param_value = param_value[:-1]
524 -
525 - # Build complete JSON fragment for this parameter
526 - if self.param_count == 0:
527 - json_fragment = (
528 - '"'
529 - + self.current_param_name
530 - + '": "'
531 - + json.dumps(param_value, ensure_ascii=False)[1:-1]
532 - + '"'
533 - )
534 - else:
535 - json_fragment = (
536 - ', "'
537 - + self.current_param_name
538 - + '": "'
539 - + json.dumps(param_value, ensure_ascii=False)[1:-1]
540 - + '"'
541 - )
542 -
543 - self.param_count += 1
544 -
545 - return DeltaMessage(
546 - tool_calls=[
547 - DeltaToolCall(
548 - index=self.current_tool_index,
549 - function=DeltaFunctionCall(
550 - arguments=json_fragment
551 - ),
552 - )
553 - ]
554 - )
555 -
556 - # Continue parameter value
557 - if self.in_param:
558 - if self.parameter_end_token in delta_text:
559 - # End of parameter
560 - end_idx = delta_text.find(self.parameter_end_token)
561 - value_chunk = delta_text[:end_idx]
562 -
563 - # Skip past > if at start
564 - if not self.current_param_value and ">" in value_chunk:
565 - gt_idx = value_chunk.find(">")
566 - value_chunk = value_chunk[gt_idx + 1 :]
567 -
568 - if not self.current_param_value and value_chunk.startswith("\n"):
569 - value_chunk = value_chunk[1:]
570 -
571 - # Calculate incremental JSON
572 - full_value = self.current_param_value + value_chunk
573 - prev_escaped = (
574 - json.dumps(self.current_param_value, ensure_ascii=False)[1:-1]
575 - if self.current_param_value
576 - else ""
577 - )
578 - full_escaped = json.dumps(full_value, ensure_ascii=False)[1:-1]
579 - delta_escaped = full_escaped[len(prev_escaped) :]
580 -
581 - self.in_param = False
582 - self.current_param_value = ""
583 -
584 - return DeltaMessage(
585 - tool_calls=[
586 - DeltaToolCall(
587 - index=self.current_tool_index,
588 - function=DeltaFunctionCall(
589 - arguments=delta_escaped + '"'
590 - ),
591 - )
592 - ]
593 - )
594 - else:
595 - # Continue accumulating value
596 - value_chunk = delta_text
597 -
598 - # Handle first chunk after param name
599 - if not self.current_param_value and ">" in value_chunk:
600 - gt_idx = value_chunk.find(">")
601 - value_chunk = value_chunk[gt_idx + 1 :]
602 -
603 - if not self.current_param_value and value_chunk.startswith("\n"):
604 - value_chunk = value_chunk[1:]
605 -
606 - if value_chunk:
607 - # Stream the escaped delta
608 - prev_escaped = (
609 - json.dumps(self.current_param_value, ensure_ascii=False)[
610 - 1:-1
611 - ]
612 - if self.current_param_value
613 - else ""
614 - )
615 - self.current_param_value += value_chunk
616 - full_escaped = json.dumps(
617 - self.current_param_value, ensure_ascii=False
618 - )[1:-1]
619 - delta_escaped = full_escaped[len(prev_escaped) :]
620 -
621 - if delta_escaped:
622 - return DeltaMessage(
623 - tool_calls=[
624 - DeltaToolCall(
625 - index=self.current_tool_index,
626 - function=DeltaFunctionCall(
627 - arguments=delta_escaped
628 - ),
629 - )
630 - ]
631 - )
632 -
633 - return None