1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-10 16:19:29 +01:00

Add support for storing OpenAI conversation responses (#165723)

This commit is contained in:
Oliver Verity
2026-04-07 20:19:25 +01:00
committed by GitHub
parent 09ee76c265
commit 550e53d192
9 changed files with 137 additions and 9 deletions
@@ -46,6 +46,7 @@ from .const import (
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_AI_TASK_NAME,
@@ -58,6 +59,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
@@ -208,7 +210,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
"user": call.context.user_id,
"store": False,
"store": conversation_subentry.data.get(
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
),
}
if model.startswith("o"):
@@ -55,6 +55,7 @@ from .const import (
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
@@ -82,6 +83,7 @@ from .const import (
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_MODEL,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
@@ -357,6 +359,10 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
CONF_TEMPERATURE,
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_STORE_RESPONSES,
default=RECOMMENDED_STORE_RESPONSES,
): bool,
}
if user_input is not None:
@@ -641,7 +647,9 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
"strict": False,
}
},
store=False,
store=self.options.get(
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
),
)
location_data = location_schema(json.loads(response.output_text) or {})
@@ -24,6 +24,7 @@ CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_REASONING_SUMMARY = "reasoning_summary"
CONF_RECOMMENDED = "recommended"
CONF_STORE_RESPONSES = "store_responses"
CONF_SERVICE_TIER = "service_tier"
CONF_TEMPERATURE = "temperature"
CONF_TOP_P = "top_p"
@@ -42,6 +43,7 @@ RECOMMENDED_CHAT_MODEL = "gpt-4o-mini"
RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_STORE_RESPONSES = False
RECOMMENDED_REASONING_SUMMARY = "auto"
RECOMMENDED_SERVICE_TIER = "auto"
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
@@ -75,6 +75,7 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_VERBOSITY,
@@ -94,6 +95,7 @@ from .const import (
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_MODEL,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
@@ -508,7 +510,7 @@ class OpenAIBaseLLMEntity(Entity):
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
user=chat_log.conversation_id,
service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER),
store=False,
store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES),
stream=True,
)
@@ -611,8 +613,10 @@ class OpenAIBaseLLMEntity(Entity):
if image_model != "gpt-image-1-mini":
image_tool["input_fidelity"] = "high"
tools.append(image_tool)
# Keep image state on OpenAI so follow-up prompts can continue by
# conversation ID without resending the generated image data.
model_args["store"] = True
model_args["tool_choice"] = ToolChoiceTypesParam(type="image_generation")
model_args["store"] = True # Avoid sending image data back and forth
if tools:
model_args["tools"] = tools
@@ -51,9 +51,13 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]",
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::store_responses%]",
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]",
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]"
},
"data_description": {
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data_description::store_responses%]"
},
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]"
},
"init": {
@@ -109,9 +113,13 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "Maximum tokens to return in response",
"store_responses": "Store requests and responses in OpenAI",
"temperature": "Temperature",
"top_p": "Top P"
},
"data_description": {
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
},
"title": "Advanced settings"
},
"init": {
@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.components import ai_task, media_source
from homeassistant.components.openai_conversation import DOMAIN
from homeassistant.components.openai_conversation.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
@@ -20,11 +21,13 @@ from tests.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"
@@ -38,6 +41,12 @@ async def test_generate_data(
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
@@ -55,6 +64,8 @@ async def test_generate_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")
@@ -212,6 +223,7 @@ async def test_generate_data_with_attachments(
@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", ["gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini"]
)
@@ -222,6 +234,7 @@ async def test_generate_image(
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
image_model: str,
configured_store: bool,
) -> None:
"""Test AI Task image generation."""
entity_id = "ai_task.openai_ai_task"
@@ -238,7 +251,11 @@ async def test_generate_image(
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={"image_model": image_model},
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
@@ -277,6 +294,8 @@ async def test_generate_image(
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_data = mock_upload_media.call_args[0][1]
assert image_data.file.getvalue() == b"A"
assert image_data.content_type == "image/png"
@@ -21,6 +21,7 @@ from homeassistant.components.openai_conversation.const import (
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
@@ -539,6 +540,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "o1-pro",
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_MAX_TOKENS: 10000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "high",
CONF_CODE_INTERPRETER: True,
},
@@ -575,6 +577,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
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",
@@ -592,6 +595,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
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,
@@ -613,6 +617,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-4o",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: True,
},
{
CONF_WEB_SEARCH: True,
@@ -630,6 +635,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
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",
@@ -686,6 +692,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-5",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "minimal",
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
CONF_CODE_INTERPRETER: False,
@@ -799,6 +806,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "o3-mini",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "low",
CONF_CODE_INTERPRETER: True,
},
@@ -844,6 +852,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
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",
@@ -896,6 +905,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
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,
@@ -915,7 +925,11 @@ async def test_subentry_switching(
expected_options,
) -> None:
"""Test the subentry form."""
subentry = next(iter(mock_config_entry.subentries.values()))
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
)
@@ -952,11 +966,19 @@ async def test_subentry_switching(
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
hass: HomeAssistant,
mock_config_entry,
mock_init_component,
store_responses: bool,
) -> None:
"""Test fetching user location."""
subentry = next(iter(mock_config_entry.subentries.values()))
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
)
@@ -982,6 +1004,7 @@ async def test_subentry_web_search_user_location(
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()
@@ -1036,6 +1059,7 @@ async def test_subentry_web_search_user_location(
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 == {
@@ -1046,6 +1070,7 @@ async def test_subentry_web_search_user_location(
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,
@@ -1149,6 +1174,7 @@ async def test_creating_ai_task_subentry_advanced(
{
CONF_CHAT_MODEL: "gpt-4o",
CONF_MAX_TOKENS: 200,
CONF_STORE_RESPONSES: True,
CONF_TEMPERATURE: 0.5,
CONF_TOP_P: 0.9,
},
@@ -1172,6 +1198,7 @@ async def test_creating_ai_task_subentry_advanced(
CONF_CHAT_MODEL: "gpt-4o",
CONF_IMAGE_MODEL: "gpt-image-1.5",
CONF_MAX_TOKENS: 200,
CONF_STORE_RESPONSES: True,
CONF_TEMPERATURE: 0.5,
CONF_TOP_P: 0.9,
CONF_CODE_INTERPRETER: False,
@@ -20,6 +20,7 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose
from homeassistant.components.openai_conversation.const import (
CONF_CODE_INTERPRETER,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_CONTEXT_SIZE,
@@ -472,6 +473,46 @@ async def test_assist_api_tools_conversion(
assert tools
@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 == intent.IntentResponseType.ACTION_DONE
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is expected_store
@pytest.mark.parametrize("inline_citations", [True, False])
async def test_web_search(
hass: HomeAssistant,
@@ -19,6 +19,7 @@ from syrupy.filters import props
from homeassistant.components.openai_conversation import CONF_CHAT_MODEL
from homeassistant.components.openai_conversation.const import (
CONF_STORE_RESPONSES,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_STT_NAME,
@@ -308,6 +309,7 @@ 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"),
[
@@ -404,18 +406,31 @@ 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"] = False
expected_args["store"] = store_responses
expected_args["input"][0]["type"] = "message"
expected_args["input"][0]["role"] = "user"