| 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 | TURN_START = "<seed:bos>" |
| 37 | TURN_END = "<seed:eos>" |
| 38 | |
| 39 | _THINK_START_ID = 50 |
| 40 | _THINK_END_ID = 51 |
| 41 | _TOOL_CALL_START_ID = 60 |
| 42 | _TOOL_CALL_END_ID = 61 |
| 43 | _SEED_BOS_ID = 70 |
| 44 | _SEED_EOS_ID = 71 |
| 45 | _TEXT_ID = 100 |
| 46 | |
| 47 | _SEED_OSS_VOCAB = { |
| 48 | THINK_START: _THINK_START_ID, |
| 49 | THINK_END: _THINK_END_ID, |
| 50 | TOOL_CALL_START: _TOOL_CALL_START_ID, |
| 51 | TOOL_CALL_END: _TOOL_CALL_END_ID, |
| 52 | TURN_START: _SEED_BOS_ID, |
| 53 | TURN_END: _SEED_EOS_ID, |
| 54 | } |
| 55 | |
| 56 | |
| 57 | @pytest.fixture |
| 58 | def mock_tokenizer(): |
| 59 | return make_mock_tokenizer(_SEED_OSS_VOCAB) |
| 60 | |
| 61 | |
| 62 | @pytest.fixture |
| 63 | def tool_parser(mock_tokenizer): |
| 64 | return SeedOssParser( |
| 65 | mock_tokenizer, chat_template_kwargs={"enable_thinking": False} |
| 66 | ) |
| 67 | |
| 68 | |
| 69 | @pytest.fixture |
| 70 | def parser(mock_tokenizer): |
| 71 | return SeedOssParser(mock_tokenizer) |
| 72 | |
| 73 | |
| 74 | def test_token_overrides_wired(parser): |
| 75 | assert parser.parser_engine_config.name == "seed_oss" |
| 76 | assert parser.reasoning_start_str == THINK_START |
| 77 | assert parser.reasoning_end_str == THINK_END |
| 78 | assert parser.parser_engine_config.turn_boundary_tokens == frozenset( |
| 79 | (TURN_START, TURN_END) |
| 80 | ) |
| 81 | |
| 82 | |
| 83 | class TestSeedTurnBoundaries: |
| 84 | """The Seed-OSS chat template wraps every message in ``<seed:bos>role`` / |
| 85 | ``<seed:eos>`` and replays prior-turn ``reasoning_content`` inside |
| 86 | ``<seed:think>...</seed:think>``, so a stale ``</seed:think>`` in history |
| 87 | is the normal multi-turn case. The backward walk must stop at the Seed |
| 88 | boundary tokens; the inherited ChatML tokens are absent from the Seed |
| 89 | vocab and would disable the scoping entirely. |
| 90 | """ |
| 91 | |
| 92 | def test_replayed_reasoning_in_history_not_end(self, parser): |
| 93 | assert not parser.is_reasoning_end( |
| 94 | [ |
| 95 | _SEED_BOS_ID, |
| 96 | _THINK_START_ID, |
| 97 | _TEXT_ID, |
| 98 | _THINK_END_ID, |
| 99 | _TEXT_ID, |
| 100 | _SEED_EOS_ID, |
| 101 | _SEED_BOS_ID, |
| 102 | ] |
| 103 | ) |
| 104 | |
| 105 | def test_think_end_in_current_turn_is_end(self, parser): |
| 106 | assert parser.is_reasoning_end( |
| 107 | [_SEED_BOS_ID, _TEXT_ID, _SEED_EOS_ID, _SEED_BOS_ID, _THINK_END_ID] |
| 108 | ) |
| 109 | |
| 110 | def test_boundary_with_thinking_disabled_is_end(self, mock_tokenizer): |
| 111 | parser = SeedOssParser( |
| 112 | mock_tokenizer, chat_template_kwargs={"enable_thinking": False} |
| 113 | ) |
| 114 | assert parser.is_reasoning_end([_SEED_BOS_ID, _TEXT_ID]) |
| 115 | |
| 116 | |
| 117 | def test_single_tool_call(tool_parser, mock_request): |
| 118 | text = ( |
| 119 | f"{TOOL_CALL_START}\n<function=get_weather>\n" |
| 120 | "<parameter=city>Tokyo</parameter>\n" |
| 121 | f"</function>\n{TOOL_CALL_END}" |
| 122 | ) |
| 123 | result = tool_parser.extract_tool_calls(text, mock_request) |
| 124 | |
| 125 | assert result.tools_called is True |
| 126 | assert result.tool_calls[0].function.name == "get_weather" |
| 127 | assert json.loads(result.tool_calls[0].function.arguments) == {"city": "Tokyo"} |
| 128 | |
| 129 | |
| 130 | def test_malformed_function_end_does_not_drop_siblings(tool_parser, mock_request): |
| 131 | """Regression for #46314: a malformed ``</function>`` with no closing ``>`` |
| 132 | on the header must not discard the other, well-formed calls.""" |
| 133 | text = ( |
| 134 | f"{TOOL_CALL_START}\n<function=broken</function>\n{TOOL_CALL_END}" |
| 135 | f"{TOOL_CALL_START}\n<function=get_weather>\n" |
| 136 | "<parameter=city>Tokyo</parameter>\n" |
| 137 | f"</function>\n{TOOL_CALL_END}" |
| 138 | ) |
| 139 | result = tool_parser.extract_tool_calls(text, mock_request) |
| 140 | |
| 141 | weather = next(tc for tc in result.tool_calls if tc.function.name == "get_weather") |
| 142 | assert json.loads(weather.function.arguments) == {"city": "Tokyo"} |
| 143 | |
| 144 | |
| 145 | def test_basic_streaming(tool_parser, mock_request): |
| 146 | chunks = [ |
| 147 | f"{TOOL_CALL_START}\n", |
| 148 | "<function=get_weather>\n", |
| 149 | "<parameter=city>Tokyo", |
| 150 | "</parameter>\n", |
| 151 | "</function>\n", |
| 152 | f"{TOOL_CALL_END}", |
| 153 | ] |
| 154 | results = simulate_tool_streaming(tool_parser, mock_request, chunks) |
| 155 | |
| 156 | assert collect_function_name(results) == "get_weather" |
| 157 | assert json.loads(collect_tool_arguments(results)) == {"city": "Tokyo"} |
| 158 | |
| 159 | |
| 160 | def test_reasoning_then_tool_call(parser): |
| 161 | text = ( |
| 162 | f"{THINK_START}I need to read the file.{THINK_END}" |
| 163 | f"{TOOL_CALL_START}\n<function=read>\n" |
| 164 | "<parameter=path>/tmp/x</parameter>\n" |
| 165 | f"</function>\n{TOOL_CALL_END}" |
| 166 | ) |
| 167 | reasoning, _ = parser.extract_reasoning(text, None) |
| 168 | assert reasoning == "I need to read the file." |
| 169 | assert TOOL_CALL_START not in reasoning |
| 170 | |
| 171 | |
| 172 | def test_streaming_think_end_and_tool_call_same_delta(parser): |
| 173 | """``</seed:think>`` and ``<seed:tool_call>`` arriving in one delta must |
| 174 | not leak the terminal tokens into the reasoning text.""" |
| 175 | reasoning, content = simulate_reasoning_streaming( |
| 176 | parser, |
| 177 | [ |
| 178 | "Let me list the directory.", |
| 179 | f"{THINK_END}{TOOL_CALL_START}", |
| 180 | "<function=read>", |
| 181 | ], |
| 182 | [(1,), (_THINK_END_ID, _TOOL_CALL_START_ID), (2,)], |
| 183 | ) |
| 184 | assert reasoning == "Let me list the directory." |
| 185 | assert THINK_END not in reasoning |
| 186 | assert TOOL_CALL_START not in reasoning |
| 187 | assert content is not None |
| 188 | |
| 189 | |
| 190 | def test_end_to_end_through_registered_adapters(mock_tokenizer, mock_request): |
| 191 | reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer) |
| 192 | tool_parser = SeedOssParserToolAdapter(mock_tokenizer) |
| 193 | text = ( |
| 194 | f"{THINK_START}Plan the call.{THINK_END}" |
| 195 | f"{TOOL_CALL_START}\n<function=get_weather>\n" |
| 196 | "<parameter=city>Tokyo</parameter>\n" |
| 197 | f"</function>\n{TOOL_CALL_END}" |
| 198 | ) |
| 199 | reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request) |
| 200 | assert reasoning == "Plan the call." |
| 201 | |
| 202 | tool_result = tool_parser.extract_tool_calls(remaining, mock_request) |
| 203 | assert tool_result.tool_calls[0].function.name == "get_weather" |
| 204 | assert json.loads(tool_result.tool_calls[0].function.arguments) == {"city": "Tokyo"} |
| 205 | |
| 206 | |
| 207 | def test_budget_reflect_tags_do_not_break_adapter_pipeline( |
| 208 | mock_tokenizer, |
| 209 | mock_request, |
| 210 | ): |
| 211 | reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer) |
| 212 | tool_parser = SeedOssParserToolAdapter(mock_tokenizer) |
| 213 | text = ( |
| 214 | f"{THINK_START}" |
| 215 | "The user's current thinking budget is 512.</seed:cot_budget_reflect>\n" |
| 216 | "I need the weather.\n" |
| 217 | "<seed:cot_budget_reflect>I have used 131 tokens." |
| 218 | "</seed:cot_budget_reflect>\n" |
| 219 | f"{THINK_END}" |
| 220 | f"{TOOL_CALL_START}\n<function=get_weather>\n" |
| 221 | "<parameter=city>Barcelona</parameter>\n" |
| 222 | f"</function>\n{TOOL_CALL_END}" |
| 223 | ) |
| 224 | |
| 225 | reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request) |
| 226 | assert reasoning is not None |
| 227 | assert "current thinking budget is 512" in reasoning |
| 228 | assert "<seed:cot_budget_reflect>" in reasoning |
| 229 | assert "</seed:cot_budget_reflect>" in reasoning |
| 230 | |
| 231 | tool_result = tool_parser.extract_tool_calls(remaining, mock_request) |
| 232 | assert tool_result.tool_calls[0].function.name == "get_weather" |
| 233 | assert json.loads(tool_result.tool_calls[0].function.arguments) == { |
| 234 | "city": "Barcelona" |
| 235 | } |