fix: make the integration work against a Claude proxy
- Treat an empty tool argument string as no parameters instead of raising JSONDecodeError; the proxy sends "" for tools like the timer status (upstream issue #3). - Do not replay earlier web searches in the history. The chat log keeps only their status, and a search without results makes the model disown the answers it based on it and search again (upstream issue #4). - Look up the home location for web search with the subentry's model instead of gpt-4o-mini, and save the options without city/region if the lookup fails (upstream issue #5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8737e5d4ec
commit
235c536cab
6 files changed
+224
-70
No files matched your search
@@ -4,6 +4,23 @@
|
|||||||
[](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml)
|
[](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml)
|
||||||
[](https://hacs.xyz)
|
[](https://hacs.xyz)
|
||||||
|
|
||||||
|
> **Fork of [adamjs83/ha-openai-compatible](https://github.com/adamjs83/ha-openai-compatible) v0.2.1**
|
||||||
|
> with fixes for running it against a proxy that serves Claude models. Drop it once
|
||||||
|
> upstream has fixed these issues:
|
||||||
|
>
|
||||||
|
> - [#3](https://github.com/adamjs83/ha-openai-compatible/issues/3): a tool call with an
|
||||||
|
> empty argument string (tools without parameters) is treated as `{}` instead of
|
||||||
|
> raising `JSONDecodeError`.
|
||||||
|
> - [#4](https://github.com/adamjs83/ha-openai-compatible/issues/4): earlier web searches
|
||||||
|
> 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.
|
||||||
|
>
|
||||||
|
> HACS only installs from GitHub, so this fork is installed by copying
|
||||||
|
> `custom_components/openai_compatible` into the Home Assistant config.
|
||||||
|
|
||||||
Home Assistant's built-in **OpenAI** integration, with one change that matters: the
|
Home Assistant's built-in **OpenAI** integration, with one change that matters: the
|
||||||
**API base URL is configurable**. Point Home Assistant's conversation agent, AI Task,
|
**API base URL is configurable**. Point Home Assistant's conversation agent, AI Task,
|
||||||
speech-to-text and text-to-speech at any OpenAI-compatible endpoint — LiteLLM, vLLM,
|
speech-to-text and text-to-speech at any OpenAI-compatible endpoint — LiteLLM, vLLM,
|
||||||
|
|||||||
@@ -702,31 +702,39 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
): str,
|
): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
response = await client.responses.create(
|
# Ask the subentry's own model: the endpoint may not serve
|
||||||
model=RECOMMENDED_CHAT_MODEL,
|
# RECOMMENDED_CHAT_MODEL (an OpenAI name). A failed lookup only
|
||||||
input=[
|
# costs the city/region hint, so it must not block saving.
|
||||||
{
|
try:
|
||||||
"role": "system",
|
response = await client.responses.create(
|
||||||
"content": "Where are the following coordinates located: "
|
model=self.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||||
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
|
input=[
|
||||||
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
|
{
|
||||||
}
|
"role": "system",
|
||||||
],
|
"content": "Where are the following coordinates located: "
|
||||||
text={
|
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
|
||||||
"format": {
|
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
|
||||||
"type": "json_schema",
|
}
|
||||||
"name": "approximate_location",
|
],
|
||||||
"description": "Approximate location data of the user "
|
text={
|
||||||
"for refined web search results",
|
"format": {
|
||||||
"schema": to_openapi(location_schema),
|
"type": "json_schema",
|
||||||
"strict": False,
|
"name": "approximate_location",
|
||||||
}
|
"description": "Approximate location data of the user "
|
||||||
},
|
"for refined web search results",
|
||||||
store=self.options.get(
|
"schema": to_openapi(location_schema),
|
||||||
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
|
"strict": False,
|
||||||
),
|
}
|
||||||
)
|
},
|
||||||
location_data = location_schema(json.loads(response.output_text) or {})
|
store=self.options.get(
|
||||||
|
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
|
||||||
|
),
|
||||||
|
)
|
||||||
|
location_data = location_schema(json.loads(response.output_text) or {})
|
||||||
|
except (openai.OpenAIError, ValueError, vol.Invalid) as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not look up the home location for web search: %s", err
|
||||||
|
)
|
||||||
|
|
||||||
if self.hass.config.country:
|
if self.hass.config.country:
|
||||||
location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
|
location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from openai.types.responses import (
|
|||||||
ResponseFunctionToolCall,
|
ResponseFunctionToolCall,
|
||||||
ResponseFunctionToolCallParam,
|
ResponseFunctionToolCallParam,
|
||||||
ResponseFunctionWebSearch,
|
ResponseFunctionWebSearch,
|
||||||
ResponseFunctionWebSearchParam,
|
|
||||||
ResponseIncompleteEvent,
|
ResponseIncompleteEvent,
|
||||||
ResponseInputFileParam,
|
ResponseInputFileParam,
|
||||||
ResponseInputImageParam,
|
ResponseInputImageParam,
|
||||||
@@ -181,20 +180,13 @@ 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] = []
|
||||||
web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {}
|
|
||||||
|
|
||||||
for content in chat_content:
|
for content in chat_content:
|
||||||
if isinstance(content, conversation.ToolResultContent):
|
if isinstance(content, conversation.ToolResultContent):
|
||||||
if (
|
# Earlier web searches are not replayed: the chat log only keeps
|
||||||
content.tool_name == "web_search_call"
|
# their status, not their results, and a search without results
|
||||||
and content.tool_call_id in web_search_calls
|
# makes the model disown the answer it based on that search.
|
||||||
):
|
if content.tool_name != "web_search_call":
|
||||||
web_search_call = web_search_calls.pop(content.tool_call_id)
|
|
||||||
web_search_call["status"] = content.tool_result.get( # type: ignore[typeddict-item]
|
|
||||||
"status", "completed"
|
|
||||||
)
|
|
||||||
messages.append(web_search_call)
|
|
||||||
else:
|
|
||||||
messages.append(
|
messages.append(
|
||||||
FunctionCallOutput(
|
FunctionCallOutput(
|
||||||
type="function_call_output",
|
type="function_call_output",
|
||||||
@@ -217,26 +209,16 @@ def _convert_content_to_param(
|
|||||||
if isinstance(content, conversation.AssistantContent):
|
if isinstance(content, conversation.AssistantContent):
|
||||||
if content.tool_calls:
|
if content.tool_calls:
|
||||||
for tool_call in content.tool_calls:
|
for tool_call in content.tool_calls:
|
||||||
if (
|
if tool_call.external and tool_call.tool_name == "web_search_call":
|
||||||
tool_call.external
|
continue
|
||||||
and tool_call.tool_name == "web_search_call"
|
messages.append(
|
||||||
and "action" in tool_call.tool_args
|
ResponseFunctionToolCallParam(
|
||||||
):
|
type="function_call",
|
||||||
web_search_calls[tool_call.id] = ResponseFunctionWebSearchParam(
|
name=tool_call.tool_name,
|
||||||
type="web_search_call",
|
arguments=json_dumps(tool_call.tool_args),
|
||||||
id=tool_call.id,
|
call_id=tool_call.id,
|
||||||
action=tool_call.tool_args["action"],
|
|
||||||
status="completed",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
messages.append(
|
|
||||||
ResponseFunctionToolCallParam(
|
|
||||||
type="function_call",
|
|
||||||
name=tool_call.tool_name,
|
|
||||||
arguments=json_dumps(tool_call.tool_args),
|
|
||||||
call_id=tool_call.id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if content.thinking_content:
|
if content.thinking_content:
|
||||||
reasoning_summary.append(content.thinking_content)
|
reasoning_summary.append(content.thinking_content)
|
||||||
@@ -418,7 +400,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
|||||||
llm.ToolInput(
|
llm.ToolInput(
|
||||||
id=current_tool_call.call_id,
|
id=current_tool_call.call_id,
|
||||||
tool_name=current_tool_call.name,
|
tool_name=current_tool_call.name,
|
||||||
tool_args=json.loads(current_tool_call.arguments),
|
tool_args=json.loads(current_tool_call.arguments or "{}"),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1274,6 +1274,74 @@ async def test_subentry_web_search_user_location(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subentry_web_search_user_location_lookup_fails(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry,
|
||||||
|
mock_init_component,
|
||||||
|
) -> None:
|
||||||
|
"""Test the location lookup uses the subentry's model and may fail.
|
||||||
|
|
||||||
|
An endpoint that only serves other providers' models rejects the OpenAI
|
||||||
|
default; saving must still succeed with country and time zone only.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
|
subentry_flow["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
|
CONF_CHAT_MODEL: "claude-sonnet-5-5",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
|
subentry_flow["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_TEMPERATURE: 1.0,
|
||||||
|
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||||
|
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||||
|
CONF_STORE_RESPONSES: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert subentry_flow["step_id"] == "model"
|
||||||
|
|
||||||
|
hass.config.country = "DE"
|
||||||
|
hass.config.time_zone = "Europe/Berlin"
|
||||||
|
hass.states.async_set("zone.home", "0", {"latitude": 50.9, "longitude": 12.1})
|
||||||
|
with patch(
|
||||||
|
"openai.resources.responses.AsyncResponses.create",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=BadRequestError(
|
||||||
|
response=httpx.Response(status_code=400, request=httpx.Request("POST", "")),
|
||||||
|
body=None,
|
||||||
|
message="unknown provider for model",
|
||||||
|
),
|
||||||
|
) as mock_create:
|
||||||
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
|
subentry_flow["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_WEB_SEARCH: True,
|
||||||
|
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
|
||||||
|
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_create.call_args.kwargs["model"] == "claude-sonnet-5-5"
|
||||||
|
assert subentry_flow["type"] is FlowResultType.ABORT
|
||||||
|
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||||
|
assert CONF_WEB_SEARCH_CITY not in subentry.data
|
||||||
|
assert CONF_WEB_SEARCH_REGION not in subentry.data
|
||||||
|
assert subentry.data[CONF_WEB_SEARCH_COUNTRY] == "DE"
|
||||||
|
assert subentry.data[CONF_WEB_SEARCH_TIMEZONE] == "Europe/Berlin"
|
||||||
|
|
||||||
|
|
||||||
async def test_creating_ai_task_subentry(
|
async def test_creating_ai_task_subentry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
|
|||||||
@@ -231,19 +231,6 @@ async def test_conversation_agent(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("description", "messages"),
|
("description", "messages"),
|
||||||
[
|
[
|
||||||
(
|
|
||||||
"Test function call started with missing arguments",
|
|
||||||
(
|
|
||||||
*create_function_tool_call_item(
|
|
||||||
id="fc_1",
|
|
||||||
arguments=[],
|
|
||||||
call_id="call_call_1",
|
|
||||||
name="test_tool",
|
|
||||||
output_index=0,
|
|
||||||
),
|
|
||||||
*create_message_item(id="msg_A", text="Cool", output_index=1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"Test invalid JSON",
|
"Test invalid JSON",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for the fork's chat log conversion fixes in entity.py."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from homeassistant.components import conversation
|
||||||
|
from homeassistant.helpers import llm
|
||||||
|
|
||||||
|
from custom_components.openai_compatible.entity import (
|
||||||
|
_convert_content_to_param,
|
||||||
|
_transform_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import create_function_tool_call_item
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream(events):
|
||||||
|
for event in events:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_call_without_arguments() -> None:
|
||||||
|
"""Test an empty argument string means a call without parameters.
|
||||||
|
|
||||||
|
Some OpenAI-compatible endpoints send "" instead of "{}" for tools that
|
||||||
|
take no parameters, e.g. the timer status intent.
|
||||||
|
"""
|
||||||
|
events = create_function_tool_call_item(
|
||||||
|
id="fc_1",
|
||||||
|
arguments=[],
|
||||||
|
call_id="call_1",
|
||||||
|
name="HassTimerStatus",
|
||||||
|
output_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
deltas = [delta async for delta in _transform_stream(MagicMock(), _stream(events))]
|
||||||
|
|
||||||
|
tool_calls = [call for delta in deltas for call in delta.get("tool_calls", [])]
|
||||||
|
assert len(tool_calls) == 1
|
||||||
|
assert tool_calls[0].tool_name == "HassTimerStatus"
|
||||||
|
assert tool_calls[0].tool_args == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_search_not_replayed() -> None:
|
||||||
|
"""Test earlier web searches are left out of the history.
|
||||||
|
|
||||||
|
The chat log keeps only a search's status, not its results. Replaying the
|
||||||
|
bare search makes the model disown the answer it based on that search.
|
||||||
|
"""
|
||||||
|
content = [
|
||||||
|
conversation.UserContent(content="How did the match go?"),
|
||||||
|
conversation.AssistantContent(
|
||||||
|
agent_id="conversation.test",
|
||||||
|
tool_calls=[
|
||||||
|
llm.ToolInput(
|
||||||
|
id="ws_1",
|
||||||
|
tool_name="web_search_call",
|
||||||
|
tool_args={"action": {"type": "search", "query": "match"}},
|
||||||
|
external=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
conversation.ToolResultContent(
|
||||||
|
agent_id="conversation.test",
|
||||||
|
tool_call_id="ws_1",
|
||||||
|
tool_name="web_search_call",
|
||||||
|
result=llm.ToolResult(data={"status": "completed"}),
|
||||||
|
),
|
||||||
|
conversation.AssistantContent(
|
||||||
|
agent_id="conversation.test",
|
||||||
|
content="It ended 2 to 2.",
|
||||||
|
tool_calls=[
|
||||||
|
llm.ToolInput(id="call_1", tool_name="GetLiveContext", tool_args={})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
conversation.ToolResultContent(
|
||||||
|
agent_id="conversation.test",
|
||||||
|
tool_call_id="call_1",
|
||||||
|
tool_name="GetLiveContext",
|
||||||
|
result=llm.ToolResult(data={"success": True}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
messages = _convert_content_to_param(content)
|
||||||
|
|
||||||
|
assert [message["type"] for message in messages] == [
|
||||||
|
"message",
|
||||||
|
"message",
|
||||||
|
"function_call",
|
||||||
|
"function_call_output",
|
||||||
|
]
|
||||||
|
assert messages[1]["content"] == "It ended 2 to 2."
|
||||||
|
assert messages[2]["call_id"] == "call_1"
|
||||||
Reference in new issue
Block a user