diff --git a/libs/langchain_v1/langchain/agents/factory.py b/libs/langchain_v1/langchain/agents/factory.py index be891cfafd4..679f207a0d0 100644 --- a/libs/langchain_v1/langchain/agents/factory.py +++ b/libs/langchain_v1/langchain/agents/factory.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import importlib import itertools import re from dataclasses import dataclass, field, fields @@ -572,6 +573,26 @@ def _supports_provider_strategy( ) +def _is_openai_compatible_model(model: BaseChatModel) -> bool: + """Check if a model inherits from `BaseChatOpenAI`. + + Used to redundantly set `strict=True` on tools when `response_format` is + provided, as older versions of `langchain-openai` do not auto-set it. + Covers `ChatOpenAI`, `ChatDeepSeek`, `ChatXAI`, etc. + + Args: + model: The chat model to check. + + Returns: + `True` if the model inherits from `BaseChatOpenAI`, `False` otherwise. + """ + try: + base_chat_openai = importlib.import_module("langchain_openai.chat_models.base") + except ImportError: + return False + return isinstance(model, base_chat_openai.BaseChatOpenAI) + + def _handle_structured_output_error( exception: Exception, response_format: ResponseFormat[Any], @@ -1334,11 +1355,16 @@ def create_agent( # Bind model based on effective response format if isinstance(effective_response_format, ProviderStrategy): # (Backward compatibility) Use OpenAI format structured output + # Redundantly set strict=True on tools for OpenAI-compatible models, as older + # versions of langchain-openai do not auto-set it in bind_tools. kwargs = effective_response_format.to_model_kwargs() + bind_kwargs: dict[str, Any] = {**kwargs, **request.model_settings} + if _is_openai_compatible_model(request.model) and not getattr( + request.model, "use_responses_api", False + ): + bind_kwargs["strict"] = True return ( - request.model.bind_tools( - final_tools, strict=True, **kwargs, **request.model_settings - ), + request.model.bind_tools(final_tools, **bind_kwargs), effective_response_format, ) diff --git a/libs/langchain_v1/tests/cassettes/test_inference_to_native_output[True].yaml.gz b/libs/langchain_v1/tests/cassettes/test_inference_to_native_output[True].yaml.gz index f2a20a6afa0..c1e97be9f0b 100644 Binary files a/libs/langchain_v1/tests/cassettes/test_inference_to_native_output[True].yaml.gz and b/libs/langchain_v1/tests/cassettes/test_inference_to_native_output[True].yaml.gz differ diff --git a/libs/langchain_v1/tests/cassettes/test_strict_mode[True].yaml.gz b/libs/langchain_v1/tests/cassettes/test_strict_mode[True].yaml.gz index dbe8be819c7..bf7509001ee 100644 Binary files a/libs/langchain_v1/tests/cassettes/test_strict_mode[True].yaml.gz and b/libs/langchain_v1/tests/cassettes/test_strict_mode[True].yaml.gz differ diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index af5f49abfd0..ff72f66ed30 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -2193,6 +2193,12 @@ class BaseChatOpenAI(BaseChatModel): """ # noqa: E501 if parallel_tool_calls is not None: kwargs["parallel_tool_calls"] = parallel_tool_calls + # When response_format is provided via the Chat Completions API, OpenAI + # requires all function tools to be strict. Default strict=True unless + # the caller explicitly passed strict=False. The Responses API does not + # require this. + if response_format and strict is not False and not self.use_responses_api: + strict = True formatted_tools = [ convert_to_openai_tool(tool, strict=strict) for tool in tools ] 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 92af400e982..594346ef46f 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 @@ -943,6 +943,37 @@ def test_bind_tools_tool_choice(tool_choice: Any, strict: bool | None) -> None: ) +def test_bind_tools_response_format_defaults_strict() -> None: + """Test that strict defaults to True when response_format is provided.""" + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0) + bound = llm.bind_tools( + tools=[GenerateUsername], + response_format=MakeASandwich, + ) + tools = bound.kwargs["tools"] # type: ignore[attr-defined] + assert tools[0]["function"]["strict"] is True + + +def test_bind_tools_response_format_respects_strict_false() -> None: + """Test that strict=False is respected even when response_format is provided.""" + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0) + bound = llm.bind_tools( + tools=[GenerateUsername], + response_format=MakeASandwich, + strict=False, + ) + tools = bound.kwargs["tools"] # type: ignore[attr-defined] + assert tools[0]["function"]["strict"] is False + + +def test_bind_tools_no_response_format_keeps_strict_none() -> None: + """Test that strict stays None when response_format is not provided.""" + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0) + bound = llm.bind_tools(tools=[GenerateUsername]) + tools = bound.kwargs["tools"] # type: ignore[attr-defined] + assert "strict" not in tools[0]["function"] + + @pytest.mark.parametrize( "schema", [GenerateUsername, GenerateUsername.model_json_schema()] )