|
| 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