fix(tool_call): reland schema type normalization (#26433)

This commit is contained in:
Xinyuan Tong
2026-05-28 14:31:18 +08:00
committed by GitHub
parent 6f85957ff2
commit bed20249f1
3 changed files with 585 additions and 1 deletions
@@ -0,0 +1,409 @@
"""Unit tests for tool-parameter schema alias normalization."""
import json
import unittest
from jsonschema import Draft202012Validator, SchemaError
from sglang.srt.function_call.utils import normalize_json_schema_types
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(1.0, "base-a-test-cpu")
class TestNormalizeJsonSchemaTypes(CustomTestCase):
def _assert_accepts(self, schema: dict) -> None:
Draft202012Validator.check_schema(schema)
def test_enum_alias_becomes_string(self):
schema = {
"type": "object",
"properties": {"color": {"type": "enum", "enum": ["red", "green", "blue"]}},
}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["color"]["type"], "string")
self.assertEqual(
schema["properties"]["color"]["enum"], ["red", "green", "blue"]
)
self._assert_accepts(schema)
def test_varchar_alias_becomes_string(self):
schema = {
"type": "object",
"properties": {
"name": {"type": "varchar"},
"short_name": {"type": "VARCHAR(255)"},
},
}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["name"]["type"], "string")
self.assertEqual(schema["properties"]["short_name"]["type"], "string")
self._assert_accepts(schema)
def test_numeric_aliases(self):
schema = {
"type": "object",
"properties": {
"age": {"type": "int"},
"big": {"type": "bigint"},
"price": {"type": "decimal(10,2)"},
"ratio": {"type": "float"},
},
}
normalize_json_schema_types(schema)
props = schema["properties"]
self.assertEqual(props["age"]["type"], "integer")
self.assertEqual(props["big"]["type"], "integer")
self.assertEqual(props["price"]["type"], "number")
self.assertEqual(props["ratio"]["type"], "number")
self._assert_accepts(schema)
def test_prefix_matched_numeric_types(self):
schema = {
"type": "object",
"properties": {
"a": {"type": "int32"},
"b": {"type": "int64"},
"c": {"type": "uint"},
"d": {"type": "unsigned"},
"e": {"type": "long"},
"f": {"type": "short"},
"g": {"type": "float32"},
"h": {"type": "float64"},
"i": {"type": "num"},
"j": {"type": "numeric"},
},
}
normalize_json_schema_types(schema)
p = schema["properties"]
for k in ("a", "b", "c", "d", "e", "f"):
self.assertEqual(p[k]["type"], "integer")
for k in ("g", "h", "i", "j"):
self.assertEqual(p[k]["type"], "number")
self._assert_accepts(schema)
def test_prefix_matched_compound_types(self):
schema = {
"type": "object",
"properties": {
"a": {"type": "list[str]"},
"b": {"type": "list<int>"},
"c": {"type": "dict"},
"d": {"type": "dict[str, int]"},
"e": {"type": "long long"},
},
}
normalize_json_schema_types(schema)
p = schema["properties"]
self.assertEqual(p["a"]["type"], "array")
self.assertEqual(p["b"]["type"], "array")
self.assertEqual(p["c"]["type"], "object")
self.assertEqual(p["d"]["type"], "object")
self.assertEqual(p["e"]["type"], "integer")
self._assert_accepts(schema)
def test_word_boundary_prevents_false_positives(self):
"""Prefixes must end at a non-identifier char, so custom type names
that merely start with a known prefix are left alone."""
schema = {
"type": "object",
"properties": {
"a": {"type": "internal"},
"b": {"type": "list_price"},
"c": {"type": "integer_enum"},
"d": {"type": "dictionary_entry"},
"e": {"type": "floating"},
},
}
normalize_json_schema_types(schema)
p = schema["properties"]
for key, expected in [
("a", "internal"),
("b", "list_price"),
("c", "integer_enum"),
("d", "dictionary_entry"),
("e", "floating"),
]:
self.assertEqual(p[key]["type"], expected)
def test_recurses_into_draft_2020_12_keywords(self):
schema = {
"type": "object",
"properties": {
"row": {
"dependentSchemas": {
"kind": {
"properties": {"sku": {"type": "varchar"}},
},
},
"propertyNames": {"type": "str"},
"unevaluatedProperties": {"type": "bigint"},
},
"rows": {
"type": "arr",
"unevaluatedItems": {"type": "int32"},
},
},
}
normalize_json_schema_types(schema)
row = schema["properties"]["row"]
rows = schema["properties"]["rows"]
self.assertEqual(
row["dependentSchemas"]["kind"]["properties"]["sku"]["type"], "string"
)
self.assertEqual(row["propertyNames"]["type"], "string")
self.assertEqual(row["unevaluatedProperties"]["type"], "integer")
self.assertEqual(rows["type"], "array")
self.assertEqual(rows["unevaluatedItems"]["type"], "integer")
self._assert_accepts(schema)
def test_bytes_and_arr_aliases(self):
schema = {
"type": "object",
"properties": {
"payload": {"type": "binary"},
"raw": {"type": "bytea"},
"items": {"type": "arr"},
},
}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["payload"]["type"], "string")
self.assertEqual(schema["properties"]["raw"]["type"], "string")
self.assertEqual(schema["properties"]["items"]["type"], "array")
self._assert_accepts(schema)
def test_bool_alias_becomes_boolean(self):
schema = {"type": "object", "properties": {"flag": {"type": "bool"}}}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["flag"]["type"], "boolean")
self._assert_accepts(schema)
def test_case_insensitive(self):
schema = {
"type": "object",
"properties": {
"a": {"type": "VARCHAR"},
"b": {"type": "INT"},
"c": {"type": "String"},
},
}
normalize_json_schema_types(schema)
p = schema["properties"]
self.assertEqual(p["a"]["type"], "string")
self.assertEqual(p["b"]["type"], "integer")
self.assertEqual(p["c"]["type"], "string")
self._assert_accepts(schema)
def test_array_and_object_aliases(self):
schema = {
"type": "object",
"properties": {
"tags": {"type": "list", "items": {"type": "str"}},
"meta": {"type": "dict"},
},
}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["tags"]["type"], "array")
self.assertEqual(schema["properties"]["tags"]["items"]["type"], "string")
self.assertEqual(schema["properties"]["meta"]["type"], "object")
self._assert_accepts(schema)
def test_nested_anyof_and_defs(self):
schema = {
"type": "object",
"properties": {
"value": {
"anyOf": [
{"type": "int"},
{"type": "varchar"},
]
}
},
"$defs": {
"Row": {"type": "object", "properties": {"id": {"type": "bigint"}}}
},
}
normalize_json_schema_types(schema)
any_of = schema["properties"]["value"]["anyOf"]
self.assertEqual(any_of[0]["type"], "integer")
self.assertEqual(any_of[1]["type"], "string")
self.assertEqual(schema["$defs"]["Row"]["properties"]["id"]["type"], "integer")
self._assert_accepts(schema)
def test_type_list_member_normalized(self):
schema = {"type": ["varchar", "null"]}
normalize_json_schema_types(schema)
self.assertEqual(schema["type"], ["string", "null"])
self._assert_accepts(schema)
def test_type_list_deduplicates_after_normalization(self):
schema = {"type": ["varchar", "string", "null", "int", "integer"]}
normalize_json_schema_types(schema)
self.assertEqual(schema["type"], ["string", "null", "integer"])
self._assert_accepts(schema)
def test_standard_types_untouched(self):
schema = {
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "integer"},
"c": {"type": "boolean"},
},
}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["a"]["type"], "string")
self.assertEqual(schema["properties"]["b"]["type"], "integer")
self.assertEqual(schema["properties"]["c"]["type"], "boolean")
def test_unknown_type_left_alone(self):
schema = {"type": "object", "properties": {"x": {"type": "geometry"}}}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["x"]["type"], "geometry")
with self.assertRaises(SchemaError):
self._assert_accepts(schema)
def test_common_db_orm_type_names_accepted(self):
"""Common non-standard DB/ORM type names all survive validation."""
recognized = [
# string family
"string",
"str",
"text",
"varchar",
"char",
"enum",
# integer via prefix
"int",
"int32",
"int64",
"uint",
"uint8",
"long",
"long long",
"short",
"unsigned",
# number via prefix
"num",
"numeric",
"float",
"float32",
"float64",
# boolean
"boolean",
"bool",
"binary",
"bytea",
"bytes",
"blob",
"varbinary",
# compound
"object",
"array",
"arr",
"dict",
"dict[str, int]",
"list",
"list[str]",
]
for t in recognized:
schema = {"type": "object", "properties": {"x": {"type": t}}}
normalize_json_schema_types(schema)
try:
self._assert_accepts(schema)
except SchemaError as e:
self.fail(f"type {t!r} -> {schema['properties']['x']['type']!r}: {e}")
def test_pre_existing_400_schema_now_accepted(self):
schema = {
"type": "object",
"properties": {
"sql": {"type": "varchar"},
"mode": {"type": "enum", "enum": ["read", "write"]},
},
"required": ["sql", "mode"],
}
normalize_json_schema_types(schema)
self._assert_accepts(schema)
def test_idempotent(self):
"""Running normalize twice produces the same result as running it once."""
schema = {
"type": "object",
"properties": {
"a": {"type": "varchar"},
"b": {"type": "int32"},
"c": {"type": ["bigint", "null"]},
"d": {
"anyOf": [{"type": "enum"}, {"type": "decimal(10,2)"}],
},
},
}
normalize_json_schema_types(schema)
once = json.loads(json.dumps(schema))
normalize_json_schema_types(schema)
self.assertEqual(schema, once)
def test_non_string_type_values_pass_through(self):
"""``type`` that isn't str/list is left for the real validator to reject."""
for bad in (None, 42, {"$ref": "#/$defs/Foo"}, ["string", 1, None]):
schema = {"properties": {"x": {"type": bad}}}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["x"]["type"], bad)
def test_recurses_into_all_walked_keywords(self):
"""Every keyword the walker recurses into must actually rewrite nested aliases."""
schema = {
"patternProperties": {"^x_": {"type": "varchar"}},
"definitions": {"Row": {"type": "bigint"}},
"prefixItems": [{"type": "int32"}, {"type": "float64"}],
"if": {"type": "bool"},
"then": {"type": "str"},
"else": {"type": "decimal"},
"not": {"type": "uuid"},
"contains": {"type": "enum"},
"additionalProperties": {"type": "bool"},
}
normalize_json_schema_types(schema)
self.assertEqual(schema["patternProperties"]["^x_"]["type"], "string")
self.assertEqual(schema["definitions"]["Row"]["type"], "integer")
self.assertEqual(schema["prefixItems"][0]["type"], "integer")
self.assertEqual(schema["prefixItems"][1]["type"], "number")
self.assertEqual(schema["if"]["type"], "boolean")
self.assertEqual(schema["then"]["type"], "string")
self.assertEqual(schema["else"]["type"], "number")
self.assertEqual(schema["not"]["type"], "string")
self.assertEqual(schema["contains"]["type"], "string")
self.assertEqual(schema["additionalProperties"]["type"], "boolean")
def test_cyclic_schema_raises_recursion_error(self):
"""A pathological cyclic schema surfaces as RecursionError; caller
(``_validate_request``) converts it to a 400, not a 500."""
schema = {"type": "object"}
schema["items"] = schema
with self.assertRaises(RecursionError):
normalize_json_schema_types(schema)
def test_boolean_subschema_does_not_crash(self):
"""``additionalProperties: True`` and ``items: false`` are valid 2020-12
forms; the walker must pass through without raising."""
schema = {
"type": "object",
"properties": {
"a": {"type": "varchar"},
"b": {"type": "array", "items": False},
},
"additionalProperties": True,
"unevaluatedProperties": False,
}
normalize_json_schema_types(schema)
self.assertEqual(schema["properties"]["a"]["type"], "string")
self.assertIs(schema["properties"]["b"]["items"], False)
self.assertIs(schema["additionalProperties"], True)
self.assertIs(schema["unevaluatedProperties"], False)
self._assert_accepts(schema)
if __name__ == "__main__":
unittest.main()