Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tensorrt_llm/serve/tool_parser/glm4_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def get_argument_type(func_name: str, arg_key: str, defined_tools: List[Tool]) -
properties = {}
if arg_key not in properties:
return None
return infer_type_from_json_schema(properties[arg_key])
return infer_type_from_json_schema(properties[arg_key], tool.function.parameters)


def _convert_to_number(value: str) -> Any:
Expand Down
2 changes: 1 addition & 1 deletion tensorrt_llm/serve/tool_parser/minimax_m2_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def _get_param_types(param_name: str, func_name: str, tools: List[Tool]) -> Opti
continue
if param_name not in properties:
continue
return infer_type_from_json_schema(properties[param_name])
return infer_type_from_json_schema(properties[param_name], parameters)
return None


Expand Down
2 changes: 1 addition & 1 deletion tensorrt_llm/serve/tool_parser/minimax_m3_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def _get_param_type(param_name: str, func_name: str, tools: List[Tool]) -> Optio
continue
if param_name not in properties:
continue
return infer_type_from_json_schema(properties[param_name])
return infer_type_from_json_schema(properties[param_name], parameters)
return None


Expand Down
2 changes: 1 addition & 1 deletion tensorrt_llm/serve/tool_parser/poolside_v1_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _get_argument_type(func_name: str, arg_key: str, tools: list[Tool]) -> str |
if not isinstance(schema, dict):
return None

return infer_type_from_json_schema(schema)
return infer_type_from_json_schema(schema, tool.function.parameters)


def _convert_number(value: str) -> Any:
Expand Down
51 changes: 45 additions & 6 deletions tensorrt_llm/serve/tool_parser/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Adapted from https://github.com/sgl-project/sglang/blob/083629c23564e1a64deaa052f1df5c5d914358d8/python/sglang/srt/function_call/qwen25_detector.py
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import json
from json import JSONDecodeError, JSONDecoder
from json.decoder import WHITESPACE
Expand Down Expand Up @@ -56,19 +58,43 @@ def is_complete_json(input_str: str) -> bool:
return False


# Bounds the recursion, so a schema that references itself still terminates.
_MAX_SCHEMA_DEPTH = 32


def _resolve_local_ref(ref: Any, root: Optional[Dict[str, Any]]) -> Any:
"""The subschema a local reference such as ``#/$defs/Name`` points to."""
if not isinstance(ref, str) or not ref.startswith("#") or not isinstance(
root, dict):
return None
node = root
for token in ref[1:].split("/")[1:]:
token = token.replace("~1", "/").replace("~0", "~")
if not isinstance(node, dict) or token not in node:
return None
node = node[token]
return node


# Adapted from https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/function_call/utils.py
def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
def infer_type_from_json_schema(schema: Dict[str, Any],
root: Optional[Dict[str, Any]] = None,
_depth: int = 0) -> Optional[str]:
"""Infer the primary type of a parameter from JSON Schema.

Supports complex JSON Schema structures including:
- Direct type field (including type arrays)
- $ref: a local reference, resolved against ``root``, the tool's whole
parameters schema
- anyOf/oneOf: parameter can be any of multiple types
- enum: parameter must be one of enum values
- allOf: parameter must satisfy all type definitions
- properties: inferred as object type
- items: inferred as array type

Returns None when the schema declares no type.
"""
if not isinstance(schema, dict):
if not isinstance(schema, dict) or _depth > _MAX_SCHEMA_DEPTH:
return None

if "type" in schema:
Expand All @@ -81,14 +107,23 @@ def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
return non_null_types[0]
return "string"

if "$ref" in schema:
inferred_type = infer_type_from_json_schema(
_resolve_local_ref(schema["$ref"], root), root, _depth + 1)
if inferred_type:
return inferred_type

if "anyOf" in schema or "oneOf" in schema:
schemas = schema.get("anyOf") or schema.get("oneOf")
types = []
if isinstance(schemas, list):
for sub_schema in schemas:
inferred_type = infer_type_from_json_schema(sub_schema)
inferred_type = infer_type_from_json_schema(
sub_schema, root, _depth + 1)
if inferred_type:
types.append(inferred_type)
# A null alternative only makes the parameter optional.
types = [t for t in types if t != "null"] or types
if types:
if len(set(types)) == 1:
return types[0]
Expand Down Expand Up @@ -120,11 +155,15 @@ def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
return "string"

if "allOf" in schema and isinstance(schema["allOf"], list):
for sub_schema in schema["allOf"]:
inferred_type = infer_type_from_json_schema(sub_schema)
types = [
infer_type_from_json_schema(sub_schema, root, _depth + 1)
for sub_schema in schema["allOf"]
]
for inferred_type in types:
if inferred_type and inferred_type != "string":
return inferred_type
return "string"
if "string" in types:
return "string"

