Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/cache-apt-packages/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ runs:
shell: bash
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
- name: Install apt packages via cache
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 # v1.5.3
with:
packages: ${{ inputs.packages }}
version: ${{ inputs.version }}
Expand Down
28 changes: 24 additions & 4 deletions homeassistant/components/humidifier/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@
from homeassistant.components.llm import LLMTools
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from homeassistant.helpers.llm import (
LLM_API_ASSIST,
IntentTool,
LLMContext,
Tool,
ToolAnnotations,
)

from .const import DOMAIN
from .intent import INTENT_HUMIDITY, INTENT_MODE

# Intents owned by this integration that are exposed as LLM tools.
LLM_INTENTS = (INTENT_MODE, INTENT_HUMIDITY)
# Each intent sets a value on the user's own entities, so calling one again
# with the same arguments has no further effect.
LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False)

# Intents owned by this integration that are exposed as LLM tools, with the
# title shown for each.
LLM_INTENTS = {
INTENT_MODE: "Set humidifier mode",
INTENT_HUMIDITY: "Set humidity",
}


@callback
Expand All @@ -28,7 +42,13 @@ def async_get_tools(
return None

tools: list[Tool] = [
IntentTool(f"{DOMAIN}__{handler.intent_type}", handler)
IntentTool(
f"{DOMAIN}__{handler.intent_type}",
handler,
title=LLM_INTENTS[handler.intent_type],
integration=DOMAIN,
annotations=LLM_ANNOTATIONS,
)
for handler in intent.async_get(hass)
if handler.intent_type in LLM_INTENTS
]
Expand Down
39 changes: 7 additions & 32 deletions homeassistant/components/openai_conversation/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS,
UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS,
)
from .schema import adjust_schema

if TYPE_CHECKING:
from . import OpenAIConfigEntry
Expand All @@ -115,31 +116,6 @@
MAX_TOOL_ITERATIONS = 10


def _adjust_schema(schema: dict[str, Any]) -> None:
"""Adjust the output schema to be compatible with OpenAI API."""
if schema["type"] == "object":
schema.setdefault("strict", True)
schema.setdefault("additionalProperties", False)
if "properties" not in schema:
return

if "required" not in schema:
schema["required"] = []

# Ensure all properties are required
for prop, prop_info in schema["properties"].items():
_adjust_schema(prop_info)
if prop not in schema["required"]:
prop_info["type"] = [prop_info["type"], "null"]
schema["required"].append(prop)

elif schema["type"] == "array":
if "items" not in schema:
return

_adjust_schema(schema["items"])


def _format_structured_output(
schema: probatio.Schema, llm_api: llm.APIInstance | None
) -> dict[str, Any]:
Expand All @@ -152,7 +128,7 @@ def _format_structured_output(
openapi_version="3.1.0",
)

_adjust_schema(result)
adjust_schema(result)

return result

Expand Down Expand Up @@ -674,12 +650,11 @@ async def _async_handle_chat_log( # noqa: C901
]

