Compare commits
3
Commits
235c536cab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aed494e44 | ||
|
|
e54f0f0fa1 | ||
|
|
84f4bcda55 |
No files matched your search
@@ -15,8 +15,13 @@
|
|||||||
> are not replayed in the history; they carry no results, and the model then disowns
|
> are not replayed in the history; they carry no results, and the model then disowns
|
||||||
> its own answers and searches again.
|
> its own answers and searches again.
|
||||||
> - [#5](https://github.com/adamjs83/ha-openai-compatible/issues/5): the home location
|
> - [#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
|
> lookup for web search uses the subentry's model instead of `gpt-4o-mini` and asks
|
||||||
> failed lookup no longer blocks saving the options.
|
> 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
|
> HACS only installs from GitHub, so this fork is installed by copying
|
||||||
> `custom_components/openai_compatible` into the Home Assistant config.
|
> `custom_components/openai_compatible` into the Home Assistant config.
|
||||||
|
|||||||
@@ -708,9 +708,11 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
try:
|
try:
|
||||||
response = await client.responses.create(
|
response = await client.responses.create(
|
||||||
model=self.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
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=[
|
input=[
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "user",
|
||||||
"content": "Where are the following coordinates located: "
|
"content": "Where are the following coordinates located: "
|
||||||
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
|
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
|
||||||
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
|
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
|
||||||
|
|||||||
@@ -180,13 +180,35 @@ def _convert_content_to_param(
|
|||||||
"""Convert any native chat message for this agent to the native format."""
|
"""Convert any native chat message for this agent to the native format."""
|
||||||
messages: ResponseInputParam = []
|
messages: ResponseInputParam = []
|
||||||
reasoning_summary: list[str] = []
|
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:
|
for content in chat_content:
|
||||||
if isinstance(content, conversation.ToolResultContent):
|
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
|
# Earlier web searches are not replayed: the chat log only keeps
|
||||||
# their status, not their results, and a search without results
|
# their status, not their results, and a search without results
|
||||||
# makes the model disown the answer it based on that search.
|
# 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(
|
messages.append(
|
||||||
FunctionCallOutput(
|
FunctionCallOutput(
|
||||||
type="function_call_output",
|
type="function_call_output",
|
||||||
@@ -211,6 +233,9 @@ def _convert_content_to_param(
|
|||||||
for tool_call in content.tool_calls:
|
for tool_call in content.tool_calls:
|
||||||
if tool_call.external and tool_call.tool_name == "web_search_call":
|
if tool_call.external and tool_call.tool_name == "web_search_call":
|
||||||
continue
|
continue
|
||||||
|
if tool_call.external and tool_call.tool_name != "code_interpreter":
|
||||||
|
local_calls[tool_call.id] = tool_call
|
||||||
|
continue
|
||||||
messages.append(
|
messages.append(
|
||||||
ResponseFunctionToolCallParam(
|
ResponseFunctionToolCallParam(
|
||||||
type="function_call",
|
type="function_call",
|
||||||
@@ -560,12 +585,20 @@ class OpenAIBaseLLMEntity(Entity):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
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(
|
web_search["user_location"] = UserLocation(
|
||||||
type="approximate",
|
type="approximate", **location
|
||||||
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, ""),
|
|
||||||
)
|
)
|
||||||
if not options.get(
|
if not options.get(
|
||||||
CONF_WEB_SEARCH_INLINE_CITATIONS,
|
CONF_WEB_SEARCH_INLINE_CITATIONS,
|
||||||
|
|||||||
@@ -1334,6 +1334,7 @@ async def test_subentry_web_search_user_location_lookup_fails(
|
|||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert mock_create.call_args.kwargs["model"] == "claude-sonnet-5-5"
|
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["type"] is FlowResultType.ABORT
|
||||||
assert subentry_flow["reason"] == "reconfigure_successful"
|
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||||
assert CONF_WEB_SEARCH_CITY not in subentry.data
|
assert CONF_WEB_SEARCH_CITY not in subentry.data
|
||||||
|
|||||||
+93
-1
@@ -3,14 +3,21 @@
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
|
from homeassistant.core import Context
|
||||||
from homeassistant.helpers import llm
|
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 (
|
from custom_components.openai_compatible.entity import (
|
||||||
_convert_content_to_param,
|
_convert_content_to_param,
|
||||||
_transform_stream,
|
_transform_stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import create_function_tool_call_item
|
from . import create_function_tool_call_item, create_message_item
|
||||||
|
|
||||||
|
|
||||||
async def _stream(events):
|
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[1]["content"] == "It ended 2 to 2."
|
||||||
assert messages[2]["call_id"] == "call_1"
|
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
|
||||||
Reference in new issue
Block a user