diff --git a/letta/functions/schema_generator.py b/letta/functions/schema_generator.py index 0ff9809c..b1d8e9e6 100644 --- a/letta/functions/schema_generator.py +++ b/letta/functions/schema_generator.py @@ -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"]: diff --git a/letta/llm_api/helpers.py b/letta/llm_api/helpers.py index c87ec188..fa7a369a 100644 --- a/letta/llm_api/helpers.py +++ b/letta/llm_api/helpers.py @@ -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 diff --git a/tests/mcp_tests/test_mcp_schema_validation.py b/tests/mcp_tests/test_mcp_schema_validation.py index 17470323..c630d689 100644 --- a/tests/mcp_tests/test_mcp_schema_validation.py +++ b/tests/mcp_tests/test_mcp_schema_validation.py @@ -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 diff --git a/tests/test_tool_schema_parsing.py b/tests/test_tool_schema_parsing.py index 9a49066d..e870c782 100644 --- a/tests/test_tool_schema_parsing.py +++ b/tests/test_tool_schema_parsing.py @@ -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)}")