[Model] Add K2-Horizon model support (#55063)
This commit is contained in:
@@ -414,6 +414,7 @@ th {
|
||||
| `IQuestLoopCoderForCausalLM` | IQuestLoopCoderV1 | `IQuestLab/IQuest-Coder-V1-40B-Loop-Instruct`, etc. | | |
|
||||
| `Jais2ForCausalLM` | Jais2 | `inceptionai/Jais-2-8B-Chat`, `inceptionai/Jais-2-70B-Chat`, etc. | | ✅︎ |
|
||||
| `JambaForCausalLM` | Jamba | `ai21labs/AI21-Jamba-1.5-Large`, `ai21labs/AI21-Jamba-1.5-Mini`, `ai21labs/Jamba-v0.1`, etc. | ✅︎ | ✅︎ |
|
||||
| `K2HorizonForCausalLM` | K2-Horizon | `IFM/K2-Horizon-7B`, `IFM/K2-Horizon-375B` | | ✅︎ |
|
||||
| `KimiLinearForCausalLM` | Kimi-Linear-48B-A3B-Base, Kimi-Linear-48B-A3B-Instruct | `moonshotai/Kimi-Linear-48B-A3B-Base`, `moonshotai/Kimi-Linear-48B-A3B-Instruct` | | ✅︎ |
|
||||
| `Lfm2ForCausalLM` | LFM2 | `LiquidAI/LFM2-1.2B`, `LiquidAI/LFM2-700M`, `LiquidAI/LFM2-350M`, etc. | ✅︎ | ✅︎ |
|
||||
| `Lfm2MoeForCausalLM` | LFM2MoE | `LiquidAI/LFM2-8B-A1B-preview`, etc. | ✅︎ | ✅︎ |
|
||||
|
||||
@@ -364,6 +364,11 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
|
||||
"random": "ai21labs/Jamba-tiny-random",
|
||||
},
|
||||
),
|
||||
"K2HorizonForCausalLM": _HfExamplesInfo(
|
||||
"IFM/K2-Horizon-36B",
|
||||
trust_remote_code=True,
|
||||
is_available_online=False,
|
||||
),
|
||||
"KimiLinearForCausalLM": _HfExamplesInfo(
|
||||
"moonshotai/Kimi-Linear-48B-A3B-Instruct", trust_remote_code=True
|
||||
),
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import string
|
||||
from collections.abc import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
||||
from vllm.parser.parser_manager import ParserManager
|
||||
from vllm.reasoning import ReasoningParserManager
|
||||
from vllm.reasoning.k2_horizon_reasoning_parser import K2HorizonReasoningParser
|
||||
|
||||
pytestmark = pytest.mark.skip_global_cleanup
|
||||
|
||||
EFFORT_TOKENS = {
|
||||
"high": ("<ifm|think>", "</ifm|think>"),
|
||||
"medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
|
||||
"low": ("<ifm|think_faster>", "</ifm|think_faster>"),
|
||||
}
|
||||
TOOL_CALLS_START = "<ifm|tool_calls>"
|
||||
|
||||
|
||||
class K2Tokenizer:
|
||||
def __init__(self) -> None:
|
||||
special_tokens = [
|
||||
token for token_pair in EFFORT_TOKENS.values() for token in token_pair
|
||||
] + [TOOL_CALLS_START]
|
||||
tokens = special_tokens + list(string.printable)
|
||||
self._vocab = {token: index for index, token in enumerate(tokens, start=1)}
|
||||
|
||||
def get_vocab(self) -> dict[str, int]:
|
||||
return dict(self._vocab)
|
||||
|
||||
def encode(self, text: str) -> list[int]:
|
||||
tokens: list[int] = []
|
||||
position = 0
|
||||
specials = sorted(EFFORT_TOKENS["high"] + (TOOL_CALLS_START,), key=len)
|
||||
while position < len(text):
|
||||
for special in specials:
|
||||
if text.startswith(special, position):
|
||||
tokens.append(self._vocab[special])
|
||||
position += len(special)
|
||||
break
|
||||
else:
|
||||
tokens.append(self._vocab[text[position]])
|
||||
position += 1
|
||||
return tokens
|
||||
|
||||
def decode(
|
||||
self,
|
||||
ids: Sequence[int] | int,
|
||||
skip_special_tokens: bool = False,
|
||||
) -> str:
|
||||
del skip_special_tokens
|
||||
if isinstance(ids, int):
|
||||
ids = [ids]
|
||||
inverse_vocab = {token_id: token for token, token_id in self._vocab.items()}
|
||||
return "".join(inverse_vocab[token_id] for token_id in ids)
|
||||
|
||||
|
||||
def _request(**kwargs) -> ChatCompletionRequest:
|
||||
return ChatCompletionRequest(
|
||||
model="k2-horizon",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["high", "medium", "low"])
|
||||
def test_reasoning_effort_selects_tokens(effort: str):
|
||||
parser = K2HorizonReasoningParser(
|
||||
K2Tokenizer(),
|
||||
chat_template_kwargs={"reasoning_effort": effort},
|
||||
)
|
||||
|
||||
assert (parser.start_token, parser.end_token) == EFFORT_TOKENS[effort]
|
||||
|
||||
|
||||
def test_reasoning_effort_precedence_and_default():
|
||||
tokenizer = K2Tokenizer()
|
||||
request = _request(
|
||||
reasoning_effort="medium",
|
||||
chat_template_kwargs={"reasoning_effort": "low"},
|
||||
)
|
||||
resolved_kwargs = (
|
||||
request.build_chat_params(None, "auto")
|
||||
.with_defaults({"reasoning_effort": "high"})
|
||||
.chat_template_kwargs
|
||||
)
|
||||
|
||||
parser = K2HorizonReasoningParser(
|
||||
tokenizer,
|
||||
chat_template_kwargs=resolved_kwargs,
|
||||
)
|
||||
default_parser = K2HorizonReasoningParser(tokenizer)
|
||||
|
||||
assert parser.start_token == EFFORT_TOKENS["medium"][0]
|
||||
assert default_parser.start_token == EFFORT_TOKENS["high"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["none", "unknown", None])
|
||||
def test_invalid_reasoning_effort_raises(effort: str | None):
|
||||
with pytest.raises(ValueError, match="Unsupported reasoning_effort"):
|
||||
K2HorizonReasoningParser(
|
||||
K2Tokenizer(),
|
||||
chat_template_kwargs={"reasoning_effort": effort},
|
||||
)
|
||||
|
||||
|
||||
def test_non_streaming_explicit_and_implicit_boundaries():
|
||||
parser = K2HorizonReasoningParser(K2Tokenizer())
|
||||
request = _request()
|
||||
|
||||
assert parser.extract_reasoning("<ifm|think>plan</ifm|think>answer", request) == (
|
||||
"plan",
|
||||
"answer",
|
||||
)
|
||||
assert parser.extract_reasoning(
|
||||
f"plan{TOOL_CALLS_START}<ifm|tool_call>ping</ifm|tool_call>", request
|
||||
) == (
|
||||
"plan",
|
||||
f"{TOOL_CALLS_START}<ifm|tool_call>ping</ifm|tool_call>",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reasoning", ["plan", " ", "\n", " \n"])
|
||||
def test_character_split_reasoning_stream(reasoning: str):
|
||||
parser = K2HorizonReasoningParser(K2Tokenizer())
|
||||
reasoning_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
|
||||
for char in f"{reasoning}</ifm|think>answer":
|
||||
delta = parser.extract_reasoning_streaming(
|
||||
previous_text="",
|
||||
current_text="",
|
||||
delta_text=char,
|
||||
previous_token_ids=[],
|
||||
current_token_ids=[],
|
||||
delta_token_ids=[],
|
||||
)
|
||||
if delta is not None:
|
||||
if delta.reasoning is not None:
|
||||
reasoning_parts.append(delta.reasoning)
|
||||
if delta.content is not None:
|
||||
content_parts.append(delta.content)
|
||||
|
||||
assert "".join(reasoning_parts) == reasoning
|
||||
assert "".join(content_parts) == "answer"
|
||||
|
||||
|
||||
def test_latest_boundary_controls_reasoning_state():
|
||||
tokenizer = K2Tokenizer()
|
||||
parser = K2HorizonReasoningParser(tokenizer)
|
||||
vocab = tokenizer.get_vocab()
|
||||
|
||||
assert parser.is_reasoning_end([vocab["<ifm|think>"], vocab[TOOL_CALLS_START]])
|
||||
assert not parser.is_reasoning_end([vocab[TOOL_CALLS_START], vocab["<ifm|think>"]])
|
||||
|
||||
|
||||
def test_composed_streaming_reasoning_to_tool_handoff():
|
||||
tokenizer = K2Tokenizer()
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ping",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
request = _request(tools=tools, tool_choice="auto")
|
||||
parser_cls = ParserManager.get_parser(
|
||||
tool_parser_name="k2_horizon",
|
||||
reasoning_parser_name="k2_horizon",
|
||||
enable_auto_tools=True,
|
||||
)
|
||||
assert parser_cls is not None
|
||||
parser = parser_cls(
|
||||
tokenizer,
|
||||
request.tools,
|
||||
chat_template_kwargs={"reasoning_effort": "high"},
|
||||
)
|
||||
output = "plan<ifm|tool_calls><ifm|tool_call>ping</ifm|tool_call></ifm|tool_calls>"
|
||||
reasoning_parts: list[str] = []
|
||||
tool_names: list[str] = []
|
||||
|
||||
for index, char in enumerate(output):
|
||||
delta = parser.parse_delta(
|
||||
delta_text=char,
|
||||
delta_token_ids=tokenizer.encode(char),
|
||||
request=request,
|
||||
prompt_token_ids=[] if index == 0 else None,
|
||||
finished=index == len(output) - 1,
|
||||
)
|
||||
if delta is not None:
|
||||
if delta.reasoning is not None:
|
||||
reasoning_parts.append(delta.reasoning)
|
||||
tool_names.extend(
|
||||
tool_call.function.name
|
||||
for tool_call in delta.tool_calls
|
||||
if tool_call.function is not None
|
||||
and tool_call.function.name is not None
|
||||
)
|
||||
|
||||
assert "".join(reasoning_parts) == "plan"
|
||||
assert tool_names == ["ping"]
|
||||
|
||||
|
||||
def test_reasoning_parser_registered():
|
||||
assert (
|
||||
ReasoningParserManager.get_reasoning_parser("k2_horizon")
|
||||
is K2HorizonReasoningParser
|
||||
)
|
||||
@@ -0,0 +1,318 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.generate.base.protocol import DeltaMessage
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
||||
from vllm.parser.parser_manager import ParserManager
|
||||
from vllm.tool_parsers import ToolParserManager
|
||||
from vllm.tool_parsers.k2_horizon_tool_parser import K2HorizonToolParser
|
||||
|
||||
pytestmark = pytest.mark.skip_global_cleanup
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
"enabled": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ping",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tokenizer():
|
||||
tokenizer = MagicMock()
|
||||
tokenizer.get_vocab.return_value = {}
|
||||
return tokenizer
|
||||
|
||||
|
||||
def _request(
|
||||
tool_format: str | None = None,
|
||||
tool_choice="auto",
|
||||
) -> ChatCompletionRequest:
|
||||
chat_template_kwargs = (
|
||||
{"tool_call_format": tool_format} if tool_format is not None else None
|
||||
)
|
||||
return ChatCompletionRequest(
|
||||
model="k2-horizon",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
tools=TOOLS,
|
||||
tool_choice=tool_choice,
|
||||
chat_template_kwargs=chat_template_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _group(*calls: str, prefix: str = "", suffix: str = "") -> str:
|
||||
return prefix + "<ifm|tool_calls>" + "\n".join(calls) + "</ifm|tool_calls>" + suffix
|
||||
|
||||
|
||||
def _json_call(name: str, arguments: dict) -> str:
|
||||
payload = json.dumps({"name": name, "arguments": arguments})
|
||||
return f"<ifm|tool_call>{payload}</ifm|tool_call>"
|
||||
|
||||
|
||||
def _xml_call(
|
||||
name: str,
|
||||
arguments: list[tuple[str, str]],
|
||||
*,
|
||||
typed: bool = False,
|
||||
) -> str:
|
||||
parts = [f"<ifm|tool_call>{name}"]
|
||||
for key, value in arguments:
|
||||
parts.append(f"<ifm|arg_key>{key}</ifm|arg_key>")
|
||||
if typed:
|
||||
arg_type = "integer" if key == "limit" else "string"
|
||||
parts.append(f"<ifm|arg_type>{arg_type}</ifm|arg_type>")
|
||||
parts.append(f"<ifm|arg_value>{value}</ifm|arg_value>")
|
||||
parts.append("</ifm|tool_call>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _collect_stream(
|
||||
parser: K2HorizonToolParser,
|
||||
request: ChatCompletionRequest,
|
||||
output: str,
|
||||
) -> tuple[str, list[tuple[str, dict]]]:
|
||||
messages: list[DeltaMessage | None] = []
|
||||
for char in output:
|
||||
messages.append(
|
||||
parser.extract_tool_calls_streaming(
|
||||
previous_text="",
|
||||
current_text="",
|
||||
delta_text=char,
|
||||
previous_token_ids=[],
|
||||
current_token_ids=[],
|
||||
delta_token_ids=[],
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
messages.append(parser.finish_streaming())
|
||||
|
||||
content: list[str] = []
|
||||
calls: list[tuple[str, dict]] = []
|
||||
for message in messages:
|
||||
if message is None:
|
||||
continue
|
||||
if message.content is not None:
|
||||
content.append(message.content)
|
||||
for tool_call in message.tool_calls:
|
||||
assert tool_call.function is not None
|
||||
assert tool_call.function.name is not None
|
||||
assert tool_call.function.arguments is not None
|
||||
calls.append(
|
||||
(
|
||||
tool_call.function.name,
|
||||
json.loads(tool_call.function.arguments),
|
||||
)
|
||||
)
|
||||
return "".join(content), calls
|
||||
|
||||
|
||||
def test_xml_default_parses_multiple_calls_and_coerces_schema(tokenizer):
|
||||
parser = K2HorizonToolParser(tokenizer, _request().tools)
|
||||
output = _group(
|
||||
_xml_call("lookup", [("query", "123"), ("limit", "3")]),
|
||||
_xml_call("ping", []),
|
||||
prefix="Before ",
|
||||
suffix=" after.",
|
||||
)
|
||||
|
||||
result = parser.extract_tool_calls(output, _request())
|
||||
|
||||
assert parser.tool_format == "xml"
|
||||
assert result.tools_called
|
||||
assert result.content == "Before after."
|
||||
assert [call.function.name for call in result.tool_calls] == ["lookup", "ping"]
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == {
|
||||
"query": "123",
|
||||
"limit": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_format", "call", "expected"),
|
||||
[
|
||||
("json", _json_call("lookup", {"limit": "2"}), {"limit": 2}),
|
||||
(
|
||||
"xml_typed",
|
||||
_xml_call("lookup", [("query", "weather"), ("limit", "2")], typed=True),
|
||||
{"query": "weather", "limit": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_request_level_format_overrides(tokenizer, tool_format, call, expected):
|
||||
request = _request(tool_format)
|
||||
parser = K2HorizonToolParser(tokenizer, request.tools)
|
||||
|
||||
result = parser.extract_tool_calls(_group(call), request)
|
||||
|
||||
assert result.tools_called
|
||||
assert json.loads(result.tool_calls[0].function.arguments) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_format",
|
||||
["yaml", "python", "xml_untyped", "", None, 1],
|
||||
)
|
||||
def test_invalid_request_format_rejected_before_generation(tokenizer, tool_format):
|
||||
parser = K2HorizonToolParser(tokenizer, _request().tools)
|
||||
request = _request().model_copy(
|
||||
update={"chat_template_kwargs": {"tool_call_format": tool_format}}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported tool_call_format"):
|
||||
parser.adjust_request(request)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_request", "call"),
|
||||
[
|
||||
(_request(), _json_call("ping", {})),
|
||||
(_request("json"), _xml_call("ping", [])),
|
||||
(_request("xml"), _xml_call("lookup", [("query", "x")], typed=True)),
|
||||
(_request("xml_typed"), _xml_call("lookup", [("query", "x")])),
|
||||
],
|
||||
)
|
||||
def test_mismatched_formats_fail_closed(tokenizer, tool_request, call):
|
||||
parser = K2HorizonToolParser(tokenizer, tool_request.tools)
|
||||
output = _group(call)
|
||||
|
||||
result = parser.extract_tool_calls(output, tool_request)
|
||||
|
||||
assert not result.tools_called
|
||||
assert result.content == output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_choice",
|
||||
[
|
||||
"auto",
|
||||
"required",
|
||||
{"type": "function", "function": {"name": "lookup"}},
|
||||
],
|
||||
)
|
||||
def test_auto_required_and_named_use_ifm_parser(tokenizer, tool_choice):
|
||||
request = _request("json", tool_choice)
|
||||
parser_cls = ParserManager.get_parser(
|
||||
tool_parser_name="k2_horizon",
|
||||
enable_auto_tools=True,
|
||||
)
|
||||
assert parser_cls is not None
|
||||
parser = parser_cls(tokenizer, request.tools)
|
||||
|
||||
assert parser.adjust_request(request) is request
|
||||
assert request.structured_outputs is None
|
||||
_, content, tool_calls = parser.parse(
|
||||
_group(_json_call("lookup", {"enabled": "true"})),
|
||||
request,
|
||||
enable_auto_tools=True,
|
||||
)
|
||||
|
||||
assert content is None
|
||||
assert tool_calls is not None
|
||||
assert json.loads(tool_calls[0].arguments) == {"enabled": True}
|
||||
|
||||
|
||||
def test_unknown_or_wrong_named_tool_fails_closed(tokenizer):
|
||||
named_request = _request(
|
||||
"json", {"type": "function", "function": {"name": "lookup"}}
|
||||
)
|
||||
parser = K2HorizonToolParser(tokenizer, named_request.tools)
|
||||
|
||||
unknown = parser.extract_tool_calls(
|
||||
_group(_json_call("missing", {})), _request("json")
|
||||
)
|
||||
wrong_named = parser.extract_tool_calls(
|
||||
_group(_json_call("ping", {})), named_request
|
||||
)
|
||||
|
||||
assert not unknown.tools_called
|
||||
assert not wrong_named.tools_called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"output",
|
||||
[
|
||||
"<ifm|tool_calls><ifm|tool_call>ping",
|
||||
"<ifm|tool_calls><ifm|tool_call>ping</ifm|tool_call>",
|
||||
_group("junk"),
|
||||
],
|
||||
)
|
||||
def test_malformed_and_incomplete_input_is_content(tokenizer, output):
|
||||
request = _request()
|
||||
parser = K2HorizonToolParser(tokenizer, request.tools)
|
||||
|
||||
result = parser.extract_tool_calls(output, request)
|
||||
|
||||
assert not result.tools_called
|
||||
assert result.content == output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_request", "call", "expected"),
|
||||
[
|
||||
(_request(), _xml_call("lookup", [("limit", "2")]), {"limit": 2}),
|
||||
(_request("json"), _json_call("lookup", {"limit": "2"}), {"limit": 2}),
|
||||
(
|
||||
_request("xml_typed"),
|
||||
_xml_call("lookup", [("limit", "2")], typed=True),
|
||||
{"limit": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_character_split_streaming(tokenizer, tool_request, call, expected):
|
||||
parser = K2HorizonToolParser(tokenizer, tool_request.tools)
|
||||
output = _group(call, prefix="Before", suffix="after")
|
||||
|
||||
content, calls = _collect_stream(parser, tool_request, output)
|
||||
|
||||
assert content == "Beforeafter"
|
||||
assert calls == [("lookup", expected)]
|
||||
|
||||
|
||||
def test_streaming_flushes_incomplete_markup_as_content(tokenizer):
|
||||
request = _request()
|
||||
parser = K2HorizonToolParser(tokenizer, request.tools)
|
||||
output = "Before<ifm|tool_calls><ifm|tool_call>ping"
|
||||
|
||||
content, calls = _collect_stream(parser, request, output)
|
||||
|
||||
assert content == output
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_whitespace_only_surrounding_content_is_not_preserved(tokenizer):
|
||||
request = _request()
|
||||
parser = K2HorizonToolParser(tokenizer, request.tools)
|
||||
|
||||
result = parser.extract_tool_calls(
|
||||
_group(_xml_call("ping", []), prefix=" \n", suffix="\t "), request
|
||||
)
|
||||
|
||||
assert result.tools_called
|
||||
assert result.content is None
|
||||
|
||||
|
||||
def test_tool_parser_registered():
|
||||
assert ToolParserManager.get_tool_parser("k2_horizon") is K2HorizonToolParser
|
||||
assert K2HorizonToolParser.supports_required_and_named is False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,7 @@ _TEXT_GENERATION_MODELS = {
|
||||
"IQuestLoopCoderForCausalLM": ("iquest_loopcoder", "IQuestLoopCoderForCausalLM"),
|
||||
"Jais2ForCausalLM": ("jais2", "Jais2ForCausalLM"),
|
||||
"JambaForCausalLM": ("jamba", "JambaForCausalLM"),
|
||||
"K2HorizonForCausalLM": ("k2_horizon", "K2HorizonForCausalLM"),
|
||||
"KimiLinearForCausalLM": (
|
||||
"vllm.models.kimi_k3",
|
||||
"KimiLinearForCausalLM",
|
||||
|
||||
@@ -96,6 +96,10 @@ _REASONING_PARSERS_TO_REGISTER = {
|
||||
"kimi_k3_reasoning_parser",
|
||||
"KimiK3ReasoningParser",
|
||||
),
|
||||
"k2_horizon": (
|
||||
"k2_horizon_reasoning_parser",
|
||||
"K2HorizonReasoningParser",
|
||||
),
|
||||
"mimo": (
|
||||
"qwen3_engine_reasoning_parser",
|
||||
"Qwen3ParserReasoningAdapter",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
from vllm.entrypoints.generate.base.protocol import DeltaMessage
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
||||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
||||
from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tool_parsers.utils import partial_tag_overlap
|
||||
|
||||
|
||||
class K2HorizonReasoningParser(DeepSeekR1ReasoningParser):
|
||||
"""Reasoning parser for K2 Horizon."""
|
||||
|
||||
_TOOL_CALLS_START = "<ifm|tool_calls>"
|
||||
_EFFORT_TOKENS: dict[str, tuple[str, str]] = {
|
||||
"high": ("<ifm|think>", "</ifm|think>"),
|
||||
"medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
|
||||
"low": ("<ifm|think_faster>", "</ifm|think_faster>"),
|
||||
}
|
||||
|
||||
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs) -> None:
|
||||
chat_template_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
|
||||
effort = chat_template_kwargs.get("reasoning_effort", "high")
|
||||
if not isinstance(effort, str) or effort not in self._EFFORT_TOKENS:
|
||||
supported = ", ".join(self._EFFORT_TOKENS)
|
||||
raise ValueError(
|
||||
f"Unsupported reasoning_effort {effort!r}. "
|
||||
f"Supported values: {supported}."
|
||||
)
|
||||
|
||||
self._start_token, self._end_token = self._EFFORT_TOKENS[effort]
|
||||
super().__init__(tokenizer, *args, **kwargs)
|
||||
self._tool_calls_start_id = self.vocab.get(self._TOOL_CALLS_START)
|
||||
self._stream_buffer = ""
|
||||
self._stream_at_start = True
|
||||
self._stream_finished = False
|
||||
|
||||
@property
|
||||
def start_token(self) -> str:
|
||||
return self._start_token
|
||||
|
||||
@property
|
||||
def end_token(self) -> str:
|
||||
return self._end_token
|
||||
|
||||
def _without_generated_start(self, model_output: str) -> str:
|
||||
return model_output.removeprefix(self.start_token)
|
||||
|
||||
def _split_model_output(self, model_output: str) -> tuple[str, str | None]:
|
||||
output = self._without_generated_start(model_output)
|
||||
if self.end_token in output:
|
||||
reasoning, _, content = output.partition(self.end_token)
|
||||
return reasoning, content or None
|
||||
|
||||
tool_start = output.find(self._TOOL_CALLS_START)
|
||||
if tool_start != -1:
|
||||
return output[:tool_start], output[tool_start:]
|
||||
|
||||
return "", output or None
|
||||
|
||||
def extract_reasoning(
|
||||
self,
|
||||
model_output: str,
|
||||
request: ChatCompletionRequest | ResponsesRequest,
|
||||
) -> tuple[str, str | None]:
|
||||
del request
|
||||
return self._split_model_output(model_output)
|
||||
|
||||
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
|
||||
for token_id in reversed(input_ids):
|
||||
if token_id == self.start_token_id:
|
||||
return False
|
||||
if token_id == self.end_token_id or token_id == self._tool_calls_start_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_reasoning_end_streaming(
|
||||
self,
|
||||
input_ids: Sequence[int],
|
||||
delta_ids: Iterable[int],
|
||||
) -> bool:
|
||||
del input_ids
|
||||
return self._stream_finished or super().is_reasoning_end_streaming(
|
||||
(), delta_ids
|
||||
)
|
||||
|
||||
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
|
||||
if self.end_token_id in input_ids:
|
||||
return input_ids[input_ids.index(self.end_token_id) + 1 :]
|
||||
if (
|
||||
self._tool_calls_start_id is not None
|
||||
and self._tool_calls_start_id in input_ids
|
||||
):
|
||||
return input_ids[input_ids.index(self._tool_calls_start_id) :]
|
||||
return input_ids if self._stream_finished else []
|
||||
|
||||
def _strip_stream_start(self) -> bool:
|
||||
if not self._stream_at_start:
|
||||
return True
|
||||
if self.start_token.startswith(self._stream_buffer):
|
||||
if self._stream_buffer != self.start_token:
|
||||
return False
|
||||
self._stream_buffer = ""
|
||||
elif self._stream_buffer.startswith(self.start_token):
|
||||
self._stream_buffer = self._stream_buffer[len(self.start_token) :]
|
||||
self._stream_at_start = False
|
||||
return True
|
||||
|
||||
def extract_reasoning_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],
|
||||
) -> DeltaMessage | None:
|
||||
del (
|
||||
previous_text,
|
||||
current_text,
|
||||
previous_token_ids,
|
||||
current_token_ids,
|
||||
delta_token_ids,
|
||||
)
|
||||
|
||||
if self._stream_finished:
|
||||
return DeltaMessage(content=delta_text) if delta_text else None
|
||||
|
||||
self._stream_buffer += delta_text
|
||||
if not self._strip_stream_start():
|
||||
return None
|
||||
|
||||
explicit_end = self._stream_buffer.find(self.end_token)
|
||||
tool_start = self._stream_buffer.find(self._TOOL_CALLS_START)
|
||||
if explicit_end != -1:
|
||||
reasoning = self._stream_buffer[:explicit_end]
|
||||
content = self._stream_buffer[explicit_end + len(self.end_token) :]
|
||||
elif tool_start != -1:
|
||||
reasoning = self._stream_buffer[:tool_start]
|
||||
content = self._stream_buffer[tool_start:]
|
||||
else:
|
||||
overlap = max(
|
||||
partial_tag_overlap(self._stream_buffer, self.end_token),
|
||||
partial_tag_overlap(self._stream_buffer, self._TOOL_CALLS_START),
|
||||
)
|
||||
sendable_end = len(self._stream_buffer) - overlap
|
||||
reasoning = self._stream_buffer[:sendable_end]
|
||||
self._stream_buffer = self._stream_buffer[sendable_end:]
|
||||
return DeltaMessage(reasoning=reasoning) if reasoning else None
|
||||
|
||||
self._stream_buffer = ""
|
||||
self._stream_finished = True
|
||||
return DeltaMessage(
|
||||
reasoning=reasoning,
|
||||
content=content or None,
|
||||
)
|
||||
@@ -118,6 +118,10 @@ _TOOL_PARSERS_TO_REGISTER = {
|
||||
"kimi_k3_tool_parser",
|
||||
"KimiK3ToolParser",
|
||||
),
|
||||
"k2_horizon": (
|
||||
"k2_horizon_tool_parser",
|
||||
"K2HorizonToolParser",
|
||||
),
|
||||
"llama3_json": (
|
||||
"llama_tool_parser",
|
||||
"Llama3JsonToolParser",
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import regex as re
|
||||
from openai.types.responses import ToolChoiceFunction
|
||||
|
||||
from vllm.entrypoints.generate.base.protocol import (
|
||||
DeltaFunctionCall,
|
||||
DeltaMessage,
|
||||
DeltaToolCall,
|
||||
ExtractedToolCallInformation,
|
||||
FunctionCall,
|
||||
ToolCall,
|
||||
)
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionNamedToolChoiceParam,
|
||||
ChatCompletionRequest,
|
||||
)
|
||||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
||||
from vllm.logger import init_logger
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser
|
||||
from vllm.tool_parsers.utils import (
|
||||
coerce_to_schema_type,
|
||||
extract_types_from_schema,
|
||||
find_tool_name,
|
||||
find_tool_properties,
|
||||
partial_tag_overlap,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
ToolParserRequest = ChatCompletionRequest | ResponsesRequest
|
||||
|
||||
|
||||
class K2HorizonToolParser(ToolParser):
|
||||
"""Parser for K2 Horizon's IFM tool-call formats."""
|
||||
|
||||
supports_required_and_named = False
|
||||
engine_based_streaming = True
|
||||
|
||||
_SUPPORTED_TOOL_FORMATS = frozenset({"json", "xml", "xml_typed"})
|
||||
|
||||
_TOOL_CALLS_START = "<ifm|tool_calls>"
|
||||
_TOOL_CALLS_END = "</ifm|tool_calls>"
|
||||
_TOOL_CALL_START = "<ifm|tool_call>"
|
||||
_TOOL_CALL_END = "</ifm|tool_call>"
|
||||
_ARG_KEY_START = "<ifm|arg_key>"
|
||||
_ARG_KEY_END = "</ifm|arg_key>"
|
||||
_ARG_TYPE_START = "<ifm|arg_type>"
|
||||
_ARG_TYPE_END = "</ifm|arg_type>"
|
||||
_ARG_VALUE_START = "<ifm|arg_value>"
|
||||
_ARG_VALUE_END = "</ifm|arg_value>"
|
||||
|
||||
_TOOL_CALL_RE = re.compile(
|
||||
re.escape(_TOOL_CALL_START) + r"(.*?)" + re.escape(_TOOL_CALL_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
_ARG_RE = re.compile(
|
||||
re.escape(_ARG_KEY_START)
|
||||
+ r"(.*?)"
|
||||
+ re.escape(_ARG_KEY_END)
|
||||
+ r"\s*(?:"
|
||||
+ re.escape(_ARG_TYPE_START)
|
||||
+ r"(.*?)"
|
||||
+ re.escape(_ARG_TYPE_END)
|
||||
+ r"\s*)?"
|
||||
+ re.escape(_ARG_VALUE_START)
|
||||
+ r"(.*?)"
|
||||
+ re.escape(_ARG_VALUE_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
_ARG_MARKERS = (
|
||||
_ARG_KEY_START,
|
||||
_ARG_KEY_END,
|
||||
_ARG_TYPE_START,
|
||||
_ARG_TYPE_END,
|
||||
_ARG_VALUE_START,
|
||||
_ARG_VALUE_END,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
tools: list[Tool] | None = None,
|
||||
) -> None:
|
||||
super().__init__(tokenizer, tools)
|
||||
self.tool_format = "xml"
|
||||
self._stream_text = ""
|
||||
self._stream_cursor = 0
|
||||
self._stream_group_start: int | None = None
|
||||
self._stream_resolved = False
|
||||
self._pending_content = ""
|
||||
self._content_seen = False
|
||||
|
||||
@classmethod
|
||||
def _request_tool_format(cls, request: ToolParserRequest) -> str:
|
||||
chat_template_kwargs = request.chat_template_kwargs or {}
|
||||
tool_format = chat_template_kwargs.get("tool_call_format", "xml")
|
||||
if (
|
||||
not isinstance(tool_format, str)
|
||||
or tool_format not in cls._SUPPORTED_TOOL_FORMATS
|
||||
):
|
||||
supported = ", ".join(sorted(cls._SUPPORTED_TOOL_FORMATS))
|
||||
raise ValueError(
|
||||
f"Unsupported tool_call_format {tool_format!r}. "
|
||||
f"Supported values: {supported}."
|
||||
)
|
||||
return tool_format
|
||||
|
||||
def adjust_request(self, request: ToolParserRequest) -> ToolParserRequest:
|
||||
self._request_tool_format(request)
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def _content_or_none(content: str) -> str | None:
|
||||
return content if content.strip() else None
|
||||
|
||||
@classmethod
|
||||
def _no_tool_calls(cls, content: str) -> ExtractedToolCallInformation:
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=False,
|
||||
tool_calls=[],
|
||||
content=cls._content_or_none(content),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_tool_call(name: str, arguments: dict[str, Any]) -> ToolCall:
|
||||
return ToolCall(
|
||||
type="function",
|
||||
function=FunctionCall(
|
||||
name=name,
|
||||
arguments=json.dumps(
|
||||
arguments,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _base_arg_type(arg_type: str | None) -> str | None:
|
||||
if not arg_type:
|
||||
return None
|
||||
return arg_type.split("[", 1)[0].strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _coerce_json_value(value: Any, schema: Any) -> Any:
|
||||
if not isinstance(schema, dict):
|
||||
return value
|
||||
raw_value = (
|
||||
value
|
||||
if isinstance(value, str)
|
||||
else json.dumps(value, ensure_ascii=False, allow_nan=False)
|
||||
)
|
||||
return coerce_to_schema_type(raw_value, extract_types_from_schema(schema))
|
||||
|
||||
@classmethod
|
||||
def _coerce_xml_value(
|
||||
cls,
|
||||
value: str,
|
||||
schema: Any,
|
||||
explicit_type: str | None,
|
||||
) -> Any:
|
||||
explicit_base = cls._base_arg_type(explicit_type)
|
||||
target_types = extract_types_from_schema(schema)
|
||||
if isinstance(schema, dict):
|
||||
if explicit_base in target_types and len(set(target_types)) > 1:
|
||||
target_types = [explicit_base]
|
||||
elif explicit_base == "any":
|
||||
return value
|
||||
elif explicit_base:
|
||||
target_types = [explicit_base]
|
||||
|
||||
raw_value = value if "string" in target_types else value.strip()
|
||||
return coerce_to_schema_type(raw_value, target_types)
|
||||
|
||||
@staticmethod
|
||||
def _named_tool(request: ToolParserRequest) -> str | None:
|
||||
tool_choice = request.tool_choice
|
||||
if isinstance(tool_choice, ChatCompletionNamedToolChoiceParam):
|
||||
return tool_choice.function.name
|
||||
if isinstance(tool_choice, ToolChoiceFunction):
|
||||
return tool_choice.name
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _validate_tool_name(
|
||||
cls,
|
||||
name: Any,
|
||||
request: ToolParserRequest,
|
||||
) -> str:
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or not name
|
||||
or any(char.isspace() for char in name)
|
||||
):
|
||||
raise ValueError("IFM tool call has an invalid function name.")
|
||||
if request.tools and not find_tool_name(request.tools, name):
|
||||
raise ValueError(f"Unknown IFM tool name: {name}.")
|
||||
named_tool = cls._named_tool(request)
|
||||
if named_tool is not None and name != named_tool:
|
||||
raise ValueError(f"Unexpected IFM tool name: {name}.")
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def _parse_json_call(
|
||||
cls,
|
||||
body: str,
|
||||
request: ToolParserRequest,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
raw_call = json.loads(body.strip())
|
||||
if not isinstance(raw_call, dict):
|
||||
raise ValueError("IFM JSON tool call must be an object.")
|
||||
|
||||
name = cls._validate_tool_name(raw_call.get("name"), request)
|
||||
arguments = raw_call.get("arguments", {})
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError("IFM JSON tool-call arguments must be an object.")
|
||||
|
||||
properties = find_tool_properties(request.tools, name)
|
||||
arguments = {
|
||||
arg_name: cls._coerce_json_value(arg_value, properties.get(arg_name))
|
||||
for arg_name, arg_value in arguments.items()
|
||||
}
|
||||
return name, arguments
|
||||
|
||||
@classmethod
|
||||
def _parse_xml_call(
|
||||
cls,
|
||||
body: str,
|
||||
request: ToolParserRequest,
|
||||
tool_format: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
first_arg = body.find(cls._ARG_KEY_START)
|
||||
if first_arg == -1:
|
||||
if any(marker in body for marker in cls._ARG_MARKERS):
|
||||
raise ValueError("Malformed IFM XML argument tags.")
|
||||
name = cls._validate_tool_name(body.strip(), request)
|
||||
return name, {}
|
||||
|
||||
name = cls._validate_tool_name(body[:first_arg].strip(), request)
|
||||
properties = find_tool_properties(request.tools, name)
|
||||
arguments: dict[str, Any] = {}
|
||||
position = first_arg
|
||||
for match in cls._ARG_RE.finditer(body, first_arg):
|
||||
if body[position : match.start()].strip():
|
||||
raise ValueError("Malformed IFM XML argument tags.")
|
||||
|
||||
arg_name = match.group(1).strip()
|
||||
if not arg_name:
|
||||
raise ValueError("IFM XML argument is missing a name.")
|
||||
if arg_name in arguments:
|
||||
raise ValueError(f"Duplicate IFM XML argument: {arg_name}.")
|
||||
|
||||
explicit_type = match.group(2)
|
||||
if explicit_type is not None:
|
||||
explicit_type = explicit_type.strip()
|
||||
if tool_format == "xml" and explicit_type is not None:
|
||||
raise ValueError("Unexpected type tag in IFM XML tool call.")
|
||||
if tool_format == "xml_typed" and not explicit_type:
|
||||
raise ValueError("IFM typed XML argument is missing a type.")
|
||||
arguments[arg_name] = cls._coerce_xml_value(
|
||||
match.group(3),
|
||||
properties.get(arg_name),
|
||||
explicit_type,
|
||||
)
|
||||
position = match.end()
|
||||
|
||||
if position == first_arg or body[position:].strip():
|
||||
raise ValueError("Malformed IFM XML argument tags.")
|
||||
return name, arguments
|
||||
|
||||
@classmethod
|
||||
def _parse_call(
|
||||
cls,
|
||||
body: str,
|
||||
request: ToolParserRequest,
|
||||
tool_format: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
if tool_format == "json":
|
||||
return cls._parse_json_call(body, request)
|
||||
return cls._parse_xml_call(body, request, tool_format)
|
||||
|
||||
def extract_tool_calls(
|
||||
self,
|
||||
model_output: str,
|
||||
request: ToolParserRequest,
|
||||
) -> ExtractedToolCallInformation:
|
||||
tool_format = self._request_tool_format(request)
|
||||
if request.tool_choice == "none":
|
||||
return self._no_tool_calls(model_output)
|
||||
|
||||
group_start = model_output.find(self._TOOL_CALLS_START)
|
||||
if group_start == -1:
|
||||
return self._no_tool_calls(model_output)
|
||||
|
||||
body_start = group_start + len(self._TOOL_CALLS_START)
|
||||
group_end = model_output.find(self._TOOL_CALLS_END, body_start)
|
||||
if group_end == -1:
|
||||
return self._no_tool_calls(model_output)
|
||||
|
||||
group_body = model_output[body_start:group_end]
|
||||
matches = list(self._TOOL_CALL_RE.finditer(group_body))
|
||||
if not matches or self._TOOL_CALL_RE.sub("", group_body).strip():
|
||||
logger.warning("Malformed K2 Horizon IFM tool-call group.")
|
||||
return self._no_tool_calls(model_output)
|
||||
|
||||
try:
|
||||
tool_calls = [
|
||||
self._make_tool_call(
|
||||
*self._parse_call(match.group(1), request, tool_format)
|
||||
)
|
||||
for match in matches
|
||||
]
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.warning("Failed to parse K2 Horizon IFM tool calls.", exc_info=True)
|
||||
return self._no_tool_calls(model_output)
|
||||
|
||||
suffix = model_output[group_end + len(self._TOOL_CALLS_END) :]
|
||||
content = model_output[:group_start] + suffix
|
||||
return ExtractedToolCallInformation(
|
||||
tools_called=True,
|
||||
tool_calls=tool_calls,
|
||||
content=self._content_or_none(content),
|
||||
)
|
||||
|
||||
def _emit_content(self, addition: str, *, final: bool = False) -> str | None:
|
||||
if addition:
|
||||
if self._content_seen:
|
||||
return addition
|
||||
self._pending_content += addition
|
||||
if self._pending_content.strip():
|
||||
self._content_seen = True
|
||||
content = self._pending_content
|
||||
self._pending_content = ""
|
||||
return content
|
||||
if final:
|
||||
self._pending_content = ""
|
||||
return None
|
||||
|
||||
def _streaming_tool_calls(
|
||||
self,
|
||||
tool_calls: list[ToolCall],
|
||||
) -> list[DeltaToolCall]:
|
||||
deltas: list[DeltaToolCall] = []
|
||||
for index, tool_call in enumerate(tool_calls):
|
||||
arguments_json = tool_call.function.arguments
|
||||
arguments = json.loads(arguments_json)
|
||||
self.prev_tool_call_arr.append(
|
||||
{"name": tool_call.function.name, "arguments": arguments}
|
||||
)
|
||||
self.streamed_args_for_tool.append(arguments_json)
|
||||
deltas.append(
|
||||
DeltaToolCall(
|
||||
index=index,
|
||||
id=tool_call.id,
|
||||
type="function",
|
||||
function=DeltaFunctionCall(
|
||||
name=tool_call.function.name,
|
||||
arguments=arguments_json,
|
||||
),
|
||||
)
|
||||
)
|
||||
return deltas
|
||||
|
||||
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: ToolParserRequest,
|
||||
) -> DeltaMessage | None:
|
||||
del (
|
||||
previous_text,
|
||||
current_text,
|
||||
previous_token_ids,
|
||||
current_token_ids,
|
||||
delta_token_ids,
|
||||
)
|
||||
|
||||
self._request_tool_format(request)
|
||||
if request.tool_choice == "none":
|
||||
emitted_content = self._emit_content(delta_text)
|
||||
return (
|
||||
DeltaMessage(content=emitted_content)
|
||||
if emitted_content is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self._stream_text += delta_text
|
||||
if self._stream_resolved:
|
||||
emitted_content = self._emit_content(delta_text)
|
||||
return (
|
||||
DeltaMessage(content=emitted_content)
|
||||
if emitted_content is not None
|
||||
else None
|
||||
)
|
||||
|
||||
content: str | None = None
|
||||
if self._stream_group_start is None:
|
||||
group_start = self._stream_text.find(
|
||||
self._TOOL_CALLS_START, self._stream_cursor
|
||||
)
|
||||
if group_start == -1:
|
||||
pending = self._stream_text[self._stream_cursor :]
|
||||
overlap = partial_tag_overlap(pending, self._TOOL_CALLS_START)
|
||||
sendable_end = len(self._stream_text) - overlap
|
||||
content = self._emit_content(
|
||||
self._stream_text[self._stream_cursor : sendable_end]
|
||||
)
|
||||
self._stream_cursor = sendable_end
|
||||
return DeltaMessage(content=content) if content is not None else None
|
||||
|
||||
self._stream_group_start = group_start
|
||||
content = self._emit_content(
|
||||
self._stream_text[self._stream_cursor : group_start]
|
||||
)
|
||||
self._stream_cursor = group_start
|
||||
|
||||
group_start = self._stream_group_start
|
||||
body_start = group_start + len(self._TOOL_CALLS_START)
|
||||
group_end = self._stream_text.find(self._TOOL_CALLS_END, body_start)
|
||||
if group_end == -1:
|
||||
return DeltaMessage(content=content) if content is not None else None
|
||||
|
||||
group_end += len(self._TOOL_CALLS_END)
|
||||
group_text = self._stream_text[group_start:group_end]
|
||||
parsed = self.extract_tool_calls(group_text, request)
|
||||
self._stream_resolved = True
|
||||
|
||||
if not parsed.tools_called:
|
||||
failed_content = self._emit_content(
|
||||
self._stream_text[self._stream_cursor :]
|
||||
)
|
||||
self._stream_cursor = len(self._stream_text)
|
||||
combined = (content or "") + (failed_content or "")
|
||||
return DeltaMessage(content=combined) if combined else None
|
||||
|
||||
tool_calls = self._streaming_tool_calls(parsed.tool_calls)
|
||||
suffix = self._emit_content(self._stream_text[group_end:])
|
||||
self._stream_cursor = len(self._stream_text)
|
||||
combined = (content or "") + (suffix or "")
|
||||
return DeltaMessage(content=combined or None, tool_calls=tool_calls)
|
||||
|
||||
def finish_streaming(self) -> DeltaMessage | None:
|
||||
if not self._stream_resolved:
|
||||
content = self._emit_content(
|
||||
self._stream_text[self._stream_cursor :], final=True
|
||||
)
|
||||
self._stream_cursor = len(self._stream_text)
|
||||
self._stream_resolved = True
|
||||
else:
|
||||
content = self._emit_content("", final=True)
|
||||
return DeltaMessage(content=content) if content is not None else None
|
||||
Reference in New Issue
Block a user