Use OpenAPI 3.1.0 format for cloud (#182768)

This commit is contained in:
Denis Shulyaka
2026-09-30 12:47:05 +01:00
committed by GitHub
parent 53b324f052
commit 433be108e9
3 changed files with 525 additions and 4 deletions
+14 -4
View File
@@ -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)
@@ -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',
})
# ---
+119
View File
@@ -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: