test: port core's openai_conversation suite as regression net

Ported from home-assistant/core tag 2026.9.2 (tests/components/openai_conversation):
conftest.py fixtures merged into tests/conftest.py, test_config_flow.py,
test_conversation.py and test_ai_task.py copied and rewritten for the fork's
custom_components.openai_compatible / "openai_compatible" domain.

Also copied as local test infrastructure (not core-only, self-contained):
- tests/__init__.py: the openai_conversation test package's own event-builder
  helpers (create_message_item, create_function_tool_call_item, etc.) that
  test_conversation.py and test_ai_task.py import via `from . import ...`.
- tests/snapshots/test_conversation.ambr: just the test_model_args snapshot
  entry, the one surviving syrupy-based assertion (see deletions below).

Deleted from test_conversation.py — depend on tests.components.conversation's
MockChatLog/mock_chat_log fixture, which lives in home-assistant/core's own
tests tree and is not distributable via pytest-homeassistant-custom-component
(confirmed: not present in the installed package's file list):
- test_function_call
- test_function_call_without_reasoning
- test_reasoning_summary_off_omits_summary_key
- test_web_search
- test_web_search_remove_citations_gpt5
- test_code_interpreter

Their now-unused imports (create_code_interpreter_item, create_web_search_item,
CONF_CODE_INTERPRETER, CONF_REASONING_SUMMARY, the CONF_WEB_SEARCH* constants,
freezegun.freeze_time, ToolInput, datetime) were removed with them.

`from tests.common import MockConfigEntry` (core's own tests package, also not
shipped by pytest-homeassistant-custom-component) rewritten to
`from pytest_homeassistant_custom_component.common import MockConfigEntry`,
matching the convention already used in this fork's tests/test_init.py.

Not run locally: this workstation is Python 3.13, HA 2026.9 (and
pytest-homeassistant-custom-component) require 3.14, so no correct local
interpreter exists. Validated with `python3 -m py_compile` only; CI
(actions/setup-python@3.14) is the real test run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4
This commit is contained in:
Adam SmithandClaude Sonnet 5 committed 2026-09-17 17:36:52 -04:00
1 parent 852ddc0aeb
commit fa9648213d
6 files changed
+3210

No files matched your search

+449
View File
@@ -0,0 +1,449 @@
"""Test helpers for openai_compatible (ported from core's openai_conversation test package)."""
from openai.types.responses import (
ResponseCodeInterpreterCallCodeDeltaEvent,
ResponseCodeInterpreterCallCodeDoneEvent,
ResponseCodeInterpreterCallCompletedEvent,
ResponseCodeInterpreterCallInProgressEvent,
ResponseCodeInterpreterCallInterpretingEvent,
ResponseCodeInterpreterToolCall,
ResponseContentPartAddedEvent,
ResponseContentPartDoneEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseFunctionWebSearch,
ResponseImageGenCallCompletedEvent,
ResponseImageGenCallPartialImageEvent,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
ResponseReasoningSummaryPartAddedEvent,
ResponseReasoningSummaryPartDoneEvent,
ResponseReasoningSummaryTextDeltaEvent,
ResponseReasoningSummaryTextDoneEvent,
ResponseStreamEvent,
ResponseTextDeltaEvent,
ResponseTextDoneEvent,
ResponseWebSearchCallCompletedEvent,
ResponseWebSearchCallInProgressEvent,
ResponseWebSearchCallSearchingEvent,
)
from openai.types.responses.response_code_interpreter_tool_call import OutputLogs
from openai.types.responses.response_function_web_search import ActionSearch
from openai.types.responses.response_output_item import ImageGenerationCall
from openai.types.responses.response_reasoning_item import Summary
def create_message_item(
id: str, text: str | list[str], output_index: int
) -> list[ResponseStreamEvent]:
"""Create a message item."""
if isinstance(text, str):
text = [text]
content = ResponseOutputText(annotations=[], text="", type="output_text")
events = [
ResponseOutputItemAddedEvent(
item=ResponseOutputMessage(
id=id,
content=[],
type="message",
role="assistant",
status="in_progress",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
),
ResponseContentPartAddedEvent(
content_index=0,
item_id=id,
output_index=output_index,
part=content,
sequence_number=0,
type="response.content_part.added",
),
]
content.text = "".join(text)
events.extend(
ResponseTextDeltaEvent(
content_index=0,
delta=delta,
item_id=id,
logprobs=[],
output_index=output_index,
sequence_number=0,
type="response.output_text.delta",
)
for delta in text
)
events.extend(
[
ResponseTextDoneEvent(
content_index=0,
item_id=id,
logprobs=[],
output_index=output_index,
text="".join(text),
sequence_number=0,
type="response.output_text.done",
),
ResponseContentPartDoneEvent(
content_index=0,
item_id=id,
output_index=output_index,
part=content,
sequence_number=0,
type="response.content_part.done",
),
ResponseOutputItemDoneEvent(
item=ResponseOutputMessage(
id=id,
content=[content],
role="assistant",
status="completed",
type="message",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
)
return events
def create_function_tool_call_item(
id: str, arguments: str | list[str], call_id: str, name: str, output_index: int
) -> list[ResponseStreamEvent]:
"""Create a function tool call item."""
if isinstance(arguments, str):
arguments = [arguments]
events = [
ResponseOutputItemAddedEvent(
item=ResponseFunctionToolCall(
id=id,
arguments="",
call_id=call_id,
name=name,
type="function_call",
status="in_progress",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
)
]
events.extend(
ResponseFunctionCallArgumentsDeltaEvent(
delta=delta,
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.function_call_arguments.delta",
)
for delta in arguments
)
events.append(
ResponseFunctionCallArgumentsDoneEvent(
arguments="".join(arguments),
item_id=id,
name=name,
output_index=output_index,
sequence_number=0,
type="response.function_call_arguments.done",
)
)
events.append(
ResponseOutputItemDoneEvent(
item=ResponseFunctionToolCall(
id=id,
arguments="".join(arguments),
call_id=call_id,
name=name,
type="function_call",
status="completed",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
)
)
return events
def create_reasoning_item(
id: str,
output_index: int,
reasoning_summary: list[list[str]] | list[str] | str | None = None,
) -> list[ResponseStreamEvent]:
"""Create a reasoning item."""
if reasoning_summary is None:
reasoning_summary = [[]]
elif isinstance(reasoning_summary, str):
reasoning_summary = [reasoning_summary]
if isinstance(reasoning_summary, list) and all(
isinstance(item, str) for item in reasoning_summary
):
reasoning_summary = [reasoning_summary]
events = [
ResponseOutputItemAddedEvent(
item=ResponseReasoningItem(
id=id,
summary=[],
type="reasoning",
status=None,
encrypted_content="AAA",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
)
]
for summary_index, summary in enumerate(reasoning_summary):
events.append(
ResponseReasoningSummaryPartAddedEvent(
item_id=id,
output_index=output_index,
part={"text": "", "type": "summary_text"},
sequence_number=0,
summary_index=summary_index,
type="response.reasoning_summary_part.added",
)
)
events.extend(
ResponseReasoningSummaryTextDeltaEvent(
delta=delta,
item_id=id,
output_index=output_index,
sequence_number=0,
summary_index=summary_index,
type="response.reasoning_summary_text.delta",
)
for delta in summary
)
events.extend(
[
ResponseReasoningSummaryTextDoneEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
summary_index=summary_index,
text="".join(summary),
type="response.reasoning_summary_text.done",
),
ResponseReasoningSummaryPartDoneEvent(
item_id=id,
output_index=output_index,
part={"text": "".join(summary), "type": "summary_text"},
sequence_number=0,
summary_index=summary_index,
type="response.reasoning_summary_part.done",
),
]
)
events.append(
ResponseOutputItemDoneEvent(
item=ResponseReasoningItem(
id=id,
summary=[
Summary(text="".join(summary), type="summary_text")
for summary in reasoning_summary
],
type="reasoning",
status=None,
encrypted_content="AAABBB",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
)
return events
def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]:
"""Create a web search call item."""
return [
ResponseOutputItemAddedEvent(
item=ResponseFunctionWebSearch(
id=id,
status="in_progress",
action=ActionSearch(query="query", type="search"),
type="web_search_call",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
),
ResponseWebSearchCallInProgressEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.web_search_call.in_progress",
),
ResponseWebSearchCallSearchingEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.web_search_call.searching",
),
ResponseWebSearchCallCompletedEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.web_search_call.completed",
),
ResponseOutputItemDoneEvent(
item=ResponseFunctionWebSearch(
id=id,
status="completed",
action=ActionSearch(query="query", type="search"),
type="web_search_call",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
def create_code_interpreter_item(
id: str, code: str | list[str], output_index: int, logs: str | None = None
) -> list[ResponseStreamEvent]:
"""Create a message item."""
if isinstance(code, str):
code = [code]
container_id = "cntr_A"
events = [
ResponseOutputItemAddedEvent(
item=ResponseCodeInterpreterToolCall(
id=id,
code="",
container_id=container_id,
outputs=None,
type="code_interpreter_call",
status="in_progress",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
),
ResponseCodeInterpreterCallInProgressEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.code_interpreter_call.in_progress",
),
]
events.extend(
ResponseCodeInterpreterCallCodeDeltaEvent(
delta=delta,
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.code_interpreter_call_code.delta",
)
for delta in code
)
code = "".join(code)
events.extend(
[
ResponseCodeInterpreterCallCodeDoneEvent(
item_id=id,
output_index=output_index,
code=code,
sequence_number=0,
type="response.code_interpreter_call_code.done",
),
ResponseCodeInterpreterCallInterpretingEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.code_interpreter_call.interpreting",
),
ResponseCodeInterpreterCallCompletedEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.code_interpreter_call.completed",
),
ResponseOutputItemDoneEvent(
item=ResponseCodeInterpreterToolCall(
id=id,
code=code,
container_id=container_id,
outputs=[OutputLogs(type="logs", logs=logs)] if logs else None,
status="completed",
type="code_interpreter_call",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
)
return events
def create_image_gen_call_item(
id: str, output_index: int, logs: str | None = None
) -> list[ResponseStreamEvent]:
"""Create a message item."""
return [
ResponseImageGenCallPartialImageEvent(
item_id=id,
output_index=output_index,
partial_image_b64="QQ==",
partial_image_index=0,
sequence_number=0,
type="response.image_generation_call.partial_image",
size="1536x1024",
quality="medium",
background="transparent",
output_format="png",
),
ResponseImageGenCallCompletedEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.image_generation_call.completed",
),
ResponseOutputItemDoneEvent(
item=ImageGenerationCall(
id=id,
result="QQ==",
status="completed",
type="image_generation_call",
background="transparent",
output_format="png",
quality="medium",
revised_prompt="Mock revised prompt.",
size="1536x1024",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
+279
View File
@@ -1,7 +1,45 @@
"""Fixtures for openai_compatible tests.""" """Fixtures for openai_compatible tests."""
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from openai.types import ResponseFormatText
from openai.types.audio import Transcription
from openai.types.responses import (
Response,
ResponseCompletedEvent,
ResponseCreatedEvent,
ResponseError,
ResponseErrorEvent,
ResponseFailedEvent,
ResponseIncompleteEvent,
ResponseInProgressEvent,
ResponseOutputItemDoneEvent,
ResponseTextConfig,
)
from openai.types.responses.response import IncompleteDetails
import pytest import pytest
from custom_components.openai_compatible.const import (
CONF_CHAT_MODEL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_STT_NAME,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TTS_OPTIONS,
)
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.setup import async_setup_component
from pytest_homeassistant_custom_component.common import MockConfigEntry
pytest_plugins = "pytest_homeassistant_custom_component" pytest_plugins = "pytest_homeassistant_custom_component"
@@ -9,3 +47,244 @@ pytest_plugins = "pytest_homeassistant_custom_component"
def auto_enable_custom_integrations(enable_custom_integrations): def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable loading custom integrations in all tests.""" """Enable loading custom integrations in all tests."""
return return
@pytest.fixture
def mock_conversation_subentry_data() -> dict[str, Any]:
"""Mock subentry data."""
return {}
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant, mock_conversation_subentry_data: dict[str, Any]
) -> MockConfigEntry:
"""Mock a config entry."""
entry = MockConfigEntry(
title="OpenAI",
domain=DOMAIN,
data={
"api_key": "bla",
},
version=2,
minor_version=7,
subentries_data=[
ConfigSubentryData(
data=mock_conversation_subentry_data,
subentry_type="conversation",
title=DEFAULT_CONVERSATION_NAME,
unique_id=None,
),
ConfigSubentryData(
data=RECOMMENDED_AI_TASK_OPTIONS,
subentry_type="ai_task_data",
title=DEFAULT_AI_TASK_NAME,
unique_id=None,
),
ConfigSubentryData(
data=RECOMMENDED_STT_OPTIONS,
subentry_type="stt",
title=DEFAULT_STT_NAME,
unique_id=None,
),
ConfigSubentryData(
data=RECOMMENDED_TTS_OPTIONS,
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
],
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
async def mock_config_entry_with_assist(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with assist."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
async def mock_config_entry_with_reasoning_model(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with assist."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"},
)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
async def mock_init_component(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Initialize integration."""
with patch(
"openai.resources.models.AsyncModels.list",
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
@pytest.fixture(autouse=True)
async def setup_ha(hass: HomeAssistant) -> None:
"""Set up Home Assistant."""
assert await async_setup_component(hass, "homeassistant", {})
@pytest.fixture
def mock_create_stream() -> Generator[AsyncMock]:
"""Mock stream response."""
async def mock_generator(events, **kwargs):
if isinstance(events, Exception):
raise events
response = Response(
id="resp_A",
created_at=1700000000,
error=None,
incomplete_details=None,
instructions=kwargs.get("instructions"),
metadata=kwargs.get("metadata", {}),
model=kwargs.get("model", "gpt-4o-mini"),
object="response",
output=[],
parallel_tool_calls=kwargs.get("parallel_tool_calls", True),
temperature=kwargs.get("temperature", 1.0),
tool_choice=kwargs.get("tool_choice", "auto"),
tools=kwargs.get("tools", []),
top_p=kwargs.get("top_p", 1.0),
max_output_tokens=kwargs.get("max_output_tokens", 100000),
previous_response_id=kwargs.get("previous_response_id"),
reasoning=kwargs.get("reasoning"),
status="in_progress",
text=kwargs.get(
"text", ResponseTextConfig(format=ResponseFormatText(type="text"))
),
truncation=kwargs.get("truncation", "disabled"),
usage=None,
user=kwargs.get("user"),
store=kwargs.get("store", True),
)
yield ResponseCreatedEvent(
response=response,
sequence_number=0,
type="response.created",
)
yield ResponseInProgressEvent(
response=response,
sequence_number=1,
type="response.in_progress",
)
sequence_number = 2
response.status = "completed"
for value in events:
if isinstance(value, ResponseOutputItemDoneEvent):
response.output.append(value.item)
elif isinstance(value, IncompleteDetails):
response.status = "incomplete"
response.incomplete_details = value
break
if isinstance(value, ResponseError):
response.status = "failed"
response.error = value
break
value.sequence_number = sequence_number
sequence_number += 1
yield value
if isinstance(value, ResponseErrorEvent):
return
if response.status == "incomplete":
yield ResponseIncompleteEvent(
response=response,
sequence_number=sequence_number,
type="response.incomplete",
)
elif response.status == "failed":
yield ResponseFailedEvent(
response=response,
sequence_number=sequence_number,
type="response.failed",
)
else:
yield ResponseCompletedEvent(
response=response,
sequence_number=sequence_number,
type="response.completed",
)
with patch(
"openai.resources.responses.AsyncResponses.create",
AsyncMock(),
) as mock_create:
mock_create.side_effect = lambda **kwargs: mock_generator(
mock_create.return_value.pop(0), **kwargs
)
yield mock_create
@pytest.fixture
def mock_create_transcription() -> Generator[AsyncMock]:
"""Mock transcription response."""
with patch(
"openai.resources.audio.transcriptions.AsyncTranscriptions.create",
AsyncMock(return_value=""),
) as mock_create:
mock_create.side_effect = lambda *args, **kwargs: (
Transcription(text=mock_create.return_value)
if isinstance(mock_create.return_value, str)
else mock_create.return_value
)
yield mock_create
@pytest.fixture
def mock_create_speech() -> Generator[MagicMock]:
"""Mock stream response."""
class AsyncIterBytesHelper:
def __init__(self, chunks) -> None:
self.chunks = chunks
self.index = 0
def __aiter__(self):
return self
async def __anext__(self):
if self.index >= len(self.chunks):
raise StopAsyncIteration
chunk = self.chunks[self.index]
self.index += 1
return chunk
mock_response = MagicMock()
mock_cm = AsyncMock()
mock_cm.__aenter__.return_value = mock_response
mock_create = MagicMock(side_effect=lambda **kwargs: mock_cm)
with patch(
"openai.resources.audio.speech.async_to_custom_streamed_response_wrapper",
return_value=mock_create,
):
mock_response.iter_bytes.side_effect = lambda **kwargs: AsyncIterBytesHelper(
mock_create.return_value
)
yield mock_create
+22
View File
@@ -0,0 +1,22 @@
# serializer version: 1
# name: test_model_args[subentry_options0]
dict({
'include': list([
'reasoning.encrypted_content',
]),
'max_output_tokens': 3000,
'model': 'gpt-5.6-sol',
'prompt_cache_retention': '24h',
'reasoning': dict({
'effort': 'low',
'mode': 'pro',
'summary': 'auto',
}),
'service_tier': 'auto',
'store': False,
'stream': True,
'text': dict({
'verbosity': 'medium',
}),
})
# ---
+353
View File
@@ -0,0 +1,353 @@
"""Test AI Task platform of OpenAI Conversation integration."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
from openai import PermissionDeniedError
import pytest
import voluptuous as vol
from homeassistant.components import ai_task, media_source
from custom_components.openai_compatible import DOMAIN
from custom_components.openai_compatible.const import CONF_STORE_RESPONSES
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector
from . import create_image_gen_call_item, create_message_item, create_reasoning_item
from pytest_homeassistant_custom_component.common import MockConfigEntry
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.parametrize("expected_store", [False, True])
async def test_generate_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
expected_store: bool,
) -> None:
"""Test AI Task data generation."""
entity_id = "ai_task.openai_ai_task"
# Ensure entity is linked to the subentry
entity_entry = entity_registry.async_get(entity_id)
ai_task_entry = next(
iter(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "ai_task_data"
)
)
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={**ai_task_entry.data, CONF_STORE_RESPONSES: expected_store},
)
await hass.async_block_till_done()
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
# Mock the OpenAI response stream
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="The test data", output_index=0)
]
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Generate test data",
)
assert result.data == "The test data"
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is expected_store
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task structured data generation."""
# Mock the OpenAI response stream with JSON data
mock_create_stream.return_value = [
create_message_item(
id="msg_A", text='{"characters": ["Mario", "Luigi"]}', output_index=0
)
]
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
assert result.data == {"characters": ["Mario", "Luigi"]}
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_invalid_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task with invalid JSON response."""
# Mock the OpenAI response stream with invalid JSON
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="INVALID JSON RESPONSE", output_index=0)
]
with pytest.raises(
HomeAssistantError, match="Error with OpenAI structured response"
):
await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_data_with_attachments(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task data generation with attachments."""
entity_id = "ai_task.openai_ai_task"
# Mock the OpenAI response stream
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hi there!", output_index=0)
]
# Test with attachments
with (
patch(
"homeassistant.components.media_source.async_resolve_media",
side_effect=[
media_source.PlayMedia(
url="http://example.com/doorbell_snapshot.jpg",
mime_type="image/jpeg",
path=Path("doorbell_snapshot.jpg"),
),
media_source.PlayMedia(
url="http://example.com/context.pdf",
mime_type="application/pdf",
path=Path("context.pdf"),
),
],
),
patch("pathlib.Path.exists", return_value=True),
patch(
"custom_components.openai_compatible.entity.guess_file_type",
return_value=("image/jpeg", None),
),
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
):
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Test prompt",
attachments=[
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
{"media_content_id": "media-source://media/context.pdf"},
],
)
assert result.data == "Hi there!"
# Verify that the create stream was called with the correct parameters
# The last call should have the user message with attachments
call_args = mock_create_stream.call_args
assert call_args is not None
# Check that the input includes the attachments
input_messages = call_args[1]["input"]
assert len(input_messages) > 0
# Find the user message with attachments
user_message_with_attachments = input_messages[-2]
assert user_message_with_attachments is not None
assert isinstance(user_message_with_attachments["content"], list)
assert len(user_message_with_attachments["content"]) == 3 # Text + attachments
assert user_message_with_attachments["content"] == [
{"type": "input_text", "text": "Test prompt"},
{
"detail": "auto",
"image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh",
"type": "input_image",
},
{
"filename": "context.pdf",
"file_data": "data:application/pdf;base64,ZmFrZV9pbWFnZV9kYXRh",
"type": "input_file",
},
]
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.freeze_time("2025-06-14 22:59:00")
@pytest.mark.parametrize("configured_store", [False, True])
@pytest.mark.parametrize(
("image_model", "input_fidelity_present"),
[
("gpt-image-2", False),
("gpt-image-1.5", True),
("gpt-image-1", True),
("gpt-image-1-mini", False),
],
)
async def test_generate_image(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
image_model: str,
input_fidelity_present: bool,
configured_store: bool,
) -> None:
"""Test AI Task image generation."""
entity_id = "ai_task.openai_ai_task"
# Ensure entity is linked to the subentry
entity_entry = entity_registry.async_get(entity_id)
ai_task_entry = next(
iter(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "ai_task_data"
)
)
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={
**ai_task_entry.data,
"image_model": image_model,
CONF_STORE_RESPONSES: configured_store,
},
)
await hass.async_block_till_done()
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
# Mock the OpenAI response stream
mock_create_stream.return_value = [
(
*create_reasoning_item(
id="rs_A",
output_index=0,
reasoning_summary=[["The user asks me to generate an image"]],
),
*create_image_gen_call_item(id="ig_A", output_index=1),
*create_message_item(id="msg_A", text="", output_index=2),
)
]
with patch.object(
media_source.local_source.LocalSource,
"async_upload_media",
return_value="media-source://ai_task/image/2025-06-14_155900_test_task.png",
) as mock_upload_media:
result = await ai_task.async_generate_image(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test image",
)
assert result["height"] == 1024
assert result["width"] == 1536
assert result["revised_prompt"] == "Mock revised prompt."
assert result["mime_type"] == "image/png"
assert result["model"] == image_model
mock_upload_media.assert_called_once()
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is True
image_tool = next(
iter(
tool
for tool in mock_create_stream.call_args.kwargs["tools"]
if tool["type"] == "image_generation"
),
)
assert ("input_fidelity" in image_tool) == input_fidelity_present
image_data = mock_upload_media.call_args[0][1]
assert image_data.file.getvalue() == b"A"
assert image_data.content_type == "image/png"
assert image_data.filename == "2025-06-14_155900_test_task.png"
assert (
issue_registry.async_get_issue(DOMAIN, "organization_verification_required")
is None
)
@pytest.mark.usefixtures("mock_init_component")
async def test_repair_issue(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that repair issue is raised when verification is required."""
with (
patch(
"openai.resources.responses.AsyncResponses.create",
side_effect=PermissionDeniedError(
response=httpx.Response(
status_code=403, request=httpx.Request(method="GET", url="")
),
body=None,
message="Please click on Verify Organization.",
),
),
pytest.raises(HomeAssistantError, match="Error talking to OpenAI"),
):
await ai_task.async_generate_image(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test image",
)
assert issue_registry.async_get_issue(DOMAIN, "organization_verification_required")
File diff suppressed because it is too large. Load diff
+464
View File
@@ -0,0 +1,464 @@
"""Tests for the OpenAI integration."""
from unittest.mock import AsyncMock
import httpx
from openai import AuthenticationError, RateLimitError
from openai.types.responses import (
ResponseError,
ResponseErrorEvent,
ResponseStreamEvent,
)
from openai.types.responses.response import IncompleteDetails
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.intent import async_register_timer_handler
from custom_components.openai_compatible.const import (
CONF_CHAT_MODEL,
CONF_PRO_MODE,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
)
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
from . import (
create_function_tool_call_item,
create_message_item,
create_reasoning_item,
)
from pytest_homeassistant_custom_component.common import MockConfigEntry
async def test_entity(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test entity properties."""
state = hass.states.get("conversation.openai_conversation")
assert state
assert state.attributes["supported_features"] == 0
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={CONF_LLM_HASS_API: "assist"},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
state = hass.states.get("conversation.openai_conversation")
assert state
assert (
state.attributes["supported_features"]
== conversation.ConversationEntityFeature.CONTROL
)
@pytest.mark.parametrize(
("exception", "message"),
[
(
RateLimitError(
response=httpx.Response(status_code=429, request=""),
body=None,
message=None,
),
"Rate limited or insufficient funds",
),
(
AuthenticationError(
response=httpx.Response(status_code=401, request=""),
body=None,
message=None,
),
"Error talking to OpenAI",
),
],
)
async def test_error_handling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
exception,
message,
) -> None:
"""Test that we handle errors when calling completion API."""
mock_create_stream.return_value = [exception]
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
)
assert result.response.response_type is intent.IntentResponseType.ERROR, result
assert result.response.speech["plain"]["speech"] == message, result.response.speech
@pytest.mark.parametrize(
("reason", "message"),
[
(
"max_output_tokens",
"max output tokens reached",
),
(
"content_filter",
"content filter triggered",
),
(
None,
"unknown reason",
),
],
)
async def test_incomplete_response(
hass: HomeAssistant,
mock_config_entry_with_assist: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
reason: str,
message: str,
) -> None:
"""Test handling early model stop."""
# Incomplete details received after some content is generated
mock_create_stream.return_value = [
(
# Start message
*create_message_item(
id="msg_A",
text=["Once upon", " a time, ", "there was "],
output_index=0,
),
# Length limit or content filter
IncompleteDetails(reason=reason),
)
]
result = await conversation.async_converse(
hass,
"Please tell me a big story",
"mock-conversation-id",
Context(),
agent_id="conversation.openai_conversation",
)
assert result.response.response_type is intent.IntentResponseType.ERROR, result
assert (
result.response.speech["plain"]["speech"]
== f"OpenAI response incomplete: {message}"
), result.response.speech
# Incomplete details received before any content is generated
mock_create_stream.return_value = [
(
# Start generating response
*create_reasoning_item(id="rs_A", output_index=0),
# Length limit or content filter
IncompleteDetails(reason=reason),
)
]
result = await conversation.async_converse(
hass,
"please tell me a big story",
"mock-conversation-id",
Context(),
agent_id="conversation.openai_conversation",
)
assert result.response.response_type is intent.IntentResponseType.ERROR, result
assert (
result.response.speech["plain"]["speech"]
== f"OpenAI response incomplete: {message}"
), result.response.speech
@pytest.mark.parametrize(
("error", "message"),
[
(
ResponseError(code="rate_limit_exceeded", message="Rate limit exceeded"),
"OpenAI response failed: Rate limit exceeded",
),
(
ResponseErrorEvent(type="error", message="Some error", sequence_number=0),
"OpenAI response error: Some error",
),
],
)
async def test_failed_response(
hass: HomeAssistant,
mock_config_entry_with_assist: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
error: ResponseError | ResponseErrorEvent,
message: str,
) -> None:
"""Test handling failed and error responses."""
mock_create_stream.return_value = [(error,)]
result = await conversation.async_converse(
hass,
"next natural number please",
"mock-conversation-id",
Context(),
agent_id="conversation.openai_conversation",
)
assert result.response.response_type is intent.IntentResponseType.ERROR, result
assert result.response.speech["plain"]["speech"] == message, result.response.speech
async def test_conversation_agent(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test OpenAIAgent."""
agent = conversation.get_agent_manager(hass).async_get_agent(
mock_config_entry.entry_id
)
assert agent.supported_languages == "*"
@pytest.mark.parametrize(
("description", "messages"),
[
(
"Test function call started with missing arguments",
(
*create_function_tool_call_item(
id="fc_1",
arguments=[],
call_id="call_call_1",
name="test_tool",
output_index=0,
),
*create_message_item(id="msg_A", text="Cool", output_index=1),
),
),
(
"Test invalid JSON",
(
*create_function_tool_call_item(
id="fc_1",
arguments=['{"para'],
call_id="call_call_1",
name="test_tool",
output_index=0,
),
*create_message_item(id="msg_A", text="Cool", output_index=1),
),
),
],
)
async def test_function_call_invalid(
hass: HomeAssistant,
mock_config_entry_with_assist: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
description: str,
messages: tuple[ResponseStreamEvent],
) -> None:
"""Test function call containing invalid data."""
mock_create_stream.return_value = [messages]
with pytest.raises(ValueError):
await conversation.async_converse(
hass,
"Please call the test function",
"mock-conversation-id",
Context(),
agent_id="conversation.openai_conversation",
)
async def test_assist_api_tools_conversion(
hass: HomeAssistant,
mock_config_entry_with_assist: MockConfigEntry,
mock_init_component,
mock_create_stream,
) -> None:
"""Test that we are able to convert actual tools from Assist API."""
for domain in (
"calendar",
"climate",
"cover",
"humidifier",
"intent",
"light",
"media_player",
"script",
"shopping_list",
"todo",
"vacuum",
"weather",
):
assert await async_setup_component(hass, domain, {})
hass.states.async_set(f"{domain}.test", "on")
async_expose_entity(hass, "conversation", f"{domain}.test", True)
async_register_timer_handler(hass, "test_device", lambda *args: None)
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Cool", output_index=0)
]
await conversation.async_converse(
hass,
"hello",
None,
Context(),
agent_id="conversation.openai_conversation",
device_id="test_device",
)
tools = mock_create_stream.mock_calls[0][2]["tools"]
assert tools
for tool in tools:
msg = (
f"Invalid schema for function '{tool['name']}': schema must have type "
"'object' and not have 'oneOf'/'anyOf'/'allOf'/"
"'enum'/'not' at the top level."
)
assert tool["parameters"]["type"] == "object", msg
for key in ("oneOf", "anyOf", "allOf", "enum", "not"):
assert key not in tool["parameters"], msg
@pytest.mark.parametrize(
"expected_store",
[
False,
True,
],
)
async def test_store_responses_forwarded_for_conversation_agent(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
expected_store: bool,
) -> None:
"""Test store_responses is forwarded for the conversation agent."""
subentry = next(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={**subentry.data, CONF_STORE_RESPONSES: expected_store},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hello!", output_index=0)
]
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
)
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is expected_store
async def test_flex_tier_retry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream,
) -> None:
"""Test retry with default tier if flex tier unavailable."""
subentry = next(iter(mock_config_entry.subentries.values()))
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
**subentry.data,
CONF_SERVICE_TIER: "flex",
},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
mock_create_stream.return_value = [
RateLimitError(
response=httpx.Response(
status_code=429,
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
),
body=None,
message="Resource Unavailable",
),
create_message_item(id="msg_A", text="How can I assist?", output_index=0),
]
result = await conversation.async_converse(
hass,
"Hi!",
None,
Context(),
agent_id="conversation.openai_conversation",
)
assert mock_create_stream.call_count == 2
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
assert result.response.speech["plain"]["speech"] == "How can I assist?", (
result.response.speech
)
assert mock_create_stream.mock_calls[0][2]["service_tier"] == "flex"
assert mock_create_stream.mock_calls[1][2]["service_tier"] == "default"
@pytest.mark.parametrize(
"subentry_options", [{CONF_CHAT_MODEL: "gpt-5.6-sol", CONF_PRO_MODE: True}]
)
async def test_model_args(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
subentry_options: dict,
) -> None:
"""Test model arguments for various configuration."""
subentry = next(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data=subentry_options,
)
await hass.async_block_till_done()
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hi!", output_index=0),
]
result = await conversation.async_converse(
hass,
"Hello",
None,
Context(),
agent_id="conversation.openai_conversation",
)
model_args = mock_create_stream.call_args.kwargs.copy()
model_args.pop("input")
assert model_args.pop("user") == result.conversation_id
assert model_args == snapshot