diff --git a/docs/features/reasoning_outputs.md b/docs/features/reasoning_outputs.md index 5c99eb20bc7d..d42b29286f6c 100644 --- a/docs/features/reasoning_outputs.md +++ b/docs/features/reasoning_outputs.md @@ -21,6 +21,7 @@ vLLM currently supports the following reasoning models: | [Holo2 series](https://huggingface.co/collections/Hcompany/holo2) | `holo2` | `json`, `regex` | ✅ | | [Hunyuan A13B series](https://huggingface.co/collections/tencent/hunyuan-a13b-685ec38e5b46321e3ea7c4be) | `hunyuan_a13b` | `json`, `regex` | ✅ | | [IBM Granite 3.2 language models](https://huggingface.co/collections/ibm-granite/granite-32-language-models-67b3bc8c13508f6d064cff9a) | `granite` | ❌ | ❌ | +| [Intern-S1](https://huggingface.co/internlm/Intern-S1) / [Intern-S1-mini](https://huggingface.co/internlm/Intern-S1-mini) | `intern-s1` | ❌ | ✅ | | [MiniMax-M2](https://huggingface.co/MiniMaxAI/MiniMax-M2) | `minimax_m2_append_think` | `json`, `regex` | ✅ | | [Qwen3 series](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) | `qwen3` | `json`, `regex` | ✅ | | [QwQ-32B](https://huggingface.co/Qwen/QwQ-32B) | `deepseek_r1` | `json`, `regex` | ✅ | @@ -75,6 +76,15 @@ Next, make a request to the model that should return the reasoning content in th The `reasoning` field contains the reasoning steps that led to the final conclusion, while the `content` field contains the final conclusion. +For Intern-S1 models that use both reasoning and tool calling, start the +server with both parsers enabled: + +```bash +vllm serve internlm/Intern-S1 \ + --tool-call-parser intern-s1 \ + --reasoning-parser intern-s1 +``` + ## Streaming chat completions Streaming chat completions are also supported for reasoning models. The `reasoning` field is available in the `delta` field in [chat completion response chunks](https://platform.openai.com/docs/api-reference/chat/streaming). diff --git a/docs/features/tool_calling.md b/docs/features/tool_calling.md index e9aa87a69647..3d0c56fbed53 100644 --- a/docs/features/tool_calling.md +++ b/docs/features/tool_calling.md @@ -276,6 +276,25 @@ Known issues: Recommended flags: `--tool-call-parser internlm --chat-template examples/tool_chat_template_internlm2_tool.jinja` +### Intern-S1 Models (`intern-s1`) + +Supported models: + +* `internlm/Intern-S1` +* `internlm/Intern-S1-mini` + +Recommended flags: `--tool-call-parser intern-s1 --reasoning-parser intern-s1` + +Notes: + +* Intern-S1 tool calls can appear inside `...`, so the + dedicated `intern-s1` reasoning parser should be used together with the + `intern-s1` tool parser for non-streaming tool calling. +* Use the model's default chat template instead of + `examples/tool_chat_template_internlm2_tool.jinja`. +* This keeps Intern-S1 handling separate from the existing `internlm` parser + and avoids changing tool-calling behavior for other InternLM-family models. + ### Jamba Models (`jamba`) AI21's Jamba-1.5 models are supported. diff --git a/tests/reasoning/test_intern_s1_reasoning_parser.py b/tests/reasoning/test_intern_s1_reasoning_parser.py new file mode 100644 index 000000000000..a71e1abe525f --- /dev/null +++ b/tests/reasoning/test_intern_s1_reasoning_parser.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from unittest.mock import MagicMock + +import pytest + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ChatCompletionToolsParam, +) +from vllm.entrypoints.openai.engine.protocol import FunctionDefinition +from vllm.reasoning import ReasoningParserManager +from vllm.tool_parsers import ToolParserManager + +pytestmark = pytest.mark.skip_global_cleanup + + +def _build_tokenizer(): + tokenizer = MagicMock() + tokenizer.get_vocab.return_value = { + "": 1, + "": 2, + "<|action_start|>": 3, + "<|plugin|>": 4, + "<|action_end|>": 5, + } + return tokenizer + + +def _build_tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + function=FunctionDefinition( + name="get_weather", + parameters={"type": "object", "properties": {}}, + ) + ) + ] + + +def test_intern_s1_reasoning_parser_registered(): + parser_cls = ReasoningParserManager.get_reasoning_parser("intern-s1") + assert parser_cls.__name__ == "InternS1ReasoningParser" + + +def test_intern_s1_reasoning_parser_hoists_action_to_content(): + tokenizer = _build_tokenizer() + parser = ReasoningParserManager.get_reasoning_parser("intern-s1")(tokenizer) + request = ChatCompletionRequest(messages=[], model="test-model") + + reasoning, content = parser.extract_reasoning( + "Need tool first.\n" + '<|action_start|> <|plugin|>\n{"name": "get_weather", ' + '"parameters": {"city": "Tokyo"}}\n<|action_end|>\n' + "Then continue reasoning.Visible answer.", + request, + ) + + assert reasoning is not None + assert "Need tool first." in reasoning + assert "Then continue reasoning." in reasoning + assert "<|action_start|>" not in reasoning + assert content is not None + assert content.startswith("<|action_start|> <|plugin|>") + assert content.endswith("Visible answer.") + + +def test_intern_s1_reasoning_parser_keeps_deepseek_r1_behavior_without_action(): + tokenizer = _build_tokenizer() + parser = ReasoningParserManager.get_reasoning_parser("intern-s1")(tokenizer) + request = ChatCompletionRequest(messages=[], model="test-model") + + reasoning, content = parser.extract_reasoning( + "Need to think first.Visible answer.", + request, + ) + + assert reasoning == "Need to think first." + assert content == "Visible answer." + + +@pytest.mark.parametrize( + "thinking_disabled_request", + [ + ChatCompletionRequest( + messages=[], + model="test-model", + chat_template_kwargs={"enable_thinking": False}, + ), + ChatCompletionRequest( + messages=[], + model="test-model", + enable_thinking=False, + ), + ], +) +def test_intern_s1_reasoning_parser_returns_raw_output_when_thinking_disabled_without_end_token( # noqa: E501 + thinking_disabled_request, +): + tokenizer = _build_tokenizer() + parser = ReasoningParserManager.get_reasoning_parser("intern-s1")(tokenizer) + + reasoning, content = parser.extract_reasoning( + "plain output without end token", + thinking_disabled_request, + ) + + assert reasoning is None + assert content == "plain output without end token" + + +@pytest.mark.parametrize( + "thinking_disabled_request", + [ + ChatCompletionRequest( + messages=[], + model="test-model", + chat_template_kwargs={"enable_thinking": False}, + ), + ChatCompletionRequest( + messages=[], + model="test-model", + enable_thinking=False, + ), + ], +) +def test_intern_s1_reasoning_parser_splits_reasoning_and_content_when_thinking_disabled_without_action( # noqa: E501 + thinking_disabled_request, +): + tokenizer = _build_tokenizer() + parser = ReasoningParserManager.get_reasoning_parser("intern-s1")(tokenizer) + + reasoning, content = parser.extract_reasoning( + "hidden reasoningvisible content", + thinking_disabled_request, + ) + + assert reasoning == "hidden reasoning" + assert content == "visible content" + + +@pytest.mark.parametrize( + "thinking_disabled_request", + [ + ChatCompletionRequest( + messages=[], + model="test-model", + chat_template_kwargs={"enable_thinking": False}, + ), + ChatCompletionRequest( + messages=[], + model="test-model", + enable_thinking=False, + ), + ], +) +def test_intern_s1_reasoning_parser_hoists_only_action_when_thinking_disabled( + thinking_disabled_request, +): + tokenizer = _build_tokenizer() + parser = ReasoningParserManager.get_reasoning_parser("intern-s1")(tokenizer) + + reasoning, content = parser.extract_reasoning( + "hidden reasoning\n" + '<|action_start|> <|plugin|>\n{"name": "get_weather", ' + '"parameters": {"city": "Tokyo"}}\n<|action_end|>\n' + "then more reasoning", + thinking_disabled_request, + ) + + assert reasoning is not None + assert "hidden reasoning" in reasoning + assert "then more reasoning" in reasoning + assert "<|action_start|>" not in reasoning + assert content is not None + assert content.startswith("<|action_start|> <|plugin|>") + assert content.endswith("<|action_end|>") + + +def test_intern_s1_reasoning_and_tool_parser_work_together(): + tokenizer = _build_tokenizer() + tools = _build_tools() + request = ChatCompletionRequest( + messages=[], + model="test-model", + tools=tools, + tool_choice="auto", + ) + + reasoning_parser = ReasoningParserManager.get_reasoning_parser("intern-s1")( + tokenizer + ) + tool_parser = ToolParserManager.get_tool_parser("intern-s1")(tokenizer, tools) + + reasoning, intermediate_content = reasoning_parser.extract_reasoning( + "Need tool first.\n" + '<|action_start|> <|plugin|>\n{"name": "get_weather", ' + '"parameters": {"city": "Tokyo"}}\n<|action_end|>\n' + "Then continue reasoning.Visible answer.", + request, + ) + extracted = tool_parser.extract_tool_calls(intermediate_content or "", request) + + assert reasoning is not None + assert extracted.tools_called is True + assert len(extracted.tool_calls) == 1 + assert extracted.tool_calls[0].function.name == "get_weather" + assert extracted.content == "Visible answer." diff --git a/tests/tool_parsers/test_intern_s1_tool_parser.py b/tests/tool_parsers/test_intern_s1_tool_parser.py new file mode 100644 index 000000000000..680c4cd90b80 --- /dev/null +++ b/tests/tool_parsers/test_intern_s1_tool_parser.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +from unittest.mock import MagicMock + +import pytest + +from tests.tool_parsers.utils import run_tool_extraction +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ChatCompletionToolsParam, +) +from vllm.entrypoints.openai.engine.protocol import DeltaMessage, FunctionDefinition +from vllm.tokenizers import TokenizerLike +from vllm.tool_parsers import ToolParserManager +from vllm.tool_parsers.intern_s1_tool_parser import _build_initial_arguments_delta + +pytestmark = pytest.mark.skip_global_cleanup + + +@pytest.fixture +def tokenizer() -> TokenizerLike: + tokenizer = MagicMock() + tokenizer.get_vocab.return_value = { + "<|action_start|>": 92540, + "<|plugin|>": 92541, + "<|action_end|>": 92542, + "": 92543, + "": 92544, + } + return tokenizer + + +@pytest.fixture +def tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + function=FunctionDefinition( + name="get_weather", + parameters={"type": "object", "properties": {}}, + ) + ), + ChatCompletionToolsParam( + function=FunctionDefinition( + name="get_time", + parameters={"type": "object", "properties": {}}, + ) + ), + ] + + +@pytest.fixture +def chat_request(tools: list[ChatCompletionToolsParam]) -> ChatCompletionRequest: + return ChatCompletionRequest( + messages=[], + model="test-model", + tools=tools, + tool_choice="auto", + ) + + +@pytest.fixture +def tool_parser( + tokenizer: TokenizerLike, + tools: list[ChatCompletionToolsParam], +): + return ToolParserManager.get_tool_parser("intern-s1")(tokenizer, tools) + + +def test_intern_s1_tool_parser_registered(): + parser_cls = ToolParserManager.get_tool_parser("intern-s1") + assert parser_cls.__name__ == "InternS1ToolParser" + + +def test_nonstreaming_supports_spaced_special_tokens(tool_parser, chat_request): + content, tool_calls = run_tool_extraction( + tool_parser, + '<|action_start|> <|plugin|>\n{"name": "get_weather", ' + '"parameters": {"city": "Tokyo"}}\n<|action_end|>', + request=chat_request, + streaming=False, + ) + + assert content is None + assert len(tool_calls) == 1 + assert tool_calls[0].function.name == "get_weather" + assert json.loads(tool_calls[0].function.arguments) == {"city": "Tokyo"} + + +def test_nonstreaming_supports_multiple_action_blocks(tool_parser, chat_request): + content, tool_calls = run_tool_extraction( + tool_parser, + '<|action_start|><|plugin|>{"name": "get_weather", ' + '"parameters": {"city": "Tokyo"}}<|action_end|>\n' + '<|action_start|><|plugin|>{"name": "get_time", ' + '"parameters": {"timezone": "Asia/Tokyo"}}<|action_end|>\n' + "Visible answer.", + request=chat_request, + streaming=False, + ) + + assert content is not None + assert content.strip() == "Visible answer." + assert "<|action_start|>" not in content + assert [tool.function.name for tool in tool_calls] == [ + "get_weather", + "get_time", + ] + + +def test_nonstreaming_gracefully_handles_malformed_json( + tool_parser, + chat_request, +): + content, tool_calls = run_tool_extraction( + tool_parser, + '<|action_start|><|plugin|>{"name": "get_weather", "parameters": {' + "<|action_end|>", + request=chat_request, + streaming=False, + ) + + assert content is not None + assert "<|action_start|>" in content + assert tool_calls == [] + + +def test_build_initial_arguments_delta_falls_back_to_full_arguments(): + arguments_json = '{"city": "Tokyo"}' + + assert ( + _build_initial_arguments_delta(arguments_json, "<|plugin|>") == arguments_json + ) + + +def test_streaming_continues_emitting_arguments_after_tool_name( + tool_parser, + chat_request, +): + first_chunk = '<|action_start|><|plugin|>{"name":"get_weather"' + second_delta = ', "parameters": {"city": "Tokyo"}}<|action_end|>' + second_chunk = first_chunk + second_delta + + first_message = tool_parser.extract_tool_calls_streaming( + previous_text="", + current_text=first_chunk, + delta_text=first_chunk, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[], + request=chat_request, + ) + assert isinstance(first_message, DeltaMessage) + assert first_message.tool_calls is not None + assert first_message.tool_calls[0].function.name == "get_weather" + assert first_message.tool_calls[0].index == 0 + + second_message = tool_parser.extract_tool_calls_streaming( + previous_text=first_chunk, + current_text=second_chunk, + delta_text=second_delta, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[], + request=chat_request, + ) + assert isinstance(second_message, DeltaMessage) + assert second_message.tool_calls is not None + assert second_message.tool_calls[0].function.arguments == '{"city": "Tokyo"}' diff --git a/vllm/reasoning/__init__.py b/vllm/reasoning/__init__.py index 2d57b93369d7..fb197c83f53c 100644 --- a/vllm/reasoning/__init__.py +++ b/vllm/reasoning/__init__.py @@ -24,6 +24,10 @@ "deepseek_r1_reasoning_parser", # filename "DeepSeekR1ReasoningParser", # class_name ), + "intern-s1": ( + "intern_s1_reasoning_parser", + "InternS1ReasoningParser", + ), "deepseek_v3": ( "deepseek_v3_reasoning_parser", "DeepSeekV3ReasoningParser", diff --git a/vllm/reasoning/intern_s1_reasoning_parser.py b/vllm/reasoning/intern_s1_reasoning_parser.py new file mode 100644 index 000000000000..5c3e17d47dd1 --- /dev/null +++ b/vllm/reasoning/intern_s1_reasoning_parser.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import TYPE_CHECKING + +import regex as re + +from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser + +if TYPE_CHECKING: + from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest + from vllm.entrypoints.openai.responses.protocol import ResponsesRequest + +_ACTION_START = "<|action_start|>" +_PLUGIN = "<|plugin|>" +_ACTION_END = "<|action_end|>" + +_ACTION_BLOCK_RE = re.compile( + rf"{re.escape(_ACTION_START)}\s*{re.escape(_PLUGIN)}\s*.*?\s*" + rf"{re.escape(_ACTION_END)}", + re.DOTALL, +) + + +def _trim_newlines(text: str) -> str: + if text.startswith("\n"): + text = text[1:] + if text.endswith("\n"): + text = text[:-1] + return text + + +def _thinking_disabled( + request: "ChatCompletionRequest | ResponsesRequest", +) -> bool: + chat_template_kwargs = getattr(request, "chat_template_kwargs", None) + if ( + isinstance(chat_template_kwargs, dict) + and chat_template_kwargs.get("enable_thinking") is False + ): + return True + + # OpenAIBaseModel still allows extra top-level fields, so keep a defensive + # fallback for callers that pass enable_thinking directly. + return getattr(request, "enable_thinking", None) is False + + +class InternS1ReasoningParser(DeepSeekR1ReasoningParser): + def extract_reasoning( + self, + model_output: str, + request: "ChatCompletionRequest | ResponsesRequest", + ) -> tuple[str | None, str | None]: + thinking_disabled = _thinking_disabled(request) + start_token = self.start_token + end_token = self.end_token + + prefix = "" + body = model_output + if start_token in model_output: + prefix, _, body = model_output.partition(start_token) + + if end_token not in body: + if thinking_disabled: + return None, model_output or None + return super().extract_reasoning(model_output, request) + + reasoning, _, suffix = body.partition(end_token) + action_blocks = list(_ACTION_BLOCK_RE.finditer(reasoning)) + if not action_blocks: + if thinking_disabled: + reasoning_text = _trim_newlines(reasoning) + visible_content = f"{prefix}{suffix}" + return reasoning_text or None, visible_content or None + return super().extract_reasoning(model_output, request) + + reasoning_parts: list[str] = [] + hoisted_actions: list[str] = [] + cursor = 0 + for match in action_blocks: + reasoning_parts.append(reasoning[cursor : match.start()]) + hoisted_actions.append(match.group(0)) + cursor = match.end() + reasoning_parts.append(reasoning[cursor:]) + + reasoning_text = _trim_newlines("".join(reasoning_parts)) + content = f"{prefix}{''.join(hoisted_actions)}{suffix}" + + if thinking_disabled: + return ( + reasoning_text or None, + content if content else reasoning_text or None, + ) + + return ( + reasoning_text or None, + content if content else None, + ) diff --git a/vllm/tool_parsers/__init__.py b/vllm/tool_parsers/__init__.py index bffa00c4ef31..3512cb38cb25 100644 --- a/vllm/tool_parsers/__init__.py +++ b/vllm/tool_parsers/__init__.py @@ -70,6 +70,10 @@ "internlm2_tool_parser", "Internlm2ToolParser", ), + "intern-s1": ( + "intern_s1_tool_parser", + "InternS1ToolParser", + ), "jamba": ( "jamba_tool_parser", "JambaToolParser", diff --git a/vllm/tool_parsers/intern_s1_tool_parser.py b/vllm/tool_parsers/intern_s1_tool_parser.py new file mode 100644 index 000000000000..6a3f68b6c759 --- /dev/null +++ b/vllm/tool_parsers/intern_s1_tool_parser.py @@ -0,0 +1,300 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +from collections.abc import Sequence + +import partial_json_parser +import regex as re +from openai.types.responses import FunctionTool +from partial_json_parser.core.options import Allow + +from vllm.entrypoints.chat_utils import make_tool_call_id +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, +) +from vllm.entrypoints.openai.engine.protocol import ( + DeltaFunctionCall, + DeltaMessage, + DeltaToolCall, + ExtractedToolCallInformation, + FunctionCall, + ToolCall, +) +from vllm.logger import init_logger +from vllm.tool_parsers.internlm2_tool_parser import Internlm2ToolParser +from vllm.tool_parsers.utils import extract_intermediate_diff + +logger = init_logger(__name__) + +_ACTION_START = "<|action_start|>" +_PLUGIN = "<|plugin|>" +_ACTION_END = "<|action_end|>" + +_ACTION_PREFIX_RE = re.compile( + rf"{re.escape(_ACTION_START)}\s*{re.escape(_PLUGIN)}\s*", + re.DOTALL, +) +_ACTION_BLOCK_RE = re.compile( + rf"{re.escape(_ACTION_START)}\s*{re.escape(_PLUGIN)}\s*(.*?)\s*" + rf"{re.escape(_ACTION_END)}", + re.DOTALL, +) + + +def _find_action_prefix(text: str, start: int = 0) -> re.Match[str] | None: + return _ACTION_PREFIX_RE.search(text, start) + + +def _extract_json_payload(text: str) -> str | None: + json_start = text.find("{") + if json_start == -1: + return None + return text[json_start:].strip() + + +def _build_initial_arguments_delta( + current_arguments_json: str, + delta_text: str, +) -> str: + try: + end_index = current_arguments_json.index(delta_text) + len(delta_text) + return current_arguments_json[:end_index] + except ValueError: + return current_arguments_json + + +class InternS1ToolParser(Internlm2ToolParser): + def extract_tool_calls_streaming( + self, + previous_text: str, + current_text: str, + delta_text: str, + previous_token_ids: Sequence[int], + current_token_ids: Sequence[int], + delta_token_ids: Sequence[int], + request: ChatCompletionRequest, + ) -> DeltaMessage | None: + if _ACTION_START not in current_text: + self.position = len(current_text) + return DeltaMessage(content=delta_text) + + if self.current_tool_id > 0: + return DeltaMessage(content="") + + last_pos = self.position + action_prefix = _find_action_prefix(current_text, last_pos) + if action_prefix is None: + return None + + text = current_text[last_pos : action_prefix.start()] + if text: + self.position = action_prefix.start() + return DeltaMessage(content=text) + + action = current_text[action_prefix.end() :] + action_end_idx = action.find(_ACTION_END) + if action_end_idx != -1: + action = action[:action_end_idx] + payload = _extract_json_payload(action) + if payload is None: + return None + + flags = Allow.ALL if self.current_tool_name_sent else Allow.ALL & ~Allow.STR + + try: + try: + tool_call_arr: dict = partial_json_parser.loads(payload, flags) + except partial_json_parser.core.exceptions.MalformedJSON: + logger.debug("not enough tokens to parse into JSON yet") + return None + + if not self.current_tool_name_sent: + function_name = tool_call_arr.get("name") + if function_name: + self.current_tool_id += 1 + delta = DeltaMessage( + tool_calls=[ + DeltaToolCall( + index=self.current_tool_id, + type="function", + id=make_tool_call_id(), + function=DeltaFunctionCall( + name=function_name + ).model_dump(exclude_none=True), + ) + ] + ) + self.current_tool_name_sent = True + self.streamed_args_for_tool.append("") + else: + delta = None + else: + prev_arguments = self.get_arguments( + self.prev_tool_call_arr[self.current_tool_id] + ) + cur_arguments = self.get_arguments(tool_call_arr) + + if not cur_arguments and not prev_arguments: + delta = None + elif not cur_arguments and prev_arguments: + logger.error( + "INVARIANT - impossible to have arguments reset mid-arguments" + ) + delta = None + elif cur_arguments and not prev_arguments: + cur_arguments_json = json.dumps(cur_arguments, ensure_ascii=False) + arguments_delta = _build_initial_arguments_delta( + cur_arguments_json, + delta_text, + ) + delta = DeltaMessage( + tool_calls=[ + DeltaToolCall( + index=self.current_tool_id, + function=DeltaFunctionCall( + arguments=arguments_delta + ).model_dump(exclude_none=True), + ) + ] + ) + self.streamed_args_for_tool[self.current_tool_id] += arguments_delta + else: + cur_args_json = json.dumps(cur_arguments, ensure_ascii=False) + prev_args_json = json.dumps(prev_arguments, ensure_ascii=False) + argument_diff = extract_intermediate_diff( + cur_args_json, + prev_args_json, + ) + delta = DeltaMessage( + tool_calls=[ + DeltaToolCall( + index=self.current_tool_id, + function=DeltaFunctionCall( + arguments=argument_diff + ).model_dump(exclude_none=True), + ) + ] + ) + self.streamed_args_for_tool[self.current_tool_id] += argument_diff + + tool_call_arr["arguments"] = self.get_arguments(tool_call_arr) + self.prev_tool_call_arr = [tool_call_arr] + return delta + except Exception: + logger.exception("Error trying to handle streaming tool call.") + logger.debug( + "Skipping chunk as a result of tool streaming extraction error" + ) + return None + + def extract_tool_calls( + self, + model_output: str, + request: ChatCompletionRequest, + ) -> ExtractedToolCallInformation: + action_blocks = list(_ACTION_BLOCK_RE.finditer(model_output)) + if not action_blocks: + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + configured_tools = self.tools or list(request.tools or []) + allowed_tools = { + tool.name if isinstance(tool, FunctionTool) else tool.function.name + for tool in configured_tools + } + if not allowed_tools: + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + tool_calls: list[ToolCall] = [] + content_parts: list[str] = [] + cursor = 0 + + for match in action_blocks: + content_parts.append(model_output[cursor : match.start()]) + cursor = match.end() + + payload = _extract_json_payload(match.group(1)) + if payload is None: + logger.warning("Intern-S1 action block missing JSON payload.") + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + try: + action_dict = json.loads(payload) + except json.JSONDecodeError: + logger.warning("Failed to parse Intern-S1 tool call JSON: %r", payload) + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + name = action_dict.get("name") + if not isinstance(name, str) or not name: + logger.warning( + "Intern-S1 action block missing a valid tool name: %r", + action_dict, + ) + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + if name not in allowed_tools: + logger.warning( + "Model requested tool %r which is not in the provided tools %r.", + name, + sorted(allowed_tools), + ) + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + arguments_obj = self.get_arguments(action_dict) + if arguments_obj is None: + arguments_obj = {} + elif not isinstance(arguments_obj, dict): + logger.warning( + "Intern-S1 tool arguments must be an object, got %s for %r.", + type(arguments_obj).__name__, + name, + ) + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + tool_calls.append( + ToolCall( + function=FunctionCall( + name=name, + arguments=json.dumps(arguments_obj, ensure_ascii=False), + ) + ) + ) + + content_parts.append(model_output[cursor:]) + remaining_content: str | None = "".join(content_parts) + if remaining_content is not None and remaining_content.strip() == "": + remaining_content = None + + return ExtractedToolCallInformation( + tools_called=True, + tool_calls=tool_calls, + content=remaining_content, + )