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
@@ -60,7 +60,10 @@ from sglang.srt.environ import envs
from sglang.srt.function_call.core_types import ToolCallItem from sglang.srt.function_call.core_types import ToolCallItem
from sglang.srt.function_call.function_call_parser import FunctionCallParser 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.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.managers.io_struct import GenerateReqInput
from sglang.srt.parser.conversation import generate_chat_conv from sglang.srt.parser.conversation import generate_chat_conv
from sglang.srt.parser.jinja_template_utils import process_content_for_template_format 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: if tool.function.parameters is None:
continue continue
try: 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) Draft202012Validator.check_schema(tool.function.parameters)
except SchemaError as e: except SchemaError as e:
return f"Tool {i} function has invalid 'parameters' schema: {str(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 max_output_tokens = request.max_completion_tokens or request.max_tokens
server_context_length = self.tokenizer_manager.server_args.context_length server_context_length = self.tokenizer_manager.server_args.context_length
+162
View File
@@ -8,6 +8,168 @@ from partial_json_parser.core.options import Allow
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice 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: def _find_common_prefix(s1: str, s2: str) -> str:
prefix = "" prefix = ""
@@ -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()