diff --git a/python/sglang/srt/function_call/glm47_moe_detector.py b/python/sglang/srt/function_call/glm47_moe_detector.py
index 09f1c3f00..fd159e0b9 100644
--- a/python/sglang/srt/function_call/glm47_moe_detector.py
+++ b/python/sglang/srt/function_call/glm47_moe_detector.py
@@ -16,11 +16,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
-from sglang.srt.function_call.utils import (
- get_schema_properties,
- infer_type_from_json_schema,
- safe_literal_eval,
-)
+from sglang.srt.function_call.utils import safe_literal_eval
logger = logging.getLogger(__name__)
@@ -50,45 +46,170 @@ class StreamState(str, Enum):
IN_VALUE = "IN_VALUE"
-def get_argument_type(
- func_name: str, arg_key: str, defined_tools: List[Tool]
-) -> Optional[str]:
- """Get the expected type of a function argument from tool definitions.
+_UNSET = object()
- Supports complex JSON Schema definitions including:
- - Direct type field (including type arrays)
- - 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
- Args:
- func_name: Name of the function/tool
- arg_key: Name of the argument
- defined_tools: List of available tools
+def _json_type(value: Any) -> str:
+ if isinstance(value, float) and value.is_integer():
+ return "integer"
+ return {
+ type(None): "null",
+ bool: "boolean",
+ int: "integer",
+ float: "number",
+ str: "string",
+ list: "array",
+ dict: "object",
+ }[type(value)]
- Returns:
- The type string (e.g., 'string', 'number', 'object') or None if not found
- """
- name2tool = {tool.function.name: tool for tool in defined_tools}
- # Check if function exists
- tool = name2tool.get(func_name)
- if not tool:
+def _json_equal(left: Any, right: Any) -> bool:
+ if _json_type(left) != _json_type(right):
+ return False
+ if isinstance(left, dict):
+ return left.keys() == right.keys() and all(
+ _json_equal(value, right[key]) for key, value in left.items()
+ )
+ if isinstance(left, list):
+ return len(left) == len(right) and all(
+ _json_equal(a, b) for a, b in zip(left, right)
+ )
+ return left == right
+
+
+def _matches_discriminators(schema: Any, root: Any, arguments: Dict[str, Any]) -> bool:
+ for name, value in arguments.items():
+ types = _argument_types(schema, root, name, value=value)
+ if types is not None and _json_type(value) not in types:
+ return False
+ return True
+
+
+def _argument_types(
+ schema: Any,
+ root: Any,
+ key: Optional[str] = None,
+ seen: frozenset[int] = frozenset(),
+ arguments: Optional[Dict[str, Any]] = None,
+ value: Any = _UNSET,
+) -> Optional[set[str]]:
+ """Keep every possible type until the argument value disambiguates a union."""
+ if not isinstance(schema, dict) or id(schema) in seen:
return None
+ seen = seen | {id(schema)}
+ constraints = []
+ ref = schema.get("$ref")
+ if isinstance(ref, str) and (ref == "#" or ref.startswith("#/")):
+ target = root
+ try:
+ for part in ref[2:].split("/") if ref != "#" else []:
+ part = part.replace("~1", "/").replace("~0", "~")
+ target = target[int(part)] if isinstance(target, list) else target[part]
+ types = _argument_types(target, root, key, seen, arguments, value)
+ if types is not None:
+ constraints.append(types)
+ except (KeyError, IndexError, TypeError, ValueError):
+ pass
+ if key is not None:
+ field = schema.get("properties", {}).get(key)
+ types = _argument_types(field, root, seen=seen, value=value)
+ if types is not None:
+ constraints.append(types)
+ else:
+ types = schema.get("type")
+ if isinstance(types, str):
+ constraints.append({types, "integer"} if types == "number" else {types})
+ elif isinstance(types, list):
+ constraints.append(
+ set(types) | ({"integer"} if "number" in types else set())
+ )
+ values = [schema["const"]] if "const" in schema else schema.get("enum")
+ if isinstance(values, list):
+ constraints.append(
+ {
+ _json_type(allowed)
+ for allowed in values
+ if value is _UNSET or _json_equal(value, allowed)
+ }
+ )
+ for keyword in ("anyOf", "oneOf"):
+ if isinstance(schema.get(keyword), list):
+ alternatives = [
+ _argument_types(branch, root, key, seen, arguments, value)
+ for branch in schema[keyword]
+ if key is None or _matches_discriminators(branch, root, arguments or {})
+ ]
+ if alternatives and all(types is not None for types in alternatives):
+ constraints.append(set().union(*alternatives))
+ if isinstance(schema.get("allOf"), list):
+ for branch in schema["allOf"]:
+ types = _argument_types(branch, root, key, seen, arguments, value)
+ if types is not None:
+ constraints.append(types)
+ return set.intersection(*constraints) if constraints else None
- # Get parameters safely using getattr
- params = getattr(tool.function, "parameters", None)
-
- arg_spec = get_schema_properties(params).get(arg_key)
- if isinstance(arg_spec, dict):
- # Use the new type inference function for complex JSON Schema support
- return infer_type_from_json_schema(arg_spec)
+def _get_argument_types(
+ func_name, arg_key, defined_tools, arguments=None, value=_UNSET
+):
+ for tool in defined_tools:
+ if tool.function.name == func_name:
+ schema = tool.function.parameters
+ return _argument_types(
+ schema, schema, arg_key, arguments=arguments, value=value
+ )
return None
+def get_argument_type(
+ func_name: str,
+ arg_key: str,
+ defined_tools: List[Tool],
+ arguments: Optional[Dict[str, Any]] = None,
+) -> Optional[str]:
+ types = _get_argument_types(func_name, arg_key, defined_tools, arguments)
+ if types == {"number", "integer"}:
+ return "number"
+ return next(iter(types)) if types and len(types) == 1 else None
+
+
+def _needs_argument_context(schema: Any) -> bool:
+ keys, seen = set(), set()
+ has_union = False
+
+ def visit(node):
+ nonlocal has_union
+ if not isinstance(node, dict) or id(node) in seen:
+ return
+ seen.add(id(node))
+ keys.update(node.get("properties", {}))
+ ref = node.get("$ref")
+ if isinstance(ref, str) and (ref == "#" or ref.startswith("#/")):
+ target = schema
+ try:
+ for part in ref[2:].split("/") if ref != "#" else []:
+ part = part.replace("~1", "/").replace("~0", "~")
+ target = (
+ target[int(part)] if isinstance(target, list) else target[part]
+ )
+ visit(target)
+ except (KeyError, IndexError, TypeError, ValueError):
+ pass
+ for keyword in ("anyOf", "oneOf", "allOf"):
+ branches = node.get(keyword)
+ if isinstance(branches, list):
+ has_union |= keyword != "allOf"
+ for branch in branches:
+ visit(branch)
+
+ visit(schema)
+ for key in keys if has_union else ():
+ types = _argument_types(schema, schema, key)
+ if types is None or len(types) > 1 and types != {"number", "integer"}:
+ return True
+ return False
+
+
def _convert_to_number(value: str) -> Any:
"""Convert string to appropriate number type (int or float).
@@ -107,6 +228,24 @@ def _convert_to_number(value: str) -> Any:
return value
+def _convert_to_integer(value: str) -> Any:
+ try:
+ return int(value)
+ except (ValueError, AttributeError):
+ return value
+
+
+def _coerce_numeric_string(value: Any, arg_type: Optional[str]) -> Any:
+ # A quoted value must not become a type its schema forbids: "1.5" may coerce
+ # for number but never for integer.
+ if isinstance(value, str):
+ if arg_type == "integer":
+ return _convert_to_integer(value)
+ if arg_type == "number":
+ return _convert_to_number(value)
+ return value
+
+
def parse_arguments(
json_value: str, arg_type: Optional[str] = None
) -> Tuple[Any, bool]:
@@ -121,25 +260,14 @@ def parse_arguments(
"""
# Strategy 1: Direct JSON parsing
try:
- parsed_value = json.loads(json_value)
-
- # Type coercion for number type
- if arg_type == "number" and isinstance(parsed_value, str):
- parsed_value = _convert_to_number(parsed_value)
-
- return parsed_value, True
+ return _coerce_numeric_string(json.loads(json_value), arg_type), True
except (json.JSONDecodeError, ValueError):
pass
# Strategy 2: Unescape and parse
try:
wrapped = json.loads('{"tmp": "' + json_value + '"}')
- parsed_value = json.loads(wrapped["tmp"])
-
- if arg_type == "number" and isinstance(parsed_value, str):
- parsed_value = _convert_to_number(parsed_value)
-
- return parsed_value, True
+ return _coerce_numeric_string(json.loads(wrapped["tmp"]), arg_type), True
except (json.JSONDecodeError, ValueError, KeyError):
pass
@@ -212,6 +340,7 @@ class Glm47MoeDetector(BaseFormatDetector):
)
self._tool_call_completed = False # Reset tool call completion status
self._sent_empty_object = False # Reset empty object sent status
+ self._buffer_arguments = None
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a glm-4.5 / glm-4.6 format tool call."""
@@ -273,84 +402,15 @@ class Glm47MoeDetector(BaseFormatDetector):
return StreamingParseResult(normal_text=text)
def _get_value_type(self, func_name: str, key: str, tools: List[Tool]) -> str:
- """Get parameter type from tool definition, with fallback to auto-detection.
-
- Args:
- func_name: Name of the function
- key: Parameter name
- tools: List of available tools
-
- Returns:
- Type string: 'string', 'number', 'object', 'array', or 'boolean'
- """
- arg_type = get_argument_type(func_name, key, tools)
- if arg_type:
- return arg_type
-
- # Improved auto-detection type from value (best effort)
- value_content = self._current_value.strip() if self._current_value else ""
-
- if not value_content:
- return "string"
-
- # Try to parse as valid JSON first
- try:
- parsed = json.loads(value_content)
- if isinstance(parsed, dict):
- return "object"
- elif isinstance(parsed, list):
- return "array"
- elif isinstance(parsed, bool):
- return "boolean"
- elif isinstance(parsed, (int, float)):
- return "number"
- # For string values, check if they look like numbers
- elif isinstance(parsed, str):
- if parsed.isdigit() or (
- parsed.startswith("-") and parsed[1:].isdigit()
- ):
- return "number"
- return "string"
- except json.JSONDecodeError:
- # Not valid JSON, try heuristic detection
- first_char = value_content[0] if value_content else ""
-
- if first_char.isdigit() or first_char in ["-", "."]:
- return "number"
- elif first_char in ["{", "["]:
- return "object"
- elif first_char in ['"', "'"]:
- return "string"
-
- # Default to string (safest fallback)
- return "string"
+ return get_argument_type(func_name, key, tools) or "auto"
def _format_value_complete(self, value: str, value_type: str) -> str:
- """Format complete value based on type.
-
- Args:
- value: Raw value string
- value_type: Expected type ('string', 'number', 'object')
-
- Returns:
- Properly formatted JSON value string
- """
+ """Format a value that completed in one chunk; number/integer/auto never
+ reach here (they are parsed at value close instead)."""
if value_type == "string":
- # Ensure proper JSON string formatting with quotes
return json.dumps(value, ensure_ascii=False)
- elif value_type == "number":
- try:
- num = _convert_to_number(value.strip() if value else "")
- return str(num)
- except (ValueError, AttributeError):
- # Fallback to string if not a valid number
- logger.warning(
- f"Failed to parse '{value}' as number, treating as string"
- )
- return json.dumps(str(value) if value else "", ensure_ascii=False)
- else:
- # For object/array types, return as-is (should already be valid JSON)
- return value
+ # object/array/boolean values arrive as JSON already
+ return value
def _process_xml_to_json_streaming(
self, raw_increment: str, func_name: str, tools: List[Tool]
@@ -397,7 +457,6 @@ class Glm47MoeDetector(BaseFormatDetector):
self._current_value = ""
self._xml_tag_buffer = ""
self._value_started = False
- # Determine and cache the value type at the start
self._cached_value_type = self._get_value_type(
func_name, self._current_key, tools
)
@@ -407,11 +466,13 @@ class Glm47MoeDetector(BaseFormatDetector):
final_value = self._xml_tag_buffer[:-12]
self._current_value += final_value
- # Use cached value type for consistency
value_type = self._cached_value_type or "string"
-
- if self._value_started:
- # Output any remaining content
+ if value_type in ("auto", "number", "integer"):
+ parsed = self._parse_argument_pairs(
+ [(self._current_key, self._current_value)], func_name, tools
+ )[self._current_key]
+ json_output += json.dumps(parsed, ensure_ascii=False)
+ elif self._value_started:
if final_value:
if value_type == "string":
json_output += json.dumps(
@@ -419,11 +480,10 @@ class Glm47MoeDetector(BaseFormatDetector):
)[1:-1]
else:
json_output += final_value
- # Always output closing quote for string type when value was started
if value_type == "string":
json_output += '"'
else:
- # Value was never started (empty or complete in one chunk)
+ # Value never started: empty, or completed in one chunk.
json_output += self._format_value_complete(
self._current_value, value_type
)
@@ -432,7 +492,7 @@ class Glm47MoeDetector(BaseFormatDetector):
self._stream_state = StreamState.BETWEEN
self._current_value = ""
self._value_started = False
- self._cached_value_type = None # Reset cached type
+ self._cached_value_type = None
else:
closing_tag = ""
is_potential_closing = len(self._xml_tag_buffer) <= len(
@@ -441,10 +501,12 @@ class Glm47MoeDetector(BaseFormatDetector):
if not is_potential_closing:
content = self._xml_tag_buffer
- # Use cached value type for consistency
value_type = self._cached_value_type or "string"
- if value_type == "string":
+ if value_type in ("auto", "number", "integer"):
+ self._current_value += content
+ self._xml_tag_buffer = ""
+ elif value_type == "string":
if not self._value_started:
json_output += '"'
self._value_started = True
@@ -454,15 +516,8 @@ class Glm47MoeDetector(BaseFormatDetector):
]
self._current_value += content
self._xml_tag_buffer = ""
- elif value_type == "number":
- if content:
- if not self._value_started:
- self._value_started = True
- json_output += content
- self._current_value += content
- self._xml_tag_buffer = ""
else:
- # For object/array types, output as-is
+ # object/array/boolean values stream through verbatim
if content:
if not self._value_started:
self._value_started = True
@@ -544,13 +599,20 @@ class Glm47MoeDetector(BaseFormatDetector):
"""
current_raw_length = len(func_args_raw)
+ if self._buffer_arguments is None:
+ self._buffer_arguments = any(
+ tool.function.name == func_name
+ and _needs_argument_context(tool.function.parameters)
+ for tool in tools
+ )
+ if self._buffer_arguments:
+ return None
+
if current_raw_length <= self._streamed_raw_length:
return None
- # Get new raw XML content
raw_increment = func_args_raw[self._streamed_raw_length :]
- # Convert XML to JSON using state machine
json_increment = self._process_xml_to_json_streaming(
raw_increment, func_name, tools
)
@@ -562,7 +624,6 @@ class Glm47MoeDetector(BaseFormatDetector):
if not json_increment:
return None
- # Update state
self._last_arguments += json_increment
self.streamed_args_for_tool[self.current_tool_id] += json_increment
@@ -594,9 +655,22 @@ class Glm47MoeDetector(BaseFormatDetector):
"""
calls = []
+ if self._buffer_arguments:
+ arguments = self._parse_argument_pairs(
+ self.func_arg_regex.findall(func_args_raw), func_name, tools
+ )
+ serialized = json.dumps(arguments, ensure_ascii=False)
+ calls.append(
+ ToolCallItem(
+ tool_index=self.current_tool_id, name=None, parameters=serialized
+ )
+ )
+ self._last_arguments += serialized
+ self.streamed_args_for_tool[self.current_tool_id] += serialized
+ self._sent_empty_object = True
+
# Handle no-arg function or need to close braces
if self._is_first_param and not self._sent_empty_object:
- # No-arg function
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
@@ -621,7 +695,6 @@ class Glm47MoeDetector(BaseFormatDetector):
self.streamed_args_for_tool[self.current_tool_id] += "}"
self._sent_empty_object = True
- # Parse final arguments
if func_args_raw:
try:
pairs = self.func_arg_regex.findall(func_args_raw)
@@ -633,7 +706,6 @@ class Glm47MoeDetector(BaseFormatDetector):
except Exception as e:
logger.debug(f"Failed to parse arguments: {e}", exc_info=True)
- # Clean buffer
self._buffer = current_text[match_end_pos:]
# Reset state for next tool call
@@ -786,26 +858,56 @@ class Glm47MoeDetector(BaseFormatDetector):
Dictionary of parsed arguments
"""
arguments = {}
+ known_arguments = {}
+ # Values with a certain type are resolved first so completed sibling
+ # discriminators (e.g. kind=const) can steer root-union branch selection.
+ if len(pairs) > 1 and any(
+ tool.function.name == func_name
+ and _needs_argument_context(tool.function.parameters)
+ for tool in tools
+ ):
+ for key, raw in pairs:
+ key = key.strip()
+ parsed = self._parse_argument_pairs([(key, raw)], func_name, tools)[key]
+ native_types = _get_argument_types(func_name, key, tools, value=raw)
+ if (
+ isinstance(parsed, str)
+ or native_types is None
+ or "string" not in native_types
+ ):
+ known_arguments[key] = parsed
for arg_key, arg_value in pairs:
arg_key = arg_key.strip()
- arg_type = get_argument_type(func_name, arg_key, tools)
- parsed_value, is_good_json = parse_arguments(arg_value, arg_type)
+ arg_type = get_argument_type(func_name, arg_key, tools, known_arguments)
+ parsed_value, is_good_json = parse_arguments(
+ arg_value, arg_type or "string"
+ )
if arg_type == "string":
- # Only convert to string if explicitly defined as string type
if isinstance(parsed_value, str):
arguments[arg_key] = parsed_value
- elif isinstance(parsed_value, (dict, list)):
- # If parsed as dict/list but schema says string, convert to JSON string
- arguments[arg_key] = json.dumps(parsed_value, ensure_ascii=False)
else:
- arguments[arg_key] = str(parsed_value)
+ arguments[arg_key] = arg_value
elif arg_type is None:
- # If type is not defined, keep the parsed value as-is
+ # A value whose parsed JSON type is inadmissible stays the raw
+ # string when the schema allows a string (e.g. enum ["7", 8]).
+ allowed = _get_argument_types(
+ func_name, arg_key, tools, known_arguments, parsed_value
+ )
+ native = _get_argument_types(
+ func_name, arg_key, tools, known_arguments, arg_value
+ )
+ if (
+ allowed is not None
+ and _json_type(parsed_value) not in allowed
+ and native is not None
+ and "string" in native
+ ):
+ parsed_value = arg_value
arguments[arg_key] = parsed_value if is_good_json else arg_value
else:
- # For other types (number, object, array, etc.), use parsed value
arguments[arg_key] = parsed_value if is_good_json else arg_value
+ known_arguments[arg_key] = arguments[arg_key]
return arguments
diff --git a/test/registered/unit/function_call/test_glm47_schema_types.py b/test/registered/unit/function_call/test_glm47_schema_types.py
new file mode 100644
index 000000000..8e37fc28d
--- /dev/null
+++ b/test/registered/unit/function_call/test_glm47_schema_types.py
@@ -0,0 +1,362 @@
+import json
+import unittest
+
+from sglang.srt.entrypoints.openai.protocol import Function, Tool
+from sglang.srt.function_call.glm47_moe_detector import Glm47MoeDetector
+from sglang.test.ci.ci_register import register_cpu_ci
+
+register_cpu_ci(est_time=2, suite="base-a-test-cpu")
+
+
+class TestGlm47SchemaTypes(unittest.TestCase):
+ def check_arguments(self, schema, expected, raw_values=None):
+ tools = [
+ Tool(type="function", function=Function(name="inspect", parameters=schema))
+ ]
+ pairs = []
+ for key, value in expected.items():
+ raw = (raw_values or {}).get(
+ key, value if isinstance(value, str) else json.dumps(value)
+ )
+ pairs.append(f"{key}{raw}")
+ text = "inspect" + "".join(pairs) + ""
+ for size in (0, 1, 7, len(text)):
+ with self.subTest(schema=schema, expected=expected, chunk_size=size):
+ detector = Glm47MoeDetector()
+ if size == 0:
+ calls = detector.detect_and_parse(text, tools).calls
+ else:
+ calls = []
+ for offset in range(0, len(text), size):
+ calls.extend(
+ detector.parse_streaming_increment(
+ text[offset : offset + size], tools
+ ).calls
+ )
+ actual = json.loads("".join(call.parameters for call in calls))
+ self.assertEqual(actual, expected)
+ for key in expected:
+ self.assertIs(type(actual[key]), type(expected[key]))
+
+ def test_field_types(self):
+ cases = [
+ ({"type": ["integer", "string"]}, "auto"),
+ ({"type": ["string", "integer"]}, 7),
+ ({"type": ["number", "string"]}, "auto"),
+ ({"type": ["object", "string"]}, "auto"),
+ ({"type": ["string", "object"]}, {"ok": False}),
+ ({"type": ["boolean", "string"]}, "auto"),
+ ({"type": ["string", "boolean"]}, False),
+ ({"type": ["string", "null"]}, None),
+ ({"type": ["null"]}, None),
+ ({"type": "null"}, None),
+ ({"enum": ["ok", None]}, None),
+ ({"type": ["string", "null"], "enum": ["ok", None]}, None),
+ ({"anyOf": [{"type": "string", "enum": ["auto"]}, {"type": "integer"}]}, 7),
+ (
+ {
+ "oneOf": [
+ {"type": "null"},
+ {"type": "array", "items": {"type": "integer"}},
+ ]
+ },
+ [1, 2],
+ ),
+ ({"const": 7}, 7),
+ ({"const": {"ok": False}}, {"ok": False}),
+ ({"const": "null"}, "null"),
+ ({"allOf": [{"type": ["integer", "string"]}, {"type": "integer"}]}, 7),
+ ({}, "123_456"),
+ ({}, {"ok": False}),
+ ]
+ cases.extend(
+ ({"type": "string"}, value)
+ for value in (
+ "null",
+ "123_456",
+ "\\d+",
+ 'line one\n"quoted" \\ café',
+ )
+ )
+ for field, value in cases:
+ self.check_arguments(
+ {"type": "object", "properties": {"value": field}}, {"value": value}
+ )
+ self.check_arguments(
+ {"type": "object", "properties": {"value": {"type": ["string", "null"]}}},
+ {"value": "null"},
+ {"value": '"null"'},
+ )
+
+ def test_local_references_and_cycles(self):
+ for value, field in [
+ (7, {"type": "integer"}),
+ ({"ok": False}, {"type": "object"}),
+ ("null", {"type": "string"}),
+ ]:
+ for defs in ("$defs", "definitions"):
+ schema = {
+ defs: {"a/b~c": field},
+ "type": "object",
+ "properties": {"value": {"$ref": f"#/{defs}/a~1b~0c"}},
+ }
+ self.check_arguments(schema, {"value": value})
+ self.check_arguments(
+ {
+ "$defs": {
+ "args": {
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ }
+ },
+ "$ref": "#/$defs/args",
+ },
+ {"value": "null"},
+ )
+ self.check_arguments(
+ {
+ "$defs": {"cycle": {"$ref": "#/$defs/cycle"}},
+ "properties": {"value": {"$ref": "#/$defs/cycle"}},
+ },
+ {"value": "auto"},
+ )
+
+ def test_root_all_of_preserves_value_type(self):
+ self.check_arguments(
+ {
+ "allOf": [
+ {
+ "type": "object",
+ "properties": {
+ "kind": {"const": "count"},
+ "value": {"type": "integer"},
+ },
+ },
+ {"required": ["kind", "value"]},
+ ]
+ },
+ {"kind": "count", "value": 7},
+ )
+
+ def test_plain_strings_still_stream_before_value_completes(self):
+ tools = [
+ Tool(
+ type="function",
+ function=Function(
+ name="inspect",
+ parameters={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
+ ),
+ )
+ ]
+ detector = Glm47MoeDetector()
+ first = detector.parse_streaming_increment(
+ "inspectvaluehello", tools
+ )
+ self.assertIn('"hello', "".join(call.parameters for call in first.calls))
+ last = detector.parse_streaming_increment(
+ " world", tools
+ )
+ self.assertEqual(
+ json.loads("".join(call.parameters for call in first.calls + last.calls)),
+ {"value": "hello world"},
+ )
+
+ def test_native_strings_reject_disallowed_json_types_and_enum_values(self):
+ for field, value in [
+ ({"type": ["string", "object"]}, "7"),
+ ({"type": ["object", "string"]}, "7"),
+ ({"oneOf": [{"type": "string"}, {"type": "object"}]}, "7"),
+ ({"type": ["string", "array"]}, "1e2"),
+ ({"type": ["string", "integer"]}, "true"),
+ ({"type": ["string", "integer"]}, "null"),
+ ({"enum": ["7", None]}, "7"),
+ ({"enum": ["7", 8]}, "7"),
+ ({"enum": ["7", 8]}, 8),
+ ({"enum": ["null", 7]}, "null"),
+ ({"enum": ["7", False]}, "7"),
+ ({"type": ["number", "string"], "enum": [1, "auto"]}, 1),
+ ({"type": ["number", "string"], "enum": [1, "auto"]}, 1.0),
+ ]:
+ self.check_arguments(
+ {"type": "object", "properties": {"value": field}}, {"value": value}
+ )
+ self.check_arguments(
+ {
+ "$defs": {"value": {"type": ["string", "object"]}},
+ "properties": {"value": {"$ref": "#/$defs/value"}},
+ },
+ {"value": "7"},
+ )
+
+ def test_json_looking_string_bytes_are_preserved(self):
+ for value in ('{"a":1}', '{ "a" : true }'):
+ for field in ({"type": "string"}, {"const": value}):
+ self.check_arguments({"properties": {"value": field}}, {"value": value})
+
+ def test_nonstream_numeric_strings_with_integer_enum(self):
+ tools = [
+ Tool(
+ type="function",
+ function=Function(
+ name="inspect",
+ parameters={
+ "properties": {"value": {"type": "number", "enum": [1, 2]}}
+ },
+ ),
+ )
+ ]
+ for raw in ('"1"', r"\"1\""):
+ text = (
+ "inspectvalue"
+ + raw
+ + ""
+ )
+ calls = Glm47MoeDetector().detect_and_parse(text, tools).calls
+ self.assertEqual(json.loads(calls[0].parameters), {"value": 1})
+
+ def test_numeric_values_are_serialized_after_the_value_closes(self):
+ for value_type in ("number", "integer"):
+ schema = {"properties": {"value": {"type": value_type}}}
+ for raw, expected in (
+ ("123abc", "123abc"),
+ ('"123"', 123),
+ (r"\"123\"", 123),
+ ("-12", -12),
+ ("1.25e2", 125.0),
+ ("", ""),
+ ):
+ self.check_arguments(schema, {"value": expected}, {"value": raw})
+ self.check_arguments(
+ {"properties": {"value": {"type": "number"}}},
+ {"value": 1.5},
+ {"value": '"1.5"'},
+ )
+ for raw in ('"1.5"', '"1e2"'):
+ self.check_arguments(
+ {"properties": {"value": {"type": "integer"}}},
+ {"value": json.loads(raw)},
+ {"value": raw},
+ )
+
+ def test_numeric_prefix_does_not_commit_to_invalid_json(self):
+ tools = [
+ Tool(
+ type="function",
+ function=Function(
+ name="inspect",
+ parameters={"properties": {"value": {"type": "number"}}},
+ ),
+ )
+ ]
+ detector = Glm47MoeDetector()
+ prefix = detector.parse_streaming_increment(
+ "inspectvalue123", tools
+ ).calls
+ self.assertNotIn("123", "".join(call.parameters for call in prefix))
+ suffix = detector.parse_streaming_increment(
+ "abc", tools
+ ).calls
+ self.assertEqual(
+ json.loads("".join(call.parameters for call in prefix + suffix)),
+ {"value": "123abc"},
+ )
+
+ def test_completed_siblings_disambiguate_root_union(self):
+ for keyword in ("oneOf", "anyOf"):
+ for discriminator in ("const", "enum"):
+ branches = [
+ {
+ "properties": {
+ "kind": {
+ discriminator: [kind]
+ if discriminator == "enum"
+ else kind
+ },
+ "value": {"type": value_type},
+ }
+ }
+ for kind, value_type in (("text", "string"), ("count", "integer"))
+ ]
+ for ordered in (branches, list(reversed(branches))):
+ for schema in (
+ {keyword: ordered},
+ {keyword: [{"allOf": [branch]} for branch in ordered]},
+ {
+ "$defs": {
+ str(i): branch for i, branch in enumerate(ordered)
+ },
+ keyword: [{"$ref": f"#/$defs/{i}"} for i in range(2)],
+ },
+ ):
+ self.check_arguments(schema, {"kind": "text", "value": "7"})
+ self.check_arguments(schema, {"kind": "count", "value": 7})
+ self.check_arguments(schema, {"value": "7", "kind": "text"})
+ self.check_arguments(schema, {"value": 7, "kind": "count"})
+
+ def test_completed_arguments_reset_between_tool_calls(self):
+ schema = {
+ "oneOf": [
+ {
+ "properties": {
+ "kind": {"const": "text"},
+ "value": {"type": "string"},
+ }
+ },
+ {
+ "properties": {
+ "kind": {"const": "count"},
+ "value": {"type": "integer"},
+ }
+ },
+ ]
+ }
+ tools = [
+ Tool(type="function", function=Function(name="inspect", parameters=schema))
+ ]
+ tools.append(
+ Tool(
+ type="function",
+ function=Function(
+ name="plain",
+ parameters={"properties": {"value": {"type": "string"}}},
+ ),
+ )
+ )
+ detector = Glm47MoeDetector()
+ outputs = {}
+ for kind, discriminator_first in (
+ ("text", True),
+ ("count", False),
+ ("text", False),
+ ):
+ tag = f"kind{kind}"
+ value = "value7"
+ text = (
+ "inspect"
+ + (tag + value if discriminator_first else value + tag)
+ + ""
+ )
+ for char in text:
+ for call in detector.parse_streaming_increment(char, tools).calls:
+ outputs[call.tool_index] = (
+ outputs.get(call.tool_index, "") + call.parameters
+ )
+ self.assertEqual(json.loads(outputs[0]), {"kind": "text", "value": "7"})
+ self.assertEqual(json.loads(outputs[1]), {"kind": "count", "value": 7})
+ self.assertEqual(json.loads(outputs[2]), {"kind": "text", "value": "7"})
+ first = detector.parse_streaming_increment(
+ "plainvaluehello", tools
+ )
+ self.assertIn('"hello', "".join(call.parameters for call in first.calls))
+ last = detector.parse_streaming_increment("", tools)
+ self.assertEqual(
+ json.loads("".join(call.parameters for call in first.calls + last.calls)),
+ {"value": "hello"},
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()