From fa9648213d8b0a54844da3b734cf7988c711e974 Mon Sep 17 00:00:00 2001 From: Adam Smith <2105363+adamjs83@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:36:52 -0400 Subject: [PATCH] test: port core's openai_conversation suite as regression net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4 --- tests/__init__.py | 449 +++++++ tests/conftest.py | 279 ++++ tests/snapshots/test_conversation.ambr | 22 + tests/test_ai_task.py | 353 +++++ tests/test_config_flow.py | 1643 ++++++++++++++++++++++++ tests/test_conversation.py | 464 +++++++ 6 files changed, 3210 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/snapshots/test_conversation.ambr create mode 100644 tests/test_ai_task.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_conversation.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..40d13f0 --- /dev/null +++ b/tests/__init__.py @@ -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", + ), + ] diff --git a/tests/conftest.py b/tests/conftest.py index ed1f671..7b045e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/snapshots/test_conversation.ambr b/tests/snapshots/test_conversation.ambr new file mode 100644 index 0000000..ae6d418 --- /dev/null +++ b/tests/snapshots/test_conversation.ambr @@ -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', + }), + }) +# --- diff --git a/tests/test_ai_task.py b/tests/test_ai_task.py new file mode 100644 index 0000000..b490797 --- /dev/null +++ b/tests/test_ai_task.py @@ -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") diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..19f5d47 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,1643 @@ +"""Test the OpenAI Conversation config flow.""" + +from unittest.mock import AsyncMock, patch + +import httpx +from openai import APIConnectionError, AuthenticationError, BadRequestError +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText +import pytest + +from homeassistant import config_entries +from custom_components.openai_compatible.config_flow import ( + RECOMMENDED_CONVERSATION_OPTIONS, +) +from custom_components.openai_compatible.const import ( + CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, + CONF_IMAGE_MODEL, + CONF_MAX_TOKENS, + CONF_PRO_MODE, + CONF_REASONING_EFFORT, + CONF_REASONING_SUMMARY, + CONF_RECOMMENDED, + CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, + CONF_TEMPERATURE, + CONF_TOP_P, + CONF_TTS_SPEED, + CONF_VERBOSITY, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_INLINE_CITATIONS, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_TTS_NAME, + DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_SUMMARY, + RECOMMENDED_STT_OPTIONS, + RECOMMENDED_TOP_P, + RECOMMENDED_TTS_OPTIONS, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + # Pretend we already set up a config entry. + hass.config.components.add("openai_compatible") + MockConfigEntry( + domain=DOMAIN, + state=config_entries.ConfigEntryState.LOADED, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ), + patch( + "custom_components.openai_compatible.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "bla", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "api_key": "bla", + } + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ] + assert result2["version"] == 2 + assert result2["minor_version"] == 7 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + with patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"name": "My Custom Agent", **RECOMMENDED_CONVERSATION_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "My Custom Agent" + + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_subentry_recommended( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the subentry flow with recommended settings.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + options = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "recommended": True, + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data["prompt"] == "Speak like a pirate" + + +async def test_subentry_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the subentry form giving error about models not supported.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + await hass.async_block_till_done() + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + # Configure additional step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: "o1-mini", + }, + ) + await hass.async_block_till_done() + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["errors"] == {"chat_model": "model_not_supported"} + + +@pytest.mark.parametrize( + ("model", "reasoning_effort_options"), + [ + ("o4-mini", ["low", "medium", "high"]), + ("gpt-5", ["minimal", "low", "medium", "high"]), + ("gpt-5.1", ["none", "low", "medium", "high"]), + ("gpt-5.2", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.2-pro", ["medium", "high", "xhigh"]), + ("gpt-5.3-codex", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.4", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.4-pro", ["medium", "high", "xhigh"]), + ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.5-pro", ["medium", "high", "xhigh"]), + ("gpt-5.6", ["none", "low", "medium", "high", "xhigh", "max"]), + ], +) +async def test_subentry_reasoning_effort_list( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, + model: str, + reasoning_effort_options: list[str], +) -> None: + """Test the list reasoning effort options.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + # Configure additional step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: model, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + assert ( + subentry_flow["data_schema"].schema[CONF_REASONING_EFFORT].config["options"] + == reasoning_effort_options + ) + + +@pytest.mark.parametrize( + ("model", "has_reasoning_summary"), + [ + ("o3", True), + ("o4-mini", True), + ("gpt-5", True), + ("gpt-5-mini", True), + ("gpt-5-pro", True), + ("gpt-4o", False), + ("gpt-4.1", False), + ], +) +async def test_subentry_reasoning_summary_visibility( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, + model: str, + has_reasoning_summary: bool, +) -> None: + """Test that reasoning_summary option is shown for all reasoning models.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + # Configure additional step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: model, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + assert (CONF_REASONING_SUMMARY in subentry_flow["data_schema"].schema) == ( + has_reasoning_summary + ) + + +@pytest.mark.parametrize( + ("model", "reasoning_summary_options"), + [ + ("o3", ["off", "auto", "detailed"]), + ("o4-mini", ["off", "auto", "detailed"]), + ("gpt-5", ["off", "auto", "concise", "detailed"]), + ("gpt-5-mini", ["off", "auto", "concise", "detailed"]), + ], +) +async def test_subentry_reasoning_summary_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, + model: str, + reasoning_summary_options: list[str], +) -> None: + """Test the list of reasoning summary options for reasoning models.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: model, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + assert ( + subentry_flow["data_schema"].schema[CONF_REASONING_SUMMARY].config["options"] + == reasoning_summary_options + ) + + +async def test_subentry_reasoning_summary_default_sanitized_on_model_switch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, +) -> None: + """Test that a stored 'concise' default is sanitized to 'auto' for o* models.""" + subentry = next( + s + for s in mock_config_entry.subentries.values() + if s.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_REASONING_SUMMARY: "concise"}, + ) + await hass.async_block_till_done() + + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["step_id"] == "init" + + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["step_id"] == "additional" + + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + {CONF_CHAT_MODEL: "o3"}, + ) + assert subentry_flow["step_id"] == "model" + + schema = subentry_flow["data_schema"].schema + summary_key = next(k for k in schema if k == CONF_REASONING_SUMMARY) + assert summary_key.default() == RECOMMENDED_REASONING_SUMMARY + + +@pytest.mark.parametrize( + ("model", "service_tier_options"), + [ + ("gpt-5.6", ["auto", "flex", "default", "priority"]), + ("gpt-5.5", ["auto", "flex", "default", "priority"]), + ("gpt-5.4", ["auto", "flex", "default", "priority"]), + ("gpt-5.4-pro", ["auto", "flex", "default", "priority"]), + ("gpt-5.2", ["auto", "flex", "default", "priority"]), + ("gpt-5.1", ["auto", "flex", "default", "priority"]), + ("gpt-5", ["auto", "flex", "default", "priority"]), + ("gpt-5-mini", ["auto", "flex", "default", "priority"]), + ("gpt-5-nano", ["auto", "flex", "default"]), + ("o3", ["auto", "flex", "default", "priority"]), + ("o4-mini", ["auto", "flex", "default", "priority"]), + ("gpt-5.3-codex", ["auto", "default", "priority"]), + ("gpt-5.2-codex", ["auto", "default", "priority"]), + ("gpt-5.1-codex-max", ["auto", "default", "priority"]), + ("gpt-5-codex", ["auto", "default", "priority"]), + ("gpt-4.1", ["auto", "default", "priority"]), + ("gpt-4.1-mini", ["auto", "default", "priority"]), + ("gpt-4.1-nano", ["auto", "default", "priority"]), + ("gpt-4o", ["auto", "default", "priority"]), + ("gpt-4o-2024-05-13", ["auto", "default", "priority"]), + ("gpt-4o-mini", ["auto", "default", "priority"]), + ("gpt-5-chat-latest", []), + ("gpt-5.2-pro", []), + ("o3-mini", []), + ], +) +async def test_subentry_service_tier_list( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + model, + service_tier_options, +) -> None: + """Test the list of service tier options.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + # Configure additional step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: model, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + assert ( + subentry_flow["data_schema"].schema[CONF_SERVICE_TIER].config["options"] + if subentry_flow["data_schema"].schema.get(CONF_SERVICE_TIER) + else [] + ) == service_tier_options + + +@pytest.mark.parametrize( + ("parameter", "error"), + [ + (CONF_WEB_SEARCH, "web_search_minimal_reasoning"), + (CONF_CODE_INTERPRETER, "code_interpreter_minimal_reasoning"), + ], +) +async def test_subentry_unsupported_reasoning_effort( + hass: HomeAssistant, mock_config_entry, mock_init_component, parameter, error +) -> None: + """Test subentry form error on unsupported reasoning effort.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + # Configure additional step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: "gpt-5", + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + + # Configure model step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_REASONING_EFFORT: "minimal", + parameter: True, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["errors"] == {parameter: error} + + # Reconfigure model step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_REASONING_EFFORT: "low", + parameter: True, + }, + ) + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIConnectionError(request=None), "cannot_connect"), + ( + AuthenticationError( + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, + ), + "invalid_auth", + ), + ( + BadRequestError( + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, + ), + "unknown", + ), + ], +) +async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "bla", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("current_options", "new_options", "expected_options"), + [ + ( # Test clearing every llm api is stored as an empty list + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [], + CONF_PROMPT: "", + }, + ), + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [], + CONF_PROMPT: "", + }, + ), + ( # Test converting single llm api format to list + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + ( # options for reasoning models + {}, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + }, + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: 10000, + }, + { + CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: 10000, + CONF_STORE_RESPONSES: False, + CONF_REASONING_EFFORT: "high", + CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, + CONF_CODE_INTERPRETER: True, + }, + ), + ( # options for web search without user location + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "bla", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_SERVICE_TIER: "auto", + CONF_CODE_INTERPRETER: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_STORE_RESPONSES: False, + CONF_SERVICE_TIER: "auto", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_CODE_INTERPRETER: False, + }, + ), + # Test that current options are showed as suggested values + ( # Case 1: web search + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: True, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_SERVICE_TIER: "auto", + CONF_CODE_INTERPRETER: True, + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: True, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_SERVICE_TIER: "default", + CONF_CODE_INTERPRETER: True, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: True, + CONF_SERVICE_TIER: "default", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_CODE_INTERPRETER: True, + }, + ), + ( # Case 2: reasoning model + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + CONF_REASONING_SUMMARY: "auto", + CONF_VERBOSITY: "high", + CONF_CODE_INTERPRETER: False, + CONF_SERVICE_TIER: "flex", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5.6", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_REASONING_EFFORT: "max", + CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, + CONF_CODE_INTERPRETER: False, + CONF_VERBOSITY: "high", + CONF_SERVICE_TIER: "flex", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5.6", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, + CONF_REASONING_EFFORT: "max", + CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, + CONF_CODE_INTERPRETER: False, + CONF_VERBOSITY: "high", + CONF_SERVICE_TIER: "flex", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, + }, + ), + # Test that old options are removed after reconfiguration + ( # Case 1: web search to recommended + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_CODE_INTERPRETER: True, + CONF_SERVICE_TIER: "priority", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + ( # Case 2: reasoning to recommended + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + CONF_SERVICE_TIER: "auto", + CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "low", + CONF_WEB_SEARCH: False, + }, + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "Speak like a pirate", + }, + ), + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "Speak like a pirate", + }, + ), + ( # Case 3: web search to reasoning + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_SERVICE_TIER: "auto", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_CODE_INTERPRETER: True, + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, + CONF_REASONING_EFFORT: "low", + CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, + CONF_CODE_INTERPRETER: True, + }, + ), + ( # Case 4: reasoning to web search + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5.6", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + CONF_REASONING_SUMMARY: "auto", + CONF_SERVICE_TIER: "flex", + CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "medium", + CONF_PRO_MODE: True, + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_SERVICE_TIER: "priority", + CONF_CODE_INTERPRETER: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, + CONF_SERVICE_TIER: "priority", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_CODE_INTERPRETER: False, + }, + ), + ( # Case 5: code interpreter supported to not supported model + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + CONF_REASONING_SUMMARY: "auto", + CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "medium", + CONF_SERVICE_TIER: "auto", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-5-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, + CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, + CONF_VERBOSITY: "medium", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_SEARCH_INLINE_CITATIONS: True, + }, + ), + ], +) +async def test_subentry_switching( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + current_options, + new_options, + expected_options, +) -> None: + """Test the subentry form.""" + subentry = next( + sub + for sub in mock_config_entry.subentries.values() + if sub.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options + ) + await hass.async_block_till_done() + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["step_id"] == "init" + + for step_options in new_options: + assert subentry_flow["type"] is FlowResultType.FORM + + # Test that current options are showed as suggested values: + for key in subentry_flow["data_schema"].schema: + if ( + isinstance(key.description, dict) + and "suggested_value" in key.description + and key in current_options + ): + current_option = current_options[key] + if key == CONF_LLM_HASS_API and isinstance(current_option, str): + current_option = [current_option] + assert key.description["suggested_value"] == current_option + + # Configure current step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + step_options, + ) + await hass.async_block_till_done() + + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + assert subentry.data == expected_options + + +@pytest.mark.parametrize("store_responses", [False, True]) +async def test_subentry_web_search_user_location( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + store_responses: bool, +) -> None: + """Test fetching user location.""" + subentry = next( + sub + for sub in mock_config_entry.subentries.values() + if sub.subentry_type == "conversation" + ) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "additional" + + # Configure additional step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_STORE_RESPONSES: store_responses, + }, + ) + await hass.async_block_till_done() + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + + hass.config.country = "US" + hass.config.time_zone = "America/Los_Angeles" + hass.states.async_set( + "zone.home", "0", {"latitude": 37.7749, "longitude": -122.4194} + ) + with patch( + "openai.resources.responses.AsyncResponses.create", + new_callable=AsyncMock, + ) as mock_create: + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text='{"city": "San Francisco", "region": "California"}', + annotations=[], + ) + ], + role="assistant", + status="completed", + ) + ], + ) + + # Configure model step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + }, + ) + await hass.async_block_till_done() + assert ( + mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" + " coordinates located: (37.7749, -122.4194)?" + ) + assert mock_create.call_args.kwargs["store"] is store_responses + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + assert subentry.data == { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_SERVICE_TIER: "auto", + CONF_STORE_RESPONSES: store_responses, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_WEB_SEARCH_INLINE_CITATIONS: False, + CONF_CODE_INTERPRETER: False, + } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + ai_task + stt + tts + assert len(mock_config_entry.subentries) == 4 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + CONF_RECOMMENDED: True, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + CONF_RECOMMENDED: True, + } + + assert ( + len(mock_config_entry.subentries) == 5 + ) # Original conversation + stt + tts + ai_task + new ai_task + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_creating_ai_task_subentry_additional( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry with additional settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + + # Go to additional settings + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Advanced AI Task", + CONF_RECOMMENDED: False, + }, + ) + + assert result2.get("type") is FlowResultType.FORM + assert result2.get("step_id") == "additional" + + # Configure additional settings + result3 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_STORE_RESPONSES: True, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + + assert result3.get("type") is FlowResultType.FORM + assert result3.get("step_id") == "model" + + # Configure model settings + result4 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CODE_INTERPRETER: False, + }, + ) + + assert result4.get("type") is FlowResultType.CREATE_ENTRY + assert result4.get("title") == "Advanced AI Task" + assert result4.get("data") == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4o", + CONF_IMAGE_MODEL: "gpt-image-2", + CONF_MAX_TOKENS: 200, + CONF_STORE_RESPONSES: True, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + CONF_CODE_INTERPRETER: False, + CONF_SERVICE_TIER: "auto", + } + + +async def test_creating_stt_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating a STT subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + ai_task + stt + tts + assert len(mock_config_entry.subentries) == 4 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "stt"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom STT", + CONF_PROMPT: ( + "Umm, let me think like, hmm\u2026 Okay," + " here’s what I’m, like, thinking." + ), + CONF_CHAT_MODEL: "gpt-4o-transcribe", + }, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Custom STT" + assert result.get("data") == { + CONF_PROMPT: ( + "Umm, let me think like, hmm\u2026 Okay, here’s what I’m, like, thinking." + ), + CONF_CHAT_MODEL: "gpt-4o-transcribe", + } + + assert ( + len(mock_config_entry.subentries) == 5 + ) # Original conversation + ai_task + tts + original stt + new stt + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "stt" + assert new_subentry.title == "Custom STT" + + +async def test_stt_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a STT subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "stt"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_stt_reconfigure( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test reconfiguring the STT subentry updates prompt and chat model.""" + subentry = [ + s for s in mock_config_entry.subentries.values() if s.subentry_type == "stt" + ][0] + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + options = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + "prompt": "This is a conversation about smart pirate ships.", + "chat_model": "gpt-4o-mini-transcribe-2025-12-15", + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data["prompt"] == "This is a conversation about smart pirate ships." + assert subentry.data["chat_model"] == "gpt-4o-mini-transcribe-2025-12-15" + + +async def test_creating_tts_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating a TTS subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + ai_task + stt + tts + assert len(mock_config_entry.subentries) == 4 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "tts"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom TTS", + CONF_PROMPT: "Speak like a drunk pirate", + CONF_TTS_SPEED: 0.85, + }, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Custom TTS" + assert result.get("data") == { + CONF_PROMPT: "Speak like a drunk pirate", + CONF_TTS_SPEED: 0.85, + CONF_CHAT_MODEL: "gpt-4o-mini-tts", + } + + assert ( + len(mock_config_entry.subentries) == 5 + ) # Original conversation + ai_task + stt + tts + new tts + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "tts" + assert new_subentry.title == "Custom TTS" + + +async def test_tts_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a TTS subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "tts"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_tts_reconfigure( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the tts subentry reconfigure flow.""" + subentry = [ + s for s in mock_config_entry.subentries.values() if s.subentry_type == "tts" + ][0] + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + options = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "tts_speed": 0.5, + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data["prompt"] == "Speak like a pirate" + assert subentry.data["tts_speed"] == 0.5 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test we can reauthenticate.""" + # Pretend we already set up a config entry. + hass.config.components.add("openai_compatible") + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + state=config_entries.ConfigEntryState.LOADED, + ) + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with ( + patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ), + patch( + "custom_components.openai_compatible.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new_api_key", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +@pytest.mark.parametrize( + ("current_llm_apis", "suggested_llm_apis", "expected_options"), + [ + ("assist", ["assist"], ["assist"]), + (["assist"], ["assist"], ["assist"]), + ("non-existent", [], ["assist"]), + (["non-existent"], [], ["assist"]), + (["assist", "non-existent"], ["assist"], ["assist"]), + ], +) +async def test_reconfigure_conversation_subentry_llm_api_schema( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + current_llm_apis: list[str], + suggested_llm_apis: list[str], + expected_options: list[str], +) -> None: + """Test llm_hass_api field values when reconfiguring a conversation subentry.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis}, + ) + await hass.async_block_till_done() + + with patch( + "custom_components.openai_compatible.config_flow.openai.AsyncOpenAI.models", + ): + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Only valid LLM APIs should be suggested and shown as options + schema = subentry_flow["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.description + assert key.description.get("suggested_value") == suggested_llm_apis + field_schema = schema[key] + assert field_schema.config + assert [ + opt["value"] for opt in field_schema.config.get("options") + ] == expected_options diff --git a/tests/test_conversation.py b/tests/test_conversation.py new file mode 100644 index 0000000..35277d0 --- /dev/null +++ b/tests/test_conversation.py @@ -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