Skip to content

Commit cb9692f

Browse files
Shulyakaballoob
andauthored
Raise error when max tokens reached for openai_conversation (#140214)
* Handle ResponseIncompleteEvent * Updated error text * Fix tests * Update conversation.py * ruff * More tests * Handle ResponseFailed and ResponseError --------- Co-authored-by: Paulus Schoutsen <[email protected]> Co-authored-by: Paulus Schoutsen <[email protected]>
1 parent 90623bb commit cb9692f

File tree

2 files changed

+203
-16
lines changed

2 files changed

+203
-16
lines changed

homeassistant/components/openai_conversation/conversation.py

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
EasyInputMessageParam,
1111
FunctionToolParam,
1212
ResponseCompletedEvent,
13+
ResponseErrorEvent,
14+
ResponseFailedEvent,
1315
ResponseFunctionCallArgumentsDeltaEvent,
1416
ResponseFunctionCallArgumentsDoneEvent,
1517
ResponseFunctionToolCall,
1618
ResponseFunctionToolCallParam,
19+
ResponseIncompleteEvent,
1720
ResponseInputParam,
1821
ResponseOutputItemAddedEvent,
1922
ResponseOutputMessage,
@@ -139,18 +142,57 @@ async def _transform_stream(
139142
)
140143
]
141144
}
142-
elif (
143-
isinstance(event, ResponseCompletedEvent)
144-
and (usage := event.response.usage) is not None
145-
):
146-
chat_log.async_trace(
147-
{
148-
"stats": {
149-
"input_tokens": usage.input_tokens,
150-
"output_tokens": usage.output_tokens,
145+
elif isinstance(event, ResponseCompletedEvent):
146+
if event.response.usage is not None:
147+
chat_log.async_trace(
148+
{
149+
"stats": {
150+
"input_tokens": event.response.usage.input_tokens,
151+
"output_tokens": event.response.usage.output_tokens,
152+
}
151153
}
152-
}
153-
)
154+
)
155+
elif isinstance(event, ResponseIncompleteEvent):
156+
if event.response.usage is not None:
157+
chat_log.async_trace(
158+
{
159+
"stats": {
160+
"input_tokens": event.response.usage.input_tokens,
161+
"output_tokens": event.response.usage.output_tokens,
162+
}
163+
}
164+
)
165+
166+
if (
167+
event.response.incomplete_details
168+
and event.response.incomplete_details.reason
169+
):
170+
reason: str = event.response.incomplete_details.reason
171+
else:
172+
reason = "unknown reason"
173+
174+
if reason == "max_output_tokens":
175+
reason = "max output tokens reached"
176+
elif reason == "content_filter":
177+
reason = "content filter triggered"
178+
179+
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
180+
elif isinstance(event, ResponseFailedEvent):
181+
if event.response.usage is not None:
182+
chat_log.async_trace(
183+
{
184+
"stats": {
185+
"input_tokens": event.response.usage.input_tokens,
186+
"output_tokens": event.response.usage.output_tokens,
187+
}
188+
}
189+
)
190+
reason = "unknown reason"
191+
if event.response.error is not None:
192+
reason = event.response.error.message
193+
raise HomeAssistantError(f"OpenAI response failed: {reason}")
194+
elif isinstance(event, ResponseErrorEvent):
195+
raise HomeAssistantError(f"OpenAI response error: {event.message}")
154196

155197

156198
class OpenAIConversationEntity(

tests/components/openai_conversation/test_conversation.py

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@
1212
ResponseContentPartAddedEvent,
1313
ResponseContentPartDoneEvent,
1414
ResponseCreatedEvent,
15+
ResponseError,
16+
ResponseErrorEvent,
17+
ResponseFailedEvent,
1518
ResponseFunctionCallArgumentsDeltaEvent,
1619
ResponseFunctionCallArgumentsDoneEvent,
1720
ResponseFunctionToolCall,
21+
ResponseIncompleteEvent,
1822
ResponseInProgressEvent,
1923
ResponseOutputItemAddedEvent,
2024
ResponseOutputItemDoneEvent,
@@ -26,6 +30,7 @@
2630
ResponseTextDeltaEvent,
2731
ResponseTextDoneEvent,
2832
)
33+
from openai.types.responses.response import IncompleteDetails
2934
import pytest
3035
from syrupy.assertion import SnapshotAssertion
3136

@@ -83,17 +88,40 @@ async def mock_generator(events, **kwargs):
8388
response=response,
8489
type="response.in_progress",
8590
)
91+
response.status = "completed"
8692

8793
for value in events:
8894
if isinstance(value, ResponseOutputItemDoneEvent):
8995
response.output.append(value.item)
96+
elif isinstance(value, IncompleteDetails):
97+
response.status = "incomplete"
98+
response.incomplete_details = value
99+
break
100+
if isinstance(value, ResponseError):
101+
response.status = "failed"
102+
response.error = value
103+
break
104+
90105
yield value
91106

