1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-25 09:49:52 +01:00

Fix reasoning summary handling for OpenAI o-models (#168093)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Denis Shulyaka <Shulyaka@gmail.com>
This commit is contained in:
jftkcs
2026-05-02 02:02:51 -07:00
committed by Paulus Schoutsen
parent 3b00c5bb96
commit f6aa4e2092
8 changed files with 398 additions and 41 deletions
@@ -46,6 +46,7 @@ from .const import (
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
@@ -59,6 +60,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
@@ -490,6 +492,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
_add_stt_subentry(hass, entry)
hass.config_entries.async_update_entry(entry, minor_version=6)
if entry.version == 2 and entry.minor_version == 6:
for subentry in entry.subentries.values():
if subentry.subentry_type in ("conversation", "ai_task_data"):
data = dict(subentry.data)
updated = False
if data.get(CONF_REASONING_SUMMARY) == "short":
data[CONF_REASONING_SUMMARY] = "concise"
updated = True
if data.get(CONF_REASONING_SUMMARY) == "concise" and not data.get(
CONF_CHAT_MODEL, ""
).startswith("gpt-5"):
data[CONF_REASONING_SUMMARY] = RECOMMENDED_REASONING_SUMMARY
updated = True
if updated:
hass.config_entries.async_update_subentry(
entry, subentry, data=data
)
hass.config_entries.async_update_entry(entry, minor_version=7)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
@@ -127,7 +127,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenAI Conversation."""
VERSION = 2
MINOR_VERSION = 6
MINOR_VERSION = 7
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -435,23 +435,37 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
mode=SelectSelectorMode.DROPDOWN,
)
),
}
)
elif CONF_VERBOSITY in options:
options.pop(CONF_VERBOSITY)
if model.startswith(("o", "gpt-5")):
reasoning_summary_options = ["off", "auto", "concise", "detailed"]
if model.startswith("o"):
reasoning_summary_options.remove("concise")
stored_summary = options.get(
CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY
)
if stored_summary not in reasoning_summary_options:
stored_summary = RECOMMENDED_REASONING_SUMMARY
options[CONF_REASONING_SUMMARY] = stored_summary
step_schema.update(
{
vol.Optional(
CONF_REASONING_SUMMARY,
default=RECOMMENDED_REASONING_SUMMARY,
default=stored_summary,
): SelectSelector(
SelectSelectorConfig(
options=["off", "auto", "short", "detailed"],
options=reasoning_summary_options,
translation_key=CONF_REASONING_SUMMARY,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
)
elif CONF_VERBOSITY in options:
options.pop(CONF_VERBOSITY)
if CONF_REASONING_SUMMARY in options:
if not model.startswith("gpt-5"):
options.pop(CONF_REASONING_SUMMARY)
elif CONF_REASONING_SUMMARY in options:
options.pop(CONF_REASONING_SUMMARY)
service_tiers = self._get_service_tiers(model)
if "flex" in service_tiers or "priority" in service_tiers:
@@ -43,7 +43,10 @@ from openai.types.responses import (
ToolParam,
WebSearchToolParam,
)
from openai.types.responses.response_create_params import ResponseCreateParamsStreaming
from openai.types.responses.response_create_params import (
Reasoning,
ResponseCreateParamsStreaming,
)
from openai.types.responses.response_input_param import (
FunctionCallOutput,
ImageGenerationCall as ImageGenerationCallParam,
@@ -520,16 +523,19 @@ class OpenAIBaseLLMEntity(Entity):
)
if model_args["model"].startswith(("o", "gpt-5")):
model_args["reasoning"] = {
reasoning: Reasoning = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
)
if not model_args["model"].startswith("gpt-5-pro")
else "high", # GPT-5 pro only supports reasoning.effort: high
"summary": options.get(
CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY
),
}
reasoning_summary = options.get(
CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY
)
if reasoning_summary != "off":
reasoning["summary"] = reasoning_summary
model_args["reasoning"] = reasoning
model_args["include"] = ["reasoning.encrypted_content"]
if (
@@ -242,9 +242,9 @@
"reasoning_summary": {
"options": {
"auto": "[%key:common::state::auto%]",
"concise": "Concise",
"detailed": "Detailed",
"off": "[%key:common::state::off%]",
"short": "Short"
"off": "[%key:common::state::off%]"
}
},
"search_context_size": {
@@ -58,7 +58,7 @@ def mock_config_entry(
"api_key": "bla",
},
version=2,
minor_version=6,
minor_version=7,
subentries_data=[
ConfigSubentryData(
data=mock_conversation_subentry_data,
@@ -119,7 +119,7 @@ async def test_form(hass: HomeAssistant) -> None:
},
]
assert result2["version"] == 2
assert result2["minor_version"] == 6
assert result2["minor_version"] == 7
assert len(mock_setup_entry.mock_calls) == 1
@@ -278,10 +278,10 @@ async def test_subentry_unsupported_model(
)
async def test_subentry_reasoning_effort_list(
hass: HomeAssistant,
mock_config_entry,
mock_init_component,
model,
reasoning_effort_options,
mock_config_entry: MockConfigEntry,
mock_init_component: None,
model: str,
reasoning_effort_options: list[str],
) -> None:
"""Test the list reasoning effort options."""
subentry = next(iter(mock_config_entry.subentries.values()))
@@ -318,6 +318,152 @@ async def test_subentry_reasoning_effort_list(
)
@pytest.mark.parametrize(
("model", "has_reasoning_summary"),
[
("o3", True),
("o4-mini", True),
("gpt-5", True),
("gpt-5-mini", True),
("gpt-5-pro", True),
("gpt-4o", False),
("gpt-4.1", False),
],
)
async def test_subentry_reasoning_summary_visibility(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component: None,
model: str,
has_reasoning_summary: bool,
) -> None:
"""Test that reasoning_summary option is shown for all reasoning models."""
subentry = next(iter(mock_config_entry.subentries.values()))
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "init"
# Configure initial step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: ["assist"],
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "advanced"
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_CHAT_MODEL: model,
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "model"
assert (CONF_REASONING_SUMMARY in subentry_flow["data_schema"].schema) == (
has_reasoning_summary
)
@pytest.mark.parametrize(
("model", "reasoning_summary_options"),
[
("o3", ["off", "auto", "detailed"]),
("o4-mini", ["off", "auto", "detailed"]),
("gpt-5", ["off", "auto", "concise", "detailed"]),
("gpt-5-mini", ["off", "auto", "concise", "detailed"]),
],
)
async def test_subentry_reasoning_summary_options(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component: None,
model: str,
reasoning_summary_options: list[str],
) -> None:
"""Test the list of reasoning summary options for reasoning models."""
subentry = next(iter(mock_config_entry.subentries.values()))
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "init"
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: ["assist"],
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "advanced"
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_CHAT_MODEL: model,
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "model"
assert (
subentry_flow["data_schema"].schema[CONF_REASONING_SUMMARY].config["options"]
== reasoning_summary_options
)
async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component: None,
) -> None:
"""Test that a stored 'concise' default is sanitized to 'auto' for o* models."""
subentry = next(
s
for s in mock_config_entry.subentries.values()
if s.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={**subentry.data, CONF_REASONING_SUMMARY: "concise"},
)
await hass.async_block_till_done()
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
assert subentry_flow["step_id"] == "init"
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: ["assist"],
},
)
assert subentry_flow["step_id"] == "advanced"
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{CONF_CHAT_MODEL: "o3"},
)
assert subentry_flow["step_id"] == "model"
schema = subentry_flow["data_schema"].schema
summary_key = next(k for k in schema if k == CONF_REASONING_SUMMARY)
assert summary_key.default() == RECOMMENDED_REASONING_SUMMARY
@pytest.mark.parametrize(
("model", "service_tier_options"),
[
@@ -544,6 +690,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_MAX_TOKENS: 10000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "high",
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
CONF_CODE_INTERPRETER: True,
},
),
@@ -810,6 +957,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "low",
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
CONF_CODE_INTERPRETER: True,
},
),
@@ -823,6 +971,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_REASONING_EFFORT: "low",
CONF_REASONING_SUMMARY: "auto",
CONF_SERVICE_TIER: "flex",
CONF_CODE_INTERPRETER: True,
CONF_VERBOSITY: "medium",
@@ -19,7 +19,9 @@ from homeassistant.components import conversation
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.intent import async_register_timer_handler
from homeassistant.components.openai_conversation.const import (
CONF_CHAT_MODEL,
CONF_CODE_INTERPRETER,
CONF_REASONING_SUMMARY,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_WEB_SEARCH,
@@ -385,6 +387,46 @@ async def test_function_call_without_reasoning(
assert mock_chat_log.content[1:] == snapshot
@freeze_time("2025-10-31 18:00:00")
async def test_reasoning_summary_off_omits_summary_key(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component: None,
mock_create_stream: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811
) -> None:
"""Test that reasoning summary 'off' omits the summary key from the API call."""
conversation_subentry = next(
subentry
for subentry in mock_config_entry.subentries.values()
if subentry.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry,
conversation_subentry,
data={
CONF_CHAT_MODEL: "o4-mini",
CONF_REASONING_SUMMARY: "off",
},
)
await hass.async_block_till_done()
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hello", output_index=0),
]
await conversation.async_converse(
hass,
"Hello",
mock_chat_log.conversation_id,
Context(),
agent_id="conversation.openai_conversation",
)
reasoning = mock_create_stream.call_args.kwargs["reasoning"]
assert "summary" not in reasoning
@pytest.mark.parametrize(
("description", "messages"),
[
+145 -20
View File
@@ -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_REASONING_SUMMARY,
CONF_STORE_RESPONSES,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
@@ -680,7 +681,7 @@ async def test_migration_from_v1(
await hass.async_block_till_done()
assert mock_config_entry.version == 2
assert mock_config_entry.minor_version == 6
assert mock_config_entry.minor_version == 7
assert mock_config_entry.data == {"api_key": "1234"}
assert mock_config_entry.options == {}
@@ -825,7 +826,7 @@ async def test_migration_from_v1_with_multiple_keys(
for idx, entry in enumerate(entries):
assert entry.version == 2
assert entry.minor_version == 6
assert entry.minor_version == 7
assert not entry.options
assert len(entry.subentries) == 4
@@ -930,7 +931,7 @@ async def test_migration_from_v1_with_same_keys(
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 6
assert entry.minor_version == 7
assert not entry.options
assert (
len(entry.subentries) == 5
@@ -1142,7 +1143,7 @@ async def test_migration_from_v1_disabled(
assert entry.disabled_by is merged_config_entry_disabled_by
assert entry.version == 2
assert entry.minor_version == (
4 if merged_config_entry_disabled_by is not None else 6
4 if merged_config_entry_disabled_by is not None else 7
)
assert not entry.options
assert entry.title == "OpenAI Conversation"
@@ -1315,7 +1316,7 @@ async def test_migration_from_v2_1(
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 6
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 5 # 2 conversation + 1 AI task + 1 STT + 1 TTS
@@ -1408,15 +1409,18 @@ async def test_devices(
)
assert len(devices) == 4 # One for conversation, AI task, STT, and TTS
# Find the conversation device for snapshot comparison
device = next(d for d in devices if d.name == "OpenAI Conversation")
assert device == snapshot(exclude=props("identifiers"))
# Verify the device has identifiers matching one of the subentries
expected_identifiers = [
{(DOMAIN, subentry.subentry_id)}
# Find the conversation subentry device specifically, since device ordering
# from concurrent platform setup is non-deterministic.
conversation_subentry = next(
subentry
for subentry in mock_config_entry.subentries.values()
]
assert device.identifiers in expected_identifiers
if subentry.subentry_type == "conversation"
)
device = device_registry.async_get_device(
identifiers={(DOMAIN, conversation_subentry.subentry_id)}
)
assert device is not None
assert device == snapshot(exclude=props("identifiers"))
async def test_migration_from_v2_2(
@@ -1463,7 +1467,7 @@ async def test_migration_from_v2_2(
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 6
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 4
@@ -1508,7 +1512,7 @@ async def test_migration_from_v2_2(
DeviceEntryDisabler.CONFIG_ENTRY,
RegistryEntryDisabler.CONFIG_ENTRY,
True,
6,
7,
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
@@ -1518,7 +1522,7 @@ async def test_migration_from_v2_2(
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
True,
6,
7,
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
@@ -1528,7 +1532,7 @@ async def test_migration_from_v2_2(
DeviceEntryDisabler.USER,
RegistryEntryDisabler.USER,
True,
6,
7,
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.USER,
@@ -1538,7 +1542,7 @@ async def test_migration_from_v2_2(
None,
None,
True,
6,
7,
None,
None,
None,
@@ -1730,7 +1734,7 @@ async def test_migration_from_v2_4(
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 6
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 4
@@ -1833,7 +1837,7 @@ async def test_migration_from_v2_5(
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 6
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 4
@@ -1878,3 +1882,124 @@ async def test_migration_from_v2_5(
stt_subentry = stt_subentries[0]
assert stt_subentry.data == {}
assert stt_subentry.title == "OpenAI STT"
async def test_migration_from_v2_6(
hass: HomeAssistant,
) -> None:
"""Test migration from version 2.6.
Ensures reasoning_summary "short" is renamed to "concise" for gpt-5 models,
and that "concise" (whether from "short" or already stored) is reset to "auto"
for o* models where it is unsupported. Other values and unrelated subentries
are unchanged.
"""
conversation_options_short_o = {
"chat_model": "o4-mini",
CONF_REASONING_SUMMARY: "short",
}
conversation_options_auto = {
"chat_model": "gpt-5-mini",
CONF_REASONING_SUMMARY: "auto",
}
ai_task_options_short_o = {
"chat_model": "o3",
CONF_REASONING_SUMMARY: "short",
}
tts_options = {
"chat_model": "gpt-4o-mini-tts",
}
conversation_options_short_gpt5 = {
"chat_model": "gpt-5",
CONF_REASONING_SUMMARY: "short",
}
conversation_options_concise_o = {
"chat_model": "o3",
CONF_REASONING_SUMMARY: "concise",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
entry_id="mock_entry_id",
version=2,
minor_version=6,
subentries_data=[
ConfigSubentryData(
data=conversation_options_short_o,
subentry_id="mock_id_1",
subentry_type="conversation",
title="ChatGPT short o",
unique_id=None,
),
ConfigSubentryData(
data=conversation_options_auto,
subentry_id="mock_id_2",
subentry_type="conversation",
title="ChatGPT auto",
unique_id=None,
),
ConfigSubentryData(
data=ai_task_options_short_o,
subentry_id="mock_id_3",
subentry_type="ai_task_data",
title="OpenAI AI Task",
unique_id=None,
),
ConfigSubentryData(
data=tts_options,
subentry_id="mock_id_4",
subentry_type="tts",
title="OpenAI TTS",
unique_id=None,
),
ConfigSubentryData(
data=conversation_options_short_gpt5,
subentry_id="mock_id_5",
subentry_type="conversation",
title="ChatGPT short gpt5",
unique_id=None,
),
ConfigSubentryData(
data=conversation_options_concise_o,
subentry_id="mock_id_6",
subentry_type="conversation",
title="ChatGPT concise o",
unique_id=None,
),
],
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
subentries_by_id = entry.subentries
# "short" on an o* model: short→concise→auto (concise unsupported on o*)
assert subentries_by_id["mock_id_1"].data[CONF_REASONING_SUMMARY] == "auto"
# "auto" on a gpt-5 model: unchanged
assert subentries_by_id["mock_id_2"].data[CONF_REASONING_SUMMARY] == "auto"
# "short" on an o* ai_task_data subentry: short→concise→auto
assert subentries_by_id["mock_id_3"].data[CONF_REASONING_SUMMARY] == "auto"
# TTS subentry is unaffected
assert subentries_by_id["mock_id_4"].data == tts_options
# "short" on a gpt-5 model: short→concise (concise is valid for gpt-5)
assert subentries_by_id["mock_id_5"].data[CONF_REASONING_SUMMARY] == "concise"
# "concise" already stored on an o* model: concise→auto
assert subentries_by_id["mock_id_6"].data[CONF_REASONING_SUMMARY] == "auto"