Skip to content

Commit 75fd791

Browse files
authored
fix: convert oneOf to anyOf in strict schema for OpenAI compatibility (#1884)
1 parent f7711af commit 75fd791

File tree

2 files changed

+282
-0
lines changed

2 files changed

+282
-0
lines changed

src/agents/strict_schema.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ def _ensure_strict_json_schema(
8787
for i, variant in enumerate(any_of)
8888
]
8989

90+
# oneOf is not supported by OpenAI's structured outputs in nested contexts,
91+
# so we convert it to anyOf which provides equivalent functionality for
92+
# discriminated unions
93+
one_of = json_schema.get("oneOf")
94+
if is_list(one_of):
95+
existing_any_of = json_schema.get("anyOf", [])
96+
if not is_list(existing_any_of):
97+
existing_any_of = []
98+
json_schema["anyOf"] = existing_any_of + [
99+
_ensure_strict_json_schema(variant, path=(*path, "oneOf", str(i)), root=root)
100+
for i, variant in enumerate(one_of)
101+
]
102+
json_schema.pop("oneOf")
103+
90104
# intersections
91105
all_of = json_schema.get("allOf")
92106
if is_list(all_of):

tests/test_strict_schema_oneof.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
from typing import Annotated, Literal, Union
2+
3+
from pydantic import BaseModel, Field
4+
5+
from agents.agent_output import AgentOutputSchema
6+
from agents.strict_schema import ensure_strict_json_schema
7+
8+
9+
def test_oneof_converted_to_anyof():
10+
schema = {
11+
"type": "object",
12+
"properties": {"value": {"oneOf": [{"type": "string"}, {"type": "integer"}]}},
13+
}
14+
15+
result = ensure_strict_json_schema(schema)
16+
17+
expected = {
18+
"type": "object",
19+
"properties": {"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
20+
"additionalProperties": False,
21+
"required": ["value"],
22+
}
23+
assert result == expected
24+
25+
26+
def test_nested_oneof_in_array_items():
27+
schema = {
28+
"type": "object",
29+
"properties": {
30+
"steps": {
31+
"type": "array",
32+
"items": {
33+
"oneOf": [
34+
{
35+
"type": "object",
36+
"properties": {
37+
"action": {"type": "string", "const": "buy_fruit"},
38+
"color": {"type": "string"},
39+
},
40+
"required": ["action", "color"],
41+
},
42+
{
43+
"type": "object",
44+
"properties": {
45+
"action": {"type": "string", "const": "buy_food"},
46+
"price": {"type": "integer"},
47+
},
48+
"required": ["action", "price"],
49+
},
50+
],
51+
"discriminator": {
52+
"propertyName": "action",
53+
"mapping": {
54+
"buy_fruit": "#/components/schemas/BuyFruitStep",
55+
"buy_food": "#/components/schemas/BuyFoodStep",
56+
},
57+
},
58+
},
59+
}
60+
},
61+
}
62+
63+
result = ensure_strict_json_schema(schema)
64+
65+
expected = {
66+
"type": "object",
67+
"properties": {
68+
"steps": {
69+
"type": "array",
70+
"items": {
71+
"anyOf": [
72+
{
73+
"type": "object",
74+
"properties": {
75+
"action": {"type": "string", "const": "buy_fruit"},
76+
"color": {"type": "string"},
77+
},
78+
"required": ["action", "color"],
79+
"additionalProperties": False,
80+
},
81+
{
82+
"type": "object",
83+
"properties": {
84+
"action": {"type": "string", "const": "buy_food"},
85+
"price": {"type": "integer"},
86+
},
87+
"required": ["action", "price"],
88+
"additionalProperties": False,
89+
},
90+
],
91+
"discriminator": {
92+
"propertyName": "action",
93+
"mapping": {
94+
"buy_fruit": "#/components/schemas/BuyFruitStep",
95+
"buy_food": "#/components/schemas/BuyFoodStep",
96+
},
97+
},
98+
},
99+
}
100+
},
101+
"additionalProperties": False,
102+
"required": ["steps"],
103+
}
104+
assert result == expected
105+
106+
107+
def test_discriminated_union_with_pydantic():
108+
class FruitArgs(BaseModel):
109+
color: str
110+
111+
class FoodArgs(BaseModel):
112+
price: int
113+
114+
class BuyFruitStep(BaseModel):
115+
action: Literal["buy_fruit"]
116+
args: FruitArgs
117+
118+
class BuyFoodStep(BaseModel):
119+
action: Literal["buy_food"]
120+
args: FoodArgs
121+
122+
Step = Annotated[Union[BuyFruitStep, BuyFoodStep], Field(discriminator="action")]
123+
124+
class Actions(BaseModel):
125+
steps: list[Step]
126+
127+
output_schema = AgentOutputSchema(Actions)
128+
schema = output_schema.json_schema()
129+
130+
items_schema = schema["properties"]["steps"]["items"]
131+
assert "oneOf" not in items_schema
132+
assert "anyOf" in items_schema
133+
assert len(items_schema["anyOf"]) == 2
134+
assert "discriminator" in items_schema
135+
136+
137+
def test_oneof_merged_with_existing_anyof():
138+
schema = {
139+
"type": "object",
140+
"anyOf": [{"type": "string"}],
141+
"oneOf": [{"type": "integer"}, {"type": "boolean"}],
142+
}
143+
144+
result = ensure_strict_json_schema(schema)
145+
146+
expected = {
147+
"type": "object",
148+
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}],
149+
"additionalProperties": False,
150+
}
151+
assert result == expected
152+
153+
154+
def test_discriminator_preserved():
155+
schema = {
156+
"oneOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}],
157+
"discriminator": {
158+
"propertyName": "type",
159+
"mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"},
160+
},
161+
"$defs": {
162+
"TypeA": {
163+
"type": "object",
164+
"properties": {"type": {"const": "a"}, "value_a": {"type": "string"}},
165+
},
166+
"TypeB": {
167+
"type": "object",
168+
"properties": {"type": {"const": "b"}, "value_b": {"type": "integer"}},
169+
},
170+
},
171+
}
172+
173+
result = ensure_strict_json_schema(schema)
174+
175+
expected = {
176+
"anyOf": [{"$ref": "#/$defs/TypeA"}, {"$ref": "#/$defs/TypeB"}],
177+
"discriminator": {
178+
"propertyName": "type",
179+
"mapping": {"a": "#/$defs/TypeA", "b": "#/$defs/TypeB"},
180+
},
181+
"$defs": {
182+
"TypeA": {
183+
"type": "object",
184+
"properties": {"type": {"const": "a"}, "value_a": {"type": "string"}},
185+
"additionalProperties": False,
186+
"required": ["type", "value_a"],
187+
},
188+
"TypeB": {
189+
"type": "object",
190+
"properties": {"type": {"const": "b"}, "value_b": {"type": "integer"}},
191+
"additionalProperties": False,
192+
"required": ["type", "value_b"],
193+
},
194+
},
195+
}
196+
assert result == expected
197+
198+
199+
def test_deeply_nested_oneof():
200+
schema = {
201+
"type": "object",
202+
"properties": {
203+
"level1": {
204+
"type": "object",
205+
"properties": {
206+
"level2": {
207+
"type": "array",
208+
"items": {"oneOf": [{"type": "string"}, {"type": "number"}]},
209+
}
210+
},
211+
}
212+
},
213+
}
214+
215+
result = ensure_strict_json_schema(schema)
216+
217+
expected = {
218+
"type": "object",
219+
"properties": {
220+
"level1": {
221+
"type": "object",
222+
"properties": {
223+
"level2": {
224+
"type": "array",
225+
"items": {"anyOf": [{"type": "string"}, {"type": "number"}]},
226+
}
227+
},
228+
"additionalProperties": False,
229+
"required": ["level2"],
230+
}
231+
},
232+
"additionalProperties": False,
233+
"required": ["level1"],
234+
}
235+
assert result == expected
236+
237+
238+
def test_oneof_with_refs():
239+
schema = {
240+
"type": "object",
241+
"properties": {
242+
"value": {
243+
"oneOf": [{"$ref": "#/$defs/StringType"}, {"$ref": "#/$defs/IntType"}]
244+
}
245+
},
246+
"$defs": {
247+
"StringType": {"type": "string"},
248+
"IntType": {"type": "integer"},
249+
},
250+
}
251+
252+
result = ensure_strict_json_schema(schema)
253+
254+
expected = {
255+
"type": "object",
256+
"properties": {
257+
"value": {
258+
"anyOf": [{"$ref": "#/$defs/StringType"}, {"$ref": "#/$defs/IntType"}]
259+
}
260+
},
261+
"$defs": {
262+
"StringType": {"type": "string"},
263+
"IntType": {"type": "integer"},
264+
},
265+
"additionalProperties": False,
266+
"required": ["value"],
267+
}
268+
assert result == expected

0 commit comments

Comments
 (0)