From fbfe4b812de119ec8e233627d88422727c823c35 Mon Sep 17 00:00:00 2001 From: ccurme Date: Sun, 8 Mar 2026 08:53:13 -0400 Subject: [PATCH] feat(openai): support tool search (#35582) --- .../messages/block_translators/openai.py | 50 ++++ .../langchain_core/utils/function_calling.py | 2 + .../langchain_openai/chat_models/_compat.py | 53 +++- .../langchain_openai/chat_models/base.py | 20 +- .../tests/cassettes/test_agent_loop.yaml.gz | Bin 0 -> 2572 bytes .../test_agent_loop_streaming.yaml.gz | Bin 0 -> 5822 bytes .../test_client_executed_tool_search.yaml.gz | Bin 0 -> 4765 bytes .../tests/cassettes/test_tool_search.yaml.gz | Bin 0 -> 3675 bytes .../test_tool_search_streaming.yaml.gz | Bin 0 -> 6814 bytes libs/partners/openai/tests/conftest.py | 22 ++ .../chat_models/test_responses_api.py | 258 ++++++++++++++++++ .../tests/unit_tests/chat_models/test_base.py | 114 +++++++- libs/partners/openai/uv.lock | 8 +- 13 files changed, 514 insertions(+), 13 deletions(-) create mode 100644 libs/partners/openai/tests/cassettes/test_agent_loop.yaml.gz create mode 100644 libs/partners/openai/tests/cassettes/test_agent_loop_streaming.yaml.gz create mode 100644 libs/partners/openai/tests/cassettes/test_client_executed_tool_search.yaml.gz create mode 100644 libs/partners/openai/tests/cassettes/test_tool_search.yaml.gz create mode 100644 libs/partners/openai/tests/cassettes/test_tool_search_streaming.yaml.gz diff --git a/libs/core/langchain_core/messages/block_translators/openai.py b/libs/core/langchain_core/messages/block_translators/openai.py index 5bcb3a6bfd7..61d4e3c3d0a 100644 --- a/libs/core/langchain_core/messages/block_translators/openai.py +++ b/libs/core/langchain_core/messages/block_translators/openai.py @@ -731,6 +731,11 @@ def _convert_to_v1_from_responses(message: AIMessage) -> list[types.ContentBlock tool_call_block["extras"]["item_id"] = block["id"] if "index" in block: tool_call_block["index"] = f"lc_tc_{block['index']}" + for extra_key in ("status", "namespace"): + if extra_key in block: + if "extras" not in tool_call_block: + tool_call_block["extras"] = {} + tool_call_block["extras"][extra_key] = block[extra_key] yield tool_call_block elif block_type == "web_search_call": @@ -979,6 +984,51 @@ def _convert_to_v1_from_responses(message: AIMessage) -> list[types.ContentBlock mcp_list_tools_result["index"] = f"lc_mltr_{block['index'] + 1}" yield cast("types.ServerToolResult", mcp_list_tools_result) + elif ( + block_type == "tool_search_call" and block.get("execution") == "server" + ): + tool_search_call: dict[str, Any] = { + "type": "server_tool_call", + "name": "tool_search", + "id": block["id"], + "args": block.get("arguments", {}), + } + if "index" in block: + tool_search_call["index"] = f"lc_tsc_{block['index']}" + extras: dict[str, Any] = {} + known = {"type", "id", "arguments", "index"} + for key in block: + if key not in known: + extras[key] = block[key] + if extras: + tool_search_call["extras"] = extras + yield cast("types.ServerToolCall", tool_search_call) + + elif ( + block_type == "tool_search_output" + and block.get("execution") == "server" + ): + tool_search_output: dict[str, Any] = { + "type": "server_tool_result", + "tool_call_id": block["id"], + "output": {"tools": block.get("tools", [])}, + } + status = block.get("status") + if status == "failed": + tool_search_output["status"] = "error" + elif status == "completed": + tool_search_output["status"] = "success" + if "index" in block and isinstance(block["index"], int): + tool_search_output["index"] = f"lc_tso_{block['index']}" + extras_out: dict[str, Any] = {"name": "tool_search"} + known_out = {"type", "id", "status", "tools", "index"} + for key in block: + if key not in known_out: + extras_out[key] = block[key] + if extras_out: + tool_search_output["extras"] = extras_out + yield cast("types.ServerToolResult", tool_search_output) + elif block_type in types.KNOWN_BLOCK_TYPES: yield cast("types.ContentBlock", block) else: diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index cda5569f795..7c91b84ee8a 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -508,6 +508,8 @@ _WellKnownOpenAITools = ( "image_generation", "web_search_preview", "web_search", + "tool_search", + "namespace", ) diff --git a/libs/partners/openai/langchain_openai/chat_models/_compat.py b/libs/partners/openai/langchain_openai/chat_models/_compat.py index 642cafe6e1c..35f0aac554a 100644 --- a/libs/partners/openai/langchain_openai/chat_models/_compat.py +++ b/libs/partners/openai/langchain_openai/chat_models/_compat.py @@ -103,6 +103,8 @@ def _convert_to_v03_ai_message( "mcp_list_tools", "mcp_approval_request", "image_generation_call", + "tool_search_call", + "tool_search_output", ): # Store built-in tool calls in additional_kwargs if "tool_outputs" not in message.additional_kwargs: @@ -420,17 +422,58 @@ def _convert_from_v1_to_responses( new_block["name"] = block["name"] if "extras" in block and "arguments" in block["extras"]: new_block["arguments"] = block["extras"]["arguments"] - if any(key not in block for key in ("name", "arguments")): + if any(key not in new_block for key in ("name", "arguments")): matching_tool_calls = [ call for call in tool_calls if call["id"] == block["id"] ] if matching_tool_calls: tool_call = matching_tool_calls[0] - if "name" not in block: + if "name" not in new_block: new_block["name"] = tool_call["name"] - if "arguments" not in block: - new_block["arguments"] = json.dumps(tool_call["args"]) + if "arguments" not in new_block: + new_block["arguments"] = json.dumps( + tool_call["args"], separators=(",", ":") + ) + if "extras" in block: + for extra_key in ("status", "namespace"): + if extra_key in block["extras"]: + new_block[extra_key] = block["extras"][extra_key] new_content.append(new_block) + + elif block["type"] == "server_tool_call" and block.get("name") == "tool_search": + extras = block.get("extras", {}) + new_block = {"id": block["id"]} + status = extras.get("status") + if status: + new_block["status"] = status + new_block["type"] = "tool_search_call" + if "args" in block: + new_block["arguments"] = block["args"] + execution = extras.get("execution") + if execution: + new_block["execution"] = execution + new_content.append(new_block) + + elif ( + block["type"] == "server_tool_result" + and block.get("extras", {}).get("name") == "tool_search" + ): + extras = block.get("extras", {}) + new_block = {"id": block.get("tool_call_id", "")} + status = block.get("status") + if status == "success": + new_block["status"] = "completed" + elif status == "error": + new_block["status"] = "failed" + elif status: + new_block["status"] = status + new_block["type"] = "tool_search_output" + new_block["execution"] = "server" + output: dict = block.get("output", {}) + if isinstance(output, dict) and "tools" in output: + new_block["tools"] = output["tools"] + new_content.append(new_block) + elif ( is_data_content_block(cast(dict, block)) and block["type"] == "image" @@ -441,7 +484,7 @@ def _convert_from_v1_to_responses( new_block = {"type": "image_generation_call", "result": block["base64"]} for extra_key in ("id", "status"): if extra_key in block: - new_block[extra_key] = block[extra_key] # type: ignore[typeddict-item] + new_block[extra_key] = block[extra_key] # type: ignore[literal-required] elif extra_key in block.get("extras", {}): new_block[extra_key] = block["extras"][extra_key] new_content.append(new_block) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 4351bd529b7..beb62c65578 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -166,6 +166,7 @@ WellKnownTools = ( "code_interpreter", "mcp", "image_generation", + "tool_search", ) @@ -1984,6 +1985,14 @@ class BaseChatOpenAI(BaseChatModel): formatted_tools = [ convert_to_openai_tool(tool, strict=strict) for tool in tools ] + for original, formatted in zip(tools, formatted_tools, strict=False): + if ( + isinstance(original, BaseTool) + and hasattr(original, "extras") + and isinstance(original.extras, dict) + and "defer_loading" in original.extras + ): + formatted["defer_loading"] = original.extras["defer_loading"] tool_names = [] for tool in formatted_tools: if "function" in tool: @@ -3981,7 +3990,8 @@ def _construct_responses_api_payload( # chat api: {"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}, "strict": ...}} # noqa: E501 # responses api: {"type": "function", "name": "...", "description": "...", "parameters": {...}, "strict": ...} # noqa: E501 if tool["type"] == "function" and "function" in tool: - new_tools.append({"type": "function", **tool["function"]}) + extra = {k: v for k, v in tool.items() if k not in ("type", "function")} + new_tools.append({"type": "function", **tool["function"], **extra}) else: if tool["type"] == "image_generation": # Handle partial images (not yet supported) @@ -4308,6 +4318,8 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list: "mcp_call", "mcp_list_tools", "mcp_approval_request", + "tool_search_call", + "tool_search_output", ): input_.append(_pop_index_and_sub_index(block)) elif block_type == "image_generation_call": @@ -4353,7 +4365,7 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list: elif msg["role"] in ("user", "system", "developer"): if isinstance(msg["content"], list): new_blocks = [] - non_message_item_types = ("mcp_approval_response",) + non_message_item_types = ("mcp_approval_response", "tool_search_output") for block in msg["content"]: if block["type"] in ("text", "image_url", "file"): new_blocks.append( @@ -4510,6 +4522,8 @@ def _construct_lc_result_from_responses_api( "mcp_list_tools", "mcp_approval_request", "image_generation_call", + "tool_search_call", + "tool_search_output", ): content_blocks.append(output.model_dump(exclude_none=True, mode="json")) @@ -4719,6 +4733,8 @@ def _convert_responses_chunk_to_generation_chunk( "mcp_list_tools", "mcp_approval_request", "image_generation_call", + "tool_search_call", + "tool_search_output", ): _advance(chunk.output_index) tool_output = chunk.item.model_dump(exclude_none=True, mode="json") diff --git a/libs/partners/openai/tests/cassettes/test_agent_loop.yaml.gz b/libs/partners/openai/tests/cassettes/test_agent_loop.yaml.gz new file mode 100644 index 0000000000000000000000000000000000000000..9440f6b0e6a6fdf40b608eac7bfe011e1c80ee30 GIT binary patch literal 2572 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 literal 0 HcmV?d00001 diff --git a/libs/partners/openai/tests/cassettes/test_tool_search.yaml.gz b/libs/partners/openai/tests/cassettes/test_tool_search.yaml.gz new file mode 100644 index 0000000000000000000000000000000000000000..2498b937c93ea16f51564f6d918549e8a27a2c7c GIT binary patch literal 3675 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 IFbvH90H|m_T>t<8 literal 0 HcmV?d00001 diff --git a/libs/partners/openai/tests/conftest.py b/libs/partners/openai/tests/conftest.py index af85f90c10a..748df9beea0 100644 --- a/libs/partners/openai/tests/conftest.py +++ b/libs/partners/openai/tests/conftest.py @@ -1,3 +1,4 @@ +import json from typing import Any import pytest @@ -30,6 +31,9 @@ def remove_response_headers(response: dict) -> dict: def vcr_config() -> dict: """Extend the default configuration coming from langchain_tests.""" config = base_vcr_config() + config["match_on"] = [ + m if m != "body" else "json_body" for m in config.get("match_on", []) + ] config.setdefault("filter_headers", []).extend(_EXTRA_HEADERS) config["before_record_request"] = remove_request_headers config["before_record_response"] = remove_response_headers @@ -38,6 +42,24 @@ def vcr_config() -> dict: return config +def _json_body_matcher(r1: Any, r2: Any) -> None: + """Match request bodies as parsed JSON, ignoring key order.""" + b1 = r1.body or b"" + b2 = r2.body or b"" + if isinstance(b1, bytes): + b1 = b1.decode("utf-8") + if isinstance(b2, bytes): + b2 = b2.decode("utf-8") + try: + j1 = json.loads(b1) + j2 = json.loads(b2) + except (json.JSONDecodeError, ValueError): + assert b1 == b2, f"body mismatch (non-JSON):\n{b1}\n!=\n{b2}" + return + assert j1 == j2, f"body mismatch:\n{j1}\n!=\n{j2}" + + def pytest_recording_configure(config: dict, vcr: VCR) -> None: vcr.register_persister(CustomPersister()) vcr.register_serializer("yaml.gz", CustomSerializer()) + vcr.register_matcher("json_body", _json_body_matcher) diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py index 56c9e4595f7..98bea67c81a 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py @@ -7,6 +7,13 @@ from typing import Annotated, Any, Literal, cast import openai import pytest +from langchain.agents import create_agent +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ToolCallRequest, + hook_config, +) from langchain_core.messages import ( AIMessage, AIMessageChunk, @@ -14,7 +21,10 @@ from langchain_core.messages import ( BaseMessageChunk, HumanMessage, MessageLikeRepresentation, + ToolMessage, ) +from langchain_core.tools import tool +from langchain_core.utils.function_calling import convert_to_openai_tool from pydantic import BaseModel from typing_extensions import TypedDict @@ -193,6 +203,74 @@ def test_function_calling(output_version: Literal["v0", "responses/v1", "v1"]) - _check_response(response) +@pytest.mark.default_cassette("test_agent_loop.yaml.gz") +@pytest.mark.vcr +@pytest.mark.parametrize("output_version", ["responses/v1", "v1"]) +def test_agent_loop(output_version: Literal["responses/v1", "v1"]) -> None: + @tool + def get_weather(location: str) -> str: + """Get the weather for a location.""" + return "It's sunny." + + llm = ChatOpenAI( + model="gpt-5.4", + use_responses_api=True, + output_version=output_version, + ) + llm_with_tools = llm.bind_tools([get_weather]) + input_message = HumanMessage("What is the weather in San Francisco, CA?") + tool_call_message = llm_with_tools.invoke([input_message]) + assert isinstance(tool_call_message, AIMessage) + tool_calls = tool_call_message.tool_calls + assert len(tool_calls) == 1 + tool_call = tool_calls[0] + tool_message = get_weather.invoke(tool_call) + assert isinstance(tool_message, ToolMessage) + response = llm_with_tools.invoke( + [ + input_message, + tool_call_message, + tool_message, + ] + ) + assert isinstance(response, AIMessage) + + +@pytest.mark.default_cassette("test_agent_loop_streaming.yaml.gz") +@pytest.mark.vcr +@pytest.mark.parametrize("output_version", ["responses/v1", "v1"]) +def test_agent_loop_streaming(output_version: Literal["responses/v1", "v1"]) -> None: + @tool + def get_weather(location: str) -> str: + """Get the weather for a location.""" + return "It's sunny." + + llm = ChatOpenAI( + model="gpt-5.2", + use_responses_api=True, + reasoning={"effort": "medium", "summary": "auto"}, + streaming=True, + output_version=output_version, + ) + llm_with_tools = llm.bind_tools([get_weather]) + input_message = HumanMessage("What is the weather in San Francisco, CA?") + tool_call_message = llm_with_tools.invoke([input_message]) + assert isinstance(tool_call_message, AIMessage) + tool_calls = tool_call_message.tool_calls + assert len(tool_calls) == 1 + tool_call = tool_calls[0] + tool_message = get_weather.invoke(tool_call) + assert isinstance(tool_message, ToolMessage) + response = llm_with_tools.invoke( + [ + input_message, + tool_call_message, + tool_message, + ] + ) + assert isinstance(response, AIMessage) + + class Foo(BaseModel): response: str @@ -1267,3 +1345,183 @@ def test_csv_input() -> None: "3" in str(response2.content).lower() or "three" in str(response2.content).lower() ) + + +@pytest.mark.default_cassette("test_tool_search.yaml.gz") +@pytest.mark.vcr +@pytest.mark.parametrize("output_version", ["responses/v1", "v1"]) +def test_tool_search(output_version: str) -> None: + @tool(extras={"defer_loading": True}) + def get_weather(location: str) -> str: + """Get the current weather for a location.""" + return f"The weather in {location} is sunny and 72°F" + + @tool(extras={"defer_loading": True}) + def get_recipe(query: str) -> None: + """Get a recipe for chicken soup.""" + + model = ChatOpenAI( + model="gpt-5.4", + use_responses_api=True, + output_version=output_version, + ) + + agent = create_agent( + model=model, + tools=[get_weather, get_recipe, {"type": "tool_search"}], + ) + input_message = {"role": "user", "content": "What's the weather in San Francisco?"} + result = agent.invoke({"messages": [input_message]}) + assert len(result["messages"]) == 4 + tool_call_message = result["messages"][1] + assert isinstance(tool_call_message, AIMessage) + assert tool_call_message.tool_calls + if output_version == "v1": + assert [block["type"] for block in tool_call_message.content] == [ # type: ignore[index] + "server_tool_call", + "server_tool_result", + "tool_call", + ] + else: + assert [block["type"] for block in tool_call_message.content] == [ # type: ignore[index] + "tool_search_call", + "tool_search_output", + "function_call", + ] + + assert isinstance(result["messages"][2], ToolMessage) + + assert result["messages"][3].text + + +@pytest.mark.default_cassette("test_tool_search_streaming.yaml.gz") +@pytest.mark.vcr +@pytest.mark.parametrize("output_version", ["responses/v1", "v1"]) +def test_tool_search_streaming(output_version: str) -> None: + @tool(extras={"defer_loading": True}) + def get_weather(location: str) -> str: + """Get the current weather for a location.""" + return f"The weather in {location} is sunny and 72°F" + + @tool(extras={"defer_loading": True}) + def get_recipe(query: str) -> None: + """Get a recipe for chicken soup.""" + + model = ChatOpenAI( + model="gpt-5.4", + use_responses_api=True, + streaming=True, + output_version=output_version, + ) + + agent = create_agent( + model=model, + tools=[get_weather, get_recipe, {"type": "tool_search"}], + ) + input_message = {"role": "user", "content": "What's the weather in San Francisco?"} + result = agent.invoke({"messages": [input_message]}) + assert len(result["messages"]) == 4 + tool_call_message = result["messages"][1] + assert isinstance(tool_call_message, AIMessage) + assert tool_call_message.tool_calls + if output_version == "v1": + assert [block["type"] for block in tool_call_message.content] == [ # type: ignore[index] + "server_tool_call", + "server_tool_result", + "tool_call", + ] + else: + assert [block["type"] for block in tool_call_message.content] == [ # type: ignore[index] + "tool_search_call", + "tool_search_output", + "function_call", + ] + + assert isinstance(result["messages"][2], ToolMessage) + + assert result["messages"][3].text + + +@pytest.mark.vcr +def test_client_executed_tool_search() -> None: + @tool + def get_weather(location: str) -> str: + """Get the current weather for a location.""" + return f"The weather in {location} is sunny and 72°F" + + def search_tools(goal: str) -> list[dict]: + """Search for available tools to help answer the question.""" + return [ + { + "type": "function", + "defer_loading": True, + **convert_to_openai_tool(get_weather)["function"], + } + ] + + tool_search_schema = convert_to_openai_tool(search_tools, strict=True) + tool_search_config: dict = { + "type": "tool_search", + "execution": "client", + "description": tool_search_schema["function"]["description"], + "parameters": tool_search_schema["function"]["parameters"], + } + + class ClientToolSearchMiddleware(AgentMiddleware): + @hook_config(can_jump_to=["model"]) + def after_model(self, state: AgentState, runtime: Any) -> dict[str, Any] | None: + last_message = state["messages"][-1] + if not isinstance(last_message, AIMessage): + return None + for block in last_message.content: + if isinstance(block, dict) and block.get("type") == "tool_search_call": + call_id = block.get("call_id") + args = block.get("arguments", {}) + goal = args.get("goal", "") if isinstance(args, dict) else "" + loaded_tools = search_tools(goal) + tool_search_output = { + "type": "tool_search_output", + "execution": "client", + "call_id": call_id, + "status": "completed", + "tools": loaded_tools, + } + return { + "messages": [HumanMessage(content=[tool_search_output])], + "jump_to": "model", + } + return None + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Any, + ) -> Any: + if request.tool_call["name"] == "get_weather": + return handler(request.override(tool=get_weather)) + return handler(request) + + llm = ChatOpenAI(model="gpt-5.4", use_responses_api=True) + + agent = create_agent( + model=llm, + tools=[tool_search_config], + middleware=[ClientToolSearchMiddleware()], + ) + + result = agent.invoke( + {"messages": [HumanMessage("What's the weather in San Francisco?")]} + ) + messages = result["messages"] + search_tool_call = messages[1] + assert search_tool_call.content[0]["type"] == "tool_search_call" + + search_tool_output = messages[2] + assert search_tool_output.content[0]["type"] == "tool_search_output" + + tool_call = messages[3] + assert tool_call.tool_calls + + assert isinstance(messages[4], ToolMessage) + + assert messages[5].text diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index 0f2e6ec7129..a7df5fce6ad 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -2787,13 +2787,13 @@ def test_convert_from_v1_to_chat_completions( "type": "function_call", "call_id": "call_123", "name": "get_weather", - "arguments": '{"location": "San Francisco"}', + "arguments": '{"location":"San Francisco"}', }, { "type": "function_call", "call_id": "call_234", "name": "get_weather_2", - "arguments": '{"location": "New York"}', + "arguments": '{"location":"New York"}', "id": "fc_123", }, {"type": "text", "text": "Hello "}, @@ -3474,3 +3474,113 @@ def test_context_overflow_error_backwards_compatibility() -> None: # Verify it's both types (multiple inheritance) assert isinstance(exc_info.value, openai.BadRequestError) assert isinstance(exc_info.value, ContextOverflowError) + + +def test_tool_search_passthrough() -> None: + """Test that tool_search dict is passed through as a built-in tool.""" + llm = ChatOpenAI(model="gpt-4o") + tool_search = {"type": "tool_search"} + bound = llm.bind_tools([tool_search]) + payload = bound._get_request_payload( # type: ignore[attr-defined] + "test", + **bound.kwargs, # type: ignore[attr-defined] + ) + assert {"type": "tool_search"} in payload["tools"] + assert "input" in payload + + +def test_tool_search_with_defer_loading_extras() -> None: + """Test that defer_loading from BaseTool extras is merged into tool defs.""" + from langchain_core.tools import tool + + @tool(extras={"defer_loading": True}) + def get_weather(location: str) -> str: + """Get weather for a location.""" + return f"Weather in {location}" + + llm = ChatOpenAI(model="gpt-4o") + bound = llm.bind_tools([get_weather, {"type": "tool_search"}]) + payload = bound._get_request_payload( # type: ignore[attr-defined] + "test", + **bound.kwargs, # type: ignore[attr-defined] + ) + weather_tool = None + for t in payload["tools"]: + if t.get("type") == "function" and t.get("name") == "get_weather": + weather_tool = t + break + assert weather_tool is not None + assert weather_tool["defer_loading"] is True + assert {"type": "tool_search"} in payload["tools"] + + +def test_namespace_passthrough() -> None: + """Test that namespace tool dicts are passed through unchanged.""" + llm = ChatOpenAI(model="gpt-4o") + namespace_tool = { + "type": "namespace", + "name": "crm", + "description": "CRM tools.", + "tools": [ + { + "type": "function", + "name": "list_orders", + "description": "List orders.", + "defer_loading": True, + "parameters": { + "type": "object", + "properties": {"customer_id": {"type": "string"}}, + "required": ["customer_id"], + }, + } + ], + } + bound = llm.bind_tools([namespace_tool, {"type": "tool_search"}]) + payload = bound._get_request_payload( # type: ignore[attr-defined] + "test", + **bound.kwargs, # type: ignore[attr-defined] + ) + ns = None + for t in payload["tools"]: + if t.get("type") == "namespace": + ns = t + break + assert ns is not None + assert ns["name"] == "crm" + assert ns["tools"][0]["defer_loading"] is True + assert {"type": "tool_search"} in payload["tools"] + + +def test_defer_loading_in_responses_api_payload() -> None: + """Test that defer_loading is preserved in Responses API tool format.""" + from langchain_openai.chat_models.base import _construct_responses_api_payload + + messages: list = [] + payload = { + "model": "gpt-4o", + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + "defer_loading": True, + }, + {"type": "tool_search"}, + ], + } + result = _construct_responses_api_payload(messages, payload) + weather_tool = None + for t in result["tools"]: + if t.get("name") == "get_weather": + weather_tool = t + break + assert weather_tool is not None + assert weather_tool["defer_loading"] is True + assert weather_tool["type"] == "function" + assert {"type": "tool_search"} in result["tools"] diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index d203c59d3d8..f71847bbbe7 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10.0, <4.0.0" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -1105,7 +1105,7 @@ wheels = [ [[package]] name = "openai" -version = "2.21.0" +version = "2.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1117,9 +1117,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/91/2a06c4e9597c338cac1e5e5a8dd6f29e1836fc229c4c523529dca387fda8/openai-2.26.0.tar.gz", hash = "sha256:b41f37c140ae0034a6e92b0c509376d907f3a66109935fba2c1b471a7c05a8fb", size = 666702, upload-time = "2026-03-05T23:17:35.874Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2e/3f73e8ca53718952222cacd0cf7eecc9db439d020f0c1fe7ae717e4e199a/openai-2.26.0-py3-none-any.whl", hash = "sha256:6151bf8f83802f036117f06cc8a57b3a4da60da9926826cc96747888b57f394f", size = 1136409, upload-time = "2026-03-05T23:17:34.072Z" }, ] [[package]]