fix(langchain,openai): only set strict=True on tools for OpenAI-compatible models in ProviderStrategy (#38370)

When using `ProviderStrategy`, `create_agent` unnecessarily sets
`strict=True` on tools for all providers. This is only needed for OpenAI
/ chat completions. Here we unset `strict`. For OpenAI:
1. We set it in `BaseChatOpenAI.bind_tools` (as a convenience to users
calling `model.bind_tools` directly)
2. We (redundantly) special-case OpenAI in the `create_agent` factory
logic so that things will not break for users who upgrade `langchain`
but not `langchain-openai`.

Note: payloads for OpenAI are tested here and appear unchanged:
https://github.com/langchain-ai/langchain/blob/master/libs/langchain_v1/tests/unit_tests/agents/test_response_format_integration.py

Quick test:
```python
from langchain.agents import create_agent
from langchain.agents.structured_output import ProviderStrategy
from pydantic import BaseModel

class Weather(BaseModel):
    temperature: float
    condition: str

def weather_tool(location: str) -> str:
    """Get the weather at a location."""
    return "Sunny and 75 degrees F."

for model in [
    "anthropic:claude-sonnet-4-6",
    "openai:gpt-5.4",
    "google_genai:gemini-3.5-flash",
]:

    agent = create_agent(
        model=model,
        tools=[weather_tool],
        response_format=ProviderStrategy(Weather),
    )
    
    result = agent.invoke({
        "messages": [{"role": "user", "content": "What's the weather in SF?"}]
    })
    
    print(result["structured_response"])
```
This commit is contained in:
ccurme
2026-06-22 18:11:55 -04:00
committed by GitHub
parent 05b5af17dc
commit 9ef324c9ab
5 changed files with 66 additions and 3 deletions

View File

@@ -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,
)

View File

@@ -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
]

View File

@@ -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()]
)