Skip to content

Commit b9357d4

Browse files
authored
openai[patch]: refactor handling of Responses API (#31587)
1 parent 532e645 commit b9357d4

File tree

10 files changed

+1420
-206
lines changed

10 files changed

+1420
-206
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""
2+
This module converts between AIMessage output formats for the Responses API.
3+
4+
ChatOpenAI v0.3 stores reasoning and tool outputs in AIMessage.additional_kwargs:
5+
6+
.. code-block:: python
7+
8+
AIMessage(
9+
content=[
10+
{"type": "text", "text": "Hello, world!", "annotations": [{"type": "foo"}]}
11+
],
12+
additional_kwargs={
13+
"reasoning": {
14+
"type": "reasoning",
15+
"id": "rs_123",
16+
"summary": [{"type": "summary_text", "text": "Reasoning summary"}],
17+
},
18+
"tool_outputs": [
19+
{"type": "web_search_call", "id": "websearch_123", "status": "completed"}
20+
],
21+
"refusal": "I cannot assist with that.",
22+
},
23+
response_metadata={"id": "resp_123"},
24+
id="msg_123",
25+
)
26+
27+
To retain information about response item sequencing (and to accommodate multiple
28+
reasoning items), ChatOpenAI now stores these items in the content sequence:
29+
30+
.. code-block:: python
31+
32+
AIMessage(
33+
content=[
34+
{
35+
"type": "reasoning",
36+
"summary": [{"type": "summary_text", "text": "Reasoning summary"}],
37+
"id": "rs_123",
38+
},
39+
{
40+
"type": "text",
41+
"text": "Hello, world!",
42+
"annotations": [{"type": "foo"}],
43+
"id": "msg_123",
44+
},
45+
{"type": "refusal", "refusal": "I cannot assist with that."},
46+
{"type": "web_search_call", "id": "websearch_123", "status": "completed"},
47+
],
48+
response_metadata={"id": "resp_123"},
49+
id="resp_123",
50+
)
51+
52+
There are other, small improvements as well-- e.g., we store message IDs on text
53+
content blocks, rather than on the AIMessage.id, which now stores the response ID.
54+
55+
For backwards compatibility, this module provides functions to convert between the
56+
old and new formats. The functions are used internally by ChatOpenAI.
57+
""" # noqa: E501
58+
59+
import json
60+
from typing import Union
61+
62+
from langchain_core.messages import AIMessage
63+
64+
_FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__"
65+
66+
67+
def _convert_to_v03_ai_message(
68+
message: AIMessage, has_reasoning: bool = False
69+
) -> AIMessage:
70+
"""Mutate an AIMessage to the old-style v0.3 format."""
71+
if isinstance(message.content, list):
72+
new_content: list[Union[dict, str]] = []
73+
for block in message.content:
74+
if isinstance(block, dict):
75+
if block.get("type") == "reasoning" or "summary" in block:
76+
# Store a reasoning item in additional_kwargs (overwriting as in
77+
# v0.3)
78+
_ = block.pop("index", None)
79+
if has_reasoning:
80+
_ = block.pop("id", None)
81+
_ = block.pop("type", None)
82+
message.additional_kwargs["reasoning"] = block
83+
elif block.get("type") in (
84+
"web_search_call",
85+
"file_search_call",
86+
"computer_call",
87+
"code_interpreter_call",
88+
"mcp_call",
89+
"mcp_list_tools",
90+
"mcp_approval_request",
91+
"image_generation_call",
92+
):
93+
# Store built-in tool calls in additional_kwargs
94+
if "tool_outputs" not in message.additional_kwargs:
95+
message.additional_kwargs["tool_outputs"] = []
96+
message.additional_kwargs["tool_outputs"].append(block)
97+
elif block.get("type") == "function_call":
98+
# Store function call item IDs in additional_kwargs, otherwise
99+
# discard function call items.
100+
if _FUNCTION_CALL_IDS_MAP_KEY not in message.additional_kwargs:
101+
message.additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY] = {}
102+
if (call_id := block.get("call_id")) and (
103+
function_call_id := block.get("id")
104+
):
105+
message.additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY][
106+
call_id
107+
] = function_call_id
108+
elif (block.get("type") == "refusal") and (
109+
refusal := block.get("refusal")
110+
):
111+
# Store a refusal item in additional_kwargs (overwriting as in
112+
# v0.3)
113+
message.additional_kwargs["refusal"] = refusal
114+
elif block.get("type") == "text":
115+
# Store a message item ID on AIMessage.id
116+
if "id" in block:
117+
message.id = block["id"]
118+
new_content.append({k: v for k, v in block.items() if k != "id"})
119+
elif (
120+
set(block.keys()) == {"id", "index"}
121+
and isinstance(block["id"], str)
122+
and block["id"].startswith("msg_")
123+
):
124+
# Drop message IDs in streaming case
125+
new_content.append({"index": block["index"]})
126+
else:
127+
new_content.append(block)
128+
else:
129+
new_content.append(block)
130+
message.content = new_content
131+
else:
132+
pass
133+
134+
return message
135+
136+
137+
def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage:
138+
"""Convert an old-style v0.3 AIMessage into the new content-block format."""
139+
# Only update ChatOpenAI v0.3 AIMessages
140+
if not (
141+
isinstance(message.content, list)
142+
and all(isinstance(b, dict) for b in message.content)
143+
) or not any(
144+
item in message.additional_kwargs
145+
for item in ["reasoning", "tool_outputs", "refusal"]
146+
):
147+
return message
148+
149+
content_order = [
150+
"reasoning",
151+
"code_interpreter_call",
152+
"mcp_call",
153+
"image_generation_call",
154+
"text",
155+
"refusal",
156+
"function_call",
157+
"computer_call",
158+
"mcp_list_tools",
159+
"mcp_approval_request",
160+
# N. B. "web_search_call" and "file_search_call" were not passed back in
161+
# in v0.3
162+
]
163+
164+
# Build a bucket for every known block type
165+
buckets: dict[str, list] = {key: [] for key in content_order}
166+
unknown_blocks = []
167+
168+
# Reasoning
169+
if reasoning := message.additional_kwargs.get("reasoning"):
170+
buckets["reasoning"].append(reasoning)
171+
172+
# Refusal
173+
if refusal := message.additional_kwargs.get("refusal"):
174+
buckets["refusal"].append({"type": "refusal", "refusal": refusal})
175+
176+
# Text
177+
for block in message.content:
178+
if isinstance(block, dict) and block.get("type") == "text":
179+
block_copy = block.copy()
180+
if isinstance(message.id, str) and message.id.startswith("msg_"):
181+
block_copy["id"] = message.id
182+
buckets["text"].append(block_copy)
183+
else:
184+
unknown_blocks.append(block)
185+
186+
# Function calls
187+
function_call_ids = message.additional_kwargs.get(_FUNCTION_CALL_IDS_MAP_KEY)
188+
for tool_call in message.tool_calls:
189+
function_call = {
190+
"type": "function_call",
191+
"name": tool_call["name"],
192+
"arguments": json.dumps(tool_call["args"]),
193+
"call_id": tool_call["id"],
194+
}
195+
if function_call_ids is not None and (
196+
_id := function_call_ids.get(tool_call["id"])
197+
):
198+
function_call["id"] = _id
199+
buckets["function_call"].append(function_call)
200+
201+
# Tool outputs
202+
tool_outputs = message.additional_kwargs.get("tool_outputs", [])
203+
for block in tool_outputs:
204+
if isinstance(block, dict) and (key := block.get("type")) and key in buckets:
205+
buckets[key].append(block)
206+
else:
207+
unknown_blocks.append(block)
208+
209+
# Re-assemble the content list in the canonical order
210+
new_content = []
211+
for key in content_order:
212+
new_content.extend(buckets[key])
213+
new_content.extend(unknown_blocks)
214+
215+
new_additional_kwargs = dict(message.additional_kwargs)
216+
new_additional_kwargs.pop("reasoning", None)
217+
new_additional_kwargs.pop("refusal", None)
218+
new_additional_kwargs.pop("tool_outputs", None)
219+
220+
if "id" in message.response_metadata:
221+
new_id = message.response_metadata["id"]
222+
else:
223+
new_id = message.id
224+
225+
return message.model_copy(
226+
update={
227+
"content": new_content,
228+
"additional_kwargs": new_additional_kwargs,
229+
"id": new_id,
230+
},
231+
deep=False,
232+
)

0 commit comments

Comments
 (0)