mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 19:53:18 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b413a1b2 | |||
| b6df9336df | |||
| 6d63af03dd |
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user