mirror of
https://github.com/PaddlePaddle/FastDeploy.git
synced 2026-04-23 00:17:25 +08:00
[BugFix] fix tool call parser (#7369)
* fix tool call parser * add unit test * fix unit test * add unit test
This commit is contained in:
@@ -111,7 +111,7 @@ class ErnieX1ToolParser(ToolParser):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return ExtractedToolCallInformation(
|
return ExtractedToolCallInformation(
|
||||||
tools_called=True,
|
tools_called=len(tool_calls) > 0,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -182,11 +182,13 @@ class ErnieX1ToolParser(ToolParser):
|
|||||||
logger.debug("attempting to close tool call, but no tool call")
|
logger.debug("attempting to close tool call, but no tool call")
|
||||||
return None
|
return None
|
||||||
diff = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
|
diff = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
|
||||||
if diff:
|
if diff is not None:
|
||||||
if '"}' not in delta_text:
|
if "}" not in delta_text:
|
||||||
|
return None
|
||||||
|
end_loc = delta_text.rindex("}")
|
||||||
|
diff = delta_text[:end_loc]
|
||||||
|
if not diff:
|
||||||
return None
|
return None
|
||||||
end_loc = delta_text.rindex('"}')
|
|
||||||
diff = delta_text[:end_loc] + '"}'
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Finishing tool and found diff that had not " "been streamed yet: %s",
|
"Finishing tool and found diff that had not " "been streamed yet: %s",
|
||||||
diff,
|
diff,
|
||||||
@@ -248,15 +250,15 @@ class ErnieX1ToolParser(ToolParser):
|
|||||||
prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
|
prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
|
||||||
cur_arguments = current_tool_call.get("arguments")
|
cur_arguments = current_tool_call.get("arguments")
|
||||||
|
|
||||||
if not cur_arguments and not prev_arguments:
|
if cur_arguments is None and prev_arguments is None:
|
||||||
logger.debug("Skipping text %s - no arguments", delta_text)
|
logger.debug("Skipping text %s - no arguments", delta_text)
|
||||||
delta = None
|
delta = None
|
||||||
|
|
||||||
elif not cur_arguments and prev_arguments:
|
elif cur_arguments is None and prev_arguments is not None:
|
||||||
logger.error("should be impossible to have arguments reset " "mid-call. skipping streaming anything.")
|
logger.error("should be impossible to have arguments reset " "mid-call. skipping streaming anything.")
|
||||||
delta = None
|
delta = None
|
||||||
|
|
||||||
elif cur_arguments and not prev_arguments:
|
elif cur_arguments is not None and prev_arguments is None:
|
||||||
function_name = current_tool_call.get("name")
|
function_name = current_tool_call.get("name")
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'\{"name":\s*"' + re.escape(function_name) + r'"\s*,\s*"arguments":\s*(.*)',
|
r'\{"name":\s*"' + re.escape(function_name) + r'"\s*,\s*"arguments":\s*(.*)',
|
||||||
@@ -265,6 +267,19 @@ class ErnieX1ToolParser(ToolParser):
|
|||||||
)
|
)
|
||||||
if match:
|
if match:
|
||||||
cur_arguments_json = match.group(1)
|
cur_arguments_json = match.group(1)
|
||||||
|
# When tool_call_portion is complete JSON, the regex
|
||||||
|
# (.*) over-captures the outer closing brace of the
|
||||||
|
# tool call object. Strip it from both
|
||||||
|
# cur_arguments_json and delta_text, consistent with
|
||||||
|
# the both-have-arguments branch handling.
|
||||||
|
try:
|
||||||
|
json.loads(tool_call_portion)
|
||||||
|
if cur_arguments_json.endswith("}"):
|
||||||
|
cur_arguments_json = cur_arguments_json[:-1]
|
||||||
|
if delta_text.rstrip().endswith("}"):
|
||||||
|
delta_text = delta_text.rstrip()[:-1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
cur_arguments_json = json.dumps(cur_arguments, ensure_ascii=False)
|
cur_arguments_json = json.dumps(cur_arguments, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -287,7 +302,7 @@ class ErnieX1ToolParser(ToolParser):
|
|||||||
)
|
)
|
||||||
self.streamed_args_for_tool[self.current_tool_id] += arguments_delta
|
self.streamed_args_for_tool[self.current_tool_id] += arguments_delta
|
||||||
|
|
||||||
elif cur_arguments and prev_arguments:
|
elif cur_arguments is not None and prev_arguments is not None:
|
||||||
try:
|
try:
|
||||||
json.loads(tool_call_portion)
|
json.loads(tool_call_portion)
|
||||||
is_complete_json = True
|
is_complete_json = True
|
||||||
|
|||||||
@@ -60,6 +60,50 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
|
|
||||||
return ErnieX1ToolParser(tokenizer=DummyTokenizer())
|
return ErnieX1ToolParser(tokenizer=DummyTokenizer())
|
||||||
|
|
||||||
|
def _simulate_streaming(self, parser, deltas):
|
||||||
|
"""Simulate a multi-step streaming flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parser: ErnieX1ToolParser instance
|
||||||
|
deltas: list of delta text strings, each representing one streaming step
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of results from each extract_tool_calls_streaming call
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
previous_text = ""
|
||||||
|
token_id = 0
|
||||||
|
previous_token_ids = []
|
||||||
|
|
||||||
|
for delta in deltas:
|
||||||
|
current_text = previous_text + delta
|
||||||
|
# When delta contains <tool_call> plus more content, use 2 tokens
|
||||||
|
# so that the parser extracts tool_call_portion (line 163-164)
|
||||||
|
if "<tool_call>" in delta and delta != "<tool_call>":
|
||||||
|
n_tokens = 2
|
||||||
|
else:
|
||||||
|
n_tokens = 1
|
||||||
|
|
||||||
|
delta_token_ids = list(range(token_id + 1, token_id + 1 + n_tokens))
|
||||||
|
token_id += n_tokens
|
||||||
|
current_token_ids = previous_token_ids + delta_token_ids
|
||||||
|
|
||||||
|
result = parser.extract_tool_calls_streaming(
|
||||||
|
previous_text,
|
||||||
|
current_text,
|
||||||
|
delta,
|
||||||
|
previous_token_ids,
|
||||||
|
current_token_ids,
|
||||||
|
delta_token_ids,
|
||||||
|
self.dummy_request,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
previous_text = current_text
|
||||||
|
previous_token_ids = list(current_token_ids)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
# ==================== __init__ tests (lines 60-81) ====================
|
# ==================== __init__ tests (lines 60-81) ====================
|
||||||
|
|
||||||
def test_init_sets_tokens_and_ids(self):
|
def test_init_sets_tokens_and_ids(self):
|
||||||
@@ -116,6 +160,14 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
self.assertTrue(result.tools_called)
|
self.assertTrue(result.tools_called)
|
||||||
self.assertEqual(result.tool_calls[0].function.arguments, "{}")
|
self.assertEqual(result.tool_calls[0].function.arguments, "{}")
|
||||||
|
|
||||||
|
def test_extract_tool_calls_empty_arguments(self):
|
||||||
|
"""Cover: tool call with explicit empty arguments {}"""
|
||||||
|
output = '<tool_call>{"name": "fn", "arguments": {}}</tool_call>'
|
||||||
|
result = self.parser.extract_tool_calls(output, self.dummy_request)
|
||||||
|
self.assertTrue(result.tools_called)
|
||||||
|
self.assertEqual(result.tool_calls[0].function.name, "fn")
|
||||||
|
self.assertEqual(result.tool_calls[0].function.arguments, "{}")
|
||||||
|
|
||||||
def test_extract_tool_calls_nested_arguments(self):
|
def test_extract_tool_calls_nested_arguments(self):
|
||||||
"""Cover regex with nested braces in arguments"""
|
"""Cover regex with nested braces in arguments"""
|
||||||
output = '<tool_call>{"name": "query", "arguments": {"filter": {"age": {"$gt": 18}}}}</tool_call>'
|
output = '<tool_call>{"name": "query", "arguments": {"filter": {"age": {"$gt": 18}}}}</tool_call>'
|
||||||
@@ -182,38 +234,24 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_end_token_in_delta(self):
|
def test_streaming_end_token_in_delta(self):
|
||||||
"""Cover lines 149-156: </tool_call> appears in delta"""
|
"""Cover lines 149-156: </tool_call> appears in delta"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
# First, start a tool call
|
results = self._simulate_streaming(
|
||||||
parser.extract_tool_calls_streaming(
|
parser,
|
||||||
"",
|
[
|
||||||
'<tool_call>{"name": "fn"',
|
'<tool_call>{"name": "fn", "arguments": {"k": "', # start + name + args key
|
||||||
'<tool_call>{"name": "fn"',
|
"v", # args value
|
||||||
[],
|
'"}}</tool_call>', # close with end token in delta
|
||||||
[1, 10],
|
],
|
||||||
[1, 10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# Now stream arguments
|
# Step 1: name sent
|
||||||
parser.extract_tool_calls_streaming(
|
self.assertIsNotNone(results[0])
|
||||||
'<tool_call>{"name": "fn"',
|
self.assertEqual(results[0].tool_calls[0].function.name, "fn")
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
# Step 2: first-args branch, regex extracts '{"k": "v' as arguments_delta
|
||||||
', "arguments": {"k": "v',
|
self.assertIsNotNone(results[1])
|
||||||
[1, 10],
|
self.assertEqual(results[1].tool_calls[0].function.arguments, '{"k": "v')
|
||||||
[1, 10, 20],
|
# Step 3: end token in delta triggers close handling
|
||||||
[20],
|
# delta before </tool_call> is '"}}', close branch: rindex('}')=2, diff='"}'
|
||||||
self.dummy_request,
|
self.assertIsNotNone(results[2])
|
||||||
)
|
self.assertEqual(results[2].tool_calls[0].function.arguments, '"}')
|
||||||
# Close with end token in delta
|
|
||||||
result = parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}}</tool_call>',
|
|
||||||
'"}}</tool_call>',
|
|
||||||
[1, 10, 20],
|
|
||||||
[1, 10, 20, 2],
|
|
||||||
[2],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# Should handle end token
|
|
||||||
self.assertTrue(result is None or isinstance(result, DeltaMessage))
|
|
||||||
|
|
||||||
# --- Lines 160-172: new tool call start (cur_start > cur_end and cur_start > prev_start) ---
|
# --- Lines 160-172: new tool call start (cur_start > cur_end and cur_start > prev_start) ---
|
||||||
|
|
||||||
@@ -255,37 +293,29 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_continue_tool_call_no_name_yet(self):
|
def test_streaming_continue_tool_call_no_name_yet(self):
|
||||||
"""Cover lines 174-176, 220-222: partial JSON without name yet"""
|
"""Cover lines 174-176, 220-222: partial JSON without name yet"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
# Start tool call
|
results = self._simulate_streaming(
|
||||||
parser.extract_tool_calls_streaming("", "<tool_call>", "<tool_call>", [], [1], [1], self.dummy_request)
|
parser,
|
||||||
# Continue with partial content, no name parseable yet
|
[
|
||||||
result = parser.extract_tool_calls_streaming(
|
"<tool_call>", # start tool call
|
||||||
"<tool_call>",
|
'{"na', # partial content, no name yet
|
||||||
'<tool_call>{"na',
|
],
|
||||||
'{"na',
|
|
||||||
[1],
|
|
||||||
[1, 10],
|
|
||||||
[10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(results[0])
|
||||||
|
self.assertIsNone(results[1])
|
||||||
|
|
||||||
def test_streaming_continue_tool_call_with_name(self):
|
def test_streaming_continue_tool_call_with_name(self):
|
||||||
"""Cover lines 174-176, 223-235: name becomes available"""
|
"""Cover lines 174-176, 223-235: name becomes available"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
# Start tool call
|
results = self._simulate_streaming(
|
||||||
parser.extract_tool_calls_streaming("", "<tool_call>", "<tool_call>", [], [1], [1], self.dummy_request)
|
parser,
|
||||||
# Name appears
|
[
|
||||||
result = parser.extract_tool_calls_streaming(
|
"<tool_call>", # start tool call
|
||||||
"<tool_call>",
|
'{"name": "get_weather"', # name appears
|
||||||
'<tool_call>{"name": "get_weather"',
|
],
|
||||||
'{"name": "get_weather"',
|
|
||||||
[1],
|
|
||||||
[1, 10],
|
|
||||||
[10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNone(results[0])
|
||||||
self.assertEqual(result.tool_calls[0].function.name, "get_weather")
|
self.assertIsNotNone(results[1])
|
||||||
|
self.assertEqual(results[1].tool_calls[0].function.name, "get_weather")
|
||||||
self.assertTrue(parser.current_tool_name_sent)
|
self.assertTrue(parser.current_tool_name_sent)
|
||||||
|
|
||||||
# --- Lines 236-237: name not sent and function_name is None ---
|
# --- Lines 236-237: name not sent and function_name is None ---
|
||||||
@@ -293,18 +323,14 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_no_function_name(self):
|
def test_streaming_no_function_name(self):
|
||||||
"""Cover lines 236-237: parsed JSON has no 'name' field"""
|
"""Cover lines 236-237: parsed JSON has no 'name' field"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming("", "<tool_call>", "<tool_call>", [], [1], [1], self.dummy_request)
|
results = self._simulate_streaming(
|
||||||
# Send JSON without name field
|
parser,
|
||||||
result = parser.extract_tool_calls_streaming(
|
[
|
||||||
"<tool_call>",
|
"<tool_call>", # start tool call
|
||||||
'<tool_call>{"arguments": {"k": "v"}}',
|
'{"arguments": {"k": "v"}}', # JSON without name field
|
||||||
'{"arguments": {"k": "v"}}',
|
],
|
||||||
[1],
|
|
||||||
[1, 10],
|
|
||||||
[10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(results[1])
|
||||||
|
|
||||||
# --- Lines 178-200: closing branch (cur_start == cur_end, end >= prev_end) ---
|
# --- Lines 178-200: closing branch (cur_start == cur_end, end >= prev_end) ---
|
||||||
|
|
||||||
@@ -333,9 +359,9 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
parser.streamed_args_for_tool = [""]
|
parser.streamed_args_for_tool = [""]
|
||||||
parser.prev_tool_call_arr = [{"name": "fn", "arguments": {"k": "v"}}]
|
parser.prev_tool_call_arr = [{"name": "fn", "arguments": {"k": "v"}}]
|
||||||
result = parser.extract_tool_calls_streaming(
|
result = parser.extract_tool_calls_streaming(
|
||||||
'<tool_call>{"name":"fn","arguments":{"k":"v"}}',
|
'<tool_call>{"name":"fn","arguments":{"k":"v"',
|
||||||
'<tool_call>{"name":"fn","arguments":{"k":"v"}}</tool_call>',
|
'<tool_call>{"name":"fn","arguments":{"k":"v"}}</tool_call>',
|
||||||
'"}}</tool_call>',
|
"}}</tool_call>",
|
||||||
[1, 10],
|
[1, 10],
|
||||||
[1, 10, 2],
|
[1, 10, 2],
|
||||||
[2],
|
[2],
|
||||||
@@ -343,9 +369,14 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
self.assertIsNotNone(result.tool_calls)
|
self.assertIsNotNone(result.tool_calls)
|
||||||
|
self.assertEqual(result.tool_calls[0].function.arguments, "}")
|
||||||
|
|
||||||
def test_streaming_close_with_diff_no_end_marker(self):
|
def test_streaming_text_after_completed_tool_call(self):
|
||||||
"""Cover lines 184-185: close with arguments but no '"}' in delta_text"""
|
"""Cover lines 143-147: text content after a completed tool call.
|
||||||
|
|
||||||
|
When start==end counts, prev_end==cur_end, and end_token not in delta,
|
||||||
|
the parser treats delta as regular text content.
|
||||||
|
"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.current_tool_id = 0
|
parser.current_tool_id = 0
|
||||||
parser.current_tool_name_sent = True
|
parser.current_tool_name_sent = True
|
||||||
@@ -353,7 +384,7 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
parser.prev_tool_call_arr = [{"name": "fn", "arguments": {"k": "v"}}]
|
parser.prev_tool_call_arr = [{"name": "fn", "arguments": {"k": "v"}}]
|
||||||
# Simulate end token in delta but without '"}' pattern
|
# Simulate end token in delta but without '"}' pattern
|
||||||
# We need cur_start==cur_end and cur_end >= prev_end, and end_token NOT in delta
|
# We need cur_start==cur_end and cur_end >= prev_end, and end_token NOT in delta
|
||||||
# so that we enter the elif at 178
|
# so that we enter the text-content branch at line 143-147
|
||||||
result = parser.extract_tool_calls_streaming(
|
result = parser.extract_tool_calls_streaming(
|
||||||
'<tool_call>{"name":"fn","arguments":{"k":"v"}}</tool_call>',
|
'<tool_call>{"name":"fn","arguments":{"k":"v"}}</tool_call>',
|
||||||
'<tool_call>{"name":"fn","arguments":{"k":"v"}}</tool_call> text',
|
'<tool_call>{"name":"fn","arguments":{"k":"v"}}</tool_call> text',
|
||||||
@@ -363,8 +394,9 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
[30],
|
[30],
|
||||||
self.dummy_request,
|
self.dummy_request,
|
||||||
)
|
)
|
||||||
# balanced counts, prev_end==cur_end, end not in delta -> returns content (line 147)
|
# balanced counts, prev_end==cur_end, end not in delta -> returns content (line 149)
|
||||||
self.assertIsInstance(result, DeltaMessage)
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result.content, " text")
|
||||||
|
|
||||||
def test_streaming_close_no_arguments(self):
|
def test_streaming_close_no_arguments(self):
|
||||||
"""Cover lines 182-183: close branch where prev arguments is None/empty"""
|
"""Cover lines 182-183: close branch where prev arguments is None/empty"""
|
||||||
@@ -382,8 +414,126 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
[2],
|
[2],
|
||||||
self.dummy_request,
|
self.dummy_request,
|
||||||
)
|
)
|
||||||
# diff is None (no arguments), so falls through to partial_json_parser
|
# diff is None (no arguments key in prev), falls through to partial_json_parser
|
||||||
self.assertTrue(result is None or isinstance(result, DeltaMessage))
|
# parses complete JSON, cur_args=None, prev_args=None -> no-args -> delta=None
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_streaming_close_with_empty_dict_arguments(self):
|
||||||
|
"""Regression: close branch must handle arguments={} (empty dict).
|
||||||
|
|
||||||
|
Before fix, `if diff:` was False for empty dict {}, so the close
|
||||||
|
logic was skipped. After fix, `if diff is not None:` correctly
|
||||||
|
enters the branch.
|
||||||
|
"""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": ', # start + name + args key
|
||||||
|
"{}", # empty dict value
|
||||||
|
"}", # outer close brace
|
||||||
|
"</tool_call>", # end token
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Step 1: name sent
|
||||||
|
# Step 2: first-args, cur_args={} is not None, prev_args=None
|
||||||
|
# Without fix: not {} == True -> no-args branch -> returns None
|
||||||
|
# With fix: enters first-args -> streams "{}" -> DeltaMessage
|
||||||
|
self.assertIsNotNone(results[1])
|
||||||
|
self.assertIsNotNone(results[1].tool_calls)
|
||||||
|
self.assertEqual(results[1].tool_calls[0].function.arguments, "{}")
|
||||||
|
|
||||||
|
def test_streaming_empty_arguments_with_outer_brace_in_same_token(self):
|
||||||
|
"""Regression: when arguments={} and outer } arrive in the same token '{}}',
|
||||||
|
regex (.*) over-captures the outer brace, producing '{}}'.
|
||||||
|
|
||||||
|
Real production data showed arguments='{}}}' for get_default_weather
|
||||||
|
with empty arguments. This test reproduces that exact scenario.
|
||||||
|
"""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "get_default_weather", "arguments": ', # start + name + args key
|
||||||
|
"{}}", # empty args + outer close brace in same token
|
||||||
|
"</tool_call>", # end token
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Step 1: name sent
|
||||||
|
self.assertIsNotNone(results[0])
|
||||||
|
self.assertEqual(results[0].tool_calls[0].function.name, "get_default_weather")
|
||||||
|
# Step 2: first-args branch, tool_call_portion is complete JSON
|
||||||
|
# regex (.*) captures '{}}' but fix strips outer '}' -> '{}'
|
||||||
|
self.assertIsNotNone(results[1])
|
||||||
|
self.assertEqual(results[1].tool_calls[0].function.arguments, "{}")
|
||||||
|
# Step 3: end token, close branch
|
||||||
|
# diff = prev_arguments = {} (not None), delta_text = '' (empty after split)
|
||||||
|
# '}' not in '' -> returns None
|
||||||
|
self.assertIsNone(results[2])
|
||||||
|
|
||||||
|
def test_streaming_close_with_number_ending_arguments(self):
|
||||||
|
"""Regression: close branch must flush remaining args ending with number.
|
||||||
|
|
||||||
|
Before fix, '"}' not in delta was True for numbers, causing return None.
|
||||||
|
After fix, rindex('}') correctly finds the closing brace.
|
||||||
|
"""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": {"count": ', # start + name + args key
|
||||||
|
"123", # number value
|
||||||
|
"}}</tool_call>", # close braces + end token
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Step 1: name sent
|
||||||
|
# Step 2: first-args, streams {"count": 123
|
||||||
|
# Step 3: close branch flushes remaining "}"
|
||||||
|
streamed_args = [
|
||||||
|
r.tool_calls[0].function.arguments
|
||||||
|
for r in results
|
||||||
|
if r is not None and r.tool_calls and r.tool_calls[0].function.arguments is not None
|
||||||
|
]
|
||||||
|
combined = "".join(streamed_args)
|
||||||
|
self.assertEqual(combined, '{"count": 123}')
|
||||||
|
|
||||||
|
def test_streaming_close_with_boolean_ending_arguments(self):
|
||||||
|
"""Regression: close branch must flush remaining args ending with boolean."""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": {"flag": ', # start + args key
|
||||||
|
"true", # boolean value
|
||||||
|
"}}</tool_call>", # close + end token
|
||||||
|
],
|
||||||
|
)
|
||||||
|
streamed_args = [
|
||||||
|
r.tool_calls[0].function.arguments
|
||||||
|
for r in results
|
||||||
|
if r is not None and r.tool_calls and r.tool_calls[0].function.arguments is not None
|
||||||
|
]
|
||||||
|
combined = "".join(streamed_args)
|
||||||
|
self.assertEqual(combined, '{"flag": true}')
|
||||||
|
|
||||||
|
def test_streaming_close_with_nested_object_ending(self):
|
||||||
|
"""Regression: close branch must flush remaining args ending with nested '}'."""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": {"nested": {"a": ', # start + args key
|
||||||
|
"1", # nested value
|
||||||
|
"}}}</tool_call>", # close all + end token
|
||||||
|
],
|
||||||
|
)
|
||||||
|
streamed_args = [
|
||||||
|
r.tool_calls[0].function.arguments
|
||||||
|
for r in results
|
||||||
|
if r is not None and r.tool_calls and r.tool_calls[0].function.arguments is not None
|
||||||
|
]
|
||||||
|
combined = "".join(streamed_args)
|
||||||
|
self.assertEqual(combined, '{"nested": {"a": 1}}')
|
||||||
|
|
||||||
# --- Lines 202-206: else branch (cur_start < cur_end, edge case) ---
|
# --- Lines 202-206: else branch (cur_start < cur_end, edge case) ---
|
||||||
|
|
||||||
@@ -404,23 +554,21 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_malformed_json(self):
|
def test_streaming_malformed_json(self):
|
||||||
"""Cover lines 213-215: MalformedJSON from partial parser"""
|
"""Cover lines 213-215: MalformedJSON from partial parser"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming("", "<tool_call>", "<tool_call>", [], [1], [1], self.dummy_request)
|
results = self._simulate_streaming(
|
||||||
# Feed badly formed content
|
parser,
|
||||||
result = parser.extract_tool_calls_streaming(
|
[
|
||||||
"<tool_call>",
|
"<tool_call>", # start tool call
|
||||||
"<tool_call>{{{",
|
"{{{", # badly formed content
|
||||||
"{{{",
|
],
|
||||||
[1],
|
|
||||||
[1, 10],
|
|
||||||
[10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(results[1])
|
||||||
|
|
||||||
def test_streaming_json_decode_error(self):
|
def test_streaming_json_decode_error(self):
|
||||||
"""Cover lines 216-218: JSONDecodeError from partial parser"""
|
"""Cover lines 216-218: JSONDecodeError from partial parser"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming("", "<tool_call>", "<tool_call>", [], [1], [1], self.dummy_request)
|
# Step 1: start tool call normally
|
||||||
|
self._simulate_streaming(parser, ["<tool_call>"])
|
||||||
|
# Step 2: mock partial_json_parser to throw ValueError
|
||||||
with patch(
|
with patch(
|
||||||
"fastdeploy.entrypoints.openai.tool_parsers.ernie_x1_tool_parser.partial_json_parser.loads",
|
"fastdeploy.entrypoints.openai.tool_parsers.ernie_x1_tool_parser.partial_json_parser.loads",
|
||||||
side_effect=ValueError("bad json"),
|
side_effect=ValueError("bad json"),
|
||||||
@@ -430,8 +578,8 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
"<tool_call>bad",
|
"<tool_call>bad",
|
||||||
"bad",
|
"bad",
|
||||||
[1],
|
[1],
|
||||||
[1, 10],
|
[1, 2],
|
||||||
[10],
|
[2],
|
||||||
self.dummy_request,
|
self.dummy_request,
|
||||||
)
|
)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
@@ -469,30 +617,17 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_first_arguments_with_regex_match(self):
|
def test_streaming_first_arguments_with_regex_match(self):
|
||||||
"""Cover lines 243-244, 257-286: first arguments appear, regex matches"""
|
"""Cover lines 243-244, 257-286: first arguments appear, regex matches"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
# Start tool call and send name
|
results = self._simulate_streaming(
|
||||||
parser.extract_tool_calls_streaming(
|
parser,
|
||||||
"",
|
[
|
||||||
'<tool_call>{"name": "get_weather"',
|
'<tool_call>{"name": "get_weather", "arguments": {"location": "', # start + name + args key
|
||||||
'<tool_call>{"name": "get_weather"',
|
"bei", # args value
|
||||||
[],
|
],
|
||||||
[1, 10],
|
|
||||||
[1, 10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# Now stream arguments (first time)
|
# Step 1: name sent
|
||||||
# Key must be complete (closing quote) so partial_json_parser returns truthy arguments.
|
# Step 2: first-args, regex finds "bei" in '{"location": "bei'
|
||||||
# delta must be a substring of the regex-extracted arguments portion (after "arguments":).
|
self.assertIsNotNone(results[1])
|
||||||
result = parser.extract_tool_calls_streaming(
|
self.assertEqual(results[1].tool_calls[0].function.arguments, '{"location": "bei')
|
||||||
'<tool_call>{"name": "get_weather"',
|
|
||||||
'<tool_call>{"name": "get_weather", "arguments": {"location": "bei',
|
|
||||||
'"bei',
|
|
||||||
[1, 10],
|
|
||||||
[1, 10, 20],
|
|
||||||
[20],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(result)
|
|
||||||
self.assertIsNotNone(result.tool_calls)
|
|
||||||
|
|
||||||
def test_streaming_first_arguments_no_regex_match(self):
|
def test_streaming_first_arguments_no_regex_match(self):
|
||||||
"""Cover lines 266-267: regex doesn't match, fallback to json.dumps"""
|
"""Cover lines 266-267: regex doesn't match, fallback to json.dumps"""
|
||||||
@@ -522,67 +657,119 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
self.assertIsNotNone(result.tool_calls)
|
self.assertIsNotNone(result.tool_calls)
|
||||||
|
|
||||||
def test_streaming_first_arguments_delta_not_in_json(self):
|
def test_streaming_first_arguments_delta_not_in_json(self):
|
||||||
"""Cover lines 271-272: delta_text not found in cur_arguments_json"""
|
"""Cover lines 275-276: delta_text not found in cur_arguments_json, returns None.
|
||||||
|
When delta contains the arguments key itself (e.g. ', "arguments": {'),
|
||||||
|
regex extracts cur_arguments_json='{' but delta ', "arguments": {' is not in '{'.
|
||||||
|
"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming(
|
results = self._simulate_streaming(
|
||||||
"",
|
parser,
|
||||||
'<tool_call>{"name": "fn"',
|
[
|
||||||
'<tool_call>{"name": "fn"',
|
'<tool_call>{"name": "fn"', # start + partial name
|
||||||
[],
|
', "arguments": {', # delta introduces arguments key + open brace
|
||||||
[1, 10],
|
],
|
||||||
[1, 10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# Delta text that doesn't appear in the arguments JSON
|
# Step 1: name sent
|
||||||
result = parser.extract_tool_calls_streaming(
|
self.assertIsNotNone(results[0])
|
||||||
'<tool_call>{"name": "fn"',
|
self.assertEqual(results[0].tool_calls[0].function.name, "fn")
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}}',
|
# Step 2: first-args branch, regex extracts cur_arguments_json='{'
|
||||||
"ZZZZZ",
|
# delta_text=', "arguments": {' is NOT in '{' -> returns None
|
||||||
[1, 10],
|
self.assertIsNone(results[1])
|
||||||
[1, 10, 20],
|
|
||||||
[20],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
# --- Lines 249-251: no cur_arguments and no prev_arguments ---
|
# --- Lines 249-251: no cur_arguments and no prev_arguments ---
|
||||||
|
|
||||||
def test_streaming_no_arguments_at_all(self):
|
def test_streaming_no_arguments_at_all(self):
|
||||||
"""Cover lines 249-251: both cur and prev arguments are empty/None"""
|
"""Cover lines 249-251: both cur and prev arguments are empty/None"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming(
|
results = self._simulate_streaming(
|
||||||
"",
|
parser,
|
||||||
'<tool_call>{"name": "fn"',
|
[
|
||||||
'<tool_call>{"name": "fn"',
|
'<tool_call>{"name": "fn"', # start + name
|
||||||
[],
|
"}", # close JSON, no arguments
|
||||||
[1, 10],
|
],
|
||||||
[1, 10],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# Continue with name only, no arguments
|
|
||||||
result = parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn"',
|
|
||||||
'<tool_call>{"name": "fn"}',
|
|
||||||
"}",
|
|
||||||
[1, 10],
|
|
||||||
[1, 10, 20],
|
|
||||||
[20],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# prev_arguments=None, cur_arguments=None -> delta=None
|
# prev_arguments=None, cur_arguments=None -> delta=None
|
||||||
# then prev_tool_call_arr updated and returns delta (which is None)
|
self.assertIsNone(results[1])
|
||||||
self.assertIsNone(result)
|
|
||||||
|
def test_streaming_empty_dict_arguments_not_skipped(self):
|
||||||
|
"""Regression: arguments={} (empty dict) must not be treated as no arguments.
|
||||||
|
|
||||||
|
Empty dict is falsy in Python (`not {} == True`). Before the fix,
|
||||||
|
this caused empty arguments to enter the no-arguments branch,
|
||||||
|
silently dropping them during streaming.
|
||||||
|
"""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": ', # start + name + args key
|
||||||
|
"{}", # empty dict value
|
||||||
|
"}", # outer close brace
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Step 1: name sent
|
||||||
|
# Step 2: cur_arguments={} (not None), prev_arguments=None
|
||||||
|
# With fix: enters first-arguments branch -> streams "{}"
|
||||||
|
# Without fix: not {} == True -> no-arguments branch -> delta=None
|
||||||
|
self.assertIsNotNone(results[1])
|
||||||
|
self.assertIsNotNone(results[1].tool_calls)
|
||||||
|
self.assertEqual(results[1].tool_calls[0].function.arguments, "{}")
|
||||||
|
|
||||||
|
def test_streaming_empty_dict_prev_arguments_not_reset(self):
|
||||||
|
"""Regression: prev_arguments={} must not be treated as no arguments.
|
||||||
|
|
||||||
|
When prev has {} and cur has a non-empty dict, the code should enter
|
||||||
|
the both-have-arguments branch, not the first-arguments branch.
|
||||||
|
|
||||||
|
This scenario (arguments growing from {} to non-empty) is hard to
|
||||||
|
produce naturally, so we build up state through a real flow then
|
||||||
|
verify the branch behavior with one additional call.
|
||||||
|
"""
|
||||||
|
parser = self._new_parser()
|
||||||
|
# Build up state naturally: prev_tool_call_arr gets arguments={}
|
||||||
|
self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": ', # name + args key
|
||||||
|
"{}", # empty dict value
|
||||||
|
"}", # outer close
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Verify state is correct
|
||||||
|
self.assertEqual(parser.prev_tool_call_arr[0].get("arguments"), {})
|
||||||
|
|
||||||
|
# Now test: if more argument data arrives, prev_args={} should be
|
||||||
|
# treated as "not None" -> enters both-have-arguments branch
|
||||||
|
# Without fix: not {} == True -> first-arguments branch (wrong)
|
||||||
|
result = parser.extract_tool_calls_streaming(
|
||||||
|
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
||||||
|
'<tool_call>{"name": "fn", "arguments": {"k": "val',
|
||||||
|
"al",
|
||||||
|
[1, 2, 3],
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
[4],
|
||||||
|
self.dummy_request,
|
||||||
|
)
|
||||||
|
# both-have-arguments branch: delta_text="al" streamed as arguments
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result.tool_calls[0].function.arguments, "al")
|
||||||
|
|
||||||
# --- Lines 253-255: cur_arguments reset (impossible branch) ---
|
# --- Lines 253-255: cur_arguments reset (impossible branch) ---
|
||||||
|
|
||||||
def test_streaming_arguments_reset_mid_call(self):
|
def test_streaming_arguments_reset_mid_call(self):
|
||||||
"""Cover lines 253-255: prev has arguments but cur doesn't (impossible case)"""
|
"""Cover lines 253-255: prev has arguments but cur doesn't (impossible case).
|
||||||
|
|
||||||
|
This is an edge case that shouldn't happen in normal flow, but tests
|
||||||
|
defensive handling when partial parser returns no arguments after
|
||||||
|
previously having them.
|
||||||
|
"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.current_tool_id = 0
|
parser.current_tool_id = 0
|
||||||
parser.current_tool_name_sent = True
|
parser.current_tool_name_sent = True
|
||||||
parser.streamed_args_for_tool = [""]
|
parser.streamed_args_for_tool = [""]
|
||||||
|
# Simulate state where prev already had arguments
|
||||||
parser.prev_tool_call_arr = [{"name": "fn", "arguments": {"k": "v"}}]
|
parser.prev_tool_call_arr = [{"name": "fn", "arguments": {"k": "v"}}]
|
||||||
# Feed content where cur has no arguments but prev does
|
# Mock parser to return no arguments (simulating the impossible reset)
|
||||||
with patch(
|
with patch(
|
||||||
"fastdeploy.entrypoints.openai.tool_parsers.ernie_x1_tool_parser.partial_json_parser.loads",
|
"fastdeploy.entrypoints.openai.tool_parsers.ernie_x1_tool_parser.partial_json_parser.loads",
|
||||||
return_value={"name": "fn"},
|
return_value={"name": "fn"},
|
||||||
@@ -591,9 +778,9 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"',
|
'<tool_call>{"name": "fn", "arguments": {"k": "v"',
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}',
|
'<tool_call>{"name": "fn", "arguments": {"k": "v"}',
|
||||||
'"}',
|
'"}',
|
||||||
[1, 10],
|
[1, 2],
|
||||||
[1, 10, 20],
|
[1, 2, 3],
|
||||||
[20],
|
[3],
|
||||||
self.dummy_request,
|
self.dummy_request,
|
||||||
)
|
)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
@@ -603,110 +790,48 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_incremental_arguments_incomplete(self):
|
def test_streaming_incremental_arguments_incomplete(self):
|
||||||
"""Cover lines 288-314: both prev and cur have arguments, JSON incomplete"""
|
"""Cover lines 288-314: both prev and cur have arguments, JSON incomplete"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming(
|
results = self._simulate_streaming(
|
||||||
"",
|
parser,
|
||||||
'<tool_call>{"name": "fn"',
|
[
|
||||||
'<tool_call>{"name": "fn"',
|
'<tool_call>{"name": "fn", "arguments": {"k": "v', # start + name + first args
|
||||||
[],
|
"a", # establishes prev_args
|
||||||
[1, 10],
|
"l", # incremental: both-have-args
|
||||||
[1, 10],
|
],
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# First arguments - delta must appear in regex-extracted arguments portion
|
# Step 1: name sent
|
||||||
parser.extract_tool_calls_streaming(
|
# Step 2: first-args branch
|
||||||
'<tool_call>{"name": "fn"',
|
# Step 3: both-have-args branch, streams "l"
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
self.assertIsNotNone(results[2])
|
||||||
'{"k": "v',
|
self.assertEqual(results[2].tool_calls[0].function.arguments, "l")
|
||||||
[1, 10],
|
|
||||||
[1, 10, 20],
|
|
||||||
[20],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# More argument tokens (both prev and cur have arguments now)
|
|
||||||
result = parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "val',
|
|
||||||
"al",
|
|
||||||
[1, 10, 20],
|
|
||||||
[1, 10, 20, 30],
|
|
||||||
[30],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(result)
|
|
||||||
self.assertEqual(result.tool_calls[0].function.arguments, "al")
|
|
||||||
|
|
||||||
def test_streaming_incremental_arguments_complete_json(self):
|
def test_streaming_incremental_arguments_complete_json(self):
|
||||||
"""Cover lines 289-305: complete JSON with trailing }"""
|
"""Cover lines 289-305: complete JSON with trailing }"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming(
|
results = self._simulate_streaming(
|
||||||
"",
|
parser,
|
||||||
'<tool_call>{"name": "fn"',
|
[
|
||||||
'<tool_call>{"name": "fn"',
|
'<tool_call>{"name": "fn", "arguments": {"k": "v', # start + name + first args
|
||||||
[],
|
"a", # establishes prev_args
|
||||||
[1, 10],
|
'"}}', # completes JSON
|
||||||
[1, 10],
|
],
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# First arguments - delta must appear in regex-extracted arguments portion
|
# Step 3: both-have-args, complete JSON, strips trailing } -> streams '"}'
|
||||||
parser.extract_tool_calls_streaming(
|
self.assertIsNotNone(results[2])
|
||||||
'<tool_call>{"name": "fn"',
|
self.assertIsInstance(results[2], DeltaMessage)
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
|
||||||
'{"k": "v',
|
|
||||||
[1, 10],
|
|
||||||
[1, 10, 20],
|
|
||||||
[20],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# Complete with closing braces - both prev and cur have arguments
|
|
||||||
result = parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v',
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}}',
|
|
||||||
'"}}',
|
|
||||||
[1, 10, 20],
|
|
||||||
[1, 10, 20, 30],
|
|
||||||
[30],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# is_complete_json=True, delta ends with }, should strip trailing }
|
|
||||||
# After strip: '"' which is not empty, so returns DeltaMessage
|
|
||||||
self.assertIsNotNone(result)
|
|
||||||
self.assertIsInstance(result, DeltaMessage)
|
|
||||||
|
|
||||||
def test_streaming_incremental_arguments_complete_empty_delta(self):
|
def test_streaming_incremental_arguments_complete_empty_delta(self):
|
||||||
"""Cover lines 304-305: complete JSON where delta becomes empty after strip"""
|
"""Cover lines 304-305: complete JSON where delta becomes empty after strip"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
parser.extract_tool_calls_streaming(
|
results = self._simulate_streaming(
|
||||||
"",
|
parser,
|
||||||
'<tool_call>{"name": "fn"',
|
[
|
||||||
'<tool_call>{"name": "fn"',
|
'<tool_call>{"name": "fn", "arguments": {"k": "v"', # start + name + first args
|
||||||
[],
|
"}", # inner close (establishes prev_args)
|
||||||
[1, 10],
|
"}", # outer close: both-have-args, complete, delta stripped to ""
|
||||||
[1, 10],
|
],
|
||||||
self.dummy_request,
|
|
||||||
)
|
)
|
||||||
# First arguments with proper delta
|
# Step 3: is_complete_json=True, delta="}" -> stripped to "" -> return None
|
||||||
parser.extract_tool_calls_streaming(
|
self.assertIsNone(results[2])
|
||||||
'<tool_call>{"name": "fn"',
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}',
|
|
||||||
'{"k": "v"}',
|
|
||||||
[1, 10],
|
|
||||||
[1, 10, 20],
|
|
||||||
[20],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# Send just the outer closing brace
|
|
||||||
# tool_call_portion becomes complete JSON, delta="}" stripped to "" -> return None
|
|
||||||
result = parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}',
|
|
||||||
'<tool_call>{"name": "fn", "arguments": {"k": "v"}}',
|
|
||||||
"}",
|
|
||||||
[1, 10, 20],
|
|
||||||
[1, 10, 20, 30],
|
|
||||||
[30],
|
|
||||||
self.dummy_request,
|
|
||||||
)
|
|
||||||
# is_complete_json=True, delta="}" -> stripped to "" -> return None
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
# --- Lines 316-319: prev_tool_call_arr update branches ---
|
# --- Lines 316-319: prev_tool_call_arr update branches ---
|
||||||
|
|
||||||
@@ -759,95 +884,71 @@ class TestErnieX1ToolParser(unittest.TestCase):
|
|||||||
def test_streaming_full_flow(self):
|
def test_streaming_full_flow(self):
|
||||||
"""Integration test: simulate a full streaming tool call flow"""
|
"""Integration test: simulate a full streaming tool call flow"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
req = self.dummy_request
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
# Step 1: text before tool call
|
[
|
||||||
r = parser.extract_tool_calls_streaming("", "thinking", "thinking", [], [], [], req)
|
"thinking", # Step 1: text before tool call
|
||||||
self.assertEqual(r.content, "thinking")
|
"<tool_call>", # Step 2: tool_call start token
|
||||||
|
'{"name": "search", "arguments": {"query": "', # Step 3: name + args key
|
||||||
# Step 2: tool_call start token
|
"test", # Step 4: args value
|
||||||
r = parser.extract_tool_calls_streaming("thinking", "thinking<tool_call>", "<tool_call>", [], [1], [1], req)
|
" data", # Step 5: more args
|
||||||
self.assertIsNone(r)
|
],
|
||||||
|
|
||||||
# Step 3: function name appears
|
|
||||||
r = parser.extract_tool_calls_streaming(
|
|
||||||
"thinking<tool_call>",
|
|
||||||
'thinking<tool_call>{"name": "search"',
|
|
||||||
'{"name": "search"',
|
|
||||||
[1],
|
|
||||||
[1, 10],
|
|
||||||
[10],
|
|
||||||
req,
|
|
||||||
)
|
)
|
||||||
self.assertIsNotNone(r)
|
# Step 1: plain text
|
||||||
self.assertEqual(r.tool_calls[0].function.name, "search")
|
self.assertEqual(results[0].content, "thinking")
|
||||||
|
# Step 2: start token -> None
|
||||||
# Step 4: arguments start - delta must appear in regex-extracted arguments portion
|
self.assertIsNone(results[1])
|
||||||
r = parser.extract_tool_calls_streaming(
|
# Step 3: name sent
|
||||||
'thinking<tool_call>{"name": "search"',
|
self.assertIsNotNone(results[2])
|
||||||
'thinking<tool_call>{"name": "search", "arguments": {"query": "test',
|
self.assertEqual(results[2].tool_calls[0].function.name, "search")
|
||||||
'{"query": "test',
|
# Step 4: first arguments
|
||||||
[1, 10],
|
self.assertIsNotNone(results[3])
|
||||||
[1, 10, 20],
|
self.assertEqual(results[3].tool_calls[0].function.arguments, '{"query": "test')
|
||||||
[20],
|
|
||||||
req,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(r)
|
|
||||||
|
|
||||||
# Step 5: more arguments
|
# Step 5: more arguments
|
||||||
r = parser.extract_tool_calls_streaming(
|
self.assertIsNotNone(results[4])
|
||||||
'thinking<tool_call>{"name": "search", "arguments": {"query": "test',
|
self.assertEqual(results[4].tool_calls[0].function.arguments, " data")
|
||||||
'thinking<tool_call>{"name": "search", "arguments": {"query": "test data',
|
|
||||||
" data",
|
def test_streaming_empty_arguments_full_flow(self):
|
||||||
[1, 10, 20],
|
"""Integration: streaming tool call with arguments={} must not lose arguments.
|
||||||
[1, 10, 20, 30],
|
|
||||||
[30],
|
Simulates a complete streaming flow where the tool call has empty
|
||||||
req,
|
arguments. Verifies the name is sent and arguments are streamed.
|
||||||
|
"""
|
||||||
|
parser = self._new_parser()
|
||||||
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
|
[
|
||||||
|
'<tool_call>{"name": "fn", "arguments": ', # Step 1: start + name + args key
|
||||||
|
"{}", # Step 2: empty dict value
|
||||||
|
"}", # Step 3: outer close
|
||||||
|
"</tool_call>", # Step 4: end token
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.assertIsNotNone(r)
|
# Step 1: name sent
|
||||||
self.assertEqual(r.tool_calls[0].function.arguments, " data")
|
self.assertIsNotNone(results[0])
|
||||||
|
self.assertEqual(results[0].tool_calls[0].function.name, "fn")
|
||||||
|
# Step 2: first-args with cur_args={}, streams "{}"
|
||||||
|
self.assertIsNotNone(results[1])
|
||||||
|
self.assertEqual(results[1].tool_calls[0].function.arguments, "{}")
|
||||||
|
# Step 4: close branch, delta_text="" after stripping </tool_call>
|
||||||
|
# diff={} is not None, but "}" not in "" -> return None
|
||||||
|
self.assertIsNone(results[2])
|
||||||
|
self.assertIsNone(results[3])
|
||||||
|
|
||||||
def test_streaming_multiple_tool_calls(self):
|
def test_streaming_multiple_tool_calls(self):
|
||||||
"""Integration test: two tool calls in one response"""
|
"""Integration test: two tool calls in one response"""
|
||||||
parser = self._new_parser()
|
parser = self._new_parser()
|
||||||
req = self.dummy_request
|
results = self._simulate_streaming(
|
||||||
|
parser,
|
||||||
# First tool call
|
[
|
||||||
parser.extract_tool_calls_streaming(
|
'<tool_call>{"name": "fn1"', # First tool: start + name
|
||||||
"",
|
"}</tool_call>", # Close first tool
|
||||||
'<tool_call>{"name": "fn1"',
|
'<tool_call>{"name": "fn2"', # Second tool: start + name
|
||||||
'<tool_call>{"name": "fn1"',
|
],
|
||||||
[],
|
|
||||||
[1, 10],
|
|
||||||
[1, 10],
|
|
||||||
req,
|
|
||||||
)
|
|
||||||
self.assertEqual(parser.current_tool_id, 0)
|
|
||||||
|
|
||||||
# Close first tool
|
|
||||||
parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn1"',
|
|
||||||
'<tool_call>{"name": "fn1"}</tool_call>',
|
|
||||||
"}</tool_call>",
|
|
||||||
[1, 10],
|
|
||||||
[1, 10, 2],
|
|
||||||
[2],
|
|
||||||
req,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Second tool call
|
|
||||||
r = parser.extract_tool_calls_streaming(
|
|
||||||
'<tool_call>{"name": "fn1"}</tool_call>',
|
|
||||||
'<tool_call>{"name": "fn1"}</tool_call><tool_call>{"name": "fn2"',
|
|
||||||
'<tool_call>{"name": "fn2"',
|
|
||||||
[1, 10, 2],
|
|
||||||
[1, 10, 2, 1, 20],
|
|
||||||
[1, 20],
|
|
||||||
req,
|
|
||||||
)
|
)
|
||||||
self.assertEqual(parser.current_tool_id, 1)
|
self.assertEqual(parser.current_tool_id, 1)
|
||||||
self.assertIsNotNone(r)
|
self.assertIsNotNone(results[2])
|
||||||
self.assertEqual(r.tool_calls[0].function.name, "fn2")
|
self.assertEqual(results[2].tool_calls[0].function.name, "fn2")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user