if "properties" in schema:
return "object"
Expand Down
138 changes: 138 additions & 0 deletions tests/unittest/llmapi/apps/test_tool_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
from tensorrt_llm.serve.tool_parser.kimi_k2_tool_parser import KimiK2ToolParser
from tensorrt_llm.serve.tool_parser.kimi_k3_tool_parser import KimiK3ToolParser
from tensorrt_llm.serve.tool_parser.minimax_m2_parser import MiniMaxM2ToolParser
from tensorrt_llm.serve.tool_parser.minimax_m3_parser import MiniMaxM3ToolParser
from tensorrt_llm.serve.tool_parser.poolside_v1_parser import \
PoolsideV1ToolParser
from tensorrt_llm.serve.tool_parser.qwen3_coder_parser import \
Qwen3CoderToolParser
from tensorrt_llm.serve.tool_parser.qwen3_tool_parser import Qwen3ToolParser
from tensorrt_llm.serve.tool_parser.utils import infer_type_from_json_schema
from tensorrt_llm.tokenizer.deepseek_v32.encoding import encode_messages

from tensorrt_llm.serve.tool_parser.gemma4_parser import ( # isort: skip
Expand Down Expand Up @@ -5721,3 +5723,139 @@ def test_warning_is_keyed_per_parser(self) -> None:
for call in mock_logger.warning_once.call_args_list
]
assert keys == [self._PARSER_NAME, self._OTHER_PARSER_NAME]


# ============================================================================
# Argument types declared through JSON-schema references
# ============================================================================

_SCHEMA_ROOT = {
"type": "object",
"$defs": {
"Opts": {
"type": "object",
"properties": {
"verbose": {
"type": "boolean"
}
}
},
"Code": {
"type": "string"
},
"Loop": {
"anyOf": [{
"$ref": "#/$defs/Loop"
}]
},
},
"definitions": {
"Mode": {
"enum": ["fast", "slow"]
}
},
}


@pytest.mark.parametrize(
"schema,root,expected",
[
({
"$ref": "#/$defs/Opts"
}, _SCHEMA_ROOT, "object"),
({
"allOf": [{
"$ref": "#/$defs/Opts"
}],
"description": "options"
}, _SCHEMA_ROOT, "object"),
({
"anyOf": [{
"$ref": "#/$defs/Opts"
}, {
"type": "null"
}]
}, _SCHEMA_ROOT, "object"),
({
"anyOf": [{
"type": "null"
}, {
"type": "integer"
}]
}, None, "integer"),
({
"$ref": "#/definitions/Mode"
}, _SCHEMA_ROOT, "string"),
({
"allOf": [{
"type": "string"
}, {
"minLength": 1
}]
}, None, "string"),
# A reference that cannot be resolved declares no type.
({
"allOf": [{
"$ref": "#/$defs/Opts"
}]
}, None, None),
({
"$ref": "#/$defs/Missing"
}, _SCHEMA_ROOT, None),
({
"$ref": "https://example.com/opts.json"
}, _SCHEMA_ROOT, None),
({
"$ref": "#/$defs/Loop"
}, _SCHEMA_ROOT, None),
],
)
def test_infer_type_from_json_schema(schema, root, expected):
assert infer_type_from_json_schema(schema, root) == expected


_VALUE = '{"verbose": true}'
_ONE_ARGUMENT_CALLS = {
Glm4ToolParser:
f"<tool_call>run\n<arg_key>arg</arg_key>\n<arg_value>{_VALUE}</arg_value>\n</tool_call>",
Glm47ToolParser:
f"<tool_call>run<arg_key>arg</arg_key><arg_value>{_VALUE}</arg_value></tool_call>",
PoolsideV1ToolParser:
f"<tool_call>run\n<arg_key>arg</arg_key>\n<arg_value>{_VALUE}</arg_value>\n</tool_call>",
MiniMaxM2ToolParser:
('<minimax:tool_call><invoke name="run"><parameter name="arg">'
f"{_VALUE}</parameter></invoke></minimax:tool_call>"),
MiniMaxM3ToolParser:
("]<]minimax[>[<tool_call>\n]<]minimax[>[<invoke name=\"run\">\n"
f"]<]minimax[>[<arg>{_VALUE}]<]minimax[>[</arg>\n"
"]<]minimax[>[</invoke>\n]<]minimax[>[</tool_call>"),
}


@pytest.mark.parametrize("parser_cls",
list(_ONE_ARGUMENT_CALLS),
ids=lambda cls: cls.__name__)
@pytest.mark.parametrize("wrap", [False, True], ids=["ref", "allOf_ref"])
@pytest.mark.parametrize("definition,expected", [("Opts", json.loads(_VALUE)),
("Code", _VALUE)])
def test_an_argument_is_typed_by_its_referenced_definition(
parser_cls, wrap, definition, expected):
ref = {"$ref": f"#/$defs/{definition}"}
parameters = {
**_SCHEMA_ROOT, "properties": {
"arg": {
"allOf": [ref],
"description": "x"
} if wrap else ref
}
}
tool = ChatCompletionToolsParam(type="function",
function=FunctionDefinition(
name="run", parameters=parameters))

result = parser_cls().detect_and_parse(_ONE_ARGUMENT_CALLS[parser_cls],
[tool])

assert [json.loads(c.parameters) for c in result.calls] == [{
"arg": expected
}]