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,
+ )