From 136aed4dc9f2f1ccde13d74a0cd01d7b2c475bb1 Mon Sep 17 00:00:00 2001 From: Open SWE Agent Date: Thu, 5 Mar 2026 19:37:07 +0000 Subject: [PATCH] feat(openai): support tool search and defer_loading for openai responses api --- .../langchain_core/utils/function_calling.py | 13 +- .../langchain_openai/chat_models/base.py | 6 +- .../chat_models/test_responses_api.py | 117 ++++++++++++++++++ .../tests/unit_tests/chat_models/test_base.py | 30 +++++ 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index cda5569f795..7d52fd8ef73 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", ) @@ -570,7 +572,16 @@ def convert_to_openai_tool( oai_tool["format"] = tool.metadata["format"] return oai_tool oai_function = convert_to_openai_function(tool, strict=strict) - return {"type": "function", "function": oai_function} + result: dict[str, Any] = {"type": "function", "function": oai_function} + if ( + isinstance(tool, langchain_core.tools.base.BaseTool) + and hasattr(tool, "extras") + and isinstance(tool.extras, dict) + ): + for key in ("defer_loading",): + if key in tool.extras: + result[key] = tool.extras[key] + return result def convert_to_json_schema( diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 4351bd529b7..d15c8d2ee74 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", ) @@ -3981,7 +3982,10 @@ 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"]}) + new_tool = {"type": "function", **tool["function"]} + if tool.get("defer_loading"): + new_tool["defer_loading"] = tool["defer_loading"] + new_tools.append(new_tool) else: if tool["type"] == "image_generation": # Handle partial images (not yet supported) 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..b6381b3aecc 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 @@ -1267,3 +1267,120 @@ def test_csv_input() -> None: "3" in str(response2.content).lower() or "three" in str(response2.content).lower() ) + + +def test_tool_search() -> None: + """Test tool search with deferred loading via extras.""" + from langchain_core.tools import tool + + @tool(extras={"defer_loading": True}) + def list_open_orders(customer_id: str) -> str: + """List open orders for a customer ID.""" + return f"Orders for {customer_id}: [order_1, order_2]" + + @tool(extras={"defer_loading": True}) + def get_customer_profile(customer_id: str) -> str: + """Fetch a customer profile by customer ID.""" + return f"Profile for {customer_id}" + + llm = ChatOpenAI(model="gpt-4o", use_responses_api=True) + bound = llm.bind_tools( + [list_open_orders, get_customer_profile, {"type": "tool_search"}], + ) + + payload = llm._get_request_payload( + [HumanMessage("List open orders for customer CUST-12345.")], + **bound.kwargs, # type: ignore[attr-defined] + ) + tools = payload["tools"] + orders_tool = next(t for t in tools if t.get("name") == "list_open_orders") + assert orders_tool["defer_loading"] is True + assert orders_tool["type"] == "function" + profile_tool = next(t for t in tools if t.get("name") == "get_customer_profile") + assert profile_tool["defer_loading"] is True + tool_search = next(t for t in tools if t.get("type") == "tool_search") + assert tool_search == {"type": "tool_search"} + + response = bound.invoke("List open orders for customer CUST-12345.") + assert isinstance(response, AIMessage) + assert response.tool_calls + + +def test_tool_search_dict_tools() -> None: + """Test tool search with raw dict tools (Responses API format).""" + llm = ChatOpenAI(model="gpt-4o", use_responses_api=True) + bound = llm.bind_tools( + [ + { + "type": "function", + "name": "list_open_orders", + "description": "List open orders for a customer ID.", + "defer_loading": True, + "parameters": { + "type": "object", + "properties": {"customer_id": {"type": "string"}}, + "required": ["customer_id"], + "additionalProperties": False, + }, + }, + {"type": "tool_search"}, + ], + ) + + payload = llm._get_request_payload( + [HumanMessage("List open orders for customer CUST-12345.")], + **bound.kwargs, # type: ignore[attr-defined] + ) + tools = payload["tools"] + orders_tool = next(t for t in tools if t.get("name") == "list_open_orders") + assert orders_tool["defer_loading"] is True + tool_search = next(t for t in tools if t.get("type") == "tool_search") + assert tool_search == {"type": "tool_search"} + + response = bound.invoke("List open orders for customer CUST-12345.") + assert isinstance(response, AIMessage) + assert response.tool_calls + + +def test_tool_search_with_namespace() -> None: + """Test tool search with namespace tools.""" + llm = ChatOpenAI(model="gpt-4o", use_responses_api=True) + bound = llm.bind_tools( + [ + { + "type": "namespace", + "name": "crm", + "description": "CRM tools for customer lookup and order management.", + "tools": [ + { + "type": "function", + "name": "list_open_orders", + "description": "List open orders for a customer ID.", + "defer_loading": True, + "parameters": { + "type": "object", + "properties": {"customer_id": {"type": "string"}}, + "required": ["customer_id"], + "additionalProperties": False, + }, + }, + ], + }, + {"type": "tool_search"}, + ], + ) + + payload = llm._get_request_payload( + [HumanMessage("List open orders for customer CUST-12345.")], + **bound.kwargs, # type: ignore[attr-defined] + ) + tools = payload["tools"] + ns_tool = next(t for t in tools if t.get("type") == "namespace") + assert ns_tool["name"] == "crm" + assert ns_tool["tools"][0]["defer_loading"] is True + tool_search = next(t for t in tools if t.get("type") == "tool_search") + assert tool_search == {"type": "tool_search"} + + response = bound.invoke("List open orders for customer CUST-12345.") + assert isinstance(response, AIMessage) + assert response.tool_calls 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..71013de42e7 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 @@ -3474,3 +3474,33 @@ 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_payload() -> None: + from langchain_core.tools import tool + + @tool(extras={"defer_loading": True}) + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return "sunny" + + @tool + def get_time(city: str) -> str: + """Get the time for a city.""" + return "12:00" + + llm = ChatOpenAI(model="gpt-4o", api_key=SecretStr("test"), use_responses_api=True) + bound = llm.bind_tools( + [get_weather, get_time, {"type": "tool_search"}], + ) + payload = llm._get_request_payload( + [HumanMessage("What's the weather in NYC?")], + **bound.kwargs, # type: ignore[attr-defined] + ) + tools = payload["tools"] + weather_tool = next(t for t in tools if t.get("name") == "get_weather") + assert weather_tool["defer_loading"] is True + time_tool = next(t for t in tools if t.get("name") == "get_time") + assert "defer_loading" not in time_tool + tool_search = next(t for t in tools if t.get("type") == "tool_search") + assert tool_search == {"type": "tool_search"}