[CI] Move existing unit tests into unit directory (#20631)

This commit is contained in:
Ke Bao
2026-03-15 23:25:18 +08:00
committed by GitHub
parent e2be31824f
commit c3483e8e97
29 changed files with 16 additions and 3 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,567 @@
"""
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()
@@ -0,0 +1,163 @@
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()
@@ -0,0 +1,83 @@
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__]))