Python: Introducing UserInputRequest and Response types and HostedMcpTool (#405)

* initial work on User Approval (and hosted mcp to validate)

* small update to the comments in the sample

* enable local MCP tools in chatClient get methods

* working streaming and improved setup

* fix for pyright

* updated create_approval -> create_response method

* added tests

* updated HostedMcpTool and addressed feedback

* update type name

* naming updates

* small docstring update

* mypy fix

* fixes and updates

* fixes for responses

* fix int tests

* removed broken tests

* updated test running

* removed specific content check on websearch

* increased timeout

* split slow foundry test

* don't parallel run samples

* add dist load to unit tests

---------

Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2025-09-10 15:37:34 +02:00
committed by GitHub
Unverified
parent 947f2bf642
commit 6aa746d891
21 changed files with 1186 additions and 447 deletions
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypeVar, runt
from pydantic import BaseModel
from ._logging import get_logger
from ._mcp import MCPTool
from ._pydantic import AFBaseModel
from ._threads import ChatMessageStore
from ._tools import AIFunction, ToolProtocol
@@ -391,6 +392,25 @@ class BaseChatClient(AFBaseModel, ABC):
return_messages.append(msg)
return return_messages
@staticmethod
def _normalize_tools(
tools: ToolProtocol
| MutableMapping[str, Any]
| Callable[..., Any]
| list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]]
| None = None,
) -> list[ToolProtocol | dict[str, Any] | Callable[..., Any]]:
"""Normalize the tools input to a list of tools."""
final_tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] = []
if not tools:
return final_tools
for tool in tools if isinstance(tools, list) else [tools]: # type: ignore[reportUnknownType]
if isinstance(tool, MCPTool):
final_tools.extend(tool.functions) # type: ignore
continue
final_tools.append(tool) # type: ignore
return final_tools
# region Internal methods to be implemented by the derived classes
@abstractmethod
@@ -513,7 +533,7 @@ class BaseChatClient(AFBaseModel, ABC):
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools, # type: ignore
tools=self._normalize_tools(tools), # type: ignore
user=user,
additional_properties=additional_properties or {},
)
@@ -592,7 +612,7 @@ class BaseChatClient(AFBaseModel, ABC):
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools, # type: ignore
tools=self._normalize_tools(tools), # type: ignore
user=user,
additional_properties=additional_properties or {},
**kwargs,
+1 -79
View File
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any
from mcp import types
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.websocket import websocket_client
@@ -49,7 +48,6 @@ LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = {
}
__all__ = [
"MCPSseTools",
"MCPStdioTool",
"MCPStreamableHTTPTool",
"MCPWebsocketTool",
@@ -224,7 +222,7 @@ def _normalize_mcp_name(name: str) -> str:
class MCPTool:
"""Base class with the MCP logic."""
"""Main MCP class, to initialize use one of the subclasses."""
def __init__(
self,
@@ -567,82 +565,6 @@ class MCPStdioTool(MCPTool):
return stdio_client(server=StdioServerParameters(**args))
class MCPSseTools(MCPTool):
"""MCP sse server configuration."""
def __init__(
self,
name: str,
url: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP sse plugin.
The arguments are used to create a sse client.
see mcp.client.sse.sse_client for more details.
Any extra arguments passed to the constructor will be passed to the
sse client constructor.
Args:
name: The name of the plugin.
url: The URL of the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
additional_properties: Additional properties.
headers: The headers to send with the request.
timeout: The timeout for the request.
sse_read_timeout: The timeout for reading from the SSE stream.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the sse client.
"""
super().__init__(
name=name,
description=description,
additional_properties=additional_properties,
session=session,
chat_client=chat_client,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
)
self.url = url
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP SSE client."""
args: dict[str, Any] = {
"url": self.url,
}
if self.headers:
args["headers"] = self.headers
if self.timeout is not None:
args["timeout"] = self.timeout
if self.sse_read_timeout is not None:
args["sse_read_timeout"] = self.sse_read_timeout
if self._client_kwargs:
args.update(self._client_kwargs)
return sse_client(**args)
class MCPStreamableHTTPTool(MCPTool):
"""MCP streamable http server configuration."""
+90 -6
View File
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
from collections.abc import Awaitable, Callable
import sys
from collections.abc import Awaitable, Callable, Collection
from functools import wraps
from time import perf_counter
from typing import (
@@ -9,6 +9,7 @@ from typing import (
Annotated,
Any,
Generic,
Literal,
Protocol,
TypeVar,
get_args,
@@ -17,15 +18,21 @@ from typing import (
)
from opentelemetry import metrics, trace
from pydantic import BaseModel, Field, PrivateAttr, create_model
from pydantic import AnyUrl, BaseModel, Field, PrivateAttr, ValidationError, create_model, field_validator
from ._logging import get_logger
from ._pydantic import AFBaseModel
from .exceptions import ToolException
from .telemetry import GenAIAttributes, start_as_current_span
if TYPE_CHECKING:
from ._types import Contents
if sys.version_info >= (3, 12):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
tracer: trace.Tracer = trace.get_tracer("agent_framework")
meter: metrics.Meter = metrics.get_meter_provider().get_meter("agent_framework")
logger = get_logger()
@@ -34,6 +41,8 @@ __all__ = [
"AIFunction",
"HostedCodeInterpreterTool",
"HostedFileSearchTool",
"HostedMCPSpecificApproval",
"HostedMCPTool",
"HostedWebSearchTool",
"ToolProtocol",
"ai_function",
@@ -197,13 +206,88 @@ class HostedWebSearchTool(BaseTool):
args: dict[str, Any] = {
"name": "web_search",
}
super().__init__(**args, **kwargs)
class HostedMCPSpecificApproval(TypedDict, total=False):
"""Represents the `specific` mode for a hosted tool.
When using this mode, the user must specify which tools always or never require approval.
This is represented as a dictionary with two optional keys:
- `always_require_approval`: A sequence of tool names that always require approval.
- `never_require_approval`: A sequence of tool names that never require approval.
"""
always_require_approval: Collection[str] | None
never_require_approval: Collection[str] | None
class HostedMCPTool(BaseTool):
"""Represents a MCP tool that is managed and executed by the service."""
url: AnyUrl
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None
allowed_tools: set[str] | None = None
headers: dict[str, str] | None = None
def __init__(
self,
*,
name: str,
description: str | None = None,
url: AnyUrl | str,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
allowed_tools: Collection[str] | None = None,
headers: dict[str, str] | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Create a hosted MCP tool.
Args:
name: The name of the tool.
description: A description of the tool.
url: The URL of the tool.
approval_mode: The approval mode for the tool. This can be:
- "always_require": The tool always requires approval before use.
- "never_require": The tool never requires approval before use.
- A dict with keys `always_require_approval` or `never_require_approval`,
followed by a sequence of strings with the names of the relevant tools.
allowed_tools: A list of tools that are allowed to use this tool.
headers: Headers to include in requests to the tool.
additional_properties: Additional properties to include in the tool definition.
**kwargs: Additional keyword arguments to pass to the base class.
"""
args: dict[str, Any] = {
"name": name,
"url": url,
}
if allowed_tools is not None:
args["allowed_tools"] = allowed_tools
if approval_mode is not None:
args["approval_mode"] = approval_mode
if headers is not None:
args["headers"] = headers
if description is not None:
args["description"] = description
if additional_properties is not None:
args["additional_properties"] = additional_properties
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedFileSearchTool and cannot be set.")
super().__init__(**args, **kwargs)
try:
super().__init__(**args, **kwargs)
except ValidationError as err:
raise ToolException(f"Error initializing HostedMCPTool: {err}", inner_exception=err) from err
@field_validator("approval_mode")
def validate_approval_mode(cls, approval_mode: str | dict[str, Any] | None) -> str | dict[str, Any] | None:
"""Validate the approval_mode field to ensure it is one of the accepted values."""
if approval_mode is None or not isinstance(approval_mode, dict):
return approval_mode
# Validate that the dict has sets
for key, value in approval_mode.items():
if not isinstance(value, set):
approval_mode[key] = set(value) # Convert to set if it's a list or other collection
return approval_mode
class HostedFileSearchTool(BaseTool):
+135 -14
View File
@@ -29,7 +29,7 @@ from pydantic import (
from ._logging import get_logger
from ._pydantic import AFBaseModel
from ._tools import ToolProtocol, ai_function
from .exceptions import AgentFrameworkException
from .exceptions import AdditionItemMismatch
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
@@ -55,6 +55,7 @@ KNOWN_MEDIA_TYPES = [
"application/pdf",
"application/xml",
"audio/mpeg",
"audio/mp3",
"audio/ogg",
"audio/wav",
"image/apng",
@@ -93,6 +94,8 @@ __all__ = [
"DataContent",
"ErrorContent",
"FinishReason",
"FunctionApprovalRequestContent",
"FunctionApprovalResponseContent",
"FunctionCallContent",
"FunctionResultContent",
"GeneratedEmbeddings",
@@ -224,7 +227,11 @@ def _process_update(
is_new_message = False
if (
not response.messages
or (update.message_id and response.messages[-1].message_id != update.message_id)
or (
update.message_id
and response.messages[-1].message_id
and response.messages[-1].message_id != update.message_id
)
or (update.role and response.messages[-1].role != update.role)
):
is_new_message = True
@@ -249,7 +256,7 @@ def _process_update(
):
try:
message.contents[-1] += content
except AgentFrameworkException:
except AdditionItemMismatch:
message.contents.append(content)
elif isinstance(content, UsageContent):
if response.usage_details is None:
@@ -718,7 +725,7 @@ class DataContent(BaseContent):
raise ValueError(f"Unknown media type: {media_type}")
return uri
def has_top_level_media_type(self, top_level_media_type: str) -> bool:
def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool:
return _has_top_level_media_type(self.media_type, top_level_media_type)
@@ -776,11 +783,13 @@ class UriContent(BaseContent):
**kwargs,
)
def has_top_level_media_type(self, top_level_media_type: str) -> bool:
def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool:
return _has_top_level_media_type(self.media_type, top_level_media_type)
def _has_top_level_media_type(media_type: str | None, top_level_media_type: str) -> bool:
def _has_top_level_media_type(
media_type: str | None, top_level_media_type: Literal["application", "audio", "image", "text"]
) -> bool:
if media_type is None:
return False
@@ -924,7 +933,7 @@ class FunctionCallContent(BaseContent):
if not isinstance(other, FunctionCallContent):
raise TypeError("Incompatible type")
if other.call_id and self.call_id != other.call_id:
raise AgentFrameworkException("Incompatible function call contents")
raise AdditionItemMismatch
if not self.arguments:
arguments = other.arguments
elif not other.arguments:
@@ -1093,6 +1102,110 @@ class HostedVectorStoreContent(BaseContent):
)
class BaseUserInputRequest(BaseContent):
"""Base class for all user requests."""
type: Literal["user_input_request"] = "user_input_request" # type: ignore[assignment]
id: Annotated[str, Field(..., min_length=1)]
class BaseUserInputResponse(BaseContent):
"""Base class for all user responses."""
type: Literal["user_input_response"] = "user_input_response" # type: ignore[assignment]
id: Annotated[str, Field(..., min_length=1)]
class FunctionApprovalResponseContent(BaseUserInputResponse):
"""Represents a response for user approval of a function call."""
type: Literal["function_approval_response"] = "function_approval_response" # type: ignore[assignment]
approved: bool
function_call: FunctionCallContent
def __init__(
self,
approved: bool,
*,
id: str,
function_call: FunctionCallContent,
annotations: list[Annotations] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
"""Initializes a FunctionApprovalResponseContent instance.
Args:
approved: Whether the function call was approved.
id: The unique identifier for the request.
function_call: The function call content to be approved.
annotations: Optional list of annotations for the request.
additional_properties: Optional additional properties for the request.
raw_representation: Optional raw representation of the request.
**kwargs: Additional keyword arguments.
"""
super().__init__(
approved=approved, # type: ignore[reportCallIssue]
id=id, # type: ignore[reportCallIssue]
function_call=function_call, # type: ignore[reportCallIssue]
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
class FunctionApprovalRequestContent(BaseUserInputRequest):
"""Represents a request for user approval of a function call."""
type: Literal["function_approval_request"] = "function_approval_request" # type: ignore[assignment]
function_call: FunctionCallContent
def __init__(
self,
*,
id: str,
function_call: FunctionCallContent,
annotations: list[Annotations] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
"""Initializes a FunctionApprovalRequestContent instance.
Args:
id: The unique identifier for the request.
function_call: The function call content to be approved.
annotations: Optional list of annotations for the request.
additional_properties: Optional additional properties for the request.
raw_representation: Optional raw representation of the request.
**kwargs: Additional keyword arguments.
"""
super().__init__(
id=id, # type: ignore[reportCallIssue]
function_call=function_call, # type: ignore[reportCallIssue]
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
def create_response(self, approved: bool) -> "FunctionApprovalResponseContent":
"""Create a response for the function approval request."""
return FunctionApprovalResponseContent(
approved,
id=self.id,
function_call=self.function_call,
additional_properties=self.additional_properties,
)
UserInputRequestContents = Annotated[
FunctionApprovalRequestContent,
Field(discriminator="type"),
]
Contents = Annotated[
TextContent
| DataContent
@@ -1103,7 +1216,9 @@ Contents = Annotated[
| ErrorContent
| UsageContent
| HostedFileContent
| HostedVectorStoreContent,
| HostedVectorStoreContent
| FunctionApprovalRequestContent
| FunctionApprovalResponseContent,
Field(discriminator="type"),
]
@@ -1957,6 +2072,13 @@ class AgentRunResponse(AFBaseModel):
"""Get the concatenated text of all messages."""
return "".join(msg.text for msg in self.messages) if self.messages else ""
@property
def user_input_requests(self) -> list[UserInputRequestContents]:
"""Get all BaseUserInputRequest messages from the response."""
return [
content for msg in self.messages for content in msg.contents if isinstance(content, BaseUserInputRequest)
]
@classmethod
def from_agent_run_response_updates(
cls: type[TAgentRunResponse], updates: Sequence["AgentRunResponseUpdate"]
@@ -2007,6 +2129,11 @@ class AgentRunResponseUpdate(AFBaseModel):
else ""
)
@property
def user_input_requests(self) -> list[UserInputRequestContents]:
"""Get all BaseUserInputRequest messages from the response."""
return [content for content in self.contents if isinstance(content, BaseUserInputRequest)]
def __str__(self) -> str:
return self.text
@@ -2082,9 +2209,3 @@ class TextToSpeechOptions(AFBaseModel):
for key in merged_exclude:
settings.pop(key, None)
return settings
# endregion
# endregion
@@ -12,13 +12,15 @@ class AgentFrameworkException(Exception):
Automatically logs the message as debug.
"""
def __init__(self, message: str, inner_exception: Exception | None = None, *args: Any, **kwargs: Any):
def __init__(self, message: str, inner_exception: Exception | None = None, *args: Any):
"""Create an AgentFrameworkException.
This emits a debug log, with the inner_exception if provided.
"""
logger.debug(message, exc_info=inner_exception)
super().__init__(message, *args, **kwargs) # type: ignore
if inner_exception:
super().__init__(message, inner_exception, *args) # type: ignore
super().__init__(message, *args) # type: ignore
class AgentException(AgentFrameworkException):
@@ -94,3 +96,14 @@ class ToolExecutionException(ToolException):
"""An error occurred while executing a tool."""
pass
class AdditionItemMismatch(AgentFrameworkException):
"""An error occurred while adding two types."""
def __init__(self) -> None:
"""Create an AdditionItemMismatch.
Unlike the AgentFrameworkException, this does not log the message automatically,
"""
pass
@@ -13,18 +13,12 @@ from openai.types.responses.parsed_response import (
ParsedResponse,
)
from openai.types.responses.response import Response as OpenAIResponse
from openai.types.responses.response_completed_event import ResponseCompletedEvent
from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent
from openai.types.responses.response_function_call_arguments_delta_event import ResponseFunctionCallArgumentsDeltaEvent
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
from openai.types.responses.response_output_text import ResponseOutputText
from openai.types.responses.response_stream_event import ResponseStreamEvent as OpenAIResponseStreamEvent
from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent
from openai.types.responses.response_usage import ResponseUsage
from openai.types.responses.tool_param import (
CodeInterpreter,
CodeInterpreterContainerCodeInterpreterToolAuto,
Mcp,
ToolParam,
)
from openai.types.responses.web_search_tool_param import UserLocation as WebSearchUserLocation
@@ -33,7 +27,14 @@ from pydantic import BaseModel, SecretStr, ValidationError
from .._clients import BaseChatClient, use_tool_calling
from .._logging import get_logger
from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool, HostedWebSearchTool, ToolProtocol
from .._tools import (
AIFunction,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedWebSearchTool,
ToolProtocol,
)
from .._types import (
ChatMessage,
ChatOptions,
@@ -42,6 +43,8 @@ from .._types import (
CitationAnnotation,
Contents,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
HostedFileContent,
@@ -364,15 +367,41 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
# region Prep methods
def _chat_to_response_tool_spec(
def _tools_to_response_tools(
self, tools: list[ToolProtocol | MutableMapping[str, Any]]
) -> list[ToolParam | dict[str, Any]]:
response_tools: list[ToolParam | dict[str, Any]] = []
for tool in tools:
if isinstance(tool, ToolProtocol):
match tool:
case HostedMCPTool():
mcp: Mcp = {
"type": "mcp",
"server_label": tool.name.replace(" ", "_"),
"server_url": str(tool.url),
"server_description": tool.description,
"headers": tool.headers,
}
if tool.allowed_tools:
mcp["allowed_tools"] = list(tool.allowed_tools)
if tool.approval_mode:
match tool.approval_mode:
case str():
mcp["require_approval"] = (
"always" if tool.approval_mode == "always_require" else "never"
)
case _:
if always_require_approvals := tool.approval_mode.get("always_require_approval"):
mcp["require_approval"] = {
"always": {"tool_names": list(always_require_approvals)}
}
if never_require_approvals := tool.approval_mode.get("never_require_approval"):
mcp["require_approval"] = {
"never": {"tool_names": list(never_require_approvals)}
}
response_tools.append(mcp)
case HostedCodeInterpreterTool():
tool_args: dict[str, Any] = {"type": "auto"}
tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"}
if tool.inputs:
tool_args["file_ids"] = []
for tool_input in tool.inputs:
@@ -383,7 +412,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
response_tools.append(
CodeInterpreter(
type="code_interpreter",
container=CodeInterpreterContainerCodeInterpreterToolAuto(**tool_args), # type: ignore[typeddict-item]
container=tool_args,
)
)
case AIFunction():
@@ -455,7 +484,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
if chat_options.tools is None:
options_dict.pop("parallel_tool_calls", None)
else:
options_dict["tools"] = self._chat_to_response_tool_spec(chat_options.tools)
options_dict["tools"] = self._tools_to_response_tools(chat_options.tools)
# other settings
if "store" not in options_dict:
options_dict["store"] = False
@@ -496,6 +525,137 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
def _openai_chat_message_parser(
self,
message: ChatMessage,
call_id_to_id: dict[str, str],
) -> list[dict[str, Any]]:
"""Parse a chat message into the openai format."""
all_messages: list[dict[str, Any]] = []
args: dict[str, Any] = {
"role": message.role.value if isinstance(message.role, Role) else message.role,
}
if message.additional_properties:
args["metadata"] = message.additional_properties
for content in message.contents:
match content:
case FunctionResultContent():
new_args: dict[str, Any] = {}
new_args.update(self._openai_content_parser(message.role, content, call_id_to_id))
all_messages.append(new_args)
case FunctionCallContent():
function_call = self._openai_content_parser(message.role, content, call_id_to_id)
all_messages.append(function_call) # type: ignore
case FunctionApprovalResponseContent() | FunctionApprovalRequestContent():
all_messages.append(self._openai_content_parser(message.role, content, call_id_to_id)) # type: ignore
case _:
if "content" not in args:
args["content"] = []
args["content"].append(self._openai_content_parser(message.role, content, call_id_to_id)) # type: ignore
if "content" in args or "tool_calls" in args:
all_messages.append(args)
return all_messages
def _openai_content_parser(
self,
role: Role,
content: Contents,
call_id_to_id: dict[str, str],
) -> dict[str, Any]:
"""Parse contents into the openai format."""
match content:
case TextContent():
return {
"type": "output_text" if role == Role.ASSISTANT else "input_text",
"text": content.text,
}
case TextReasoningContent():
ret: dict[str, Any] = {
"type": "reasoning",
"summary": {
"type": "summary_text",
"text": content.text,
},
}
if content.additional_properties is not None:
if status := content.additional_properties.get("status"):
ret["status"] = status
if reasoning_text := content.additional_properties.get("reasoning_text"):
ret["content"] = {"type": "reasoning_text", "text": reasoning_text}
if encrypted_content := content.additional_properties.get("encrypted_content"):
ret["encrypted_content"] = encrypted_content
return ret
case DataContent() | UriContent():
if content.has_top_level_media_type("image"):
return {
"type": "input_image",
"image_url": content.uri,
"detail": content.additional_properties.get("detail", "auto")
if content.additional_properties
else "auto",
"file_id": content.additional_properties.get("file_id", None)
if content.additional_properties
else None,
}
if content.has_top_level_media_type("audio"):
if content.media_type and "wav" in content.media_type:
format = "wav"
elif content.media_type and "mp3" in content.media_type:
format = "mp3"
else:
logger.warning("Unsupported audio media type: %s", content.media_type)
return {}
return {
"type": "input_audio",
"input_audio": {
"data": content.uri,
"format": format,
},
}
return {}
case FunctionCallContent():
return {
"call_id": content.call_id,
"id": call_id_to_id[content.call_id],
"type": "function_call",
"name": content.name,
"arguments": content.arguments,
}
case FunctionResultContent():
# call_id for the result needs to be the same as the call_id for the function call
args: dict[str, Any] = {
"call_id": content.call_id,
"id": call_id_to_id.get(content.call_id),
"type": "function_call_output",
}
if content.result:
args["output"] = prepare_function_call_results(content.result)
return args
case FunctionApprovalRequestContent():
return {
"type": "mcp_approval_request",
"id": content.id,
"arguments": content.function_call.arguments,
"name": content.function_call.name,
"server_label": content.function_call.additional_properties.get("server_label")
if content.function_call.additional_properties
else None,
}
case FunctionApprovalResponseContent():
return {
"type": "mcp_approval_response",
"approval_request_id": content.id,
"approve": content.approved,
}
case HostedFileContent():
return {
"type": "input_file",
"file_id": content.file_id,
}
case _: # should catch UsageDetails and ErrorContent and HostedVectorStoreContent
logger.debug("Unsupported content type passed (type: %s)", type(content))
return {}
# region Response creation methods
def _create_response_content(
@@ -533,7 +693,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
match message_content.type:
case "output_text":
text_content = TextContent(
text=message_content.text, raw_representation=message_content
text=message_content.text,
raw_representation=message_content, # type: ignore[reportUnknownArgumentType]
)
metadata.update(self._get_metadata_from_response(message_content))
if message_content.annotations:
@@ -639,6 +800,19 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
raw_representation=item,
)
)
case "mcp_approval_request": # ResponseOutputMcpApprovalRequest
contents.append(
FunctionApprovalRequestContent(
id=item.id,
function_call=FunctionCallContent(
call_id=item.id,
name=item.name,
arguments=item.arguments,
additional_properties={"server_label": item.server_label},
raw_representation=item,
),
)
)
case "image_generation_call": # ResponseOutputImageGenerationCall
if item.result:
contents.append(
@@ -649,7 +823,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
)
# TODO(peterychang): Add support for other content types
case _:
logger.debug("Unparsed content of type: %s: %s", item.type, item)
logger.debug("Unparsed output of type: %s: %s", item.type, item)
response_message = ChatMessage(role="assistant", contents=contents)
args: dict[str, Any] = {
"response_id": response.id,
@@ -677,35 +851,151 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
) -> ChatResponseUpdate:
"""Create a streaming chat message content object from a choice."""
metadata: dict[str, Any] = {}
items: list[Contents] = []
contents: list[Contents] = []
conversation_id: str | None = None
model = self.ai_model_id
# TODO(peterychang): Add support for other content types
match event:
case ResponseContentPartAddedEvent():
match event.part:
case ResponseOutputText():
items.append(TextContent(text=event.part.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event.part))
case ResponseOutputRefusal():
items.append(TextContent(text=event.part.refusal, raw_representation=event))
case ResponseTextDeltaEvent():
items.append(TextContent(text=event.delta, raw_representation=event))
match event.type:
# types:
# ResponseAudioDeltaEvent,
# ResponseAudioDoneEvent,
# ResponseAudioTranscriptDeltaEvent,
# ResponseAudioTranscriptDoneEvent,
# ResponseCodeInterpreterCallCodeDeltaEvent,
# ResponseCodeInterpreterCallCodeDoneEvent,
# ResponseCodeInterpreterCallCompletedEvent,
# ResponseCodeInterpreterCallInProgressEvent,
# ResponseCodeInterpreterCallInterpretingEvent,
# ResponseCompletedEvent,
# ResponseContentPartAddedEvent,
# ResponseContentPartDoneEvent,
# ResponseCreatedEvent,
# ResponseErrorEvent,
# ResponseFileSearchCallCompletedEvent,
# ResponseFileSearchCallInProgressEvent,
# ResponseFileSearchCallSearchingEvent,
# ResponseFunctionCallArgumentsDeltaEvent,
# ResponseFunctionCallArgumentsDoneEvent,
# ResponseInProgressEvent,
# ResponseFailedEvent,
# ResponseIncompleteEvent,
# ResponseOutputItemAddedEvent,
# ResponseOutputItemDoneEvent,
# ResponseReasoningSummaryPartAddedEvent,
# ResponseReasoningSummaryPartDoneEvent,
# ResponseReasoningSummaryTextDeltaEvent,
# ResponseReasoningSummaryTextDoneEvent,
# ResponseReasoningTextDeltaEvent,
# ResponseReasoningTextDoneEvent,
# ResponseRefusalDeltaEvent,
# ResponseRefusalDoneEvent,
# ResponseTextDeltaEvent,
# ResponseTextDoneEvent,
# ResponseWebSearchCallCompletedEvent,
# ResponseWebSearchCallInProgressEvent,
# ResponseWebSearchCallSearchingEvent,
# ResponseImageGenCallCompletedEvent,
# ResponseImageGenCallGeneratingEvent,
# ResponseImageGenCallInProgressEvent,
# ResponseImageGenCallPartialImageEvent,
# ResponseMcpCallArgumentsDeltaEvent,
# ResponseMcpCallArgumentsDoneEvent,
# ResponseMcpCallCompletedEvent,
# ResponseMcpCallFailedEvent,
# ResponseMcpCallInProgressEvent,
# ResponseMcpListToolsCompletedEvent,
# ResponseMcpListToolsFailedEvent,
# ResponseMcpListToolsInProgressEvent,
# ResponseOutputTextAnnotationAddedEvent,
# ResponseQueuedEvent,
# ResponseCustomToolCallInputDeltaEvent,
# ResponseCustomToolCallInputDoneEvent,
case "response.content_part.added":
event_part = event.part
match event_part.type:
case "output_text":
contents.append(TextContent(text=event_part.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event_part))
case "refusal":
contents.append(TextContent(text=event_part.refusal, raw_representation=event))
case "response.output_text.delta":
contents.append(TextContent(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case ResponseCompletedEvent():
case "response.completed":
conversation_id = event.response.id if chat_options.store is True else None
model = event.response.model
if event.response.usage:
usage = self._usage_details_from_openai(event.response.usage)
if usage:
items.append(UsageContent(details=usage, raw_representation=event))
case ResponseOutputItemAddedEvent():
if event.item.type == "function_call":
function_call_ids[event.output_index] = (event.item.call_id, event.item.name)
case ResponseFunctionCallArgumentsDeltaEvent():
contents.append(UsageContent(details=usage, raw_representation=event))
case "response.output_item.added":
event_item = event.item
match event_item.type:
# types:
# ResponseOutputMessage,
# ResponseFileSearchToolCall,
# ResponseFunctionToolCall,
# ResponseFunctionWebSearch,
# ResponseComputerToolCall,
# ResponseReasoningItem,
# ImageGenerationCall,
# ResponseCodeInterpreterToolCall,
# LocalShellCall,
# McpCall,
# McpListTools,
# McpApprovalRequest,
# ResponseCustomToolCall,
case "function_call":
function_call_ids[event.output_index] = (event_item.call_id, event_item.name)
case "mcp_approval_request":
contents.append(
FunctionApprovalRequestContent(
id=event_item.id,
function_call=FunctionCallContent(
call_id=event_item.id,
name=event_item.name,
arguments=event_item.arguments,
additional_properties={"server_label": event_item.server_label},
raw_representation=event_item,
),
)
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
if event_item.outputs:
for code_output in event_item.outputs:
if code_output.type == "logs":
contents.append(TextContent(text=code_output.logs, raw_representation=event_item))
if code_output.type == "image":
contents.append(
UriContent(
uri=code_output.url,
raw_representation=event_item,
# no more specific media type then this can be inferred
media_type="image",
)
)
elif event_item.code:
# fallback if no output was returned is the code:
contents.append(TextContent(text=event_item.code, raw_representation=event_item))
case "reasoning": # ResponseOutputReasoning
if event_item.content:
for index, reasoning_content in enumerate(event_item.content):
additional_properties = None
if event_item.summary and index < len(event_item.summary):
additional_properties = {"summary": event_item.summary[index]}
contents.append(
TextReasoningContent(
text=reasoning_content.text,
raw_representation=reasoning_content,
additional_properties=additional_properties,
)
)
case _:
logger.debug("Unparsed event of type: %s: %s", event.type, event)
case "response.function_call_arguments.delta":
call_id, name = function_call_ids.get(event.output_index, (None, None))
if call_id and name:
items.append(
contents.append(
FunctionCallContent(
call_id=call_id,
name=name,
@@ -715,10 +1005,10 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
)
)
case _:
logger.debug("Unparsed event: %s", event)
logger.debug("Unparsed event of type: %s: %s", event.type, event)
return ChatResponseUpdate(
contents=items,
contents=contents,
conversation_id=conversation_id,
role=Role.ASSISTANT,
ai_model_id=model,
@@ -738,69 +1028,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens
return details
def _openai_chat_message_parser(
self,
message: ChatMessage,
call_id_to_id: dict[str, str],
) -> list[dict[str, Any]]:
"""Parse a chat message into the openai format."""
all_messages: list[dict[str, Any]] = []
args: dict[str, Any] = {
"role": message.role.value if isinstance(message.role, Role) else message.role,
}
if message.additional_properties:
args["metadata"] = message.additional_properties
for content in message.contents:
match content:
case FunctionResultContent():
new_args: dict[str, Any] = {}
new_args.update(self._openai_content_parser(message.role, content, call_id_to_id))
all_messages.append(new_args)
case FunctionCallContent():
function_call = self._openai_content_parser(message.role, content, call_id_to_id)
all_messages.append(function_call) # type: ignore
case _:
if "content" not in args:
args["content"] = []
args["content"].append(self._openai_content_parser(message.role, content, call_id_to_id)) # type: ignore
if "content" in args or "tool_calls" in args:
all_messages.append(args)
return all_messages
def _openai_content_parser(
self,
role: Role,
content: Contents,
call_id_to_id: dict[str, str],
) -> dict[str, Any]:
"""Parse contents into the openai format."""
match content:
case FunctionCallContent():
return {
"call_id": content.call_id,
"id": call_id_to_id[content.call_id],
"type": "function_call",
"name": content.name,
"arguments": content.arguments,
}
case FunctionResultContent():
# call_id for the result needs to be the same as the call_id for the function call
args: dict[str, Any] = {
"call_id": content.call_id,
"type": "function_call_output",
}
if content.result:
args["output"] = prepare_function_call_results(content.result)
return args
case TextContent():
return {
"type": "output_text" if role == Role.ASSISTANT else "input_text",
"text": content.text,
}
# TODO(peterychang): We'll probably need to specialize the other content types as well
case _:
return content.model_dump(exclude_none=True)
def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
"""Get metadata from a chat choice."""
if logprobs := getattr(output, "logprobs", None):