mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Preserve native code interpreter items in openai_conversation (#182715)
This commit is contained in:
@@ -14,6 +14,7 @@ from openai.types.responses import (
|
||||
EasyInputMessageParam,
|
||||
FunctionToolParam,
|
||||
ResponseCodeInterpreterToolCall,
|
||||
ResponseCodeInterpreterToolCallParam,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFailedEvent,
|
||||
@@ -41,6 +42,9 @@ from openai.types.responses import (
|
||||
ToolParam,
|
||||
WebSearchToolParam,
|
||||
)
|
||||
from openai.types.responses.response_code_interpreter_tool_call_param import (
|
||||
Output as CodeInterpreterOutputParam,
|
||||
)
|
||||
from openai.types.responses.response_create_params import (
|
||||
Reasoning,
|
||||
ResponseCreateParamsStreaming,
|
||||
@@ -160,6 +164,7 @@ def _convert_content_to_param(
|
||||
messages: ResponseInputParam = []
|
||||
reasoning_summary: list[str] = []
|
||||
web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {}
|
||||
code_interpreter_calls: dict[str, llm.ToolInput] = {}
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
@@ -172,6 +177,24 @@ def _convert_content_to_param(
|
||||
"status", "completed"
|
||||
)
|
||||
messages.append(web_search_call)
|
||||
elif (
|
||||
content.tool_name == "code_interpreter"
|
||||
and content.tool_call_id in code_interpreter_calls
|
||||
):
|
||||
tool_call = code_interpreter_calls.pop(content.tool_call_id)
|
||||
messages.append(
|
||||
ResponseCodeInterpreterToolCallParam(
|
||||
type="code_interpreter_call",
|
||||
id=tool_call.id,
|
||||
code=tool_call.tool_args["code"],
|
||||
container_id=cast(str, content.result.data["container_id"]),
|
||||
outputs=cast(
|
||||
list[CodeInterpreterOutputParam] | None,
|
||||
content.result.data["output"],
|
||||
),
|
||||
status=content.result.data["status"], # type: ignore[typeddict-item]
|
||||
)
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
FunctionCallOutput(
|
||||
@@ -211,6 +234,10 @@ def _convert_content_to_param(
|
||||
action=tool_call.tool_args["action"],
|
||||
status="completed",
|
||||
)
|
||||
elif (
|
||||
tool_call.external and tool_call.tool_name == "code_interpreter"
|
||||
):
|
||||
code_interpreter_calls[tool_call.id] = tool_call
|
||||
else:
|
||||
messages.append(
|
||||
ResponseFunctionToolCallParam(
|
||||
@@ -311,10 +338,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
llm.ToolInput(
|
||||
id=event.item.id,
|
||||
tool_name="code_interpreter",
|
||||
tool_args={
|
||||
"code": event.item.code,
|
||||
"container": event.item.container_id,
|
||||
},
|
||||
tool_args={"code": event.item.code},
|
||||
external=True,
|
||||
)
|
||||
]
|
||||
@@ -325,11 +349,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
"tool_name": "code_interpreter",
|
||||
"result": llm.ToolResult(
|
||||
data={
|
||||
"container_id": event.item.container_id,
|
||||
"output": (
|
||||
[output.to_dict() for output in event.item.outputs] # type: ignore[misc]
|
||||
if event.item.outputs is not None
|
||||
else None
|
||||
)
|
||||
),
|
||||
"status": event.item.status,
|
||||
},
|
||||
error=event.item.status == "failed",
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the OpenAI Conversation integration."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from openai.types.responses import (
|
||||
ResponseCodeInterpreterCallCodeDeltaEvent,
|
||||
ResponseCodeInterpreterCallCodeDoneEvent,
|
||||
@@ -325,7 +327,11 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve
|
||||
|
||||
|
||||
def create_code_interpreter_item(
|
||||
id: str, code: str | list[str], output_index: int, logs: str | None = None
|
||||
id: str,
|
||||
code: str | list[str],
|
||||
output_index: int,
|
||||
logs: str | None = None,
|
||||
status: Literal["completed", "incomplete", "failed"] = "completed",
|
||||
) -> list[ResponseStreamEvent]:
|
||||
"""Create a message item."""
|
||||
if isinstance(code, str):
|
||||
@@ -382,26 +388,31 @@ def create_code_interpreter_item(
|
||||
sequence_number=0,
|
||||
type="response.code_interpreter_call.interpreting",
|
||||
),
|
||||
]
|
||||
)
|
||||
if status == "completed":
|
||||
events.append(
|
||||
ResponseCodeInterpreterCallCompletedEvent(
|
||||
item_id=id,
|
||||
output_index=output_index,
|
||||
sequence_number=0,
|
||||
type="response.code_interpreter_call.completed",
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
ResponseOutputItemDoneEvent(
|
||||
item=ResponseCodeInterpreterToolCall(
|
||||
id=id,
|
||||
code=code,
|
||||
container_id=container_id,
|
||||
outputs=[OutputLogs(type="logs", logs=logs)] if logs else None,
|
||||
status=status,
|
||||
type="code_interpreter_call",
|
||||
),
|
||||
ResponseOutputItemDoneEvent(
|
||||
item=ResponseCodeInterpreterToolCall(
|
||||
id=id,
|
||||
code=code,
|
||||
container_id=container_id,
|
||||
outputs=[OutputLogs(type="logs", logs=logs)] if logs else None,
|
||||
status="completed",
|
||||
type="code_interpreter_call",
|
||||
),
|
||||
output_index=output_index,
|
||||
sequence_number=0,
|
||||
type="response.output_item.done",
|
||||
),
|
||||
]
|
||||
output_index=output_index,
|
||||
sequence_number=0,
|
||||
type="response.output_item.done",
|
||||
)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_code_interpreter
|
||||
# name: test_code_interpreter[completed]
|
||||
list([
|
||||
dict({
|
||||
'content': 'Please use the python tool to calculate square root of 55555',
|
||||
@@ -7,15 +7,109 @@
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'arguments': '{"code":"import math\\nmath.sqrt(55555)","container":"cntr_A"}',
|
||||
'call_id': 'ci_A',
|
||||
'name': 'code_interpreter',
|
||||
'type': 'function_call',
|
||||
'code': '''
|
||||
import math
|
||||
math.sqrt(55555)
|
||||
''',
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': list([
|
||||
dict({
|
||||
'logs': '''
|
||||
235.70108188126758
|
||||
|
||||
''',
|
||||
'type': 'logs',
|
||||
}),
|
||||
]),
|
||||
'status': 'completed',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
dict({
|
||||
'call_id': 'ci_A',
|
||||
'output': '{"data":{"output":[{"logs":"235.70108188126758\\n","type":"logs"}]},"error":false}',
|
||||
'type': 'function_call_output',
|
||||
'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.',
|
||||
'role': 'assistant',
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'content': 'Thank you!',
|
||||
'role': 'user',
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'content': 'You are welcome!',
|
||||
'role': 'assistant',
|
||||
'type': 'message',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_code_interpreter[failed]
|
||||
list([
|
||||
dict({
|
||||
'content': 'Please use the python tool to calculate square root of 55555',
|
||||
'role': 'user',
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'code': '''
|
||||
import math
|
||||
math.sqrt(55555)
|
||||
''',
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': list([
|
||||
dict({
|
||||
'logs': '''
|
||||
235.70108188126758
|
||||
|
||||
''',
|
||||
'type': 'logs',
|
||||
}),
|
||||
]),
|
||||
'status': 'failed',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
dict({
|
||||
'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.',
|
||||
'role': 'assistant',
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'content': 'Thank you!',
|
||||
'role': 'user',
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'content': 'You are welcome!',
|
||||
'role': 'assistant',
|
||||
'type': 'message',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_code_interpreter[incomplete]
|
||||
list([
|
||||
dict({
|
||||
'content': 'Please use the python tool to calculate square root of 55555',
|
||||
'role': 'user',
|
||||
'type': 'message',
|
||||
}),
|
||||
dict({
|
||||
'code': '''
|
||||
import math
|
||||
math.sqrt(55555)
|
||||
''',
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': list([
|
||||
dict({
|
||||
'logs': '''
|
||||
235.70108188126758
|
||||
|
||||
''',
|
||||
'type': 'logs',
|
||||
}),
|
||||
]),
|
||||
'status': 'incomplete',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
dict({
|
||||
'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# serializer version: 1
|
||||
# name: test_convert_code_interpreter[custom_function]
|
||||
list([
|
||||
dict({
|
||||
'arguments': '{"code":"print(1)"}',
|
||||
'call_id': 'ci_A',
|
||||
'name': 'code_interpreter',
|
||||
'type': 'function_call',
|
||||
}),
|
||||
dict({
|
||||
'call_id': 'ci_A',
|
||||
'output': '{"data":{"container_id":"cntr_A","output":null,"status":"completed"},"error":false}',
|
||||
'type': 'function_call_output',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_convert_code_interpreter[failed]
|
||||
list([
|
||||
dict({
|
||||
'code': 'raise ValueError()',
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': list([
|
||||
dict({
|
||||
'logs': 'ValueError',
|
||||
'type': 'logs',
|
||||
}),
|
||||
]),
|
||||
'status': 'failed',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_convert_code_interpreter[image]
|
||||
list([
|
||||
dict({
|
||||
'code': 'plt.show()',
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': list([
|
||||
dict({
|
||||
'type': 'image',
|
||||
'url': 'https://example.com/plot.png',
|
||||
}),
|
||||
]),
|
||||
'status': 'completed',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_convert_code_interpreter[incomplete]
|
||||
list([
|
||||
dict({
|
||||
'code': 'print(1)',
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': None,
|
||||
'status': 'incomplete',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_convert_code_interpreter[no_output]
|
||||
list([
|
||||
dict({
|
||||
'code': None,
|
||||
'container_id': 'cntr_A',
|
||||
'id': 'ci_A',
|
||||
'outputs': None,
|
||||
'status': 'completed',
|
||||
'type': 'code_interpreter_call',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for the OpenAI integration."""
|
||||
|
||||
import datetime
|
||||
from typing import Literal
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun import freeze_time
|
||||
@@ -713,13 +714,22 @@ async def test_web_search_remove_citations_gpt5(
|
||||
assert result.response.speech["plain"]["speech"] == "The match ended 0-2."
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_init_component")
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
pytest.param("completed", id="completed"),
|
||||
pytest.param("incomplete", id="incomplete"),
|
||||
pytest.param("failed", id="failed"),
|
||||
],
|
||||
)
|
||||
async def test_code_interpreter(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream,
|
||||
mock_create_stream: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
snapshot: SnapshotAssertion,
|
||||
status: Literal["completed", "incomplete", "failed"],
|
||||
) -> None:
|
||||
"""Test code_interpreter tool."""
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
@@ -744,6 +754,7 @@ async def test_code_interpreter(
|
||||
code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"],
|
||||
logs="235.70108188126758\n",
|
||||
output_index=0,
|
||||
status=status,
|
||||
),
|
||||
*create_message_item(id="msg_A", text=message, output_index=1),
|
||||
)
|
||||
@@ -763,6 +774,16 @@ async def test_code_interpreter(
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.speech["plain"]["speech"] == message, result.response.speech
|
||||
|
||||
assistant_content = mock_chat_log.content[2]
|
||||
assert isinstance(assistant_content, conversation.AssistantContent)
|
||||
assert assistant_content.tool_calls
|
||||
assert assistant_content.tool_calls[0].tool_args == {
|
||||
"code": "import math\nmath.sqrt(55555)"
|
||||
}
|
||||
tool_result = mock_chat_log.content[3]
|
||||
assert isinstance(tool_result, conversation.ToolResultContent)
|
||||
assert tool_result.result.data["container_id"] == "cntr_A"
|
||||
|
||||
# Test follow-up message in multi-turn conversation
|
||||
mock_create_stream.return_value = [
|
||||
(*create_message_item(id="msg_B", text="You are welcome!", output_index=1),)
|
||||
@@ -777,6 +798,10 @@ async def test_code_interpreter(
|
||||
)
|
||||
|
||||
assert mock_create_stream.mock_calls[1][2]["input"][1:] == snapshot
|
||||
assert mock_create_stream.mock_calls[1][2]["tools"] == [
|
||||
{"type": "code_interpreter", "container": {"type": "auto"}}
|
||||
]
|
||||
assert mock_create_stream.mock_calls[1][2]["input"][2]["status"] == status
|
||||
|
||||
|
||||
async def test_flex_tier_retry(
|
||||
|
||||
@@ -5,14 +5,77 @@ from unittest.mock import patch
|
||||
|
||||
import probatio
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.openai_conversation.entity import (
|
||||
_convert_content_to_param,
|
||||
_format_structured_output,
|
||||
async_prepare_files_for_prompt,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers import llm, selector
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "outputs", "error", "external", "status"),
|
||||
[
|
||||
pytest.param(None, None, False, True, "completed", id="no_output"),
|
||||
pytest.param(
|
||||
"raise ValueError()",
|
||||
[{"type": "logs", "logs": "ValueError"}],
|
||||
True,
|
||||
True,
|
||||
"failed",
|
||||
id="failed",
|
||||
),
|
||||
pytest.param(
|
||||
"plt.show()",
|
||||
[{"type": "image", "url": "https://example.com/plot.png"}],
|
||||
False,
|
||||
True,
|
||||
"completed",
|
||||
id="image",
|
||||
),
|
||||
pytest.param("print(1)", None, False, False, "completed", id="custom_function"),
|
||||
pytest.param("print(1)", None, False, True, "incomplete", id="incomplete"),
|
||||
],
|
||||
)
|
||||
def test_convert_code_interpreter(
|
||||
code: str | None,
|
||||
outputs: list[JsonObjectType] | None,
|
||||
error: bool,
|
||||
external: bool,
|
||||
status: str,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Restore native external calls while preserving custom function calls."""
|
||||
content = [
|
||||
conversation.AssistantContent(
|
||||
agent_id="conversation.openai_conversation",
|
||||
tool_calls=[
|
||||
llm.ToolInput(
|
||||
id="ci_A",
|
||||
tool_name="code_interpreter",
|
||||
tool_args={"code": code},
|
||||
external=external,
|
||||
)
|
||||
],
|
||||
),
|
||||
conversation.ToolResultContent(
|
||||
agent_id="conversation.openai_conversation",
|
||||
tool_call_id="ci_A",
|
||||
tool_name="code_interpreter",
|
||||
result=llm.ToolResult(
|
||||
data={"container_id": "cntr_A", "output": outputs, "status": status},
|
||||
error=error,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
assert _convert_content_to_param(content) == snapshot
|
||||
|
||||
|
||||
async def test_format_structured_output() -> None:
|
||||
|
||||
Reference in New Issue
Block a user