Files
2026-07-13 13:22:34 +08:00

567 lines
21 KiB
Python

import json
from collections.abc import Sequence
from itertools import tee
from typing import Any, Generator, Iterator
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, model_validator
from mlflow.types.agent import ChatContext
from mlflow.types.responses_helpers import (
BaseRequestPayload,
Message,
OutputItem,
Response,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseOutputItemDoneEvent,
ResponseTextAnnotationDeltaEvent,
ResponseTextDeltaEvent,
)
__all__ = [
"ResponsesAgentRequest",
"ResponsesAgentResponse",
"ResponsesAgentStreamEvent",
]
from mlflow.types.schema import Schema
from mlflow.types.type_hints import _infer_schema_from_type_hint
from mlflow.utils.autologging_utils.logging_and_warnings import (
MlflowEventsAndWarningsBehaviorGlobally,
)
class ResponsesAgentRequest(BaseRequestPayload):
"""Request object for ResponsesAgent.
Args:
input: List of simple `role` and `content` messages or output items. See examples at
https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#testing-out-your-agent
and
https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
custom_inputs (Dict[str, Any]): An optional param to provide arbitrary additional context
to the model. The dictionary values must be JSON-serializable.
**Optional** defaults to ``None``
context (:py:class:`mlflow.types.agent.ChatContext`): The context to be used in the chat
endpoint. Includes conversation_id and user_id. **Optional** defaults to ``None``
"""
input: list[Message | OutputItem]
custom_inputs: dict[str, Any] | None = None
context: ChatContext | None = None
class ResponsesAgentResponse(Response):
"""Response object for ResponsesAgent.
Args:
output: List of output items. See examples at
https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
reasoning: Reasoning parameters
usage: Usage information
custom_outputs (Dict[str, Any]): An optional param to provide arbitrary additional context
from the model. The dictionary values must be JSON-serializable. **Optional**, defaults
to ``None``
"""
custom_outputs: dict[str, Any] | None = None
class ResponsesAgentStreamEvent(BaseModel):
"""Stream event for ResponsesAgent.
See examples at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#streaming-agent-output
Args:
type (str): Type of the stream event
custom_outputs (Dict[str, Any]): An optional param to provide arbitrary additional context
from the model. The dictionary values must be JSON-serializable. **Optional**, defaults
to ``None``
"""
model_config = ConfigDict(extra="allow")
type: str
custom_outputs: dict[str, Any] | None = None
@model_validator(mode="after")
def check_type(self) -> "ResponsesAgentStreamEvent":
type = self.type
if type == "response.output_item.done":
ResponseOutputItemDoneEvent(**self.model_dump())
elif type == "response.output_text.delta":
ResponseTextDeltaEvent(**self.model_dump())
elif type == "response.output_text.annotation.added":
ResponseTextAnnotationDeltaEvent(**self.model_dump())
elif type == "error":
ResponseErrorEvent(**self.model_dump())
elif type == "response.completed":
ResponseCompletedEvent(**self.model_dump())
"""
unvalidated types: {
"response.created",
"response.in_progress",
"response.completed",
"response.failed",
"response.incomplete",
"response.content_part.added",
"response.content_part.done",
"response.output_text.done",
"response.output_item.added",
"response.refusal.delta",
"response.refusal.done",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.file_search_call.in_progress",
"response.file_search_call.searching",
"response.file_search_call.completed",
"response.web_search_call.in_progress",
"response.web_search_call.searching",
"response.web_search_call.completed",
"response.error",
}
"""
return self
with MlflowEventsAndWarningsBehaviorGlobally(
reroute_warnings=False,
disable_event_logs=True,
disable_warnings=True,
):
properties = _infer_schema_from_type_hint(ResponsesAgentRequest).to_dict()[0]["properties"]
formatted_properties = [{**prop, "name": name} for name, prop in properties.items()]
RESPONSES_AGENT_INPUT_SCHEMA = Schema.from_json(json.dumps(formatted_properties))
RESPONSES_AGENT_OUTPUT_SCHEMA = _infer_schema_from_type_hint(ResponsesAgentResponse)
RESPONSES_AGENT_INPUT_EXAMPLE = {"input": [{"role": "user", "content": "Hello!"}]}
try:
from langchain_core.messages import BaseMessage
_HAS_LANGCHAIN_BASE_MESSAGE = True
except ImportError:
_HAS_LANGCHAIN_BASE_MESSAGE = False
def responses_agent_output_reducer(
chunks: list[ResponsesAgentStreamEvent | dict[str, Any]],
):
"""Output reducer for ResponsesAgent streaming."""
output_items = []
for chunk in chunks:
# Handle both dict and pydantic object formats
if isinstance(chunk, dict):
chunk_type = chunk.get("type")
if chunk_type == "response.output_item.done":
output_items.append(chunk.get("item"))
else:
# Pydantic object (ResponsesAgentStreamEvent)
if hasattr(chunk, "type") and chunk.type == "response.output_item.done":
output_items.append(chunk.item)
return ResponsesAgentResponse(output=output_items).model_dump(exclude_none=True)
def create_text_delta(delta: str, item_id: str) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the text delta schema for
streaming.
Read more at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#streaming-agent-output.
"""
return {
"type": "response.output_text.delta",
"item_id": item_id,
"delta": delta,
}
def create_annotation_added(
item_id: str, annotation: dict[str, Any], annotation_index: int | None = 0
) -> dict[str, Any]:
"""Helper method to create annotation added event."""
return {
"type": "response.output_text.annotation.added",
"item_id": item_id,
"annotation_index": annotation_index,
"annotation": annotation,
}
def create_text_output_item(
text: str, id: str, annotations: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the text output item schema.
Read more at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
Args:
text (str): The text to be outputted.
id (str): The id of the output item.
annotations (Optional[list[dict]]): The annotations of the output item.
"""
content_item = {
"text": text,
"type": "output_text",
"annotations": annotations or [],
}
return {
"id": id,
"content": [content_item],
"role": "assistant",
"type": "message",
}
def create_reasoning_item(id: str, reasoning_text: str) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the reasoning item schema.
Read more at https://www.mlflow.org/docs/latest/llms/responses-agent-intro/#creating-agent-output.
"""
return {
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": reasoning_text,
}
],
"id": id,
}
def create_function_call_item(id: str, call_id: str, name: str, arguments: str) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the function call item schema.
Read more at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
Args:
id (str): The id of the output item.
call_id (str): The id of the function call.
name (str): The name of the function to be called.
arguments (str): The arguments to be passed to the function.
"""
return {
"type": "function_call",
"id": id,
"call_id": call_id,
"name": name,
"arguments": arguments,
}
def create_function_call_output_item(call_id: str, output: str) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the function call output item
schema.
Read more at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
Args:
call_id (str): The id of the function call.
output (str): The output of the function call.
"""
return {
"type": "function_call_output",
"call_id": call_id,
"output": output,
}
def create_mcp_approval_request_item(
id: str, arguments: str, name: str, server_label: str
) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the MCP approval request item schema.
Read more at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
Args:
id (str): The unique id of the approval request.
arguments (str): A JSON string of arguments for the tool.
name (str): The name of the tool to run.
server_label (str): The label of the MCP server making the request.
"""
return {
"type": "mcp_approval_request",
"id": id,
"arguments": arguments,
"name": name,
"server_label": server_label,
}
def create_mcp_approval_response_item(
id: str,
approval_request_id: str,
approve: bool,
reason: str | None = None,
) -> dict[str, Any]:
"""Helper method to create a dictionary conforming to the MCP approval response item schema.
Read more at https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro#creating-agent-output.
Args:
id (str): The unique id of the approval response.
approval_request_id (str): The id of the approval request being answered.
approve (bool): Whether the request was approved.
reason (Optional[str]): The reason for the approval.
"""
return {
"type": "mcp_approval_response",
"id": id,
"approval_request_id": approval_request_id,
"approve": approve,
"reason": reason,
}
def responses_to_cc(message: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert from a Responses API output item to a list of ChatCompletion messages."""
msg_type = message.get("type")
if msg_type == "function_call":
return [
{
"role": "assistant",
"content": "tool call", # empty content is not supported by claude models
"tool_calls": [
{
"id": message["call_id"],
"type": "function",
"function": {
"arguments": message.get("arguments") or "{}",
"name": message["name"],
},
}
],
}
]
elif msg_type == "message" and isinstance(message.get("content"), list):
return [
{"role": message["role"], "content": content["text"]} for content in message["content"]
]
elif msg_type == "reasoning":
return [{"role": "assistant", "content": json.dumps(message["summary"])}]
elif msg_type == "function_call_output":
output = message["output"]
# Convert non-string output to string for ChatCompletion compatibility
if not isinstance(output, str):
try:
output = json.dumps(output)
except (TypeError, ValueError):
output = str(output)
return [
{
"role": "tool",
"content": output,
"tool_call_id": message["call_id"],
}
]
elif msg_type == "mcp_approval_request":
return [
{
"role": "assistant",
"content": "mcp approval request",
"tool_calls": [
{
"id": message["id"],
"type": "function",
"function": {
"arguments": message.get("arguments") or "{}",
"name": message["name"],
},
}
],
}
]
elif msg_type == "mcp_approval_response":
return [
{
"role": "tool",
"content": str(message["approve"]),
"tool_call_id": message["approval_request_id"],
}
]
compatible_keys = ["role", "content", "name", "tool_calls", "tool_call_id"]
filtered = {k: v for k, v in message.items() if k in compatible_keys}
return [filtered] if filtered else []
def to_chat_completions_input(
responses_input: Sequence[dict[str, Any] | Message | OutputItem],
) -> list[dict[str, Any]]:
"""Convert from Responses input items to ChatCompletion dictionaries."""
cc_msgs = []
for msg in responses_input:
if isinstance(msg, BaseModel):
cc_msgs.extend(responses_to_cc(msg.model_dump()))
else:
cc_msgs.extend(responses_to_cc(msg))
return cc_msgs
def output_to_responses_items_stream(
chunks: Iterator[dict[str, Any]],
aggregator: list[dict[str, Any]] | None = None,
) -> Generator[ResponsesAgentStreamEvent, None, None]:
"""
For streaming, convert from various message format dicts to Responses output items,
returning a generator of ResponsesAgentStreamEvent objects.
If `aggregator` is provided, it will be extended with the aggregated output item dicts.
Handles an iterator of ChatCompletion chunks or LangChain BaseMessage objects.
"""
peeking_iter, chunks = tee(chunks)
first_chunk = next(peeking_iter)
if _HAS_LANGCHAIN_BASE_MESSAGE and isinstance(first_chunk, BaseMessage):
yield from _langchain_message_stream_to_responses_stream(chunks, aggregator)
else:
yield from _cc_stream_to_responses_stream(chunks, aggregator)
if _HAS_LANGCHAIN_BASE_MESSAGE:
def _stringify_content(content: Any) -> str:
"""Ensure content is a string, JSON-serializing if necessary."""
if isinstance(content, str):
return content
try:
return json.dumps(content)
except (TypeError, ValueError):
return str(content)
def _langchain_message_stream_to_responses_stream(
chunks: Iterator[BaseMessage],
aggregator: list[dict[str, Any]] | None = None,
) -> Generator[ResponsesAgentStreamEvent, None, None]:
"""Convert from a stream of LangChain BaseMessage objects to a stream of
ResponsesAgentStreamEvent objects. Skips user or human messages.
"""
for chunk in chunks:
message = chunk.model_dump()
role = message["type"]
if role == "ai":
if message.get("content"):
text_output_item = create_text_output_item(
text=message["content"],
id=message.get("id") or str(uuid4()),
)
if aggregator is not None:
aggregator.append(text_output_item)
yield ResponsesAgentStreamEvent(
type="response.output_item.done", item=text_output_item
)
if tool_calls := message.get("tool_calls"):
for tool_call in tool_calls:
function_call_item = create_function_call_item(
id=tool_call.get("id") or message.get("id") or str(uuid4()),
call_id=tool_call["id"],
name=tool_call["name"],
arguments=json.dumps(tool_call["args"]),
)
if aggregator is not None:
aggregator.append(function_call_item)
yield ResponsesAgentStreamEvent(
type="response.output_item.done", item=function_call_item
)
elif role == "tool":
function_call_output_item = create_function_call_output_item(
call_id=message["tool_call_id"],
output=_stringify_content(message["content"]),
)
if aggregator is not None:
aggregator.append(function_call_output_item)
yield ResponsesAgentStreamEvent(
type="response.output_item.done", item=function_call_output_item
)
elif role == "user" or "human":
continue
def _cc_stream_to_responses_stream(
chunks: Iterator[dict[str, Any]],
aggregator: list[dict[str, Any]] | None = None,
) -> Generator[ResponsesAgentStreamEvent, None, None]:
"""
Convert from stream of ChatCompletion chunks to a stream of
ResponsesAgentStreamEvent objects.
"""
llm_content = ""
reasoning_content = ""
tool_calls: dict[int, dict[str, Any]] = {} # index -> tool_call dict
msg_id = None
for chunk in chunks:
if chunk.get("choices") is None or len(chunk["choices"]) == 0:
continue
delta = chunk["choices"][0]["delta"]
msg_id = chunk.get("id", None)
content = delta.get("content", None)
if tc := delta.get("tool_calls"):
for tool_call_delta in tc:
idx = tool_call_delta.get("index", 0)
if idx not in tool_calls:
# First chunk for this tool call contains id and name
tool_calls[idx] = {
"id": tool_call_delta.get("id"),
"function": {
"name": tool_call_delta.get("function", {}).get("name", ""),
"arguments": tool_call_delta.get("function", {}).get("arguments", ""),
},
}
else:
# Subsequent chunks only contain argument fragments
tool_calls[idx]["function"]["arguments"] += tool_call_delta.get(
"function", {}
).get("arguments", "")
elif content is not None:
# logic for content item format
# https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/api-reference#contentitem
if isinstance(content, list):
for item in content:
if isinstance(item, dict):
if item.get("type") == "reasoning":
reasoning_content += item.get("summary", [])[0].get("text", "")
if item.get("type") == "text" and item.get("text"):
llm_content += item["text"]
yield ResponsesAgentStreamEvent(
**create_text_delta(item["text"], item_id=msg_id)
)
elif reasoning_content != "":
# reasoning content is done streaming
reasoning_item = create_reasoning_item(msg_id, reasoning_content)
if aggregator is not None:
aggregator.append(reasoning_item)
yield ResponsesAgentStreamEvent(
type="response.output_item.done",
item=reasoning_item,
)
reasoning_content = ""
if isinstance(content, str):
llm_content += content
yield ResponsesAgentStreamEvent(**create_text_delta(content, item_id=msg_id))
# yield an `output_item.done` `output_text` event that aggregates the stream
# this enables tracing and payload logging
if llm_content:
text_output_item = create_text_output_item(llm_content, msg_id)
if aggregator is not None:
aggregator.append(text_output_item)
yield ResponsesAgentStreamEvent(
type="response.output_item.done",
item=text_output_item,
)
for idx in sorted(tool_calls.keys()):
tool_call = tool_calls[idx]
function_call_output_item = create_function_call_item(
msg_id,
tool_call["id"],
tool_call["function"]["name"],
tool_call["function"]["arguments"],
)
if aggregator is not None:
aggregator.append(function_call_output_item)
yield ResponsesAgentStreamEvent(
type="response.output_item.done",
item=function_call_output_item,
)