Skip to content

Commit 053a124

Browse files
authored
openai[patch]: support built-in code interpreter and remote MCP tools (#31304)
1 parent 1b5ffe4 commit 053a124

File tree

6 files changed

+389
-14
lines changed

6 files changed

+389
-14
lines changed

docs/docs/integrations/chat/openai.ipynb

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,175 @@
915915
"response_2.text()"
916916
]
917917
},
918+
{
919+
"cell_type": "markdown",
920+
"id": "34ad0015-688c-4274-be55-93268b44f558",
921+
"metadata": {},
922+
"source": [
923+
"#### Code interpreter\n",
924+
"\n",
925+
"OpenAI implements a [code interpreter](https://platform.openai.com/docs/guides/tools-code-interpreter) tool to support the sandboxed generation and execution of code.\n",
926+
"\n",
927+
"Example use:"
928+
]
929+
},
930+
{
931+
"cell_type": "code",
932+
"execution_count": 2,
933+
"id": "34826aae-6d48-4b84-bc00-89594a87d461",
934+
"metadata": {},
935+
"outputs": [],
936+
"source": [
937+
"from langchain_openai import ChatOpenAI\n",
938+
"\n",
939+
"llm = ChatOpenAI(model=\"o4-mini\", use_responses_api=True)\n",
940+
"\n",
941+
"llm_with_tools = llm.bind_tools(\n",
942+
" [\n",
943+
" {\n",
944+
" \"type\": \"code_interpreter\",\n",
945+
" # Create a new container\n",
946+
" \"container\": {\"type\": \"auto\"},\n",
947+
" }\n",
948+
" ]\n",
949+
")\n",
950+
"response = llm_with_tools.invoke(\n",
951+
" \"Write and run code to answer the question: what is 3^3?\"\n",
952+
")"
953+
]
954+
},
955+
{
956+
"cell_type": "markdown",
957+
"id": "1b4d92b9-941f-4d54-93a5-b0c73afd66b2",
958+
"metadata": {},
959+
"source": [
960+
"Note that the above command created a new container. We can also specify an existing container ID:"
961+
]
962+
},
963+
{
964+
"cell_type": "code",
965+
"execution_count": 4,
966+
"id": "d8c82895-5011-4062-a1bb-278ec91321e9",
967+
"metadata": {},
968+
"outputs": [],
969+
"source": [
970+
"tool_outputs = response.additional_kwargs[\"tool_outputs\"]\n",
971+
"assert len(tool_outputs) == 1\n",
972+
"# highlight-next-line\n",
973+
"container_id = tool_outputs[0][\"container_id\"]\n",
974+
"\n",
975+
"llm_with_tools = llm.bind_tools(\n",
976+
" [\n",
977+
" {\n",
978+
" \"type\": \"code_interpreter\",\n",
979+
" # Use an existing container\n",
980+
" # highlight-next-line\n",
981+
" \"container\": container_id,\n",
982+
" }\n",
983+
" ]\n",
984+
")"
985+
]
986+
},
987+
{
988+
"cell_type": "markdown",
989+
"id": "8db30501-522c-4915-963d-d60539b5c16e",
990+
"metadata": {},
991+
"source": [
992+
"#### Remote MCP\n",
993+
"\n",
994+
"OpenAI implements a [remote MCP](https://platform.openai.com/docs/guides/tools-remote-mcp) tool that allows for model-generated calls to MCP servers.\n",
995+
"\n",
996+
"Example use:"
997+
]
998+
},
999+
{
1000+
"cell_type": "code",
1001+
"execution_count": 1,
1002+
"id": "7044a87b-8b99-49e8-8ca4-e2a8ae49f65a",
1003+
"metadata": {},
1004+
"outputs": [],
1005+
"source": [
1006+
"from langchain_openai import ChatOpenAI\n",
1007+
"\n",
1008+
"llm = ChatOpenAI(model=\"o4-mini\", use_responses_api=True)\n",
1009+
"\n",
1010+
"llm_with_tools = llm.bind_tools(\n",
1011+
" [\n",
1012+
" {\n",
1013+
" \"type\": \"mcp\",\n",
1014+
" \"server_label\": \"deepwiki\",\n",
1015+
" \"server_url\": \"https://mcp.deepwiki.com/mcp\",\n",
1016+
" \"require_approval\": \"never\",\n",
1017+
" }\n",
1018+
" ]\n",
1019+
")\n",
1020+
"response = llm_with_tools.invoke(\n",
1021+
" \"What transport protocols does the 2025-03-26 version of the MCP \"\n",
1022+
" \"spec (modelcontextprotocol/modelcontextprotocol) support?\"\n",
1023+
")"
1024+
]
1025+
},
1026+
{
1027+
"cell_type": "markdown",
1028+
"id": "0ed7494e-425d-4bdf-ab83-3164757031dd",
1029+
"metadata": {},
1030+
"source": [
1031+
"<details>\n",
1032+
"<summary>MCP Approvals</summary>\n",
1033+
"\n",
1034+
"OpenAI will at times request approval before sharing data with a remote MCP server.\n",
1035+
"\n",
1036+
"In the above command, we instructed the model to never require approval. We can also configure the model to always request approval, or to always request approval for specific tools:\n",
1037+
"\n",
1038+
"```python\n",
1039+
"llm_with_tools = llm.bind_tools(\n",
1040+
" [\n",
1041+
" {\n",
1042+
" \"type\": \"mcp\",\n",
1043+
" \"server_label\": \"deepwiki\",\n",
1044+
" \"server_url\": \"https://mcp.deepwiki.com/mcp\",\n",
1045+
" \"require_approval\": {\n",
1046+
" \"always\": {\n",
1047+
" \"tool_names\": [\"read_wiki_structure\"]\n",
1048+
" }\n",
1049+
" }\n",
1050+
" }\n",
1051+
" ]\n",
1052+
")\n",
1053+
"response = llm_with_tools.invoke(\n",
1054+
" \"What transport protocols does the 2025-03-26 version of the MCP \"\n",
1055+
" \"spec (modelcontextprotocol/modelcontextprotocol) support?\"\n",
1056+
")\n",
1057+
"```\n",
1058+
"\n",
1059+
"Responses may then include blocks with type `\"mcp_approval_request\"`.\n",
1060+
"\n",
1061+
"To submit approvals for an approval request, structure it into a content block in an input message:\n",
1062+
"\n",
1063+
"```python\n",
1064+
"approval_message = {\n",
1065+
" \"role\": \"user\",\n",
1066+
" \"content\": [\n",
1067+
" {\n",
1068+
" \"type\": \"mcp_approval_response\",\n",
1069+
" \"approve\": True,\n",
1070+
" \"approval_request_id\": output[\"id\"],\n",
1071+
" }\n",
1072+
" for output in response.additional_kwargs[\"tool_outputs\"]\n",
1073+
" if output[\"type\"] == \"mcp_approval_request\"\n",
1074+
" ]\n",
1075+
"}\n",
1076+
"\n",
1077+
"next_response = llm_with_tools.invoke(\n",
1078+
" [approval_message],\n",
1079+
" # continue existing thread\n",
1080+
" previous_response_id=response.response_metadata[\"id\"]\n",
1081+
")\n",
1082+
"```\n",
1083+
"\n",
1084+
"</details>"
1085+
]
1086+
},
9181087
{
9191088
"cell_type": "markdown",
9201089
"id": "6fda05f0-4b81-4709-9407-f316d760ad50",

libs/core/langchain_core/utils/function_calling.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,9 +554,19 @@ def convert_to_openai_tool(
554554
Return OpenAI Responses API-style tools unchanged. This includes
555555
any dict with "type" in "file_search", "function", "computer_use_preview",
556556
"web_search_preview".
557+
558+
.. versionchanged:: 0.3.61
559+
560+
Added support for OpenAI's built-in code interpreter and remote MCP tools.
557561
"""
558562
if isinstance(tool, dict):
559-
if tool.get("type") in ("function", "file_search", "computer_use_preview"):
563+
if tool.get("type") in (
564+
"function",
565+
"file_search",
566+
"computer_use_preview",
567+
"code_interpreter",
568+
"mcp",
569+
):
560570
return tool
561571
# As of 03.12.25 can be "web_search_preview" or "web_search_preview_2025_03_11"
562572
if (tool.get("type") or "").startswith("web_search_preview"):

libs/partners/openai/langchain_openai/chat_models/base.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -775,16 +775,22 @@ def _stream_responses(
775775

776776
with context_manager as response:
777777
is_first_chunk = True
778+
has_reasoning = False
778779
for chunk in response:
779780
metadata = headers if is_first_chunk else {}
780781
if generation_chunk := _convert_responses_chunk_to_generation_chunk(
781-
chunk, schema=original_schema_obj, metadata=metadata
782+
chunk,
783+
schema=original_schema_obj,
784+
metadata=metadata,
785+
has_reasoning=has_reasoning,
782786
):
783787
if run_manager:
784788
run_manager.on_llm_new_token(
785789
generation_chunk.text, chunk=generation_chunk
786790
)
787791
is_first_chunk = False
792+
if "reasoning" in generation_chunk.message.additional_kwargs:
793+
has_reasoning = True
788794
yield generation_chunk
789795

790796
async def _astream_responses(
@@ -811,16 +817,22 @@ async def _astream_responses(
811817

812818
async with context_manager as response:
813819
is_first_chunk = True
820+
has_reasoning = False
814821
async for chunk in response:
815822
metadata = headers if is_first_chunk else {}
816823
if generation_chunk := _convert_responses_chunk_to_generation_chunk(
817-
chunk, schema=original_schema_obj, metadata=metadata
824+
chunk,
825+
schema=original_schema_obj,
826+
metadata=metadata,
827+
has_reasoning=has_reasoning,
818828
):
819829
if run_manager:
820830
await run_manager.on_llm_new_token(
821831
generation_chunk.text, chunk=generation_chunk
822832
)
823833
is_first_chunk = False
834+
if "reasoning" in generation_chunk.message.additional_kwargs:
835+
has_reasoning = True
824836
yield generation_chunk
825837

826838
def _should_stream_usage(
@@ -1176,12 +1188,22 @@ def _get_invocation_params(
11761188
self, stop: Optional[list[str]] = None, **kwargs: Any
11771189
) -> dict[str, Any]:
11781190
"""Get the parameters used to invoke the model."""
1179-
return {
1191+
params = {
11801192
"model": self.model_name,
11811193
**super()._get_invocation_params(stop=stop),
11821194
**self._default_params,
11831195
**kwargs,
11841196
}
1197+
# Redact headers from built-in remote MCP tool invocations
1198+
if (tools := params.get("tools")) and isinstance(tools, list):
1199+
params["tools"] = [
1200+
({**tool, "headers": "**REDACTED**"} if "headers" in tool else tool)
1201+
if isinstance(tool, dict) and tool.get("type") == "mcp"
1202+
else tool
1203+
for tool in tools
1204+
]
1205+
1206+
return params
11851207

11861208
def _get_ls_params(
11871209
self, stop: Optional[list[str]] = None, **kwargs: Any
@@ -1456,6 +1478,8 @@ def bind_tools(
14561478
"file_search",
14571479
"web_search_preview",
14581480
"computer_use_preview",
1481+
"code_interpreter",
1482+
"mcp",
14591483
):
14601484
tool_choice = {"type": tool_choice}
14611485
# 'any' is not natively supported by OpenAI API.
@@ -3150,12 +3174,22 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
31503174
):
31513175
function_call["id"] = _id
31523176
function_calls.append(function_call)
3153-
# Computer calls
3177+
# Built-in tool calls
31543178
computer_calls = []
3179+
code_interpreter_calls = []
3180+
mcp_calls = []
31553181
tool_outputs = lc_msg.additional_kwargs.get("tool_outputs", [])
31563182
for tool_output in tool_outputs:
31573183
if tool_output.get("type") == "computer_call":
31583184
computer_calls.append(tool_output)
3185+
elif tool_output.get("type") == "code_interpreter_call":
3186+
code_interpreter_calls.append(tool_output)
3187+
elif tool_output.get("type") == "mcp_call":
3188+
mcp_calls.append(tool_output)
3189+
else:
3190+
pass
3191+
input_.extend(code_interpreter_calls)
3192+
input_.extend(mcp_calls)
31593193
msg["content"] = msg.get("content") or []
31603194
if lc_msg.additional_kwargs.get("refusal"):
31613195
if isinstance(msg["content"], str):
@@ -3196,6 +3230,7 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
31963230
elif msg["role"] in ("user", "system", "developer"):
31973231
if isinstance(msg["content"], list):
31983232
new_blocks = []
3233+
non_message_item_types = ("mcp_approval_response",)
31993234
for block in msg["content"]:
32003235
# chat api: {"type": "text", "text": "..."}
32013236
# responses api: {"type": "input_text", "text": "..."}
@@ -3216,10 +3251,15 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
32163251
new_blocks.append(new_block)
32173252
elif block["type"] in ("input_text", "input_image", "input_file"):
32183253
new_blocks.append(block)
3254+
elif block["type"] in non_message_item_types:
3255+
input_.append(block)
32193256
else:
32203257
pass
32213258
msg["content"] = new_blocks
3222-
input_.append(msg)
3259+
if msg["content"]:
3260+
input_.append(msg)
3261+
else:
3262+
input_.append(msg)
32233263
else:
32243264
input_.append(msg)
32253265

@@ -3366,7 +3406,10 @@ def _construct_lc_result_from_responses_api(
33663406

33673407

33683408
def _convert_responses_chunk_to_generation_chunk(
3369-
chunk: Any, schema: Optional[type[_BM]] = None, metadata: Optional[dict] = None
3409+
chunk: Any,
3410+
schema: Optional[type[_BM]] = None,
3411+
metadata: Optional[dict] = None,
3412+
has_reasoning: bool = False,
33703413
) -> Optional[ChatGenerationChunk]:
33713414
content = []
33723415
tool_call_chunks: list = []
@@ -3429,6 +3472,10 @@ def _convert_responses_chunk_to_generation_chunk(
34293472
"web_search_call",
34303473
"file_search_call",
34313474
"computer_call",
3475+
"code_interpreter_call",
3476+
"mcp_call",
3477+
"mcp_list_tools",
3478+
"mcp_approval_request",
34323479
):
34333480
additional_kwargs["tool_outputs"] = [
34343481
chunk.item.model_dump(exclude_none=True, mode="json")
@@ -3444,9 +3491,11 @@ def _convert_responses_chunk_to_generation_chunk(
34443491
elif chunk.type == "response.refusal.done":
34453492
additional_kwargs["refusal"] = chunk.refusal
34463493
elif chunk.type == "response.output_item.added" and chunk.item.type == "reasoning":
3447-
additional_kwargs["reasoning"] = chunk.item.model_dump(
3448-
exclude_none=True, mode="json"
3449-
)
3494+
if not has_reasoning:
3495+
# Hack until breaking release: store first reasoning item ID.
3496+
additional_kwargs["reasoning"] = chunk.item.model_dump(
3497+
exclude_none=True, mode="json"
3498+
)
34503499
elif chunk.type == "response.reasoning_summary_part.added":
34513500
additional_kwargs["reasoning"] = {
34523501
# langchain-core uses the `index` key to aggregate text blocks.

0 commit comments

Comments
 (0)