From 433be108e936ee9fd0fecc3f2cc5acb46d0e1790 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 30 Sep 2026 14:47:05 +0300 Subject: [PATCH] Use OpenAPI 3.1.0 format for cloud (#182768) --- homeassistant/components/cloud/entity.py | 18 +- .../cloud/snapshots/test_entity.ambr | 392 ++++++++++++++++++ tests/components/cloud/test_entity.py | 119 ++++++ 3 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 tests/components/cloud/snapshots/test_entity.ambr diff --git a/homeassistant/components/cloud/entity.py b/homeassistant/components/cloud/entity.py index ab24892a9056..98f519b4c718 100644 --- a/homeassistant/components/cloud/entity.py +++ b/homeassistant/components/cloud/entity.py @@ -173,7 +173,9 @@ def _format_tool( ) -> ToolParam: """Format a Home Assistant tool for the OpenAI Responses API.""" parameters = probatio.to_openapi( - tool.parameters, custom_serializer=custom_serializer + tool.parameters, + custom_serializer=custom_serializer, + openapi_version="3.1.0", ) spec: FunctionToolParam = { @@ -221,6 +223,7 @@ def _format_structured_output( custom_serializer=( llm_api.custom_serializer if llm_api else llm.selector_serializer ), + openapi_version="3.1.0", ) _ensure_schema_constraints(result) @@ -230,16 +233,23 @@ def _format_structured_output( def _ensure_schema_constraints(schema: dict[str, Any]) -> None: """Ensure generated schemas match the Responses API expectations.""" - schema_type = schema.get("type") + for keyword in ("anyOf", "oneOf", "allOf", "prefixItems"): + if isinstance(branches := schema.get(keyword), list): + for branch in branches: + if isinstance(branch, dict): + _ensure_schema_constraints(branch) - if schema_type == "object": + schema_type = schema.get("type") + schema_types = schema_type if isinstance(schema_type, list) else [schema_type] + + if "object" in schema_types: schema.setdefault("additionalProperties", False) properties = schema.get("properties") if isinstance(properties, dict): for property_schema in properties.values(): if isinstance(property_schema, dict): _ensure_schema_constraints(property_schema) - elif schema_type == "array": + if "array" in schema_types: items = schema.get("items") if isinstance(items, dict): _ensure_schema_constraints(items) diff --git a/tests/components/cloud/snapshots/test_entity.ambr b/tests/components/cloud/snapshots/test_entity.ambr new file mode 100644 index 000000000000..63dfa83c57e8 --- /dev/null +++ b/tests/components/cloud/snapshots/test_entity.ambr @@ -0,0 +1,392 @@ +# serializer version: 1 +# name: test_format_structured_output_nested_objects[all-of] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'value': dict({ + 'allOf': list([ + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'name': dict({ + 'type': 'string', + }), + }), + 'type': 'object', + }), + dict({ + 'additionalProperties': True, + 'type': 'object', + }), + ]), + }), + }), + 'required': list([ + 'value', + ]), + 'type': 'object', + }) +# --- +# name: test_format_structured_output_nested_objects[any-of] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'value': dict({ + 'anyOf': list([ + dict({ + 'type': 'null', + }), + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'name': dict({ + 'type': 'string', + }), + }), + 'type': 'object', + }), + ]), + }), + }), + 'required': list([ + 'value', + ]), + 'type': 'object', + }) +# --- +# name: test_format_structured_output_nested_objects[nullable-prefix-items] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'value': dict({ + 'anyOf': list([ + dict({ + 'type': 'null', + }), + dict({ + 'items': False, + 'prefixItems': list([ + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'name': dict({ + 'type': 'string', + }), + }), + 'type': 'object', + }), + ]), + 'type': 'array', + }), + ]), + }), + }), + 'required': list([ + 'value', + ]), + 'type': 'object', + }) +# --- +# name: test_format_structured_output_nested_objects[one-of] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'value': dict({ + 'oneOf': list([ + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'name': dict({ + 'type': 'string', + }), + }), + 'type': 'object', + }), + dict({ + 'type': 'string', + }), + ]), + }), + }), + 'required': list([ + 'value', + ]), + 'type': 'object', + }) +# --- +# name: test_format_structured_output_nested_objects[prefix-items] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'value': dict({ + 'items': False, + 'prefixItems': list([ + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'name': dict({ + 'type': 'string', + }), + }), + 'type': 'object', + }), + ]), + 'type': 'array', + }), + }), + 'required': list([ + 'value', + ]), + 'type': 'object', + }) +# --- +# name: test_format_structured_output_openapi_31[exclusive-bounds] + dict({ + 'exclusiveMaximum': 10, + 'exclusiveMinimum': 0, + 'type': 'number', + }) +# --- +# name: test_format_structured_output_openapi_31[null] + dict({ + 'type': 'null', + }) +# --- +# name: test_format_structured_output_openapi_31[nullable-enum] + dict({ + 'enum': list([ + 'on', + 'off', + None, + ]), + 'type': list([ + 'string', + 'null', + ]), + }) +# --- +# name: test_format_structured_output_openapi_31[nullable-string] + dict({ + 'anyOf': list([ + dict({ + 'type': 'null', + }), + dict({ + 'type': 'string', + }), + ]), + }) +# --- +# name: test_format_structured_output_openapi_31[prefix-items] + dict({ + 'items': False, + 'prefixItems': list([ + dict({ + 'type': 'string', + }), + dict({ + 'type': 'integer', + }), + ]), + 'type': 'array', + }) +# --- +# name: test_format_structured_output_type_arrays[null-first] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'mapping': dict({ + 'additionalProperties': True, + 'type': list([ + 'null', + 'object', + ]), + }), + 'objects': dict({ + 'items': dict({ + 'additionalProperties': False, + 'type': list([ + 'null', + 'object', + ]), + }), + 'type': list([ + 'null', + 'array', + ]), + }), + 'untyped': dict({ + }), + 'value': dict({ + 'type': list([ + 'string', + 'null', + ]), + }), + }), + 'type': list([ + 'null', + 'object', + ]), + }) +# --- +# name: test_format_structured_output_type_arrays[nullable-types] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'mapping': dict({ + 'additionalProperties': True, + 'type': list([ + 'object', + 'null', + ]), + }), + 'objects': dict({ + 'items': dict({ + 'additionalProperties': False, + 'type': list([ + 'object', + 'null', + ]), + }), + 'type': list([ + 'array', + 'null', + ]), + }), + 'untyped': dict({ + }), + 'value': dict({ + 'type': list([ + 'string', + 'null', + ]), + }), + }), + 'type': list([ + 'object', + 'null', + ]), + }) +# --- +# name: test_format_structured_output_type_arrays[object-array-union] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'mapping': dict({ + 'additionalProperties': True, + 'type': 'object', + }), + 'objects': dict({ + 'additionalProperties': False, + 'items': dict({ + 'additionalProperties': False, + 'type': 'object', + }), + 'type': list([ + 'object', + 'array', + ]), + }), + 'untyped': dict({ + }), + 'value': dict({ + 'type': list([ + 'string', + 'null', + ]), + }), + }), + 'type': 'object', + }) +# --- +# name: test_format_structured_output_type_arrays[single-type-arrays] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'mapping': dict({ + 'additionalProperties': True, + 'type': list([ + 'object', + ]), + }), + 'objects': dict({ + 'items': dict({ + 'additionalProperties': False, + 'type': list([ + 'object', + ]), + }), + 'type': list([ + 'array', + ]), + }), + 'untyped': dict({ + }), + 'value': dict({ + 'type': list([ + 'string', + 'null', + ]), + }), + }), + 'type': list([ + 'object', + ]), + }) +# --- +# name: test_format_structured_output_type_arrays[single-types] + dict({ + 'additionalProperties': False, + 'properties': dict({ + 'mapping': dict({ + 'additionalProperties': True, + 'type': 'object', + }), + 'objects': dict({ + 'items': dict({ + 'additionalProperties': False, + 'type': 'object', + }), + 'type': 'array', + }), + 'untyped': dict({ + }), + 'value': dict({ + 'type': list([ + 'string', + 'null', + ]), + }), + }), + 'type': 'object', + }) +# --- +# name: test_format_tool_openapi_31 + dict({ + 'description': 'Test tool', + 'name': 'do_something', + 'parameters': dict({ + 'additionalProperties': False, + 'properties': dict({ + 'value': dict({ + 'enum': list([ + 'on', + 'off', + None, + ]), + 'type': list([ + 'string', + 'null', + ]), + }), + }), + 'required': list([ + 'value', + ]), + 'type': 'object', + }), + 'strict': False, + 'type': 'function', + }) +# --- diff --git a/tests/components/cloud/test_entity.py b/tests/components/cloud/test_entity.py index c9b9362ade86..0a188baa706a 100644 --- a/tests/components/cloud/test_entity.py +++ b/tests/components/cloud/test_entity.py @@ -1,6 +1,7 @@ """Tests for helpers in the Home Assistant Cloud conversation entity.""" import base64 +from collections.abc import Callable import datetime from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -8,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from PIL import Image import probatio import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.cloud.const import AI_TASK_ENTITY_UNIQUE_ID, DOMAIN @@ -15,6 +17,7 @@ from homeassistant.components.cloud.entity import ( BaseCloudLLMEntity, _convert_content_to_param, _format_structured_output, + _format_tool, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -64,6 +67,16 @@ class DummyTool(llm.Tool): return {"value": "done"} +def test_format_tool_openapi_31(snapshot: SnapshotAssertion) -> None: + """Test that tool parameters use OpenAPI 3.1 nullable types.""" + tool = DummyTool() + tool.parameters = probatio.Schema( + {probatio.Required("value"): probatio.In(["on", "off", None])} + ) + + assert _format_tool(tool, None) == snapshot + + async def test_format_structured_output() -> None: """Test that structured output schemas are normalized.""" schema = probatio.Schema( @@ -106,6 +119,112 @@ async def test_format_structured_output() -> None: } +@pytest.mark.parametrize( + ("object_type", "array_type"), + [ + pytest.param("object", "array", id="single-types"), + pytest.param(["object", "null"], ["array", "null"], id="nullable-types"), + pytest.param(["null", "object"], ["null", "array"], id="null-first"), + pytest.param(["object"], ["array"], id="single-type-arrays"), + pytest.param("object", ["object", "array"], id="object-array-union"), + ], +) +def test_format_structured_output_type_arrays( + object_type: str | list[str], + array_type: str | list[str], + snapshot: SnapshotAssertion, +) -> None: + """Test constraints on object and array types from a custom serializer.""" + schema = { + "type": object_type, + "properties": { + "objects": { + "type": array_type, + "items": {"type": object_type}, + }, + "mapping": {"type": object_type, "additionalProperties": True}, + "value": {"type": ["string", "null"]}, + "untyped": {}, + }, + } + llm_api = MagicMock( + spec=llm.APIInstance, + custom_serializer=MagicMock(return_value=schema), + ) + + assert _format_structured_output(probatio.Schema(dict), llm_api) == snapshot + + +@pytest.mark.parametrize( + "schema", + [ + pytest.param(probatio.Schema(None), id="null"), + pytest.param(probatio.Schema(probatio.Maybe(str)), id="nullable-string"), + pytest.param( + probatio.Schema(probatio.In(["on", "off", None])), id="nullable-enum" + ), + pytest.param( + probatio.Schema( + probatio.All( + float, + probatio.Range( + min=0, max=10, min_included=False, max_included=False + ), + ) + ), + id="exclusive-bounds", + ), + pytest.param( + probatio.Schema(probatio.ExactSequence([str, int])), id="prefix-items" + ), + ], +) +def test_format_structured_output_openapi_31( + schema: probatio.Schema, snapshot: SnapshotAssertion +) -> None: + """Test version-specific schema conversion using OpenAPI 3.1.""" + assert _format_structured_output(schema, None) == snapshot + + +@pytest.mark.parametrize( + "schema_factory", + [ + pytest.param(probatio.Maybe, id="any-of"), + pytest.param( + lambda schema: probatio.SomeOf([schema, str], min_valid=1, max_valid=1), + id="one-of", + ), + pytest.param( + lambda schema: probatio.SomeOf([schema, dict], min_valid=2, max_valid=2), + id="all-of", + ), + pytest.param( + lambda schema: probatio.ExactSequence([schema]), id="prefix-items" + ), + pytest.param( + lambda schema: probatio.Maybe(probatio.ExactSequence([schema])), + id="nullable-prefix-items", + ), + ], +) +def test_format_structured_output_nested_objects( + schema_factory: Callable[[selector.ObjectSelector], object], + snapshot: SnapshotAssertion, +) -> None: + """Test constraints on objects nested in composition branches and tuples.""" + schema = probatio.Schema( + { + probatio.Required("value"): schema_factory( + selector.ObjectSelector( + {"fields": {"name": {"selector": {"text": None}}}} + ) + ) + } + ) + + assert _format_structured_output(schema, None) == snapshot + + async def test_prepare_files_for_prompt( cloud_entity: BaseCloudLLMEntity, tmp_path: Path ) -> None: