mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
Use OpenAPI 3.1.0 format for cloud (#182768)
This commit is contained in:
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user