feat: support anyOf for structured output tools [LET-5615] (#5556)
* base * works? * update tests --------- Co-authored-by: Letta Bot <noreply@letta.com>
This commit is contained in:
@@ -662,6 +662,16 @@ def normalize_mcp_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Handle anyOf (complex union types)
|
||||
if "anyOf" in prop_schema:
|
||||
for option in prop_schema["anyOf"]:
|
||||
# Add explicit type to $ref options for flattening support
|
||||
if "$ref" in option and "type" not in option:
|
||||
if defs and option["$ref"].startswith("#/$defs/"):
|
||||
def_name = option["$ref"].split("/")[-1]
|
||||
if def_name in defs and "type" in defs[def_name]:
|
||||
option["type"] = defs[def_name]["type"]
|
||||
# Default to object if type can't be resolved
|
||||
if "type" not in option:
|
||||
option["type"] = "object"
|
||||
# Recursively normalize object types
|
||||
if isinstance(option, dict) and option.get("type") == "object":
|
||||
normalize_object_schema(option, defs)
|
||||
|
||||
@@ -710,28 +720,61 @@ def generate_tool_schema_for_mcp(
|
||||
# Normalise so downstream code can treat it consistently.
|
||||
parameters_schema.setdefault("required", [])
|
||||
|
||||
# Process properties to handle anyOf types and make optional fields strict-compatible
|
||||
# TODO: de-duplicate with handling in normalize_mcp_schema
|
||||
# Get $defs for $ref resolution
|
||||
defs = parameters_schema.get("$defs", {})
|
||||
|
||||
def inline_ref(schema_node, defs, depth=0, max_depth=10):
|
||||
"""
|
||||
Recursively inline all $ref references in a schema node.
|
||||
Returns a new schema with all $refs replaced by their definitions.
|
||||
"""
|
||||
if depth > max_depth:
|
||||
return schema_node # Prevent infinite recursion
|
||||
|
||||
if not isinstance(schema_node, dict):
|
||||
return schema_node
|
||||
|
||||
# Make a copy to avoid modifying the original
|
||||
result = schema_node.copy()
|
||||
|
||||
# If this node has a $ref, resolve it and merge
|
||||
if "$ref" in result:
|
||||
ref_path = result["$ref"]
|
||||
if ref_path.startswith("#/$defs/"):
|
||||
def_name = ref_path.split("/")[-1]
|
||||
if def_name in defs:
|
||||
# Get the referenced schema
|
||||
ref_schema = defs[def_name].copy()
|
||||
# Remove the $ref
|
||||
del result["$ref"]
|
||||
# Merge the referenced schema into result
|
||||
# The referenced schema properties take precedence
|
||||
for key, value in ref_schema.items():
|
||||
if key not in result:
|
||||
result[key] = value
|
||||
# Recursively inline any $refs in the merged schema
|
||||
result = inline_ref(result, defs, depth + 1, max_depth)
|
||||
|
||||
# Recursively process nested structures
|
||||
if "anyOf" in result:
|
||||
result["anyOf"] = [inline_ref(opt, defs, depth + 1, max_depth) for opt in result["anyOf"]]
|
||||
if "properties" in result and isinstance(result["properties"], dict):
|
||||
result["properties"] = {
|
||||
prop_name: inline_ref(prop_schema, defs, depth + 1, max_depth) for prop_name, prop_schema in result["properties"].items()
|
||||
}
|
||||
if "items" in result:
|
||||
result["items"] = inline_ref(result["items"], defs, depth + 1, max_depth)
|
||||
|
||||
return result
|
||||
|
||||
# Process properties to inline all $refs while keeping anyOf structure
|
||||
if "properties" in parameters_schema:
|
||||
for field_name, field_props in parameters_schema["properties"].items():
|
||||
# Handle anyOf types by flattening to type array
|
||||
if "anyOf" in field_props and "type" not in field_props:
|
||||
types = []
|
||||
format_value = None
|
||||
for option in field_props["anyOf"]:
|
||||
if "type" in option:
|
||||
types.append(option["type"])
|
||||
# Capture format if present (e.g., uuid format for strings)
|
||||
if "format" in option and not format_value:
|
||||
format_value = option["format"]
|
||||
if types:
|
||||
# Deduplicate types using set
|
||||
field_props["type"] = list(dict.fromkeys(types))
|
||||
# Only add format if the field is not optional (doesn't have null type)
|
||||
if format_value and len(field_props["type"]) == 1 and "null" not in field_props["type"]:
|
||||
field_props["format"] = format_value
|
||||
# Remove the anyOf since we've flattened it
|
||||
del field_props["anyOf"]
|
||||
for field_name in list(parameters_schema["properties"].keys()):
|
||||
field_props = parameters_schema["properties"][field_name]
|
||||
|
||||
# Inline all $refs in this property (recursively)
|
||||
field_props = inline_ref(field_props, defs)
|
||||
parameters_schema["properties"][field_name] = field_props
|
||||
|
||||
# For strict mode: heal optional fields by making them required with null type
|
||||
if strict and field_name not in parameters_schema["required"]:
|
||||
|
||||
@@ -17,15 +17,65 @@ from letta.utils import count_tokens, printd
|
||||
def _convert_to_structured_output_helper(property: dict) -> dict:
|
||||
"""Convert a single JSON schema property to structured output format (recursive)"""
|
||||
|
||||
if "type" not in property:
|
||||
raise ValueError(f"Property {property} is missing a type")
|
||||
param_type = property["type"]
|
||||
# Handle anyOf structures
|
||||
if "anyOf" in property and "type" not in property:
|
||||
# Check if this is a simple anyOf that can be flattened to type array
|
||||
types = []
|
||||
has_complex = False
|
||||
for option in property["anyOf"]:
|
||||
if "type" in option:
|
||||
opt_type = option["type"]
|
||||
if opt_type in ["object", "array"]:
|
||||
has_complex = True
|
||||
break
|
||||
types.append(opt_type)
|
||||
elif "$ref" in option:
|
||||
# Has unresolved $ref, treat as complex
|
||||
has_complex = True
|
||||
break
|
||||
|
||||
if "description" not in property:
|
||||
# raise ValueError(f"Property {property} is missing a description")
|
||||
param_description = None
|
||||
else:
|
||||
param_description = property["description"]
|
||||
# If it's simple primitives only (string, null, integer, boolean, etc), flatten to type array
|
||||
if not has_complex and types:
|
||||
param_description = property.get("description")
|
||||
property_dict = {"type": types}
|
||||
if param_description is not None:
|
||||
property_dict["description"] = param_description
|
||||
if "default" in property:
|
||||
property_dict["default"] = property["default"]
|
||||
# Preserve other fields like enum, format, etc
|
||||
for key in ["enum", "format", "pattern", "minLength", "maxLength", "minimum", "maximum"]:
|
||||
if key in property:
|
||||
property_dict[key] = property[key]
|
||||
return property_dict
|
||||
|
||||
# Otherwise, preserve anyOf and recursively process each option
|
||||
property_dict = {"anyOf": [_convert_to_structured_output_helper(opt) for opt in property["anyOf"]]}
|
||||
if "description" in property:
|
||||
property_dict["description"] = property["description"]
|
||||
if "default" in property:
|
||||
property_dict["default"] = property["default"]
|
||||
if "title" in property:
|
||||
property_dict["title"] = property["title"]
|
||||
return property_dict
|
||||
|
||||
if "type" not in property:
|
||||
raise ValueError(f"Property {property} is missing a type and doesn't have anyOf")
|
||||
|
||||
param_type = property["type"]
|
||||
param_description = property.get("description")
|
||||
|
||||
# Handle type arrays (e.g., ["string", "null"])
|
||||
if isinstance(param_type, list):
|
||||
property_dict = {"type": param_type}
|
||||
if param_description is not None:
|
||||
property_dict["description"] = param_description
|
||||
if "default" in property:
|
||||
property_dict["default"] = property["default"]
|
||||
# Preserve other fields
|
||||
for key in ["enum", "format", "pattern", "minLength", "maxLength", "minimum", "maximum", "title"]:
|
||||
if key in property:
|
||||
property_dict[key] = property[key]
|
||||
return property_dict
|
||||
|
||||
if param_type == "object":
|
||||
if "properties" not in property:
|
||||
@@ -39,6 +89,8 @@ def _convert_to_structured_output_helper(property: dict) -> dict:
|
||||
}
|
||||
if param_description is not None:
|
||||
property_dict["description"] = param_description
|
||||
if "title" in property:
|
||||
property_dict["title"] = property["title"]
|
||||
return property_dict
|
||||
|
||||
elif param_type == "array":
|
||||
@@ -51,6 +103,8 @@ def _convert_to_structured_output_helper(property: dict) -> dict:
|
||||
}
|
||||
if param_description is not None:
|
||||
property_dict["description"] = param_description
|
||||
if "title" in property:
|
||||
property_dict["title"] = property["title"]
|
||||
return property_dict
|
||||
|
||||
else:
|
||||
@@ -59,6 +113,10 @@ def _convert_to_structured_output_helper(property: dict) -> dict:
|
||||
}
|
||||
if param_description is not None:
|
||||
property_dict["description"] = param_description
|
||||
# Preserve other fields
|
||||
for key in ["enum", "format", "pattern", "minLength", "maxLength", "minimum", "maximum", "default", "title"]:
|
||||
if key in property:
|
||||
property_dict[key] = property[key]
|
||||
return property_dict
|
||||
|
||||
|
||||
@@ -66,6 +124,14 @@ def convert_to_structured_output(openai_function: dict, allow_optional: bool = F
|
||||
"""Convert function call objects to structured output objects.
|
||||
|
||||
See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas
|
||||
|
||||
Supports:
|
||||
- Simple type arrays: type: ["string", "null"]
|
||||
- anyOf with primitives (flattened to type array)
|
||||
- anyOf with complex objects (preserved as anyOf)
|
||||
- Nested structures with recursion
|
||||
|
||||
For OpenAI strict mode, optional fields (not in required) must have explicit default values.
|
||||
"""
|
||||
description = openai_function.get("description", "")
|
||||
|
||||
@@ -82,57 +148,19 @@ def convert_to_structured_output(openai_function: dict, allow_optional: bool = F
|
||||
}
|
||||
|
||||
for param, details in openai_function["parameters"]["properties"].items():
|
||||
param_type = details["type"]
|
||||
param_description = details.get("description", "")
|
||||
|
||||
if param_type == "object":
|
||||
if "properties" not in details:
|
||||
raise ValueError(f"Property {param} of type object is missing 'properties'")
|
||||
structured_output["parameters"]["properties"][param] = {
|
||||
"type": "object",
|
||||
"description": param_description,
|
||||
"properties": {k: _convert_to_structured_output_helper(v) for k, v in details["properties"].items()},
|
||||
"additionalProperties": False,
|
||||
"required": list(details["properties"].keys()),
|
||||
}
|
||||
|
||||
elif param_type == "array":
|
||||
items_schema = details.get("items")
|
||||
prefix_items_schema = details.get("prefixItems")
|
||||
|
||||
if prefix_items_schema:
|
||||
# assume fixed-length tuple — safe fallback to use first type for items
|
||||
fallback_item = prefix_items_schema[0] if isinstance(prefix_items_schema, list) else prefix_items_schema
|
||||
structured_output["parameters"]["properties"][param] = {
|
||||
"type": "array",
|
||||
"description": param_description,
|
||||
"prefixItems": [_convert_to_structured_output_helper(item) for item in prefix_items_schema],
|
||||
"items": _convert_to_structured_output_helper(fallback_item),
|
||||
"minItems": details.get("minItems", len(prefix_items_schema)),
|
||||
"maxItems": details.get("maxItems", len(prefix_items_schema)),
|
||||
}
|
||||
elif items_schema:
|
||||
structured_output["parameters"]["properties"][param] = {
|
||||
"type": "array",
|
||||
"description": param_description,
|
||||
"items": _convert_to_structured_output_helper(items_schema),
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Array param '{param}' is missing both 'items' and 'prefixItems'")
|
||||
|
||||
else:
|
||||
prop = {
|
||||
"type": param_type,
|
||||
"description": param_description,
|
||||
}
|
||||
if "enum" in details:
|
||||
prop["enum"] = details["enum"]
|
||||
structured_output["parameters"]["properties"][param] = prop
|
||||
# Use the helper for all parameter types - it now handles anyOf, type arrays, objects, arrays, etc.
|
||||
structured_output["parameters"]["properties"][param] = _convert_to_structured_output_helper(details)
|
||||
|
||||
# Determine which fields are required
|
||||
# For OpenAI strict mode, ALL fields must be in the required array
|
||||
# This is a requirement for strict: true schemas
|
||||
if not allow_optional:
|
||||
# All fields are required for strict mode
|
||||
structured_output["parameters"]["required"] = list(structured_output["parameters"]["properties"].keys())
|
||||
else:
|
||||
raise NotImplementedError("Optional parameter handling is not implemented.")
|
||||
# Use the input's required list if provided, otherwise empty
|
||||
structured_output["parameters"]["required"] = openai_function["parameters"].get("required", [])
|
||||
|
||||
return structured_output
|
||||
|
||||
|
||||
|
||||
@@ -440,3 +440,361 @@ def test_mcp_schema_with_uuid_format_required_field():
|
||||
# Should be strict compliant
|
||||
status, _ = validate_complete_json_schema(strict_schema["parameters"])
|
||||
assert status == SchemaHealth.STRICT_COMPLIANT
|
||||
|
||||
|
||||
def test_mcp_schema_complex_nested_with_defs():
|
||||
"""Test generating exact schema with nested Pydantic-like models using $defs."""
|
||||
import json
|
||||
|
||||
from letta.functions.mcp_client.types import MCPToolHealth
|
||||
|
||||
mcp_tool = MCPTool(
|
||||
name="get_vehicle_configuration",
|
||||
description="Get vehicle configuration details for a given model type and optional dealer info and customization options.\n\nArgs:\n model_type (VehicleModel): The vehicle model type selection.\n dealer_location (str | None): Dealer location identifier from registration system, if available.\n customization_options (CustomizationData | None): Customization preferences for the vehicle from user selections, if available.\n\nReturns:\n str: The vehicle configuration details.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model_type": {
|
||||
"$ref": "#/$defs/VehicleModel",
|
||||
"description": "The vehicle model type selection.",
|
||||
"title": "Model Type",
|
||||
},
|
||||
"dealer_location": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Dealer location identifier from registration system, if available.",
|
||||
"title": "Dealer Location",
|
||||
},
|
||||
"customization_options": {
|
||||
"anyOf": [{"$ref": "#/$defs/CustomizationData"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Customization preferences for the vehicle from user selections, if available.",
|
||||
"title": "Customization Options",
|
||||
},
|
||||
},
|
||||
"required": ["model_type"],
|
||||
"additionalProperties": False,
|
||||
"$defs": {
|
||||
"VehicleModel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sedan",
|
||||
"suv",
|
||||
"truck",
|
||||
"coupe",
|
||||
"hatchback",
|
||||
"minivan",
|
||||
"wagon",
|
||||
"convertible",
|
||||
"sports",
|
||||
"luxury",
|
||||
"electric",
|
||||
"hybrid",
|
||||
"compact",
|
||||
"crossover",
|
||||
"other",
|
||||
"unknown",
|
||||
],
|
||||
"title": "VehicleModel",
|
||||
},
|
||||
"Feature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feature_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Feature ID",
|
||||
},
|
||||
"category_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Category Code",
|
||||
},
|
||||
"variant_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Variant Code",
|
||||
},
|
||||
"package_level": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Package Level",
|
||||
},
|
||||
},
|
||||
"title": "Feature",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"CustomizationData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"has_premium_package": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Premium Package",
|
||||
},
|
||||
"has_multiple_trims": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Multiple Trims",
|
||||
},
|
||||
"selected_features": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"$ref": "#/$defs/Feature"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"title": "Selected Features",
|
||||
},
|
||||
},
|
||||
"title": "CustomizationData",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
# Initialize health status to simulate what happens in the server
|
||||
mcp_tool.health = MCPToolHealth(status=SchemaHealth.STRICT_COMPLIANT.value, reasons=[])
|
||||
|
||||
# Generate schema with heartbeat
|
||||
schema = generate_tool_schema_for_mcp(mcp_tool, append_heartbeat=True, strict=False)
|
||||
|
||||
# Add metadata fields (these are normally added by ToolCreate.from_mcp)
|
||||
from letta.schemas.tool import MCP_TOOL_METADATA_SCHEMA_STATUS, MCP_TOOL_METADATA_SCHEMA_WARNINGS
|
||||
|
||||
schema[MCP_TOOL_METADATA_SCHEMA_STATUS] = mcp_tool.health.status
|
||||
schema[MCP_TOOL_METADATA_SCHEMA_WARNINGS] = mcp_tool.health.reasons
|
||||
|
||||
# Expected schema
|
||||
expected_schema = {
|
||||
"name": "get_vehicle_configuration",
|
||||
"description": "Get vehicle configuration details for a given model type and optional dealer info and customization options.\n\nArgs:\n model_type (VehicleModel): The vehicle model type selection.\n dealer_location (str | None): Dealer location identifier from registration system, if available.\n customization_options (CustomizationData | None): Customization preferences for the vehicle from user selections, if available.\n\nReturns:\n str: The vehicle configuration details.",
|
||||
"parameters": {
|
||||
"$defs": {
|
||||
"Feature": {
|
||||
"properties": {
|
||||
"feature_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Feature ID",
|
||||
},
|
||||
"category_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Category Code",
|
||||
},
|
||||
"variant_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Variant Code",
|
||||
},
|
||||
"package_level": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Package Level",
|
||||
},
|
||||
},
|
||||
"title": "Feature",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"CustomizationData": {
|
||||
"properties": {
|
||||
"has_premium_package": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Premium Package",
|
||||
},
|
||||
"has_multiple_trims": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Multiple Trims",
|
||||
},
|
||||
"selected_features": {
|
||||
"anyOf": [
|
||||
{"items": {"$ref": "#/$defs/Feature"}, "type": "array"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"title": "Selected Features",
|
||||
},
|
||||
},
|
||||
"title": "CustomizationData",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"VehicleModel": {
|
||||
"enum": [
|
||||
"sedan",
|
||||
"suv",
|
||||
"truck",
|
||||
"coupe",
|
||||
"hatchback",
|
||||
"minivan",
|
||||
"wagon",
|
||||
"convertible",
|
||||
"sports",
|
||||
"luxury",
|
||||
"electric",
|
||||
"hybrid",
|
||||
"compact",
|
||||
"crossover",
|
||||
"other",
|
||||
"unknown",
|
||||
],
|
||||
"title": "VehicleModel",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"properties": {
|
||||
"model_type": {
|
||||
"$ref": "#/$defs/VehicleModel",
|
||||
"description": "The vehicle model type selection.",
|
||||
"title": "Model Type",
|
||||
"type": "string",
|
||||
},
|
||||
"dealer_location": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Dealer location identifier from registration system, if available.",
|
||||
"title": "Dealer Location",
|
||||
},
|
||||
"customization_options": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"title": "CustomizationData",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"has_premium_package": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Premium Package",
|
||||
},
|
||||
"has_multiple_trims": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Multiple Trims",
|
||||
},
|
||||
"selected_features": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"title": "Feature",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"feature_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Feature ID",
|
||||
},
|
||||
"category_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Category Code",
|
||||
},
|
||||
"variant_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Variant Code",
|
||||
},
|
||||
"package_level": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Package Level",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"title": "Selected Features",
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"description": "Customization preferences for the vehicle from user selections, if available.",
|
||||
"title": "Customization Options",
|
||||
},
|
||||
"request_heartbeat": {
|
||||
"type": "boolean",
|
||||
"description": "Request an immediate heartbeat after function execution. You MUST set this value to `True` if you want to send a follow-up message or run a follow-up tool call (chain multiple tools together). If set to `False` (the default), then the chain of execution will end immediately after this function call.",
|
||||
},
|
||||
},
|
||||
"required": ["model_type", "request_heartbeat"],
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"mcp:SCHEMA_STATUS": "STRICT_COMPLIANT",
|
||||
"mcp:SCHEMA_WARNINGS": [],
|
||||
}
|
||||
|
||||
# Compare key components
|
||||
assert schema["name"] == expected_schema["name"]
|
||||
assert schema["description"] == expected_schema["description"]
|
||||
assert schema["parameters"]["type"] == expected_schema["parameters"]["type"]
|
||||
assert schema["parameters"]["additionalProperties"] == expected_schema["parameters"]["additionalProperties"]
|
||||
assert set(schema["parameters"]["required"]) == set(expected_schema["parameters"]["required"])
|
||||
|
||||
# Check $defs
|
||||
assert "$defs" in schema["parameters"]
|
||||
assert set(schema["parameters"]["$defs"].keys()) == set(expected_schema["parameters"]["$defs"].keys())
|
||||
|
||||
# Check properties
|
||||
assert "model_type" in schema["parameters"]["properties"]
|
||||
assert "dealer_location" in schema["parameters"]["properties"]
|
||||
assert "customization_options" in schema["parameters"]["properties"]
|
||||
assert "request_heartbeat" in schema["parameters"]["properties"]
|
||||
|
||||
# Verify model_type property ($ref is inlined)
|
||||
model_prop = schema["parameters"]["properties"]["model_type"]
|
||||
assert model_prop["type"] == "string"
|
||||
assert "enum" in model_prop, "$ref should be inlined with enum values"
|
||||
assert model_prop["description"] == "The vehicle model type selection."
|
||||
|
||||
# Verify dealer_location property (anyOf preserved)
|
||||
dl_prop = schema["parameters"]["properties"]["dealer_location"]
|
||||
assert "anyOf" in dl_prop, "anyOf should be preserved for optional primitives"
|
||||
assert len(dl_prop["anyOf"]) == 2
|
||||
types_in_anyof = {opt.get("type") for opt in dl_prop["anyOf"]}
|
||||
assert types_in_anyof == {"string", "null"}
|
||||
assert dl_prop["description"] == "Dealer location identifier from registration system, if available."
|
||||
|
||||
# Verify customization_options property (anyOf with fully inlined $refs)
|
||||
co_prop = schema["parameters"]["properties"]["customization_options"]
|
||||
assert "anyOf" in co_prop, "Should use anyOf structure"
|
||||
assert len(co_prop["anyOf"]) == 2, "Should have object and null options"
|
||||
|
||||
# Find the object option in anyOf
|
||||
object_option = next((opt for opt in co_prop["anyOf"] if opt.get("type") == "object"), None)
|
||||
assert object_option is not None, "Should have object type in anyOf"
|
||||
assert object_option["additionalProperties"] is False, "Object must have additionalProperties: false"
|
||||
assert "properties" in object_option, "$ref should be fully inlined with properties"
|
||||
|
||||
# Verify the inlined properties are present
|
||||
assert "has_premium_package" in object_option["properties"]
|
||||
assert "has_multiple_trims" in object_option["properties"]
|
||||
assert "selected_features" in object_option["properties"]
|
||||
|
||||
# Verify nested selected_features array has inlined Feature objects
|
||||
features_prop = object_option["properties"]["selected_features"]
|
||||
assert "anyOf" in features_prop, "selected_features should have anyOf"
|
||||
array_option = next((opt for opt in features_prop["anyOf"] if opt.get("type") == "array"), None)
|
||||
assert array_option is not None
|
||||
assert "items" in array_option
|
||||
assert array_option["items"]["type"] == "object"
|
||||
assert array_option["items"]["additionalProperties"] is False
|
||||
assert "feature_id" in array_option["items"]["properties"]
|
||||
assert "category_code" in array_option["items"]["properties"]
|
||||
|
||||
# Verify metadata fields
|
||||
assert schema[MCP_TOOL_METADATA_SCHEMA_STATUS] == "STRICT_COMPLIANT"
|
||||
assert schema[MCP_TOOL_METADATA_SCHEMA_WARNINGS] == []
|
||||
|
||||
# Should be strict compliant
|
||||
status, _ = validate_complete_json_schema(schema["parameters"])
|
||||
assert status == SchemaHealth.STRICT_COMPLIANT
|
||||
|
||||
@@ -455,3 +455,208 @@ def missing_param_doc(x: int, y: int) -> str:
|
||||
)
|
||||
def test_google_style_docstring_validation(fn, regex):
|
||||
_check(fn, regex)
|
||||
|
||||
|
||||
def test_complex_nested_anyof_schema_to_structured_output():
|
||||
"""Test that complex nested anyOf schemas with inlined $refs can be converted to structured outputs.
|
||||
|
||||
This test verifies that convert_to_structured_output properly handles:
|
||||
- Simple anyOf (primitives) - flattened to type arrays
|
||||
- Complex anyOf (with objects) - preserved as anyOf
|
||||
- Nested structures with recursion
|
||||
"""
|
||||
|
||||
# This is the schema generated by our anyOf inlining approach for MCP tools
|
||||
# It uses anyOf throughout and has fully inlined $refs
|
||||
schema = {
|
||||
"name": "get_vehicle_configuration",
|
||||
"description": "Get vehicle configuration details for a given model type and optional dealer info and customization options.",
|
||||
"parameters": {
|
||||
"$defs": {
|
||||
"Feature": {
|
||||
"properties": {
|
||||
"feature_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Feature ID"},
|
||||
"category_code": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": None, "title": "Category Code"},
|
||||
"variant_code": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": None, "title": "Variant Code"},
|
||||
"package_level": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": None, "title": "Package Level"},
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Feature",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"CustomizationData": {
|
||||
"properties": {
|
||||
"has_premium_package": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Premium Package",
|
||||
},
|
||||
"has_multiple_trims": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Multiple Trims",
|
||||
},
|
||||
"selected_features": {
|
||||
"anyOf": [{"items": {"$ref": "#/$defs/Feature"}, "type": "array"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Selected Features",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
"title": "CustomizationData",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"VehicleModel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sedan",
|
||||
"suv",
|
||||
"truck",
|
||||
"coupe",
|
||||
"hatchback",
|
||||
"minivan",
|
||||
"wagon",
|
||||
"convertible",
|
||||
"sports",
|
||||
"luxury",
|
||||
"electric",
|
||||
"hybrid",
|
||||
"compact",
|
||||
"crossover",
|
||||
"other",
|
||||
"unknown",
|
||||
],
|
||||
"title": "VehicleModel",
|
||||
},
|
||||
},
|
||||
"properties": {
|
||||
"model_type": {
|
||||
"description": "The vehicle model type selection.",
|
||||
"title": "Model Type",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sedan",
|
||||
"suv",
|
||||
"truck",
|
||||
"coupe",
|
||||
"hatchback",
|
||||
"minivan",
|
||||
"wagon",
|
||||
"convertible",
|
||||
"sports",
|
||||
"luxury",
|
||||
"electric",
|
||||
"hybrid",
|
||||
"compact",
|
||||
"crossover",
|
||||
"other",
|
||||
"unknown",
|
||||
],
|
||||
},
|
||||
"dealer_location": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Dealer location identifier from registration system, if available.",
|
||||
"title": "Dealer Location",
|
||||
},
|
||||
"customization_options": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"has_premium_package": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Premium Package",
|
||||
},
|
||||
"has_multiple_trims": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Has Multiple Trims",
|
||||
},
|
||||
"selected_features": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"properties": {
|
||||
"feature_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Feature ID",
|
||||
},
|
||||
"category_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Category Code",
|
||||
},
|
||||
"variant_code": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Variant Code",
|
||||
},
|
||||
"package_level": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Package Level",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Feature",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"title": "Selected Features",
|
||||
},
|
||||
},
|
||||
"title": "CustomizationData",
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"description": "Customization preferences for the vehicle from user selections, if available.",
|
||||
"title": "Customization Options",
|
||||
},
|
||||
"request_heartbeat": {"type": "boolean", "description": "Request an immediate heartbeat after function execution."},
|
||||
},
|
||||
"required": ["model_type", "request_heartbeat"],
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
# Attempt to convert to structured output
|
||||
# This should succeed if the schema is properly formatted for OpenAI
|
||||
try:
|
||||
structured_output = convert_to_structured_output(schema)
|
||||
|
||||
# Verify the conversion succeeded and returned a valid schema
|
||||
assert "name" in structured_output
|
||||
assert "parameters" in structured_output
|
||||
assert "strict" in structured_output
|
||||
assert structured_output["strict"] is True
|
||||
|
||||
# Verify properties are preserved
|
||||
assert "model_type" in structured_output["parameters"]["properties"]
|
||||
assert "dealer_location" in structured_output["parameters"]["properties"]
|
||||
assert "customization_options" in structured_output["parameters"]["properties"]
|
||||
assert "request_heartbeat" in structured_output["parameters"]["properties"]
|
||||
|
||||
# Verify required fields
|
||||
# For strict mode, ALL fields must be required (OpenAI requirement)
|
||||
assert set(structured_output["parameters"]["required"]) == {
|
||||
"model_type",
|
||||
"dealer_location",
|
||||
"customization_options",
|
||||
"request_heartbeat",
|
||||
}
|
||||
|
||||
print("✅ Complex nested anyOf schema successfully converted to structured output")
|
||||
print(json.dumps(structured_output, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to convert complex nested anyOf schema to structured output: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user