forked from Karylab-cklius/vllm
[Refactor] Extract shared coerce_to_schema_type utility from Minimax M2 tool parser (#43006)
Signed-off-by: sfeng33 <4florafeng@gmail.com>
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user