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/core/uv.lock b/libs/core/uv.lock index 8dad83ef38f..612bb812126 100644 --- a/libs/core/uv.lock +++ b/libs/core/uv.lock @@ -992,7 +992,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.16" +version = "1.2.17" source = { editable = "." } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 4351bd529b7..caa98740d5d 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -166,8 +166,15 @@ WellKnownTools = ( "code_interpreter", "mcp", "image_generation", + "tool_search", + "namespace", ) +_OPENAI_EXTRA_FIELDS: set[str] = { + "defer_loading", +} +"""Valid OpenAI-specific extra fields that are promoted from BaseTool.extras.""" + def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: """Convert a dictionary to a LangChain message. @@ -1981,9 +1988,18 @@ class BaseChatOpenAI(BaseChatModel): """ # noqa: E501 if parallel_tool_calls is not None: kwargs["parallel_tool_calls"] = parallel_tool_calls - formatted_tools = [ - convert_to_openai_tool(tool, strict=strict) for tool in tools - ] + formatted_tools = [] + for tool in tools: + formatted = convert_to_openai_tool(tool, strict=strict) + if ( + isinstance(tool, BaseTool) + and hasattr(tool, "extras") + and isinstance(tool.extras, dict) + ): + for key, value in tool.extras.items(): + if key in _OPENAI_EXTRA_FIELDS: + formatted[key] = value + formatted_tools.append(formatted) tool_names = [] for tool in formatted_tools: if "function" in tool: @@ -3981,7 +3997,11 @@ 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"]}) + flattened = {"type": "function", **tool["function"]} + for key in _OPENAI_EXTRA_FIELDS: + if key in tool: + flattened[key] = tool[key] + new_tools.append(flattened) 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..052f3e8ba3c 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,60 @@ 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 defer_loading via extras on BaseTool.""" + from langchain_core.tools import tool + + @tool(extras={"defer_loading": True}) + def get_weather(location: str) -> str: + """Get the current weather for a location.""" + return f"Sunny in {location}" + + @tool(extras={"defer_loading": True}) + def get_population(city: str) -> str: + """Get the population of a city.""" + return f"Population of {city}: 1,000,000" + + llm = ChatOpenAI(model="gpt-4.1-mini") + bound = llm.bind_tools( + [get_weather, get_population, {"type": "tool_search"}], + parallel_tool_calls=False, + ) + response = cast(AIMessage, bound.invoke("What's the weather in San Francisco?")) + assert response.tool_calls + assert response.tool_calls[0]["name"] == "get_weather" + + +def test_tool_search_with_namespace() -> None: + """Test tool search with namespace and defer_loading.""" + weather_ns = { + "type": "namespace", + "name": "weather", + "description": "Weather tools for looking up current conditions.", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a location.", + "defer_loading": True, + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"}, + }, + "required": ["location"], + "additionalProperties": False, + }, + } + ], + } + llm = ChatOpenAI(model="gpt-4.1-mini") + bound = llm.bind_tools( + [weather_ns, {"type": "tool_search"}], + parallel_tool_calls=False, + ) + response = cast(AIMessage, bound.invoke("What's the weather in San Francisco?")) + assert response.tool_calls + assert response.tool_calls[0]["name"] == "get_weather" diff --git a/libs/partners/openai/tests/unit_tests/test_tools.py b/libs/partners/openai/tests/unit_tests/test_tools.py index 07a3b86d412..eabd95ff36f 100644 --- a/libs/partners/openai/tests/unit_tests/test_tools.py +++ b/libs/partners/openai/tests/unit_tests/test_tools.py @@ -1,5 +1,5 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -from langchain_core.tools import Tool +from langchain_core.tools import Tool, tool from langchain_openai import ChatOpenAI, custom_tool @@ -96,6 +96,70 @@ def test_custom_tool() -> None: assert payload["input"] == expected_input +def test_extras_with_defer_loading() -> None: + @tool(extras={"defer_loading": True}) + def get_weather(location: str) -> str: + """Get the current weather for a location.""" + return f"Sunny in {location}" + + llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True) + bound = llm.bind_tools( + [get_weather, {"type": "tool_search"}], + ) + tools = bound.kwargs["tools"] # type: ignore[attr-defined] + func_tool = next(t for t in tools if t["type"] == "function") + assert func_tool["defer_loading"] is True + assert func_tool["function"]["name"] == "get_weather" + assert any(t["type"] == "tool_search" for t in tools) + + from langchain_openai.chat_models.base import _construct_responses_api_payload + + payload = _construct_responses_api_payload( + [HumanMessage("hello")], + {"model": "gpt-4.1", "stream": False, "tools": list(tools)}, + ) + resp_tools = payload["tools"] + resp_func = next(t for t in resp_tools if t["type"] == "function") + assert resp_func["defer_loading"] is True + assert resp_func["name"] == "get_weather" + assert any(t["type"] == "tool_search" for t in resp_tools) + + +def test_tool_search_dict_passthrough() -> None: + llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True) + tool_search = {"type": "tool_search"} + bound = llm.bind_tools([tool_search]) + tools = bound.kwargs["tools"] # type: ignore[attr-defined] + assert any(t["type"] == "tool_search" for t in tools) + + +def test_namespace_dict_passthrough() -> None: + llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True) + ns = { + "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"], + "additionalProperties": False, + }, + } + ], + } + bound = llm.bind_tools([ns, {"type": "tool_search"}]) + tools = bound.kwargs["tools"] # type: ignore[attr-defined] + assert any(t["type"] == "namespace" for t in tools) + assert any(t["type"] == "tool_search" for t in tools) + + async def test_async_custom_tool() -> None: @custom_tool async def my_async_tool(x: str) -> str: diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index 83c3081a9bb..64e24ead33b 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -610,7 +610,7 @@ typing = [ [[package]] name = "langchain-core" -version = "1.2.16" +version = "1.2.17" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" },