[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