[Fix] Resolve tool argument types through top-level anyOf/oneOf/allOf (#36626)
This commit is contained in:
@@ -16,7 +16,10 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import _is_complete_json
|
from sglang.srt.function_call.utils import (
|
||||||
|
_is_complete_json,
|
||||||
|
get_schema_properties,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,10 +140,9 @@ class DotsToolDetector(BaseFormatDetector):
|
|||||||
schema = tool.function.parameters
|
schema = tool.function.parameters
|
||||||
if not isinstance(schema, dict):
|
if not isinstance(schema, dict):
|
||||||
break
|
break
|
||||||
properties = schema.get("properties", {})
|
|
||||||
defs = schema.get("$defs", {})
|
defs = schema.get("$defs", {})
|
||||||
return (
|
return (
|
||||||
properties if isinstance(properties, dict) else {},
|
get_schema_properties(schema),
|
||||||
defs if isinstance(defs, dict) else {},
|
defs if isinstance(defs, dict) else {},
|
||||||
)
|
)
|
||||||
return {}, {}
|
return {}, {}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import (
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
infer_type_from_json_schema,
|
infer_type_from_json_schema,
|
||||||
safe_literal_eval,
|
safe_literal_eval,
|
||||||
)
|
)
|
||||||
@@ -79,15 +80,8 @@ def get_argument_type(
|
|||||||
|
|
||||||
# Get parameters safely using getattr
|
# Get parameters safely using getattr
|
||||||
params = getattr(tool.function, "parameters", None)
|
params = getattr(tool.function, "parameters", None)
|
||||||
if not isinstance(params, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Navigate to the type using dict.get() for safe access
|
arg_spec = get_schema_properties(params).get(arg_key)
|
||||||
properties = params.get("properties")
|
|
||||||
if not isinstance(properties, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
arg_spec = properties.get(arg_key)
|
|
||||||
if isinstance(arg_spec, dict):
|
if isinstance(arg_spec, dict):
|
||||||
# Use the new type inference function for complex JSON Schema support
|
# Use the new type inference function for complex JSON Schema support
|
||||||
return infer_type_from_json_schema(arg_spec)
|
return infer_type_from_json_schema(arg_spec)
|
||||||
@@ -613,8 +607,9 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
self._last_arguments += "{}"
|
self._last_arguments += "{}"
|
||||||
self.streamed_args_for_tool[self.current_tool_id] += "{}"
|
self.streamed_args_for_tool[self.current_tool_id] += "{}"
|
||||||
self._sent_empty_object = True
|
self._sent_empty_object = True
|
||||||
elif not self._last_arguments.endswith("}") and not self._sent_empty_object:
|
elif not self._is_first_param and not self._sent_empty_object:
|
||||||
# Need to close brace
|
# The streamed outer `{` is only closed here; a trailing "}" in
|
||||||
|
# _last_arguments may belong to a nested object value.
|
||||||
calls.append(
|
calls.append(
|
||||||
ToolCallItem(
|
ToolCallItem(
|
||||||
tool_index=self.current_tool_id,
|
tool_index=self.current_tool_id,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import (
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
infer_type_from_json_schema,
|
infer_type_from_json_schema,
|
||||||
safe_literal_eval,
|
safe_literal_eval,
|
||||||
)
|
)
|
||||||
@@ -54,9 +55,7 @@ def get_argument_type(
|
|||||||
if func_name not in name2tool:
|
if func_name not in name2tool:
|
||||||
return None
|
return None
|
||||||
tool = name2tool[func_name]
|
tool = name2tool[func_name]
|
||||||
properties = (tool.function.parameters or {}).get("properties", {})
|
properties = get_schema_properties(tool.function.parameters)
|
||||||
if not isinstance(properties, dict):
|
|
||||||
properties = {}
|
|
||||||
if arg_key not in properties:
|
if arg_key not in properties:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -572,7 +571,9 @@ class Glm4MoeDetector(BaseFormatDetector):
|
|||||||
self.streamed_args_for_tool[
|
self.streamed_args_for_tool[
|
||||||
self.current_tool_id
|
self.current_tool_id
|
||||||
] += empty_object
|
] += empty_object
|
||||||
elif not self._last_arguments.endswith("}"):
|
else:
|
||||||
|
# The streamed outer `{` is only closed here; a
|
||||||
|
# trailing "}" may belong to a nested object value.
|
||||||
closing_brace = "}"
|
closing_brace = "}"
|
||||||
calls.append(
|
calls.append(
|
||||||
ToolCallItem(
|
ToolCallItem(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.function_call.utils import get_schema_properties
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ class HunyuanDetector(BaseFormatDetector):
|
|||||||
if tool.function.name == function_name:
|
if tool.function.name == function_name:
|
||||||
if tool.function.parameters is None:
|
if tool.function.parameters is None:
|
||||||
return {}
|
return {}
|
||||||
return tool.function.parameters.get("properties", {}).get(arg_key, {})
|
return get_schema_properties(tool.function.parameters).get(arg_key, {})
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.function_call.utils import get_schema_properties
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ class KimiK2Detector(BaseFormatDetector):
|
|||||||
best_score = -1
|
best_score = -1
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
params = tool.function.parameters or {}
|
params = tool.function.parameters or {}
|
||||||
props = set(params.get("properties", {}).keys())
|
props = set(get_schema_properties(params).keys())
|
||||||
if not props:
|
if not props:
|
||||||
continue
|
continue
|
||||||
overlap = len(arg_keys & props)
|
overlap = len(arg_keys & props)
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ from sglang.srt.entrypoints.openai.protocol import Tool
|
|||||||
from sglang.srt.environ import envs
|
from sglang.srt.environ import envs
|
||||||
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
|
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
|
||||||
from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
|
from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
|
||||||
from sglang.srt.function_call.utils import safe_literal_eval
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
|
safe_literal_eval,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ def _get_param_type(func_name: str, param_name: str, tools: List[Tool]) -> str:
|
|||||||
"""Get parameter type from tool schema."""
|
"""Get parameter type from tool schema."""
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.function.name == func_name:
|
if tool.function.name == func_name:
|
||||||
props = tool.function.parameters.get("properties", {})
|
props = get_schema_properties(tool.function.parameters)
|
||||||
if param_name in props:
|
if param_name in props:
|
||||||
return props[param_name].get("type", "string")
|
return props[param_name].get("type", "string")
|
||||||
return "string"
|
return "string"
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ from sglang.srt.function_call.core_types import (
|
|||||||
StreamingParseResult,
|
StreamingParseResult,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import safe_literal_eval
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
|
safe_literal_eval,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ def get_argument_type(
|
|||||||
params = tool.function.parameters or {}
|
params = tool.function.parameters or {}
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
return None
|
return None
|
||||||
return params.get("properties", {}).get(arg_key, {}).get("type")
|
return get_schema_properties(params).get(arg_key, {}).get("type")
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments(json_value):
|
def parse_arguments(json_value):
|
||||||
@@ -84,10 +87,7 @@ class MiniCPM5Detector(BaseFormatDetector):
|
|||||||
name_to_required = {}
|
name_to_required = {}
|
||||||
for name, t in name_to_tool.items():
|
for name, t in name_to_tool.items():
|
||||||
params = t.function.parameters or {}
|
params = t.function.parameters or {}
|
||||||
props = (
|
name_to_allowed_props[name] = set(get_schema_properties(params).keys())
|
||||||
(params.get("properties", {}) or {}) if isinstance(params, dict) else {}
|
|
||||||
)
|
|
||||||
name_to_allowed_props[name] = set(props.keys())
|
|
||||||
req = params.get("required", []) if isinstance(params, dict) else []
|
req = params.get("required", []) if isinstance(params, dict) else []
|
||||||
try:
|
try:
|
||||||
name_to_required[name] = set(req)
|
name_to_required[name] = set(req)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.function_call.utils import get_schema_properties
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -508,8 +509,8 @@ class MinimaxM2Detector(BaseFormatDetector):
|
|||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.function.name == fname and tool.function.parameters is not None:
|
if tool.function.name == fname and tool.function.parameters is not None:
|
||||||
parameters = tool.function.parameters
|
parameters = tool.function.parameters
|
||||||
if isinstance(parameters, dict) and "properties" in parameters:
|
if isinstance(parameters, dict):
|
||||||
param_config = parameters["properties"]
|
param_config = get_schema_properties(parameters)
|
||||||
break
|
break
|
||||||
|
|
||||||
param_type = self._get_param_types_from_config(pname, param_config)
|
param_type = self._get_param_types_from_config(pname, param_config)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.function_call.utils import get_schema_properties
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -374,8 +375,8 @@ class MinimaxM3Detector(BaseFormatDetector):
|
|||||||
if self._schema_has_type(parent_schema, ("array",)) and child_tag == "item":
|
if self._schema_has_type(parent_schema, ("array",)) and child_tag == "item":
|
||||||
return self._get_array_item_schema(parent_schema, parent_value)
|
return self._get_array_item_schema(parent_schema, parent_value)
|
||||||
|
|
||||||
properties = parent_schema.get("properties")
|
properties = get_schema_properties(parent_schema)
|
||||||
if isinstance(properties, dict) and child_tag in properties:
|
if child_tag in properties:
|
||||||
child_schema = properties[child_tag]
|
child_schema = properties[child_tag]
|
||||||
return child_schema if isinstance(child_schema, dict) else None
|
return child_schema if isinstance(child_schema, dict) else None
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import safe_literal_eval
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
|
safe_literal_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _ParseState(Enum):
|
class _ParseState(Enum):
|
||||||
@@ -150,7 +153,7 @@ class PoolsideV1Detector(BaseFormatDetector):
|
|||||||
and tool.function.name == func_name
|
and tool.function.name == func_name
|
||||||
and isinstance(tool.function.parameters, dict)
|
and isinstance(tool.function.parameters, dict)
|
||||||
):
|
):
|
||||||
return tool.function.parameters.get("properties", {})
|
return get_schema_properties(tool.function.parameters)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
continue
|
continue
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import (
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
infer_type_from_json_schema,
|
infer_type_from_json_schema,
|
||||||
safe_literal_eval,
|
safe_literal_eval,
|
||||||
)
|
)
|
||||||
@@ -80,9 +81,10 @@ class Qwen3CoderDetector(BaseFormatDetector):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if isinstance(params, dict) and "properties" in params:
|
if isinstance(params, dict):
|
||||||
return params["properties"]
|
properties = get_schema_properties(params)
|
||||||
elif isinstance(params, dict):
|
if properties or "properties" in params:
|
||||||
|
return properties
|
||||||
return params
|
return params
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.function_call.utils import get_schema_properties
|
||||||
|
|
||||||
TOOL_CALL_BEGIN = "<tool_call>"
|
TOOL_CALL_BEGIN = "<tool_call>"
|
||||||
TOOL_CALL_END = "</tool_call>"
|
TOOL_CALL_END = "</tool_call>"
|
||||||
@@ -47,10 +48,7 @@ def _get_param_type(tools: list[Tool], function_name: str, param_name: str) -> s
|
|||||||
parameters = getattr(function, "parameters", None)
|
parameters = getattr(function, "parameters", None)
|
||||||
if not isinstance(parameters, dict):
|
if not isinstance(parameters, dict):
|
||||||
continue
|
continue
|
||||||
properties = parameters.get("properties")
|
definition = get_schema_properties(parameters).get(param_name)
|
||||||
if not isinstance(properties, dict):
|
|
||||||
continue
|
|
||||||
definition = properties.get(param_name)
|
|
||||||
if isinstance(definition, dict) and isinstance(definition.get("type"), str):
|
if isinstance(definition, dict) and isinstance(definition.get("type"), str):
|
||||||
return definition["type"]
|
return definition["type"]
|
||||||
return "string"
|
return "string"
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
from sglang.srt.function_call.utils import safe_literal_eval
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_schema_properties,
|
||||||
|
safe_literal_eval,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ def get_argument_type(func_name: str, arg_key: str, defined_tools: List[Tool]) -
|
|||||||
return None
|
return None
|
||||||
tool = name2tool[func_name]
|
tool = name2tool[func_name]
|
||||||
parameters = tool.function.parameters or {}
|
parameters = tool.function.parameters or {}
|
||||||
properties = parameters.get("properties", {})
|
properties = get_schema_properties(parameters)
|
||||||
if arg_key not in properties:
|
if arg_key not in properties:
|
||||||
return None
|
return None
|
||||||
return properties[arg_key].get("type", None)
|
return properties[arg_key].get("type", None)
|
||||||
|
|||||||
@@ -304,6 +304,25 @@ def _get_tool_schema(tool: Tool) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_schema_properties(schema: Any) -> Dict[str, Any]:
|
||||||
|
"""Top-level ``properties`` of a tool ``parameters`` schema, descending
|
||||||
|
into ``anyOf``/``oneOf``/``allOf`` branches when the top level declares
|
||||||
|
none (legal JSON Schema, e.g. discriminated-union arguments)."""
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return {}
|
||||||
|
properties = schema.get("properties")
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
return properties
|
||||||
|
merged: Dict[str, Any] = {}
|
||||||
|
for keyword in ("anyOf", "oneOf", "allOf"):
|
||||||
|
branches = schema.get(keyword)
|
||||||
|
if isinstance(branches, list):
|
||||||
|
for branch in branches:
|
||||||
|
for key, value in get_schema_properties(branch).items():
|
||||||
|
merged.setdefault(key, value)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
|
def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Infer the primary type of a parameter from JSON Schema.
|
Infer the primary type of a parameter from JSON Schema.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from sglang.srt.function_call.llama32_detector import Llama32Detector
|
|||||||
from sglang.srt.function_call.mistral_detector import MistralDetector
|
from sglang.srt.function_call.mistral_detector import MistralDetector
|
||||||
from sglang.srt.function_call.pythonic_detector import PythonicDetector
|
from sglang.srt.function_call.pythonic_detector import PythonicDetector
|
||||||
from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
|
from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
|
||||||
|
from sglang.srt.function_call.utils import get_schema_properties
|
||||||
from sglang.test.ci.ci_register import register_cpu_ci
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
register_cpu_ci(est_time=20, suite="base-a-test-cpu")
|
register_cpu_ci(est_time=20, suite="base-a-test-cpu")
|
||||||
@@ -5636,5 +5637,196 @@ class TestGemma4Detector(unittest.TestCase):
|
|||||||
self.assertEqual(params1["timezone"], "UTC")
|
self.assertEqual(params1["timezone"], "UTC")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSchemaProperties(unittest.TestCase):
|
||||||
|
def test_flat_properties(self):
|
||||||
|
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
|
||||||
|
self.assertEqual(get_schema_properties(schema), {"a": {"type": "string"}})
|
||||||
|
|
||||||
|
def test_top_level_combinators(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {"const": "acme"},
|
||||||
|
"payload": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "object", "properties": {"kind": {"const": "other"}}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
# duplicate keys resolve to the first branch that declares them
|
||||||
|
self.assertEqual(
|
||||||
|
get_schema_properties(schema),
|
||||||
|
{"kind": {"const": "acme"}, "payload": {"type": "object"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anyof_allof_and_nesting(self):
|
||||||
|
anyof = {
|
||||||
|
"anyOf": [{"properties": {"x": {"type": "integer"}}}, {"type": "null"}]
|
||||||
|
}
|
||||||
|
self.assertEqual(get_schema_properties(anyof), {"x": {"type": "integer"}})
|
||||||
|
allof = {
|
||||||
|
"allOf": [
|
||||||
|
{"oneOf": [{"properties": {"y": {"type": "boolean"}}}]},
|
||||||
|
{"properties": {"z": {"type": "string"}}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
get_schema_properties(allof),
|
||||||
|
{"y": {"type": "boolean"}, "z": {"type": "string"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_dict_and_missing(self):
|
||||||
|
self.assertEqual(get_schema_properties(None), {})
|
||||||
|
self.assertEqual(
|
||||||
|
get_schema_properties(
|
||||||
|
{"type": "object"},
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
self.assertEqual(get_schema_properties({"oneOf": "not-a-list"}), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestTopLevelCompositeToolSchema(unittest.TestCase):
|
||||||
|
"""Parsers must resolve argument types when tool ``parameters`` declares
|
||||||
|
its properties under a top-level anyOf/oneOf/allOf instead of directly."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.oneof_tools = [
|
||||||
|
Tool(
|
||||||
|
type="function",
|
||||||
|
function=Function(
|
||||||
|
name="acme",
|
||||||
|
description="Send a value to Acme.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {"const": "acme"},
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["kind", "payload"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"kind": {"const": "other"}},
|
||||||
|
"required": ["kind"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.flat_tools = [
|
||||||
|
Tool(
|
||||||
|
type="function",
|
||||||
|
function=Function(
|
||||||
|
name="acme",
|
||||||
|
description="Send a value to Acme.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {"type": "string"},
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
"required": ["value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["kind", "payload"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.glm47_text = (
|
||||||
|
"<tool_call>acme"
|
||||||
|
"<arg_key>kind</arg_key><arg_value>acme</arg_value>"
|
||||||
|
"<arg_key>payload</arg_key>"
|
||||||
|
'<arg_value>{"value": "hello"}</arg_value>'
|
||||||
|
"</tool_call>"
|
||||||
|
)
|
||||||
|
self.glm4_text = (
|
||||||
|
"<tool_call>acme\n"
|
||||||
|
"<arg_key>kind</arg_key>\n<arg_value>acme</arg_value>\n"
|
||||||
|
"<arg_key>payload</arg_key>\n"
|
||||||
|
'<arg_value>{"value": "hello"}</arg_value>\n'
|
||||||
|
"</tool_call>"
|
||||||
|
)
|
||||||
|
self.qwen_text = (
|
||||||
|
"<tool_call><function=acme>"
|
||||||
|
"<parameter=kind>acme</parameter>"
|
||||||
|
'<parameter=payload>{"value": "hello"}</parameter>'
|
||||||
|
"</function></tool_call>"
|
||||||
|
)
|
||||||
|
self.expected = {"kind": "acme", "payload": {"value": "hello"}}
|
||||||
|
|
||||||
|
def _stream_arguments(self, detector, text, tools, chunk_size=8):
|
||||||
|
name = None
|
||||||
|
arguments = ""
|
||||||
|
for i in range(0, len(text), chunk_size):
|
||||||
|
result = detector.parse_streaming_increment(text[i : i + chunk_size], tools)
|
||||||
|
for call in result.calls:
|
||||||
|
if call.name:
|
||||||
|
name = call.name
|
||||||
|
arguments += call.parameters
|
||||||
|
return name, arguments
|
||||||
|
|
||||||
|
def test_glm47_streaming(self):
|
||||||
|
detector = Glm47MoeDetector()
|
||||||
|
name, arguments = self._stream_arguments(
|
||||||
|
detector, self.glm47_text, self.oneof_tools
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "acme")
|
||||||
|
self.assertEqual(json.loads(arguments), self.expected)
|
||||||
|
|
||||||
|
def test_glm47_streaming_object_argument_closes_outer_brace(self):
|
||||||
|
detector = Glm47MoeDetector()
|
||||||
|
name, arguments = self._stream_arguments(
|
||||||
|
detector, self.glm47_text, self.flat_tools
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "acme")
|
||||||
|
self.assertEqual(json.loads(arguments), self.expected)
|
||||||
|
|
||||||
|
def test_glm4_streaming(self):
|
||||||
|
detector = Glm4MoeDetector()
|
||||||
|
name, arguments = self._stream_arguments(
|
||||||
|
detector, self.glm4_text, self.oneof_tools
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "acme")
|
||||||
|
self.assertEqual(json.loads(arguments), self.expected)
|
||||||
|
|
||||||
|
def test_glm4_streaming_object_argument_closes_outer_brace(self):
|
||||||
|
detector = Glm4MoeDetector()
|
||||||
|
name, arguments = self._stream_arguments(
|
||||||
|
detector, self.glm4_text, self.flat_tools
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "acme")
|
||||||
|
self.assertEqual(json.loads(arguments), self.expected)
|
||||||
|
|
||||||
|
def test_qwen3_coder_detect_and_parse(self):
|
||||||
|
detector = Qwen3CoderDetector()
|
||||||
|
result = detector.detect_and_parse(self.qwen_text, self.oneof_tools)
|
||||||
|
self.assertEqual(len(result.calls), 1)
|
||||||
|
self.assertEqual(json.loads(result.calls[0].parameters), self.expected)
|
||||||
|
|
||||||
|
def test_qwen3_coder_streaming(self):
|
||||||
|
detector = Qwen3CoderDetector()
|
||||||
|
name, arguments = self._stream_arguments(
|
||||||
|
detector, self.qwen_text, self.oneof_tools
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "acme")
|
||||||
|
self.assertEqual(json.loads(arguments), self.expected)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -535,5 +535,52 @@ def _parse_segments_text(text, tools):
|
|||||||
], result.normal_text
|
], result.normal_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinimaxM3TopLevelOneOf(CustomTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tools = [
|
||||||
|
Tool(
|
||||||
|
type="function",
|
||||||
|
function=Function(
|
||||||
|
name="acme",
|
||||||
|
description="Send a value to Acme.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {"type": "integer"},
|
||||||
|
"verbose": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": ["count", "verbose"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"kind": {"const": "other"}},
|
||||||
|
"required": ["kind"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.segments = (
|
||||||
|
"<tool_call>",
|
||||||
|
'<invoke name="acme">',
|
||||||
|
"<count>7",
|
||||||
|
"</count>",
|
||||||
|
"<verbose>true",
|
||||||
|
"</verbose>",
|
||||||
|
"</invoke>",
|
||||||
|
"</tool_call>",
|
||||||
|
)
|
||||||
|
self.expected = {"count": 7, "verbose": True}
|
||||||
|
|
||||||
|
def test_detect_and_parse(self):
|
||||||
|
calls, _ = _parse_segments(self.segments, self.tools)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(calls[0]["args"], self.expected)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user