[CI] Move existing unit tests into unit directory (#20631)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
"""
|
||||
Unit tests for Jinja chat template utils.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from sglang.srt.parser.jinja_template_utils import (
|
||||
detect_jinja_template_content_format,
|
||||
process_content_for_template_format,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=7, suite="stage-a-cpu-only")
|
||||
|
||||
|
||||
class TestTemplateContentFormatDetection(CustomTestCase):
|
||||
"""Test template content format detection functionality."""
|
||||
|
||||
def test_detect_llama4_openai_format(self):
|
||||
"""Test detection of llama4-style template (should be 'openai' format)."""
|
||||
llama4_pattern = """
|
||||
{%- for message in messages %}
|
||||
{%- if message['content'] is string %}
|
||||
{{- message['content'] }}
|
||||
{%- else %}
|
||||
{%- for content in message['content'] %}
|
||||
{%- if content['type'] == 'image' %}
|
||||
{{- '<|image|>' }}
|
||||
{%- elif content['type'] == 'text' %}
|
||||
{{- content['text'] | trim }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
"""
|
||||
|
||||
result = detect_jinja_template_content_format(llama4_pattern)
|
||||
self.assertEqual(result, "openai")
|
||||
|
||||
def test_detect_deepseek_string_format(self):
|
||||
"""Test detection of deepseek-style template (should be 'string' format)."""
|
||||
deepseek_pattern = """
|
||||
{%- for message in messages %}
|
||||
{%- if message['role'] == 'user' %}
|
||||
{{- '<|User|>' + message['content'] + '<|Assistant|>' }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
"""
|
||||
|
||||
result = detect_jinja_template_content_format(deepseek_pattern)
|
||||
self.assertEqual(result, "string")
|
||||
|
||||
def test_detect_invalid_template(self):
|
||||
"""Test handling of invalid template (should default to 'string')."""
|
||||
invalid_pattern = "{{{{ invalid jinja syntax }}}}"
|
||||
|
||||
result = detect_jinja_template_content_format(invalid_pattern)
|
||||
self.assertEqual(result, "string")
|
||||
|
||||
def test_detect_empty_template(self):
|
||||
"""Test handling of empty template (should default to 'string')."""
|
||||
result = detect_jinja_template_content_format("")
|
||||
self.assertEqual(result, "string")
|
||||
|
||||
def test_detect_msg_content_pattern(self):
|
||||
"""Test detection of template with msg.content pattern (should be 'openai' format)."""
|
||||
msg_content_pattern = """
|
||||
[gMASK]<sop>
|
||||
{%- for msg in messages %}
|
||||
{%- if msg.role == 'system' %}
|
||||
<|system|>
|
||||
{{ msg.content }}
|
||||
{%- elif msg.role == 'user' %}
|
||||
<|user|>{{ '\n' }}
|
||||
{%- if msg.content is string %}
|
||||
{{ msg.content }}
|
||||
{%- else %}
|
||||
{%- for item in msg.content %}
|
||||
{%- if item.type == 'video' or 'video' in item %}
|
||||
<|begin_of_video|><|video|><|end_of_video|>
|
||||
{%- elif item.type == 'image' or 'image' in item %}
|
||||
<|begin_of_image|><|image|><|end_of_image|>
|
||||
{%- elif item.type == 'text' %}
|
||||
{{ item.text }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- elif msg.role == 'assistant' %}
|
||||
{%- if msg.metadata %}
|
||||
<|assistant|>{{ msg.metadata }}
|
||||
{{ msg.content }}
|
||||
{%- else %}
|
||||
<|assistant|>
|
||||
{{ msg.content }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{% if add_generation_prompt %}<|assistant|>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
result = detect_jinja_template_content_format(msg_content_pattern)
|
||||
self.assertEqual(result, "openai")
|
||||
|
||||
def test_detect_m_content_pattern(self):
|
||||
"""Test detection of template with m.content pattern (should be 'openai' format)."""
|
||||
msg_content_pattern = """
|
||||
[gMASK]<sop>
|
||||
{%- for m in messages %}
|
||||
{%- if m.role == 'system' %}
|
||||
<|system|>
|
||||
{{ m.content }}
|
||||
{%- elif m.role == 'user' %}
|
||||
<|user|>{{ '\n' }}
|
||||
{%- if m.content is string %}
|
||||
{{ m.content }}
|
||||
{%- else %}
|
||||
{%- for item in m.content %}
|
||||
{%- if item.type == 'video' or 'video' in item %}
|
||||
<|begin_of_video|><|video|><|end_of_video|>
|
||||
{%- elif item.type == 'image' or 'image' in item %}
|
||||
<|begin_of_image|><|image|><|end_of_image|>
|
||||
{%- elif item.type == 'text' %}
|
||||
{{ item.text }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- elif m.role == 'assistant' %}
|
||||
{%- if m.metadata %}
|
||||
<|assistant|>{{ m.metadata }}
|
||||
{{ m.content }}
|
||||
{%- else %}
|
||||
<|assistant|>
|
||||
{{ m.content }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{% if add_generation_prompt %}<|assistant|>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
result = detect_jinja_template_content_format(msg_content_pattern)
|
||||
self.assertEqual(result, "openai")
|
||||
|
||||
def test_process_content_openai_format(self):
|
||||
"""Test content processing for openai format."""
|
||||
msg_dict = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Look at this image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "http://example.com/image.jpg"},
|
||||
},
|
||||
{"type": "text", "text": "What do you see?"},
|
||||
],
|
||||
}
|
||||
|
||||
image_data = []
|
||||
video_data = []
|
||||
audio_data = []
|
||||
modalities = []
|
||||
|
||||
result = process_content_for_template_format(
|
||||
msg_dict, "openai", image_data, video_data, audio_data, modalities
|
||||
)
|
||||
|
||||
# Check that image_data was extracted
|
||||
self.assertEqual(len(image_data), 1)
|
||||
self.assertEqual(image_data[0].url, "http://example.com/image.jpg")
|
||||
|
||||
# Check that content was normalized
|
||||
expected_content = [
|
||||
{"type": "text", "text": "Look at this image:"},
|
||||
{"type": "image"}, # normalized from image_url
|
||||
{"type": "text", "text": "What do you see?"},
|
||||
]
|
||||
self.assertEqual(result["content"], expected_content)
|
||||
self.assertEqual(result["role"], "user")
|
||||
|
||||
def test_process_content_string_format(self):
|
||||
"""Test content processing for string format."""
|
||||
msg_dict = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "http://example.com/image.jpg"},
|
||||
},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
}
|
||||
|
||||
image_data = []
|
||||
video_data = []
|
||||
audio_data = []
|
||||
modalities = []
|
||||
|
||||
result = process_content_for_template_format(
|
||||
msg_dict, "string", image_data, video_data, audio_data, modalities
|
||||
)
|
||||
|
||||
# For string format, should flatten to text only
|
||||
self.assertEqual(result["content"], "Hello world")
|
||||
self.assertEqual(result["role"], "user")
|
||||
|
||||
# Image data should not be extracted for string format
|
||||
self.assertEqual(len(image_data), 0)
|
||||
|
||||
def test_process_content_with_audio(self):
|
||||
"""Test content processing with audio content."""
|
||||
msg_dict = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Listen to this:"},
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": "http://example.com/audio.mp3"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
image_data = []
|
||||
video_data = []
|
||||
audio_data = []
|
||||
modalities = []
|
||||
|
||||
result = process_content_for_template_format(
|
||||
msg_dict, "openai", image_data, video_data, audio_data, modalities
|
||||
)
|
||||
|
||||
# Check that audio_data was extracted
|
||||
self.assertEqual(len(audio_data), 1)
|
||||
self.assertEqual(audio_data[0], "http://example.com/audio.mp3")
|
||||
|
||||
# Check that content was normalized
|
||||
expected_content = [
|
||||
{"type": "text", "text": "Listen to this:"},
|
||||
{"type": "audio"}, # normalized from audio_url
|
||||
]
|
||||
self.assertEqual(result["content"], expected_content)
|
||||
|
||||
def test_process_content_already_string(self):
|
||||
"""Test processing content that's already a string."""
|
||||
msg_dict = {"role": "user", "content": "Hello world"}
|
||||
|
||||
image_data = []
|
||||
video_data = []
|
||||
audio_data = []
|
||||
modalities = []
|
||||
|
||||
result = process_content_for_template_format(
|
||||
msg_dict, "openai", image_data, video_data, audio_data, modalities
|
||||
)
|
||||
|
||||
# Should pass through unchanged
|
||||
self.assertEqual(result["content"], "Hello world")
|
||||
self.assertEqual(result["role"], "user")
|
||||
self.assertEqual(len(image_data), 0)
|
||||
|
||||
def test_process_content_with_modalities(self):
|
||||
"""Test content processing with modalities field."""
|
||||
msg_dict = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "http://example.com/image.jpg"},
|
||||
"modalities": ["vision"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
image_data = []
|
||||
video_data = []
|
||||
audio_data = []
|
||||
modalities = []
|
||||
|
||||
result = process_content_for_template_format(
|
||||
msg_dict, "openai", image_data, video_data, audio_data, modalities
|
||||
)
|
||||
|
||||
# Check that modalities was extracted
|
||||
self.assertEqual(len(modalities), 1)
|
||||
self.assertEqual(modalities[0], ["vision"])
|
||||
|
||||
def test_process_content_filter_none_values(self):
|
||||
"""Test that None values are filtered out of processed messages."""
|
||||
msg_dict = {
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
"name": None,
|
||||
"tool_call_id": None,
|
||||
}
|
||||
|
||||
image_data = []
|
||||
video_data = []
|
||||
audio_data = []
|
||||
modalities = []
|
||||
|
||||
result = process_content_for_template_format(
|
||||
msg_dict, "string", image_data, video_data, audio_data, modalities
|
||||
)
|
||||
|
||||
# None values should be filtered out
|
||||
expected_keys = {"role", "content"}
|
||||
self.assertEqual(set(result.keys()), expected_keys)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,567 +0,0 @@
|
||||
"""
|
||||
Tests for JSON schema constraint functionality used by JsonArrayParser
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import jsonschema
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import (
|
||||
Function,
|
||||
Tool,
|
||||
ToolChoice,
|
||||
ToolChoiceFuncName,
|
||||
)
|
||||
from sglang.srt.function_call.utils import (
|
||||
_get_tool_schema_defs,
|
||||
get_json_schema_constraint,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(1.0, "default")
|
||||
|
||||
|
||||
class TestJsonSchemaConstraint(unittest.TestCase):
|
||||
"""Test JSON schema constraint generation for tool choices"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test tools"""
|
||||
self.tools = [
|
||||
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",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "Temperature unit",
|
||||
},
|
||||
},
|
||||
"required": ["location"],
|
||||
},
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="search",
|
||||
description="Search for information",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def test_required_tool_choice_schema(self):
|
||||
"""Test schema generation for tool_choice='required'"""
|
||||
schema = get_json_schema_constraint(self.tools, "required")
|
||||
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
self.assertEqual(schema["type"], "array")
|
||||
self.assertEqual(schema["minItems"], 1)
|
||||
self.assertIn("items", schema)
|
||||
self.assertIn("anyOf", schema["items"])
|
||||
|
||||
# Should have schemas for both tools
|
||||
self.assertEqual(len(schema["items"]["anyOf"]), 2)
|
||||
|
||||
# Check that each tool schema is present
|
||||
tool_names = [
|
||||
item["properties"]["name"]["enum"][0] for item in schema["items"]["anyOf"]
|
||||
]
|
||||
self.assertIn("get_weather", tool_names)
|
||||
self.assertIn("search", tool_names)
|
||||
|
||||
def test_specific_tool_choice_schema(self):
|
||||
"""Test schema generation for specific tool choice"""
|
||||
tool_choice = ToolChoice(
|
||||
type="function", function=ToolChoiceFuncName(name="get_weather")
|
||||
)
|
||||
schema = get_json_schema_constraint(self.tools, tool_choice)
|
||||
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
self.assertEqual(schema["type"], "array")
|
||||
self.assertEqual(schema["minItems"], 1)
|
||||
self.assertEqual(schema["maxItems"], 1)
|
||||
|
||||
# Should only have schema for the specific tool
|
||||
item_schema = schema["items"]
|
||||
self.assertEqual(item_schema["properties"]["name"]["enum"], ["get_weather"])
|
||||
self.assertIn("parameters", item_schema["properties"])
|
||||
|
||||
def test_specific_tool_choice_dict_schema(self):
|
||||
"""Test schema generation for specific tool choice as ToolChoice object"""
|
||||
tool_choice = ToolChoice(
|
||||
type="function", function=ToolChoiceFuncName(name="search")
|
||||
)
|
||||
schema = get_json_schema_constraint(self.tools, tool_choice)
|
||||
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
self.assertEqual(schema["type"], "array")
|
||||
self.assertEqual(schema["minItems"], 1)
|
||||
self.assertEqual(schema["maxItems"], 1)
|
||||
|
||||
# Should only have schema for the specific tool
|
||||
item_schema = schema["items"]
|
||||
self.assertEqual(item_schema["properties"]["name"]["enum"], ["search"])
|
||||
self.assertIn("parameters", item_schema["properties"])
|
||||
|
||||
def test_nonexistent_tool_choice(self):
|
||||
"""Test schema generation for nonexistent tool"""
|
||||
tool_choice = ToolChoice(
|
||||
type="function", function=ToolChoiceFuncName(name="nonexistent")
|
||||
)
|
||||
schema = get_json_schema_constraint(self.tools, tool_choice)
|
||||
|
||||
self.assertIsNone(schema)
|
||||
|
||||
def test_nonexistent_tool_choice_dict(self):
|
||||
"""Test schema generation for nonexistent tool as dict"""
|
||||
tool_choice = {"type": "function", "function": {"name": "nonexistent"}}
|
||||
schema = get_json_schema_constraint(self.tools, tool_choice)
|
||||
|
||||
self.assertIsNone(schema)
|
||||
|
||||
def test_auto_tool_choice_schema(self):
|
||||
"""Test schema generation for tool_choice='auto'"""
|
||||
schema = get_json_schema_constraint(self.tools, "auto")
|
||||
|
||||
self.assertIsNone(schema)
|
||||
|
||||
def test_none_tool_choice_schema(self):
|
||||
"""Test schema generation for tool_choice=None"""
|
||||
schema = get_json_schema_constraint(self.tools, None)
|
||||
|
||||
self.assertIsNone(schema)
|
||||
|
||||
def test_tools_with_defs(self):
|
||||
"""Test schema generation with tools that have $defs"""
|
||||
tools_with_defs = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="complex_tool",
|
||||
description="Tool with complex schema",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nested": {"$ref": "#/$defs/NestedType"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"NestedType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
try:
|
||||
_get_tool_schema_defs(tools_with_defs)
|
||||
except ValueError as e:
|
||||
self.fail(f"Should not raise ValueError, but got: {e}")
|
||||
|
||||
schema = get_json_schema_constraint(tools_with_defs, "required")
|
||||
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
self.assertIn("$defs", schema)
|
||||
self.assertIn("NestedType", schema["$defs"])
|
||||
|
||||
def test_tools_without_parameters(self):
|
||||
"""Test schema generation with tools that have no parameters"""
|
||||
tools_without_params = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="simple_tool",
|
||||
description="Tool without parameters",
|
||||
parameters=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
schema = get_json_schema_constraint(tools_without_params, "required")
|
||||
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
item_schema = schema["items"]["anyOf"][0]
|
||||
self.assertEqual(
|
||||
item_schema["properties"]["parameters"],
|
||||
{"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
def test_conflicting_defs_raises_valueerror(self):
|
||||
"""Test that conflicting tool definitions raise ValueError with proper message"""
|
||||
tools_with_conflicting_defs = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="tool1",
|
||||
description="Tool 1",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"$defs": {
|
||||
"ConflictingType": {
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="tool2",
|
||||
description="Tool 2",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"$defs": {
|
||||
"ConflictingType": {
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "number"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
_get_tool_schema_defs(tools_with_conflicting_defs)
|
||||
|
||||
self.assertIn(
|
||||
"Tool definition 'ConflictingType' has multiple schemas",
|
||||
str(context.exception),
|
||||
)
|
||||
self.assertIn("which is not supported", str(context.exception))
|
||||
|
||||
def test_tools_with_empty_defs(self):
|
||||
"""Test tools with empty $defs objects"""
|
||||
tools_with_empty_defs = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="empty_defs_tool",
|
||||
description="Tool with empty $defs",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
},
|
||||
"required": ["data"],
|
||||
"$defs": {},
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
try:
|
||||
_get_tool_schema_defs(tools_with_empty_defs)
|
||||
except ValueError as e:
|
||||
self.fail(f"Should not raise ValueError, but got: {e}")
|
||||
|
||||
schema = get_json_schema_constraint(tools_with_empty_defs, "required")
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
# Should not have $defs section when empty
|
||||
self.assertNotIn("$defs", schema)
|
||||
|
||||
def test_tools_with_identical_defs(self):
|
||||
"""Test different tools with same $defs names but identical schemas (should not raise exception)"""
|
||||
tools_with_identical_defs = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="weather_tool",
|
||||
description="Get weather information",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"$ref": "#/$defs/Location"},
|
||||
},
|
||||
"required": ["location"],
|
||||
"$defs": {
|
||||
"Location": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lat": {"type": "number"},
|
||||
"lon": {"type": "number"},
|
||||
},
|
||||
"required": ["lat", "lon"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="address_tool",
|
||||
description="Get address information",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {"$ref": "#/$defs/Location"},
|
||||
},
|
||||
"required": ["address"],
|
||||
"$defs": {
|
||||
"Location": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lat": {"type": "number"},
|
||||
"lon": {"type": "number"},
|
||||
},
|
||||
"required": ["lat", "lon"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
try:
|
||||
_get_tool_schema_defs(tools_with_identical_defs)
|
||||
except ValueError as e:
|
||||
self.fail(
|
||||
f"Should not raise ValueError for identical schemas, but got: {e}"
|
||||
)
|
||||
|
||||
# Also test that schema generation works
|
||||
schema = get_json_schema_constraint(tools_with_identical_defs, "required")
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
# Verify both tools are present
|
||||
tool_names = [
|
||||
item["properties"]["name"]["enum"][0] for item in schema["items"]["anyOf"]
|
||||
]
|
||||
self.assertIn("weather_tool", tool_names)
|
||||
self.assertIn("address_tool", tool_names)
|
||||
|
||||
# Should have $defs with Location
|
||||
self.assertIn("$defs", schema)
|
||||
self.assertIn("Location", schema["$defs"])
|
||||
|
||||
def test_tools_with_nested_defs(self):
|
||||
"""Test tools with nested $defs"""
|
||||
tools_with_nested_defs = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="complex_tool",
|
||||
description="Tool with nested $defs",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {"$ref": "#/$defs/User"},
|
||||
"settings": {"$ref": "#/$defs/Settings"},
|
||||
},
|
||||
"required": ["user"],
|
||||
"$defs": {
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"profile": {"$ref": "#/$defs/Profile"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"Profile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string", "format": "email"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"],
|
||||
},
|
||||
"notifications": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
try:
|
||||
_get_tool_schema_defs(tools_with_nested_defs)
|
||||
except ValueError as e:
|
||||
self.fail(f"Should not raise ValueError, but got: {e}")
|
||||
|
||||
schema = get_json_schema_constraint(tools_with_nested_defs, "required")
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
# Verify all $defs are properly included
|
||||
self.assertIn("$defs", schema)
|
||||
self.assertIn("User", schema["$defs"])
|
||||
self.assertIn("Profile", schema["$defs"])
|
||||
self.assertIn("Settings", schema["$defs"])
|
||||
|
||||
def test_mixed_tools_with_and_without_defs(self):
|
||||
"""Test mixed tools with and without $defs"""
|
||||
mixed_tools = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="simple_tool",
|
||||
description="Simple tool without $defs",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="complex_tool",
|
||||
description="Complex tool with $defs",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"$ref": "#/$defs/DataType"},
|
||||
},
|
||||
"required": ["data"],
|
||||
"$defs": {
|
||||
"DataType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"type": "string"},
|
||||
"metadata": {"type": "object"},
|
||||
},
|
||||
"required": ["value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="another_simple_tool",
|
||||
description="Another simple tool",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
try:
|
||||
_get_tool_schema_defs(mixed_tools)
|
||||
except ValueError as e:
|
||||
self.fail(f"Should not raise ValueError, but got: {e}")
|
||||
|
||||
schema = get_json_schema_constraint(mixed_tools, "required")
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
# Should have $defs from the complex tool
|
||||
self.assertIn("$defs", schema)
|
||||
self.assertIn("DataType", schema["$defs"])
|
||||
|
||||
# Should have all three tools
|
||||
tool_names = [
|
||||
item["properties"]["name"]["enum"][0] for item in schema["items"]["anyOf"]
|
||||
]
|
||||
self.assertEqual(len(tool_names), 3)
|
||||
self.assertIn("simple_tool", tool_names)
|
||||
self.assertIn("complex_tool", tool_names)
|
||||
self.assertIn("another_simple_tool", tool_names)
|
||||
|
||||
def test_tools_with_defs_but_no_refs(self):
|
||||
"""Test tools with $defs but no $ref usage"""
|
||||
tools_with_unused_defs = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="unused_defs_tool",
|
||||
description="Tool with $defs but no $ref usage",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
},
|
||||
"required": ["data"],
|
||||
"$defs": {
|
||||
"UnusedType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
try:
|
||||
_get_tool_schema_defs(tools_with_unused_defs)
|
||||
except ValueError as e:
|
||||
self.fail(f"Should not raise ValueError, but got: {e}")
|
||||
|
||||
schema = get_json_schema_constraint(tools_with_unused_defs, "required")
|
||||
self.assertIsNotNone(schema)
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
# Should still include $defs even if not referenced
|
||||
self.assertIn("$defs", schema)
|
||||
self.assertIn("UnusedType", schema["$defs"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,163 +0,0 @@
|
||||
import json
|
||||
|
||||
"""
|
||||
Test case for parallel tool call parsing.
|
||||
|
||||
This test verifies that the parser correctly handles parallel tool calls
|
||||
with array parameters in JSON array format.
|
||||
|
||||
Scenario:
|
||||
- Model outputs two parallel tool calls in JSON array format
|
||||
- Both tools have array parameters (e.g., "title": ["7.8.9 H-9 ..."])
|
||||
- First tool completes with closing braces
|
||||
- Second tool starts with opening brace
|
||||
- The parser must correctly handle the '[' characters in array parameters
|
||||
without confusing them with the JSON array start
|
||||
|
||||
Expected behavior: Both tools should be parsed correctly.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import Function, Tool
|
||||
from sglang.srt.function_call.json_array_parser import JsonArrayParser
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(1.0, "default")
|
||||
|
||||
|
||||
class TestParallelToolCalls(unittest.TestCase):
|
||||
"""Test case for parallel tool call parsing with array parameters."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test tools and detector."""
|
||||
self.tools = [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="search_docs",
|
||||
description="Search documents",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Document title",
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
self.detector = JsonArrayParser()
|
||||
|
||||
def _accumulate_tool_calls(self, tool_calls, result):
|
||||
"""Helper method to accumulate tool call results from parsing output."""
|
||||
if not result.calls:
|
||||
return
|
||||
for call in result.calls:
|
||||
if call.tool_index is None:
|
||||
continue
|
||||
while len(tool_calls) <= call.tool_index:
|
||||
tool_calls.append({"name": "", "parameters": ""})
|
||||
if call.name:
|
||||
tool_calls[call.tool_index]["name"] = call.name
|
||||
if call.parameters:
|
||||
tool_calls[call.tool_index]["parameters"] += call.parameters
|
||||
|
||||
def test_parallel_tool_calls_with_array_parameters(self):
|
||||
"""
|
||||
Test parsing two parallel tool calls where both have array parameters.
|
||||
|
||||
This test reproduces the specific scenario:
|
||||
- Two tool calls separated by comma
|
||||
- Both tools have array parameters containing '[' character
|
||||
- First tool completes with '}},'
|
||||
- Second tool starts with '{"name": ..., "parameters": {"title": ["'
|
||||
|
||||
Expected: Both tools should be parsed correctly without errors.
|
||||
"""
|
||||
# Simulate more realistic streaming chunks where
|
||||
# the key issue is the comma separator followed by second tool with array param
|
||||
chunks = [
|
||||
"[\n",
|
||||
' {"name": "search_docs", "parameters": {"title": ["7.8.9"',
|
||||
'], "filename": "doc1"}},\n',
|
||||
' {"name": "search_docs", "parameters": {"title": ',
|
||||
'["4.8"], "filename": "doc2"}}',
|
||||
"]",
|
||||
]
|
||||
|
||||
tool_calls = []
|
||||
errors = []
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
try:
|
||||
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||
# Collect tool calls
|
||||
self._accumulate_tool_calls(tool_calls, result)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Chunk {i} ({repr(chunk)}): {type(e).__name__}: {e}")
|
||||
|
||||
# Verify no errors occurred
|
||||
if errors:
|
||||
self.fail("Errors occurred during parsing:\n" + "\n".join(errors))
|
||||
|
||||
# Verify both tool calls were parsed
|
||||
self.assertEqual(len(tool_calls), 2, "Should have parsed exactly 2 tool calls")
|
||||
|
||||
# Verify first tool call
|
||||
self.assertEqual(
|
||||
tool_calls[0]["name"],
|
||||
"search_docs",
|
||||
"First tool name should be search_docs",
|
||||
)
|
||||
params1 = json.loads(tool_calls[0]["parameters"])
|
||||
self.assertEqual(params1["title"], ["7.8.9"], "First tool title should match")
|
||||
self.assertEqual(
|
||||
params1["filename"], "doc1", "First tool filename should be doc1"
|
||||
)
|
||||
|
||||
# Verify second tool call
|
||||
self.assertEqual(
|
||||
tool_calls[1]["name"],
|
||||
"search_docs",
|
||||
"Second tool name should be search_docs",
|
||||
)
|
||||
params2 = json.loads(tool_calls[1]["parameters"])
|
||||
self.assertEqual(params2["title"], ["4.8"], "Second tool title should match")
|
||||
self.assertEqual(
|
||||
params2["filename"], "doc2", "Second tool filename should be doc2"
|
||||
)
|
||||
|
||||
def test_simple_parallel_tool_calls(self):
|
||||
"""
|
||||
Test a simpler case of two parallel tool calls with array parameters.
|
||||
|
||||
This is a minimal test case that still tests the core functionality.
|
||||
"""
|
||||
chunks = [
|
||||
"[\n",
|
||||
' {"name": "search_docs", "parameters": {"title": ["a"]}},',
|
||||
"\n",
|
||||
' {"name": "search_docs", "parameters": {"title": ["b"]}}',
|
||||
"]",
|
||||
]
|
||||
|
||||
tool_calls = []
|
||||
|
||||
for chunk in chunks:
|
||||
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||
self._accumulate_tool_calls(tool_calls, result)
|
||||
|
||||
# Should parse both tools successfully
|
||||
self.assertEqual(len(tool_calls), 2, "Should parse 2 tool calls")
|
||||
self.assertEqual(tool_calls[0]["name"], "search_docs")
|
||||
self.assertEqual(tool_calls[1]["name"], "search_docs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,83 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import Function, Tool
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
|
||||
from sglang.srt.function_call.core_types import StreamingParseResult
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(1.0, "default")
|
||||
|
||||
|
||||
class DummyDetector(BaseFormatDetector):
|
||||
def has_tool_call(self, text: str) -> bool:
|
||||
return True
|
||||
|
||||
def detect_and_parse(self, text: str, tools):
|
||||
action = json.loads(text)
|
||||
return StreamingParseResult(
|
||||
normal_text="", calls=self.parse_base_json(action, tools)
|
||||
)
|
||||
|
||||
def structure_info(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_unknown_tool_name_dropped_default(caplog):
|
||||
"""Test that unknown tools are dropped by default (legacy behavior)."""
|
||||
with envs.SGLANG_FORWARD_UNKNOWN_TOOLS.override(False):
|
||||
tools = [
|
||||
Tool(
|
||||
function=Function(
|
||||
name="get_weather", parameters={"type": "object", "properties": {}}
|
||||
)
|
||||
)
|
||||
]
|
||||
detector = DummyDetector()
|
||||
with caplog.at_level(
|
||||
logging.WARNING, logger="sglang.srt.function_call.base_format_detector"
|
||||
):
|
||||
result = detector.detect_and_parse(
|
||||
'{"name":"unknown_tool","parameters":{"city":"Paris"}}', tools
|
||||
)
|
||||
assert any(
|
||||
"Model attempted to call undefined function: unknown_tool" in m
|
||||
for m in caplog.messages
|
||||
)
|
||||
assert len(result.calls) == 0 # dropped in default mode
|
||||
|
||||
|
||||
def test_unknown_tool_name_forwarded(caplog):
|
||||
"""Test that unknown tools are forwarded when env var is True."""
|
||||
with envs.SGLANG_FORWARD_UNKNOWN_TOOLS.override(True):
|
||||
tools = [
|
||||
Tool(
|
||||
function=Function(
|
||||
name="get_weather", parameters={"type": "object", "properties": {}}
|
||||
)
|
||||
)
|
||||
]
|
||||
detector = DummyDetector()
|
||||
with caplog.at_level(
|
||||
logging.WARNING, logger="sglang.srt.function_call.base_format_detector"
|
||||
):
|
||||
result = detector.detect_and_parse(
|
||||
'{"name":"unknown_tool","parameters":{"city":"Paris"}}', tools
|
||||
)
|
||||
assert any(
|
||||
"Model attempted to call undefined function: unknown_tool" in m
|
||||
for m in caplog.messages
|
||||
)
|
||||
assert len(result.calls) == 1
|
||||
assert result.calls[0].name == "unknown_tool"
|
||||
assert result.calls[0].tool_index == -1
|
||||
assert json.loads(result.calls[0].parameters)["city"] == "Paris"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__]))
|
||||
Reference in New Issue
Block a user