Compare commits

..
3 Commits
Author SHA1 Message Date
pc-heiniandClaude Opus 5.5 6aed494e44 fix: describe local intent answers to the model instead of replaying them
validate / hassfest (push) Failing after 1m2s
validate / hacs (push) Failing after 1m11s
test / pytest (push) Successful in 1m36s
With prefer_local_intents, Home Assistant puts requests its local intents
answered into the chat log as external calls of intents like
HassGetCurrentTime. The model does not have these tools, and replayed as
its own calls they make it apologise for using tools it does not have and
doubt answers it never gave. They are now passed as a developer note with
the intent, its arguments and its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:35:26 +02:00
pc-heiniandClaude Opus 5.5 e54f0f0fa1 docs: list the location lookup fixes in the fork note
validate / hassfest (push) Failing after 10s
validate / hacs (push) Failing after 18s
test / pytest (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:52:47 +02:00
pc-heiniandClaude Opus 5.5 84f4bcda55 fix: make the home location lookup work against Claude
validate / hassfest (push) Failing after 11s
validate / hacs (push) Failing after 23s
test / pytest (push) Successful in 46s
- Send the location question as a user message. A request with only a
  system message has no user content, which endpoints in front of Anthropic
  models reject ("text content blocks must be non-empty").
- Leave unknown fields out of the web search user_location. After a failed
  lookup there is no city or region, and the empty strings made every
  request fail ("user_location.city: String should have at least 1
  character").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:52:14 +02:00
5 changed files with 143 additions and 10 deletions

No files matched your search

+7 -2
View File
@@ -15,8 +15,13 @@
> are not replayed in the history; they carry no results, and the model then disowns
> its own answers and searches again.
> - [#5](https://github.com/adamjs83/ha-openai-compatible/issues/5): the home location
> lookup for web search uses the subentry's model instead of `gpt-4o-mini`, and a
> failed lookup no longer blocks saving the options.
> lookup for web search uses the subentry's model instead of `gpt-4o-mini` and asks
> as a user message (Anthropic needs one); a failed lookup no longer blocks saving
> the options, and empty location fields are left out of the web search tool.
> - Requests that Home Assistant's local intents answered (`prefer_local_intents`) are
> described to the model in a note instead of being replayed as its own calls of
> intents like `HassGetCurrentTime`, which are not among its tools; replayed, they
> make it apologise for using tools it does not have. Not reported upstream yet.
>
> HACS only installs from GitHub, so this fork is installed by copying
> `custom_components/openai_compatible` into the Home Assistant config.
@@ -708,9 +708,11 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
try:
response = await client.responses.create(
model=self.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
# A user message, not a system one: endpoints in front of
# Anthropic models reject a request without user content.
input=[
{
"role": "system",
"role": "user",
"content": "Where are the following coordinates located: "
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
+39 -6
View File
@@ -180,13 +180,35 @@ def _convert_content_to_param(
"""Convert any native chat message for this agent to the native format."""
messages: ResponseInputParam = []
reasoning_summary: list[str] = []
# Requests that Home Assistant's local intents answered without the model
# (prefer_local_intents) are in the chat log as calls of intents like
# HassGetCurrentTime, which are not among the model's tools. Replayed as
# the model's own calls, they make it apologise for using tools it does
# not have, so they are described to it as a note instead.
local_calls: dict[str, llm.ToolInput] = {}
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
if (tool_call := local_calls.pop(content.tool_call_id, None)) is not None:
messages.append(
EasyInputMessageParam(
type="message",
role="developer",
content=(
"Home Assistant's local intent recognition answered "
"the last user request itself, without you: it ran "
f"{tool_call.tool_name} with "
f"{json_dumps(tool_call.tool_args)} and got "
f"{json_dumps(content.tool_result)}. The assistant "
"reply after this note is its wording, not yours. "
"That intent is not one of your tools."
),
)
)
# Earlier web searches are not replayed: the chat log only keeps
# their status, not their results, and a search without results
# makes the model disown the answer it based on that search.
if content.tool_name != "web_search_call":
elif content.tool_name != "web_search_call":
messages.append(
FunctionCallOutput(
type="function_call_output",
@@ -211,6 +233,9 @@ def _convert_content_to_param(
for tool_call in content.tool_calls:
if tool_call.external and tool_call.tool_name == "web_search_call":
continue
if tool_call.external and tool_call.tool_name != "code_interpreter":
local_calls[tool_call.id] = tool_call
continue
messages.append(
ResponseFunctionToolCallParam(
type="function_call",
@@ -560,12 +585,20 @@ class OpenAIBaseLLMEntity(Entity):
),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
# Leave out unknown fields: some endpoints reject empty strings,
# and the location lookup may have found no city or region.
location = {
key: value
for key, option in (
("city", CONF_WEB_SEARCH_CITY),
("region", CONF_WEB_SEARCH_REGION),
("country", CONF_WEB_SEARCH_COUNTRY),
("timezone", CONF_WEB_SEARCH_TIMEZONE),
)
if (value := options.get(option))
}
web_search["user_location"] = UserLocation(
type="approximate",
city=options.get(CONF_WEB_SEARCH_CITY, ""),
region=options.get(CONF_WEB_SEARCH_REGION, ""),
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
type="approximate", **location
)
if not options.get(
CONF_WEB_SEARCH_INLINE_CITATIONS,
+1
View File
@@ -1334,6 +1334,7 @@ async def test_subentry_web_search_user_location_lookup_fails(
)
await hass.async_block_till_done()
assert mock_create.call_args.kwargs["model"] == "claude-sonnet-5-5"
assert mock_create.call_args.kwargs["input"][0]["role"] == "user"
assert subentry_flow["type"] is FlowResultType.ABORT
assert subentry_flow["reason"] == "reconfigure_successful"
assert CONF_WEB_SEARCH_CITY not in subentry.data
+93 -1
View File
@@ -3,14 +3,21 @@
from unittest.mock import MagicMock
from homeassistant.components import conversation
from homeassistant.core import Context
from homeassistant.helpers import llm
from custom_components.openai_compatible.const import (
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
)
from custom_components.openai_compatible.entity import (
_convert_content_to_param,
_transform_stream,
)
from . import create_function_tool_call_item
from . import create_function_tool_call_item, create_message_item
async def _stream(events):
@@ -90,3 +97,88 @@ def test_web_search_not_replayed() -> None:
]
assert messages[1]["content"] == "It ended 2 to 2."
assert messages[2]["call_id"] == "call_1"
async def test_web_search_location_leaves_out_empty_fields(
hass, mock_config_entry, mock_init_component, mock_create_stream
) -> None:
"""Test unknown location fields are left out of the web search tool.
A failed location lookup stores no city or region; endpoints in front of
Anthropic models reject empty strings there.
"""
subentry = next(iter(mock_config_entry.subentries.values()))
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
**subentry.data,
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_USER_LOCATION: True,
CONF_WEB_SEARCH_COUNTRY: "DE",
CONF_WEB_SEARCH_TIMEZONE: "Europe/Berlin",
},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hi", output_index=0)
]
await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.openai_conversation"
)
tools = mock_create_stream.mock_calls[0][2]["tools"]
web_search = next(tool for tool in tools if tool["type"] == "web_search")
assert web_search["user_location"] == {
"type": "approximate",
"country": "DE",
"timezone": "Europe/Berlin",
}
def test_local_intent_replayed_as_note() -> None:
"""Test a request a local intent answered is described, not replayed as a call.
With prefer_local_intents, the chat log holds intents like
HassGetCurrentTime as external tool calls. They are not the model's tools,
and replayed as its own calls they make it apologise for using them.
"""
content = [
conversation.UserContent(content="What time is it?"),
conversation.AssistantContent(
agent_id="conversation.test",
tool_calls=[
llm.ToolInput(
id="intent_1",
tool_name="HassGetCurrentTime",
tool_args={},
external=True,
)
],
),
conversation.ToolResultContent(
agent_id="conversation.test",
tool_call_id="intent_1",
tool_name="HassGetCurrentTime",
result=llm.ToolResult(data={"speech": {"plain": {"speech": "10:29"}}}),
),
conversation.AssistantContent(
agent_id="conversation.test", content="It is 10:29"
),
conversation.UserContent(content="What is your name?"),
]
messages = _convert_content_to_param(content)
assert [message["type"] for message in messages] == ["message"] * 4
assert [message["role"] for message in messages] == [
"user",
"developer",
"assistant",
"user",
]
note = messages[1]["content"]
assert "HassGetCurrentTime" in note
assert "10:29" in note
assert "not one of your tools" in note