From 235c536cab18f4d363d3a59a057fb272ecf02f16 Mon Sep 17 00:00:00 2001 From: pc-heini Date: Thu, 22 Oct 2026 08:38:01 +0200 Subject: [PATCH] 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 --- README.md | 17 ++++ .../openai_compatible/config_flow.py | 58 +++++++----- custom_components/openai_compatible/entity.py | 46 +++------- tests/test_config_flow.py | 68 ++++++++++++++ tests/test_conversation.py | 13 --- tests/test_entity.py | 92 +++++++++++++++++++ 6 files changed, 224 insertions(+), 70 deletions(-) create mode 100644 tests/test_entity.py diff --git a/README.md b/README.md index 41e9346..712d66f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,23 @@ [![validate](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml/badge.svg)](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml) [![HACS: custom](https://img.shields.io/badge/HACS-custom-41BDF5.svg)](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 **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, diff --git a/custom_components/openai_compatible/config_flow.py b/custom_components/openai_compatible/config_flow.py index 104982d..cd91d94 100644 --- a/custom_components/openai_compatible/config_flow.py +++ b/custom_components/openai_compatible/config_flow.py @@ -702,31 +702,39 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): ): str, } ) - response = await client.responses.create( - model=RECOMMENDED_CHAT_MODEL, - input=[ - { - "role": "system", - "content": "Where are the following coordinates located: " - f"({zone_home.attributes[EntityStateAttribute.LATITUDE]}," - f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?", - } - ], - text={ - "format": { - "type": "json_schema", - "name": "approximate_location", - "description": "Approximate location data of the user " - "for refined web search results", - "schema": to_openapi(location_schema), - "strict": False, - } - }, - store=self.options.get( - CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES - ), - ) - location_data = location_schema(json.loads(response.output_text) or {}) + # Ask the subentry's own model: the endpoint may not serve + # RECOMMENDED_CHAT_MODEL (an OpenAI name). A failed lookup only + # costs the city/region hint, so it must not block saving. + try: + response = await client.responses.create( + model=self.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + input=[ + { + "role": "system", + "content": "Where are the following coordinates located: " + f"({zone_home.attributes[EntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?", + } + ], + text={ + "format": { + "type": "json_schema", + "name": "approximate_location", + "description": "Approximate location data of the user " + "for refined web search results", + "schema": to_openapi(location_schema), + "strict": False, + } + }, + 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: location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country diff --git a/custom_components/openai_compatible/entity.py b/custom_components/openai_compatible/entity.py index aa0d05a..2f86269 100644 --- a/custom_components/openai_compatible/entity.py +++ b/custom_components/openai_compatible/entity.py @@ -22,7 +22,6 @@ from openai.types.responses import ( ResponseFunctionToolCall, ResponseFunctionToolCallParam, ResponseFunctionWebSearch, - ResponseFunctionWebSearchParam, ResponseIncompleteEvent, ResponseInputFileParam, ResponseInputImageParam, @@ -181,20 +180,13 @@ def _convert_content_to_param( """Convert any native chat message for this agent to the native format.""" messages: ResponseInputParam = [] reasoning_summary: list[str] = [] - web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {} for content in chat_content: if isinstance(content, conversation.ToolResultContent): - if ( - content.tool_name == "web_search_call" - and content.tool_call_id in web_search_calls - ): - 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: + # 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": messages.append( FunctionCallOutput( type="function_call_output", @@ -217,26 +209,16 @@ def _convert_content_to_param( if isinstance(content, conversation.AssistantContent): if content.tool_calls: for tool_call in content.tool_calls: - if ( - tool_call.external - and tool_call.tool_name == "web_search_call" - and "action" in tool_call.tool_args - ): - web_search_calls[tool_call.id] = ResponseFunctionWebSearchParam( - type="web_search_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 tool_call.external and tool_call.tool_name == "web_search_call": + continue + 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: 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( id=current_tool_call.call_id, tool_name=current_tool_call.name, - tool_args=json.loads(current_tool_call.arguments), + tool_args=json.loads(current_tool_call.arguments or "{}"), ) ] } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index b87c47b..4bd1ffc 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 35277d0..2d322fe 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -231,19 +231,6 @@ async def test_conversation_agent( @pytest.mark.parametrize( ("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", ( diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 0000000..c3b1673 --- /dev/null +++ b/tests/test_entity.py @@ -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"