From bed20249f16903853e472e181e6a6358bf5df802 Mon Sep 17 00:00:00 2001 From: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com> Date: Thu, 28 May 2026 07:31:18 +0100 Subject: [PATCH] fix(tool_call): reland schema type normalization (#26433) --- .../srt/entrypoints/openai/serving_chat.py | 15 +- python/sglang/srt/function_call/utils.py | 162 +++++++ .../test_normalize_json_schema_types.py | 409 ++++++++++++++++++ 3 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 test/registered/unit/function_call/test_normalize_json_schema_types.py diff --git a/python/sglang/srt/entrypoints/openai/serving_chat.py b/python/sglang/srt/entrypoints/openai/serving_chat.py index 5b31de242..d55c3a6ad 100644 --- a/python/sglang/srt/entrypoints/openai/serving_chat.py +++ b/python/sglang/srt/entrypoints/openai/serving_chat.py @@ -60,7 +60,10 @@ from sglang.srt.environ import envs from sglang.srt.function_call.core_types import ToolCallItem from sglang.srt.function_call.function_call_parser import FunctionCallParser from sglang.srt.function_call.json_array_parser import JsonArrayParser -from sglang.srt.function_call.utils import get_json_schema_constraint +from sglang.srt.function_call.utils import ( + get_json_schema_constraint, + normalize_json_schema_types, +) from sglang.srt.managers.io_struct import GenerateReqInput from sglang.srt.parser.conversation import generate_chat_conv from sglang.srt.parser.jinja_template_utils import process_content_for_template_format @@ -416,9 +419,19 @@ class OpenAIServingChat(OpenAIServingBase): if tool.function.parameters is None: continue try: + # Rewrite DB/ORM-style aliases (e.g. "varchar", "enum", "int") + # to standard JSON Schema types before validation. RecursionError + # guards against hand-crafted cyclic schemas so the request gets + # a 400 instead of crashing into a 500. + normalize_json_schema_types(tool.function.parameters) Draft202012Validator.check_schema(tool.function.parameters) except SchemaError as e: return f"Tool {i} function has invalid 'parameters' schema: {str(e)}" + except RecursionError: + return ( + f"Tool {i} function 'parameters' schema is too deeply nested " + "or contains a cycle." + ) max_output_tokens = request.max_completion_tokens or request.max_tokens server_context_length = self.tokenizer_manager.server_args.context_length diff --git a/python/sglang/srt/function_call/utils.py b/python/sglang/srt/function_call/utils.py index 1ef93e051..f36179a9f 100644 --- a/python/sglang/srt/function_call/utils.py +++ b/python/sglang/srt/function_call/utils.py @@ -8,6 +8,168 @@ from partial_json_parser.core.options import Allow from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice +_STANDARD_JSON_SCHEMA_TYPES = { + "null", + "boolean", + "object", + "array", + "number", + "string", + "integer", +} + +# Non-standard ``type`` values commonly emitted by DB/ORM-driven tool-schema +# generators. Mapped to the closest JSON Schema 2020-12 primitive so that +# ``Draft202012Validator.check_schema`` does not reject an otherwise-usable +# tool definition. +_JSON_SCHEMA_TYPE_ALIASES: Dict[str, str] = { + "str": "string", + "text": "string", + "varchar": "string", + "char": "string", + "enum": "string", + "uuid": "string", + "date": "string", + "datetime": "string", + "time": "string", + "timestamp": "string", + "binary": "string", + "blob": "string", + "bytea": "string", + "bytes": "string", + "varbinary": "string", + "bool": "boolean", + "bigint": "integer", + "smallint": "integer", + "tinyint": "integer", + "double": "number", + "decimal": "number", + "real": "number", + "numeric": "number", + "arr": "array", + "tuple": "array", + "set": "array", + "map": "object", +} + +# Prefix-based matching so that parameterised names like ``int32`` / +# ``float64`` / ``list[str]`` / ``dict[str, int]`` resolve. A prefix only +# matches when it spans the entire token or is followed by a non-identifier +# char, so "int" does not swallow "internal" and "list" does not swallow +# "list_price". +_PREFIX_BOUNDARY_CHARS = frozenset("0123456789[<( \t") +_PREFIX_RULES: Tuple[Tuple[Tuple[str, ...], str], ...] = ( + (("int", "uint", "long", "short", "unsigned"), "integer"), + (("num", "float"), "number"), + (("list",), "array"), + (("dict",), "object"), +) + + +def _matches_type_prefix(base: str, prefixes: Tuple[str, ...]) -> bool: + for p in prefixes: + if base == p: + return True + if ( + len(base) > len(p) + and base.startswith(p) + and base[len(p)] in _PREFIX_BOUNDARY_CHARS + ): + return True + return False + + +def _normalize_single_type(raw: Any) -> Any: + if not isinstance(raw, str): + return raw + if raw in _STANDARD_JSON_SCHEMA_TYPES: + return raw + # ``split("(", 1)[0]`` strips parenthesized params like ``varchar(255)`` + # or ``decimal(10,2)`` without the overhead of a regex per call. + base = raw.split("(", 1)[0].strip().lower() + if base in _STANDARD_JSON_SCHEMA_TYPES: + return base + mapped = _JSON_SCHEMA_TYPE_ALIASES.get(base) + if mapped is not None: + return mapped + for prefixes, target in _PREFIX_RULES: + if _matches_type_prefix(base, prefixes): + return target + return raw + + +def _normalize_type_list(raw_items: List[Any]) -> List[Any]: + normalized_items: List[Any] = [] + for item in raw_items: + normalized_item = _normalize_single_type(item) + if normalized_item not in normalized_items: + normalized_items.append(normalized_item) + return normalized_items + + +def normalize_json_schema_types(schema: Any) -> None: + """ + Walk a JSON Schema in place and rewrite non-standard ``"type"`` values + (e.g. ``"varchar"``, ``"enum"``, ``"int"``) to their standard JSON Schema + equivalents. + + Acts as a compatibility layer for tool ``parameters`` schemas exported + from database / ORM tooling, which often uses DB type names rather than + JSON Schema types. Unknown types are left untouched so that downstream + validation can still surface genuine errors. + + Mutates the input dict in place; the rewritten schema is also what gets + rendered into the model prompt, so e.g. a user-supplied ``"varchar"`` + reaches the model as ``"string"``. ``$ref`` values are not resolved; + callers pass tree-shaped schemas (HTTP JSON input is always a tree). + """ + if isinstance(schema, list): + for item in schema: + normalize_json_schema_types(item) + return + if not isinstance(schema, dict): + return + + if "type" in schema: + t = schema["type"] + if isinstance(t, str): + schema["type"] = _normalize_single_type(t) + elif isinstance(t, list): + schema["type"] = _normalize_type_list(t) + + for key in ( + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + ): + nested = schema.get(key) + if isinstance(nested, dict): + for v in nested.values(): + normalize_json_schema_types(v) + + for key in ("anyOf", "oneOf", "allOf", "prefixItems"): + nested = schema.get(key) + if isinstance(nested, list): + for v in nested: + normalize_json_schema_types(v) + + for key in ( + "items", + "additionalProperties", + "not", + "if", + "then", + "else", + "contains", + "propertyNames", + "unevaluatedItems", + "unevaluatedProperties", + ): + if key in schema: + normalize_json_schema_types(schema[key]) + def _find_common_prefix(s1: str, s2: str) -> str: prefix = "" diff --git a/test/registered/unit/function_call/test_normalize_json_schema_types.py b/test/registered/unit/function_call/test_normalize_json_schema_types.py new file mode 100644 index 000000000..bbf64d69a --- /dev/null +++ b/test/registered/unit/function_call/test_normalize_json_schema_types.py @@ -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"}, + "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()