mirror of
https://github.com/hwchase17/langchain.git
synced 2025-06-28 17:38:36 +00:00
openai[patch]: refactor handling of Responses API (#31587)
This commit is contained in:
parent
532e6455e9
commit
b9357d456e
232
libs/partners/openai/langchain_openai/chat_models/_compat.py
Normal file
232
libs/partners/openai/langchain_openai/chat_models/_compat.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
This module converts between AIMessage output formats for the Responses API.
|
||||||
|
|
||||||
|
ChatOpenAI v0.3 stores reasoning and tool outputs in AIMessage.additional_kwargs:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
AIMessage(
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": "Hello, world!", "annotations": [{"type": "foo"}]}
|
||||||
|
],
|
||||||
|
additional_kwargs={
|
||||||
|
"reasoning": {
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_123",
|
||||||
|
"summary": [{"type": "summary_text", "text": "Reasoning summary"}],
|
||||||
|
},
|
||||||
|
"tool_outputs": [
|
||||||
|
{"type": "web_search_call", "id": "websearch_123", "status": "completed"}
|
||||||
|
],
|
||||||
|
"refusal": "I cannot assist with that.",
|
||||||
|
},
|
||||||
|
response_metadata={"id": "resp_123"},
|
||||||
|
id="msg_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
To retain information about response item sequencing (and to accommodate multiple
|
||||||
|
reasoning items), ChatOpenAI now stores these items in the content sequence:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
AIMessage(
|
||||||
|
content=[
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [{"type": "summary_text", "text": "Reasoning summary"}],
|
||||||
|
"id": "rs_123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hello, world!",
|
||||||
|
"annotations": [{"type": "foo"}],
|
||||||
|
"id": "msg_123",
|
||||||
|
},
|
||||||
|
{"type": "refusal", "refusal": "I cannot assist with that."},
|
||||||
|
{"type": "web_search_call", "id": "websearch_123", "status": "completed"},
|
||||||
|
],
|
||||||
|
response_metadata={"id": "resp_123"},
|
||||||
|
id="resp_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
There are other, small improvements as well-- e.g., we store message IDs on text
|
||||||
|
content blocks, rather than on the AIMessage.id, which now stores the response ID.
|
||||||
|
|
||||||
|
For backwards compatibility, this module provides functions to convert between the
|
||||||
|
old and new formats. The functions are used internally by ChatOpenAI.
|
||||||
|
""" # noqa: E501
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
|
||||||
|
_FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__"
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_to_v03_ai_message(
|
||||||
|
message: AIMessage, has_reasoning: bool = False
|
||||||
|
) -> AIMessage:
|
||||||
|
"""Mutate an AIMessage to the old-style v0.3 format."""
|
||||||
|
if isinstance(message.content, list):
|
||||||
|
new_content: list[Union[dict, str]] = []
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
if block.get("type") == "reasoning" or "summary" in block:
|
||||||
|
# Store a reasoning item in additional_kwargs (overwriting as in
|
||||||
|
# v0.3)
|
||||||
|
_ = block.pop("index", None)
|
||||||
|
if has_reasoning:
|
||||||
|
_ = block.pop("id", None)
|
||||||
|
_ = block.pop("type", None)
|
||||||
|
message.additional_kwargs["reasoning"] = block
|
||||||
|
elif block.get("type") in (
|
||||||
|
"web_search_call",
|
||||||
|
"file_search_call",
|
||||||
|
"computer_call",
|
||||||
|
"code_interpreter_call",
|
||||||
|
"mcp_call",
|
||||||
|
"mcp_list_tools",
|
||||||
|
"mcp_approval_request",
|
||||||
|
"image_generation_call",
|
||||||
|
):
|
||||||
|
# Store built-in tool calls in additional_kwargs
|
||||||
|
if "tool_outputs" not in message.additional_kwargs:
|
||||||
|
message.additional_kwargs["tool_outputs"] = []
|
||||||
|
message.additional_kwargs["tool_outputs"].append(block)
|
||||||
|
elif block.get("type") == "function_call":
|
||||||
|
# Store function call item IDs in additional_kwargs, otherwise
|
||||||
|
# discard function call items.
|
||||||
|
if _FUNCTION_CALL_IDS_MAP_KEY not in message.additional_kwargs:
|
||||||
|
message.additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY] = {}
|
||||||
|
if (call_id := block.get("call_id")) and (
|
||||||
|
function_call_id := block.get("id")
|
||||||
|
):
|
||||||
|
message.additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY][
|
||||||
|
call_id
|
||||||
|
] = function_call_id
|
||||||
|
elif (block.get("type") == "refusal") and (
|
||||||
|
refusal := block.get("refusal")
|
||||||
|
):
|
||||||
|
# Store a refusal item in additional_kwargs (overwriting as in
|
||||||
|
# v0.3)
|
||||||
|
message.additional_kwargs["refusal"] = refusal
|
||||||
|
elif block.get("type") == "text":
|
||||||
|
# Store a message item ID on AIMessage.id
|
||||||
|
if "id" in block:
|
||||||
|
message.id = block["id"]
|
||||||
|
new_content.append({k: v for k, v in block.items() if k != "id"})
|
||||||
|
elif (
|
||||||
|
set(block.keys()) == {"id", "index"}
|
||||||
|
and isinstance(block["id"], str)
|
||||||
|
and block["id"].startswith("msg_")
|
||||||
|
):
|
||||||
|
# Drop message IDs in streaming case
|
||||||
|
new_content.append({"index": block["index"]})
|
||||||
|
else:
|
||||||
|
new_content.append(block)
|
||||||
|
else:
|
||||||
|
new_content.append(block)
|
||||||
|
message.content = new_content
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage:
|
||||||
|
"""Convert an old-style v0.3 AIMessage into the new content-block format."""
|
||||||
|
# Only update ChatOpenAI v0.3 AIMessages
|
||||||
|
if not (
|
||||||
|
isinstance(message.content, list)
|
||||||
|
and all(isinstance(b, dict) for b in message.content)
|
||||||
|
) or not any(
|
||||||
|
item in message.additional_kwargs
|
||||||
|
for item in ["reasoning", "tool_outputs", "refusal"]
|
||||||
|
):
|
||||||
|
return message
|
||||||
|
|
||||||
|
content_order = [
|
||||||
|
"reasoning",
|
||||||
|
"code_interpreter_call",
|
||||||
|
"mcp_call",
|
||||||
|
"image_generation_call",
|
||||||
|
"text",
|
||||||
|
"refusal",
|
||||||
|
"function_call",
|
||||||
|
"computer_call",
|
||||||
|
"mcp_list_tools",
|
||||||
|
"mcp_approval_request",
|
||||||
|
# N. B. "web_search_call" and "file_search_call" were not passed back in
|
||||||
|
# in v0.3
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build a bucket for every known block type
|
||||||
|
buckets: dict[str, list] = {key: [] for key in content_order}
|
||||||
|
unknown_blocks = []
|
||||||
|
|
||||||
|
# Reasoning
|
||||||
|
if reasoning := message.additional_kwargs.get("reasoning"):
|
||||||
|
buckets["reasoning"].append(reasoning)
|
||||||
|
|
||||||
|
# Refusal
|
||||||
|
if refusal := message.additional_kwargs.get("refusal"):
|
||||||
|
buckets["refusal"].append({"type": "refusal", "refusal": refusal})
|
||||||
|
|
||||||
|
# Text
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
block_copy = block.copy()
|
||||||
|
if isinstance(message.id, str) and message.id.startswith("msg_"):
|
||||||
|
block_copy["id"] = message.id
|
||||||
|
buckets["text"].append(block_copy)
|
||||||
|
else:
|
||||||
|
unknown_blocks.append(block)
|
||||||
|
|
||||||
|
# Function calls
|
||||||
|
function_call_ids = message.additional_kwargs.get(_FUNCTION_CALL_IDS_MAP_KEY)
|
||||||
|
for tool_call in message.tool_calls:
|
||||||
|
function_call = {
|
||||||
|
"type": "function_call",
|
||||||
|
"name": tool_call["name"],
|
||||||
|
"arguments": json.dumps(tool_call["args"]),
|
||||||
|
"call_id": tool_call["id"],
|
||||||
|
}
|
||||||
|
if function_call_ids is not None and (
|
||||||
|
_id := function_call_ids.get(tool_call["id"])
|
||||||
|
):
|
||||||
|
function_call["id"] = _id
|
||||||
|
buckets["function_call"].append(function_call)
|
||||||
|
|
||||||
|
# Tool outputs
|
||||||
|
tool_outputs = message.additional_kwargs.get("tool_outputs", [])
|
||||||
|
for block in tool_outputs:
|
||||||
|
if isinstance(block, dict) and (key := block.get("type")) and key in buckets:
|
||||||
|
buckets[key].append(block)
|
||||||
|
else:
|
||||||
|
unknown_blocks.append(block)
|
||||||
|
|
||||||
|
# Re-assemble the content list in the canonical order
|
||||||
|
new_content = []
|
||||||
|
for key in content_order:
|
||||||
|
new_content.extend(buckets[key])
|
||||||
|
new_content.extend(unknown_blocks)
|
||||||
|
|
||||||
|
new_additional_kwargs = dict(message.additional_kwargs)
|
||||||
|
new_additional_kwargs.pop("reasoning", None)
|
||||||
|
new_additional_kwargs.pop("refusal", None)
|
||||||
|
new_additional_kwargs.pop("tool_outputs", None)
|
||||||
|
|
||||||
|
if "id" in message.response_metadata:
|
||||||
|
new_id = message.response_metadata["id"]
|
||||||
|
else:
|
||||||
|
new_id = message.id
|
||||||
|
|
||||||
|
return message.model_copy(
|
||||||
|
update={
|
||||||
|
"content": new_content,
|
||||||
|
"additional_kwargs": new_additional_kwargs,
|
||||||
|
"id": new_id,
|
||||||
|
},
|
||||||
|
deep=False,
|
||||||
|
)
|
@ -106,6 +106,10 @@ from langchain_openai.chat_models._client_utils import (
|
|||||||
_get_default_async_httpx_client,
|
_get_default_async_httpx_client,
|
||||||
_get_default_httpx_client,
|
_get_default_httpx_client,
|
||||||
)
|
)
|
||||||
|
from langchain_openai.chat_models._compat import (
|
||||||
|
_convert_from_v03_ai_message,
|
||||||
|
_convert_to_v03_ai_message,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from openai.types.responses import Response
|
from openai.types.responses import Response
|
||||||
@ -116,8 +120,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# https://www.python-httpx.org/advanced/ssl/#configuring-client-instances
|
# https://www.python-httpx.org/advanced/ssl/#configuring-client-instances
|
||||||
global_ssl_context = ssl.create_default_context(cafile=certifi.where())
|
global_ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
|
||||||
_FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__"
|
|
||||||
|
|
||||||
WellKnownTools = (
|
WellKnownTools = (
|
||||||
"file_search",
|
"file_search",
|
||||||
"web_search_preview",
|
"web_search_preview",
|
||||||
@ -797,15 +799,27 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
|
|
||||||
with context_manager as response:
|
with context_manager as response:
|
||||||
is_first_chunk = True
|
is_first_chunk = True
|
||||||
|
current_index = -1
|
||||||
|
current_output_index = -1
|
||||||
|
current_sub_index = -1
|
||||||
has_reasoning = False
|
has_reasoning = False
|
||||||
for chunk in response:
|
for chunk in response:
|
||||||
metadata = headers if is_first_chunk else {}
|
metadata = headers if is_first_chunk else {}
|
||||||
if generation_chunk := _convert_responses_chunk_to_generation_chunk(
|
(
|
||||||
|
current_index,
|
||||||
|
current_output_index,
|
||||||
|
current_sub_index,
|
||||||
|
generation_chunk,
|
||||||
|
) = _convert_responses_chunk_to_generation_chunk(
|
||||||
chunk,
|
chunk,
|
||||||
|
current_index,
|
||||||
|
current_output_index,
|
||||||
|
current_sub_index,
|
||||||
schema=original_schema_obj,
|
schema=original_schema_obj,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
has_reasoning=has_reasoning,
|
has_reasoning=has_reasoning,
|
||||||
):
|
)
|
||||||
|
if generation_chunk:
|
||||||
if run_manager:
|
if run_manager:
|
||||||
run_manager.on_llm_new_token(
|
run_manager.on_llm_new_token(
|
||||||
generation_chunk.text, chunk=generation_chunk
|
generation_chunk.text, chunk=generation_chunk
|
||||||
@ -839,15 +853,27 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
|
|
||||||
async with context_manager as response:
|
async with context_manager as response:
|
||||||
is_first_chunk = True
|
is_first_chunk = True
|
||||||
|
current_index = -1
|
||||||
|
current_output_index = -1
|
||||||
|
current_sub_index = -1
|
||||||
has_reasoning = False
|
has_reasoning = False
|
||||||
async for chunk in response:
|
async for chunk in response:
|
||||||
metadata = headers if is_first_chunk else {}
|
metadata = headers if is_first_chunk else {}
|
||||||
if generation_chunk := _convert_responses_chunk_to_generation_chunk(
|
(
|
||||||
|
current_index,
|
||||||
|
current_output_index,
|
||||||
|
current_sub_index,
|
||||||
|
generation_chunk,
|
||||||
|
) = _convert_responses_chunk_to_generation_chunk(
|
||||||
chunk,
|
chunk,
|
||||||
|
current_index,
|
||||||
|
current_output_index,
|
||||||
|
current_sub_index,
|
||||||
schema=original_schema_obj,
|
schema=original_schema_obj,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
has_reasoning=has_reasoning,
|
has_reasoning=has_reasoning,
|
||||||
):
|
)
|
||||||
|
if generation_chunk:
|
||||||
if run_manager:
|
if run_manager:
|
||||||
await run_manager.on_llm_new_token(
|
await run_manager.on_llm_new_token(
|
||||||
generation_chunk.text, chunk=generation_chunk
|
generation_chunk.text, chunk=generation_chunk
|
||||||
@ -3209,27 +3235,26 @@ def _make_computer_call_output_from_message(message: ToolMessage) -> dict:
|
|||||||
return computer_call_output
|
return computer_call_output
|
||||||
|
|
||||||
|
|
||||||
def _pop_summary_index_from_reasoning(reasoning: dict) -> dict:
|
def _pop_index_and_sub_index(block: dict) -> dict:
|
||||||
"""When streaming, langchain-core uses the ``index`` key to aggregate reasoning
|
"""When streaming, langchain-core uses the ``index`` key to aggregate
|
||||||
text blocks. OpenAI API does not support this key, so we need to remove it.
|
text blocks. OpenAI API does not support this key, so we need to remove it.
|
||||||
|
|
||||||
N.B. OpenAI also does not appear to support the ``summary_inex`` key when passed
|
|
||||||
back in.
|
|
||||||
"""
|
"""
|
||||||
new_reasoning = reasoning.copy()
|
new_block = {k: v for k, v in block.items() if k != "index"}
|
||||||
if "summary" in reasoning and isinstance(reasoning["summary"], list):
|
if "summary" in new_block and isinstance(new_block["summary"], list):
|
||||||
new_summary = []
|
new_summary = []
|
||||||
for block in reasoning["summary"]:
|
for sub_block in new_block["summary"]:
|
||||||
new_block = {k: v for k, v in block.items() if k != "index"}
|
new_sub_block = {k: v for k, v in sub_block.items() if k != "index"}
|
||||||
new_summary.append(new_block)
|
new_summary.append(new_sub_block)
|
||||||
new_reasoning["summary"] = new_summary
|
new_block["summary"] = new_summary
|
||||||
return new_reasoning
|
return new_block
|
||||||
|
|
||||||
|
|
||||||
def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
|
def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
|
||||||
"""Construct the input for the OpenAI Responses API."""
|
"""Construct the input for the OpenAI Responses API."""
|
||||||
input_ = []
|
input_ = []
|
||||||
for lc_msg in messages:
|
for lc_msg in messages:
|
||||||
|
if isinstance(lc_msg, AIMessage):
|
||||||
|
lc_msg = _convert_from_v03_ai_message(lc_msg)
|
||||||
msg = _convert_message_to_dict(lc_msg)
|
msg = _convert_message_to_dict(lc_msg)
|
||||||
# "name" parameter unsupported
|
# "name" parameter unsupported
|
||||||
if "name" in msg:
|
if "name" in msg:
|
||||||
@ -3251,97 +3276,85 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
|
|||||||
}
|
}
|
||||||
input_.append(function_call_output)
|
input_.append(function_call_output)
|
||||||
elif msg["role"] == "assistant":
|
elif msg["role"] == "assistant":
|
||||||
# Reasoning items
|
if isinstance(msg.get("content"), list):
|
||||||
reasoning_items = []
|
|
||||||
if reasoning := lc_msg.additional_kwargs.get("reasoning"):
|
|
||||||
reasoning_items.append(_pop_summary_index_from_reasoning(reasoning))
|
|
||||||
input_.extend(reasoning_items)
|
|
||||||
# Function calls
|
|
||||||
function_calls = []
|
|
||||||
if tool_calls := msg.pop("tool_calls", None):
|
|
||||||
# TODO: should you be able to preserve the function call object id on
|
|
||||||
# the langchain tool calls themselves?
|
|
||||||
function_call_ids = lc_msg.additional_kwargs.get(
|
|
||||||
_FUNCTION_CALL_IDS_MAP_KEY
|
|
||||||
)
|
|
||||||
for tool_call in tool_calls:
|
|
||||||
function_call = {
|
|
||||||
"type": "function_call",
|
|
||||||
"name": tool_call["function"]["name"],
|
|
||||||
"arguments": tool_call["function"]["arguments"],
|
|
||||||
"call_id": tool_call["id"],
|
|
||||||
}
|
|
||||||
if function_call_ids is not None and (
|
|
||||||
_id := function_call_ids.get(tool_call["id"])
|
|
||||||
):
|
|
||||||
function_call["id"] = _id
|
|
||||||
function_calls.append(function_call)
|
|
||||||
# Built-in tool calls
|
|
||||||
computer_calls = []
|
|
||||||
code_interpreter_calls = []
|
|
||||||
mcp_calls = []
|
|
||||||
image_generation_calls = []
|
|
||||||
tool_outputs = lc_msg.additional_kwargs.get("tool_outputs", [])
|
|
||||||
for tool_output in tool_outputs:
|
|
||||||
if tool_output.get("type") == "computer_call":
|
|
||||||
computer_calls.append(tool_output)
|
|
||||||
elif tool_output.get("type") == "code_interpreter_call":
|
|
||||||
code_interpreter_calls.append(tool_output)
|
|
||||||
elif tool_output.get("type") == "mcp_call":
|
|
||||||
mcp_calls.append(tool_output)
|
|
||||||
elif tool_output.get("type") == "image_generation_call":
|
|
||||||
image_generation_calls.append(tool_output)
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
input_.extend(code_interpreter_calls)
|
|
||||||
input_.extend(mcp_calls)
|
|
||||||
|
|
||||||
# A previous image generation call can be referenced by ID
|
|
||||||
|
|
||||||
input_.extend(
|
|
||||||
[
|
|
||||||
{"type": "image_generation_call", "id": image_generation_call["id"]}
|
|
||||||
for image_generation_call in image_generation_calls
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
msg["content"] = msg.get("content") or []
|
|
||||||
if lc_msg.additional_kwargs.get("refusal"):
|
|
||||||
if isinstance(msg["content"], str):
|
|
||||||
msg["content"] = [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": msg["content"],
|
|
||||||
"annotations": [],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
msg["content"] = msg["content"] + [
|
|
||||||
{"type": "refusal", "refusal": lc_msg.additional_kwargs["refusal"]}
|
|
||||||
]
|
|
||||||
if isinstance(msg["content"], list):
|
|
||||||
new_blocks = []
|
|
||||||
for block in msg["content"]:
|
for block in msg["content"]:
|
||||||
# chat api: {"type": "text", "text": "..."}
|
if isinstance(block, dict) and (block_type := block.get("type")):
|
||||||
# responses api: {"type": "output_text", "text": "...", "annotations": [...]} # noqa: E501
|
# Aggregate content blocks for a single message
|
||||||
if block["type"] == "text":
|
if block_type in ("text", "output_text", "refusal"):
|
||||||
new_blocks.append(
|
msg_id = block.get("id")
|
||||||
{
|
if block_type in ("text", "output_text"):
|
||||||
"type": "output_text",
|
new_block = {
|
||||||
"text": block["text"],
|
"type": "output_text",
|
||||||
"annotations": block.get("annotations") or [],
|
"text": block["text"],
|
||||||
}
|
"annotations": block.get("annotations") or [],
|
||||||
)
|
}
|
||||||
elif block["type"] in ("output_text", "refusal"):
|
elif block_type == "refusal":
|
||||||
new_blocks.append(block)
|
new_block = {
|
||||||
else:
|
"type": "refusal",
|
||||||
pass
|
"refusal": block["refusal"],
|
||||||
msg["content"] = new_blocks
|
}
|
||||||
if msg["content"]:
|
for item in input_:
|
||||||
if lc_msg.id and lc_msg.id.startswith("msg_"):
|
if (item_id := item.get("id")) and item_id == msg_id:
|
||||||
msg["id"] = lc_msg.id
|
# If existing block with this ID, append to it
|
||||||
input_.append(msg)
|
if "content" not in item:
|
||||||
input_.extend(function_calls)
|
item["content"] = []
|
||||||
input_.extend(computer_calls)
|
item["content"].append(new_block)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If no block with this ID, create a new one
|
||||||
|
input_.append(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"content": [new_block],
|
||||||
|
"role": "assistant",
|
||||||
|
"id": msg_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif block_type in (
|
||||||
|
"reasoning",
|
||||||
|
"web_search_call",
|
||||||
|
"file_search_call",
|
||||||
|
"function_call",
|
||||||
|
"computer_call",
|
||||||
|
"code_interpreter_call",
|
||||||
|
"mcp_call",
|
||||||
|
"mcp_list_tools",
|
||||||
|
"mcp_approval_request",
|
||||||
|
):
|
||||||
|
input_.append(_pop_index_and_sub_index(block))
|
||||||
|
elif block_type == "image_generation_call":
|
||||||
|
# A previous image generation call can be referenced by ID
|
||||||
|
input_.append(
|
||||||
|
{"type": "image_generation_call", "id": block["id"]}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
elif isinstance(msg.get("content"), str):
|
||||||
|
input_.append(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": msg["content"]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add function calls from tool calls if not already present
|
||||||
|
if tool_calls := msg.pop("tool_calls", None):
|
||||||
|
content_call_ids = {
|
||||||
|
block["call_id"]
|
||||||
|
for block in input_
|
||||||
|
if block.get("type") == "function_call" and "call_id" in block
|
||||||
|
}
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
if tool_call["id"] not in content_call_ids:
|
||||||
|
function_call = {
|
||||||
|
"type": "function_call",
|
||||||
|
"name": tool_call["function"]["name"],
|
||||||
|
"arguments": tool_call["function"]["arguments"],
|
||||||
|
"call_id": tool_call["id"],
|
||||||
|
}
|
||||||
|
input_.append(function_call)
|
||||||
|
|
||||||
elif msg["role"] in ("user", "system", "developer"):
|
elif msg["role"] in ("user", "system", "developer"):
|
||||||
if isinstance(msg["content"], list):
|
if isinstance(msg["content"], list):
|
||||||
new_blocks = []
|
new_blocks = []
|
||||||
@ -3396,6 +3409,8 @@ def _construct_lc_result_from_responses_api(
|
|||||||
if k
|
if k
|
||||||
in (
|
in (
|
||||||
"created_at",
|
"created_at",
|
||||||
|
# backwards compatibility: keep response ID in response_metadata as well as
|
||||||
|
# top-level-id
|
||||||
"id",
|
"id",
|
||||||
"incomplete_details",
|
"incomplete_details",
|
||||||
"metadata",
|
"metadata",
|
||||||
@ -3419,7 +3434,6 @@ def _construct_lc_result_from_responses_api(
|
|||||||
tool_calls = []
|
tool_calls = []
|
||||||
invalid_tool_calls = []
|
invalid_tool_calls = []
|
||||||
additional_kwargs: dict = {}
|
additional_kwargs: dict = {}
|
||||||
msg_id = None
|
|
||||||
for output in response.output:
|
for output in response.output:
|
||||||
if output.type == "message":
|
if output.type == "message":
|
||||||
for content in output.content:
|
for content in output.content:
|
||||||
@ -3431,14 +3445,17 @@ def _construct_lc_result_from_responses_api(
|
|||||||
annotation.model_dump()
|
annotation.model_dump()
|
||||||
for annotation in content.annotations
|
for annotation in content.annotations
|
||||||
],
|
],
|
||||||
|
"id": output.id,
|
||||||
}
|
}
|
||||||
content_blocks.append(block)
|
content_blocks.append(block)
|
||||||
if hasattr(content, "parsed"):
|
if hasattr(content, "parsed"):
|
||||||
additional_kwargs["parsed"] = content.parsed
|
additional_kwargs["parsed"] = content.parsed
|
||||||
if content.type == "refusal":
|
if content.type == "refusal":
|
||||||
additional_kwargs["refusal"] = content.refusal
|
content_blocks.append(
|
||||||
msg_id = output.id
|
{"type": "refusal", "refusal": content.refusal, "id": output.id}
|
||||||
|
)
|
||||||
elif output.type == "function_call":
|
elif output.type == "function_call":
|
||||||
|
content_blocks.append(output.model_dump(exclude_none=True, mode="json"))
|
||||||
try:
|
try:
|
||||||
args = json.loads(output.arguments, strict=False)
|
args = json.loads(output.arguments, strict=False)
|
||||||
error = None
|
error = None
|
||||||
@ -3462,19 +3479,19 @@ def _construct_lc_result_from_responses_api(
|
|||||||
"error": error,
|
"error": error,
|
||||||
}
|
}
|
||||||
invalid_tool_calls.append(tool_call)
|
invalid_tool_calls.append(tool_call)
|
||||||
if _FUNCTION_CALL_IDS_MAP_KEY not in additional_kwargs:
|
elif output.type in (
|
||||||
additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY] = {}
|
"reasoning",
|
||||||
additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY][output.call_id] = output.id
|
"web_search_call",
|
||||||
elif output.type == "reasoning":
|
"file_search_call",
|
||||||
additional_kwargs["reasoning"] = output.model_dump(
|
"computer_call",
|
||||||
exclude_none=True, mode="json"
|
"code_interpreter_call",
|
||||||
)
|
"mcp_call",
|
||||||
else:
|
"mcp_list_tools",
|
||||||
tool_output = output.model_dump(exclude_none=True, mode="json")
|
"mcp_approval_request",
|
||||||
if "tool_outputs" in additional_kwargs:
|
"image_generation_call",
|
||||||
additional_kwargs["tool_outputs"].append(tool_output)
|
):
|
||||||
else:
|
content_blocks.append(output.model_dump(exclude_none=True, mode="json"))
|
||||||
additional_kwargs["tool_outputs"] = [tool_output]
|
|
||||||
# Workaround for parsing structured output in the streaming case.
|
# Workaround for parsing structured output in the streaming case.
|
||||||
# from openai import OpenAI
|
# from openai import OpenAI
|
||||||
# from pydantic import BaseModel
|
# from pydantic import BaseModel
|
||||||
@ -3510,22 +3527,70 @@ def _construct_lc_result_from_responses_api(
|
|||||||
pass
|
pass
|
||||||
message = AIMessage(
|
message = AIMessage(
|
||||||
content=content_blocks,
|
content=content_blocks,
|
||||||
id=msg_id,
|
id=response.id,
|
||||||
usage_metadata=usage_metadata,
|
usage_metadata=usage_metadata,
|
||||||
response_metadata=response_metadata,
|
response_metadata=response_metadata,
|
||||||
additional_kwargs=additional_kwargs,
|
additional_kwargs=additional_kwargs,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
invalid_tool_calls=invalid_tool_calls,
|
invalid_tool_calls=invalid_tool_calls,
|
||||||
)
|
)
|
||||||
|
message = _convert_to_v03_ai_message(message)
|
||||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||||
|
|
||||||
|
|
||||||
def _convert_responses_chunk_to_generation_chunk(
|
def _convert_responses_chunk_to_generation_chunk(
|
||||||
chunk: Any,
|
chunk: Any,
|
||||||
|
current_index: int, # index in content
|
||||||
|
current_output_index: int, # index in Response output
|
||||||
|
current_sub_index: int, # index of content block in output item
|
||||||
schema: Optional[type[_BM]] = None,
|
schema: Optional[type[_BM]] = None,
|
||||||
metadata: Optional[dict] = None,
|
metadata: Optional[dict] = None,
|
||||||
has_reasoning: bool = False,
|
has_reasoning: bool = False,
|
||||||
) -> Optional[ChatGenerationChunk]:
|
) -> tuple[int, int, int, Optional[ChatGenerationChunk]]:
|
||||||
|
def _advance(output_idx: int, sub_idx: Optional[int] = None) -> None:
|
||||||
|
"""Advance indexes tracked during streaming.
|
||||||
|
|
||||||
|
Example: we stream a response item of the form:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "message", # output_index 0
|
||||||
|
"role": "assistant",
|
||||||
|
"id": "msg_123",
|
||||||
|
"content": [
|
||||||
|
{"type": "output_text", "text": "foo"}, # sub_index 0
|
||||||
|
{"type": "output_text", "text": "bar"}, # sub_index 1
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
This is a single item with a shared ``output_index`` and two sub-indexes, one
|
||||||
|
for each content block.
|
||||||
|
|
||||||
|
This will be processed into an AIMessage with two text blocks:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
AIMessage(
|
||||||
|
[
|
||||||
|
{"type": "text", "text": "foo", "id": "msg_123"}, # index 0
|
||||||
|
{"type": "text", "text": "bar", "id": "msg_123"}, # index 1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
This function just identifies updates in output or sub-indexes and increments
|
||||||
|
the current index accordingly.
|
||||||
|
"""
|
||||||
|
nonlocal current_index, current_output_index, current_sub_index
|
||||||
|
if sub_idx is None:
|
||||||
|
if current_output_index != output_idx:
|
||||||
|
current_index += 1
|
||||||
|
else:
|
||||||
|
if (current_output_index != output_idx) or (current_sub_index != sub_idx):
|
||||||
|
current_index += 1
|
||||||
|
current_sub_index = sub_idx
|
||||||
|
current_output_index = output_idx
|
||||||
|
|
||||||
content = []
|
content = []
|
||||||
tool_call_chunks: list = []
|
tool_call_chunks: list = []
|
||||||
additional_kwargs: dict = {}
|
additional_kwargs: dict = {}
|
||||||
@ -3536,16 +3601,18 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
usage_metadata = None
|
usage_metadata = None
|
||||||
id = None
|
id = None
|
||||||
if chunk.type == "response.output_text.delta":
|
if chunk.type == "response.output_text.delta":
|
||||||
content.append(
|
_advance(chunk.output_index, chunk.content_index)
|
||||||
{"type": "text", "text": chunk.delta, "index": chunk.content_index}
|
content.append({"type": "text", "text": chunk.delta, "index": current_index})
|
||||||
)
|
|
||||||
elif chunk.type == "response.output_text.annotation.added":
|
elif chunk.type == "response.output_text.annotation.added":
|
||||||
|
_advance(chunk.output_index, chunk.content_index)
|
||||||
if isinstance(chunk.annotation, dict):
|
if isinstance(chunk.annotation, dict):
|
||||||
# Appears to be a breaking change in openai==1.82.0
|
# Appears to be a breaking change in openai==1.82.0
|
||||||
annotation = chunk.annotation
|
annotation = chunk.annotation
|
||||||
else:
|
else:
|
||||||
annotation = chunk.annotation.model_dump(exclude_none=True, mode="json")
|
annotation = chunk.annotation.model_dump(exclude_none=True, mode="json")
|
||||||
content.append({"annotations": [annotation], "index": chunk.content_index})
|
content.append({"annotations": [annotation], "index": current_index})
|
||||||
|
elif chunk.type == "response.output_text.done":
|
||||||
|
content.append({"id": chunk.item_id, "index": current_index})
|
||||||
elif chunk.type == "response.created":
|
elif chunk.type == "response.created":
|
||||||
response_metadata["id"] = chunk.response.id
|
response_metadata["id"] = chunk.response.id
|
||||||
elif chunk.type == "response.completed":
|
elif chunk.type == "response.completed":
|
||||||
@ -3569,18 +3636,26 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
chunk.type == "response.output_item.added"
|
chunk.type == "response.output_item.added"
|
||||||
and chunk.item.type == "function_call"
|
and chunk.item.type == "function_call"
|
||||||
):
|
):
|
||||||
|
_advance(chunk.output_index)
|
||||||
tool_call_chunks.append(
|
tool_call_chunks.append(
|
||||||
{
|
{
|
||||||
"type": "tool_call_chunk",
|
"type": "tool_call_chunk",
|
||||||
"name": chunk.item.name,
|
"name": chunk.item.name,
|
||||||
"args": chunk.item.arguments,
|
"args": chunk.item.arguments,
|
||||||
"id": chunk.item.call_id,
|
"id": chunk.item.call_id,
|
||||||
"index": chunk.output_index,
|
"index": current_index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"name": chunk.item.name,
|
||||||
|
"arguments": chunk.item.arguments,
|
||||||
|
"call_id": chunk.item.call_id,
|
||||||
|
"id": chunk.item.id,
|
||||||
|
"index": current_index,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY] = {
|
|
||||||
chunk.item.call_id: chunk.item.id
|
|
||||||
}
|
|
||||||
elif chunk.type == "response.output_item.done" and chunk.item.type in (
|
elif chunk.type == "response.output_item.done" and chunk.item.type in (
|
||||||
"web_search_call",
|
"web_search_call",
|
||||||
"file_search_call",
|
"file_search_call",
|
||||||
@ -3591,55 +3666,70 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
"mcp_approval_request",
|
"mcp_approval_request",
|
||||||
"image_generation_call",
|
"image_generation_call",
|
||||||
):
|
):
|
||||||
additional_kwargs["tool_outputs"] = [
|
_advance(chunk.output_index)
|
||||||
chunk.item.model_dump(exclude_none=True, mode="json")
|
tool_output = chunk.item.model_dump(exclude_none=True, mode="json")
|
||||||
]
|
tool_output["index"] = current_index
|
||||||
|
content.append(tool_output)
|
||||||
elif chunk.type == "response.function_call_arguments.delta":
|
elif chunk.type == "response.function_call_arguments.delta":
|
||||||
|
_advance(chunk.output_index)
|
||||||
tool_call_chunks.append(
|
tool_call_chunks.append(
|
||||||
{
|
{"type": "tool_call_chunk", "args": chunk.delta, "index": current_index}
|
||||||
"type": "tool_call_chunk",
|
)
|
||||||
"args": chunk.delta,
|
content.append(
|
||||||
"index": chunk.output_index,
|
{"type": "function_call", "arguments": chunk.delta, "index": current_index}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
elif chunk.type == "response.refusal.done":
|
elif chunk.type == "response.refusal.done":
|
||||||
additional_kwargs["refusal"] = chunk.refusal
|
content.append({"type": "refusal", "refusal": chunk.refusal})
|
||||||
elif chunk.type == "response.output_item.added" and chunk.item.type == "reasoning":
|
elif chunk.type == "response.output_item.added" and chunk.item.type == "reasoning":
|
||||||
if not has_reasoning:
|
_advance(chunk.output_index)
|
||||||
# Hack until breaking release: store first reasoning item ID.
|
reasoning = chunk.item.model_dump(exclude_none=True, mode="json")
|
||||||
additional_kwargs["reasoning"] = chunk.item.model_dump(
|
reasoning["index"] = current_index
|
||||||
exclude_none=True, mode="json"
|
content.append(reasoning)
|
||||||
)
|
|
||||||
elif chunk.type == "response.reasoning_summary_part.added":
|
elif chunk.type == "response.reasoning_summary_part.added":
|
||||||
additional_kwargs["reasoning"] = {
|
_advance(chunk.output_index)
|
||||||
# langchain-core uses the `index` key to aggregate text blocks.
|
content.append(
|
||||||
"summary": [
|
{
|
||||||
{"index": chunk.summary_index, "type": "summary_text", "text": ""}
|
# langchain-core uses the `index` key to aggregate text blocks.
|
||||||
]
|
"summary": [
|
||||||
}
|
{"index": chunk.summary_index, "type": "summary_text", "text": ""}
|
||||||
|
],
|
||||||
|
"index": current_index,
|
||||||
|
}
|
||||||
|
)
|
||||||
elif chunk.type == "response.image_generation_call.partial_image":
|
elif chunk.type == "response.image_generation_call.partial_image":
|
||||||
# Partial images are not supported yet.
|
# Partial images are not supported yet.
|
||||||
pass
|
pass
|
||||||
elif chunk.type == "response.reasoning_summary_text.delta":
|
elif chunk.type == "response.reasoning_summary_text.delta":
|
||||||
additional_kwargs["reasoning"] = {
|
_advance(chunk.output_index)
|
||||||
"summary": [
|
content.append(
|
||||||
{
|
{
|
||||||
"index": chunk.summary_index,
|
"summary": [
|
||||||
"type": "summary_text",
|
{
|
||||||
"text": chunk.delta,
|
"index": chunk.summary_index,
|
||||||
}
|
"type": "summary_text",
|
||||||
]
|
"text": chunk.delta,
|
||||||
}
|
}
|
||||||
else:
|
],
|
||||||
return None
|
"index": current_index,
|
||||||
|
}
|
||||||
return ChatGenerationChunk(
|
|
||||||
message=AIMessageChunk(
|
|
||||||
content=content, # type: ignore[arg-type]
|
|
||||||
tool_call_chunks=tool_call_chunks,
|
|
||||||
usage_metadata=usage_metadata,
|
|
||||||
response_metadata=response_metadata,
|
|
||||||
additional_kwargs=additional_kwargs,
|
|
||||||
id=id,
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
return current_index, current_output_index, current_sub_index, None
|
||||||
|
|
||||||
|
message = AIMessageChunk(
|
||||||
|
content=content, # type: ignore[arg-type]
|
||||||
|
tool_call_chunks=tool_call_chunks,
|
||||||
|
usage_metadata=usage_metadata,
|
||||||
|
response_metadata=response_metadata,
|
||||||
|
additional_kwargs=additional_kwargs,
|
||||||
|
id=id,
|
||||||
|
)
|
||||||
|
message = cast(
|
||||||
|
AIMessageChunk, _convert_to_v03_ai_message(message, has_reasoning=has_reasoning)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
current_index,
|
||||||
|
current_output_index,
|
||||||
|
current_sub_index,
|
||||||
|
ChatGenerationChunk(message=message),
|
||||||
)
|
)
|
||||||
|
@ -7,8 +7,8 @@ authors = []
|
|||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"langchain-core<1.0.0,>=0.3.65",
|
"langchain-core<1.0.0,>=0.3.64",
|
||||||
"openai<2.0.0,>=1.68.2",
|
"openai<2.0.0,>=1.86.0",
|
||||||
"tiktoken<1,>=0.7",
|
"tiktoken<1,>=0.7",
|
||||||
]
|
]
|
||||||
name = "langchain-openai"
|
name = "langchain-openai"
|
||||||
|
Binary file not shown.
Binary file not shown.
BIN
libs/partners/openai/tests/cassettes/test_web_search.yaml.gz
Normal file
BIN
libs/partners/openai/tests/cassettes/test_web_search.yaml.gz
Normal file
Binary file not shown.
@ -56,7 +56,7 @@ def _check_response(response: Optional[BaseMessage]) -> None:
|
|||||||
assert tool_output["type"]
|
assert tool_output["type"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.flaky(retries=3, delay=1)
|
@pytest.mark.vcr
|
||||||
def test_web_search() -> None:
|
def test_web_search() -> None:
|
||||||
llm = ChatOpenAI(model=MODEL_NAME)
|
llm = ChatOpenAI(model=MODEL_NAME)
|
||||||
first_response = llm.invoke(
|
first_response = llm.invoke(
|
||||||
@ -442,6 +442,7 @@ def test_mcp_builtin() -> None:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
response = llm_with_tools.invoke([input_message])
|
response = llm_with_tools.invoke([input_message])
|
||||||
|
assert all(isinstance(block, dict) for block in response.content)
|
||||||
|
|
||||||
approval_message = HumanMessage(
|
approval_message = HumanMessage(
|
||||||
[
|
[
|
||||||
@ -457,10 +458,53 @@ def test_mcp_builtin() -> None:
|
|||||||
_ = llm_with_tools.invoke(
|
_ = llm_with_tools.invoke(
|
||||||
[approval_message], previous_response_id=response.response_metadata["id"]
|
[approval_message], previous_response_id=response.response_metadata["id"]
|
||||||
)
|
)
|
||||||
# Zero-data retention (e.g., as below) requires change in output format.
|
|
||||||
# _ = llm_with_tools.invoke(
|
|
||||||
# [input_message, response, approval_message]
|
@pytest.mark.skip
|
||||||
# )
|
def test_mcp_builtin_zdr() -> None:
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model="o4-mini",
|
||||||
|
use_responses_api=True,
|
||||||
|
model_kwargs={"store": False, "include": ["reasoning.encrypted_content"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
llm_with_tools = llm.bind_tools(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "mcp",
|
||||||
|
"server_label": "deepwiki",
|
||||||
|
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||||
|
"require_approval": {"always": {"tool_names": ["read_wiki_structure"]}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
input_message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"What transport protocols does the 2025-03-26 version of the MCP spec "
|
||||||
|
"support?"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
full: Optional[BaseMessageChunk] = None
|
||||||
|
for chunk in llm_with_tools.stream([input_message]):
|
||||||
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
|
full = chunk if full is None else full + chunk
|
||||||
|
|
||||||
|
assert isinstance(full, AIMessageChunk)
|
||||||
|
assert all(isinstance(block, dict) for block in full.content)
|
||||||
|
|
||||||
|
approval_message = HumanMessage(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "mcp_approval_response",
|
||||||
|
"approve": True,
|
||||||
|
"approval_request_id": block["id"], # type: ignore[index]
|
||||||
|
}
|
||||||
|
for block in full.content
|
||||||
|
if block["type"] == "mcp_approval_request" # type: ignore[index]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_ = llm_with_tools.invoke([input_message, full, approval_message])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
@ -494,6 +538,7 @@ def test_image_generation_streaming() -> None:
|
|||||||
|
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
"id",
|
"id",
|
||||||
|
"index",
|
||||||
"background",
|
"background",
|
||||||
"output_format",
|
"output_format",
|
||||||
"quality",
|
"quality",
|
||||||
|
@ -23,7 +23,7 @@ from langchain_core.outputs import ChatGeneration, ChatResult
|
|||||||
from langchain_core.runnables import RunnableLambda
|
from langchain_core.runnables import RunnableLambda
|
||||||
from langchain_core.tracers.base import BaseTracer
|
from langchain_core.tracers.base import BaseTracer
|
||||||
from langchain_core.tracers.schemas import Run
|
from langchain_core.tracers.schemas import Run
|
||||||
from openai.types.responses import ResponseOutputMessage
|
from openai.types.responses import ResponseOutputMessage, ResponseReasoningItem
|
||||||
from openai.types.responses.response import IncompleteDetails, Response, ResponseUsage
|
from openai.types.responses.response import IncompleteDetails, Response, ResponseUsage
|
||||||
from openai.types.responses.response_error import ResponseError
|
from openai.types.responses.response_error import ResponseError
|
||||||
from openai.types.responses.response_file_search_tool_call import (
|
from openai.types.responses.response_file_search_tool_call import (
|
||||||
@ -36,6 +36,7 @@ from openai.types.responses.response_function_web_search import (
|
|||||||
)
|
)
|
||||||
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
|
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
|
||||||
from openai.types.responses.response_output_text import ResponseOutputText
|
from openai.types.responses.response_output_text import ResponseOutputText
|
||||||
|
from openai.types.responses.response_reasoning_item import Summary
|
||||||
from openai.types.responses.response_usage import (
|
from openai.types.responses.response_usage import (
|
||||||
InputTokensDetails,
|
InputTokensDetails,
|
||||||
OutputTokensDetails,
|
OutputTokensDetails,
|
||||||
@ -44,8 +45,12 @@ from pydantic import BaseModel, Field
|
|||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_openai.chat_models.base import (
|
from langchain_openai.chat_models._compat import (
|
||||||
_FUNCTION_CALL_IDS_MAP_KEY,
|
_FUNCTION_CALL_IDS_MAP_KEY,
|
||||||
|
_convert_from_v03_ai_message,
|
||||||
|
_convert_to_v03_ai_message,
|
||||||
|
)
|
||||||
|
from langchain_openai.chat_models.base import (
|
||||||
_construct_lc_result_from_responses_api,
|
_construct_lc_result_from_responses_api,
|
||||||
_construct_responses_api_input,
|
_construct_responses_api_input,
|
||||||
_convert_dict_to_message,
|
_convert_dict_to_message,
|
||||||
@ -1209,8 +1214,62 @@ def test__construct_lc_result_from_responses_api_multiple_text_blocks() -> None:
|
|||||||
result = _construct_lc_result_from_responses_api(response)
|
result = _construct_lc_result_from_responses_api(response)
|
||||||
|
|
||||||
assert len(result.generations[0].message.content) == 2
|
assert len(result.generations[0].message.content) == 2
|
||||||
assert result.generations[0].message.content[0]["text"] == "First part" # type: ignore
|
assert result.generations[0].message.content == [
|
||||||
assert result.generations[0].message.content[1]["text"] == "Second part" # type: ignore
|
{"type": "text", "text": "First part", "annotations": []},
|
||||||
|
{"type": "text", "text": "Second part", "annotations": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test__construct_lc_result_from_responses_api_multiple_messages() -> None:
|
||||||
|
"""Test a response with multiple text blocks."""
|
||||||
|
response = Response(
|
||||||
|
id="resp_123",
|
||||||
|
created_at=1234567890,
|
||||||
|
model="gpt-4o",
|
||||||
|
object="response",
|
||||||
|
parallel_tool_calls=True,
|
||||||
|
tools=[],
|
||||||
|
tool_choice="auto",
|
||||||
|
output=[
|
||||||
|
ResponseOutputMessage(
|
||||||
|
type="message",
|
||||||
|
id="msg_123",
|
||||||
|
content=[
|
||||||
|
ResponseOutputText(type="output_text", text="foo", annotations=[])
|
||||||
|
],
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
),
|
||||||
|
ResponseReasoningItem(
|
||||||
|
type="reasoning",
|
||||||
|
id="rs_123",
|
||||||
|
summary=[Summary(type="summary_text", text="reasoning foo")],
|
||||||
|
),
|
||||||
|
ResponseOutputMessage(
|
||||||
|
type="message",
|
||||||
|
id="msg_234",
|
||||||
|
content=[
|
||||||
|
ResponseOutputText(type="output_text", text="bar", annotations=[])
|
||||||
|
],
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _construct_lc_result_from_responses_api(response)
|
||||||
|
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{"type": "text", "text": "foo", "annotations": []},
|
||||||
|
{"type": "text", "text": "bar", "annotations": []},
|
||||||
|
]
|
||||||
|
assert result.generations[0].message.additional_kwargs == {
|
||||||
|
"reasoning": {
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [{"type": "summary_text", "text": "reasoning foo"}],
|
||||||
|
"id": "rs_123",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_refusal_response() -> None:
|
def test__construct_lc_result_from_responses_api_refusal_response() -> None:
|
||||||
@ -1240,10 +1299,8 @@ def test__construct_lc_result_from_responses_api_refusal_response() -> None:
|
|||||||
|
|
||||||
result = _construct_lc_result_from_responses_api(response)
|
result = _construct_lc_result_from_responses_api(response)
|
||||||
|
|
||||||
assert result.generations[0].message.content == []
|
assert result.generations[0].message.additional_kwargs["refusal"] == (
|
||||||
assert (
|
"I cannot assist with that request."
|
||||||
result.generations[0].message.additional_kwargs["refusal"]
|
|
||||||
== "I cannot assist with that request."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1620,6 +1677,40 @@ def test__construct_responses_api_input_human_message_with_text_blocks_conversio
|
|||||||
assert result[0]["content"][0]["text"] == "What's in this image?"
|
assert result[0]["content"][0]["text"] == "What's in this image?"
|
||||||
|
|
||||||
|
|
||||||
|
def test__construct_responses_api_input_multiple_message_components() -> None:
|
||||||
|
"""Test that human messages with text blocks are properly converted."""
|
||||||
|
messages: list = [
|
||||||
|
AIMessage(
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": "foo", "id": "msg_123"},
|
||||||
|
{"type": "text", "text": "bar", "id": "msg_123"},
|
||||||
|
{"type": "refusal", "refusal": "I refuse.", "id": "msg_123"},
|
||||||
|
{"type": "text", "text": "baz", "id": "msg_234"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
result = _construct_responses_api_input(messages)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "output_text", "text": "foo", "annotations": []},
|
||||||
|
{"type": "output_text", "text": "bar", "annotations": []},
|
||||||
|
{"type": "refusal", "refusal": "I refuse."},
|
||||||
|
],
|
||||||
|
"id": "msg_123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": "baz", "annotations": []}],
|
||||||
|
"id": "msg_234",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_responses_api_input_human_message_with_image_url_conversion() -> (
|
def test__construct_responses_api_input_human_message_with_image_url_conversion() -> (
|
||||||
None
|
None
|
||||||
):
|
):
|
||||||
@ -1666,13 +1757,17 @@ def test__construct_responses_api_input_ai_message_with_tool_calls() -> None:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create a mapping from tool call IDs to function call IDs
|
|
||||||
function_call_ids = {"call_123": "func_456"}
|
|
||||||
|
|
||||||
ai_message = AIMessage(
|
ai_message = AIMessage(
|
||||||
content="",
|
content=[
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": '{"location": "San Francisco"}',
|
||||||
|
"call_id": "call_123",
|
||||||
|
"id": "fc_456",
|
||||||
|
}
|
||||||
|
],
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
additional_kwargs={_FUNCTION_CALL_IDS_MAP_KEY: function_call_ids},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = _construct_responses_api_input([ai_message])
|
result = _construct_responses_api_input([ai_message])
|
||||||
@ -1682,7 +1777,19 @@ def test__construct_responses_api_input_ai_message_with_tool_calls() -> None:
|
|||||||
assert result[0]["name"] == "get_weather"
|
assert result[0]["name"] == "get_weather"
|
||||||
assert result[0]["arguments"] == '{"location": "San Francisco"}'
|
assert result[0]["arguments"] == '{"location": "San Francisco"}'
|
||||||
assert result[0]["call_id"] == "call_123"
|
assert result[0]["call_id"] == "call_123"
|
||||||
assert result[0]["id"] == "func_456"
|
assert result[0]["id"] == "fc_456"
|
||||||
|
|
||||||
|
# Message with only tool calls attribute provided
|
||||||
|
ai_message = AIMessage(content="", tool_calls=tool_calls)
|
||||||
|
|
||||||
|
result = _construct_responses_api_input([ai_message])
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["type"] == "function_call"
|
||||||
|
assert result[0]["name"] == "get_weather"
|
||||||
|
assert result[0]["arguments"] == '{"location": "San Francisco"}'
|
||||||
|
assert result[0]["call_id"] == "call_123"
|
||||||
|
assert "id" not in result[0]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_responses_api_input_ai_message_with_tool_calls_and_content() -> (
|
def test__construct_responses_api_input_ai_message_with_tool_calls_and_content() -> (
|
||||||
@ -1698,29 +1805,59 @@ def test__construct_responses_api_input_ai_message_with_tool_calls_and_content()
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create a mapping from tool call IDs to function call IDs
|
# Content blocks
|
||||||
function_call_ids = {"call_123": "func_456"}
|
|
||||||
|
|
||||||
ai_message = AIMessage(
|
ai_message = AIMessage(
|
||||||
content="I'll check the weather for you.",
|
content=[
|
||||||
|
{"type": "text", "text": "I'll check the weather for you."},
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": '{"location": "San Francisco"}',
|
||||||
|
"call_id": "call_123",
|
||||||
|
"id": "fc_456",
|
||||||
|
},
|
||||||
|
],
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
additional_kwargs={_FUNCTION_CALL_IDS_MAP_KEY: function_call_ids},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = _construct_responses_api_input([ai_message])
|
result = _construct_responses_api_input([ai_message])
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
# Check content
|
|
||||||
assert result[0]["role"] == "assistant"
|
assert result[0]["role"] == "assistant"
|
||||||
assert result[0]["content"] == "I'll check the weather for you."
|
assert result[0]["content"] == [
|
||||||
|
{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": "I'll check the weather for you.",
|
||||||
|
"annotations": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
# Check function call
|
|
||||||
assert result[1]["type"] == "function_call"
|
assert result[1]["type"] == "function_call"
|
||||||
assert result[1]["name"] == "get_weather"
|
assert result[1]["name"] == "get_weather"
|
||||||
assert result[1]["arguments"] == '{"location": "San Francisco"}'
|
assert result[1]["arguments"] == '{"location": "San Francisco"}'
|
||||||
assert result[1]["call_id"] == "call_123"
|
assert result[1]["call_id"] == "call_123"
|
||||||
assert result[1]["id"] == "func_456"
|
assert result[1]["id"] == "fc_456"
|
||||||
|
|
||||||
|
# String content
|
||||||
|
ai_message = AIMessage(
|
||||||
|
content="I'll check the weather for you.", tool_calls=tool_calls
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _construct_responses_api_input([ai_message])
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
assert result[0]["role"] == "assistant"
|
||||||
|
assert result[0]["content"] == [
|
||||||
|
{"type": "output_text", "text": "I'll check the weather for you."}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert result[1]["type"] == "function_call"
|
||||||
|
assert result[1]["name"] == "get_weather"
|
||||||
|
assert result[1]["arguments"] == '{"location": "San Francisco"}'
|
||||||
|
assert result[1]["call_id"] == "call_123"
|
||||||
|
assert "id" not in result[1]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_responses_api_input_tool_message_conversion() -> None:
|
def test__construct_responses_api_input_tool_message_conversion() -> None:
|
||||||
@ -1761,7 +1898,6 @@ def test__construct_responses_api_input_multiple_message_types() -> None:
|
|||||||
"args": {"location": "San Francisco"},
|
"args": {"location": "San Francisco"},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
additional_kwargs={_FUNCTION_CALL_IDS_MAP_KEY: {"call_123": "func_456"}},
|
|
||||||
),
|
),
|
||||||
ToolMessage(
|
ToolMessage(
|
||||||
content='{"temperature": 72, "conditions": "sunny"}',
|
content='{"temperature": 72, "conditions": "sunny"}',
|
||||||
@ -1777,7 +1913,7 @@ def test__construct_responses_api_input_multiple_message_types() -> None:
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
messages_copy = [m.copy(deep=True) for m in messages]
|
messages_copy = [m.model_copy(deep=True) for m in messages]
|
||||||
|
|
||||||
result = _construct_responses_api_input(messages)
|
result = _construct_responses_api_input(messages)
|
||||||
|
|
||||||
@ -1805,7 +1941,6 @@ def test__construct_responses_api_input_multiple_message_types() -> None:
|
|||||||
assert result[4]["name"] == "get_weather"
|
assert result[4]["name"] == "get_weather"
|
||||||
assert result[4]["arguments"] == '{"location": "San Francisco"}'
|
assert result[4]["arguments"] == '{"location": "San Francisco"}'
|
||||||
assert result[4]["call_id"] == "call_123"
|
assert result[4]["call_id"] == "call_123"
|
||||||
assert result[4]["id"] == "func_456"
|
|
||||||
|
|
||||||
# Check function call output
|
# Check function call output
|
||||||
assert result[5]["type"] == "function_call_output"
|
assert result[5]["type"] == "function_call_output"
|
||||||
@ -1813,7 +1948,12 @@ def test__construct_responses_api_input_multiple_message_types() -> None:
|
|||||||
assert result[5]["call_id"] == "call_123"
|
assert result[5]["call_id"] == "call_123"
|
||||||
|
|
||||||
assert result[6]["role"] == "assistant"
|
assert result[6]["role"] == "assistant"
|
||||||
assert result[6]["content"] == "The weather in San Francisco is 72°F and sunny."
|
assert result[6]["content"] == [
|
||||||
|
{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": "The weather in San Francisco is 72°F and sunny.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
assert result[7]["role"] == "assistant"
|
assert result[7]["role"] == "assistant"
|
||||||
assert result[7]["content"] == [
|
assert result[7]["content"] == [
|
||||||
@ -1925,3 +2065,64 @@ def test_mcp_tracing() -> None:
|
|||||||
# Test headers are correctly propagated to request
|
# Test headers are correctly propagated to request
|
||||||
payload = llm_with_tools._get_request_payload([input_message], tools=tools) # type: ignore[attr-defined]
|
payload = llm_with_tools._get_request_payload([input_message], tools=tools) # type: ignore[attr-defined]
|
||||||
assert payload["tools"][0]["headers"]["Authorization"] == "Bearer PLACEHOLDER"
|
assert payload["tools"][0]["headers"]["Authorization"] == "Bearer PLACEHOLDER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compat() -> None:
|
||||||
|
# Check compatibility with v0.3 message format
|
||||||
|
message_v03 = AIMessage(
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": "Hello, world!", "annotations": [{"type": "foo"}]}
|
||||||
|
],
|
||||||
|
additional_kwargs={
|
||||||
|
"reasoning": {
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_123",
|
||||||
|
"summary": [{"type": "summary_text", "text": "Reasoning summary"}],
|
||||||
|
},
|
||||||
|
"tool_outputs": [
|
||||||
|
{
|
||||||
|
"type": "web_search_call",
|
||||||
|
"id": "websearch_123",
|
||||||
|
"status": "completed",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refusal": "I cannot assist with that.",
|
||||||
|
},
|
||||||
|
response_metadata={"id": "resp_123"},
|
||||||
|
id="msg_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = _convert_from_v03_ai_message(message_v03)
|
||||||
|
expected = AIMessage(
|
||||||
|
content=[
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [{"type": "summary_text", "text": "Reasoning summary"}],
|
||||||
|
"id": "rs_123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hello, world!",
|
||||||
|
"annotations": [{"type": "foo"}],
|
||||||
|
"id": "msg_123",
|
||||||
|
},
|
||||||
|
{"type": "refusal", "refusal": "I cannot assist with that."},
|
||||||
|
{"type": "web_search_call", "id": "websearch_123", "status": "completed"},
|
||||||
|
],
|
||||||
|
response_metadata={"id": "resp_123"},
|
||||||
|
id="resp_123",
|
||||||
|
)
|
||||||
|
assert message == expected
|
||||||
|
|
||||||
|
## Check no mutation
|
||||||
|
assert message != message_v03
|
||||||
|
assert len(message_v03.content) == 1
|
||||||
|
assert all(
|
||||||
|
item in message_v03.additional_kwargs
|
||||||
|
for item in ["reasoning", "tool_outputs", "refusal"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert back
|
||||||
|
message_v03_output = _convert_to_v03_ai_message(message)
|
||||||
|
assert message_v03_output == message_v03
|
||||||
|
assert message_v03_output is not message_v03
|
||||||
|
@ -0,0 +1,646 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain_core.messages import AIMessageChunk, BaseMessageChunk
|
||||||
|
from openai.types.responses import (
|
||||||
|
ResponseCompletedEvent,
|
||||||
|
ResponseContentPartAddedEvent,
|
||||||
|
ResponseContentPartDoneEvent,
|
||||||
|
ResponseCreatedEvent,
|
||||||
|
ResponseInProgressEvent,
|
||||||
|
ResponseOutputItemAddedEvent,
|
||||||
|
ResponseOutputItemDoneEvent,
|
||||||
|
ResponseOutputMessage,
|
||||||
|
ResponseReasoningItem,
|
||||||
|
ResponseReasoningSummaryPartAddedEvent,
|
||||||
|
ResponseReasoningSummaryPartDoneEvent,
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent,
|
||||||
|
ResponseReasoningSummaryTextDoneEvent,
|
||||||
|
ResponseTextConfig,
|
||||||
|
ResponseTextDeltaEvent,
|
||||||
|
ResponseTextDoneEvent,
|
||||||
|
)
|
||||||
|
from openai.types.responses.response import Response, ResponseUsage
|
||||||
|
from openai.types.responses.response_output_text import ResponseOutputText
|
||||||
|
from openai.types.responses.response_reasoning_item import Summary
|
||||||
|
from openai.types.responses.response_reasoning_summary_part_added_event import (
|
||||||
|
Part as PartAdded,
|
||||||
|
)
|
||||||
|
from openai.types.responses.response_reasoning_summary_part_done_event import (
|
||||||
|
Part as PartDone,
|
||||||
|
)
|
||||||
|
from openai.types.responses.response_usage import (
|
||||||
|
InputTokensDetails,
|
||||||
|
OutputTokensDetails,
|
||||||
|
)
|
||||||
|
from openai.types.shared.reasoning import Reasoning
|
||||||
|
from openai.types.shared.response_format_text import ResponseFormatText
|
||||||
|
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from tests.unit_tests.chat_models.test_base import MockSyncContextManager
|
||||||
|
|
||||||
|
responses_stream = [
|
||||||
|
ResponseCreatedEvent(
|
||||||
|
response=Response(
|
||||||
|
id="resp_123",
|
||||||
|
created_at=1749734255.0,
|
||||||
|
error=None,
|
||||||
|
incomplete_details=None,
|
||||||
|
instructions=None,
|
||||||
|
metadata={},
|
||||||
|
model="o4-mini-2025-04-16",
|
||||||
|
object="response",
|
||||||
|
output=[],
|
||||||
|
parallel_tool_calls=True,
|
||||||
|
temperature=1.0,
|
||||||
|
tool_choice="auto",
|
||||||
|
tools=[],
|
||||||
|
top_p=1.0,
|
||||||
|
background=False,
|
||||||
|
max_output_tokens=None,
|
||||||
|
previous_response_id=None,
|
||||||
|
reasoning=Reasoning(
|
||||||
|
effort="medium", generate_summary=None, summary="detailed"
|
||||||
|
),
|
||||||
|
service_tier="auto",
|
||||||
|
status="in_progress",
|
||||||
|
text=ResponseTextConfig(format=ResponseFormatText(type="text")),
|
||||||
|
truncation="disabled",
|
||||||
|
usage=None,
|
||||||
|
user=None,
|
||||||
|
),
|
||||||
|
sequence_number=0,
|
||||||
|
type="response.created",
|
||||||
|
),
|
||||||
|
ResponseInProgressEvent(
|
||||||
|
response=Response(
|
||||||
|
id="resp_123",
|
||||||
|
created_at=1749734255.0,
|
||||||
|
error=None,
|
||||||
|
incomplete_details=None,
|
||||||
|
instructions=None,
|
||||||
|
metadata={},
|
||||||
|
model="o4-mini-2025-04-16",
|
||||||
|
object="response",
|
||||||
|
output=[],
|
||||||
|
parallel_tool_calls=True,
|
||||||
|
temperature=1.0,
|
||||||
|
tool_choice="auto",
|
||||||
|
tools=[],
|
||||||
|
top_p=1.0,
|
||||||
|
background=False,
|
||||||
|
max_output_tokens=None,
|
||||||
|
previous_response_id=None,
|
||||||
|
reasoning=Reasoning(
|
||||||
|
effort="medium", generate_summary=None, summary="detailed"
|
||||||
|
),
|
||||||
|
service_tier="auto",
|
||||||
|
status="in_progress",
|
||||||
|
text=ResponseTextConfig(format=ResponseFormatText(type="text")),
|
||||||
|
truncation="disabled",
|
||||||
|
usage=None,
|
||||||
|
user=None,
|
||||||
|
),
|
||||||
|
sequence_number=1,
|
||||||
|
type="response.in_progress",
|
||||||
|
),
|
||||||
|
ResponseOutputItemAddedEvent(
|
||||||
|
item=ResponseReasoningItem(
|
||||||
|
id="rs_123",
|
||||||
|
summary=[],
|
||||||
|
type="reasoning",
|
||||||
|
encrypted_content=None,
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=2,
|
||||||
|
type="response.output_item.added",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartAddedEvent(
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
part=PartAdded(text="", type="summary_text"),
|
||||||
|
sequence_number=3,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_part.added",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent(
|
||||||
|
delta="reasoning block",
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=4,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_text.delta",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent(
|
||||||
|
delta=" one",
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=5,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_text.delta",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDoneEvent(
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=6,
|
||||||
|
summary_index=0,
|
||||||
|
text="reasoning block one",
|
||||||
|
type="response.reasoning_summary_text.done",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartDoneEvent(
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
part=PartDone(text="reasoning block one", type="summary_text"),
|
||||||
|
sequence_number=7,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_part.done",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartAddedEvent(
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
part=PartAdded(text="", type="summary_text"),
|
||||||
|
sequence_number=8,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_part.added",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent(
|
||||||
|
delta="another reasoning",
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=9,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_text.delta",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent(
|
||||||
|
delta=" block",
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=10,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_text.delta",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDoneEvent(
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=11,
|
||||||
|
summary_index=1,
|
||||||
|
text="another reasoning block",
|
||||||
|
type="response.reasoning_summary_text.done",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartDoneEvent(
|
||||||
|
item_id="rs_123",
|
||||||
|
output_index=0,
|
||||||
|
part=PartDone(text="another reasoning block", type="summary_text"),
|
||||||
|
sequence_number=12,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_part.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemDoneEvent(
|
||||||
|
item=ResponseReasoningItem(
|
||||||
|
id="rs_123",
|
||||||
|
summary=[
|
||||||
|
Summary(text="reasoning block one", type="summary_text"),
|
||||||
|
Summary(text="another reasoning block", type="summary_text"),
|
||||||
|
],
|
||||||
|
type="reasoning",
|
||||||
|
encrypted_content=None,
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
output_index=0,
|
||||||
|
sequence_number=13,
|
||||||
|
type="response.output_item.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemAddedEvent(
|
||||||
|
item=ResponseOutputMessage(
|
||||||
|
id="msg_123",
|
||||||
|
content=[],
|
||||||
|
role="assistant",
|
||||||
|
status="in_progress",
|
||||||
|
type="message",
|
||||||
|
),
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=14,
|
||||||
|
type="response.output_item.added",
|
||||||
|
),
|
||||||
|
ResponseContentPartAddedEvent(
|
||||||
|
content_index=0,
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
part=ResponseOutputText(annotations=[], text="", type="output_text"),
|
||||||
|
sequence_number=15,
|
||||||
|
type="response.content_part.added",
|
||||||
|
),
|
||||||
|
ResponseTextDeltaEvent(
|
||||||
|
content_index=0,
|
||||||
|
delta="text block",
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=16,
|
||||||
|
type="response.output_text.delta",
|
||||||
|
),
|
||||||
|
ResponseTextDeltaEvent(
|
||||||
|
content_index=0,
|
||||||
|
delta=" one",
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=17,
|
||||||
|
type="response.output_text.delta",
|
||||||
|
),
|
||||||
|
ResponseTextDoneEvent(
|
||||||
|
content_index=0,
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=18,
|
||||||
|
text="text block one",
|
||||||
|
type="response.output_text.done",
|
||||||
|
),
|
||||||
|
ResponseContentPartDoneEvent(
|
||||||
|
content_index=0,
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
part=ResponseOutputText(
|
||||||
|
annotations=[], text="text block one", type="output_text"
|
||||||
|
),
|
||||||
|
sequence_number=19,
|
||||||
|
type="response.content_part.done",
|
||||||
|
),
|
||||||
|
ResponseContentPartAddedEvent(
|
||||||
|
content_index=1,
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
part=ResponseOutputText(annotations=[], text="", type="output_text"),
|
||||||
|
sequence_number=20,
|
||||||
|
type="response.content_part.added",
|
||||||
|
),
|
||||||
|
ResponseTextDeltaEvent(
|
||||||
|
content_index=1,
|
||||||
|
delta="another text",
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=21,
|
||||||
|
type="response.output_text.delta",
|
||||||
|
),
|
||||||
|
ResponseTextDeltaEvent(
|
||||||
|
content_index=1,
|
||||||
|
delta=" block",
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=22,
|
||||||
|
type="response.output_text.delta",
|
||||||
|
),
|
||||||
|
ResponseTextDoneEvent(
|
||||||
|
content_index=1,
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=23,
|
||||||
|
text="another text block",
|
||||||
|
type="response.output_text.done",
|
||||||
|
),
|
||||||
|
ResponseContentPartDoneEvent(
|
||||||
|
content_index=1,
|
||||||
|
item_id="msg_123",
|
||||||
|
output_index=1,
|
||||||
|
part=ResponseOutputText(
|
||||||
|
annotations=[], text="another text block", type="output_text"
|
||||||
|
),
|
||||||
|
sequence_number=24,
|
||||||
|
type="response.content_part.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemDoneEvent(
|
||||||
|
item=ResponseOutputMessage(
|
||||||
|
id="msg_123",
|
||||||
|
content=[
|
||||||
|
ResponseOutputText(
|
||||||
|
annotations=[], text="text block one", type="output_text"
|
||||||
|
),
|
||||||
|
ResponseOutputText(
|
||||||
|
annotations=[], text="another text block", type="output_text"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
type="message",
|
||||||
|
),
|
||||||
|
output_index=1,
|
||||||
|
sequence_number=25,
|
||||||
|
type="response.output_item.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemAddedEvent(
|
||||||
|
item=ResponseReasoningItem(
|
||||||
|
id="rs_234",
|
||||||
|
summary=[],
|
||||||
|
type="reasoning",
|
||||||
|
encrypted_content=None,
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
output_index=2,
|
||||||
|
sequence_number=26,
|
||||||
|
type="response.output_item.added",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartAddedEvent(
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
part=PartAdded(text="", type="summary_text"),
|
||||||
|
sequence_number=27,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_part.added",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent(
|
||||||
|
delta="more reasoning",
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
sequence_number=28,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_text.delta",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDoneEvent(
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
sequence_number=29,
|
||||||
|
summary_index=0,
|
||||||
|
text="more reasoning",
|
||||||
|
type="response.reasoning_summary_text.done",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartDoneEvent(
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
part=PartDone(text="more reasoning", type="summary_text"),
|
||||||
|
sequence_number=30,
|
||||||
|
summary_index=0,
|
||||||
|
type="response.reasoning_summary_part.done",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartAddedEvent(
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
part=PartAdded(text="", type="summary_text"),
|
||||||
|
sequence_number=31,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_part.added",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDeltaEvent(
|
||||||
|
delta="still more reasoning",
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
sequence_number=32,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_text.delta",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryTextDoneEvent(
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
sequence_number=33,
|
||||||
|
summary_index=1,
|
||||||
|
text="still more reasoning",
|
||||||
|
type="response.reasoning_summary_text.done",
|
||||||
|
),
|
||||||
|
ResponseReasoningSummaryPartDoneEvent(
|
||||||
|
item_id="rs_234",
|
||||||
|
output_index=2,
|
||||||
|
part=PartDone(text="still more reasoning", type="summary_text"),
|
||||||
|
sequence_number=34,
|
||||||
|
summary_index=1,
|
||||||
|
type="response.reasoning_summary_part.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemDoneEvent(
|
||||||
|
item=ResponseReasoningItem(
|
||||||
|
id="rs_234",
|
||||||
|
summary=[
|
||||||
|
Summary(text="more reasoning", type="summary_text"),
|
||||||
|
Summary(text="still more reasoning", type="summary_text"),
|
||||||
|
],
|
||||||
|
type="reasoning",
|
||||||
|
encrypted_content=None,
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
output_index=2,
|
||||||
|
sequence_number=35,
|
||||||
|
type="response.output_item.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemAddedEvent(
|
||||||
|
item=ResponseOutputMessage(
|
||||||
|
id="msg_234",
|
||||||
|
content=[],
|
||||||
|
role="assistant",
|
||||||
|
status="in_progress",
|
||||||
|
type="message",
|
||||||
|
),
|
||||||
|
output_index=3,
|
||||||
|
sequence_number=36,
|
||||||
|
type="response.output_item.added",
|
||||||
|
),
|
||||||
|
ResponseContentPartAddedEvent(
|
||||||
|
content_index=0,
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
part=ResponseOutputText(annotations=[], text="", type="output_text"),
|
||||||
|
sequence_number=37,
|
||||||
|
type="response.content_part.added",
|
||||||
|
),
|
||||||
|
ResponseTextDeltaEvent(
|
||||||
|
content_index=0,
|
||||||
|
delta="more",
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
sequence_number=38,
|
||||||
|
type="response.output_text.delta",
|
||||||
|
),
|
||||||
|
ResponseTextDoneEvent(
|
||||||
|
content_index=0,
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
sequence_number=39,
|
||||||
|
text="more",
|
||||||
|
type="response.output_text.done",
|
||||||
|
),
|
||||||
|
ResponseContentPartDoneEvent(
|
||||||
|
content_index=0,
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
part=ResponseOutputText(annotations=[], text="more", type="output_text"),
|
||||||
|
sequence_number=40,
|
||||||
|
type="response.content_part.done",
|
||||||
|
),
|
||||||
|
ResponseContentPartAddedEvent(
|
||||||
|
content_index=1,
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
part=ResponseOutputText(annotations=[], text="", type="output_text"),
|
||||||
|
sequence_number=41,
|
||||||
|
type="response.content_part.added",
|
||||||
|
),
|
||||||
|
ResponseTextDeltaEvent(
|
||||||
|
content_index=1,
|
||||||
|
delta="text",
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
sequence_number=42,
|
||||||
|
type="response.output_text.delta",
|
||||||
|
),
|
||||||
|
ResponseTextDoneEvent(
|
||||||
|
content_index=1,
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
sequence_number=43,
|
||||||
|
text="text",
|
||||||
|
type="response.output_text.done",
|
||||||
|
),
|
||||||
|
ResponseContentPartDoneEvent(
|
||||||
|
content_index=1,
|
||||||
|
item_id="msg_234",
|
||||||
|
output_index=3,
|
||||||
|
part=ResponseOutputText(annotations=[], text="text", type="output_text"),
|
||||||
|
sequence_number=44,
|
||||||
|
type="response.content_part.done",
|
||||||
|
),
|
||||||
|
ResponseOutputItemDoneEvent(
|
||||||
|
item=ResponseOutputMessage(
|
||||||
|
id="msg_234",
|
||||||
|
content=[
|
||||||
|
ResponseOutputText(annotations=[], text="more", type="output_text"),
|
||||||
|
ResponseOutputText(annotations=[], text="text", type="output_text"),
|
||||||
|
],
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
type="message",
|
||||||
|
),
|
||||||
|
output_index=3,
|
||||||
|
sequence_number=45,
|
||||||
|
type="response.output_item.done",
|
||||||
|
),
|
||||||
|
ResponseCompletedEvent(
|
||||||
|
response=Response(
|
||||||
|
id="resp_123",
|
||||||
|
created_at=1749734255.0,
|
||||||
|
error=None,
|
||||||
|
incomplete_details=None,
|
||||||
|
instructions=None,
|
||||||
|
metadata={},
|
||||||
|
model="o4-mini-2025-04-16",
|
||||||
|
object="response",
|
||||||
|
output=[
|
||||||
|
ResponseReasoningItem(
|
||||||
|
id="rs_123",
|
||||||
|
summary=[
|
||||||
|
Summary(text="reasoning block one", type="summary_text"),
|
||||||
|
Summary(text="another reasoning block", type="summary_text"),
|
||||||
|
],
|
||||||
|
type="reasoning",
|
||||||
|
encrypted_content=None,
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
ResponseOutputMessage(
|
||||||
|
id="msg_123",
|
||||||
|
content=[
|
||||||
|
ResponseOutputText(
|
||||||
|
annotations=[], text="text block one", type="output_text"
|
||||||
|
),
|
||||||
|
ResponseOutputText(
|
||||||
|
annotations=[],
|
||||||
|
text="another text block",
|
||||||
|
type="output_text",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
type="message",
|
||||||
|
),
|
||||||
|
ResponseReasoningItem(
|
||||||
|
id="rs_234",
|
||||||
|
summary=[
|
||||||
|
Summary(text="more reasoning", type="summary_text"),
|
||||||
|
Summary(text="still more reasoning", type="summary_text"),
|
||||||
|
],
|
||||||
|
type="reasoning",
|
||||||
|
encrypted_content=None,
|
||||||
|
status=None,
|
||||||
|
),
|
||||||
|
ResponseOutputMessage(
|
||||||
|
id="msg_234",
|
||||||
|
content=[
|
||||||
|
ResponseOutputText(
|
||||||
|
annotations=[], text="more", type="output_text"
|
||||||
|
),
|
||||||
|
ResponseOutputText(
|
||||||
|
annotations=[], text="text", type="output_text"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
type="message",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
parallel_tool_calls=True,
|
||||||
|
temperature=1.0,
|
||||||
|
tool_choice="auto",
|
||||||
|
tools=[],
|
||||||
|
top_p=1.0,
|
||||||
|
background=False,
|
||||||
|
max_output_tokens=None,
|
||||||
|
previous_response_id=None,
|
||||||
|
reasoning=Reasoning(
|
||||||
|
effort="medium", generate_summary=None, summary="detailed"
|
||||||
|
),
|
||||||
|
service_tier="default",
|
||||||
|
status="completed",
|
||||||
|
text=ResponseTextConfig(format=ResponseFormatText(type="text")),
|
||||||
|
truncation="disabled",
|
||||||
|
usage=ResponseUsage(
|
||||||
|
input_tokens=13,
|
||||||
|
input_tokens_details=InputTokensDetails(cached_tokens=0),
|
||||||
|
output_tokens=71,
|
||||||
|
output_tokens_details=OutputTokensDetails(reasoning_tokens=64),
|
||||||
|
total_tokens=84,
|
||||||
|
),
|
||||||
|
user=None,
|
||||||
|
),
|
||||||
|
sequence_number=46,
|
||||||
|
type="response.completed",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(reason="Will be fixed with output format flags.")
|
||||||
|
def test_responses_stream() -> None:
|
||||||
|
llm = ChatOpenAI(model="o4-mini", use_responses_api=True)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
|
||||||
|
def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:
|
||||||
|
return MockSyncContextManager(responses_stream)
|
||||||
|
|
||||||
|
mock_client.responses.create = mock_create
|
||||||
|
|
||||||
|
full: Optional[BaseMessageChunk] = None
|
||||||
|
with patch.object(llm, "root_client", mock_client):
|
||||||
|
for chunk in llm.stream("test"):
|
||||||
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
|
full = chunk if full is None else full + chunk
|
||||||
|
assert isinstance(full, AIMessageChunk)
|
||||||
|
|
||||||
|
expected_content = [
|
||||||
|
{
|
||||||
|
"id": "rs_123",
|
||||||
|
"summary": [
|
||||||
|
{"index": 0, "type": "summary_text", "text": "reasoning block one"},
|
||||||
|
{"index": 1, "type": "summary_text", "text": "another reasoning block"},
|
||||||
|
],
|
||||||
|
"type": "reasoning",
|
||||||
|
"index": 0,
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "text block one", "index": 1, "id": "msg_123"},
|
||||||
|
{"type": "text", "text": "another text block", "index": 2, "id": "msg_123"},
|
||||||
|
{
|
||||||
|
"id": "rs_234",
|
||||||
|
"summary": [
|
||||||
|
{"index": 0, "type": "summary_text", "text": "more reasoning"},
|
||||||
|
{"index": 1, "type": "summary_text", "text": "still more reasoning"},
|
||||||
|
],
|
||||||
|
"type": "reasoning",
|
||||||
|
"index": 3,
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "more", "index": 4, "id": "msg_234"},
|
||||||
|
{"type": "text", "text": "text", "index": 5, "id": "msg_234"},
|
||||||
|
]
|
||||||
|
assert full.content == expected_content
|
||||||
|
assert full.additional_kwargs == {}
|
@ -587,7 +587,7 @@ typing = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "langchain-core", editable = "../../core" },
|
{ name = "langchain-core", editable = "../../core" },
|
||||||
{ name = "openai", specifier = ">=1.68.2,<2.0.0" },
|
{ name = "openai", specifier = ">=1.86.0,<2.0.0" },
|
||||||
{ name = "tiktoken", specifier = ">=0.7,<1" },
|
{ name = "tiktoken", specifier = ">=0.7,<1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user