From d0b225ef76d8fddf0b5b6239dbd043646b52ec3e Mon Sep 17 00:00:00 2001 From: Arkadeep Dutta Date: Sat, 2 May 2026 04:25:18 -0500 Subject: [PATCH 1/6] fix: recursively apply strict schema constraints for tools_strict=True The existing code only set additionalProperties: false at the top level of tool parameter schemas. OpenAI strict mode requires it recursively on all nested objects, $defs, array items, and anyOf/oneOf/allOf branches. Add _make_schema_strict() that walks the schema recursively and sets additionalProperties: false + required on every object. Replace the single-line top-level-only fix in _prepare_api_call. 10 new tests including a 4-level-deep ComponentTool-style schema. Closes #9411 --- haystack/components/generators/chat/openai.py | 33 ++- .../components/generators/chat/test_openai.py | 223 ++++++++++++++++++ 2 files changed, 255 insertions(+), 1 deletion(-) diff --git a/haystack/components/generators/chat/openai.py b/haystack/components/generators/chat/openai.py index 42de35a4a8..0181f16f93 100644 --- a/haystack/components/generators/chat/openai.py +++ b/haystack/components/generators/chat/openai.py @@ -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} @@ -550,6 +550,37 @@ 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. + + 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. + """ + 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( diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index cd26717e9d..39cabf226a 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -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 ( @@ -1871,3 +1872,225 @@ 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["additionalProperties"] is False + assert result["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["additionalProperties"] is False + assert result["required"] == ["person"] + nested = result["properties"]["person"] + assert nested["additionalProperties"] is False + assert sorted(nested["required"]) == ["age", "name"] + + 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) + addr_def = result["$defs"]["Address"] + assert addr_def["additionalProperties"] is False + assert sorted(addr_def["required"]) == ["city", "street"] + + def test_array_items(self): + schema = { + "type": "object", + "properties": { + "people": { + "type": "array", + "items": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + } + }, + } + result = _make_schema_strict(schema) + items = result["properties"]["people"]["items"] + assert items["additionalProperties"] is False + assert items["required"] == ["name"] + + def test_anyof(self): + schema = { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "string"}, + {"type": "object", "properties": {"x": {"type": "integer"}}}, + ] + } + }, + } + result = _make_schema_strict(schema) + obj_branch = result["properties"]["value"]["anyOf"][1] + assert obj_branch["additionalProperties"] is False + assert obj_branch["required"] == ["x"] + + 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["additionalProperties"] is False + + def test_preserves_existing_required(self): + schema = { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "integer"}}, + "required": ["a"], + } + result = _make_schema_strict(schema) + assert sorted(result["required"]) == ["a", "b"] + + def test_oneof(self): + schema = { + "type": "object", + "properties": { + "value": { + "oneOf": [ + {"type": "string"}, + {"type": "object", "properties": {"x": {"type": "integer"}}}, + ] + } + }, + } + result = _make_schema_strict(schema) + obj_branch = result["properties"]["value"]["oneOf"][1] + assert obj_branch["additionalProperties"] is False + assert obj_branch["required"] == ["x"] + + def test_complex_schema_with_defs_and_combinators(self): + """Simulate a ComponentTool schema with ChatMessage-like nested types, $defs, $ref, and combinators.""" + 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["additionalProperties"] is False + assert sorted(result["required"]) == ["config", "messages"] + + cm = result["$defs"]["ChatMessage"] + assert cm["additionalProperties"] is False + assert sorted(cm["required"]) == ["content", "meta", "role"] + + meta = cm["properties"]["meta"] + assert meta["additionalProperties"] is False + assert sorted(meta["required"]) == ["model", "usage"] + + usage = meta["properties"]["usage"] + assert usage["additionalProperties"] is False + assert sorted(usage["required"]) == ["completion_tokens", "prompt_tokens"] + + config_oneof = result["properties"]["config"]["oneOf"] + assert config_oneof[1]["additionalProperties"] is False + assert sorted(config_oneof[1]["required"]) == ["max_tokens", "temperature"] + + def test_prepare_api_call_strict_nested_tool(self): + """Verify _prepare_api_call applies recursive strict-ification to nested tool schemas.""" + 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"]["additionalProperties"] is False + assert sorted(tool_def["parameters"]["required"]) == ["address", "name"] + + addr = tool_def["parameters"]["properties"]["address"] + assert addr["additionalProperties"] is False + assert sorted(addr["required"]) == ["city", "street"] From a8b85a7205c34bd853fa3e049607d0e65bb27db3 Mon Sep 17 00:00:00 2001 From: Arkadeep Dutta Date: Mon, 4 May 2026 06:47:08 -0500 Subject: [PATCH 2/6] style: ruff format test file and add release note --- ...ict-recursive-schema-a1b2c3d4e5f6g7h8.yaml | 7 ++ .../components/generators/chat/test_openai.py | 65 +++---------------- 2 files changed, 17 insertions(+), 55 deletions(-) create mode 100644 releasenotes/notes/fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml diff --git a/releasenotes/notes/fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml b/releasenotes/notes/fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml new file mode 100644 index 0000000000..550034730b --- /dev/null +++ b/releasenotes/notes/fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml @@ -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. diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index 39cabf226a..549bfa7080 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -1885,13 +1885,7 @@ def test_nested_object(self): schema = { "type": "object", "properties": { - "person": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"}, - }, - } + "person": {"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}} }, } result = _make_schema_strict(schema) @@ -1906,13 +1900,7 @@ def test_defs_and_ref(self): "type": "object", "properties": {"address": {"$ref": "#/$defs/Address"}}, "$defs": { - "Address": { - "type": "object", - "properties": { - "street": {"type": "string"}, - "city": {"type": "string"}, - }, - } + "Address": {"type": "object", "properties": {"street": {"type": "string"}, "city": {"type": "string"}}} }, } result = _make_schema_strict(schema) @@ -1924,13 +1912,7 @@ def test_array_items(self): schema = { "type": "object", "properties": { - "people": { - "type": "array", - "items": { - "type": "object", - "properties": {"name": {"type": "string"}}, - }, - } + "people": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}}}} }, } result = _make_schema_strict(schema) @@ -1942,12 +1924,7 @@ def test_anyof(self): schema = { "type": "object", "properties": { - "value": { - "anyOf": [ - {"type": "string"}, - {"type": "object", "properties": {"x": {"type": "integer"}}}, - ] - } + "value": {"anyOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "integer"}}}]} }, } result = _make_schema_strict(schema) @@ -1975,12 +1952,7 @@ def test_oneof(self): schema = { "type": "object", "properties": { - "value": { - "oneOf": [ - {"type": "string"}, - {"type": "object", "properties": {"x": {"type": "integer"}}}, - ] - } + "value": {"oneOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "integer"}}}]} }, } result = _make_schema_strict(schema) @@ -1993,19 +1965,13 @@ def test_complex_schema_with_defs_and_combinators(self): schema = { "type": "object", "properties": { - "messages": { - "type": "array", - "items": {"$ref": "#/$defs/ChatMessage"}, - }, + "messages": {"type": "array", "items": {"$ref": "#/$defs/ChatMessage"}}, "config": { "oneOf": [ {"type": "null"}, { "type": "object", - "properties": { - "temperature": {"type": "number"}, - "max_tokens": {"type": "integer"}, - }, + "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}}, }, ] }, @@ -2015,12 +1981,7 @@ def test_complex_schema_with_defs_and_combinators(self): "type": "object", "properties": { "role": {"type": "string"}, - "content": { - "anyOf": [ - {"type": "string"}, - {"type": "null"}, - ] - }, + "content": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "meta": { "type": "object", "properties": { @@ -2069,10 +2030,7 @@ def test_prepare_api_call_strict_nested_tool(self): "name": {"type": "string"}, "address": { "type": "object", - "properties": { - "street": {"type": "string"}, - "city": {"type": "string"}, - }, + "properties": {"street": {"type": "string"}, "city": {"type": "string"}}, }, }, "required": ["name"], @@ -2081,10 +2039,7 @@ def test_prepare_api_call_strict_nested_tool(self): ) 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], - ) + 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 From 3fc29c4e1411816c68d197158ccbb0f6c257eeb4 Mon Sep 17 00:00:00 2001 From: Arkadeep Dutta Date: Mon, 4 May 2026 08:52:52 -0500 Subject: [PATCH 3/6] rename release note to fix reno UID collision --- ...ml => fix-tools-strict-recursive-schema-2225caf529c2b3da.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename releasenotes/notes/{fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml => fix-tools-strict-recursive-schema-2225caf529c2b3da.yaml} (100%) diff --git a/releasenotes/notes/fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml b/releasenotes/notes/fix-tools-strict-recursive-schema-2225caf529c2b3da.yaml similarity index 100% rename from releasenotes/notes/fix-tools-strict-recursive-schema-a1b2c3d4e5f6g7h8.yaml rename to releasenotes/notes/fix-tools-strict-recursive-schema-2225caf529c2b3da.yaml From ae196c7c1b71692ffe96a0898d9e323fa30d8d25 Mon Sep 17 00:00:00 2001 From: Arkadeep Dutta Date: Mon, 4 May 2026 12:26:45 -0500 Subject: [PATCH 4/6] address review feedback: single backticks, full dict assertions, integration test - single backticks in docstring instead of double - link to OpenAI structured outputs docs - all unit tests use full dict comparison instead of checking individual keys - added integration test with complex schema through _prepare_api_call --- haystack/components/generators/chat/openai.py | 8 +- .../components/generators/chat/test_openai.py | 300 +++++++++++++++--- 2 files changed, 259 insertions(+), 49 deletions(-) diff --git a/haystack/components/generators/chat/openai.py b/haystack/components/generators/chat/openai.py index 0181f16f93..db2addec36 100644 --- a/haystack/components/generators/chat/openai.py +++ b/haystack/components/generators/chat/openai.py @@ -554,9 +554,11 @@ def _make_schema_strict(schema: dict[str, Any]) -> dict[str, Any]: """ Recursively transform a JSON schema to be OpenAI strict-mode compliant. - 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. + 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} diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index 549bfa7080..960a339ef8 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -1878,8 +1878,12 @@ class TestMakeSchemaStrict: def test_flat_object(self): schema = {"type": "object", "properties": {"name": {"type": "string"}}} result = _make_schema_strict(schema) - assert result["additionalProperties"] is False - assert result["required"] == ["name"] + assert result == { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": False, + "required": ["name"], + } def test_nested_object(self): schema = { @@ -1889,11 +1893,19 @@ def test_nested_object(self): }, } result = _make_schema_strict(schema) - assert result["additionalProperties"] is False - assert result["required"] == ["person"] - nested = result["properties"]["person"] - assert nested["additionalProperties"] is False - assert sorted(nested["required"]) == ["age", "name"] + 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 = { @@ -1904,9 +1916,20 @@ def test_defs_and_ref(self): }, } result = _make_schema_strict(schema) - addr_def = result["$defs"]["Address"] - assert addr_def["additionalProperties"] is False - assert sorted(addr_def["required"]) == ["city", "street"] + 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 = { @@ -1916,9 +1939,22 @@ def test_array_items(self): }, } result = _make_schema_strict(schema) - items = result["properties"]["people"]["items"] - assert items["additionalProperties"] is False - assert items["required"] == ["name"] + 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 = { @@ -1928,16 +1964,36 @@ def test_anyof(self): }, } result = _make_schema_strict(schema) - obj_branch = result["properties"]["value"]["anyOf"][1] - assert obj_branch["additionalProperties"] is False - assert obj_branch["required"] == ["x"] + 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["additionalProperties"] is False + assert result == { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": False, + "required": ["a"], + } def test_preserves_existing_required(self): schema = { @@ -1946,7 +2002,12 @@ def test_preserves_existing_required(self): "required": ["a"], } result = _make_schema_strict(schema) - assert sorted(result["required"]) == ["a", "b"] + assert result == { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "integer"}}, + "additionalProperties": False, + "required": ["a", "b"], + } def test_oneof(self): schema = { @@ -1956,12 +2017,26 @@ def test_oneof(self): }, } result = _make_schema_strict(schema) - obj_branch = result["properties"]["value"]["oneOf"][1] - assert obj_branch["additionalProperties"] is False - assert obj_branch["required"] == ["x"] + assert result == { + "type": "object", + "properties": { + "value": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": {"x": {"type": "integer"}}, + "additionalProperties": False, + "required": ["x"], + }, + ] + } + }, + "additionalProperties": False, + "required": ["value"], + } def test_complex_schema_with_defs_and_combinators(self): - """Simulate a ComponentTool schema with ChatMessage-like nested types, $defs, $ref, and combinators.""" schema = { "type": "object", "properties": { @@ -2000,27 +2075,55 @@ def test_complex_schema_with_defs_and_combinators(self): }, } result = _make_schema_strict(schema) - assert result["additionalProperties"] is False - assert sorted(result["required"]) == ["config", "messages"] - - cm = result["$defs"]["ChatMessage"] - assert cm["additionalProperties"] is False - assert sorted(cm["required"]) == ["content", "meta", "role"] - - meta = cm["properties"]["meta"] - assert meta["additionalProperties"] is False - assert sorted(meta["required"]) == ["model", "usage"] - - usage = meta["properties"]["usage"] - assert usage["additionalProperties"] is False - assert sorted(usage["required"]) == ["completion_tokens", "prompt_tokens"] - - config_oneof = result["properties"]["config"]["oneOf"] - assert config_oneof[1]["additionalProperties"] is False - assert sorted(config_oneof[1]["required"]) == ["max_tokens", "temperature"] + 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): - """Verify _prepare_api_call applies recursive strict-ification to nested tool schemas.""" nested_tool = Tool( name="create_person", description="Create a person record", @@ -2043,9 +2146,114 @@ def test_prepare_api_call_strict_nested_tool(self): tool_def = api_args["tools"][0]["function"] assert tool_def["strict"] is True - assert tool_def["parameters"]["additionalProperties"] is False - assert sorted(tool_def["parameters"]["required"]) == ["address", "name"] + 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"], + } + + def test_prepare_api_call_strict_complex_tool(self): + complex_tool = Tool( + name="send_messages", + description="Send messages with config", + parameters={ + "type": "object", + "properties": { + "messages": {"type": "array", "items": {"$ref": "#/$defs/Message"}}, + "config": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}}, + }, + ] + }, + }, + "$defs": { + "Message": { + "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"}, + }, + }, + }, + }, + }, + } + }, + }, + function=lambda **kwargs: str(kwargs), + ) - addr = tool_def["parameters"]["properties"]["address"] - assert addr["additionalProperties"] is False - assert sorted(addr["required"]) == ["city", "street"] + component = OpenAIChatGenerator(api_key=Secret.from_token("test-key"), tools_strict=True) + api_args = component._prepare_api_call(messages=[ChatMessage.from_user("test")], tools=[complex_tool]) + + tool_def = api_args["tools"][0]["function"] + assert tool_def["strict"] is True + assert tool_def["parameters"] == { + "type": "object", + "properties": { + "messages": {"type": "array", "items": {"$ref": "#/$defs/Message"}}, + "config": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}}, + "additionalProperties": False, + "required": ["temperature", "max_tokens"], + }, + ] + }, + }, + "$defs": { + "Message": { + "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"], + } From f09e947789b26e8637248b192e065cc8e6ef6381 Mon Sep 17 00:00:00 2001 From: Arkadeep Dutta Date: Tue, 5 May 2026 04:57:27 -0500 Subject: [PATCH 5/6] add ComponentTool integration test and live OpenAI strict test - test_prepare_api_call_strict_component_tool verifies ComponentTool with ChatMessage params gets all nested $defs strictified - test_live_run_strict_nested_tool hits the OpenAI API with a nested tool schema under tools_strict=True to confirm acceptance --- .../components/generators/chat/test_openai.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index 960a339ef8..56912838a5 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -2257,3 +2257,57 @@ def test_prepare_api_call_strict_complex_tool(self): "additionalProperties": False, "required": ["messages", "config"], } + + @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"] + + def test_prepare_api_call_strict_component_tool(self): + tool = ComponentTool( + component=MessageExtractor(), name="message_extractor", description="Extracts text from ChatMessage objects" + ) + component = OpenAIChatGenerator(api_key=Secret.from_token("test-key"), tools_strict=True) + api_args = component._prepare_api_call(messages=[ChatMessage.from_user("test")], tools=[tool]) + + params = api_args["tools"][0]["function"]["parameters"] + assert params["additionalProperties"] is False + assert "messages" in params["required"] + + for def_name, def_schema in params["$defs"].items(): + if def_schema.get("type") == "object": + assert def_schema["additionalProperties"] is False, f"$defs/{def_name} missing additionalProperties" + assert "required" in def_schema, f"$defs/{def_name} missing required" From 06b2939a927df8f7f876116eb77d55974d7acaf2 Mon Sep 17 00:00:00 2001 From: Arkadeep Dutta Date: Fri, 8 May 2026 14:22:59 -0500 Subject: [PATCH 6/6] Remove redundant tests per reviewer feedback Dropped test_oneof (branch covered by test_anyof), test_prepare_api_call_strict_complex_tool (superseded by integration test), and test_prepare_api_call_strict_component_tool (complex cases already covered). Co-Authored-By: Claude Haiku 4.5 --- .../components/generators/chat/test_openai.py | 140 ------------------ 1 file changed, 140 deletions(-) diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index 56912838a5..14856d06a0 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -2009,33 +2009,6 @@ def test_preserves_existing_required(self): "required": ["a", "b"], } - def test_oneof(self): - schema = { - "type": "object", - "properties": { - "value": {"oneOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "integer"}}}]} - }, - } - result = _make_schema_strict(schema) - assert result == { - "type": "object", - "properties": { - "value": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"x": {"type": "integer"}}, - "additionalProperties": False, - "required": ["x"], - }, - ] - } - }, - "additionalProperties": False, - "required": ["value"], - } - def test_complex_schema_with_defs_and_combinators(self): schema = { "type": "object", @@ -2161,103 +2134,6 @@ def test_prepare_api_call_strict_nested_tool(self): "required": ["name", "address"], } - def test_prepare_api_call_strict_complex_tool(self): - complex_tool = Tool( - name="send_messages", - description="Send messages with config", - parameters={ - "type": "object", - "properties": { - "messages": {"type": "array", "items": {"$ref": "#/$defs/Message"}}, - "config": { - "oneOf": [ - {"type": "null"}, - { - "type": "object", - "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}}, - }, - ] - }, - }, - "$defs": { - "Message": { - "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"}, - }, - }, - }, - }, - }, - } - }, - }, - function=lambda **kwargs: str(kwargs), - ) - - component = OpenAIChatGenerator(api_key=Secret.from_token("test-key"), tools_strict=True) - api_args = component._prepare_api_call(messages=[ChatMessage.from_user("test")], tools=[complex_tool]) - - tool_def = api_args["tools"][0]["function"] - assert tool_def["strict"] is True - assert tool_def["parameters"] == { - "type": "object", - "properties": { - "messages": {"type": "array", "items": {"$ref": "#/$defs/Message"}}, - "config": { - "oneOf": [ - {"type": "null"}, - { - "type": "object", - "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}}, - "additionalProperties": False, - "required": ["temperature", "max_tokens"], - }, - ] - }, - }, - "$defs": { - "Message": { - "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"], - } - @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.", @@ -2295,19 +2171,3 @@ def test_live_run_strict_nested_tool(self): assert "address" in tool_call.arguments assert "street" in tool_call.arguments["address"] assert "city" in tool_call.arguments["address"] - - def test_prepare_api_call_strict_component_tool(self): - tool = ComponentTool( - component=MessageExtractor(), name="message_extractor", description="Extracts text from ChatMessage objects" - ) - component = OpenAIChatGenerator(api_key=Secret.from_token("test-key"), tools_strict=True) - api_args = component._prepare_api_call(messages=[ChatMessage.from_user("test")], tools=[tool]) - - params = api_args["tools"][0]["function"]["parameters"] - assert params["additionalProperties"] is False - assert "messages" in params["required"] - - for def_name, def_schema in params["$defs"].items(): - if def_schema.get("type") == "object": - assert def_schema["additionalProperties"] is False, f"$defs/{def_name} missing additionalProperties" - assert "required" in def_schema, f"$defs/{def_name} missing required"