Skip to content

Commit a6a59c8

Browse files
committed
fix(open_responses): preserve spec stream details
1 parent 732f029 commit a6a59c8

6 files changed

Lines changed: 223 additions & 23 deletions

File tree

homeassistant/components/open_responses/client.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ def __init__(self, http_client: AsyncClient, api_key: str, base_url: str) -> Non
3535
"""Initialize the client."""
3636
self._http_client = http_client
3737
self._url = f"{base_url.rstrip('/')}/responses"
38-
self._headers = {
38+
self._json_headers = {
39+
"authorization": f"Bearer {api_key}",
40+
"accept": "application/json",
41+
}
42+
self._stream_headers = {
3943
"authorization": f"Bearer {api_key}",
4044
"accept": "text/event-stream",
4145
}
@@ -48,7 +52,7 @@ async def create_response(self, **params: Any) -> dict[str, Any]:
4852
response = await self._http_client.post(
4953
self._url,
5054
json=body,
51-
headers=self._headers,
55+
headers=self._json_headers,
5256
)
5357
response.raise_for_status()
5458
except HTTPStatusError as err:
@@ -69,7 +73,7 @@ async def stream_response(self, **params: Any) -> AsyncGenerator[dict[str, Any]]
6973
"POST",
7074
self._url,
7175
json=body,
72-
headers=self._headers,
76+
headers=self._stream_headers,
7377
) as response:
7478
response.raise_for_status()
7579
async for event in _iter_sse_events(response.aiter_lines()):
@@ -104,26 +108,49 @@ def _strip_none_values(value: Any) -> Any:
104108
async def _iter_sse_events(lines: AsyncIterable[str]) -> AsyncGenerator[dict[str, Any]]:
105109
"""Yield JSON server-sent events from an Open Responses stream."""
106110
event_type: str | None = None
111+
data_lines: list[str] = []
112+
done = False
113+
114+
async def flush_event() -> dict[str, Any] | None:
115+
nonlocal done, event_type, data_lines
116+
117+
if not data_lines:
118+
event_type = None
119+
return None
120+
121+
data = "\n".join(data_lines)
122+
event_type_for_payload = event_type
123+
event_type = None
124+
data_lines = []
125+
126+
if data == "[DONE]":
127+
done = True
128+
return None
129+
130+
event = json.loads(data)
131+
if event_type_for_payload and "type" not in event:
132+
event["type"] = event_type_for_payload
133+
return event
107134

108135
async for line in lines:
136+
if done:
137+
return
109138
if not line:
110-
event_type = None
139+
if event := await flush_event():
140+
yield event
141+
if done:
142+
return
111143
continue
112144
if line.startswith("event:"):
113145
event_type = line.split(":", 1)[1].strip()
114146
continue
115147
if not line.startswith("data:"):
116148
continue
117149

118-
data = line.split(":", 1)[1].strip()
119-
if data == "[DONE]":
120-
return
150+
data_lines.append(line.split(":", 1)[1].strip())
121151

122-
event = json.loads(data)
123-
if event_type and "type" not in event:
124-
event["type"] = event_type
152+
if event := await flush_event():
125153
yield event
126-
event_type = None
127154

128155

129156
def _raise_client_error(err: HTTPStatusError) -> None:

homeassistant/components/open_responses/config_flow.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ConfigEntryState,
1313
ConfigFlow,
1414
ConfigFlowResult,
15+
ConfigSubentry,
1516
ConfigSubentryFlow,
1617
SubentryFlowResult,
1718
)
@@ -60,6 +61,16 @@
6061
}
6162
)
6263

64+
DEFAULT_SUBENTRY_TITLES = {
65+
"ai_task_data": DEFAULT_AI_TASK_NAME,
66+
"conversation": DEFAULT_CONVERSATION_NAME,
67+
}
68+
69+
70+
def _is_default_subentry(subentry: ConfigSubentry) -> bool:
71+
"""Return whether a subentry is the generated default subentry."""
72+
return subentry.title == DEFAULT_SUBENTRY_TITLES.get(subentry.subentry_type)
73+
6374

6475
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
6576
"""Validate Open Responses connection details."""
@@ -84,7 +95,9 @@ def _async_update_default_subentry_models(
8495
old_model = entry.data[CONF_MODEL]
8596

8697
for subentry in entry.subentries.values():
87-
if subentry.data.get(CONF_MODEL) != old_model:
98+
if subentry.data.get(CONF_MODEL) != old_model or not _is_default_subentry(
99+
subentry
100+
):
88101
continue
89102

90103
hass.config_entries.async_update_subentry(

homeassistant/components/open_responses/entity.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .client import (
2424
OpenResponsesAuthError,
2525
OpenResponsesConnectionError,
26+
OpenResponsesInvalidModelError,
2627
OpenResponsesRateLimitError,
2728
)
2829
from .const import (
@@ -256,7 +257,7 @@ async def _transform_stream(
256257
"native": {
257258
"type": "reasoning",
258259
"id": item.get("id"),
259-
"summary": [],
260+
"summary": item.get("summary") or [],
260261
"encrypted_content": item.get("encrypted_content"),
261262
}
262263
}
@@ -324,9 +325,9 @@ async def _transform_stream(
324325
reason = error.get("message") or reason
325326
raise HomeAssistantError(f"Open Responses response failed: {reason}")
326327
elif event_type == "response.error":
327-
raise HomeAssistantError(
328-
f"Open Responses response error: {event['message']}"
329-
)
328+
error = cast(dict[str, Any], event.get("error") or {})
329+
reason = error.get("message") or event.get("message") or "unknown reason"
330+
raise HomeAssistantError(f"Open Responses response error: {reason}")
330331

331332

332333
class OpenResponsesEntity(Entity):
@@ -413,6 +414,9 @@ async def _async_handle_chat_log(
413414
raise HomeAssistantError(
414415
"Rate limited by Open Responses endpoint"
415416
) from err
417+
except OpenResponsesInvalidModelError as err:
418+
LOGGER.error("Invalid Open Responses model: %s", err)
419+
raise HomeAssistantError("Invalid Open Responses model") from err
416420
except OpenResponsesConnectionError as err:
417421
LOGGER.error("Error talking to Open Responses endpoint: %s", err)
418422
raise HomeAssistantError(
@@ -462,7 +466,7 @@ def append_files_to_content() -> ResponseInputMessageContentListParam:
462466
content.append(
463467
{
464468
"type": "input_file",
465-
"filename": str(file_path),
469+
"filename": file_path.name,
466470
"file_data": f"data:{mime_type};base64,{base64_file}",
467471
}
468472
)

tests/components/open_responses/test_client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""Tests for the Open Responses client."""
22

3+
from collections.abc import AsyncGenerator
4+
from unittest.mock import AsyncMock
5+
36
import httpx
47
from pydantic import ValidationError
58
import pytest
69

710
from homeassistant.components.open_responses.client import (
11+
OpenResponsesClient,
812
OpenResponsesInvalidModelError,
913
_format_request_body,
14+
_iter_sse_events,
1015
_raise_client_error,
1116
)
1217

@@ -54,6 +59,58 @@ def test_format_request_body_validates_response_body() -> None:
5459
)
5560

5661

62+
async def test_create_response_requests_json() -> None:
63+
"""Test non-streaming responses request a JSON response."""
64+
response = httpx.Response(
65+
200,
66+
json={"id": "resp_1"},
67+
request=httpx.Request("POST", "https://example.local/v1/responses"),
68+
)
69+
http_client = AsyncMock()
70+
http_client.post.return_value = response
71+
client = OpenResponsesClient(http_client, "api-key", "https://example.local/v1")
72+
73+
assert await client.create_response(
74+
model="model",
75+
input=[{"type": "message", "role": "user", "content": "ping"}],
76+
) == {"id": "resp_1"}
77+
78+
assert http_client.post.await_args.kwargs["headers"]["accept"] == "application/json"
79+
80+
81+
async def test_iter_sse_events_accumulates_multiline_data() -> None:
82+
"""Test SSE data lines are joined until the event delimiter."""
83+
84+
async def lines() -> AsyncGenerator[str]:
85+
yield "event: response.output_text.delta"
86+
yield 'data: {"delta":'
87+
yield 'data: "hello"}'
88+
yield ""
89+
90+
assert [event async for event in _iter_sse_events(lines())] == [
91+
{
92+
"delta": "hello",
93+
"type": "response.output_text.delta",
94+
}
95+
]
96+
97+
98+
async def test_iter_sse_events_stops_on_done() -> None:
99+
"""Test the OpenAI stream terminator stops event iteration."""
100+
101+
async def lines() -> AsyncGenerator[str]:
102+
yield 'data: {"type": "response.created"}'
103+
yield ""
104+
yield "data: [DONE]"
105+
yield ""
106+
yield 'data: {"type": "response.output_text.delta", "delta": "late"}'
107+
yield ""
108+
109+
assert [event async for event in _iter_sse_events(lines())] == [
110+
{"type": "response.created"}
111+
]
112+
113+
57114
def test_raise_client_error_detects_invalid_model() -> None:
58115
"""Test model validation errors are separated from endpoint failures."""
59116
response = httpx.Response(

tests/components/open_responses/test_config_flow.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,49 @@ async def test_form_handles_invalid_model(hass: HomeAssistant) -> None:
219219

220220

221221
async def test_reauth_updates_default_subentry_models(
222-
hass: HomeAssistant, mock_config_entry: MockConfigEntry
222+
hass: HomeAssistant,
223223
) -> None:
224224
"""Test reauth model changes are propagated to generated subentries."""
225+
mock_config_entry = MockConfigEntry(
226+
title="Open Responses",
227+
domain=DOMAIN,
228+
data={
229+
CONF_API_KEY: "bla",
230+
CONF_BASE_URL: "https://example.local/v1",
231+
CONF_MODEL: "open-responses-model",
232+
},
233+
version=1,
234+
subentries_data=[
235+
config_entries.ConfigSubentryData(
236+
data={
237+
**RECOMMENDED_CONVERSATION_OPTIONS,
238+
CONF_MODEL: "open-responses-model",
239+
},
240+
subentry_type="conversation",
241+
title=DEFAULT_CONVERSATION_NAME,
242+
unique_id=None,
243+
),
244+
config_entries.ConfigSubentryData(
245+
data={
246+
**RECOMMENDED_AI_TASK_OPTIONS,
247+
CONF_MODEL: "open-responses-model",
248+
},
249+
subentry_type="ai_task_data",
250+
title=DEFAULT_AI_TASK_NAME,
251+
unique_id=None,
252+
),
253+
config_entries.ConfigSubentryData(
254+
data={
255+
**RECOMMENDED_CONVERSATION_OPTIONS,
256+
CONF_MODEL: "open-responses-model",
257+
},
258+
subentry_type="conversation",
259+
title="My Custom Agent",
260+
unique_id=None,
261+
),
262+
],
263+
)
264+
mock_config_entry.add_to_hass(hass)
225265
result = await mock_config_entry.start_reauth_flow(hass)
226266

227267
assert result["type"] is FlowResultType.FORM
@@ -254,9 +294,15 @@ async def test_reauth_updates_default_subentry_models(
254294
assert result2["type"] is FlowResultType.ABORT
255295
assert result2["reason"] == "reauth_successful"
256296
assert mock_config_entry.data[CONF_MODEL] == "new-open-responses-model"
257-
assert {
258-
subentry.data[CONF_MODEL] for subentry in mock_config_entry.subentries.values()
259-
} == {"new-open-responses-model"}
297+
subentry_models = {
298+
subentry.title: subentry.data[CONF_MODEL]
299+
for subentry in mock_config_entry.subentries.values()
300+
}
301+
assert subentry_models == {
302+
DEFAULT_AI_TASK_NAME: "new-open-responses-model",
303+
DEFAULT_CONVERSATION_NAME: "new-open-responses-model",
304+
"My Custom Agent": "open-responses-model",
305+
}
260306

261307

262308
async def test_creating_conversation_subentry(

0 commit comments

Comments
 (0)