Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 133 additions & 46 deletions homeassistant/components/openai_conversation/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
from openai.types.responses import (
EasyInputMessageParam,
FunctionToolParam,
ResponseCodeInterpreterToolCall,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseFunctionToolCallParam,
ResponseFunctionWebSearch,
ResponseFunctionWebSearchParam,
ResponseIncompleteEvent,
ResponseInputFileParam,
ResponseInputImageParam,
Expand Down Expand Up @@ -149,16 +152,27 @@ 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):
messages.append(
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
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:
messages.append(
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
)
)
)
continue

if content.content:
Expand All @@ -173,15 +187,27 @@ def _convert_content_to_param(

if isinstance(content, conversation.AssistantContent):
if content.tool_calls:
messages.extend(
ResponseFunctionToolCallParam(
type="function_call",
name=tool_call.tool_name,
arguments=json.dumps(tool_call.tool_args),
call_id=tool_call.id,
)
for tool_call in 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 content.thinking_content:
reasoning_summary.append(content.thinking_content)
Expand Down Expand Up @@ -211,25 +237,37 @@ def _convert_content_to_param(
async def _transform_stream(
chat_log: conversation.ChatLog,
stream: AsyncStream[ResponseStreamEvent],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""Transform an OpenAI delta stream into HA format."""
last_summary_index = None
last_role: Literal["assistant", "tool_result"] | None = None

async for event in stream:
LOGGER.debug("Received event: %s", event)

if isinstance(event, ResponseOutputItemAddedEvent):
if isinstance(event.item, ResponseOutputMessage):
yield {"role": event.item.role}
last_summary_index = None
elif isinstance(event.item, ResponseFunctionToolCall):
if isinstance(event.item, ResponseFunctionToolCall):
# OpenAI has tool calls as individual events
# while HA puts tool calls inside the assistant message.
# We turn them into individual assistant content for HA
# to ensure that tools are called as soon as possible.
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
current_tool_call = event.item
elif (
isinstance(event.item, ResponseOutputMessage)
or (
isinstance(event.item, ResponseReasoningItem)
and last_summary_index is not None
) # Subsequent ResponseReasoningItem
or last_role != "assistant"
):
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
elif isinstance(event, ResponseOutputItemDoneEvent):
if isinstance(event.item, ResponseReasoningItem):
yield {
Expand All @@ -240,6 +278,52 @@ async def _transform_stream(
encrypted_content=event.item.encrypted_content,
)
}
last_summary_index = len(event.item.summary) - 1
elif isinstance(event.item, ResponseCodeInterpreterToolCall):
yield {
"tool_calls": [
llm.ToolInput(
id=event.item.id,
tool_name="code_interpreter",
tool_args={
"code": event.item.code,
"container": event.item.container_id,
},
external=True,
)
]
}
yield {
"role": "tool_result",
"tool_call_id": event.item.id,
"tool_name": "code_interpreter",
"tool_result": {
"output": [output.to_dict() for output in event.item.outputs] # type: ignore[misc]
if event.item.outputs is not None
else None
},
}
last_role = "tool_result"
elif isinstance(event.item, ResponseFunctionWebSearch):
yield {
"tool_calls": [
llm.ToolInput(
id=event.item.id,
tool_name="web_search_call",
tool_args={
"action": event.item.action.to_dict(),
},
external=True,
)
]
}
yield {
"role": "tool_result",
"tool_call_id": event.item.id,
"tool_name": "web_search_call",
"tool_result": {"status": event.item.status},
}
last_role = "tool_result"
elif isinstance(event, ResponseTextDeltaEvent):
yield {"content": event.delta}
elif isinstance(event, ResponseReasoningSummaryTextDeltaEvent):
Expand All @@ -252,6 +336,7 @@ async def _transform_stream(
and event.summary_index != last_summary_index
):
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = event.summary_index
yield {"thinking_content": event.delta}
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
Expand Down Expand Up @@ -348,6 +433,33 @@ async def _async_handle_chat_log(
"""Generate an answer for the chat log."""
options = self.subentry.data

messages = _convert_content_to_param(chat_log.content)

model_args = ResponseCreateParamsStreaming(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
user=chat_log.conversation_id,
store=False,
stream=True,
)

if model_args["model"].startswith(("o", "gpt-5")):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
),
"summary": "auto",
}
model_args["include"] = ["reasoning.encrypted_content"]

if model_args["model"].startswith("gpt-5"):
model_args["text"] = {
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
}

tools: list[ToolParam] = []
if chat_log.llm_api:
tools = [
Expand Down Expand Up @@ -381,36 +493,11 @@ async def _async_handle_chat_log(
),
)
)
model_args.setdefault("include", []).append("code_interpreter_call.outputs") # type: ignore[union-attr]

messages = _convert_content_to_param(chat_log.content)

model_args = ResponseCreateParamsStreaming(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
user=chat_log.conversation_id,
store=False,
stream=True,
)
if tools:
model_args["tools"] = tools

if model_args["model"].startswith(("o", "gpt-5")):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
),
"summary": "auto",
}
model_args["include"] = ["reasoning.encrypted_content"]

if model_args["model"].startswith("gpt-5"):
model_args["text"] = {
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
}

last_content = chat_log.content[-1]

# Handle attachments by adding them to the last user message
Expand Down
5 changes: 3 additions & 2 deletions tests/components/openai_conversation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
ResponseWebSearchCallInProgressEvent,
ResponseWebSearchCallSearchingEvent,
)
from openai.types.responses.response_code_interpreter_tool_call import OutputLogs
from openai.types.responses.response_function_web_search import ActionSearch
from openai.types.responses.response_reasoning_item import Summary

Expand Down Expand Up @@ -320,7 +321,7 @@ 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
id: str, code: str | list[str], output_index: int, logs: str | None = None
) -> list[ResponseStreamEvent]:
"""Create a message item."""
if isinstance(code, str):
Expand Down Expand Up @@ -388,7 +389,7 @@ def create_code_interpreter_item(
id=id,
code=code,
container_id=container_id,
outputs=None,
outputs=[OutputLogs(type="logs", logs=logs)] if logs else None,
status="completed",
type="code_interpreter_call",
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
# serializer version: 1
# name: test_code_interpreter
list([
dict({
'content': 'Please use the python tool to calculate square root of 55555',
'role': 'user',
'type': 'message',
}),
dict({
'arguments': '{"code": "import math\\nmath.sqrt(55555)", "container": "cntr_A"}',
'call_id': 'ci_A',
'name': 'code_interpreter',
'type': 'function_call',
}),
dict({
'call_id': 'ci_A',
'output': '{"output": [{"logs": "235.70108188126758\\n", "type": "logs"}]}',
'type': 'function_call_output',
}),
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_function_call
list([
dict({
Expand Down Expand Up @@ -172,3 +207,36 @@
}),
])
# ---
# name: test_web_search
list([
dict({
'content': "What's on the latest news?",
'role': 'user',
'type': 'message',
}),
dict({
'action': dict({
'query': 'query',
'type': 'search',
}),
'id': 'ws_A',
'status': 'completed',
'type': 'web_search_call',
}),
dict({
'content': 'Home Assistant now supports ChatGPT Search in Assist',
'role': 'assistant',
'type': 'message',
}),
dict({
'content': 'Thank you!',
'role': 'user',
'type': 'message',
}),
dict({
'content': 'You are welcome!',
'role': 'assistant',
'type': 'message',
}),
])
# ---
Loading
Loading