From 550e53d19212e480562ea00fc4cc286becada1c3 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Tue, 7 Apr 2026 20:19:25 +0100 Subject: [PATCH] Add support for storing OpenAI conversation responses (#165723) --- .../openai_conversation/__init__.py | 6 ++- .../openai_conversation/config_flow.py | 10 ++++- .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 8 +++- .../openai_conversation/strings.json | 8 ++++ .../openai_conversation/test_ai_task.py | 21 +++++++++- .../openai_conversation/test_config_flow.py | 33 +++++++++++++-- .../openai_conversation/test_conversation.py | 41 +++++++++++++++++++ .../openai_conversation/test_init.py | 17 +++++++- 9 files changed, 137 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 44fed05e1365..28ada1a71d51 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -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"): diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 5843e2f36c8d..1a2bcbed4a65 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -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 {}) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 2acf2aa97915..5c45dee4d1ec 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -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" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 50a4f6f8f7e9..93d72d0b72ad 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -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 diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 178910ae0978..58e0f35ba3cc 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -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": { diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 990c4322a046..9cc7822ea729 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -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" diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index e5b3005d6efa..bf2f482ab6f8 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -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, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index a6abb29a3fb7..a6d0c64acd13 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -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, diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 4ed56812afaa..0da3842884b4 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -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"