Compare commits

...

2 Commits

Author SHA1 Message Date
Mason Daugherty
c673b9d5c4 Merge branch 'master' into mdrxy/openai-strict-bind_tools 2025-11-27 19:22:03 -05:00
Mason Daugherty
3d0a38cc92 fix(openai): pass strict for response_format in bind_tools() 2025-11-27 19:00:37 -05:00
3 changed files with 40 additions and 18 deletions

View File

@@ -1801,6 +1801,7 @@ class BaseChatOpenAI(BaseChatModel):
Args:
tools: A list of tool definitions to bind to this chat model.
Supports any tool definition handled by
`langchain_core.utils.function_calling.convert_to_openai_tool`.
tool_choice: Which tool to require the model to call. Options are:
@@ -1812,22 +1813,31 @@ class BaseChatOpenAI(BaseChatModel):
- `dict` of the form `{"type": "function", "function": {"name": <<tool_name>>}}`: calls `<<tool_name>>` tool.
- `False` or `None`: no effect, default OpenAI behavior.
strict: If `True`, model output is guaranteed to exactly match the JSON Schema
provided in the tool definition. The input schema will also be validated according to the
provided in the tool definition.
The input schema will also be validated according to the
[supported schemas](https://platform.openai.com/docs/guides/structured-outputs/supported-schemas?api-mode=responses#supported-schemas).
If `False`, input schema will not be validated and model output will not
be validated. If `None`, `strict` argument will not be passed to the model.
be validated.
If `None`, `strict` argument will not be passed to the model.
parallel_tool_calls: Set to `False` to disable parallel tool use.
Defaults to `None` (no specification, which allows parallel tool use).
response_format: Optional schema to format model response. If provided
and the model does not call a tool, the model will generate a
[structured response](https://platform.openai.com/docs/guides/structured-outputs).
response_format: Optional schema to format model response.
If provided and the model **does not** call a tool, the model will
generate a [structured response](https://platform.openai.com/docs/guides/structured-outputs).
kwargs: Any additional parameters are passed directly to `bind`.
""" # 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
]
tool_names = []
for tool in formatted_tools:
if "function" in tool:
@@ -1836,6 +1846,7 @@ class BaseChatOpenAI(BaseChatModel):
tool_names.append(tool["name"])
else:
pass
if tool_choice:
if isinstance(tool_choice, str):
# tool_choice is a tool/function name
@@ -1865,17 +1876,20 @@ class BaseChatOpenAI(BaseChatModel):
kwargs["tool_choice"] = tool_choice
if response_format:
# response_format present when using agents.create_agent's ProviderStrategy
# ---
# ProviderStrategy converts to OpenAI-style format, uses
# response_format
if (
isinstance(response_format, dict)
and response_format.get("type") == "json_schema"
and "schema" in response_format.get("json_schema", {})
):
# compat with langchain.agents.create_agent response_format, which is
# an approximation of OpenAI format
response_format = cast(dict, response_format["json_schema"]["schema"])
kwargs["response_format"] = _convert_to_openai_response_format(
response_format
response_format, strict=strict
)
return super().bind(tools=formatted_tools, **kwargs)
def with_structured_output(

View File

@@ -1147,28 +1147,33 @@ def test_multi_party_conversation() -> None:
assert "Bob" in response.content
class ResponseFormat(BaseModel):
class ResponseFormatPydanticBaseModel(BaseModel):
response: str
explanation: str
class ResponseFormatDict(TypedDict):
class ResponseFormatTypedDict(TypedDict):
response: str
explanation: str
@pytest.mark.parametrize(
"schema", [ResponseFormat, ResponseFormat.model_json_schema(), ResponseFormatDict]
"schema",
[
ResponseFormatPydanticBaseModel,
ResponseFormatPydanticBaseModel.model_json_schema(),
ResponseFormatTypedDict,
],
)
def test_structured_output_and_tools(schema: Any) -> None:
llm = ChatOpenAI(model="gpt-5-nano", verbosity="low").bind_tools(
[GenerateUsername], strict=True, response_format=schema
)
response = llm.invoke("What weighs more, a pound of feathers or a pound of gold?")
if schema == ResponseFormat:
if schema == ResponseFormatPydanticBaseModel:
parsed = response.additional_kwargs["parsed"]
assert isinstance(parsed, ResponseFormat)
assert isinstance(parsed, ResponseFormatPydanticBaseModel)
else:
parsed = json.loads(response.text)
assert isinstance(parsed, dict)
@@ -1190,7 +1195,10 @@ def test_structured_output_and_tools(schema: Any) -> None:
def test_tools_and_structured_output() -> None:
llm = ChatOpenAI(model="gpt-5-nano").with_structured_output(
ResponseFormat, strict=True, include_raw=True, tools=[GenerateUsername]
ResponseFormatPydanticBaseModel,
strict=True,
include_raw=True,
tools=[GenerateUsername],
)
expected_keys = {"raw", "parsing_error", "parsed"}
@@ -1199,7 +1207,7 @@ def test_tools_and_structured_output() -> None:
# Test invoke
## Engage structured output
response = llm.invoke(query)
assert isinstance(response["parsed"], ResponseFormat)
assert isinstance(response["parsed"], ResponseFormatPydanticBaseModel)
## Engage tool calling
response_tools = llm.invoke(tool_query)
ai_msg = response_tools["raw"]

View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10.0, <4.0.0"
resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation == 'PyPy'",
@@ -544,7 +544,7 @@ wheels = [
[[package]]
name = "langchain"
version = "1.0.5"
version = "1.1.0"
source = { editable = "../../langchain_v1" }
dependencies = [
{ name = "langchain-core" },