if structure and structure_name:
model_args["text"] = {
"format": {
"type": "json_schema",
"name": slugify(structure_name),
"schema": _format_structured_output(structure, chat_log.llm_api),
},
model_args.setdefault("text", {})["format"] = {
"type": "json_schema",
"name": slugify(structure_name),
"schema": _format_structured_output(structure, chat_log.llm_api),
"strict": True,
}

client = self.entry.runtime_data
Expand Down
263 changes: 263 additions & 0 deletions homeassistant/components/openai_conversation/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
"""Convert output schemas to OpenAI's supported JSON Schema subset for strict structured output format."""
# Documentation: https://developers.openai.com/api/docs/guides/structured-outputs?api-mode=responses#supported-schemas

from collections.abc import Iterator
from copy import deepcopy
import logging
from typing import Any
from urllib.parse import unquote

from homeassistant.exceptions import HomeAssistantError

_LOGGER = logging.getLogger(__name__)

_ANNOTATIONS = {
"default",
"examples",
"$comment",
"deprecated",
"readOnly",
"writeOnly",
}
_UNSUPPORTED_KEYWORDS = {
"not",
"dependentRequired",
"dependentSchemas",
"if",
"then",
"else",
}
_SELECTOR_FORMATS = {"entity_id", "jinja2", "RFC 5646", "ISO 3166-1 alpha-2", "RGB"}
_SCHEMA_MAPS = (
"properties",
"$defs",
"definitions",
"patternProperties",
"dependentSchemas",
)
_SCHEMA_LISTS = ("anyOf", "oneOf", "allOf", "prefixItems")
_SCHEMA_VALUES = (
"items",
"contains",
"additionalProperties",
"propertyNames",
"not",
"if",
"then",
"else",
)


def adjust_schema(schema: dict[str, Any]) -> None:
"""Normalize known incompatibilities, preserving unfamiliar API features."""
_stabilize_references(schema)
_adjust_schema(schema, "$")
if schema.get("type") != "object" or "anyOf" in schema:
raise HomeAssistantError("OpenAI structured output requires an object root")


def _walk_schemas(schema: dict[str, Any]) -> Iterator[dict[str, Any]]:
"""Walk schema locations without interpreting examples or literal data as schemas."""
yield schema
for keyword in _SCHEMA_MAPS:
for child in schema.get(keyword, {}).values():
if isinstance(child, dict):
yield from _walk_schemas(child)
for keyword in _SCHEMA_LISTS:
for child in schema.get(keyword, []):
if isinstance(child, dict):
yield from _walk_schemas(child)
for keyword in _SCHEMA_VALUES:
if isinstance(child := schema.get(keyword), dict):
yield from _walk_schemas(child)


def _stabilize_references(schema: dict[str, Any]) -> None:
"""Move reference targets to root definitions before their paths can change."""
references = {node["$ref"] for node in _walk_schemas(schema) if "$ref" in node}
replacements: dict[str, str] = {}
definitions: dict[str, Any] = {}
existing_names = set(schema.get("$defs", {}))
for reference in sorted(references):
target = _resolve_reference(reference, schema)
parts = _reference_parts(reference)
if not parts or (len(parts) == 2 and parts[0] == "$defs"):
replacements[reference] = reference
continue
name = f"_ha_ref_{len(definitions)}"
while name in existing_names:
name = f"_{name}"
existing_names.add(name)
definitions[name] = deepcopy(target)
replacements[reference] = f"#/$defs/{name}"

if definitions:
schema.setdefault("$defs", {}).update(definitions)
# Legacy definitions have been copied to $defs wherever they are referenced.
for node in _walk_schemas(schema):
node.pop("definitions", None)
if "$ref" in node:
node["$ref"] = replacements[node["$ref"]]


def _reference_parts(reference: str) -> list[str]:
"""Decode a local JSON Pointer, including URI fragment escaping."""
if reference == "#":
return []
if not reference.startswith("#/"):
raise HomeAssistantError(
f"Unsupported OpenAI output schema reference: {reference}"
)
return [
part.replace("~1", "/").replace("~0", "~")
for part in unquote(reference[2:]).split("/")
]


def _resolve_reference(reference: str, root: dict[str, Any]) -> dict[str, Any]:
"""Resolve local references through both objects and arrays."""
target: Any = root
try:
for part in _reference_parts(reference):
if isinstance(target, list):
target = target[_array_index(part)]
else:
target = target[part]
except (KeyError, IndexError, TypeError, ValueError) as err:
raise HomeAssistantError(
f"Invalid OpenAI output schema reference: {reference}"
) from err
if not isinstance(target, dict):
raise HomeAssistantError(
f"Unsupported OpenAI output schema reference: {reference}"
)
return target


def _array_index(value: str) -> int:
"""Parse the array-index form of a JSON Pointer token."""
if (
not value.isascii()
or not value.isdecimal()
or (len(value) > 1 and value[0] == "0")
):
raise ValueError("Invalid array index")
return int(value)


def _flatten_all_of(schema: dict[str, Any], path: str) -> None:
"""Unwrap intersections only when sibling constraints can all be retained."""
while "allOf" in schema:
branches = schema["allOf"]
if len(branches) != 1 or not isinstance(branches[0], dict):
raise HomeAssistantError(
f"Unsupported OpenAI output schema allOf at {path}"
)
branch = branches[0]
siblings = schema.keys() - {"allOf", "description", "title"} - _ANNOTATIONS
conflicts = {
key for key in siblings & branch.keys() if schema[key] != branch[key]
}
if conflicts:
raise HomeAssistantError(
f"Conflicting OpenAI output schema allOf at {path}: {', '.join(sorted(conflicts))}"
)
del schema["allOf"]
for key, value in branch.items():
schema.setdefault(key, value)


def _adjust_reference(schema: dict[str, Any], path: str, *, nullable: bool) -> None:
"""Keep references bare and preserve annotations on nullable wrappers."""
if siblings := schema.keys() - {"$ref", "title", "description"}:
raise HomeAssistantError(
f"Unsupported OpenAI output schema reference siblings at {path}: {', '.join(sorted(siblings))}"
)
annotations: dict[str, Any] = {
keyword: schema.pop(keyword)
for keyword in ("title", "description")
if keyword in schema
}
if nullable:
_make_nullable(schema)
schema.update(annotations)
elif annotations:
_LOGGER.debug(
"Removed reference annotations %s from OpenAI output schema at %s",
", ".join(annotations),
path,
)


def _adjust_schema(
schema: dict[str, Any] | bool, path: str, *, nullable: bool = False
) -> None:
"""Normalize nested schemas and keep unsupported enforcement out of requests."""
if not isinstance(schema, dict):
raise HomeAssistantError(f"Unsupported OpenAI output schema at {path}")
_flatten_all_of(schema, path)
for keyword in _ANNOTATIONS:
schema.pop(keyword, None)
if unsupported := schema.keys() & _UNSUPPORTED_KEYWORDS:
raise HomeAssistantError(
f"Unsupported OpenAI output schema keywords at {path}: {', '.join(sorted(unsupported))}"
)
if not schema:
raise HomeAssistantError(f"Unsupported OpenAI output schema at {path}")
if schema.get("format") in _SELECTOR_FORMATS:
del schema["format"]
if schema.pop("uniqueItems", None) is True:
_LOGGER.debug(
"Removed unsupported uniqueItems: true from OpenAI output schema at %s",
path,
)
if "$ref" in schema:
_adjust_reference(schema, path, nullable=nullable)
return

for name, definition in schema.get("$defs", {}).items():
_adjust_schema(definition, f"{path}.$defs.{name}")
for keyword in ("anyOf", "oneOf"):
for index, variant in enumerate(schema.get(keyword, [])):
_adjust_schema(variant, f"{path}.{keyword}[{index}]")

schema_type = schema.get("type", [])
types = [schema_type] if isinstance(schema_type, str) else schema_type
if "object" in types:
if schema.get("additionalProperties", False) is not False:
raise HomeAssistantError(
f"OpenAI output schema requires explicitly defined object fields at {path}"
)
schema["additionalProperties"] = False
properties = schema.setdefault("properties", {})
required = schema.setdefault("required", [])
for name, prop in properties.items():
_adjust_schema(
prop, f"{path}.properties.{name}", nullable=name not in required
)
if name not in required:
required.append(name)
if "array" in types:
if "items" not in schema:
raise HomeAssistantError(
f"OpenAI output schema requires array items at {path}"
)
_adjust_schema(schema["items"], f"{path}.items")
if nullable:
_make_nullable(schema)


def _make_nullable(schema: dict[str, Any]) -> None:
"""Allow null without weakening the non-null schema's constraints."""
if "type" not in schema or schema.keys() & {"$ref", "const", "anyOf", "oneOf"}:
original = schema.copy()
schema.clear()
schema["anyOf"] = [original, {"type": "null"}]
return
schema_type = schema["type"]
types = [schema_type] if isinstance(schema_type, str) else schema_type
if "null" not in types:
types.append("null")
schema["type"] = types
if "enum" in schema and None not in schema["enum"]:
schema["enum"].append(None)
Loading
Loading