Signed-off-by: wang.yuqi <yuqi.wang@daocloud.io> Signed-off-by: wang.yuqi <noooop@126.com> Co-authored-by: Cyrus Leung <cyrus.tl.leung@gmail.com>
2656 lines
94 KiB
Python
2656 lines
94 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
import json
|
|
from collections.abc import Generator
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import partial_json_parser
|
|
import pytest
|
|
import regex as re
|
|
from mistral_common.protocol.instruct.messages import AssistantMessage
|
|
from mistral_common.protocol.instruct.request import InstructRequest
|
|
from mistral_common.protocol.instruct.tool_calls import (
|
|
FunctionCall,
|
|
ToolCall,
|
|
)
|
|
from mistral_common.protocol.instruct.tool_calls import (
|
|
NamedToolChoice as MistralNamedToolChoice,
|
|
)
|
|
from mistral_common.protocol.instruct.tool_calls import (
|
|
ToolChoice as MistralToolChoice,
|
|
)
|
|
from mistral_common.protocol.instruct.tool_calls import (
|
|
ToolChoiceEnum as MistralToolChoiceEnum,
|
|
)
|
|
from partial_json_parser.core.options import Allow
|
|
|
|
from vllm.entrypoints.generate.base.protocol import (
|
|
DeltaFunctionCall,
|
|
DeltaMessage,
|
|
DeltaToolCall,
|
|
ExtractedToolCallInformation,
|
|
StructuralTagResponseFormat,
|
|
)
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionRequest,
|
|
)
|
|
from vllm.parser.engine.events import EventType
|
|
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
|
|
from vllm.parser.mistral import _DEFAULT_JSON_SCHEMA, MistralParser, mistral_config
|
|
from vllm.parser.parser_manager import ParserManager
|
|
from vllm.sampling_params import StructuredOutputsParams
|
|
from vllm.tokenizers import TokenizerLike, get_tokenizer
|
|
from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
|
|
from vllm.tokenizers.mistral import MistralTokenizer
|
|
from vllm.tool_parsers.mistral_tool_parser import MistralToolParser
|
|
|
|
_DUMMY_REQUEST = ChatCompletionRequest(messages=[], model="test")
|
|
# Minimal request for the engine driver: tool_choice=None falls through to
|
|
# auto extraction without triggering "tools must be set" validation.
|
|
_ENGINE_REQUEST = ChatCompletionRequest(messages=[], model="test", tool_choice=None)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def mistral_pre_v11_tokenizer():
|
|
MODEL = "mistralai/Mistral-7B-Instruct-v0.3"
|
|
return get_tokenizer(tokenizer_name=MODEL)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def mistral_tokenizer():
|
|
MODEL = "mistralai/Mistral-Small-3.2-24B-Instruct-2506"
|
|
return get_tokenizer(tokenizer_name=MODEL, tokenizer_mode="mistral")
|
|
|
|
|
|
@pytest.fixture
|
|
def mistral_pre_v11_tool_parser(mistral_pre_v11_tokenizer):
|
|
return MistralToolParser(mistral_pre_v11_tokenizer)
|
|
|
|
|
|
@pytest.fixture
|
|
def mistral_tool_parser(mistral_tokenizer):
|
|
return MistralToolParser(mistral_tokenizer)
|
|
|
|
|
|
@pytest.fixture
|
|
def non_mistral_parser() -> MistralToolParser:
|
|
mock_tokenizer = MagicMock()
|
|
mock_tokenizer.get_vocab.return_value = {"[TOOL_CALLS]": 1}
|
|
# Ensure the legacy (non-grammar) path is taken by MistralParser.
|
|
mock_tokenizer.supports_grammar = False
|
|
return MistralToolParser(mock_tokenizer)
|
|
|
|
|
|
def assert_tool_calls(
|
|
actual_tool_calls: list[ToolCall] | list[DeltaToolCall],
|
|
expected_tool_calls: list[ToolCall],
|
|
):
|
|
assert len(actual_tool_calls) == len(expected_tool_calls)
|
|
|
|
for actual_tool_call, expected_tool_call in zip(
|
|
actual_tool_calls, expected_tool_calls
|
|
):
|
|
assert isinstance(actual_tool_call.id, str)
|
|
assert len(actual_tool_call.id) == 9
|
|
|
|
if isinstance(actual_tool_call, ToolCall):
|
|
assert actual_tool_call.type == "function"
|
|
elif isinstance(actual_tool_call, DeltaToolCall):
|
|
assert actual_tool_call.function is not None
|
|
assert actual_tool_call.function.name is not None
|
|
assert actual_tool_call.function.arguments is not None
|
|
assert actual_tool_call.function is not None
|
|
assert actual_tool_call.function.name == expected_tool_call.function.name, (
|
|
f"got wrong function name:${actual_tool_call.function.name}"
|
|
)
|
|
actual_args = actual_tool_call.function.arguments
|
|
# Streaming deltas (DeltaToolCall) may have partial JSON because the
|
|
# engine holds back the final closing brace until finish() is called.
|
|
# Apply partial_json_parser so the comparison works for both the
|
|
# legacy (complete JSON) and engine (potentially partial) paths.
|
|
if isinstance(actual_tool_call, DeltaToolCall) and actual_args is not None:
|
|
actual_args = partial_json_parser.ensure_json(
|
|
actual_args, Allow.OBJ | Allow.STR
|
|
)
|
|
assert actual_args == expected_tool_call.function.arguments, (
|
|
f"got wrong function argument:${actual_tool_call.function.arguments}"
|
|
)
|
|
|
|
|
|
def fix_tool_call_tokenization(
|
|
tokens: list[int],
|
|
mistral_tool_parser: MistralToolParser,
|
|
mistral_tokenizer: TokenizerLike,
|
|
):
|
|
"""Replace textual token sequences for the control markers ([TOOL_CALLS]
|
|
and [ARGS]) with their single special token IDs.
|
|
|
|
Real generation emits these as control tokens; encoding the marker *string*
|
|
can instead yield literal text tokens. The engine detects markers by id, so
|
|
the test stream must carry the special ids.
|
|
"""
|
|
vocab = mistral_tokenizer.get_vocab()
|
|
markers: list[tuple[str, int]] = [
|
|
(
|
|
mistral_tool_parser._parser_engine.bot_token,
|
|
mistral_tool_parser._parser_engine.bot_token_id,
|
|
)
|
|
]
|
|
args_id = vocab.get("[ARGS]")
|
|
if args_id is not None:
|
|
markers.append(("[ARGS]", args_id))
|
|
|
|
# (textual token sequence, special id) pairs to replace.
|
|
replacements: list[tuple[list[int], int]] = []
|
|
for text, special_id in markers:
|
|
if special_id is None:
|
|
continue
|
|
textual = mistral_tokenizer.encode(text=text, add_special_tokens=False)
|
|
if textual:
|
|
replacements.append((textual, special_id))
|
|
|
|
result_tokens: list[int] = []
|
|
i = 0
|
|
while i < len(tokens):
|
|
for textual, special_id in replacements:
|
|
if tokens[i : i + len(textual)] == textual:
|
|
result_tokens.append(special_id)
|
|
i += len(textual)
|
|
break
|
|
else:
|
|
result_tokens.append(tokens[i])
|
|
i += 1
|
|
|
|
return result_tokens
|
|
|
|
|
|
def encode_mistral_output(tokenizer: MistralTokenizer, text: str) -> list[int]:
|
|
"""Encode generated output with control markers as their special-token ids.
|
|
|
|
Real generation emits ``[TOOL_CALLS]``/``[ARGS]`` as single control tokens.
|
|
Encoding the marker *strings* can instead glue them to neighbours (e.g.
|
|
``}[``) and yield literal text tokens, which the id-based engine never sees.
|
|
Split on the markers and insert their special ids so the token stream
|
|
matches real generation.
|
|
"""
|
|
vocab = tokenizer.get_vocab()
|
|
marker_ids = {
|
|
"[TOOL_CALLS]": vocab.get("[TOOL_CALLS]"),
|
|
"[ARGS]": vocab.get("[ARGS]"),
|
|
}
|
|
ids: list[int] = []
|
|
for part in re.split(r"(\[TOOL_CALLS\]|\[ARGS\])", text):
|
|
if part in marker_ids:
|
|
# Fall back to text encoding here and the id-based engine path
|
|
# silently stops being exercised while the tests still pass.
|
|
assert marker_ids[part] is not None, f"{part} missing from vocab"
|
|
ids.append(marker_ids[part])
|
|
elif part:
|
|
ids.extend(tokenizer.encode(part, add_special_tokens=False))
|
|
return ids
|
|
|
|
|
|
def stream_delta_message_generator(
|
|
mistral_tool_parser: MistralToolParser,
|
|
mistral_tokenizer: TokenizerLike,
|
|
model_output: str | None,
|
|
tools: list[tuple[str, str]] | None,
|
|
chunk_size: int = 1,
|
|
driver: str = "legacy",
|
|
reasoning_parser: str | None = None,
|
|
) -> Generator[DeltaMessage, None, None]:
|
|
if (
|
|
isinstance(mistral_tokenizer, MistralTokenizer)
|
|
and mistral_tokenizer.version >= 11
|
|
):
|
|
# With the newer versions of the tokenizer,
|
|
# we cannot tokenize free text
|
|
# so we need to create a list of messages to get tokenized
|
|
assert tools is not None
|
|
assistant_msg = AssistantMessage(
|
|
tool_calls=[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name=name,
|
|
arguments=arg,
|
|
)
|
|
)
|
|
for (name, arg) in tools
|
|
],
|
|
)
|
|
request = InstructRequest(
|
|
messages=[assistant_msg],
|
|
)
|
|
all_token_ids = mistral_tokenizer.instruct.encode_instruct(request).tokens
|
|
else:
|
|
# Older versions of the tokenizer are
|
|
# able to encode directly the model's output (free text) into tokens
|
|
assert model_output is not None
|
|
all_token_ids = mistral_tokenizer.encode(model_output, add_special_tokens=False)
|
|
|
|
all_token_ids = fix_tool_call_tokenization(
|
|
all_token_ids, mistral_tool_parser, mistral_tokenizer
|
|
)
|
|
|
|
if isinstance(mistral_tokenizer, MistralTokenizer):
|
|
# Real serving streams only generated tokens; encode_instruct adds
|
|
# framing (BOS/EOS) the parser never receives. Strip them so the
|
|
# id-based engine detection sees a realistic stream.
|
|
if all_token_ids and all_token_ids[0] == mistral_tokenizer.bos_token_id:
|
|
all_token_ids = all_token_ids[1:]
|
|
if all_token_ids and all_token_ids[-1] == mistral_tokenizer.eos_token_id:
|
|
all_token_ids = all_token_ids[:-1]
|
|
|
|
if driver == "engine":
|
|
_parser_cls = ParserManager.get_parser(
|
|
tool_parser_name="mistral",
|
|
reasoning_parser_name=reasoning_parser,
|
|
enable_auto_tools=True,
|
|
)
|
|
_engine_parser = _parser_cls(mistral_tokenizer, None)
|
|
if reasoning_parser is not None:
|
|
_req_with_tools = ChatCompletionRequest(
|
|
messages=[],
|
|
model="test",
|
|
tools=SAMPLE_TOOLS_DICTS,
|
|
tool_choice="auto",
|
|
)
|
|
_engine_req = _engine_parser.adjust_request(_req_with_tools)
|
|
else:
|
|
_engine_req = _ENGINE_REQUEST
|
|
|
|
previous_text = ""
|
|
previous_tokens = None
|
|
prefix_offset = 0
|
|
read_offset = 0
|
|
pending_text = ""
|
|
pending_token_ids: list[int] = []
|
|
for i, delta_token in enumerate(all_token_ids):
|
|
(new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
|
|
detokenize_incrementally(
|
|
tokenizer=mistral_tokenizer,
|
|
all_input_ids=all_token_ids[: i + 1],
|
|
prev_tokens=previous_tokens,
|
|
prefix_offset=prefix_offset,
|
|
read_offset=read_offset,
|
|
skip_special_tokens=isinstance(mistral_tokenizer, MistralTokenizer),
|
|
spaces_between_special_tokens=True,
|
|
)
|
|
)
|
|
previous_tokens = (
|
|
previous_tokens + new_tokens if previous_tokens else new_tokens
|
|
)
|
|
prefix_offset = new_prefix_offset
|
|
read_offset = new_read_offset
|
|
|
|
# Buffer tokens so each streamed delta can carry ``chunk_size`` tokens,
|
|
# reproducing the multi-token deltas produced by async scheduling /
|
|
# stream_interval > 1.
|
|
pending_text += delta_text
|
|
pending_token_ids.append(delta_token)
|
|
if len(pending_token_ids) < chunk_size and i != len(all_token_ids) - 1:
|
|
continue
|
|
|
|
previous_token_ids = all_token_ids[: i + 1 - len(pending_token_ids)]
|
|
current_token_ids = all_token_ids[: i + 1]
|
|
current_text = previous_text + pending_text
|
|
|
|
if driver == "legacy":
|
|
delta_message = mistral_tool_parser.extract_tool_calls_streaming(
|
|
previous_text,
|
|
current_text,
|
|
pending_text,
|
|
previous_token_ids,
|
|
current_token_ids,
|
|
pending_token_ids,
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
else: # engine: per-delta windows, mirroring parse_delta behaviour
|
|
delta_message = _engine_parser.parse_delta(
|
|
delta_text=pending_text,
|
|
delta_token_ids=list(pending_token_ids),
|
|
request=_engine_req,
|
|
prompt_token_ids=[1],
|
|
finished=(i == len(all_token_ids) - 1),
|
|
)
|
|
if delta_message:
|
|
yield delta_message
|
|
|
|
previous_text = current_text
|
|
pending_text = ""
|
|
pending_token_ids = []
|
|
|
|
# For the legacy driver the engine holds back the final closing brace
|
|
# in _args_buffer until finish_streaming() is called, mirroring how
|
|
# the real serving layer flushes at end of stream.
|
|
if driver == "legacy":
|
|
flush_delta = mistral_tool_parser.finish_streaming()
|
|
if flush_delta:
|
|
yield flush_delta
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"parser_fixture",
|
|
["mistral_pre_v11_tool_parser", "mistral_tool_parser"],
|
|
ids=["pre_v11", "v11"],
|
|
)
|
|
def test_extract_tool_calls_no_tools(parser_fixture, request):
|
|
parser = request.getfixturevalue(parser_fixture)
|
|
model_output = "This is a test"
|
|
result = parser.extract_tool_calls(model_output, request=_DUMMY_REQUEST)
|
|
assert result == ExtractedToolCallInformation(
|
|
tools_called=False, tool_calls=[], content=model_output
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
ids=[
|
|
"single_tool_add",
|
|
"single_tool_weather",
|
|
"argument_before_name",
|
|
"argument_before_name_and_name_in_argument",
|
|
"multiple_tools",
|
|
"content_before_tool",
|
|
"trailing_data_after_json",
|
|
],
|
|
argnames=["model_output", "expected_tool_calls", "expected_content"],
|
|
argvalues=[
|
|
(
|
|
"""[TOOL_CALLS][{"name": "add", "arguments":{"a": 3.5, "b": 4}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "get_current_weather", "arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}, "name": "get_current_weather"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"arguments":{"name": "John Doe"}, "name": "get_age"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_age",
|
|
arguments=json.dumps(
|
|
{
|
|
"name": "John Doe",
|
|
}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "add", "arguments": {"a": 3.5, "b": 4}}, {"name": "get_current_weather", "arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
),
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""Hello[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 1, "b": 2})
|
|
)
|
|
)
|
|
],
|
|
"Hello",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "get_current_weather", "arguments":{"city": "Dallas", "state": "TX", "unit": "fahrenheit"}}]\nextra trailing data""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{
|
|
"city": "Dallas",
|
|
"state": "TX",
|
|
"unit": "fahrenheit",
|
|
}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
],
|
|
)
|
|
def test_extract_tool_calls_pre_v11_tokenizer(
|
|
mistral_pre_v11_tool_parser, model_output, expected_tool_calls, expected_content
|
|
):
|
|
extracted_tool_calls = mistral_pre_v11_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert extracted_tool_calls.tools_called
|
|
|
|
assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
|
|
|
|
assert extracted_tool_calls.content == expected_content
|
|
|
|
|
|
def test_extract_tool_calls_pre_v11_multiple_bot_tokens_raises(
|
|
mistral_pre_v11_tool_parser,
|
|
):
|
|
model_output = (
|
|
'[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1}}]'
|
|
'[TOOL_CALLS] [{"name": "sub", "arguments":{"b": 2}}]'
|
|
)
|
|
with pytest.raises(ValueError, match="Only one BOT token"):
|
|
mistral_pre_v11_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
|
|
|
|
def test_extract_tool_calls_pre_v11_regex_fallback(
|
|
mistral_pre_v11_tool_parser,
|
|
):
|
|
"""The regex fallback path finds valid JSON via regex when the primary
|
|
raw_decode fails on leading junk. It should re-serialize arguments
|
|
and return a valid tool call."""
|
|
model_output = (
|
|
'[TOOL_CALLS] junk [{"name": "add", "arguments":{"a": 1, "b": 2}}] trail'
|
|
)
|
|
result = mistral_pre_v11_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "add"
|
|
assert result.tool_calls[0].function.arguments == json.dumps({"a": 1, "b": 2})
|
|
|
|
|
|
def test_extract_tool_calls_pre_v11_regex_fallback_fails(
|
|
mistral_pre_v11_tool_parser,
|
|
):
|
|
model_output = "[TOOL_CALLS] not json at all"
|
|
result = mistral_pre_v11_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result == ExtractedToolCallInformation(
|
|
tools_called=False, tool_calls=[], content="not json at all"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
ids=[
|
|
"single_tool_add",
|
|
"single_tool_weather",
|
|
"multiple_tool_calls",
|
|
"single_tool_add_args",
|
|
"single_tool_weather_args",
|
|
"multiple_tool_calls_args",
|
|
"content_before_tool_args",
|
|
"complex",
|
|
"wrong_json",
|
|
"trailing_text_after_json",
|
|
"trailing_text_after_args_json",
|
|
],
|
|
argnames=["model_output", "expected_tool_calls", "expected_content"],
|
|
argvalues=[
|
|
(
|
|
"""[TOOL_CALLS]add_this_and_that{"a": 3.5, "b": 4}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_this_and_that",
|
|
arguments=json.dumps({"a": 3.5, "b": 4}),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS]get_current_weather{"city": "San Francisco", "state": "CA", "unit": "celsius"}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS]add{"a": 3.5, "b": 4}[TOOL_CALLS]multiply{"a": 3, "b": 6}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="multiply", arguments=json.dumps({"a": 3, "b": 6})
|
|
)
|
|
),
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
# v11+ emits an explicit [ARGS] separator between name and args.
|
|
"""[TOOL_CALLS]add_this_and_that[ARGS]{"a": 3.5, "b": 4}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_this_and_that",
|
|
arguments=json.dumps({"a": 3.5, "b": 4}),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS]get_current_weather[ARGS]{"city": "San Francisco", "state": "CA", "unit": "celsius"}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""[TOOL_CALLS]add[ARGS]{"a": 3.5, "b": 4}[TOOL_CALLS]multiply[ARGS]{"a": 3, "b": 6}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="multiply", arguments=json.dumps({"a": 3, "b": 6})
|
|
)
|
|
),
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
"""hi[TOOL_CALLS]add[ARGS]{"a": 1, "b": 2}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 1, "b": 2})
|
|
)
|
|
)
|
|
],
|
|
"hi",
|
|
),
|
|
(
|
|
# Complex
|
|
"""hi{hi[TOOL_CALLS]bash{"command": "print(\\"hello world!\\")\\nre.compile(r\'{}\')""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="bash",
|
|
arguments=json.dumps(
|
|
{"command": "print(\"hello world!\")\nre.compile(r'{}')"}
|
|
)[:-2],
|
|
)
|
|
)
|
|
],
|
|
"hi{hi",
|
|
),
|
|
(
|
|
# Wrong json
|
|
"""hi{hi[TOOL_CALLS]bash{"command": "print(\\"hello world!\\")\\nre.compile(r\'{}\')"}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="bash",
|
|
arguments=json.dumps(
|
|
{"command": "print(\"hello world!\")\nre.compile(r'{}')"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"hi{hi",
|
|
),
|
|
(
|
|
# gh#48975: trailing text after the JSON object must not leak into
|
|
# arguments (v11+ name{args} format has no terminator).
|
|
"""[TOOL_CALLS]get_current_weather{"city": "San Francisco"}Estimating the weather...""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps({"city": "San Francisco"}),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
(
|
|
# gh#48975 with the explicit [ARGS] separator.
|
|
"""[TOOL_CALLS]get_current_weather[ARGS]{"city": "San Francisco"}Estimating the weather...""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps({"city": "San Francisco"}),
|
|
)
|
|
)
|
|
],
|
|
None,
|
|
),
|
|
],
|
|
)
|
|
def test_extract_tool_calls(
|
|
mistral_tool_parser, model_output, expected_tool_calls, expected_content
|
|
):
|
|
extracted_tool_calls = mistral_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert extracted_tool_calls.tools_called
|
|
|
|
assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
|
|
|
|
assert extracted_tool_calls.content == expected_content
|
|
|
|
|
|
def test_extract_tool_calls_v11_without_args_skipped(mistral_tool_parser):
|
|
# Before Stage 2: the legacy path skipped tool calls with no arg separator,
|
|
# returning tools_called=True with an empty list. After Stage 2 the engine
|
|
# path is used for v11; it recognises the name and emits a tool call with
|
|
# empty arguments ("{}"). The semantic result is equivalent — a valid zero-
|
|
# argument call — and is strictly more correct than silently dropping it.
|
|
model_output = "[TOOL_CALLS]toolname_no_args"
|
|
result = mistral_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "toolname_no_args"
|
|
assert result.tool_calls[0].function.arguments == "{}"
|
|
assert result.content is None
|
|
|
|
|
|
def test_extract_tool_calls_malformed_name_before_marker_emits_empty_name(
|
|
mistral_tool_parser,
|
|
):
|
|
model_output = 'bash[TOOL_CALLS]{"command": "cat file.py"}'
|
|
result = mistral_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == ""
|
|
assert result.tool_calls[0].function.arguments == json.dumps(
|
|
{"command": "cat file.py"}
|
|
)
|
|
assert result.content == "bash"
|
|
|
|
|
|
def test_extract_tool_calls_well_formed_name_unaffected(mistral_tool_parser):
|
|
model_output = '[TOOL_CALLS]bash[ARGS]{"command": "ls -la"}'
|
|
result = mistral_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "bash"
|
|
assert result.tool_calls[0].function.arguments == json.dumps({"command": "ls -la"})
|
|
assert result.content is None
|
|
|
|
|
|
def test_extract_tool_calls_empty_name_unparsable_args_no_crash(
|
|
mistral_tool_parser,
|
|
):
|
|
# Empty name AND args that never form valid JSON: still emitted (raw
|
|
# text as arguments) instead of raising.
|
|
model_output = "bash[TOOL_CALLS]{not valid json at all"
|
|
result = mistral_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == ""
|
|
assert result.tool_calls[0].function.arguments == "{not valid json at all"
|
|
assert result.content == "bash"
|
|
|
|
|
|
def test_extract_tool_calls_streaming_malformed_name_before_marker(
|
|
mistral_tool_parser,
|
|
):
|
|
# Streaming counterpart: once the args start the slot can never go back to
|
|
# naming, so the (empty) name is already final and streams immediately
|
|
# alongside the args.
|
|
model_output = 'bash[TOOL_CALLS]{"command": "cat file.py"}'
|
|
mid_delta = mistral_tool_parser.extract_tool_calls_streaming(
|
|
previous_text="",
|
|
current_text=model_output,
|
|
delta_text=model_output,
|
|
previous_token_ids=[],
|
|
current_token_ids=[],
|
|
delta_token_ids=[],
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
assert mid_delta is not None
|
|
assert mid_delta.content == "bash"
|
|
assert len(mid_delta.tool_calls) == 1
|
|
assert mid_delta.tool_calls[0].function.name == ""
|
|
assert mid_delta.tool_calls[0].function.arguments == '{"command": "cat file.py"'
|
|
|
|
final_delta = mistral_tool_parser.finish_streaming()
|
|
assert final_delta is not None
|
|
assert len(final_delta.tool_calls) == 1
|
|
assert final_delta.tool_calls[0].function.name is None
|
|
assert final_delta.tool_calls[0].function.arguments == "}"
|
|
|
|
|
|
def test_extract_tool_calls_parallel_malformed_then_well_formed(
|
|
mistral_tool_parser,
|
|
):
|
|
# A malformed empty-name call followed by a well-formed one must not
|
|
# regress into brace-splitting: nested braces in the second call's
|
|
# arguments must stay intact, and both calls must be correctly separated.
|
|
model_output = (
|
|
'bash[TOOL_CALLS]{"command": "ls"}'
|
|
'[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}'
|
|
)
|
|
result = mistral_tool_parser.extract_tool_calls(
|
|
model_output, request=_DUMMY_REQUEST
|
|
)
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 2
|
|
assert result.tool_calls[0].function.name == ""
|
|
assert result.tool_calls[0].function.arguments == json.dumps({"command": "ls"})
|
|
assert result.tool_calls[1].function.name == "submit"
|
|
assert result.tool_calls[1].function.arguments == json.dumps(
|
|
{"answer": {"nested": 1}}
|
|
)
|
|
assert result.content == "bash"
|
|
|
|
|
|
def _test_extract_tool_calls_streaming(
|
|
tool_parser,
|
|
tokenizer,
|
|
model_output,
|
|
tools,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
driver: str = "legacy",
|
|
reasoning_parser: str | None = None,
|
|
):
|
|
other_content: str = ""
|
|
function_names: list[str] = []
|
|
function_args_strs: list[str] = []
|
|
tool_call_idx: int = -1
|
|
tool_call_ids: list[str | None] = []
|
|
|
|
for delta_message in stream_delta_message_generator(
|
|
tool_parser,
|
|
tokenizer,
|
|
model_output,
|
|
tools,
|
|
driver=driver,
|
|
reasoning_parser=reasoning_parser,
|
|
):
|
|
# role should never be streamed from tool parser
|
|
assert not delta_message.role
|
|
|
|
if delta_message.content:
|
|
other_content += delta_message.content
|
|
|
|
streamed_tool_calls = delta_message.tool_calls
|
|
|
|
if streamed_tool_calls and len(streamed_tool_calls) > 0:
|
|
# On the final finished=True delta the engine's _flush_engine_parsers
|
|
# may append the flushed '}' as a separate DeltaToolCall for the
|
|
# same index without coalescing. Merge entries sharing an index so
|
|
# the "one diff per delta" invariant can be checked after merging.
|
|
if len(streamed_tool_calls) > 1:
|
|
assert len({tc.index for tc in streamed_tool_calls}) == 1, (
|
|
"only within-chunk finish/flush of one tool is allowed; "
|
|
"distinct indices in one delta indicate a regression: "
|
|
f"{[tc.index for tc in streamed_tool_calls]}"
|
|
)
|
|
by_index: dict[int, DeltaToolCall] = {}
|
|
for tc in streamed_tool_calls:
|
|
existing = by_index.get(tc.index)
|
|
if existing is None:
|
|
by_index[tc.index] = tc
|
|
else:
|
|
if tc.id and not existing.id:
|
|
existing.id = tc.id
|
|
if tc.type and not existing.type:
|
|
existing.type = tc.type
|
|
if tc.function and existing.function and tc.function.arguments:
|
|
existing.function.arguments = (
|
|
existing.function.arguments or ""
|
|
) + tc.function.arguments
|
|
streamed_tool_calls = list(by_index.values())
|
|
|
|
# make sure only one diff is present - correct even for parallel
|
|
assert len(streamed_tool_calls) == 1
|
|
tool_call = streamed_tool_calls[0]
|
|
|
|
# if a new tool is being called, set up empty arguments
|
|
if tool_call.index != tool_call_idx:
|
|
tool_call_idx = tool_call.index
|
|
function_args_strs.append("")
|
|
tool_call_ids.append(None)
|
|
|
|
# if a tool call ID is streamed, make sure one hasn't been already
|
|
if tool_call.id and not tool_call_ids[tool_call.index]:
|
|
tool_call_ids[tool_call.index] = tool_call.id
|
|
|
|
# if parts of the function start being streamed
|
|
if tool_call.function:
|
|
# if the function name is defined, set it. it should be streamed
|
|
# IN ENTIRETY, exactly one time.
|
|
if tool_call.function.name:
|
|
assert isinstance(tool_call.function.name, str)
|
|
function_names.append(tool_call.function.name)
|
|
|
|
if tool_call.function.arguments:
|
|
# make sure they're a string and then add them to the list
|
|
assert isinstance(tool_call.function.arguments, str)
|
|
|
|
function_args_strs[tool_call.index] += tool_call.function.arguments
|
|
|
|
assert other_content == expected_content
|
|
|
|
actual_tool_calls = [
|
|
ToolCall(
|
|
id=tool_call_id,
|
|
function=FunctionCall(
|
|
name=function_name,
|
|
arguments=partial_json_parser.ensure_json(
|
|
function_args_str, Allow.OBJ | Allow.STR
|
|
),
|
|
),
|
|
)
|
|
for tool_call_id, function_name, function_args_str in zip(
|
|
tool_call_ids, function_names, function_args_strs
|
|
)
|
|
]
|
|
assert_tool_calls(actual_tool_calls, expected_tool_calls)
|
|
|
|
|
|
@pytest.mark.parametrize("driver", ["legacy", "engine"])
|
|
@pytest.mark.parametrize(
|
|
ids=[
|
|
"no_tools",
|
|
"single_tool_add",
|
|
"single_tool_add_strings",
|
|
"single_tool_weather",
|
|
"argument_before_name",
|
|
"argument_before_name_and_name_in_argument",
|
|
"multiple_tools",
|
|
"trailing_data_after_json",
|
|
],
|
|
argnames=["model_output", "expected_tool_calls", "expected_content"],
|
|
argvalues=[
|
|
("""This is a test""", [], """This is a test"""),
|
|
(
|
|
"""[TOOL_CALLS] [ {"name":"add" , "arguments" : {"a": 3, "b": 4} } ]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3, "b": 4})
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "add", "arguments":{"a": "3", "b": "4"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": "3", "b": "4"})
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"arguments": {"city": "San Francisco", "state": "CA", "unit": "celsius"}, "name": "get_current_weather"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"arguments": {"name": "John Doe"}, "name": "get_age"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_age",
|
|
arguments=json.dumps(
|
|
{
|
|
"name": "John Doe",
|
|
}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "add", "arguments": {"a": 3.5, "b": 4}}, {"name": "get_current_weather", "arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
),
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
"""[TOOL_CALLS] [{"name": "get_current_weather", "arguments":{"city": "Dallas", "state": "TX", "unit": "fahrenheit"}}]\nextra trailing data""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{
|
|
"city": "Dallas",
|
|
"state": "TX",
|
|
"unit": "fahrenheit",
|
|
}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"\nextra trailing data",
|
|
),
|
|
],
|
|
)
|
|
def test_extract_tool_calls_streaming_pre_v11_tokenizer(
|
|
mistral_pre_v11_tool_parser,
|
|
mistral_pre_v11_tokenizer,
|
|
model_output,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
driver,
|
|
):
|
|
_test_extract_tool_calls_streaming(
|
|
mistral_pre_v11_tool_parser,
|
|
mistral_pre_v11_tokenizer,
|
|
model_output,
|
|
None,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
driver=driver,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"driver,reasoning_parser",
|
|
[("legacy", None), ("engine", None), ("engine", "mistral")],
|
|
ids=["legacy", "engine_no_reasoning", "engine_with_reasoning"],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
ids=[
|
|
"single_tool_add",
|
|
"single_tool_add_strings",
|
|
"multiple_tools",
|
|
],
|
|
argnames=["tools", "expected_tool_calls", "expected_content"],
|
|
argvalues=[
|
|
(
|
|
[("add", '{"a": 3, "b": 4}')],
|
|
# [TOOL_CALLS]add{"a": 3, "b": 4}
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3, "b": 4})
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
[("add_two_strings", '{"a": "3", "b": "4"}')],
|
|
# [TOOL_CALLS]add_two_strings{"a": "3", "b": "4"}
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_two_strings",
|
|
arguments=json.dumps({"a": "3", "b": "4"}),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
[
|
|
("add", '{"a": 3.5, "b": 4}'),
|
|
(
|
|
"get_current_weather",
|
|
'{"city": "San Francisco", "state": "CA", "unit": "celsius"}', # noqa: E501
|
|
),
|
|
],
|
|
# [TOOL_CALLS]add{"a": 3.5, "b": 4}[TOOL_CALLS]get_current_weather{"city": "San Francisco", "state": "CA", "unit": "celsius"} # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
),
|
|
],
|
|
"",
|
|
),
|
|
],
|
|
)
|
|
def test_extract_tool_calls_streaming(
|
|
mistral_tool_parser,
|
|
mistral_tokenizer,
|
|
tools,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
driver,
|
|
reasoning_parser,
|
|
):
|
|
_test_extract_tool_calls_streaming(
|
|
mistral_tool_parser,
|
|
mistral_tokenizer,
|
|
None,
|
|
tools,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
driver=driver,
|
|
reasoning_parser=reasoning_parser,
|
|
)
|
|
|
|
|
|
# Drives extract_tool_calls_streaming directly (bespoke detokenization loop),
|
|
# not via stream_delta_message_generator — driver parametrization not needed.
|
|
def test_extract_tool_calls_streaming_v11_no_tools(
|
|
mistral_tool_parser, mistral_tokenizer
|
|
):
|
|
model_output = "This is a test"
|
|
# add_special_tokens=False: real serving streams only generated tokens.
|
|
all_token_ids = mistral_tokenizer.encode(model_output, add_special_tokens=False)
|
|
skip_special = isinstance(mistral_tokenizer, MistralTokenizer)
|
|
collected_content = ""
|
|
previous_text = ""
|
|
previous_tokens = None
|
|
prefix_offset = 0
|
|
read_offset = 0
|
|
for i in range(len(all_token_ids)):
|
|
current_token_ids = all_token_ids[: i + 1]
|
|
previous_token_ids = all_token_ids[:i]
|
|
delta_token_ids = [all_token_ids[i]]
|
|
|
|
new_tokens, delta_text, prefix_offset, read_offset = detokenize_incrementally(
|
|
tokenizer=mistral_tokenizer,
|
|
all_input_ids=current_token_ids,
|
|
prev_tokens=previous_tokens,
|
|
prefix_offset=prefix_offset,
|
|
read_offset=read_offset,
|
|
skip_special_tokens=skip_special,
|
|
spaces_between_special_tokens=True,
|
|
)
|
|
current_text = previous_text + delta_text
|
|
previous_tokens = (
|
|
previous_tokens + new_tokens if previous_tokens else new_tokens
|
|
)
|
|
|
|
delta_message = mistral_tool_parser.extract_tool_calls_streaming(
|
|
previous_text=previous_text,
|
|
current_text=current_text,
|
|
delta_text=delta_text,
|
|
previous_token_ids=previous_token_ids,
|
|
current_token_ids=current_token_ids,
|
|
delta_token_ids=delta_token_ids,
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
if delta_message and delta_message.content:
|
|
collected_content += delta_message.content
|
|
if delta_message:
|
|
assert not delta_message.tool_calls
|
|
|
|
previous_text = current_text
|
|
|
|
assert collected_content == model_output
|
|
|
|
|
|
def test_mistral_parser_drops_eos_from_output(mistral_tokenizer):
|
|
"""EOS token must never surface as content/reasoning; literal EOS string
|
|
must be preserved when the EOS token id is absent.
|
|
|
|
The engine drops special tokens by id, so:
|
|
Case A: the real EOS token id causes the text to be dropped.
|
|
Case B: the same EOS string with a non-EOS token id is preserved.
|
|
"""
|
|
if not isinstance(mistral_tokenizer, MistralTokenizer):
|
|
pytest.skip("Requires MistralTokenizer")
|
|
|
|
eos_id: int = mistral_tokenizer.eos_token_id
|
|
eos_text: str = mistral_tokenizer.decode([eos_id])
|
|
assert eos_text
|
|
|
|
# Case A: real EOS token — text must be dropped.
|
|
parser_a = MistralParser(mistral_tokenizer)
|
|
parser_a.initialize_streaming()
|
|
events_a = parser_a._feed(eos_text, [eos_id])
|
|
events_a.extend(parser_a._engine.finish())
|
|
delta_a = parser_a._events_to_delta(events_a, finished=True)
|
|
content_a = (delta_a.content if delta_a else None) or ""
|
|
reasoning_a = (delta_a.reasoning if delta_a else None) or ""
|
|
assert eos_text not in content_a
|
|
assert eos_text not in reasoning_a
|
|
|
|
# Case B: EOS text with a non-EOS token id — text must be preserved.
|
|
# This proves content equal to the EOS string is not silently dropped
|
|
# when it did not come from the real EOS token.
|
|
non_eos_ids = mistral_tokenizer.encode(text="hello", add_special_tokens=False)
|
|
non_eos_id = next(tid for tid in non_eos_ids if tid != eos_id)
|
|
parser_b = MistralParser(mistral_tokenizer)
|
|
parser_b.initialize_streaming()
|
|
events_b = parser_b._feed(eos_text, [non_eos_id])
|
|
events_b.extend(parser_b._engine.finish())
|
|
delta_b = parser_b._events_to_delta(events_b, finished=True)
|
|
content_b = (delta_b.content if delta_b else None) or ""
|
|
reasoning_b = (delta_b.reasoning if delta_b else None) or ""
|
|
assert eos_text in content_b or eos_text in reasoning_b
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"parser_fixture, tokenizer_fixture, model_output,"
|
|
" expected_tool_calls, expected_content",
|
|
[
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""[TOOL_CALLS]add_this_and_that{"a": 3.5, "b": 4}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_this_and_that",
|
|
arguments=json.dumps({"a": 3.5, "b": 4}),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="v11-single_tool_add",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""[TOOL_CALLS]get_current_weather{"city": "San Francisco", "state": "CA", "unit": "celsius"}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="v11-single_tool_weather",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""[TOOL_CALLS]add{"a": 3.5, "b": 4}[TOOL_CALLS]multiply{"a": 3, "b": 6}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="multiply", arguments=json.dumps({"a": 3, "b": 6})
|
|
)
|
|
),
|
|
],
|
|
"",
|
|
id="v11-multiple_tool_calls",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""bla[TOOL_CALLS]add_this_and_that{"a": 3.5, "b": 4}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_this_and_that",
|
|
arguments=json.dumps({"a": 3.5, "b": 4}),
|
|
)
|
|
)
|
|
],
|
|
"bla",
|
|
id="v11-content_before_tool",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""[TOOL_CALLS]add_this_and_that[ARGS]{"a": 3.5, "b": 4}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_this_and_that",
|
|
arguments=json.dumps({"a": 3.5, "b": 4}),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="v11-single_tool_add_args",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""[TOOL_CALLS]add[ARGS]{"a": 3.5, "b": 4}[TOOL_CALLS]multiply[ARGS]{"a": 3, "b": 6}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="multiply", arguments=json.dumps({"a": 3, "b": 6})
|
|
)
|
|
),
|
|
],
|
|
"",
|
|
id="v11-multiple_tool_calls_args",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""bla[TOOL_CALLS]add_this_and_that[ARGS]{"a": 3.5, "b": 4}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add_this_and_that",
|
|
arguments=json.dumps({"a": 3.5, "b": 4}),
|
|
)
|
|
)
|
|
],
|
|
"bla",
|
|
id="v11-content_before_tool_args",
|
|
),
|
|
pytest.param(
|
|
"mistral_tool_parser",
|
|
"mistral_tokenizer",
|
|
"""hi{hi[TOOL_CALLS]bash{"command": "print(\\"hello world!\\")\\nre.compile(r\'{}\')"}""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="bash",
|
|
arguments=json.dumps(
|
|
{"command": "print(\"hello world!\")\nre.compile(r'{}')"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"hi{hi",
|
|
id="v11-complex",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""This is a test""",
|
|
[],
|
|
"""This is a test""",
|
|
id="pre_v11-no_tools",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""[TOOL_CALLS] [ {"name":"add" , "arguments" : {"a": 3, "b": 4} } ]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3, "b": 4})
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="pre_v11-single_tool_add",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""[TOOL_CALLS] [{"name": "add", "arguments":{"a": "3", "b": "4"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": "3", "b": "4"})
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="pre_v11-single_tool_add_strings",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""[TOOL_CALLS] [{"name": "get_current_weather", "arguments": {"city": "San Francisco", "state": "CA", "unit": "celsius"}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="pre_v11-single_tool_weather",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""[TOOL_CALLS] [{"arguments": {"city": "San Francisco", "state": "CA", "unit": "celsius"}, "name": "get_current_weather"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="pre_v11-argument_before_name",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""[TOOL_CALLS] [{"arguments": {"name": "John Doe"}, "name": "get_age"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_age",
|
|
arguments=json.dumps(
|
|
{
|
|
"name": "John Doe",
|
|
}
|
|
),
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
id="pre_v11-argument_before_name_and_name_in_argument",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""[TOOL_CALLS] [{"arguments": {"a": 3.5, "b": 4}, "name": "add"}, {"arguments":{"city": "San Francisco", "state": "CA", "unit": "celsius"}, "name": "get_current_weather"}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{"city": "San Francisco", "state": "CA", "unit": "celsius"}
|
|
),
|
|
)
|
|
),
|
|
],
|
|
"",
|
|
id="pre_v11-multiple_tools",
|
|
),
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
"mistral_pre_v11_tokenizer",
|
|
"""Some text[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]""", # noqa: E501
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 1, "b": 2})
|
|
)
|
|
)
|
|
],
|
|
"Some text",
|
|
id="pre_v11-content_before_tool",
|
|
),
|
|
],
|
|
)
|
|
def test_extract_tool_calls_streaming_one_chunk(
|
|
parser_fixture,
|
|
tokenizer_fixture,
|
|
model_output,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
request,
|
|
):
|
|
tool_parser = request.getfixturevalue(parser_fixture)
|
|
tokenizer = request.getfixturevalue(tokenizer_fixture)
|
|
|
|
# Real serving streams only generated tokens (no BOS/EOS framing) with the
|
|
# control markers as special ids.
|
|
if isinstance(tokenizer, MistralTokenizer) and tokenizer.version >= 11:
|
|
all_token_ids = encode_mistral_output(tokenizer, model_output)
|
|
else:
|
|
all_token_ids = tokenizer.encode(model_output, add_special_tokens=False)
|
|
all_token_ids = fix_tool_call_tokenization(
|
|
all_token_ids, tool_parser, tokenizer
|
|
)
|
|
|
|
delta_message = tool_parser.extract_tool_calls_streaming(
|
|
previous_text="",
|
|
current_text=model_output,
|
|
delta_text=model_output,
|
|
previous_token_ids=[],
|
|
current_token_ids=all_token_ids,
|
|
delta_token_ids=all_token_ids,
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
# The engine buffers the final closing brace ('}') in _args_buffer until
|
|
# finish_streaming() is called, mirroring real serving. Flush it and
|
|
# merge the result so the asserted arguments are complete.
|
|
flush_delta = tool_parser.finish_streaming()
|
|
if flush_delta is not None and flush_delta.tool_calls:
|
|
if delta_message is not None and delta_message.tool_calls:
|
|
for flush_tc in flush_delta.tool_calls:
|
|
for existing_tc in delta_message.tool_calls:
|
|
if existing_tc.index == flush_tc.index and flush_tc.function:
|
|
existing_tc.function = (
|
|
existing_tc.function or DeltaFunctionCall()
|
|
)
|
|
existing_tc.function.arguments = (
|
|
existing_tc.function.arguments or ""
|
|
) + (flush_tc.function.arguments or "")
|
|
elif delta_message is not None:
|
|
delta_message.tool_calls = flush_delta.tool_calls
|
|
else:
|
|
delta_message = flush_delta
|
|
|
|
assert isinstance(delta_message, DeltaMessage)
|
|
assert len(delta_message.tool_calls) == len(expected_tool_calls)
|
|
|
|
assert_tool_calls(delta_message.tool_calls, expected_tool_calls)
|
|
|
|
if delta_message.content is None:
|
|
assert expected_content == ""
|
|
else:
|
|
assert delta_message.content == expected_content
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"parser_fixture, model_output, fake_count, two_phase",
|
|
[
|
|
pytest.param(
|
|
"mistral_pre_v11_tool_parser",
|
|
'[TOOL_CALLS] [{"name": "add", "arguments":{"a": 1, "b": 2}}]',
|
|
30,
|
|
False,
|
|
id="pre_v11",
|
|
),
|
|
],
|
|
)
|
|
def test_fast_detokenization_text_detection(
|
|
parser_fixture, model_output, fake_count, two_phase, request
|
|
):
|
|
"""Regression: bot_token in text but not token_ids (PR #37209).
|
|
|
|
Only the pre-v11 legacy path detects the marker from text; v11+ routes
|
|
through the engine and detects the marker by token id.
|
|
"""
|
|
parser = request.getfixturevalue(parser_fixture)
|
|
# Token IDs that do NOT contain bot_token_id.
|
|
fake_token_ids = list(range(99, 99 + fake_count))
|
|
|
|
if two_phase:
|
|
# First delta: pure content, no bot token yet
|
|
delta_message_before = parser.extract_tool_calls_streaming(
|
|
previous_text="",
|
|
current_text="Hello",
|
|
delta_text="Hello",
|
|
previous_token_ids=[],
|
|
current_token_ids=[99],
|
|
delta_token_ids=[99],
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
assert delta_message_before is not None
|
|
assert delta_message_before.content == "Hello"
|
|
assert not delta_message_before.tool_calls
|
|
|
|
previous_text = "Hello"
|
|
current_text = "Hello" + model_output
|
|
previous_token_ids = [99]
|
|
delta_token_ids = fake_token_ids[1:]
|
|
else:
|
|
previous_text = ""
|
|
current_text = model_output
|
|
previous_token_ids = []
|
|
delta_token_ids = fake_token_ids
|
|
|
|
delta_message = parser.extract_tool_calls_streaming(
|
|
previous_text=previous_text,
|
|
current_text=current_text,
|
|
delta_text=model_output,
|
|
previous_token_ids=previous_token_ids,
|
|
current_token_ids=fake_token_ids,
|
|
delta_token_ids=delta_token_ids,
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
assert delta_message is not None
|
|
assert delta_message.tool_calls is not None
|
|
assert len(delta_message.tool_calls) == 1
|
|
assert delta_message.tool_calls[0].function is not None
|
|
assert delta_message.tool_calls[0].function.name == "add"
|
|
|
|
|
|
def test_extract_tool_calls_streaming_exception_returns_none(
|
|
mistral_pre_v11_tool_parser,
|
|
):
|
|
# v11 routes through the ParserEngine and does not swallow exceptions;
|
|
# only the pre-v11 legacy state-machine path has explicit exception handling.
|
|
parser = mistral_pre_v11_tool_parser
|
|
patched_method = "_extract_tool_calls_streaming_pre_v11_tokenizer"
|
|
current_text = '[TOOL_CALLS] [{"name":"a","arguments":{}}]'
|
|
with patch.object(
|
|
parser._parser_engine, patched_method, side_effect=RuntimeError("boom")
|
|
):
|
|
result = parser.extract_tool_calls_streaming(
|
|
previous_text="",
|
|
current_text=current_text,
|
|
delta_text=current_text,
|
|
previous_token_ids=[],
|
|
current_token_ids=[parser._parser_engine.bot_token_id],
|
|
delta_token_ids=[parser._parser_engine.bot_token_id],
|
|
request=_DUMMY_REQUEST,
|
|
)
|
|
assert result is None
|
|
|
|
|
|
SAMPLE_TOOLS_DICTS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get the weather",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
"required": ["city"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "add",
|
|
"description": "Add two numbers",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "number"},
|
|
"b": {"type": "number"},
|
|
},
|
|
"required": ["a", "b"],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _make_request(**kwargs) -> ChatCompletionRequest:
|
|
defaults: dict = {
|
|
"messages": [],
|
|
"model": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
|
|
"tools": SAMPLE_TOOLS_DICTS,
|
|
"tool_choice": "auto",
|
|
}
|
|
defaults.update(kwargs)
|
|
return ChatCompletionRequest(**defaults)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"request_kwargs,expected_mode,expected_parallel",
|
|
[
|
|
({"tool_choice": "auto"}, MistralToolChoiceEnum.auto, True),
|
|
({"tool_choice": "none"}, MistralToolChoiceEnum.none, True),
|
|
({"tool_choice": "required"}, MistralToolChoiceEnum.required, True),
|
|
({"tool_choice": None, "tools": None}, MistralToolChoiceEnum.auto, True),
|
|
(
|
|
{
|
|
"tool_choice": {
|
|
"type": "function",
|
|
"function": {"name": "get_weather"},
|
|
}
|
|
},
|
|
MistralNamedToolChoice.model_validate(
|
|
{"type": "function", "function": {"name": "get_weather"}}
|
|
),
|
|
True,
|
|
),
|
|
(
|
|
{"tool_choice": "auto", "parallel_tool_calls": False},
|
|
MistralToolChoiceEnum.auto,
|
|
False,
|
|
),
|
|
(
|
|
{"tool_choice": "auto", "response_format": {"type": "text"}},
|
|
MistralToolChoiceEnum.auto,
|
|
True,
|
|
),
|
|
],
|
|
ids=[
|
|
"auto",
|
|
"none",
|
|
"required",
|
|
"null_tool_choice",
|
|
"named_tool_choice",
|
|
"parallel_false",
|
|
"response_format_text",
|
|
],
|
|
)
|
|
def test_adjust_request_grammar_factory(
|
|
mistral_tool_parser: MistralToolParser,
|
|
request_kwargs: dict,
|
|
expected_mode: MistralToolChoice,
|
|
expected_parallel: bool,
|
|
) -> None:
|
|
request = _make_request(**request_kwargs)
|
|
factory = mistral_tool_parser.model_tokenizer.grammar_factory
|
|
|
|
with patch.object(
|
|
factory,
|
|
"get_lark_from_jinja",
|
|
wraps=factory.get_lark_from_jinja,
|
|
) as mock_get_lark:
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
|
|
mock_get_lark.assert_called_once()
|
|
call_kwargs = mock_get_lark.call_args
|
|
|
|
assert call_kwargs.kwargs["mode"] == expected_mode
|
|
assert call_kwargs.kwargs["json_schema"] is None
|
|
assert call_kwargs.kwargs["parallel_tool_calls"] == expected_parallel
|
|
|
|
assert result.structured_outputs is not None
|
|
assert isinstance(result.structured_outputs.grammar, str)
|
|
assert len(result.structured_outputs.grammar) > 0
|
|
|
|
|
|
def test_adjust_request_unsupported_grammar_for_tokenizer(mistral_tokenizer) -> None:
|
|
with patch.object(
|
|
type(mistral_tokenizer),
|
|
"supports_grammar",
|
|
new_callable=lambda: property(lambda self: False),
|
|
):
|
|
parser = MistralToolParser(mistral_tokenizer)
|
|
request = _make_request()
|
|
result = parser.adjust_request(request)
|
|
|
|
assert result.structured_outputs is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_choice,expected_skip",
|
|
[("auto", False), ("none", False)],
|
|
ids=["auto_skip_false", "none_skip_false"],
|
|
)
|
|
def test_adjust_request_non_mistral_tokenizer(
|
|
non_mistral_parser: MistralToolParser,
|
|
tool_choice: str,
|
|
expected_skip: bool,
|
|
) -> None:
|
|
# MistralParser (ParserEngine) always sets skip_special_tokens=False so
|
|
# that special tokens like [TOOL_CALLS] are visible to the parser even
|
|
# when tool_choice="none".
|
|
request = _make_request(tool_choice=tool_choice)
|
|
result = non_mistral_parser.adjust_request(request)
|
|
|
|
assert result.skip_special_tokens is expected_skip
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"so_kwargs",
|
|
[
|
|
{"regex": r"\d+"},
|
|
{"choice": ["a", "b"]},
|
|
{
|
|
"structural_tag": json.dumps(
|
|
{
|
|
"structures": [
|
|
{
|
|
"begin": "<tool>",
|
|
"schema": {"type": "object"},
|
|
"end": "</tool>",
|
|
}
|
|
],
|
|
"triggers": ["<tool>"],
|
|
}
|
|
)
|
|
},
|
|
{"grammar": "start: 'hello'"},
|
|
],
|
|
ids=["regex", "choice", "structural_tag", "grammar"],
|
|
)
|
|
def test_adjust_request_unsupported_structured_outputs(
|
|
mistral_tool_parser: MistralToolParser,
|
|
so_kwargs: dict,
|
|
) -> None:
|
|
request = _make_request(
|
|
structured_outputs=StructuredOutputsParams(**so_kwargs),
|
|
)
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
|
|
assert result.structured_outputs == request.structured_outputs
|
|
|
|
|
|
def test_adjust_request_unsupported_response_format(
|
|
mistral_tool_parser: MistralToolParser,
|
|
) -> None:
|
|
request = _make_request(
|
|
response_format=StructuralTagResponseFormat(
|
|
type="structural_tag",
|
|
format={
|
|
"type": "triggered_tags",
|
|
"tags": [
|
|
{
|
|
"begin": "<tool>",
|
|
"content": {"type": "any_text"},
|
|
"end": "</tool>",
|
|
}
|
|
],
|
|
"triggers": ["<tool>"],
|
|
},
|
|
),
|
|
)
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
assert result.structured_outputs is None
|
|
assert result.response_format == request.response_format
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"so_kwargs,expected_json_schema",
|
|
[
|
|
({"json_object": True}, _DEFAULT_JSON_SCHEMA),
|
|
({"json": '{"type": "object"}'}, {"type": "object"}),
|
|
(
|
|
{"json": {"type": "object", "properties": {"x": {"type": "integer"}}}},
|
|
{"type": "object", "properties": {"x": {"type": "integer"}}},
|
|
),
|
|
],
|
|
ids=["json_object", "json_str", "json_dict"],
|
|
)
|
|
def test_adjust_request_structured_outputs_generates_grammar(
|
|
mistral_tool_parser: MistralToolParser,
|
|
so_kwargs: dict,
|
|
expected_json_schema: str,
|
|
) -> None:
|
|
request = _make_request(
|
|
structured_outputs=StructuredOutputsParams(**so_kwargs),
|
|
)
|
|
factory = mistral_tool_parser.model_tokenizer.grammar_factory
|
|
|
|
with patch.object(
|
|
factory,
|
|
"get_lark_from_jinja",
|
|
wraps=factory.get_lark_from_jinja,
|
|
) as mock_get_lark:
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
|
|
mock_get_lark.assert_called_once()
|
|
assert mock_get_lark.call_args.kwargs["json_schema"] == expected_json_schema
|
|
|
|
assert result.structured_outputs is not None
|
|
assert isinstance(result.structured_outputs.grammar, str)
|
|
assert len(result.structured_outputs.grammar) > 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_format_kwargs,expected_json_schema",
|
|
[
|
|
({"type": "json_object"}, _DEFAULT_JSON_SCHEMA),
|
|
(
|
|
{
|
|
"type": "json_schema",
|
|
"json_schema": {
|
|
"name": "my_schema",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {"x": {"type": "integer"}},
|
|
},
|
|
},
|
|
},
|
|
{"type": "object", "properties": {"x": {"type": "integer"}}},
|
|
),
|
|
],
|
|
ids=["json_object", "json_schema_with_schema"],
|
|
)
|
|
def test_adjust_request_response_format_generates_grammar(
|
|
mistral_tool_parser: MistralToolParser,
|
|
response_format_kwargs: dict,
|
|
expected_json_schema: str,
|
|
) -> None:
|
|
request = _make_request(response_format=response_format_kwargs)
|
|
factory = mistral_tool_parser.model_tokenizer.grammar_factory
|
|
|
|
with patch.object(
|
|
factory,
|
|
"get_lark_from_jinja",
|
|
wraps=factory.get_lark_from_jinja,
|
|
) as mock_get_lark:
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
|
|
mock_get_lark.assert_called_once()
|
|
assert mock_get_lark.call_args.kwargs["json_schema"] == expected_json_schema
|
|
|
|
assert result.structured_outputs is not None
|
|
assert isinstance(result.structured_outputs.grammar, str)
|
|
assert len(result.structured_outputs.grammar) > 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_choice, expected_method, not_called_method",
|
|
[
|
|
("none", "get_lark_for_json_schema", None),
|
|
("auto", "get_lark_from_jinja", "get_lark_for_json_schema"),
|
|
],
|
|
ids=["none_uses_json_schema_factory", "auto_uses_jinja_factory"],
|
|
)
|
|
def test_adjust_request_tool_choice_with_json_schema_factory_routing(
|
|
mistral_tool_parser: MistralToolParser,
|
|
tool_choice: str,
|
|
expected_method: str,
|
|
not_called_method: str | None,
|
|
) -> None:
|
|
request = _make_request(
|
|
tool_choice=tool_choice,
|
|
structured_outputs=StructuredOutputsParams(json='{"type": "object"}'),
|
|
)
|
|
factory = mistral_tool_parser.model_tokenizer.grammar_factory
|
|
|
|
patches = {
|
|
expected_method: patch.object(
|
|
factory,
|
|
expected_method,
|
|
wraps=getattr(factory, expected_method),
|
|
),
|
|
}
|
|
if not_called_method:
|
|
patches[not_called_method] = patch.object(
|
|
factory,
|
|
not_called_method,
|
|
wraps=getattr(factory, not_called_method),
|
|
)
|
|
|
|
with patches[expected_method] as mock_expected:
|
|
ctx = patches[not_called_method] if not_called_method else None
|
|
if ctx:
|
|
with ctx as mock_not_called:
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
mock_not_called.assert_not_called()
|
|
else:
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
|
|
mock_expected.assert_called_once()
|
|
assert mock_expected.call_args.kwargs["json_schema"] == {"type": "object"}
|
|
|
|
assert result.structured_outputs is not None
|
|
assert isinstance(result.structured_outputs.grammar, str)
|
|
assert len(result.structured_outputs.grammar) > 0
|
|
|
|
|
|
def test_grammar_from_parser_default_false() -> None:
|
|
request = _make_request()
|
|
assert request._grammar_from_parser is False
|
|
|
|
|
|
def test_grammar_from_parser_set_by_adjust_request(
|
|
mistral_tool_parser: MistralToolParser,
|
|
) -> None:
|
|
request = _make_request()
|
|
result = mistral_tool_parser.adjust_request(request)
|
|
assert result._grammar_from_parser is True
|
|
|
|
|
|
@pytest.mark.parametrize("driver", ["legacy", "engine"])
|
|
@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5])
|
|
def test_streaming_pre_v11_parallel_calls_batched_deltas(
|
|
mistral_pre_v11_tool_parser, mistral_pre_v11_tokenizer, chunk_size, driver
|
|
):
|
|
"""A batched delta spanning the boundary between two parallel calls must
|
|
keep them on distinct indices (the bug collapsed both onto index 0)."""
|
|
model_output = (
|
|
'[TOOL_CALLS] [{"name": "add", "arguments": {"a": 3.5, "b": 4}}, '
|
|
'{"name": "get_current_weather", "arguments": '
|
|
'{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]'
|
|
)
|
|
names: list[str] = []
|
|
args: list[str] = []
|
|
idx = -1
|
|
for delta_message in stream_delta_message_generator(
|
|
mistral_pre_v11_tool_parser,
|
|
mistral_pre_v11_tokenizer,
|
|
model_output,
|
|
tools=None,
|
|
chunk_size=chunk_size,
|
|
driver=driver,
|
|
):
|
|
for tool_call in delta_message.tool_calls or []:
|
|
if tool_call.index != idx:
|
|
idx = tool_call.index
|
|
args.append("")
|
|
if tool_call.function and tool_call.function.name:
|
|
names.append(tool_call.function.name)
|
|
if tool_call.function and tool_call.function.arguments:
|
|
args[tool_call.index] += tool_call.function.arguments
|
|
|
|
assert names == ["add", "get_current_weather"]
|
|
assert len(args) == 2
|
|
# trailing args of the final call are flushed by the serving layer
|
|
assert json.loads(args[0]) == {"a": 3.5, "b": 4}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"reasoning_encoding",
|
|
["text", "special_token"],
|
|
ids=["text_encoding", "special_token_encoding"],
|
|
)
|
|
def test_content_tool_calls_transition_emits_reasoning_end(
|
|
mistral_tokenizer,
|
|
reasoning_encoding,
|
|
):
|
|
"""(CONTENT, TOOL_CALLS) must emit REASONING_END when reasoning is enabled."""
|
|
|
|
cfg = mistral_config(reasoning_encoding=reasoning_encoding)
|
|
engine = StreamingParserEngine(
|
|
config=cfg, tokenizer=mistral_tokenizer, vocab=mistral_tokenizer.get_vocab()
|
|
)
|
|
engine.skip_tool_parsing = True
|
|
|
|
bot_token_id = mistral_tokenizer.get_vocab().get("[TOOL_CALLS]")
|
|
assert bot_token_id is not None
|
|
|
|
events = engine.feed(delta_text="[TOOL_CALLS]", delta_token_ids=[bot_token_id])
|
|
event_types = [e.type for e in events]
|
|
|
|
assert EventType.REASONING_END in event_types, (
|
|
f"REASONING_END missing for reasoning_encoding={reasoning_encoding!r}; "
|
|
f"got events: {event_types}"
|
|
)
|
|
# Tool call start must NOT appear in skip_tool_parsing mode
|
|
assert EventType.TOOL_CALL_START not in event_types
|
|
|
|
|
|
@pytest.mark.parametrize("chunk_size", [1, 3])
|
|
@pytest.mark.parametrize(
|
|
"tools,expected_tool_calls,expected_content",
|
|
[
|
|
(
|
|
[("add", '{"a": 3, "b": 4}')],
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3, "b": 4})
|
|
)
|
|
)
|
|
],
|
|
"",
|
|
),
|
|
(
|
|
[
|
|
("add", '{"a": 3.5, "b": 4}'),
|
|
(
|
|
"get_current_weather",
|
|
'{"city": "San Francisco", "state": "CA", "unit": "celsius"}',
|
|
),
|
|
],
|
|
[
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="add", arguments=json.dumps({"a": 3.5, "b": 4})
|
|
)
|
|
),
|
|
ToolCall(
|
|
function=FunctionCall(
|
|
name="get_current_weather",
|
|
arguments=json.dumps(
|
|
{
|
|
"city": "San Francisco",
|
|
"state": "CA",
|
|
"unit": "celsius",
|
|
}
|
|
),
|
|
)
|
|
),
|
|
],
|
|
"",
|
|
),
|
|
],
|
|
ids=["single_tool_add", "parallel_tools"],
|
|
)
|
|
def test_reasoning_active_no_think_block_no_leak(
|
|
mistral_tool_parser,
|
|
mistral_tokenizer,
|
|
tools,
|
|
expected_tool_calls,
|
|
expected_content,
|
|
chunk_size,
|
|
):
|
|
"""Tool call without a preceding think block must not leak as content
|
|
when the reasoning parser is active."""
|
|
accumulated_content = ""
|
|
function_names: list[str] = []
|
|
function_args_strs: list[str] = []
|
|
tool_call_idx = -1
|
|
tool_call_ids: list[str | None] = []
|
|
|
|
for delta_message in stream_delta_message_generator(
|
|
mistral_tool_parser,
|
|
mistral_tokenizer,
|
|
model_output=None,
|
|
tools=tools,
|
|
chunk_size=chunk_size,
|
|
driver="engine",
|
|
reasoning_parser="mistral",
|
|
):
|
|
if delta_message.content:
|
|
accumulated_content += delta_message.content
|
|
|
|
for tool_call in delta_message.tool_calls or []:
|
|
if tool_call.index != tool_call_idx:
|
|
tool_call_idx = tool_call.index
|
|
function_args_strs.append("")
|
|
tool_call_ids.append(None)
|
|
if tool_call.id and not tool_call_ids[tool_call.index]:
|
|
tool_call_ids[tool_call.index] = tool_call.id
|
|
if tool_call.function:
|
|
if tool_call.function.name:
|
|
function_names.append(tool_call.function.name)
|
|
if tool_call.function.arguments:
|
|
function_args_strs[tool_call.index] += tool_call.function.arguments
|
|
|
|
assert "[TOOL_CALLS]" not in accumulated_content
|
|
assert "[ARGS]" not in accumulated_content
|
|
assert accumulated_content == expected_content
|
|
|
|
actual_tool_calls = [
|
|
ToolCall(
|
|
id=tc_id,
|
|
function=FunctionCall(
|
|
name=name,
|
|
arguments=partial_json_parser.ensure_json(args, Allow.OBJ | Allow.STR),
|
|
),
|
|
)
|
|
for tc_id, name, args in zip(tool_call_ids, function_names, function_args_strs)
|
|
]
|
|
assert_tool_calls(actual_tool_calls, expected_tool_calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pre-v11 guided schema injection and bare-array extraction tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_choice,expected_items_count",
|
|
[
|
|
("required", 2), # all tools
|
|
(
|
|
{"type": "function", "function": {"name": "get_weather"}},
|
|
1,
|
|
), # named: single tool
|
|
],
|
|
ids=["required", "named"],
|
|
)
|
|
def test_adjust_request_pre_v11_guided_schema_injected(
|
|
mistral_pre_v11_tool_parser: MistralToolParser,
|
|
tool_choice: object,
|
|
expected_items_count: int,
|
|
) -> None:
|
|
request = _make_request(tool_choice=tool_choice)
|
|
result = mistral_pre_v11_tool_parser.adjust_request(request)
|
|
|
|
assert result.structured_outputs is not None
|
|
schema = result.structured_outputs.json
|
|
if isinstance(schema, str):
|
|
schema = json.loads(schema)
|
|
assert schema["type"] == "array"
|
|
assert schema["minItems"] == 1
|
|
items = schema["items"]
|
|
assert "anyOf" in items
|
|
assert len(items["anyOf"]) == expected_items_count
|
|
for entry in items["anyOf"]:
|
|
props = entry["properties"]
|
|
assert "name" in props
|
|
assert "arguments" in props
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_format",
|
|
[{"type": "json_object"}, {"type": "json_schema", "json_schema": {"name": "s"}}],
|
|
ids=["json_object", "json_schema"],
|
|
)
|
|
def test_adjust_request_pre_v11_required_clears_response_format(
|
|
mistral_pre_v11_tool_parser: MistralToolParser,
|
|
response_format: dict,
|
|
) -> None:
|
|
"""required + response_format must inject the tool schema and clear
|
|
response_format so the tool schema is the sole structured-output
|
|
constraint, matching the base ToolParser (otherwise the request either
|
|
hits the "multiple constraints" engine error or, with no constraint,
|
|
rambles to finish_reason='length')."""
|
|
request = _make_request(tool_choice="required", response_format=response_format)
|
|
result = mistral_pre_v11_tool_parser.adjust_request(request)
|
|
|
|
assert result.response_format is None
|
|
assert result.structured_outputs is not None
|
|
schema = result.structured_outputs.json
|
|
if isinstance(schema, str):
|
|
schema = json.loads(schema)
|
|
assert schema["type"] == "array"
|
|
|
|
|
|
def test_adjust_request_non_mistral_tokenizer_required_injects_schema(
|
|
non_mistral_parser: MistralToolParser,
|
|
) -> None:
|
|
"""The guided-schema injection must also fire for non-Mistral (e.g. HF-mode)
|
|
tokenizers driving the Mistral tool parser, mirroring the base ToolParser so
|
|
required + response_format does not fall back to an unconstrained ramble."""
|
|
request = _make_request(
|
|
tool_choice="required", response_format={"type": "json_object"}
|
|
)
|
|
result = non_mistral_parser.adjust_request(request)
|
|
|
|
assert result.response_format is None
|
|
assert result.structured_outputs is not None
|
|
assert result.structured_outputs.json is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_choice",
|
|
["auto", "none"],
|
|
ids=["auto", "none"],
|
|
)
|
|
def test_adjust_request_pre_v11_no_injection_for_auto_none(
|
|
mistral_pre_v11_tool_parser: MistralToolParser,
|
|
tool_choice: str,
|
|
) -> None:
|
|
request = _make_request(tool_choice=tool_choice)
|
|
result = mistral_pre_v11_tool_parser.adjust_request(request)
|
|
|
|
assert result.structured_outputs is None
|
|
|
|
|
|
def test_legacy_extract_tool_calls_guided_bare_array_required(
|
|
mistral_pre_v11_tool_parser: MistralToolParser,
|
|
) -> None:
|
|
model_output = '[{"name": "get_current_weather", "arguments": {"city": "Dallas"}}]'
|
|
request = _make_request(tool_choice="required")
|
|
|
|
result = mistral_pre_v11_tool_parser.extract_tool_calls(
|
|
model_output, request=request
|
|
)
|
|
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
tc = result.tool_calls[0]
|
|
assert tc.function.name == "get_current_weather"
|
|
assert json.loads(tc.function.arguments) == {"city": "Dallas"}
|
|
assert isinstance(tc.id, str)
|
|
assert len(tc.id) == 9
|
|
assert result.content is None
|
|
|
|
|
|
def test_legacy_extract_tool_calls_none_with_tools(
|
|
mistral_pre_v11_tool_parser: MistralToolParser,
|
|
) -> None:
|
|
model_output = (
|
|
'[TOOL_CALLS] [{"name": "get_current_weather",'
|
|
' "arguments": {"city": "Dallas"}}]'
|
|
)
|
|
request = _make_request(tool_choice="none")
|
|
|
|
result = mistral_pre_v11_tool_parser.extract_tool_calls(
|
|
model_output, request=request
|
|
)
|
|
|
|
assert not result.tools_called
|
|
assert result.tool_calls == []
|
|
assert result.content == model_output
|
|
|
|
|
|
def test_guided_streaming_required_pre_v11(
|
|
mistral_pre_v11_tool_parser: MistralToolParser,
|
|
mistral_pre_v11_tokenizer,
|
|
) -> None:
|
|
model_output = '[{"name": "get_current_weather", "arguments": {"city": "Dallas"}}]'
|
|
request = _make_request(tool_choice="required")
|
|
all_token_ids = mistral_pre_v11_tokenizer.encode(
|
|
model_output, add_special_tokens=False
|
|
)
|
|
|
|
function_name: str | None = None
|
|
function_args = ""
|
|
tool_call_id: str | None = None
|
|
previous_text = ""
|
|
previous_tokens = None
|
|
prefix_offset = 0
|
|
read_offset = 0
|
|
|
|
for i, token_id in enumerate(all_token_ids):
|
|
(new_tokens, delta_text, prefix_offset, read_offset) = detokenize_incrementally(
|
|
tokenizer=mistral_pre_v11_tokenizer,
|
|
all_input_ids=all_token_ids[: i + 1],
|
|
prev_tokens=previous_tokens,
|
|
prefix_offset=prefix_offset,
|
|
read_offset=read_offset,
|
|
skip_special_tokens=False,
|
|
spaces_between_special_tokens=True,
|
|
)
|
|
previous_tokens = (
|
|
(previous_tokens + new_tokens) if previous_tokens else new_tokens
|
|
)
|
|
current_text = previous_text + delta_text
|
|
|
|
delta_message = mistral_pre_v11_tool_parser.extract_tool_calls_streaming(
|
|
previous_text=previous_text,
|
|
current_text=current_text,
|
|
delta_text=delta_text,
|
|
previous_token_ids=all_token_ids[:i],
|
|
current_token_ids=all_token_ids[: i + 1],
|
|
delta_token_ids=[token_id],
|
|
request=request,
|
|
)
|
|
previous_text = current_text
|
|
|
|
if delta_message and delta_message.tool_calls:
|
|
for tc in delta_message.tool_calls:
|
|
if tc.id and not tool_call_id:
|
|
tool_call_id = tc.id
|
|
if tc.function:
|
|
if tc.function.name:
|
|
function_name = tc.function.name
|
|
if tc.function.arguments:
|
|
function_args += tc.function.arguments
|
|
|
|
assert function_name == "get_current_weather"
|
|
assert tool_call_id is not None
|
|
assert len(tool_call_id) == 9
|
|
assert json.loads(function_args) == {"city": "Dallas"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Malformed-name-before-marker matrix: {malformed, well-formed, mixed
|
|
# parallel} x {streaming, non-streaming}.
|
|
#
|
|
# These tests drive a real MistralParser built from the module's
|
|
# `mistral_tokenizer` fixture (grammar-capable v11+ tokenizer, so the
|
|
# declarative ParserEngine path is exercised, not the pre-v11 legacy state
|
|
# machine) with actual bash/search_replace/submit tools, mirroring the tools
|
|
# present in the real captured traces this bug was found in.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BASH_SEARCH_SUBMIT_TOOLS_DICTS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "bash",
|
|
"description": "Run a bash command",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"command": {"type": "string"}},
|
|
"required": ["command"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_replace",
|
|
"description": "Search and replace text in a file",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"search": {"type": "string"},
|
|
"replace": {"type": "string"},
|
|
},
|
|
"required": ["path", "search", "replace"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "submit",
|
|
"description": "Submit the final answer",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"answer": {"type": "object"}},
|
|
"required": ["answer"],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _malformed_matrix_request() -> ChatCompletionRequest:
|
|
return ChatCompletionRequest(
|
|
messages=[],
|
|
model="test",
|
|
tools=_BASH_SEARCH_SUBMIT_TOOLS_DICTS,
|
|
tool_choice="auto",
|
|
)
|
|
|
|
|
|
def _stream_via_parse_delta(
|
|
parser: MistralParser,
|
|
tokenizer: MistralTokenizer,
|
|
model_output: str,
|
|
request: ChatCompletionRequest,
|
|
) -> tuple[str, list[str | None], list[str]]:
|
|
"""Feed `model_output` through `parser.parse_delta` token-by-token.
|
|
|
|
This is the exact API production serving drives on every generated
|
|
token (see `ParserEngine.parse_delta`), unlike
|
|
`extract_tool_calls_streaming` which some legacy call sites drive with
|
|
pre-computed previous/current text windows.
|
|
"""
|
|
parser.initialize_streaming()
|
|
all_token_ids = encode_mistral_output(tokenizer, model_output)
|
|
|
|
content = ""
|
|
tool_call_idx = -1
|
|
names: list[str | None] = []
|
|
args: list[str] = []
|
|
|
|
previous_tokens = None
|
|
prefix_offset = 0
|
|
read_offset = 0
|
|
for i, token_id in enumerate(all_token_ids):
|
|
new_tokens, delta_text, prefix_offset, read_offset = detokenize_incrementally(
|
|
tokenizer=tokenizer,
|
|
all_input_ids=all_token_ids[: i + 1],
|
|
prev_tokens=previous_tokens,
|
|
prefix_offset=prefix_offset,
|
|
read_offset=read_offset,
|
|
skip_special_tokens=False,
|
|
spaces_between_special_tokens=True,
|
|
)
|
|
previous_tokens = (
|
|
previous_tokens + new_tokens if previous_tokens else new_tokens
|
|
)
|
|
finished = i == len(all_token_ids) - 1
|
|
|
|
delta_message = parser.parse_delta(
|
|
delta_text=delta_text,
|
|
delta_token_ids=[token_id],
|
|
request=request,
|
|
prompt_token_ids=[1],
|
|
finished=finished,
|
|
)
|
|
if delta_message is None:
|
|
continue
|
|
if delta_message.content:
|
|
content += delta_message.content
|
|
for tool_call in delta_message.tool_calls or []:
|
|
if tool_call.index != tool_call_idx:
|
|
tool_call_idx = tool_call.index
|
|
names.append(None)
|
|
args.append("")
|
|
if tool_call.function:
|
|
if tool_call.function.name is not None:
|
|
names[tool_call.index] = tool_call.function.name
|
|
if tool_call.function.arguments:
|
|
args[tool_call.index] += tool_call.function.arguments
|
|
|
|
return content, names, args
|
|
|
|
|
|
_MALFORMED_MATRIX_CASES = [
|
|
pytest.param(
|
|
# 2x2 corner: name present, `[ARGS]` token present.
|
|
'[TOOL_CALLS]bash[ARGS]{"command": "ls -la"}',
|
|
["bash"],
|
|
[json.dumps({"command": "ls -la"})],
|
|
None,
|
|
id="wellformed_single",
|
|
),
|
|
pytest.param(
|
|
# 2x2 corner: name present, no `[ARGS]` token.
|
|
'[TOOL_CALLS]bash{"command": "ls -la"}',
|
|
["bash"],
|
|
[json.dumps({"command": "ls -la"})],
|
|
None,
|
|
id="wellformed_single_no_args_token",
|
|
),
|
|
pytest.param(
|
|
# 2x2 corner: name skipped, no `[ARGS]` token. "bash" lands in
|
|
# content before the marker, then generation jumps straight to
|
|
# args (no NAME slot, no [ARGS]).
|
|
'bash[TOOL_CALLS]{"command": "cat file.py"}',
|
|
[""],
|
|
[json.dumps({"command": "cat file.py"})],
|
|
"bash",
|
|
id="malformed_single",
|
|
),
|
|
pytest.param(
|
|
# 2x2 corner: name skipped, `[ARGS]` token present.
|
|
'bash[TOOL_CALLS][ARGS]{"command": "cat file.py"}',
|
|
[""],
|
|
[json.dumps({"command": "cat file.py"})],
|
|
"bash",
|
|
id="malformed_single_args_token",
|
|
),
|
|
pytest.param(
|
|
# parallel well-formed x2; the second call's nested braces in its
|
|
# arguments must not confuse call splitting.
|
|
'[TOOL_CALLS]bash[ARGS]{"command": "ls"}'
|
|
'[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}',
|
|
["bash", "submit"],
|
|
[json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
|
|
None,
|
|
id="parallel_wellformed",
|
|
),
|
|
pytest.param(
|
|
# parallel malformed x2: both calls skip the NAME slot entirely
|
|
# (marker immediately followed by `{`), each ending with an empty
|
|
# name and correctly separated (nested-brace) arguments.
|
|
'bash[TOOL_CALLS]{"command": "ls"}[TOOL_CALLS]{"answer": {"nested": 1}}',
|
|
["", ""],
|
|
[json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
|
|
"bash",
|
|
id="parallel_malformed",
|
|
),
|
|
pytest.param(
|
|
# parallel malformed x2, `[ARGS]` token variant: both calls skip
|
|
# the NAME slot but enter TOOL_ARGS via `[ARGS]` instead of `{`.
|
|
'bash[TOOL_CALLS][ARGS]{"command": "ls"}'
|
|
'[TOOL_CALLS][ARGS]{"answer": {"nested": 1}}',
|
|
["", ""],
|
|
[json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
|
|
"bash",
|
|
id="parallel_malformed_args_token",
|
|
),
|
|
pytest.param(
|
|
# mixed: malformed first call, well-formed second call. The
|
|
# important case: proves calls are split on the `[TOOL_CALLS]`
|
|
# marker, not on braces — the first call's simple args and the
|
|
# second call's nested-brace args must not bleed into each other.
|
|
'bash[TOOL_CALLS]{"command": "ls"}'
|
|
'[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}',
|
|
["", "submit"],
|
|
[json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
|
|
"bash",
|
|
id="mixed_malformed_then_wellformed",
|
|
),
|
|
pytest.param(
|
|
# mixed, `[ARGS]` token variant: malformed first call enters
|
|
# TOOL_ARGS via `[ARGS]` instead of `{`, second call well-formed.
|
|
'bash[TOOL_CALLS][ARGS]{"command": "ls"}'
|
|
'[TOOL_CALLS]submit[ARGS]{"answer": {"nested": 1}}',
|
|
["", "submit"],
|
|
[json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
|
|
"bash",
|
|
id="mixed_malformed_args_token_then_wellformed",
|
|
),
|
|
pytest.param(
|
|
# mixed: well-formed first call, malformed second call (NAME slot
|
|
# skipped entirely for the second call).
|
|
'[TOOL_CALLS]bash[ARGS]{"command": "ls"}[TOOL_CALLS]{"answer": {"nested": 1}}',
|
|
["bash", ""],
|
|
[json.dumps({"command": "ls"}), json.dumps({"answer": {"nested": 1}})],
|
|
None,
|
|
id="mixed_wellformed_then_malformed",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_output,expected_names,expected_args,expected_content",
|
|
_MALFORMED_MATRIX_CASES,
|
|
)
|
|
def test_malformed_tool_call_matrix_non_streaming(
|
|
mistral_tokenizer,
|
|
model_output,
|
|
expected_names,
|
|
expected_args,
|
|
expected_content,
|
|
):
|
|
parser = MistralParser(mistral_tokenizer)
|
|
request = _malformed_matrix_request()
|
|
|
|
result = parser.extract_tool_calls_from_content(model_output, request)
|
|
|
|
assert result.tools_called
|
|
assert [tc.function.name for tc in result.tool_calls] == expected_names
|
|
assert [tc.function.arguments for tc in result.tool_calls] == expected_args
|
|
assert result.content == expected_content
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_output,expected_names,expected_args,expected_content",
|
|
_MALFORMED_MATRIX_CASES,
|
|
)
|
|
def test_malformed_tool_call_matrix_streaming(
|
|
mistral_tokenizer,
|
|
model_output,
|
|
expected_names,
|
|
expected_args,
|
|
expected_content,
|
|
):
|
|
parser = MistralParser(mistral_tokenizer)
|
|
request = _malformed_matrix_request()
|
|
|
|
content, names, args = _stream_via_parse_delta(
|
|
parser, mistral_tokenizer, model_output, request
|
|
)
|
|
|
|
assert content == (expected_content or "")
|
|
assert names == expected_names
|
|
assert args == expected_args
|
|
|
|
|
|
# Real payload captured from a production SWE-bench-agent.
|
|
_REAL_LEAK_CONTENT = 'bash[TOOL_CALLS]{"command": "cd /testbed && git diff xarray/core/weighted.py"}ometracediff --git a/xarray/core/weighted.py b/xarray/core/weighted.py\\nindex 5f2d138e..9c38b0b7 100644\\n--- a/xarray/core/weighted.py\\n+++ b/xarray/core/weighted.py\\n@@ -1,4 +1,6 @@\\n from typing import TYPE_CHECKING, Hashable, Iterable, Optional, Union, overload\\n+\\n+import numpy as np\\n \\n from .computation import dot\\n from .options import _get_keep_attrs\\n@@ -105,6 +107,10 @@ class Weighted:\\n )\\n \\n self.obj = obj\\n- self.weights = weights\\n+ # Ensure weights are numeric (float64) to avoid boolean arithmetic issues\\n+ # in dot products and weighted operations\\n+ if not np.issubdtype(weights.dtype, np.number):\\n+ weights = weights.astype(np.float64)\\n+ self.weights = weights\\n "}现在 hareview the changes we made:bash[ARGS]{"command": "cat /testbed/xarray/core/weighted.py | head -120 | tail -20"}' # noqa: E501
|
|
|
|
|
|
def test_extract_tool_calls_real_captured_malformed_output(mistral_tokenizer):
|
|
parser = MistralParser(mistral_tokenizer)
|
|
request = _malformed_matrix_request()
|
|
|
|
result = parser.extract_tool_calls_from_content(_REAL_LEAK_CONTENT, request)
|
|
|
|
assert result.tools_called
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == ""
|
|
assert result.tool_calls[0].function.arguments == json.dumps(
|
|
{"command": "cd /testbed && git diff xarray/core/weighted.py"}
|
|
)
|
|
assert result.content == "bash"
|