diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py new file mode 100644 index 00000000000..d72696554d4 --- /dev/null +++ b/tests/tool_parsers/test_utils.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from vllm.tool_parsers.utils import coerce_to_schema_type + + +class TestCoerceToSchemaType: + class TestNullHandling: + def test_null_converted_when_type_is_null(self): + assert coerce_to_schema_type("null", "null") is None + + def test_null_converted_when_null_in_type_list(self): + assert coerce_to_schema_type("null", ["string", "null"]) is None + + def test_null_preserved_as_string_when_type_is_string(self): + assert coerce_to_schema_type("null", "string") == "null" + + def test_null_case_insensitive(self): + assert coerce_to_schema_type("NULL", "null") is None + assert coerce_to_schema_type("Null", "null") is None + + def test_none_string_never_converted(self): + assert coerce_to_schema_type("none", "null") == "none" + assert coerce_to_schema_type("none", "string") == "none" + assert coerce_to_schema_type("none", ["string", "null"]) == "none" + + def test_nil_string_never_converted(self): + assert coerce_to_schema_type("nil", "string") == "nil" + assert coerce_to_schema_type("nil", ["string", "null"]) == "nil" + + def test_non_null_value_with_null_type(self): + assert coerce_to_schema_type("hello", ["null", "string"]) == "hello" + + class TestStringType: + def test_string_type(self): + assert coerce_to_schema_type("hello", "string") == "hello" + + def test_str_alias(self): + assert coerce_to_schema_type("hello", "str") == "hello" + + def test_text_alias(self): + assert coerce_to_schema_type("hello", "text") == "hello" + + def test_varchar_alias(self): + assert coerce_to_schema_type("hello", "varchar") == "hello" + + def test_char_alias(self): + assert coerce_to_schema_type("x", "char") == "x" + + def test_enum_alias(self): + assert coerce_to_schema_type("option_a", "enum") == "option_a" + + class TestIntegerType: + def test_integer_type(self): + assert coerce_to_schema_type("42", "integer") == 42 + + def test_int_alias(self): + assert coerce_to_schema_type("42", "int") == 42 + + def test_negative_integer(self): + assert coerce_to_schema_type("-7", "integer") == -7 + + def test_invalid_integer_fallback(self): + assert coerce_to_schema_type("not_a_number", "integer") == "not_a_number" + + def test_uint32_alias(self): + assert coerce_to_schema_type("5", "uint32") == 5 + + def test_long_alias(self): + assert coerce_to_schema_type("100", "long") == 100 + + class TestNumberType: + def test_number_type(self): + assert coerce_to_schema_type("3.14", "number") == 3.14 + + def test_float_alias(self): + assert coerce_to_schema_type("2.5", "float") == 2.5 + + def test_double_alias(self): + assert coerce_to_schema_type("2.5", "double") == 2.5 + + def test_whole_float_returns_int(self): + assert coerce_to_schema_type("5.0", "number") == 5 + assert isinstance(coerce_to_schema_type("5.0", "number"), int) + + def test_invalid_number_fallback(self): + assert coerce_to_schema_type("abc", "number") == "abc" + + class TestBooleanType: + def test_true(self): + assert coerce_to_schema_type("true", "boolean") is True + + def test_false(self): + assert coerce_to_schema_type("false", "boolean") is False + + def test_bool_alias(self): + assert coerce_to_schema_type("true", "bool") is True + + def test_one_is_true(self): + assert coerce_to_schema_type("1", "boolean") is True + + def test_zero_is_false(self): + assert coerce_to_schema_type("0", "boolean") is False + + def test_invalid_boolean_fallback(self): + assert coerce_to_schema_type("maybe", "boolean") == "maybe" + + class TestObjectArrayType: + def test_object_type(self): + assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1} + + def test_array_type(self): + assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3] + + def test_invalid_json_fallback(self): + assert coerce_to_schema_type("not json", "object") == "not json" + + def test_dict_alias(self): + assert coerce_to_schema_type('{"k": "v"}', "dict") == {"k": "v"} + + def test_list_alias(self): + assert coerce_to_schema_type("[1]", "list") == [1] + + class TestMultiType: + def test_null_takes_priority_over_string(self): + assert coerce_to_schema_type("null", ["string", "null"]) is None + + def test_integer_tried_before_string(self): + assert coerce_to_schema_type("42", ["integer", "string"]) == 42 + + def test_falls_through_to_string(self): + assert coerce_to_schema_type("hello", ["integer", "string"]) == "hello" + + class TestFallback: + def test_unknown_type_returns_string(self): + assert coerce_to_schema_type("hello", "unknown_type") == "hello" + + def test_json_fallback_for_unknown_type(self): + assert coerce_to_schema_type('{"a": 1}', "unknown_type") == {"a": 1} + + @pytest.mark.parametrize("schema_type", ["string", "str", "text"]) + def test_string_types_preserve_value(self, schema_type): + assert coerce_to_schema_type("anything", schema_type) == "anything" + + def test_unrecognized_type_falls_back_to_json(self): + assert coerce_to_schema_type("42", "interval") == 42 diff --git a/vllm/tool_parsers/minimax_m2_tool_parser.py b/vllm/tool_parsers/minimax_m2_tool_parser.py index 85fb266b958..b831d147375 100644 --- a/vllm/tool_parsers/minimax_m2_tool_parser.py +++ b/vllm/tool_parsers/minimax_m2_tool_parser.py @@ -25,6 +25,7 @@ from vllm.tool_parsers.abstract_tool_parser import ( Tool, ToolParser, ) +from vllm.tool_parsers.utils import coerce_to_schema_type logger = init_logger(__name__) @@ -146,80 +147,6 @@ class MinimaxM2ToolParser(ToolParser): return list(types) - def _convert_param_value_with_types( - self, value: str, param_types: list[str] - ) -> Any: - """ - Convert parameter value to the correct type based on a list of possible types. - Tries each type in order until one succeeds. - - Args: - value: The string value to convert - param_types: List of possible type strings - - Returns: - The converted value - """ - # Normalize types - normalized_types = [t.lower() for t in param_types] - - # Try each type in order of preference (most specific first, string as fallback) - # Priority: null > integer > number > boolean > object > array > string - type_priority = [ - "null", - "integer", - "int", - "number", - "float", - "boolean", - "bool", - "object", - "array", - "string", - "str", - "text", - ] - - for param_type in type_priority: - if param_type not in normalized_types: - continue - - if param_type == "null": - if value.lower() == "null": - return None - continue - elif param_type in ["string", "str", "text"]: - return value - elif param_type in ["integer", "int"]: - try: - return int(value) - except (ValueError, TypeError): - continue - elif param_type in ["number", "float"]: - try: - val = float(value) - return val if val != int(val) else int(val) - except (ValueError, TypeError): - continue - elif param_type in ["boolean", "bool"]: - lower_val = value.lower().strip() - if lower_val in ["true", "1", "yes", "on"]: - return True - elif lower_val in ["false", "0", "no", "off"]: - return False - continue - elif param_type in ["object", "array"]: - try: - return json.loads(value) - except json.JSONDecodeError: - continue - - # Fallback: try JSON parse, then return as string - try: - return json.loads(value) - except json.JSONDecodeError: - return value - def _get_param_types_from_config( self, param_name: str, param_config: dict ) -> list[str]: @@ -280,9 +207,7 @@ class MinimaxM2ToolParser(ToolParser): param_type = self._get_param_types_from_config(param_name, param_config) # Convert value - param_dict[param_name] = self._convert_param_value_with_types( - param_value, param_type - ) + param_dict[param_name] = coerce_to_schema_type(param_value, param_type) return ToolCall( type="function", diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 464ed40f948..68af1e3dc84 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -450,6 +450,103 @@ def make_valid_python(text: str) -> tuple[str, str] | None: return candidate, added_text +_TYPE_ALIASES: dict[str, str] = { + "str": "string", + "text": "string", + "varchar": "string", + "char": "string", + "enum": "string", + "int": "integer", + "int32": "integer", + "int64": "integer", + "uint": "integer", + "uint32": "integer", + "uint64": "integer", + "long": "integer", + "short": "integer", + "unsigned": "integer", + "float": "number", + "float32": "number", + "float64": "number", + "double": "number", + "bool": "boolean", + "dict": "object", + "arr": "array", + "list": "array", + "sequence": "array", +} + + +def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any: + """Best-effort coercion of a raw string value to a JSON Schema type. + + Tries each type in priority order (null > integer > number > boolean > + object > array > string) and returns the first successful coercion. + Falls back to the original string when no coercion succeeds. + + Args: + value: The raw string value from the model output. + schema_type: One or more JSON Schema type strings + (e.g. ``"string"`` or ``["string", "null"]``). + """ + if isinstance(schema_type, str): + schema_type = [schema_type] + + normalized_types = { + _TYPE_ALIASES.get(key, key) for t in schema_type for key in [t.strip().lower()] + } + + # Priority: null > integer > number > boolean > object > array > string + type_priority = [ + "null", + "integer", + "number", + "boolean", + "object", + "array", + "string", + ] + + for candidate_type in type_priority: + if candidate_type not in normalized_types: + continue + + if candidate_type == "null": + if value.lower() == "null": + return None + continue + if candidate_type == "string": + return value + if candidate_type == "integer": + try: + return int(value) + except (ValueError, TypeError): + continue + if candidate_type == "number": + try: + val = float(value) + return val if val != int(val) else int(val) + except (ValueError, TypeError): + continue + if candidate_type == "boolean": + lower_val = value.lower().strip() + if lower_val in ("true", "1"): + return True + if lower_val in ("false", "0"): + return False + continue + if candidate_type in ("object", "array"): + try: + return json.loads(value) + except (json.JSONDecodeError, ValueError, TypeError): + continue + + try: + return json.loads(value) + except (json.JSONDecodeError, ValueError): + return value + + def compute_tool_delta( previously_sent_args: str, new_call: ToolCall,