Compare commits

...

3 Commits

Author SHA1 Message Date
epenet 39b413a1b2 Improve 2026-04-27 07:14:26 +00:00
epenet b6df9336df Cleanup icons.json 2026-04-27 07:11:44 +00:00
epenet 6d63af03dd Remove deprecated openai_conversation actions 2026-04-27 06:38:49 +00:00
6 changed files with 5 additions and 911 deletions
@@ -2,53 +2,23 @@
from __future__ import annotations
from pathlib import Path
from types import MappingProxyType
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, Platform
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
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 ConfigType
from .const import (
CONF_CHAT_MODEL,
CONF_FILENAMES,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_AI_TASK_NAME,
DEFAULT_NAME,
DEFAULT_STT_NAME,
@@ -56,19 +26,9 @@ from .const import (
DOMAIN,
LOGGER,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
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 +40,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
@@ -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_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
@@ -1,10 +0,0 @@
{
"services": {
"generate_content": {
"service": "mdi:receipt-text"
},
"generate_image": {
"service": "mdi:image-sync"
}
}
}
@@ -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
@@ -209,20 +209,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)"
}
}
}
@@ -1,25 +1,16 @@
"""Tests for the OpenAI integration."""
from typing import Any
from unittest.mock import AsyncMock, Mock, mock_open, patch
from unittest.mock import patch
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
from homeassistant.components.openai_conversation import CONF_CHAT_MODEL
from homeassistant.components.openai_conversation.const import (
CONF_STORE_RESPONSES,
CONF_CHAT_MODEL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_STT_NAME,
@@ -31,14 +22,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
@@ -47,211 +36,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(
"openai_conversation",
"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(
"openai_conversation",
"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(
"openai_conversation",
"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(
"openai_conversation",
"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(
"openai_conversation",
service_name,
service_data,
blocking=True,
return_response=True,
)
@pytest.mark.parametrize(
("side_effect", "error"),
[
@@ -309,329 +93,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(
"openai_conversation",
"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(
"openai_conversation",
"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(
"openai_conversation",
"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(
"openai_conversation",
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,