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:
1 parent
852ddc0aeb
commit
fa9648213d
6 files changed
+3210
No files matched your search
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,45 @@
|
||||
"""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
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -9,3 +47,244 @@ pytest_plugins = "pytest_homeassistant_custom_component"
|
||||
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||
"""Enable loading custom integrations in all tests."""
|
||||
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
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user