Preserve native code interpreter items in openai_conversation (#182715)

This commit is contained in:
Denis Shulyaka
2026-09-20 17:28:40 +02:00
committed by GitHub
parent 113db1bdf4
commit 4550565c7a
6 changed files with 324 additions and 31 deletions
@@ -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: