Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion haystack/components/generators/chat/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ def _prepare_api_call( # noqa: PLR0913
function_spec = {**t.tool_spec}
if tools_strict:
function_spec["strict"] = True
function_spec["parameters"]["additionalProperties"] = False
function_spec["parameters"] = _make_schema_strict(function_spec["parameters"])
tool_definitions.append({"type": "function", "function": function_spec})
openai_tools = {"tools": tool_definitions}

Expand Down Expand Up @@ -550,6 +550,39 @@ async def _handle_async_stream_response(
return [_convert_streaming_chunks_to_chat_message(chunks=chunks)]


def _make_schema_strict(schema: dict[str, Any]) -> dict[str, Any]:
"""
Recursively transform a JSON schema to be OpenAI strict-mode compliant.
Comment thread
sjrl marked this conversation as resolved.

Sets `additionalProperties: false` on all objects and ensures every defined
property is listed in `required`. Walks into nested properties, `$defs`,
array `items`, and `anyOf`/`oneOf`/`allOf` combinators.

See https://platform.openai.com/docs/guides/structured-outputs#supported-schemas
"""
schema = {**schema}

schema_type = schema.get("type")

if schema_type == "object" or "properties" in schema:
schema["additionalProperties"] = False
if "properties" in schema:
schema["required"] = list(schema["properties"].keys())
schema["properties"] = {k: _make_schema_strict(v) for k, v in schema["properties"].items()}

if "items" in schema:
schema["items"] = _make_schema_strict(schema["items"])

if "$defs" in schema:
schema["$defs"] = {k: _make_schema_strict(v) for k, v in schema["$defs"].items()}

for combinator in ("anyOf", "oneOf", "allOf"):
if combinator in schema:
schema[combinator] = [_make_schema_strict(s) for s in schema[combinator]]

return schema


def _check_finish_reason(meta: dict[str, Any]) -> None:
if meta["finish_reason"] == "length":
logger.warning(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
fixes:
- |
Fixed ``tools_strict=True`` in ``OpenAIChatGenerator`` to recursively apply
``additionalProperties: false`` and ``required`` to all nested objects in tool
parameter schemas. Previously only the top-level object was transformed, causing
OpenAI's strict mode to reject tools with nested parameters.
300 changes: 300 additions & 0 deletions test/components/generators/chat/test_openai.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all of the unit tests, but could we also make one integration test that would have failed before this change? Perhaps take a complicated one like test_complex_schema_with_defs_and_combinators and make an integration test version of it as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a fix where I added test_prepare_api_call_strict_complex_tool; it takes the same schema structure from test_complex_schema_with_defs_and_combinators and puts it thru _prepare_api_call with tools_strict=True

Fails on the old code b/c nested objects dont have additionalProperties: false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right thanks but we should still add an integration test so we can see if the schema is actually accepted by OpenAI. We have other integration tests in this file to see how to structure them to be skipped if an API key is missing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_live_run_strict_nested_tool and test_prepare_api_call_strict_component_tool

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
OpenAIChatGenerator,
_check_finish_reason,
_convert_chat_completion_chunk_to_streaming_chunk,
_make_schema_strict,
)
from haystack.components.generators.utils import print_streaming_chunk
from haystack.dataclasses import (
Expand Down Expand Up @@ -1871,3 +1872,302 @@ def test_convert_usage_chunk_to_streaming_chunk(self):
assert result.tool_call_result is None
assert result.meta["model"] == "gpt-5-mini"
assert result.meta["received_at"] is not None


class TestMakeSchemaStrict:
def test_flat_object(self):
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {"name": {"type": "string"}},
"additionalProperties": False,
"required": ["name"],
}

def test_nested_object(self):
schema = {
"type": "object",
"properties": {
"person": {"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}}
},
}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {
"person": {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
"additionalProperties": False,
"required": ["name", "age"],
}
},
"additionalProperties": False,
"required": ["person"],
}

def test_defs_and_ref(self):
schema = {
"type": "object",
"properties": {"address": {"$ref": "#/$defs/Address"}},
"$defs": {
"Address": {"type": "object", "properties": {"street": {"type": "string"}, "city": {"type": "string"}}}
},
}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {"address": {"$ref": "#/$defs/Address"}},
"$defs": {
"Address": {
"type": "object",
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
"additionalProperties": False,
"required": ["street", "city"],
}
},
"additionalProperties": False,
"required": ["address"],
}

def test_array_items(self):
schema = {
"type": "object",
"properties": {
"people": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}}}}
},
}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {
"people": {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
"additionalProperties": False,
"required": ["name"],
},
}
},
"additionalProperties": False,
"required": ["people"],
}

def test_anyof(self):
schema = {
"type": "object",
"properties": {
"value": {"anyOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "integer"}}}]}
},
}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {
"value": {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {"x": {"type": "integer"}},
"additionalProperties": False,
"required": ["x"],
},
]
}
},
"additionalProperties": False,
"required": ["value"],
}

def test_does_not_mutate_original(self):
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
result = _make_schema_strict(schema)
assert "additionalProperties" not in schema
assert "required" not in schema
assert result == {
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": False,
"required": ["a"],
}

def test_preserves_existing_required(self):
schema = {
"type": "object",
"properties": {"a": {"type": "string"}, "b": {"type": "integer"}},
"required": ["a"],
}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {"a": {"type": "string"}, "b": {"type": "integer"}},
"additionalProperties": False,
"required": ["a", "b"],
}

def test_complex_schema_with_defs_and_combinators(self):
schema = {
"type": "object",
"properties": {
"messages": {"type": "array", "items": {"$ref": "#/$defs/ChatMessage"}},
"config": {
"oneOf": [
{"type": "null"},
{
"type": "object",
"properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}},
},
]
},
},
"$defs": {
"ChatMessage": {
"type": "object",
"properties": {
"role": {"type": "string"},
"content": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"meta": {
"type": "object",
"properties": {
"model": {"type": "string"},
"usage": {
"type": "object",
"properties": {
"prompt_tokens": {"type": "integer"},
"completion_tokens": {"type": "integer"},
},
},
},
},
},
}
},
}
result = _make_schema_strict(schema)
assert result == {
"type": "object",
"properties": {
"messages": {"type": "array", "items": {"$ref": "#/$defs/ChatMessage"}},
"config": {
"oneOf": [
{"type": "null"},
{
"type": "object",
"properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}},
"additionalProperties": False,
"required": ["temperature", "max_tokens"],
},
]
},
},
"$defs": {
"ChatMessage": {
"type": "object",
"properties": {
"role": {"type": "string"},
"content": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"meta": {
"type": "object",
"properties": {
"model": {"type": "string"},
"usage": {
"type": "object",
"properties": {
"prompt_tokens": {"type": "integer"},
"completion_tokens": {"type": "integer"},
},
"additionalProperties": False,
"required": ["prompt_tokens", "completion_tokens"],
},
},
"additionalProperties": False,
"required": ["model", "usage"],
},
},
"additionalProperties": False,
"required": ["role", "content", "meta"],
}
},
"additionalProperties": False,
"required": ["messages", "config"],
}

def test_prepare_api_call_strict_nested_tool(self):
nested_tool = Tool(
name="create_person",
description="Create a person record",
parameters={
"type": "object",
"properties": {
"name": {"type": "string"},
"address": {
"type": "object",
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
},
},
"required": ["name"],
},
function=lambda name, address: f"{name} at {address}",
)

component = OpenAIChatGenerator(api_key=Secret.from_token("test-key"), tools_strict=True)
api_args = component._prepare_api_call(messages=[ChatMessage.from_user("test")], tools=[nested_tool])

tool_def = api_args["tools"][0]["function"]
assert tool_def["strict"] is True
assert tool_def["parameters"] == {
"type": "object",
"properties": {
"name": {"type": "string"},
"address": {
"type": "object",
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
"additionalProperties": False,
"required": ["street", "city"],
},
},
"additionalProperties": False,
"required": ["name", "address"],
}

@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
def test_live_run_strict_nested_tool(self):
tool = Tool(
name="create_person",
description="Create a person record with an address",
parameters={
"type": "object",
"properties": {
"name": {"type": "string", "description": "Full name"},
"address": {
"type": "object",
"properties": {
"street": {"type": "string", "description": "Street address"},
"city": {"type": "string", "description": "City name"},
},
},
},
},
function=lambda name, address: f"{name} at {address}",
)
component = OpenAIChatGenerator(model="gpt-4.1-nano", tools_strict=True)
results = component.run(
messages=[ChatMessage.from_user("Create a person named John at 123 Main St, Springfield")], tools=[tool]
)
assert len(results["replies"]) == 1
message = results["replies"][0]
assert message.tool_calls
tool_call = message.tool_call
assert tool_call.tool_name == "create_person"
assert "name" in tool_call.arguments
assert "address" in tool_call.arguments
assert "street" in tool_call.arguments["address"]
assert "city" in tool_call.arguments["address"]