From 415f6d3def9949f2ca46d9a3f3d11fd4e29803b3 Mon Sep 17 00:00:00 2001 From: ruskaruma Date: Sat, 11 Oct 2025 21:34:50 +0530 Subject: [PATCH 1/2] fix: convert oneOf to anyOf in strict schema for OpenAI compatibility OpenAI's Structured Outputs API does not support oneOf in nested contexts (e.g., inside array items). Pydantic generates oneOf for discriminated unions, causing validation errors when sending schemas to OpenAI. This change modifies ensure_strict_json_schema() to convert oneOf to anyOf, which provides equivalent functionality for discriminated unions while maintaining OpenAI API compatibility. Fixes #1091 --- src/agents/strict_schema.py | 14 +++ tests/test_strict_schema_oneof.py | 179 ++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tests/test_strict_schema_oneof.py diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 3f37660a0..650c17308 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -87,6 +87,20 @@ def _ensure_strict_json_schema( for i, variant in enumerate(any_of) ] + # oneOf is not supported by OpenAI's structured outputs in nested contexts, + # so we convert it to anyOf which provides equivalent functionality for + # discriminated unions + one_of = json_schema.get("oneOf") + if is_list(one_of): + existing_any_of = json_schema.get("anyOf", []) + if not is_list(existing_any_of): + existing_any_of = [] + json_schema["anyOf"] = existing_any_of + [ + _ensure_strict_json_schema(variant, path=(*path, "oneOf", str(i)), root=root) + for i, variant in enumerate(one_of) + ] + json_schema.pop("oneOf") + # intersections all_of = json_schema.get("allOf") if is_list(all_of): diff --git a/tests/test_strict_schema_oneof.py b/tests/test_strict_schema_oneof.py new file mode 100644 index 000000000..47d39b3d0 --- /dev/null +++ b/tests/test_strict_schema_oneof.py @@ -0,0 +1,179 @@ +from typing import Annotated, Literal, Union + +from pydantic import BaseModel, Field + +from agents.agent_output import AgentOutputSchema +from agents.strict_schema import ensure_strict_json_schema + + +def test_oneof_converted_to_anyof(): + schema = { + "type": "object", + "properties": {"value": {"oneOf": [{"type": "string"}, {"type": "integer"}]}}, + } + + result = ensure_strict_json_schema(schema) + + assert "oneOf" not in str(result) + assert "anyOf" in result["properties"]["value"] + assert len(result["properties"]["value"]["anyOf"]) == 2 + + +def test_nested_oneof_in_array_items(): + # Test the issue #1091 scenario: oneOf in array items with discriminator + schema = { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": {"type": "string", "const": "buy_fruit"}, + "color": {"type": "string"}, + }, + "required": ["action", "color"], + }, + { + "type": "object", + "properties": { + "action": {"type": "string", "const": "buy_food"}, + "price": {"type": "integer"}, + }, + "required": ["action", "price"], + }, + ], + "discriminator": { + "propertyName": "action", + "mapping": { + "buy_fruit": "#/components/schemas/BuyFruitStep", + "buy_food": "#/components/schemas/BuyFoodStep", + }, + }, + }, + } + }, + } + + result = ensure_strict_json_schema(schema) + + assert "oneOf" not in str(result) + items_schema = result["properties"]["steps"]["items"] + assert "anyOf" in items_schema + assert "discriminator" in items_schema + assert items_schema["discriminator"]["propertyName"] == "action" + + +def test_discriminated_union_with_pydantic(): + # Test with actual Pydantic models from issue #1091 + class FruitArgs(BaseModel): + color: str + + class FoodArgs(BaseModel): + price: int + + class BuyFruitStep(BaseModel): + action: Literal["buy_fruit"] + args: FruitArgs + + class BuyFoodStep(BaseModel): + action: Literal["buy_food"] + args: FoodArgs + + Step = Annotated[Union[BuyFruitStep, BuyFoodStep], Field(discriminator="action")] + + class Actions(BaseModel): + steps: list[Step] + + output_schema = AgentOutputSchema(Actions) + schema = output_schema.json_schema() + + assert "oneOf" not in str(schema) + assert "anyOf" in str(schema) + + +def test_oneof_merged_with_existing_anyof(): + # When both anyOf and oneOf exist, they should be merged + schema = { + "type": "object", + "anyOf": [{"type": "string"}], + "oneOf": [{"type": "integer"}, {"type": "boolean"}], + } + + result = ensure_strict_json_schema(schema) + + assert "oneOf" not in result + assert "anyOf" in result + assert len(result["anyOf"]) == 3 + + +def test_discriminator_preserved(): + schema = { + "oneOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}], + "discriminator": { + "propertyName": "type", + "mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"}, + }, + "$defs": { + "TypeA": { + "type": "object", + "properties": {"type": {"const": "a"}, "value_a": {"type": "string"}}, + }, + "TypeB": { + "type": "object", + "properties": {"type": {"const": "b"}, "value_b": {"type": "integer"}}, + }, + }, + } + + result = ensure_strict_json_schema(schema) + + assert "discriminator" in result + assert result["discriminator"]["propertyName"] == "type" + assert "oneOf" not in result + assert "anyOf" in result + + +def test_deeply_nested_oneof(): + schema = { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "array", + "items": {"oneOf": [{"type": "string"}, {"type": "number"}]}, + } + }, + } + }, + } + + result = ensure_strict_json_schema(schema) + + assert "oneOf" not in str(result) + items = result["properties"]["level1"]["properties"]["level2"]["items"] + assert "anyOf" in items + + +def test_oneof_with_refs(): + schema = { + "type": "object", + "properties": { + "value": { + "oneOf": [{"$ref": "#/$defs/StringType"}, {"$ref": "#/$defs/IntType"}] + } + }, + "$defs": { + "StringType": {"type": "string"}, + "IntType": {"type": "integer"}, + }, + } + + result = ensure_strict_json_schema(schema) + + assert "oneOf" not in str(result) + assert "anyOf" in result["properties"]["value"] From fdff690f69bc9e37150eaca2ca00403415184f98 Mon Sep 17 00:00:00 2001 From: ruskaruma Date: Tue, 14 Oct 2025 18:38:24 +0530 Subject: [PATCH 2/2] Use explicit expected dicts in test assertions --- tests/test_strict_schema_oneof.py | 139 ++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 25 deletions(-) diff --git a/tests/test_strict_schema_oneof.py b/tests/test_strict_schema_oneof.py index 47d39b3d0..d6a145b57 100644 --- a/tests/test_strict_schema_oneof.py +++ b/tests/test_strict_schema_oneof.py @@ -14,13 +14,16 @@ def test_oneof_converted_to_anyof(): result = ensure_strict_json_schema(schema) - assert "oneOf" not in str(result) - assert "anyOf" in result["properties"]["value"] - assert len(result["properties"]["value"]["anyOf"]) == 2 + expected = { + "type": "object", + "properties": {"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, + "additionalProperties": False, + "required": ["value"], + } + assert result == expected def test_nested_oneof_in_array_items(): - # Test the issue #1091 scenario: oneOf in array items with discriminator schema = { "type": "object", "properties": { @@ -59,15 +62,49 @@ def test_nested_oneof_in_array_items(): result = ensure_strict_json_schema(schema) - assert "oneOf" not in str(result) - items_schema = result["properties"]["steps"]["items"] - assert "anyOf" in items_schema - assert "discriminator" in items_schema - assert items_schema["discriminator"]["propertyName"] == "action" + expected = { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": {"type": "string", "const": "buy_fruit"}, + "color": {"type": "string"}, + }, + "required": ["action", "color"], + "additionalProperties": False, + }, + { + "type": "object", + "properties": { + "action": {"type": "string", "const": "buy_food"}, + "price": {"type": "integer"}, + }, + "required": ["action", "price"], + "additionalProperties": False, + }, + ], + "discriminator": { + "propertyName": "action", + "mapping": { + "buy_fruit": "#/components/schemas/BuyFruitStep", + "buy_food": "#/components/schemas/BuyFoodStep", + }, + }, + }, + } + }, + "additionalProperties": False, + "required": ["steps"], + } + assert result == expected def test_discriminated_union_with_pydantic(): - # Test with actual Pydantic models from issue #1091 class FruitArgs(BaseModel): color: str @@ -90,12 +127,14 @@ class Actions(BaseModel): output_schema = AgentOutputSchema(Actions) schema = output_schema.json_schema() - assert "oneOf" not in str(schema) - assert "anyOf" in str(schema) + items_schema = schema["properties"]["steps"]["items"] + assert "oneOf" not in items_schema + assert "anyOf" in items_schema + assert len(items_schema["anyOf"]) == 2 + assert "discriminator" in items_schema def test_oneof_merged_with_existing_anyof(): - # When both anyOf and oneOf exist, they should be merged schema = { "type": "object", "anyOf": [{"type": "string"}], @@ -104,9 +143,12 @@ def test_oneof_merged_with_existing_anyof(): result = ensure_strict_json_schema(schema) - assert "oneOf" not in result - assert "anyOf" in result - assert len(result["anyOf"]) == 3 + expected = { + "type": "object", + "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}], + "additionalProperties": False, + } + assert result == expected def test_discriminator_preserved(): @@ -130,10 +172,28 @@ def test_discriminator_preserved(): result = ensure_strict_json_schema(schema) - assert "discriminator" in result - assert result["discriminator"]["propertyName"] == "type" - assert "oneOf" not in result - assert "anyOf" in result + expected = { + "anyOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}], + "discriminator": { + "propertyName": "type", + "mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"}, + }, + "$defs": { + "TypeA": { + "type": "object", + "properties": {"type": {"const": "a"}, "value_a": {"type": "string"}}, + "additionalProperties": False, + "required": ["type", "value_a"], + }, + "TypeB": { + "type": "object", + "properties": {"type": {"const": "b"}, "value_b": {"type": "integer"}}, + "additionalProperties": False, + "required": ["type", "value_b"], + }, + }, + } + assert result == expected def test_deeply_nested_oneof(): @@ -154,9 +214,25 @@ def test_deeply_nested_oneof(): result = ensure_strict_json_schema(schema) - assert "oneOf" not in str(result) - items = result["properties"]["level1"]["properties"]["level2"]["items"] - assert "anyOf" in items + expected = { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "array", + "items": {"anyOf": [{"type": "string"}, {"type": "number"}]}, + } + }, + "additionalProperties": False, + "required": ["level2"], + } + }, + "additionalProperties": False, + "required": ["level1"], + } + assert result == expected def test_oneof_with_refs(): @@ -175,5 +251,18 @@ def test_oneof_with_refs(): result = ensure_strict_json_schema(schema) - assert "oneOf" not in str(result) - assert "anyOf" in result["properties"]["value"] + expected = { + "type": "object", + "properties": { + "value": { + "anyOf": [{"$ref": "#/$defs/StringType"}, {"$ref": "#/$defs/IntType"}] + } + }, + "$defs": { + "StringType": {"type": "string"}, + "IntType": {"type": "integer"}, + }, + "additionalProperties": False, + "required": ["value"], + } + assert result == expected