diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 67799727ed54..5f6b19b641a1 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -1,53 +1,25 @@ """The OpenAI Conversation integration.""" -from pathlib import Path from types import MappingProxyType from typing import Any, cast import openai -from openai.types.images_response import ImagesResponse -from openai.types.responses import ( - EasyInputMessageParam, - Response, - ResponseInputMessageContentListParam, - ResponseInputParam, - ResponseInputTextParam, -) -import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigSubentry -from homeassistant.const import CONF_API_KEY, CONF_PROMPT, Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, - selector, ) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( CONF_CHAT_MODEL, - CONF_FILENAMES, - CONF_MAX_TOKENS, - CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, - CONF_STORE_RESPONSES, - CONF_TEMPERATURE, - CONF_TOP_P, DEFAULT_AI_TASK_NAME, DEFAULT_NAME, DEFAULT_STT_NAME, @@ -55,20 +27,10 @@ from .const import ( DOMAIN, LOGGER, RECOMMENDED_AI_TASK_OPTIONS, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, - RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_OPTIONS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_P, RECOMMENDED_TTS_OPTIONS, ) -from .entity import async_prepare_files_for_prompt - -SERVICE_GENERATE_IMAGE = "generate_image" -SERVICE_GENERATE_CONTENT = "generate_content" PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.STT, Platform.TTS) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -80,202 +42,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" await async_migrate_integration(hass) - async def render_image(call: ServiceCall) -> ServiceResponse: - """Render an image with dall-e.""" - LOGGER.warning( - "Action '%s.%s' is deprecated and will be removed in the 2026.9.0 release. " - "Please use the 'ai_task.generate_image' action instead", - DOMAIN, - SERVICE_GENERATE_IMAGE, - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_generate_image", - breaks_in_ha_version="2026.9.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_generate_image", - ) - - entry_id = call.data["config_entry"] - entry = hass.config_entries.async_get_entry(entry_id) - - if entry is None or entry.domain != DOMAIN: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_config_entry", - translation_placeholders={"config_entry": entry_id}, - ) - - client: openai.AsyncClient = entry.runtime_data - - try: - response: ImagesResponse = await client.images.generate( - model="dall-e-3", - prompt=call.data[CONF_PROMPT], - size=call.data["size"], - quality=call.data["quality"], - style=call.data["style"], - response_format="url", - n=1, - ) - except openai.AuthenticationError as err: - entry.async_start_reauth(hass) - raise HomeAssistantError("Authentication error") from err - except openai.OpenAIError as err: - raise HomeAssistantError(f"Error generating image: {err}") from err - - if not response.data or not response.data[0].url: - raise HomeAssistantError("No image returned") - - return response.data[0].model_dump(exclude={"b64_json"}) - - async def send_prompt(call: ServiceCall) -> ServiceResponse: - """Send a prompt to ChatGPT and return the response.""" - LOGGER.warning( - "Action '%s.%s' is deprecated and will be removed in the 2026.9.0 release. " - "Please use the 'ai_task.generate_data' action instead", - DOMAIN, - SERVICE_GENERATE_CONTENT, - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_generate_content", - breaks_in_ha_version="2026.9.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_generate_content", - ) - - entry_id = call.data["config_entry"] - entry = hass.config_entries.async_get_entry(entry_id) - - if entry is None or entry.domain != DOMAIN: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_config_entry", - translation_placeholders={"config_entry": entry_id}, - ) - - # Get first conversation subentry for options - conversation_subentry = next( - ( - sub - for sub in entry.subentries.values() - if sub.subentry_type == "conversation" - ), - None, - ) - if not conversation_subentry: - raise ServiceValidationError("No conversation configuration found") - - model: str = conversation_subentry.data.get( - CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL - ) - client: openai.AsyncClient = entry.runtime_data - - content: ResponseInputMessageContentListParam = [ - ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT]) - ] - - if filenames := call.data.get(CONF_FILENAMES): - for filename in filenames: - if not hass.config.is_allowed_path(filename): - raise HomeAssistantError( - f"Cannot read `{filename}`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ) - - content.extend( - await async_prepare_files_for_prompt( - hass, [(Path(filename), None) for filename in filenames] - ) - ) - - messages: ResponseInputParam = [ - EasyInputMessageParam(type="message", role="user", content=content) - ] - - model_args = { - "model": model, - "input": messages, - "max_output_tokens": conversation_subentry.data.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": conversation_subentry.data.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - "user": call.context.user_id, - "store": conversation_subentry.data.get( - CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES - ), - } - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": conversation_subentry.data.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - - try: - response: Response = await client.responses.create(**model_args) - except openai.AuthenticationError as err: - entry.async_start_reauth(hass) - raise HomeAssistantError("Authentication error") from err - except openai.OpenAIError as err: - raise HomeAssistantError(f"Error generating content: {err}") from err - except FileNotFoundError as err: - raise HomeAssistantError(f"Error generating content: {err}") from err - - return {"text": response.output_text} - - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_CONTENT, - send_prompt, - schema=vol.Schema( - { - vol.Required("config_entry"): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Required(CONF_PROMPT): cv.string, - vol.Optional(CONF_FILENAMES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ), - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_IMAGE, - render_image, - schema=vol.Schema( - { - vol.Required("config_entry"): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Required(CONF_PROMPT): cv.string, - vol.Optional("size", default="1024x1024"): vol.In( - ("1024x1024", "1024x1792", "1792x1024") - ), - vol.Optional("quality", default="standard"): vol.In(("standard", "hd")), - vol.Optional("style", default="vivid"): vol.In(("vivid", "natural")), - } - ), - supports_response=SupportsResponse.ONLY, - ) - return True diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 78e7bf948582..518e8217a12e 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -18,7 +18,6 @@ DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" CONF_IMAGE_MODEL = "image_model" CONF_CODE_INTERPRETER = "code_interpreter" -CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PRO_MODE = "pro_mode" CONF_REASONING_EFFORT = "reasoning_effort" diff --git a/homeassistant/components/openai_conversation/icons.json b/homeassistant/components/openai_conversation/icons.json deleted file mode 100644 index 23cdc26ed713..000000000000 --- a/homeassistant/components/openai_conversation/icons.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "services": { - "generate_content": { - "service": "mdi:receipt-text" - }, - "generate_image": { - "service": "mdi:image-sync" - } - } -} diff --git a/homeassistant/components/openai_conversation/quality_scale.yaml b/homeassistant/components/openai_conversation/quality_scale.yaml index 1a55ec2fbb41..99aa0cb06f3a 100644 --- a/homeassistant/components/openai_conversation/quality_scale.yaml +++ b/homeassistant/components/openai_conversation/quality_scale.yaml @@ -1,6 +1,9 @@ rules: # Bronze - action-setup: done + action-setup: + status: exempt + comment: | + Integration has no actions. appropriate-polling: status: exempt comment: | @@ -10,7 +13,10 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: done + docs-actions: + status: exempt + comment: | + Integration has no actions. docs-conditions: status: exempt comment: This integration does not have any conditions. diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml deleted file mode 100644 index 75fa097f25df..000000000000 --- a/homeassistant/components/openai_conversation/services.yaml +++ /dev/null @@ -1,60 +0,0 @@ -generate_image: - fields: - config_entry: - required: true - selector: - config_entry: - integration: openai_conversation - prompt: - required: true - selector: - text: - multiline: true - size: - required: false - example: "1024x1024" - default: "1024x1024" - selector: - select: - options: - - "1024x1024" - - "1024x1792" - - "1792x1024" - quality: - required: false - example: "standard" - default: "standard" - selector: - select: - options: - - "standard" - - "hd" - style: - required: false - example: "vivid" - default: "vivid" - selector: - select: - options: - - "vivid" - - "natural" -generate_content: - fields: - config_entry: - required: true - selector: - config_entry: - integration: openai_conversation - prompt: - required: true - selector: - text: - multiline: true - example: "Hello, how can I help you?" - filenames: - selector: - text: - multiline: true - example: | - - /path/to/file1.txt - - /path/to/file2.txt diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 1ab4611ca403..3ffc6bfb2d35 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -208,20 +208,7 @@ } } }, - "exceptions": { - "invalid_config_entry": { - "message": "Invalid config entry provided. Got {config_entry}" - } - }, "issues": { - "deprecated_generate_content": { - "description": "Action 'openai_conversation.generate_content' is deprecated and will be removed in the 2026.9.0 release. Please use the 'ai_task.generate_data' action instead", - "title": "Deprecated 'generate_content' action" - }, - "deprecated_generate_image": { - "description": "Action 'openai_conversation.generate_image' is deprecated and will be removed in the 2026.9.0 release. Please use the 'ai_task.generate_image' action instead", - "title": "Deprecated 'generate_image' action" - }, "organization_verification_required": { "description": "Your organization must be verified to use this model. Please go to {platform_settings} and select Verify Organization. If you just verified, it can take up to 15 minutes for access to propagate.", "title": "Organization verification required" @@ -269,52 +256,5 @@ "medium": "[%key:common::state::medium%]" } } - }, - "services": { - "generate_content": { - "description": "Sends a conversational query to ChatGPT including any attached image or PDF files (deprecated)", - "fields": { - "config_entry": { - "description": "The config entry to use for this action", - "name": "Config entry" - }, - "filenames": { - "description": "List of files to upload", - "name": "Files" - }, - "prompt": { - "description": "The prompt to send", - "name": "Prompt" - } - }, - "name": "Generate content (deprecated)" - }, - "generate_image": { - "description": "Turns a prompt into an image (deprecated)", - "fields": { - "config_entry": { - "description": "The config entry to use for this action", - "name": "Config entry" - }, - "prompt": { - "description": "The text to turn into an image", - "example": "A photo of a dog", - "name": "Prompt" - }, - "quality": { - "description": "The quality of the image that will be generated", - "name": "Quality" - }, - "size": { - "description": "The size of the image to generate", - "name": "Size" - }, - "style": { - "description": "The style of the generated image", - "name": "Style" - } - }, - "name": "Generate image (deprecated)" - } } } diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py index c24cb5b3d799..1297c2f90e2b 100644 --- a/tests/components/openai_conversation/test_entity.py +++ b/tests/components/openai_conversation/test_entity.py @@ -1,10 +1,17 @@ """Tests for the OpenAI Conversation entity.""" +from pathlib import Path +from unittest.mock import patch + +import pytest import voluptuous as vol from homeassistant.components.openai_conversation.entity import ( _format_structured_output, + async_prepare_files_for_prompt, ) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector @@ -77,3 +84,83 @@ async def test_format_structured_output() -> None: "strict": True, "type": "object", } + + +@pytest.mark.parametrize( + ("filename", "expected_content"), + [ + pytest.param( + "image.jpg", + { + "type": "input_image", + "image_url": "data:image/jpeg;base64,QUJD", + "detail": "auto", + }, + id="jpeg", + ), + pytest.param( + "document.pdf", + { + "type": "input_file", + "filename": "document.pdf", + "file_data": "data:application/pdf;base64,QUJD", + }, + id="pdf", + ), + ], +) +async def test_prepare_files_for_prompt_infers_mime_type( + hass: HomeAssistant, + filename: str, + expected_content: dict[str, str], +) -> None: + """Test attachments without an explicit MIME type.""" + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_bytes", return_value=b"ABC"), + ): + assert await async_prepare_files_for_prompt(hass, [(Path(filename), None)]) == [ + expected_content + ] + + +@pytest.mark.parametrize( + ("filename", "exists", "mime_type", "error"), + [ + pytest.param("image.jpg", False, None, "does not exist", id="missing_file"), + pytest.param( + "document.txt", + True, + None, + "not an image file or PDF", + id="unsupported_inferred_mime_type", + ), + pytest.param( + "document.unknown_openai_attachment", + True, + None, + "not an image file or PDF", + id="unknown_mime_type", + ), + pytest.param( + "image.jpg", + True, + "text/plain", + "not an image file or PDF", + id="unsupported_explicit_mime_type", + ), + ], +) +async def test_prepare_files_for_prompt_invalid_file( + hass: HomeAssistant, + filename: str, + exists: bool, + mime_type: str | None, + error: str, +) -> None: + """Test missing files and unsupported attachment types.""" + with ( + patch("pathlib.Path.exists", return_value=exists), + pytest.raises(HomeAssistantError, match=error), + ): + await async_prepare_files_for_prompt(hass, [(Path(filename), mime_type)]) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 4da106e583ab..c572d10f627f 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,19 +1,11 @@ """Tests for the OpenAI integration.""" from typing import Any -from unittest.mock import AsyncMock, Mock, mock_open, patch +from unittest.mock import AsyncMock, patch import attr import httpx -from openai import ( - APIConnectionError, - AuthenticationError, - BadRequestError, - RateLimitError, -) -from openai.types.image import Image -from openai.types.images_response import ImagesResponse -from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText +from openai import APIConnectionError, AuthenticationError, BadRequestError import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -21,7 +13,6 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL from homeassistant.components.openai_conversation.const import ( CONF_REASONING_SUMMARY, - CONF_STORE_RESPONSES, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_STT_NAME, @@ -33,14 +24,12 @@ from homeassistant.components.openai_conversation.const import ( RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ( - SOURCE_REAUTH, ConfigEntryDisabler, ConfigEntryState, ConfigSubentryData, ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryDisabler from homeassistant.helpers.entity_registry import RegistryEntryDisabler @@ -49,211 +38,6 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -@pytest.mark.parametrize( - ("service_data", "expected_args"), - [ - ( - {"prompt": "Picture of a dog"}, - { - "prompt": "Picture of a dog", - "size": "1024x1024", - "quality": "standard", - "style": "vivid", - }, - ), - ( - { - "prompt": "Picture of a dog", - "size": "1024x1792", - "quality": "hd", - "style": "vivid", - }, - { - "prompt": "Picture of a dog", - "size": "1024x1792", - "quality": "hd", - "style": "vivid", - }, - ), - ( - { - "prompt": "Picture of a dog", - "size": "1792x1024", - "quality": "standard", - "style": "natural", - }, - { - "prompt": "Picture of a dog", - "size": "1792x1024", - "quality": "standard", - "style": "natural", - }, - ), - ], -) -async def test_generate_image_service( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - service_data, - expected_args, -) -> None: - """Test generate image service.""" - service_data["config_entry"] = mock_config_entry.entry_id - expected_args["model"] = "dall-e-3" - expected_args["response_format"] = "url" - expected_args["n"] = 1 - - with patch( - "openai.resources.images.AsyncImages.generate", - new_callable=AsyncMock, - return_value=ImagesResponse( - created=1700000000, - data=[ - Image( - b64_json=None, - revised_prompt="A clear and detailed picture of an ordinary canine", - url="A", - ) - ], - ), - ) as mock_create: - response = await hass.services.async_call( - DOMAIN, - "generate_image", - service_data, - blocking=True, - return_response=True, - ) - - assert response == { - "url": "A", - "revised_prompt": "A clear and detailed picture of an ordinary canine", - } - assert len(mock_create.mock_calls) == 1 - assert mock_create.mock_calls[0][2] == expected_args - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_image_service_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test generate image service handles errors.""" - with ( - patch( - "openai.resources.images.AsyncImages.generate", - new_callable=AsyncMock, - side_effect=RateLimitError( - response=httpx.Response( - status_code=500, request=httpx.Request(method="GET", url="") - ), - body=None, - message="Reason", - ), - ), - pytest.raises(HomeAssistantError, match="Error generating image: Reason"), - ): - await hass.services.async_call( - DOMAIN, - "generate_image", - { - "config_entry": mock_config_entry.entry_id, - "prompt": "Image of an epic fail", - }, - blocking=True, - return_response=True, - ) - - with ( - patch( - "openai.resources.images.AsyncImages.generate", - new_callable=AsyncMock, - return_value=ImagesResponse( - created=1700000000, - data=[ - Image( - b64_json=None, - revised_prompt=None, - url=None, - ) - ], - ), - ), - pytest.raises(HomeAssistantError, match="No image returned"), - ): - await hass.services.async_call( - DOMAIN, - "generate_image", - { - "config_entry": mock_config_entry.entry_id, - "prompt": "Image of an epic fail", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_image_not_allowed_path( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test generate content service with an image in a not allowed path.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=False), - pytest.raises( - HomeAssistantError, - match=( - "Cannot read `doorbell_snapshot.jpg`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ), - ), - ): - await hass.services.async_call( - DOMAIN, - "generate_content", - { - "config_entry": mock_config_entry.entry_id, - "prompt": "Describe this image from my doorbell camera", - "filenames": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.parametrize( - ("service_name", "error"), - [ - ("generate_image", "Invalid config entry provided. Got invalid_entry"), - ("generate_content", "Invalid config entry provided. Got invalid_entry"), - ], -) -async def test_invalid_config_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - service_name: str, - error: str, -) -> None: - """Assert exception when invalid config entry is provided.""" - service_data = { - "prompt": "Picture of a dog", - "config_entry": "invalid_entry", - } - with pytest.raises(ServiceValidationError, match=error): - await hass.services.async_call( - DOMAIN, - service_name, - service_data, - blocking=True, - return_response=True, - ) - - @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -313,332 +97,6 @@ async def test_init_auth_error( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -@pytest.mark.parametrize("store_responses", [False, True]) -@pytest.mark.parametrize( - ("service_data", "expected_args", "number_of_files"), - [ - ( - {"prompt": "Picture of a dog", "filenames": []}, - { - "input": [ - { - "content": [ - { - "type": "input_text", - "text": "Picture of a dog", - }, - ], - }, - ], - }, - 0, - ), - ( - {"prompt": "Picture of a dog", "filenames": ["/a/b/c.pdf"]}, - { - "input": [ - { - "content": [ - { - "type": "input_text", - "text": "Picture of a dog", - }, - { - "type": "input_file", - "file_data": "data:application/pdf;base64,BASE64IMAGE1", - "filename": "/a/b/c.pdf", - }, - ], - }, - ], - }, - 1, - ), - ( - {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, - { - "input": [ - { - "content": [ - { - "type": "input_text", - "text": "Picture of a dog", - }, - { - "type": "input_image", - "image_url": "data:image/jpeg;base64,BASE64IMAGE1", - "detail": "auto", - }, - ], - }, - ], - }, - 1, - ), - ( - { - "prompt": "Picture of a dog", - "filenames": ["/a/b/c.jpg", "d/e/f.jpg"], - }, - { - "input": [ - { - "content": [ - { - "type": "input_text", - "text": "Picture of a dog", - }, - { - "type": "input_image", - "image_url": "data:image/jpeg;base64,BASE64IMAGE1", - "detail": "auto", - }, - { - "type": "input_image", - "image_url": "data:image/jpeg;base64,BASE64IMAGE2", - "detail": "auto", - }, - ], - }, - ], - }, - 2, - ), - ], -) -async def test_generate_content_service( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - store_responses: bool, - service_data, - expected_args, - number_of_files, -) -> None: - """Test generate content service.""" - conversation_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, - conversation_subentry, - data={**conversation_subentry.data, CONF_STORE_RESPONSES: store_responses}, - ) - await hass.async_block_till_done() - - service_data["config_entry"] = mock_config_entry.entry_id - expected_args["model"] = "gpt-4o-mini" - expected_args["max_output_tokens"] = 3000 - expected_args["top_p"] = 1.0 - expected_args["temperature"] = 1.0 - expected_args["user"] = None - expected_args["store"] = store_responses - expected_args["input"][0]["type"] = "message" - expected_args["input"][0]["role"] = "user" - - with ( - patch( - "openai.resources.responses.AsyncResponses.create", - new_callable=AsyncMock, - ) as mock_create, - patch( - "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] - ) as mock_b64encode, - patch("pathlib.Path.read_bytes", Mock(return_value=b"ABC")) as mock_file, - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - ): - 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="This is the response", - annotations=[], - ) - ], - role="assistant", - status="completed", - ) - ], - ) - - response = await hass.services.async_call( - DOMAIN, - "generate_content", - service_data, - blocking=True, - return_response=True, - ) - assert response == {"text": "This is the response"} - assert len(mock_create.mock_calls) == 1 - assert mock_create.mock_calls[0][2] == expected_args - assert mock_b64encode.call_count == number_of_files - assert mock_file.call_count == number_of_files - - -@pytest.mark.parametrize( - ( - "service_data", - "error", - "exists_side_effect", - "is_allowed_side_effect", - ), - [ - ( - {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, - "`/a/b/c.jpg` does not exist", - [False], - [True], - ), - ( - { - "prompt": "Picture of a dog", - "filenames": ["/a/b/c.jpg", "d/e/f.png"], - }, - "Cannot read `d/e/f.png`, no access to path;" - " `allowlist_external_dirs` may need to be adjusted" - " in `configuration.yaml`", - [True, True], - [True, False], - ), - ( - {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.mov"]}, - "Only images and PDF are supported by the OpenAI" - " API,`/a/b/c.mov` is not an image file or PDF", - [True], - [True], - ), - ], -) -async def test_generate_content_service_invalid( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - service_data, - error, - exists_side_effect, - is_allowed_side_effect, -) -> None: - """Test generate content service.""" - service_data["config_entry"] = mock_config_entry.entry_id - - with ( - patch( - "openai.resources.responses.AsyncResponses.create", - new_callable=AsyncMock, - ) as mock_create, - patch("base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"]), - patch("builtins.open", mock_open(read_data="ABC")), - patch("pathlib.Path.exists", side_effect=exists_side_effect), - patch.object( - hass.config, "is_allowed_path", side_effect=is_allowed_side_effect - ), - ): - with pytest.raises(HomeAssistantError, match=error): - await hass.services.async_call( - DOMAIN, - "generate_content", - service_data, - blocking=True, - return_response=True, - ) - assert len(mock_create.mock_calls) == 0 - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test generate content service handles errors.""" - with ( - patch( - "openai.resources.responses.AsyncResponses.create", - side_effect=RateLimitError( - response=httpx.Response( - status_code=417, request=httpx.Request(method="GET", url="") - ), - body=None, - message="Reason", - ), - ), - pytest.raises(HomeAssistantError, match="Error generating content: Reason"), - ): - await hass.services.async_call( - DOMAIN, - "generate_content", - { - "config_entry": mock_config_entry.entry_id, - "prompt": "Image of an epic fail", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.parametrize( - ("service_name", "patch_path"), - [ - ("generate_image", "openai.resources.images.AsyncImages.generate"), - ("generate_content", "openai.resources.responses.AsyncResponses.create"), - ], -) -@pytest.mark.usefixtures("mock_init_component") -async def test_service_auth_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - service_name: str, - patch_path: str, -) -> None: - """Test generate content service handles errors.""" - with ( - patch( - patch_path, - side_effect=AuthenticationError( - response=httpx.Response( - status_code=401, request=httpx.Request(method="GET", url="") - ), - body=None, - message="Reason", - ), - ), - pytest.raises(HomeAssistantError, match="Authentication error"), - ): - await hass.services.async_call( - DOMAIN, - service_name, - { - "config_entry": mock_config_entry.entry_id, - "prompt": "Image of an epic fail", - }, - blocking=True, - return_response=True, - ) - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow["step_id"] == "reauth_confirm" - assert flow["handler"] == DOMAIN - assert "context" in flow - assert flow["context"]["source"] == SOURCE_REAUTH - assert flow["context"]["entry_id"] == mock_config_entry.entry_id - - async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry,