OpenAI Responses API (#140713)

This commit is contained in:
Denis Shulyaka 2025-03-16 20:18:18 +03:00 committed by GitHub
parent 214d14b06b
commit bb7b5b9ccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 463 additions and 433 deletions

View File

@ -7,21 +7,15 @@ from mimetypes import guess_file_type
from pathlib import Path from pathlib import Path
import openai import openai
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_content_part_image_param import (
ChatCompletionContentPartImageParam,
ImageURL,
)
from openai.types.chat.chat_completion_content_part_param import (
ChatCompletionContentPartParam,
)
from openai.types.chat.chat_completion_content_part_text_param import (
ChatCompletionContentPartTextParam,
)
from openai.types.chat.chat_completion_user_message_param import (
ChatCompletionUserMessageParam,
)
from openai.types.images_response import ImagesResponse from openai.types.images_response import ImagesResponse
from openai.types.responses import (
EasyInputMessageParam,
Response,
ResponseInputImageParam,
ResponseInputMessageContentListParam,
ResponseInputParam,
ResponseInputTextParam,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -44,10 +38,18 @@ from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_CHAT_MODEL, CONF_CHAT_MODEL,
CONF_FILENAMES, CONF_FILENAMES,
CONF_MAX_TOKENS,
CONF_PROMPT, CONF_PROMPT,
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
CONF_TOP_P,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
) )
SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_IMAGE = "generate_image"
@ -112,17 +114,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
translation_placeholders={"config_entry": entry_id}, translation_placeholders={"config_entry": entry_id},
) )
model: str = entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
client: openai.AsyncClient = entry.runtime_data client: openai.AsyncClient = entry.runtime_data
prompt_parts: list[ChatCompletionContentPartParam] = [ content: ResponseInputMessageContentListParam = [
ChatCompletionContentPartTextParam( ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT])
type="text",
text=call.data[CONF_PROMPT],
)
] ]
def append_files_to_prompt() -> None: def append_files_to_content() -> None:
for filename in call.data[CONF_FILENAMES]: for filename in call.data[CONF_FILENAMES]:
if not hass.config.is_allowed_path(filename): if not hass.config.is_allowed_path(filename):
raise HomeAssistantError( raise HomeAssistantError(
@ -138,46 +137,52 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"Only images are supported by the OpenAI API," "Only images are supported by the OpenAI API,"
f"`{filename}` is not an image file" f"`{filename}` is not an image file"
) )
prompt_parts.append( content.append(
ChatCompletionContentPartImageParam( ResponseInputImageParam(
type="image_url", type="input_image",
image_url=ImageURL( file_id=filename,
url=f"data:{mime_type};base64,{base64_file}" image_url=f"data:{mime_type};base64,{base64_file}",
), detail="auto",
) )
) )
if CONF_FILENAMES in call.data: if CONF_FILENAMES in call.data:
await hass.async_add_executor_job(append_files_to_prompt) await hass.async_add_executor_job(append_files_to_content)
messages: list[ChatCompletionUserMessageParam] = [ messages: ResponseInputParam = [
ChatCompletionUserMessageParam( EasyInputMessageParam(type="message", role="user", content=content)
role="user",
content=prompt_parts,
)
] ]
try: try:
response: ChatCompletion = await client.chat.completions.create( model_args = {
model=model, "model": model,
messages=messages, "input": messages,
n=1, "max_output_tokens": entry.options.get(
response_format={ CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
"type": "json_object", ),
}, "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"temperature": entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
"user": call.context.user_id,
"store": False,
}
if model.startswith("o"):
model_args["reasoning"] = {
"effort": entry.options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
) )
}
response: Response = await client.responses.create(**model_args)
except openai.OpenAIError as err: except openai.OpenAIError as err:
raise HomeAssistantError(f"Error generating content: {err}") from err raise HomeAssistantError(f"Error generating content: {err}") from err
except FileNotFoundError as err: except FileNotFoundError as err:
raise HomeAssistantError(f"Error generating content: {err}") from err raise HomeAssistantError(f"Error generating content: {err}") from err
response_text: str = "" return {"text": response.output_text}
for response_choice in response.choices:
if response_choice.message.content is not None:
response_text += response_choice.message.content.strip()
return {"text": response_text}
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,

View File

@ -2,21 +2,25 @@
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
import json import json
from typing import Any, Literal, cast from typing import Any, Literal
import openai import openai
from openai._streaming import AsyncStream from openai._streaming import AsyncStream
from openai._types import NOT_GIVEN from openai.types.responses import (
from openai.types.chat import ( EasyInputMessageParam,
ChatCompletionAssistantMessageParam, FunctionToolParam,
ChatCompletionChunk, ResponseFunctionCallArgumentsDeltaEvent,
ChatCompletionMessageParam, ResponseFunctionCallArgumentsDoneEvent,
ChatCompletionMessageToolCallParam, ResponseFunctionToolCall,
ChatCompletionToolMessageParam, ResponseFunctionToolCallParam,
ChatCompletionToolParam, ResponseInputParam,
ResponseOutputItemAddedEvent,
ResponseOutputMessage,
ResponseStreamEvent,
ResponseTextDeltaEvent,
ToolParam,
) )
from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.responses.response_input_param import FunctionCallOutput
from openai.types.shared_params import FunctionDefinition
from voluptuous_openapi import convert from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation from homeassistant.components import assist_pipeline, conversation
@ -60,123 +64,81 @@ async def async_setup_entry(
def _format_tool( def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ChatCompletionToolParam: ) -> FunctionToolParam:
"""Format tool specification.""" """Format tool specification."""
tool_spec = FunctionDefinition( return FunctionToolParam(
type="function",
name=tool.name, name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer), parameters=convert(tool.parameters, custom_serializer=custom_serializer),
description=tool.description,
strict=False,
) )
if tool.description:
tool_spec["description"] = tool.description
return ChatCompletionToolParam(type="function", function=tool_spec)
def _convert_content_to_param( def _convert_content_to_param(
content: conversation.Content, content: conversation.Content,
) -> ChatCompletionMessageParam: ) -> ResponseInputParam:
"""Convert any native chat message for this agent to the native format.""" """Convert any native chat message for this agent to the native format."""
if content.role == "tool_result": messages: ResponseInputParam = []
assert type(content) is conversation.ToolResultContent if isinstance(content, conversation.ToolResultContent):
return ChatCompletionToolMessageParam( return [
role="tool", FunctionCallOutput(
tool_call_id=content.tool_call_id, type="function_call_output",
content=json.dumps(content.tool_result), call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
) )
if content.role != "assistant" or not content.tool_calls: ]
role: Literal["system", "user", "assistant", "developer"] = content.role
if content.content:
role: Literal["user", "assistant", "system", "developer"] = content.role
if role == "system": if role == "system":
role = "developer" role = "developer"
return cast( messages.append(
ChatCompletionMessageParam, EasyInputMessageParam(type="message", role=role, content=content.content)
{"role": content.role, "content": content.content},
) )
# Handle the Assistant content including tool calls. if isinstance(content, conversation.AssistantContent) and content.tool_calls:
assert type(content) is conversation.AssistantContent messages.extend(
return ChatCompletionAssistantMessageParam( # https://github.com/openai/openai-python/issues/2205
role="assistant", ResponseFunctionToolCallParam( # type: ignore[typeddict-item]
content=content.content, type="function_call",
tool_calls=[
ChatCompletionMessageToolCallParam(
id=tool_call.id,
function=Function(
arguments=json.dumps(tool_call.tool_args),
name=tool_call.tool_name, name=tool_call.tool_name,
), arguments=json.dumps(tool_call.tool_args),
type="function", call_id=tool_call.id,
) )
for tool_call in content.tool_calls for tool_call in content.tool_calls
],
) )
return messages
async def _transform_stream( async def _transform_stream(
result: AsyncStream[ChatCompletionChunk], result: AsyncStream[ResponseStreamEvent],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format.""" """Transform an OpenAI delta stream into HA format."""
current_tool_call: dict | None = None async for event in result:
LOGGER.debug("Received event: %s", event)
async for chunk in result: if isinstance(event, ResponseOutputItemAddedEvent):
LOGGER.debug("Received chunk: %s", chunk) if isinstance(event.item, ResponseOutputMessage):
choice = chunk.choices[0] yield {"role": event.item.role}
elif isinstance(event.item, ResponseFunctionToolCall):
if choice.finish_reason: current_tool_call = event.item
if current_tool_call: elif isinstance(event, ResponseTextDeltaEvent):
yield {"content": event.delta}
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
current_tool_call.arguments += event.delta
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
current_tool_call.status = "completed"
yield { yield {
"tool_calls": [ "tool_calls": [
llm.ToolInput( llm.ToolInput(
id=current_tool_call["id"], id=current_tool_call.call_id,
tool_name=current_tool_call["tool_name"], tool_name=current_tool_call.name,
tool_args=json.loads(current_tool_call["tool_args"]), tool_args=json.loads(current_tool_call.arguments),
) )
] ]
} }
break
delta = chunk.choices[0].delta
# We can yield delta messages not continuing or starting tool calls
if current_tool_call is None and not delta.tool_calls:
yield { # type: ignore[misc]
key: value
for key in ("role", "content")
if (value := getattr(delta, key)) is not None
}
continue
# When doing tool calls, we should always have a tool call
# object or we have gotten stopped above with a finish_reason set.
if (
not delta.tool_calls
or not (delta_tool_call := delta.tool_calls[0])
or not delta_tool_call.function
):
raise ValueError("Expected delta with tool call")
if current_tool_call and delta_tool_call.index == current_tool_call["index"]:
current_tool_call["tool_args"] += delta_tool_call.function.arguments or ""
continue
# We got tool call with new index, so we need to yield the previous
if current_tool_call:
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call["id"],
tool_name=current_tool_call["tool_name"],
tool_args=json.loads(current_tool_call["tool_args"]),
)
]
}
current_tool_call = {
"index": delta_tool_call.index,
"id": delta_tool_call.id,
"tool_name": delta_tool_call.function.name,
"tool_args": delta_tool_call.function.arguments or "",
}
class OpenAIConversationEntity( class OpenAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent conversation.ConversationEntity, conversation.AbstractConversationAgent
@ -241,7 +203,7 @@ class OpenAIConversationEntity(
except conversation.ConverseError as err: except conversation.ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()
tools: list[ChatCompletionToolParam] | None = None tools: list[ToolParam] | None = None
if chat_log.llm_api: if chat_log.llm_api:
tools = [ tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer) _format_tool(tool, chat_log.llm_api.custom_serializer)
@ -249,7 +211,11 @@ class OpenAIConversationEntity(
] ]
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
messages = [_convert_content_to_param(content) for content in chat_log.content] messages = [
m
for content in chat_log.content
for m in _convert_content_to_param(content)
]
client = self.entry.runtime_data client = self.entry.runtime_data
@ -257,24 +223,28 @@ class OpenAIConversationEntity(
for _iteration in range(MAX_TOOL_ITERATIONS): for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = { model_args = {
"model": model, "model": model,
"messages": messages, "input": messages,
"tools": tools or NOT_GIVEN, "max_output_tokens": options.get(
"max_completion_tokens": options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
), ),
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
"user": chat_log.conversation_id, "user": chat_log.conversation_id,
"store": False,
"stream": True, "stream": True,
} }
if tools:
model_args["tools"] = tools
if model.startswith("o"): if model.startswith("o"):
model_args["reasoning_effort"] = options.get( model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
) )
}
try: try:
result = await client.chat.completions.create(**model_args) result = await client.responses.create(**model_args)
except openai.RateLimitError as err: except openai.RateLimitError as err:
LOGGER.error("Rate limited by OpenAI: %s", err) LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err raise HomeAssistantError("Rate limited or insufficient funds") from err
@ -282,14 +252,10 @@ class OpenAIConversationEntity(
LOGGER.error("Error talking to OpenAI: %s", err) LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err raise HomeAssistantError("Error talking to OpenAI") from err
messages.extend(
[
_convert_content_to_param(content)
async for content in chat_log.async_add_delta_content_stream( async for content in chat_log.async_add_delta_content_stream(
user_input.agent_id, _transform_stream(result) user_input.agent_id, _transform_stream(result)
) ):
] messages.extend(_convert_content_to_param(content))
)
if not chat_log.unresponded_tool_results: if not chat_log.unresponded_tool_results:
break break

View File

@ -3,14 +3,28 @@
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from httpx import Response import httpx
from openai import AuthenticationError, RateLimitError from openai import AuthenticationError, RateLimitError
from openai.types.chat.chat_completion_chunk import ( from openai.types import ResponseFormatText
ChatCompletionChunk, from openai.types.responses import (
Choice, Response,
ChoiceDelta, ResponseCompletedEvent,
ChoiceDeltaToolCall, ResponseContentPartAddedEvent,
ChoiceDeltaToolCallFunction, ResponseContentPartDoneEvent,
ResponseCreatedEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseInProgressEvent,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
ResponseStreamEvent,
ResponseTextConfig,
ResponseTextDeltaEvent,
ResponseTextDoneEvent,
) )
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -28,40 +42,65 @@ from tests.components.conversation import (
mock_chat_log, # noqa: F401 mock_chat_log, # noqa: F401
) )
ASSIST_RESPONSE_FINISH = (
# Assistant message
ChatCompletionChunk(
id="chatcmpl-B",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))],
),
# Finish stream
ChatCompletionChunk(
id="chatcmpl-B",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[Choice(index=0, finish_reason="stop", delta=ChoiceDelta())],
),
)
@pytest.fixture @pytest.fixture
def mock_create_stream() -> Generator[AsyncMock]: def mock_create_stream() -> Generator[AsyncMock]:
"""Mock stream response.""" """Mock stream response."""
async def mock_generator(stream): async def mock_generator(events, **kwargs):
for value in stream: response = Response(
id="resp_A",
created_at=1700000000,
error=None,
incomplete_details=None,
instructions=kwargs.get("instructions"),
metadata=kwargs.get("metadata", {}),
model=kwargs.get("model", "gpt-4o-mini"),
object="response",
output=[],
parallel_tool_calls=kwargs.get("parallel_tool_calls", True),
temperature=kwargs.get("temperature", 1.0),
tool_choice=kwargs.get("tool_choice", "auto"),
tools=kwargs.get("tools"),
top_p=kwargs.get("top_p", 1.0),
max_output_tokens=kwargs.get("max_output_tokens", 100000),
previous_response_id=kwargs.get("previous_response_id"),
reasoning=kwargs.get("reasoning"),
status="in_progress",
text=kwargs.get(
"text", ResponseTextConfig(format=ResponseFormatText(type="text"))
),
truncation=kwargs.get("truncation", "disabled"),
usage=None,
user=kwargs.get("user"),
store=kwargs.get("store", True),
)
yield ResponseCreatedEvent(
response=response,
type="response.created",
)
yield ResponseInProgressEvent(
response=response,
type="response.in_progress",
)
for value in events:
if isinstance(value, ResponseOutputItemDoneEvent):
response.output.append(value.item)
yield value yield value
response.status = "completed"
yield ResponseCompletedEvent(
response=response,
type="response.completed",
)
with patch( with patch(
"openai.resources.chat.completions.AsyncCompletions.create", "openai.resources.responses.AsyncResponses.create",
AsyncMock(), AsyncMock(),
) as mock_create: ) as mock_create:
mock_create.side_effect = lambda **kwargs: mock_generator( mock_create.side_effect = lambda **kwargs: mock_generator(
mock_create.return_value.pop(0) mock_create.return_value.pop(0), **kwargs
) )
yield mock_create yield mock_create
@ -99,13 +138,17 @@ async def test_entity(
[ [
( (
RateLimitError( RateLimitError(
response=Response(status_code=429, request=""), body=None, message=None response=httpx.Response(status_code=429, request=""),
body=None,
message=None,
), ),
"Rate limited or insufficient funds", "Rate limited or insufficient funds",
), ),
( (
AuthenticationError( AuthenticationError(
response=Response(status_code=401, request=""), body=None, message=None response=httpx.Response(status_code=401, request=""),
body=None,
message=None,
), ),
"Error talking to OpenAI", "Error talking to OpenAI",
), ),
@ -120,7 +163,7 @@ async def test_error_handling(
) -> None: ) -> None:
"""Test that we handle errors when calling completion API.""" """Test that we handle errors when calling completion API."""
with patch( with patch(
"openai.resources.chat.completions.AsyncCompletions.create", "openai.resources.responses.AsyncResponses.create",
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=exception, side_effect=exception,
): ):
@ -144,6 +187,165 @@ async def test_conversation_agent(
assert agent.supported_languages == "*" assert agent.supported_languages == "*"
def create_message_item(
id: str, text: str | list[str], output_index: int
) -> list[ResponseStreamEvent]:
"""Create a message item."""
if isinstance(text, str):
text = [text]
content = ResponseOutputText(annotations=[], text="", type="output_text")
events = [
ResponseOutputItemAddedEvent(
item=ResponseOutputMessage(
id=id,
content=[],
type="message",
role="assistant",
status="in_progress",
),
output_index=output_index,
type="response.output_item.added",
),
ResponseContentPartAddedEvent(
content_index=0,
item_id=id,
output_index=output_index,
part=content,
type="response.content_part.added",
),
]
content.text = "".join(text)
events.extend(
ResponseTextDeltaEvent(
content_index=0,
delta=delta,
item_id=id,
output_index=output_index,
type="response.output_text.delta",
)
for delta in text
)
events.extend(
[
ResponseTextDoneEvent(
content_index=0,
item_id=id,
output_index=output_index,
text="".join(text),
type="response.output_text.done",
),
ResponseContentPartDoneEvent(
content_index=0,
item_id=id,
output_index=output_index,
part=content,
type="response.content_part.done",
),
ResponseOutputItemDoneEvent(
item=ResponseOutputMessage(
id=id,
content=[content],
role="assistant",
status="completed",
type="message",
),
output_index=output_index,
type="response.output_item.done",
),
]
)
return events
def create_function_tool_call_item(
id: str, arguments: str | list[str], call_id: str, name: str, output_index: int
) -> list[ResponseStreamEvent]:
"""Create a function tool call item."""
if isinstance(arguments, str):
arguments = [arguments]
events = [
ResponseOutputItemAddedEvent(
item=ResponseFunctionToolCall(
id=id,
arguments="",
call_id=call_id,
name=name,
type="function_call",
status="in_progress",
),
output_index=output_index,
type="response.output_item.added",
)
]
events.extend(
ResponseFunctionCallArgumentsDeltaEvent(
delta=delta,
item_id=id,
output_index=output_index,
type="response.function_call_arguments.delta",
)
for delta in arguments
)
events.append(
ResponseFunctionCallArgumentsDoneEvent(
arguments="".join(arguments),
item_id=id,
output_index=output_index,
type="response.function_call_arguments.done",
)
)
events.append(
ResponseOutputItemDoneEvent(
item=ResponseFunctionToolCall(
id=id,
arguments="".join(arguments),
call_id=call_id,
name=name,
type="function_call",
status="completed",
),
output_index=output_index,
type="response.output_item.done",
)
)
return events
def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]:
"""Create a reasoning item."""
return [
ResponseOutputItemAddedEvent(
item=ResponseReasoningItem(
id=id,
summary=[],
type="reasoning",
status=None,
),
output_index=output_index,
type="response.output_item.added",
),
ResponseOutputItemDoneEvent(
item=ResponseReasoningItem(
id=id,
summary=[],
type="reasoning",
status=None,
),
output_index=output_index,
type="response.output_item.done",
),
]
async def test_function_call( async def test_function_call(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry_with_assist: MockConfigEntry, mock_config_entry_with_assist: MockConfigEntry,
@ -156,111 +358,27 @@ async def test_function_call(
mock_create_stream.return_value = [ mock_create_stream.return_value = [
# Initial conversation # Initial conversation
( (
# Wait for the model to think
*create_reasoning_item(id="rs_A", output_index=0),
# First tool call # First tool call
ChatCompletionChunk( *create_function_tool_call_item(
id="chatcmpl-A", id="fc_1",
created=1700000000, arguments=['{"para', 'm1":"call1"}'],
model="gpt-4-1106-preview", call_id="call_call_1",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
id="call_call_1",
index=0,
function=ChoiceDeltaToolCallFunction(
name="test_tool", name="test_tool",
arguments=None, output_index=1,
),
)
]
),
)
],
),
ChatCompletionChunk(
id="chatcmpl-A",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
function=ChoiceDeltaToolCallFunction(
name=None,
arguments='{"para',
),
)
]
),
)
],
),
ChatCompletionChunk(
id="chatcmpl-A",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
function=ChoiceDeltaToolCallFunction(
name=None,
arguments='m1":"call1"}',
),
)
]
),
)
],
), ),
# Second tool call # Second tool call
ChatCompletionChunk( *create_function_tool_call_item(
id="chatcmpl-A", id="fc_2",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
id="call_call_2",
index=1,
function=ChoiceDeltaToolCallFunction(
name="test_tool",
arguments='{"param1":"call2"}', arguments='{"param1":"call2"}',
), call_id="call_call_2",
) name="test_tool",
] output_index=2,
),
)
],
),
# Finish stream
ChatCompletionChunk(
id="chatcmpl-A",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[
Choice(index=0, finish_reason="tool_calls", delta=ChoiceDelta())
],
), ),
), ),
# Response after tool responses # Response after tool responses
ASSIST_RESPONSE_FINISH, create_message_item(id="msg_A", text="Cool", output_index=0),
] ]
mock_chat_log.mock_tool_results( mock_chat_log.mock_tool_results(
{ {
@ -288,99 +406,27 @@ async def test_function_call(
( (
"Test function call started with missing arguments", "Test function call started with missing arguments",
( (
ChatCompletionChunk( *create_function_tool_call_item(
id="chatcmpl-A", id="fc_1",
created=1700000000, arguments=[],
model="gpt-4-1106-preview", call_id="call_call_1",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
id="call_call_1",
index=0,
function=ChoiceDeltaToolCallFunction(
name="test_tool", name="test_tool",
arguments=None, output_index=0,
),
)
]
),
)
],
),
ChatCompletionChunk(
id="chatcmpl-B",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))],
), ),
*create_message_item(id="msg_A", text="Cool", output_index=1),
), ),
), ),
( (
"Test invalid JSON", "Test invalid JSON",
( (
ChatCompletionChunk( *create_function_tool_call_item(
id="chatcmpl-A", id="fc_1",
created=1700000000, arguments=['{"para'],
model="gpt-4-1106-preview", call_id="call_call_1",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
id="call_call_1",
index=0,
function=ChoiceDeltaToolCallFunction(
name="test_tool", name="test_tool",
arguments=None, output_index=0,
),
)
]
),
)
],
),
ChatCompletionChunk(
id="chatcmpl-A",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
function=ChoiceDeltaToolCallFunction(
name=None,
arguments='{"para',
),
)
]
),
)
],
),
ChatCompletionChunk(
id="chatcmpl-B",
created=1700000000,
model="gpt-4-1106-preview",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(content="Cool"),
finish_reason="tool_calls",
)
],
), ),
*create_message_item(id="msg_A", text="Cool", output_index=1),
), ),
), ),
], ],
@ -392,7 +438,7 @@ async def test_function_call_invalid(
mock_create_stream: AsyncMock, mock_create_stream: AsyncMock,
mock_chat_log: MockChatLog, # noqa: F811 mock_chat_log: MockChatLog, # noqa: F811
description: str, description: str,
messages: tuple[ChatCompletionChunk], messages: tuple[ResponseStreamEvent],
) -> None: ) -> None:
"""Test function call containing invalid data.""" """Test function call containing invalid data."""
mock_create_stream.return_value = [messages] mock_create_stream.return_value = [messages]
@ -432,7 +478,9 @@ async def test_assist_api_tools_conversion(
hass.states.async_set(f"{component}.test", "on") hass.states.async_set(f"{component}.test", "on")
async_expose_entity(hass, "conversation", f"{component}.test", True) async_expose_entity(hass, "conversation", f"{component}.test", True)
mock_create_stream.return_value = [ASSIST_RESPONSE_FINISH] mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Cool", output_index=0)
]
await conversation.async_converse( await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.openai" hass, "hello", None, Context(), agent_id="conversation.openai"

View File

@ -2,17 +2,16 @@
from unittest.mock import AsyncMock, mock_open, patch from unittest.mock import AsyncMock, mock_open, patch
from httpx import Request, Response import httpx
from openai import ( from openai import (
APIConnectionError, APIConnectionError,
AuthenticationError, AuthenticationError,
BadRequestError, BadRequestError,
RateLimitError, RateLimitError,
) )
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.image import Image from openai.types.image import Image
from openai.types.images_response import ImagesResponse from openai.types.images_response import ImagesResponse
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText
import pytest import pytest
from homeassistant.components.openai_conversation import CONF_FILENAMES from homeassistant.components.openai_conversation import CONF_FILENAMES
@ -117,8 +116,8 @@ async def test_generate_image_service_error(
patch( patch(
"openai.resources.images.AsyncImages.generate", "openai.resources.images.AsyncImages.generate",
side_effect=RateLimitError( side_effect=RateLimitError(
response=Response( response=httpx.Response(
status_code=500, request=Request(method="GET", url="") status_code=500, request=httpx.Request(method="GET", url="")
), ),
body=None, body=None,
message="Reason", message="Reason",
@ -202,13 +201,13 @@ async def test_invalid_config_entry(
("side_effect", "error"), ("side_effect", "error"),
[ [
( (
APIConnectionError(request=Request(method="GET", url="test")), APIConnectionError(request=httpx.Request(method="GET", url="test")),
"Connection error", "Connection error",
), ),
( (
AuthenticationError( AuthenticationError(
response=Response( response=httpx.Response(
status_code=500, request=Request(method="GET", url="test") status_code=500, request=httpx.Request(method="GET", url="test")
), ),
body=None, body=None,
message="", message="",
@ -217,8 +216,8 @@ async def test_invalid_config_entry(
), ),
( (
BadRequestError( BadRequestError(
response=Response( response=httpx.Response(
status_code=500, request=Request(method="GET", url="test") status_code=500, request=httpx.Request(method="GET", url="test")
), ),
body=None, body=None,
message="", message="",
@ -250,11 +249,11 @@ async def test_init_error(
( (
{"prompt": "Picture of a dog", "filenames": []}, {"prompt": "Picture of a dog", "filenames": []},
{ {
"messages": [ "input": [
{ {
"content": [ "content": [
{ {
"type": "text", "type": "input_text",
"text": "Picture of a dog", "text": "Picture of a dog",
}, },
], ],
@ -266,18 +265,18 @@ async def test_init_error(
( (
{"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]},
{ {
"messages": [ "input": [
{ {
"content": [ "content": [
{ {
"type": "text", "type": "input_text",
"text": "Picture of a dog", "text": "Picture of a dog",
}, },
{ {
"type": "image_url", "type": "input_image",
"image_url": { "image_url": "data:image/jpeg;base64,BASE64IMAGE1",
"url": "data:image/jpeg;base64,BASE64IMAGE1", "detail": "auto",
}, "file_id": "/a/b/c.jpg",
}, },
], ],
}, },
@ -291,24 +290,24 @@ async def test_init_error(
"filenames": ["/a/b/c.jpg", "d/e/f.jpg"], "filenames": ["/a/b/c.jpg", "d/e/f.jpg"],
}, },
{ {
"messages": [ "input": [
{ {
"content": [ "content": [
{ {
"type": "text", "type": "input_text",
"text": "Picture of a dog", "text": "Picture of a dog",
}, },
{ {
"type": "image_url", "type": "input_image",
"image_url": { "image_url": "data:image/jpeg;base64,BASE64IMAGE1",
"url": "data:image/jpeg;base64,BASE64IMAGE1", "detail": "auto",
}, "file_id": "/a/b/c.jpg",
}, },
{ {
"type": "image_url", "type": "input_image",
"image_url": { "image_url": "data:image/jpeg;base64,BASE64IMAGE2",
"url": "data:image/jpeg;base64,BASE64IMAGE2", "detail": "auto",
}, "file_id": "d/e/f.jpg",
}, },
], ],
}, },
@ -329,13 +328,17 @@ async def test_generate_content_service(
"""Test generate content service.""" """Test generate content service."""
service_data["config_entry"] = mock_config_entry.entry_id service_data["config_entry"] = mock_config_entry.entry_id
expected_args["model"] = "gpt-4o-mini" expected_args["model"] = "gpt-4o-mini"
expected_args["n"] = 1 expected_args["max_output_tokens"] = 150
expected_args["response_format"] = {"type": "json_object"} expected_args["top_p"] = 1.0
expected_args["messages"][0]["role"] = "user" expected_args["temperature"] = 1.0
expected_args["user"] = None
expected_args["store"] = False
expected_args["input"][0]["type"] = "message"
expected_args["input"][0]["role"] = "user"
with ( with (
patch( patch(
"openai.resources.chat.completions.AsyncCompletions.create", "openai.resources.responses.AsyncResponses.create",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_create, ) as mock_create,
patch( patch(
@ -345,19 +348,27 @@ async def test_generate_content_service(
patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.exists", return_value=True),
patch.object(hass.config, "is_allowed_path", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True),
): ):
mock_create.return_value = ChatCompletion( mock_create.return_value = Response(
id="", object="response",
model="", id="resp_A",
created=1700000000, created_at=1700000000,
object="chat.completion", model="gpt-4o-mini",
choices=[ parallel_tool_calls=True,
Choice( tool_choice="auto",
index=0, tools=[],
finish_reason="stop", output=[
message=ChatCompletionMessage( ResponseOutputMessage(
type="message",
id="msg_A",
content=[
ResponseOutputText(
type="output_text",
text="This is the response",
annotations=[],
)
],
role="assistant", role="assistant",
content="This is the response", status="completed",
),
) )
], ],
) )
@ -427,7 +438,7 @@ async def test_generate_content_service_invalid(
with ( with (
patch( patch(
"openai.resources.chat.completions.AsyncCompletions.create", "openai.resources.responses.AsyncResponses.create",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_create, ) as mock_create,
patch( patch(
@ -459,10 +470,10 @@ async def test_generate_content_service_error(
"""Test generate content service handles errors.""" """Test generate content service handles errors."""
with ( with (
patch( patch(
"openai.resources.chat.completions.AsyncCompletions.create", "openai.resources.responses.AsyncResponses.create",
side_effect=RateLimitError( side_effect=RateLimitError(
response=Response( response=httpx.Response(
status_code=417, request=Request(method="GET", url="") status_code=417, request=httpx.Request(method="GET", url="")
), ),
body=None, body=None,
message="Reason", message="Reason",