92-
response.status = "completed"
93-
yield ResponseCompletedEvent(
94-
response=response,
95-
type="response.completed",
96-
)
107+
if isinstance(value, ResponseErrorEvent):
108+
return
109+
110+
if response.status == "incomplete":
111+
yield ResponseIncompleteEvent(
112+
response=response,
113+
type="response.incomplete",
114+
)
115+
elif response.status == "failed":
116+
yield ResponseFailedEvent(
117+
response=response,
118+
type="response.failed",
119+
)
120+
else:
121+
yield ResponseCompletedEvent(
122+
response=response,
123+
type="response.completed",
124+
)
97125

98126
with patch(
99127
"openai.resources.responses.AsyncResponses.create",
@@ -175,6 +203,123 @@ async def test_error_handling(
175203
assert result.response.speech["plain"]["speech"] == message, result.response.speech
176204

177205

206+
@pytest.mark.parametrize(
207+
("reason", "message"),
208+
[
209+
(
210+
"max_output_tokens",
211+
"max output tokens reached",
212+
),
213+
(
214+
"content_filter",
215+
"content filter triggered",
216+
),
217+
(
218+
None,
219+
"unknown reason",
220+
),
221+
],
222+
)
223+
async def test_incomplete_response(
224+
hass: HomeAssistant,
225+
mock_config_entry_with_assist: MockConfigEntry,
226+
mock_init_component,
227+
mock_create_stream: AsyncMock,
228+
mock_chat_log: MockChatLog, # noqa: F811
229+
reason: str,
230+
message: str,
231+
) -> None:
232+
"""Test handling early model stop."""
233+
# Incomplete details received after some content is generated
234+
mock_create_stream.return_value = [
235+
(
236+
# Start message
237+
*create_message_item(
238+
id="msg_A",
239+
text=["Once upon", " a time, ", "there was "],
240+
output_index=0,
241+
),
242+
# Length limit or content filter
243+
IncompleteDetails(reason=reason),
244+
)
245+
]
246+
247+
result = await conversation.async_converse(
248+
hass,
249+
"Please tell me a big story",
250+
"mock-conversation-id",
251+
Context(),
252+
agent_id="conversation.openai",
253+
)
254+
255+
assert result.response.response_type == intent.IntentResponseType.ERROR, result
256+
assert (
257+
result.response.speech["plain"]["speech"]
258+
== f"OpenAI response incomplete: {message}"
259+
), result.response.speech
260+
261+
# Incomplete details received before any content is generated
262+
mock_create_stream.return_value = [
263+
(
264+
# Start generating response
265+
*create_reasoning_item(id="rs_A", output_index=0),
266+
# Length limit or content filter
267+
IncompleteDetails(reason=reason),
268+
)
269+
]
270+
271+
result = await conversation.async_converse(
272+
hass,
273+
"please tell me a big story",
274+
"mock-conversation-id",
275+
Context(),
276+
agent_id="conversation.openai",
277+
)
278+
279+
assert result.response.response_type == intent.IntentResponseType.ERROR, result
280+
assert (
281+
result.response.speech["plain"]["speech"]
282+
== f"OpenAI response incomplete: {message}"
283+
), result.response.speech
284+
285+
286+
@pytest.mark.parametrize(
287+
("error", "message"),
288+
[
289+
(
290+
ResponseError(code="rate_limit_exceeded", message="Rate limit exceeded"),
291+
"OpenAI response failed: Rate limit exceeded",
292+
),
293+
(
294+
ResponseErrorEvent(type="error", message="Some error"),
295+
"OpenAI response error: Some error",
296+
),
297+
],
298+
)
299+
async def test_failed_response(
300+
hass: HomeAssistant,
301+
mock_config_entry_with_assist: MockConfigEntry,
302+
mock_init_component,
303+
mock_create_stream: AsyncMock,
304+
mock_chat_log: MockChatLog, # noqa: F811
305+
error: ResponseError | ResponseErrorEvent,
306+
message: str,
307+
) -> None:
308+
"""Test handling failed and error responses."""
309+
mock_create_stream.return_value = [(error,)]
310+
311+
result = await conversation.async_converse(
312+
hass,
313+
"next natural number please",
314+
"mock-conversation-id",
315+
Context(),
316+
agent_id="conversation.openai",
317+
)
318+
319+
assert result.response.response_type == intent.IntentResponseType.ERROR, result
320+
assert result.response.speech["plain"]["speech"] == message, result.response.speech
321+
322+
178323
async def test_conversation_agent(
179324
hass: HomeAssistant,
180325
mock_config_entry: MockConfigEntry,

0 commit comments

Comments
 (0)