1848 lines
76 KiB
Python
1848 lines
76 KiB
Python
import json
|
|
import unittest
|
|
|
|
from sglang.srt.entrypoints.openai.protocol import Function, Tool
|
|
from sglang.srt.function_call.core_types import StreamingParseResult
|
|
from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector
|
|
from sglang.srt.function_call.glm47_moe_detector import (
|
|
Glm47MoeDetector,
|
|
get_argument_type,
|
|
)
|
|
from sglang.test.ci.ci_register import register_cpu_ci
|
|
|
|
register_cpu_ci(1.0, "default")
|
|
|
|
|
|
class TestGlm47MoeDetector(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_weather",
|
|
description="Get weather information",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"city": {"type": "string", "description": "City name"},
|
|
"date": {"type": "string", "description": "Date"},
|
|
},
|
|
"required": ["city", "date"],
|
|
},
|
|
),
|
|
),
|
|
]
|
|
self.detector = Glm47MoeDetector()
|
|
|
|
# ==================== Basic Parsing Tests (5) ====================
|
|
|
|
def test_single_tool_call(self):
|
|
"""
|
|
Test basic single tool call parsing.
|
|
|
|
Scenario: Parse a complete tool call with two string parameters in a single text block.
|
|
Purpose: Verify the detector can correctly identify and extract function name and parameters
|
|
from a simple, well-formed tool call.
|
|
"""
|
|
text = (
|
|
"<tool_call>get_weather"
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
|
|
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>"
|
|
"</tool_call>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "get_weather")
|
|
self.assertEqual(
|
|
result.calls[0].parameters, '{"city": "Beijing", "date": "2024-06-27"}'
|
|
)
|
|
self.assertEqual(result.normal_text, "")
|
|
|
|
def test_multiple_tool_calls(self):
|
|
"""
|
|
Test parsing multiple consecutive tool calls.
|
|
|
|
Scenario: Parse two complete tool calls back-to-back without any text in between.
|
|
Purpose: Verify the detector correctly handles multiple tool calls and resets state
|
|
between calls to avoid parameter leakage or ID conflicts.
|
|
"""
|
|
text = (
|
|
"<tool_call>get_weather"
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
|
|
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>"
|
|
"</tool_call>"
|
|
"<tool_call>get_weather"
|
|
"<arg_key>city</arg_key><arg_value>Shanghai</arg_value>"
|
|
"<arg_key>date</arg_key><arg_value>2024-06-28</arg_value>"
|
|
"</tool_call>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 2)
|
|
self.assertEqual(result.calls[0].name, "get_weather")
|
|
self.assertEqual(
|
|
result.calls[0].parameters, '{"city": "Beijing", "date": "2024-06-27"}'
|
|
)
|
|
self.assertEqual(result.calls[1].name, "get_weather")
|
|
self.assertEqual(
|
|
result.calls[1].parameters, '{"city": "Shanghai", "date": "2024-06-28"}'
|
|
)
|
|
self.assertEqual(result.normal_text, "")
|
|
|
|
def test_no_arg_function_non_streaming(self):
|
|
"""
|
|
Test no-argument function call without streaming.
|
|
|
|
Scenario: Parse a tool call for a function that has no parameters (empty properties).
|
|
Purpose: Verify the detector generates a single empty object "{}" for no-argument functions
|
|
and does not duplicate empty parameter objects.
|
|
"""
|
|
tools_with_no_args = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="list_filenames",
|
|
description="List filenames",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
text = "<tool_call>list_filenames</tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools_with_no_args)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "list_filenames")
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params, {})
|
|
|
|
def test_invalid_tool_call(self):
|
|
"""
|
|
Test handling of invalid tool calls.
|
|
|
|
Scenario: Attempt to parse a tool call with a function name that doesn't exist in the tool list.
|
|
Purpose: Verify the detector gracefully rejects invalid function calls and returns no calls
|
|
rather than throwing an error or accepting invalid input.
|
|
"""
|
|
text = "<tool_call>invalid_func<arg_key>city</arg_key><arg_value>Beijing</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 0)
|
|
|
|
def test_array_argument_with_escaped_json(self):
|
|
"""
|
|
Test array arguments containing escaped JSON strings.
|
|
|
|
Scenario: Parse tool calls with array parameters containing nested JSON objects with
|
|
escaped quotes (both backslash-escaped and raw escaped strings).
|
|
Purpose: Verify the detector properly handles JSON escaping without double-escaping,
|
|
preserving special characters like backslashes in paths and newline sequences.
|
|
"""
|
|
tools_with_array = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="todo_write",
|
|
description="Write todos",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"todos": {
|
|
"type": "array",
|
|
"description": "The updated todo list",
|
|
}
|
|
},
|
|
"required": ["todos"],
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
def check_params(result):
|
|
self.assertEqual(1, len(result.calls))
|
|
self.assertEqual("todo_write", result.calls[0].name)
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertIsInstance(params["todos"], list)
|
|
self.assertEqual(4, len(params["todos"]))
|
|
self.assertEqual("1", params["todos"][0]["id"])
|
|
self.assertEqual(
|
|
"Check for hard-coded issues in the backend code",
|
|
params["todos"][0]["task"],
|
|
)
|
|
self.assertEqual("in_progress", params["todos"][0]["status"])
|
|
self.assertEqual("2", params["todos"][1]["id"])
|
|
self.assertEqual(
|
|
"Check for hard-coded issues in the frontend code",
|
|
params["todos"][1]["task"],
|
|
)
|
|
self.assertEqual("pending", params["todos"][1]["status"])
|
|
self.assertEqual("3", params["todos"][2]["id"])
|
|
self.assertEqual(
|
|
"Check for code violating the Single Responsibility Principle",
|
|
params["todos"][2]["task"],
|
|
)
|
|
self.assertEqual("pending", params["todos"][2]["status"])
|
|
self.assertEqual("4", params["todos"][3]["id"])
|
|
self.assertEqual(
|
|
"Generate a rectification proposal report", params["todos"][3]["task"]
|
|
)
|
|
self.assertEqual("pending", params["todos"][3]["status"])
|
|
|
|
# Test with normal escaped JSON in XML
|
|
result = self.detector.detect_and_parse(
|
|
"""<tool_call>todo_write<arg_key>todos</arg_key><arg_value>[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}]</arg_value>
|
|
</tool_call>""",
|
|
tools_with_array,
|
|
)
|
|
check_params(result)
|
|
|
|
# Test with raw string escaped JSON
|
|
result = self.detector.detect_and_parse(
|
|
r"""<tool_call>todo_write<arg_key>todos</arg_key><arg_value>[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}]</arg_value>
|
|
</tool_call>""",
|
|
tools_with_array,
|
|
)
|
|
check_params(result)
|
|
|
|
def check_single_todos(tool_result, expected):
|
|
self.assertEqual(1, len(tool_result.calls))
|
|
self.assertEqual("todo_write", tool_result.calls[0].name)
|
|
params = json.loads(tool_result.calls[0].parameters)
|
|
self.assertIsInstance(params["todos"], list)
|
|
self.assertEqual(1, len(params["todos"]))
|
|
self.assertEqual("1", params["todos"][0]["id"])
|
|
self.assertEqual(expected, params["todos"][0]["task"])
|
|
self.assertEqual("pending", params["todos"][0]["status"])
|
|
|
|
# Test with escaped backslashes (Windows paths)
|
|
expected_path = r"Check file at C:\Users\test.txt"
|
|
result = self.detector.detect_and_parse(
|
|
"""<tool_call>todo_write<arg_key>todos</arg_key><arg_value>[{\"id\": \"1\", \"task\": \"Check file at C:\\\\Users\\\\test.txt\", \"status\": \"pending\"}]</arg_value></tool_call>""",
|
|
tools_with_array,
|
|
)
|
|
check_single_todos(result, expected_path)
|
|
|
|
# Test with literal backslash-n (not newline)
|
|
expected_output = r"Print \n to see newline"
|
|
result = self.detector.detect_and_parse(
|
|
"""<tool_call>todo_write<arg_key>todos</arg_key><arg_value>[{\"id\": \"1\", \"task\": \"Print \\\\n to see newline\",\"status\": \"pending\"}]</arg_value></tool_call>""",
|
|
tools_with_array,
|
|
)
|
|
check_single_todos(result, expected_output)
|
|
|
|
# ==================== MTP Core Scenarios (3) ====================
|
|
|
|
def test_mtp_func_and_string_split(self):
|
|
"""
|
|
Test MTP-style function name and string parameter value splitting across chunks.
|
|
|
|
Scenario: Simulate Model Token Provider (MTP) behavior where function names and string
|
|
parameter values are split mid-word across multiple chunks.
|
|
Purpose: This is the MOST CRITICAL test - verify the detector correctly reassembles:
|
|
- Function name split as "create_ta" + "sk"
|
|
- String values split as "Go to Bei" + "jing" and "San Fran" + "cisco"
|
|
These splits mimic real MTP output where tokenization breaks words arbitrarily.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="create_task",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"location": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
chunks = [
|
|
"I'll create a task.", # normal text before tool call
|
|
"<tool_call>create_ta", # function name split mid-word
|
|
"sk<arg_key>title</arg_key><arg_value>Go to Bei", # function name completes, param value splits
|
|
"jing</arg_value>", # first parameter value completes
|
|
"<arg_key>location</arg_key><arg_value>San Fran", # second parameter value splits
|
|
"cisco</arg_value></tool_call>", # second parameter and tool call complete
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_calls = []
|
|
all_normal_text = ""
|
|
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
all_calls.extend(result.calls)
|
|
all_normal_text += result.normal_text
|
|
|
|
# Verify normal text is preserved
|
|
self.assertEqual(all_normal_text, "I'll create a task.")
|
|
|
|
# Verify function call
|
|
func_calls = [c for c in all_calls if c.name]
|
|
self.assertEqual(len(func_calls), 1)
|
|
self.assertEqual(
|
|
func_calls[0].name, "create_task"
|
|
) # "create_ta" + "sk" reassembled
|
|
|
|
# Verify parameter reassembly
|
|
full_params = "".join([c.parameters for c in all_calls if c.parameters])
|
|
params = json.loads(full_params)
|
|
self.assertEqual(
|
|
params["title"], "Go to Beijing"
|
|
) # "Go to Bei" + "jing" reassembled
|
|
self.assertEqual(
|
|
params["location"], "San Francisco"
|
|
) # "San Fran" + "cisco" reassembled
|
|
|
|
def test_mtp_noarg_and_multiple_calls(self):
|
|
"""
|
|
Test MTP-style no-argument function and multiple tool calls with state reset.
|
|
|
|
Scenario: Stream a no-argument function call followed by a regular function call,
|
|
simulating MTP's output pattern where function completion triggers state reset.
|
|
Purpose: Verify:
|
|
- No-argument functions emit exactly ONE empty object "{}", not duplicates
|
|
- State properly resets between consecutive tool calls (tool_index increments)
|
|
- Second tool call doesn't inherit parameters from first call
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="list_files",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
),
|
|
),
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_weather",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"city": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
chunks = [
|
|
"<tool_call>list_files</tool_call>", # no-arg function, complete in one chunk
|
|
"<tool_call>get_weather<arg_key>city</arg_key><arg_value>Beijing</arg_value></tool_call>",
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_calls = []
|
|
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
# Verify two distinct tool calls
|
|
func_calls = [c for c in all_calls if c.name]
|
|
self.assertEqual(len(func_calls), 2)
|
|
self.assertEqual(func_calls[0].name, "list_files")
|
|
self.assertEqual(func_calls[1].name, "get_weather")
|
|
|
|
# Verify no duplicate empty objects for no-arg function
|
|
empty_object_calls = [c for c in all_calls if c.parameters == "{}"]
|
|
self.assertLessEqual(
|
|
len(empty_object_calls),
|
|
1,
|
|
"No-argument function should emit at most one empty object",
|
|
)
|
|
|
|
# Verify second call has correct parameters
|
|
weather_params = [
|
|
c.parameters for c in all_calls if c.parameters and c.parameters != "{}"
|
|
]
|
|
if weather_params:
|
|
full_params = "".join(weather_params)
|
|
params = json.loads(full_params)
|
|
self.assertEqual(params["city"], "Beijing")
|
|
|
|
def test_mtp_number_and_complex_json(self):
|
|
"""
|
|
Test MTP-style number parameters and complex JSON array splitting.
|
|
|
|
Scenario: Parse tool calls with number parameters (int and float) and JSON arrays
|
|
split across chunks, including splits within JSON structure.
|
|
Purpose: Verify:
|
|
- Number types (5.5, 10) are preserved as numbers, not strings
|
|
- JSON array content split as "description" + ": \"" maintains validity
|
|
- Nested JSON objects in arrays are correctly reconstructed
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="create_todos",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"priority": {"type": "number"},
|
|
"count": {"type": "integer"},
|
|
"items": {"type": "array"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
chunks = [
|
|
"<tool_call>create_todos",
|
|
"<arg_key>priority</arg_key><arg_value>5.5</arg_value>", # float number
|
|
"<arg_key>count</arg_key><arg_value>10</arg_value>", # integer number
|
|
'<arg_key>items</arg_key><arg_value>[{"description', # JSON array splits mid-key
|
|
'": "Test', # key completes, value starts
|
|
'Todo 1"}, {"description": "TestTodo 2"}]</arg_value></tool_call>',
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_calls = []
|
|
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
# Verify function name
|
|
func_calls = [c for c in all_calls if c.name]
|
|
self.assertEqual(len(func_calls), 1)
|
|
self.assertEqual(func_calls[0].name, "create_todos")
|
|
|
|
# Verify parameters - numbers and JSON array
|
|
full_params = "".join([c.parameters for c in all_calls if c.parameters])
|
|
params = json.loads(full_params)
|
|
|
|
# Number types should be preserved
|
|
self.assertIsInstance(params["priority"], (int, float))
|
|
self.assertEqual(params["priority"], 5.5)
|
|
self.assertIsInstance(params["count"], int)
|
|
self.assertEqual(params["count"], 10)
|
|
|
|
# JSON array should be correctly reconstructed
|
|
self.assertIsInstance(params["items"], list)
|
|
self.assertEqual(len(params["items"]), 2)
|
|
self.assertEqual(params["items"][0]["description"], "TestTodo 1")
|
|
self.assertEqual(params["items"][1]["description"], "TestTodo 2")
|
|
|
|
# ==================== Streaming Basics (3) ====================
|
|
|
|
def test_streaming_tool_call(self):
|
|
"""
|
|
Test basic streaming incremental parsing of a single tool call.
|
|
|
|
Scenario: Parse a tool call split across 4 chunks with natural boundaries
|
|
(function name, first param, second param, closing tag).
|
|
Purpose: Verify basic streaming functionality works correctly and accumulates
|
|
parameters progressively across chunks.
|
|
"""
|
|
chunks = [
|
|
"<tool_call>get_weather",
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>",
|
|
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>",
|
|
"</tool_call>",
|
|
]
|
|
tool_calls = []
|
|
for chunk in chunks:
|
|
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
|
for tool_call_chunk in result.calls:
|
|
if (
|
|
hasattr(tool_call_chunk, "tool_index")
|
|
and tool_call_chunk.tool_index is not None
|
|
):
|
|
while len(tool_calls) <= tool_call_chunk.tool_index:
|
|
tool_calls.append({"name": "", "parameters": ""})
|
|
tc = tool_calls[tool_call_chunk.tool_index]
|
|
if tool_call_chunk.name:
|
|
tc["name"] = tool_call_chunk.name
|
|
if tool_call_chunk.parameters:
|
|
tc["parameters"] += tool_call_chunk.parameters
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0]["name"], "get_weather")
|
|
self.assertEqual(
|
|
tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}'
|
|
)
|
|
|
|
def test_streaming_multiple_tool_calls(self):
|
|
"""
|
|
Test streaming incremental parsing of multiple consecutive tool calls.
|
|
|
|
Scenario: Stream two complete tool calls with the transition "</tool_call><tool_call>"
|
|
occurring within a single chunk.
|
|
Purpose: Verify streaming correctly handles multiple tool calls and properly increments
|
|
tool_index for each new call.
|
|
"""
|
|
chunks = [
|
|
"<tool_call>get_weather",
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>",
|
|
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>",
|
|
"</tool_call><tool_call>get_weather", # two tool calls transition in same chunk
|
|
"<arg_key>city</arg_key><arg_value>Shanghai</arg_value>",
|
|
"<arg_key>date</arg_key><arg_value>2024-06-28</arg_value>",
|
|
"</tool_call>",
|
|
]
|
|
tool_calls = []
|
|
for chunk in chunks:
|
|
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
|
for tool_call_chunk in result.calls:
|
|
if (
|
|
hasattr(tool_call_chunk, "tool_index")
|
|
and tool_call_chunk.tool_index is not None
|
|
):
|
|
while len(tool_calls) <= tool_call_chunk.tool_index:
|
|
tool_calls.append({"name": "", "parameters": ""})
|
|
tc = tool_calls[tool_call_chunk.tool_index]
|
|
if tool_call_chunk.name:
|
|
tc["name"] = tool_call_chunk.name
|
|
if tool_call_chunk.parameters:
|
|
tc["parameters"] += tool_call_chunk.parameters
|
|
self.assertEqual(len(tool_calls), 2)
|
|
self.assertEqual(tool_calls[0]["name"], "get_weather")
|
|
self.assertEqual(
|
|
tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}'
|
|
)
|
|
self.assertEqual(tool_calls[1]["name"], "get_weather")
|
|
self.assertEqual(
|
|
tool_calls[1]["parameters"], '{"city": "Shanghai", "date": "2024-06-28"}'
|
|
)
|
|
|
|
def test_normal_text_before_tool_call(self):
|
|
"""
|
|
Test preservation of normal text (including punctuation) before tool calls.
|
|
|
|
Scenario: Parse chunks containing normal text with various punctuation marks
|
|
(English and Chinese) immediately followed by tool call tags.
|
|
Purpose: Verify normal text is preserved in result.normal_text and not lost when
|
|
tool call parsing begins. This consolidates 6 previous Chinese punctuation tests.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="list_dir",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
test_cases = [
|
|
("Sure, let me help.<tool_call>list_dir", "English with period"),
|
|
("结构:<tool_call>list_dir", "Chinese colon"),
|
|
("问题。<tool_call>list_dir", "Chinese period"),
|
|
("Complete!<tool_call>list_dir", "English exclamation"),
|
|
("说明;<tool_call>list_dir", "Chinese semicolon"),
|
|
]
|
|
|
|
for text, description in test_cases:
|
|
with self.subTest(description=description):
|
|
detector = Glm47MoeDetector()
|
|
result = detector.parse_streaming_increment(text, tools)
|
|
|
|
before_token = text.split("<tool_call>")[0]
|
|
self.assertIn(
|
|
before_token,
|
|
result.normal_text,
|
|
f"Should preserve '{before_token}' in '{description}'",
|
|
)
|
|
|
|
# ==================== Boundary Cases (9) ====================
|
|
|
|
def test_boundary_empty_param_value(self):
|
|
"""
|
|
Test handling of empty parameter values.
|
|
|
|
Scenario: Parse a tool call where a parameter value is an empty string.
|
|
Purpose: Verify the detector correctly handles empty strings as valid parameter values
|
|
and doesn't skip or error on them.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="create_note",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"content": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
text = "<tool_call>create_note<arg_key>title</arg_key><arg_value>Test</arg_value><arg_key>content</arg_key><arg_value></arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["title"], "Test")
|
|
self.assertEqual(params["content"], "") # empty string should be preserved
|
|
|
|
def test_boundary_param_value_extreme_split(self):
|
|
"""
|
|
Test extreme parameter value splitting - one character per chunk.
|
|
|
|
Scenario: Stream a parameter value where each character arrives in a separate chunk,
|
|
representing worst-case MTP tokenization.
|
|
Purpose: Stress test the buffer reassembly mechanism to ensure it can handle
|
|
extremely granular chunk boundaries without data loss or corruption.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="search",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
chunks = [
|
|
"<tool_call>search<arg_key>query</arg_key><arg_value>N",
|
|
"e",
|
|
"w ",
|
|
"Y",
|
|
"o",
|
|
"rk</arg_value></tool_call>",
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_calls = []
|
|
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
full_params = "".join([c.parameters for c in all_calls if c.parameters])
|
|
params = json.loads(full_params)
|
|
self.assertEqual(
|
|
params["query"], "New York"
|
|
) # all characters correctly reassembled
|
|
|
|
def test_boundary_param_value_with_special_chars(self):
|
|
"""
|
|
Test parameter values containing special characters and escape sequences.
|
|
|
|
Scenario: Parse parameter values with quotes, backslashes, newlines, and other
|
|
special characters that require JSON escaping.
|
|
Purpose: Verify special characters are properly escaped/unescaped and preserved
|
|
through the parsing pipeline without corruption.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="execute_command",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# Test with single quotes (no escaping needed)
|
|
text = "<tool_call>execute_command<arg_key>command</arg_key><arg_value>echo 'Hello World'</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["command"], "echo 'Hello World'")
|
|
|
|
# Test with spaces and special chars that don't need escaping
|
|
text = "<tool_call>execute_command<arg_key>command</arg_key><arg_value>echo Hello & World</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["command"], "echo Hello & World")
|
|
|
|
def test_boundary_json_deeply_nested(self):
|
|
"""
|
|
Test deeply nested JSON structures in parameter values.
|
|
|
|
Scenario: Parse a parameter containing a deeply nested JSON object with multiple levels.
|
|
Purpose: Verify the detector can handle complex nested structures without stack overflow
|
|
or parsing errors.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="process_data",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {"type": "object"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
nested_json = (
|
|
'{"level1": {"level2": {"level3": {"level4": {"value": "deep"}}}}}'
|
|
)
|
|
text = f"<tool_call>process_data<arg_key>data</arg_key><arg_value>{nested_json}</arg_value></tool_call>"
|
|
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
params = json.loads(result.calls[0].parameters)
|
|
|
|
# Navigate through nested structure
|
|
self.assertEqual(
|
|
params["data"]["level1"]["level2"]["level3"]["level4"]["value"], "deep"
|
|
)
|
|
|
|
def test_boundary_json_empty_structures(self):
|
|
"""
|
|
Test empty JSON structures (empty objects and arrays) in parameters.
|
|
|
|
Scenario: Parse parameters containing empty objects {} and empty arrays [].
|
|
Purpose: Verify empty structures are preserved and not confused with no-argument
|
|
function empty parameter generation.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="create_structure",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"empty_obj": {"type": "object"},
|
|
"empty_arr": {"type": "array"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
text = "<tool_call>create_structure<arg_key>empty_obj</arg_key><arg_value>{}</arg_value><arg_key>empty_arr</arg_key><arg_value>[]</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["empty_obj"], {})
|
|
self.assertEqual(params["empty_arr"], [])
|
|
|
|
def test_boundary_multi_tags_one_chunk(self):
|
|
"""
|
|
Test multiple XML tags appearing in a single chunk.
|
|
|
|
Scenario: Parse chunks where multiple complete tags (arg_key, arg_value, etc.)
|
|
appear together without any chunk boundaries between them.
|
|
Purpose: Verify the regex-based tag extraction correctly handles multiple tags
|
|
in one chunk and processes them in the correct order.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="multi_param",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "string"},
|
|
"b": {"type": "string"},
|
|
"c": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# All three parameters in one chunk
|
|
text = "<tool_call>multi_param<arg_key>a</arg_key><arg_value>1</arg_value><arg_key>b</arg_key><arg_value>2</arg_value><arg_key>c</arg_key><arg_value>3</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["a"], "1")
|
|
self.assertEqual(params["b"], "2")
|
|
self.assertEqual(params["c"], "3")
|
|
|
|
def test_boundary_normal_text_mixed_with_tool(self):
|
|
"""
|
|
Test normal text interleaved with tool calls.
|
|
|
|
Scenario: Parse text with normal text before and after tool calls.
|
|
Purpose: Verify normal text segments are correctly separated from tool call parsing
|
|
and preserved in the normal_text output.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="action",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
text = "First I'll do this.<tool_call>action</tool_call>Then I'll do that."
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "action")
|
|
# Verify both text before and after tool calls are preserved
|
|
self.assertIn("First I'll do this.", result.normal_text)
|
|
self.assertIn("Then I'll do that.", result.normal_text)
|
|
|
|
def test_boundary_number_edge_values(self):
|
|
"""
|
|
Test edge-case number values (zero, negative, scientific notation).
|
|
|
|
Scenario: Parse parameters with various numeric edge cases to ensure proper type handling.
|
|
Purpose: Verify the detector correctly preserves number types for edge values and doesn't
|
|
convert them to strings or lose precision.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="calculate",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"zero": {"type": "number"},
|
|
"negative": {"type": "number"},
|
|
"large": {"type": "number"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
text = "<tool_call>calculate<arg_key>zero</arg_key><arg_value>0</arg_value><arg_key>negative</arg_key><arg_value>-42.5</arg_value><arg_key>large</arg_key><arg_value>1e10</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["zero"], 0)
|
|
self.assertEqual(params["negative"], -42.5)
|
|
self.assertEqual(params["large"], 1e10)
|
|
|
|
def test_boundary_type_string_with_numeric_content(self):
|
|
"""
|
|
Test string parameters that contain numeric-looking content.
|
|
|
|
Scenario: Parse string parameters with values like "123" or "45.67" that look like
|
|
numbers but should remain strings based on parameter schema.
|
|
Purpose: Verify type preservation based on schema definition, not content appearance.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="store_data",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {
|
|
"type": "string"
|
|
}, # string type despite numeric content
|
|
"code": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
text = "<tool_call>store_data<arg_key>id</arg_key><arg_value>12345</arg_value><arg_key>code</arg_key><arg_value>67.89</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
# Should be strings, not numbers
|
|
self.assertIsInstance(params["id"], str)
|
|
self.assertIsInstance(params["code"], str)
|
|
self.assertEqual(params["id"], "12345")
|
|
self.assertEqual(params["code"], "67.89")
|
|
|
|
# ==================== Error Handling (2) ====================
|
|
|
|
def test_error_undefined_tool(self):
|
|
"""
|
|
Test error handling for undefined tool names.
|
|
|
|
Scenario: Attempt to call a function that doesn't exist in the provided tools list.
|
|
Purpose: Verify the detector gracefully handles undefined tools by returning an empty
|
|
call list rather than crashing or producing malformed output.
|
|
"""
|
|
text = "<tool_call>nonexistent_function<arg_key>param</arg_key><arg_value>value</arg_value></tool_call>"
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
|
|
# Should not crash, should return empty calls
|
|
self.assertEqual(len(result.calls), 0)
|
|
|
|
def test_error_incomplete_buffer_at_end(self):
|
|
"""
|
|
Test handling of incomplete tool calls at end of stream.
|
|
|
|
Scenario: Streaming ends with an incomplete tool call (e.g., missing closing tag).
|
|
Purpose: Verify the detector handles incomplete buffers gracefully without throwing
|
|
exceptions, as streaming may end mid-parse in real scenarios.
|
|
"""
|
|
chunks = [
|
|
"<tool_call>get_weather<arg_key>city</arg_key><arg_value>Beijing",
|
|
# Stream ends here, no closing tags
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, self.tools)
|
|
# Should not crash
|
|
self.assertIsInstance(result, StreamingParseResult)
|
|
|
|
# Incomplete call should not be in results
|
|
# (or may be partially present - main thing is no exception)
|
|
|
|
# ==================== Streamed Raw Length Bug Tests (3) ====================
|
|
|
|
def test_streamed_raw_length_incomplete_xml_tag(self):
|
|
"""
|
|
Test that _streamed_raw_length is updated even when json_increment is empty.
|
|
|
|
Scenario: Stream XML content that is split at an incomplete tag boundary,
|
|
causing the state machine to buffer without producing JSON output.
|
|
Purpose: Verify that _streamed_raw_length is updated regardless of whether
|
|
json_increment is empty, preventing reprocessing of the same input.
|
|
|
|
This tests the bug where:
|
|
1. raw_increment is extracted from func_args_raw[self._streamed_raw_length:]
|
|
2. _process_xml_to_json_streaming() returns empty string (buffering state)
|
|
3. If _streamed_raw_length is NOT updated before the early return,
|
|
the next call will reprocess the same raw_increment
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_weather",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"city": {"type": "string"},
|
|
"temperature": {"type": "number"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# Simulate streaming chunks where XML tags are split
|
|
chunks = [
|
|
"<tool_call>get_weather",
|
|
"<arg_key>city</arg_key><arg_value>Bei", # Split in middle of value
|
|
"jing</arg_value>", # Complete the value
|
|
"<arg_key>temperature</arg_key><arg_value>2", # Split numeric value
|
|
"5</arg_value></tool_call>",
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_calls = []
|
|
collected_params = ""
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
# Collect parameters
|
|
for call in result.calls:
|
|
if call.parameters:
|
|
collected_params += call.parameters
|
|
|
|
# Verify complete parameters were collected without duplication
|
|
if collected_params:
|
|
params = json.loads(collected_params)
|
|
self.assertEqual(params["city"], "Beijing")
|
|
self.assertEqual(params["temperature"], 25)
|
|
|
|
# Critical: Verify no duplicate JSON output due to reprocessing
|
|
# Count occurrences of "city" key - should appear exactly once
|
|
city_count = collected_params.count('"city"')
|
|
self.assertEqual(
|
|
city_count,
|
|
1,
|
|
f"'city' key appears {city_count} times, expected 1. "
|
|
f"This indicates input reprocessing bug.",
|
|
)
|
|
|
|
def test_streamed_raw_length_tag_split_across_chunks(self):
|
|
"""
|
|
Test _streamed_raw_length update when tag is split across chunk boundaries.
|
|
|
|
Scenario: XML tags themselves are split across chunks (e.g., "<arg_k" + "ey>").
|
|
Purpose: Verify that even when the state machine is buffering partial tags,
|
|
_streamed_raw_length is correctly updated to prevent reprocessing.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="search",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# Split tags in extreme positions
|
|
chunks = [
|
|
"<tool_call>search<arg_", # Split tag name
|
|
"key>query</arg_key><arg_value>Python progra", # Complete tag, split value
|
|
"mming</arg_value><arg_", # Complete value, split next tag
|
|
"key>limit</arg_key><arg_value>10</arg_value></tool_call>",
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_params = ""
|
|
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
for call in result.calls:
|
|
if call.parameters:
|
|
all_params += call.parameters
|
|
|
|
# Verify correct reassembly
|
|
params = json.loads(all_params)
|
|
self.assertEqual(params["query"], "Python programming")
|
|
self.assertEqual(params["limit"], 10)
|
|
|
|
# Verify no duplication in output
|
|
query_count = all_params.count('"query"')
|
|
limit_count = all_params.count('"limit"')
|
|
self.assertEqual(query_count, 1, "query key duplicated - reprocessing bug")
|
|
self.assertEqual(limit_count, 1, "limit key duplicated - reprocessing bug")
|
|
|
|
def test_streamed_raw_length_buffer_only_partial_tag(self):
|
|
"""
|
|
Test that _streamed_raw_length updates even when state machine returns empty.
|
|
|
|
Scenario: Send increment that is ONLY a partial opening tag that state machine
|
|
must buffer completely without producing any JSON output.
|
|
Purpose: Force json_increment to be empty string to expose the bug where
|
|
_streamed_raw_length is not updated before early return.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="test_func",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"key1": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# Manually call _process_arguments_streaming to have precise control
|
|
detector = Glm47MoeDetector()
|
|
detector.current_tool_id = 0
|
|
detector.current_tool_name_sent = True
|
|
detector._reset_streaming_state()
|
|
detector.streamed_args_for_tool = [""]
|
|
detector._streamed_raw_length = 0
|
|
|
|
# First call: Complete tag that produces JSON output
|
|
func_args_1 = "<arg_key>key1</arg_key><arg_value>va"
|
|
result_1 = detector._process_arguments_streaming(
|
|
"test_func", func_args_1, tools
|
|
)
|
|
|
|
# Should produce JSON output: {"key1": "va (partial)
|
|
self.assertIsNotNone(result_1)
|
|
self.assertGreater(len(result_1.parameters), 0)
|
|
initial_length = detector._streamed_raw_length
|
|
self.assertEqual(initial_length, len(func_args_1))
|
|
|
|
# Second call: Add just partial closing tag - state machine will buffer this
|
|
# without producing JSON (it's waiting to see if </arg_value> is complete)
|
|
func_args_2 = func_args_1 + "<" # Add partial tag
|
|
result_2 = detector._process_arguments_streaming(
|
|
"test_func", func_args_2, tools
|
|
)
|
|
|
|
# This is the critical test: if _streamed_raw_length is NOT updated when
|
|
# json_increment is empty, then detector._streamed_raw_length will still be
|
|
# at initial_length, and the next call will reprocess the "<" character
|
|
|
|
# Check if length was updated (bug test)
|
|
updated_length = detector._streamed_raw_length
|
|
|
|
# BUG: If code has bug, updated_length will equal initial_length
|
|
# FIXED: If code is correct, updated_length should equal len(func_args_2)
|
|
self.assertEqual(
|
|
updated_length,
|
|
len(func_args_2),
|
|
"Bug detected: _streamed_raw_length not updated when json_increment is empty. "
|
|
f"Expected {len(func_args_2)}, got {updated_length}",
|
|
)
|
|
|
|
def test_streamed_raw_length_multiple_empty_returns(self):
|
|
"""
|
|
Test consecutive chunks that produce empty json_increment.
|
|
|
|
Scenario: Multiple consecutive chunks that all result in empty json_increment
|
|
as the state machine buffers complex nested structures.
|
|
Purpose: Verify _streamed_raw_length advances correctly through multiple
|
|
empty-return cycles without getting stuck or reprocessing.
|
|
"""
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="update_settings",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"value": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# Split XML at positions that may cause state machine buffering
|
|
chunks = [
|
|
"<tool_call>update_settings<arg_key>na", # Split in tag name
|
|
"me</arg_key><arg_val", # Complete tag, split next tag
|
|
"ue>co", # Complete tag start, split value # codespell:ignore ue
|
|
"nf", # Continue value
|
|
"ig_v1</arg_value><arg_key>val", # Complete value, split next key
|
|
"ue</arg_key><arg_value>ena", # Complete key name, split value # codespell:ignore ue
|
|
"bled</arg_value></tool_call>", # Complete everything
|
|
]
|
|
|
|
detector = Glm47MoeDetector()
|
|
all_params = ""
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
result = detector.parse_streaming_increment(chunk, tools)
|
|
|
|
for call in result.calls:
|
|
if call.parameters:
|
|
all_params += call.parameters
|
|
|
|
# Verify final output is correct
|
|
self.assertGreater(len(all_params), 0, "Should have generated some parameters")
|
|
params = json.loads(all_params)
|
|
self.assertEqual(params["name"], "config_v1")
|
|
self.assertEqual(params["value"], "enabled")
|
|
|
|
# Verify no duplicate keys due to reprocessing
|
|
name_count = all_params.count('"name"')
|
|
value_count = all_params.count('"value"')
|
|
self.assertEqual(
|
|
name_count,
|
|
1,
|
|
f"'name' appears {name_count} times - indicates reprocessing bug",
|
|
)
|
|
self.assertEqual(
|
|
value_count,
|
|
1,
|
|
f"'value' appears {value_count} times - indicates reprocessing bug",
|
|
)
|
|
|
|
|
|
class TestGlm4ComplexJsonSchema(unittest.TestCase):
|
|
"""Test complex JSON Schema type inference for GLM function call parsers."""
|
|
|
|
def setUp(self):
|
|
"""Set up test tools with complex JSON schemas."""
|
|
self.tools_with_complex_schema = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="search",
|
|
description="Search for information",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"description": "Search query, can be a string or a complex object",
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {"type": "string"},
|
|
"filters": {"type": "object"},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"priority": {"enum": ["low", "medium", "high"]},
|
|
"options": {
|
|
"oneOf": [{"type": "string"}, {"type": "number"}]
|
|
},
|
|
"config": {
|
|
"allOf": [
|
|
{"type": "object"},
|
|
{"properties": {"timeout": {"type": "number"}}},
|
|
]
|
|
},
|
|
"tags": {"type": ["string", "null"]},
|
|
"data": {
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"type": "string"}
|
|
},
|
|
},
|
|
]
|
|
}
|
|
},
|
|
},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
),
|
|
),
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_weather",
|
|
description="Get weather information",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"location": {
|
|
"type": "string",
|
|
"description": "Location to get weather for",
|
|
},
|
|
"unit": {
|
|
"type": "string",
|
|
"description": "Temperature unit",
|
|
"enum": ["celsius", "fahrenheit"],
|
|
},
|
|
},
|
|
"required": ["location"],
|
|
},
|
|
),
|
|
),
|
|
]
|
|
self.glm4_detector = Glm4MoeDetector()
|
|
self.glm47_detector = Glm47MoeDetector()
|
|
|
|
def test_get_argument_type_simple_type(self):
|
|
"""Test that get_argument_type correctly handles simple type fields."""
|
|
result = get_argument_type(
|
|
"get_weather", "location", self.tools_with_complex_schema
|
|
)
|
|
self.assertEqual(result, "string")
|
|
|
|
def test_get_argument_type_enum_type(self):
|
|
"""Test that get_argument_type correctly identifies enum as string type."""
|
|
result = get_argument_type(
|
|
"get_weather", "unit", self.tools_with_complex_schema
|
|
)
|
|
# Current implementation returns the direct type field, which is "string" for the enum parameter
|
|
# But it doesn't handle enum-only schemas properly (without type field)
|
|
self.assertEqual(result, "string")
|
|
|
|
def test_get_argument_type_anyof_type(self):
|
|
"""Test that get_argument_type correctly handles anyOf type fields."""
|
|
result = get_argument_type("search", "query", self.tools_with_complex_schema)
|
|
# anyOf with [{"type": "string"}, {"type": "object", ...}] should return "string"
|
|
self.assertEqual(result, "string") # Returns first common type
|
|
|
|
def test_get_argument_type_oneof_type(self):
|
|
"""Test that get_argument_type correctly handles oneOf type fields."""
|
|
result = get_argument_type("search", "options", self.tools_with_complex_schema)
|
|
# oneOf with [{"type": "string"}, {"type": "number"}] should return "string" (prioritizes string)
|
|
self.assertEqual(result, "string")
|
|
|
|
def test_get_argument_type_allof_type(self):
|
|
"""Test that get_argument_type correctly handles allOf type fields."""
|
|
result = get_argument_type("search", "config", self.tools_with_complex_schema)
|
|
# allOf with [{"type": "object"}, ...] should return "object"
|
|
self.assertEqual(result, "object")
|
|
|
|
def test_get_argument_type_type_array(self):
|
|
"""Test that get_argument_type correctly handles type arrays."""
|
|
result = get_argument_type("search", "tags", self.tools_with_complex_schema)
|
|
# Type arrays should return the first non-null type
|
|
self.assertEqual(
|
|
result, "string"
|
|
) # ["string", "null"] -> "string" (non-null type)
|
|
|
|
def test_glm4_detector_with_complex_schema_anyof(self):
|
|
"""Test GLM4 detector with anyOf schema - should demonstrate current issues."""
|
|
# This test shows the current behavior with complex schemas
|
|
text = (
|
|
"<tool_call>search\n"
|
|
"<arg_key>query</arg_key>\n<arg_value>Hello world</arg_value>\n"
|
|
"<arg_key>priority</arg_key>\n<arg_value>medium</arg_value>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = self.glm4_detector.detect_and_parse(
|
|
text, self.tools_with_complex_schema
|
|
)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "search")
|
|
|
|
# Parse parameters to check if they are correctly handled
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["query"], "Hello world")
|
|
self.assertEqual(params["priority"], "medium")
|
|
|
|
def test_glm47_detector_with_complex_schema_anyof(self):
|
|
"""Test GLM47 detector with anyOf schema - should demonstrate current issues."""
|
|
# This test shows the current behavior with complex schemas
|
|
text = (
|
|
"<tool_call>search"
|
|
"<arg_key>query</arg_key><arg_value>Hello world</arg_value>"
|
|
"<arg_key>priority</arg_key><arg_value>medium</arg_value>"
|
|
"</tool_call>"
|
|
)
|
|
result = self.glm47_detector.detect_and_parse(
|
|
text, self.tools_with_complex_schema
|
|
)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "search")
|
|
|
|
# Parse parameters to check if they are correctly handled
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["query"], "Hello world")
|
|
self.assertEqual(params["priority"], "medium")
|
|
|
|
def test_glm4_detector_with_enum_values(self):
|
|
"""Test GLM4 detector with enum values in complex schema."""
|
|
text = (
|
|
"<tool_call>search\n"
|
|
"<arg_key>query</arg_key>\n<arg_value>test query</arg_value>\n"
|
|
"<arg_key>priority</arg_key>\n<arg_value>high</arg_value>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = self.glm4_detector.detect_and_parse(
|
|
text, self.tools_with_complex_schema
|
|
)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "search")
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["query"], "test query")
|
|
self.assertEqual(params["priority"], "high")
|
|
|
|
def test_glm47_detector_with_enum_values(self):
|
|
"""Test GLM47 detector with enum values in complex schema."""
|
|
text = (
|
|
"<tool_call>search"
|
|
"<arg_key>query</arg_key><arg_value>test query</arg_value>"
|
|
"<arg_key>priority</arg_key><arg_value>high</arg_value>"
|
|
"</tool_call>"
|
|
)
|
|
result = self.glm47_detector.detect_and_parse(
|
|
text, self.tools_with_complex_schema
|
|
)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "search")
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["query"], "test query")
|
|
self.assertEqual(params["priority"], "high")
|
|
|
|
def test_glm4_detector_streaming_with_complex_schema(self):
|
|
"""Test GLM4 detector streaming with complex schema."""
|
|
chunks = [
|
|
"<tool_call>search\n",
|
|
"<arg_key>query</arg_key>\n<arg_value>nested object</arg_value>\n",
|
|
"<arg_key>priority</arg_key>\n<arg_value>low</arg_value>\n",
|
|
"</tool_call>",
|
|
]
|
|
tool_calls = []
|
|
for chunk in chunks:
|
|
result = self.glm4_detector.parse_streaming_increment(
|
|
chunk, self.tools_with_complex_schema
|
|
)
|
|
for tool_call_chunk in result.calls:
|
|
if (
|
|
hasattr(tool_call_chunk, "tool_index")
|
|
and tool_call_chunk.tool_index is not None
|
|
):
|
|
while len(tool_calls) <= tool_call_chunk.tool_index:
|
|
tool_calls.append({"name": "", "parameters": ""})
|
|
tc = tool_calls[tool_call_chunk.tool_index]
|
|
if tool_call_chunk.name:
|
|
tc["name"] = tool_call_chunk.name
|
|
if tool_call_chunk.parameters:
|
|
tc["parameters"] += tool_call_chunk.parameters
|
|
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0]["name"], "search")
|
|
|
|
params = json.loads(tool_calls[0]["parameters"])
|
|
self.assertEqual(params["query"], "nested object")
|
|
self.assertEqual(params["priority"], "low")
|
|
|
|
def test_glm47_detector_streaming_with_complex_schema(self):
|
|
"""Test GLM47 detector streaming with complex schema."""
|
|
chunks = [
|
|
"<tool_call>search",
|
|
"<arg_key>query</arg_key><arg_value>nested object</arg_value>",
|
|
"<arg_key>priority</arg_key><arg_value>low</arg_value>",
|
|
"</tool_call>",
|
|
]
|
|
tool_calls = []
|
|
for chunk in chunks:
|
|
result = self.glm47_detector.parse_streaming_increment(
|
|
chunk, self.tools_with_complex_schema
|
|
)
|
|
for tool_call_chunk in result.calls:
|
|
if (
|
|
hasattr(tool_call_chunk, "tool_index")
|
|
and tool_call_chunk.tool_index is not None
|
|
):
|
|
while len(tool_calls) <= tool_call_chunk.tool_index:
|
|
tool_calls.append({"name": "", "parameters": ""})
|
|
tc = tool_calls[tool_call_chunk.tool_index]
|
|
if tool_call_chunk.name:
|
|
tc["name"] = tool_call_chunk.name
|
|
if tool_call_chunk.parameters:
|
|
tc["parameters"] += tool_call_chunk.parameters
|
|
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0]["name"], "search")
|
|
|
|
params = json.loads(tool_calls[0]["parameters"])
|
|
self.assertEqual(params["query"], "nested object")
|
|
self.assertEqual(params["priority"], "low")
|
|
|
|
def test_type_inference_issue_reproduction(self):
|
|
"""Reproduce the issue where complex JSON schemas are not properly handled."""
|
|
# This test demonstrates the current limitations
|
|
complex_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="complex_function",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"complex_param": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
]
|
|
},
|
|
"enum_param": {"enum": ["option1", "option2", "option3"]},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
|
|
# Test that get_argument_type returns appropriate types for complex schemas
|
|
anyof_result = get_argument_type(
|
|
"complex_function", "complex_param", complex_tools
|
|
)
|
|
enum_result = get_argument_type("complex_function", "enum_param", complex_tools)
|
|
|
|
# Verify complex schema types are correctly inferred
|
|
self.assertEqual(anyof_result, "string") # anyOf prioritizes string type
|
|
self.assertEqual(enum_result, "string") # enum values are strings
|
|
|
|
def test_expected_behavior_for_complex_schemas(self):
|
|
"""Test cases that should work but currently fail - demonstrating the issue."""
|
|
# This test shows what the behavior SHOULD be after the fix
|
|
complex_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="complex_function",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"complex_param": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
]
|
|
},
|
|
"enum_param": {"enum": ["option1", "option2", "option3"]},
|
|
"oneof_param": {
|
|
"oneOf": [{"type": "string"}, {"type": "number"}]
|
|
},
|
|
"allof_param": {
|
|
"allOf": [
|
|
{"type": "object"},
|
|
{"properties": {"timeout": {"type": "number"}}},
|
|
]
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
|
|
# These assertions represent the EXPECTED behavior after implementing RFC improvements
|
|
# Currently they will fail, demonstrating the issue
|
|
anyof_result = get_argument_type(
|
|
"complex_function", "complex_param", complex_tools
|
|
)
|
|
enum_result = get_argument_type("complex_function", "enum_param", complex_tools)
|
|
oneof_result = get_argument_type(
|
|
"complex_function", "oneof_param", complex_tools
|
|
)
|
|
allof_result = get_argument_type(
|
|
"complex_function", "allof_param", complex_tools
|
|
)
|
|
|
|
# These should pass after implementing the RFC improvements, but will currently fail
|
|
# This demonstrates the issue exists
|
|
self.assertIsNotNone(
|
|
anyof_result, "anyOf should return a type after RFC implementation"
|
|
)
|
|
self.assertEqual(
|
|
enum_result,
|
|
"string",
|
|
"enum should return 'string' type after RFC implementation",
|
|
)
|
|
self.assertIsNotNone(
|
|
oneof_result, "oneOf should return a type after RFC implementation"
|
|
)
|
|
self.assertIsNotNone(
|
|
allof_result, "allOf should return a type after RFC implementation"
|
|
)
|
|
|
|
def test_complex_schema_type_inference_scenarios(self):
|
|
"""Test various complex schema scenarios mentioned in the RFC."""
|
|
# Create tools with different complex schema structures
|
|
complex_schema_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="search_complex",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
# anyOf example - parameter can be string or object
|
|
"query": {
|
|
"description": "Search query, can be a string or a complex object",
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {"type": "string"},
|
|
"filters": {"type": "object"},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
# oneOf example - parameter must be one of the specified types
|
|
"priority": {
|
|
"oneOf": [{"type": "string"}, {"type": "integer"}]
|
|
},
|
|
# enum example - parameter must be one of the enum values
|
|
"category": {"enum": ["news", "sports", "tech"]},
|
|
# allOf example - parameter must satisfy all schemas
|
|
"config": {
|
|
"allOf": [
|
|
{"type": "object"},
|
|
{"properties": {"timeout": {"type": "number"}}},
|
|
]
|
|
},
|
|
# Type array example
|
|
"tags": {"type": ["string", "null"]},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_data",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
# Complex nested anyOf
|
|
"input": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{"type": "number"},
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {"type": "string"},
|
|
"value": {},
|
|
},
|
|
},
|
|
]
|
|
}
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
# Test each complex type scenario
|
|
query_type = get_argument_type("search_complex", "query", complex_schema_tools)
|
|
priority_type = get_argument_type(
|
|
"search_complex", "priority", complex_schema_tools
|
|
)
|
|
category_type = get_argument_type(
|
|
"search_complex", "category", complex_schema_tools
|
|
)
|
|
config_type = get_argument_type(
|
|
"search_complex", "config", complex_schema_tools
|
|
)
|
|
tags_type = get_argument_type("search_complex", "tags", complex_schema_tools)
|
|
input_type = get_argument_type("get_data", "input", complex_schema_tools)
|
|
|
|
# All of these should return appropriate types according to RFC
|
|
self.assertEqual(query_type, "string") # anyOf: string | object -> string
|
|
self.assertEqual(priority_type, "string") # oneOf: string | integer -> string
|
|
self.assertEqual(
|
|
category_type, "string"
|
|
) # enum: ["news", "sports", "tech"] -> string
|
|
self.assertEqual(config_type, "object") # allOf with object -> object
|
|
self.assertEqual(
|
|
tags_type, "string"
|
|
) # type array: ["string", "null"] -> string
|
|
self.assertEqual(
|
|
input_type, "string"
|
|
) # nested anyOf: string | number | object -> string
|
|
|
|
def test_glm4_detector_type_handling_with_complex_schema(self):
|
|
"""Test how GLM4 detector handles type inference for complex schemas in practice."""
|
|
complex_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="complex_search",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
},
|
|
]
|
|
},
|
|
"category": {"enum": ["tech", "news", "sports"]},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
|
|
# Test with string value for anyOf parameter
|
|
text = (
|
|
"<tool_call>complex_search\n"
|
|
"<arg_key>query</arg_key>\n<arg_value>test search</arg_value>\n"
|
|
"<arg_key>category</arg_key>\n<arg_value>tech</arg_value>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = self.glm4_detector.detect_and_parse(text, complex_tools)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "complex_search")
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["query"], "test search")
|
|
self.assertEqual(params["category"], "tech")
|
|
|
|
def test_glm47_detector_type_handling_with_complex_schema(self):
|
|
"""Test how GLM47 detector handles type inference for complex schemas in practice."""
|
|
complex_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="complex_search",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
},
|
|
]
|
|
},
|
|
"category": {"enum": ["tech", "news", "sports"]},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
|
|
# Test with string value for anyOf parameter
|
|
text = (
|
|
"<tool_call>complex_search"
|
|
"<arg_key>query</arg_key><arg_value>test search</arg_value>"
|
|
"<arg_key>category</arg_key><arg_value>tech</arg_value>"
|
|
"</tool_call>"
|
|
)
|
|
result = self.glm47_detector.detect_and_parse(text, complex_tools)
|
|
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "complex_search")
|
|
|
|
params = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(params["query"], "test search")
|
|
self.assertEqual(params["category"], "tech")
|
|
|
|
def test_streaming_with_complex_schema_type_inference(self):
|
|
"""Test streaming behavior with complex schema type inference."""
|
|
complex_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="stream_test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
]
|
|
},
|
|
"status": {"enum": ["active", "inactive"]},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
|
|
# Test GLM4 detector streaming
|
|
chunks = [
|
|
"<tool_call>stream_test\n",
|
|
"<arg_key>data</arg_key>\n<arg_value>nested data</arg_value>\n",
|
|
"<arg_key>status</arg_key>\n<arg_value>active</arg_value>\n",
|
|
"</tool_call>",
|
|
]
|
|
tool_calls = []
|
|
for chunk in chunks:
|
|
result = self.glm4_detector.parse_streaming_increment(chunk, complex_tools)
|
|
for tool_call_chunk in result.calls:
|
|
if (
|
|
hasattr(tool_call_chunk, "tool_index")
|
|
and tool_call_chunk.tool_index is not None
|
|
):
|
|
while len(tool_calls) <= tool_call_chunk.tool_index:
|
|
tool_calls.append({"name": "", "parameters": ""})
|
|
tc = tool_calls[tool_call_chunk.tool_index]
|
|
if tool_call_chunk.name:
|
|
tc["name"] = tool_call_chunk.name
|
|
if tool_call_chunk.parameters:
|
|
tc["parameters"] += tool_call_chunk.parameters
|
|
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0]["name"], "stream_test")
|
|
|
|
params = json.loads(tool_calls[0]["parameters"])
|
|
self.assertEqual(params["data"], "nested data")
|
|
self.assertEqual(params["status"], "active")
|
|
|
|
def test_streaming_with_complex_schema_type_inference_glm47(self):
|
|
"""Test GLM47 streaming behavior with complex schema type inference."""
|
|
complex_tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="stream_test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
]
|
|
},
|
|
"status": {"enum": ["active", "inactive"]},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
|
|
# Test GLM47 detector streaming
|
|
chunks = [
|
|
"<tool_call>stream_test",
|
|
"<arg_key>data</arg_key><arg_value>nested data</arg_value>",
|
|
"<arg_key>status</arg_key><arg_value>active</arg_value>",
|
|
"</tool_call>",
|
|
]
|
|
tool_calls = []
|
|
for chunk in chunks:
|
|
result = self.glm47_detector.parse_streaming_increment(chunk, complex_tools)
|
|
for tool_call_chunk in result.calls:
|
|
if (
|
|
hasattr(tool_call_chunk, "tool_index")
|
|
and tool_call_chunk.tool_index is not None
|
|
):
|
|
while len(tool_calls) <= tool_call_chunk.tool_index:
|
|
tool_calls.append({"name": "", "parameters": ""})
|
|
tc = tool_calls[tool_call_chunk.tool_index]
|
|
if tool_call_chunk.name:
|
|
tc["name"] = tool_call_chunk.name
|
|
if tool_call_chunk.parameters:
|
|
tc["parameters"] += tool_call_chunk.parameters
|
|
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0]["name"], "stream_test")
|
|
|
|
params = json.loads(tool_calls[0]["parameters"])
|
|
self.assertEqual(params["data"], "nested data")
|
|
self.assertEqual(params["status"], "active")
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|