Skip to content

Commit 351104f

Browse files
authored
Make Tool Output dict conversion stricter to improve backward compatibility (openai#1965)
1 parent 648d14d commit 351104f

File tree

3 files changed

+266
-12
lines changed

3 files changed

+266
-12
lines changed

src/agents/items.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,9 @@ def _maybe_get_output_as_structured_function_output(
361361
if isinstance(output, (ToolOutputText, ToolOutputImage, ToolOutputFileContent)):
362362
return output
363363
elif isinstance(output, dict):
364+
# Require explicit 'type' field in dict to be considered a structured output
365+
if "type" not in output:
366+
return None
364367
try:
365368
return ValidToolOutputPydanticModelsTypeAdapter.validate_python(output)
366369
except pydantic.ValidationError:

src/agents/tool.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from openai.types.responses.tool_param import CodeInterpreter, ImageGeneration, Mcp
1616
from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters
1717
from openai.types.responses.web_search_tool_param import UserLocation
18-
from pydantic import BaseModel, TypeAdapter, ValidationError
18+
from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator
1919
from typing_extensions import Concatenate, NotRequired, ParamSpec, TypedDict
2020

2121
from . import _debug
@@ -75,6 +75,13 @@ class ToolOutputImage(BaseModel):
7575
file_id: str | None = None
7676
detail: Literal["low", "high", "auto"] | None = None
7777

78+
@model_validator(mode="after")
79+
def check_at_least_one_required_field(self) -> ToolOutputImage:
80+
"""Validate that at least one of image_url or file_id is provided."""
81+
if self.image_url is None and self.file_id is None:
82+
raise ValueError("At least one of image_url or file_id must be provided")
83+
return self
84+
7885

7986
class ToolOutputImageDict(TypedDict, total=False):
8087
"""TypedDict variant for image tool outputs."""
@@ -98,6 +105,13 @@ class ToolOutputFileContent(BaseModel):
98105
file_id: str | None = None
99106
filename: str | None = None
100107

108+
@model_validator(mode="after")
109+
def check_at_least_one_required_field(self) -> ToolOutputFileContent:
110+
"""Validate that at least one of file_data, file_url, or file_id is provided."""
111+
if self.file_data is None and self.file_url is None and self.file_id is None:
112+
raise ValueError("At least one of file_data, file_url, or file_id must be provided")
113+
return self
114+
101115

102116
class ToolOutputFileContentDict(TypedDict, total=False):
103117
"""TypedDict variant for file content tool outputs."""

tests/test_tool_output_conversion.py

Lines changed: 248 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,52 @@ def test_tool_call_output_item_mixed_list() -> None:
7676
assert items[2]["type"] == "input_file" and items[2]["file_data"] == "ZmlsZS1kYXRh"
7777

7878

79+
def test_tool_call_output_item_image_forwards_file_id_and_detail() -> None:
80+
"""Ensure image outputs forward provided file_id and detail fields."""
81+
call = _make_tool_call()
82+
out = ToolOutputImage(file_id="file_123", detail="high")
83+
payload = ItemHelpers.tool_call_output_item(call, out)
84+
85+
assert payload["type"] == "function_call_output"
86+
assert payload["call_id"] == call.call_id
87+
item = payload["output"][0]
88+
assert isinstance(item, dict)
89+
assert item["type"] == "input_image"
90+
assert item["file_id"] == "file_123"
91+
assert item["detail"] == "high"
92+
93+
94+
def test_tool_call_output_item_file_forwards_file_id_and_filename() -> None:
95+
"""Ensure file outputs forward provided file_id and filename fields."""
96+
call = _make_tool_call()
97+
out = ToolOutputFileContent(file_id="file_456", filename="report.pdf")
98+
payload = ItemHelpers.tool_call_output_item(call, out)
99+
100+
assert payload["type"] == "function_call_output"
101+
assert payload["call_id"] == call.call_id
102+
item = payload["output"][0]
103+
assert isinstance(item, dict)
104+
assert item["type"] == "input_file"
105+
assert item["file_id"] == "file_456"
106+
assert item["filename"] == "report.pdf"
107+
108+
109+
def test_tool_call_output_item_file_forwards_file_url() -> None:
110+
"""Ensure file outputs forward provided file_url when present."""
111+
call = _make_tool_call()
112+
out = ToolOutputFileContent(file_url="https://example.com/report.pdf")
113+
payload = ItemHelpers.tool_call_output_item(call, out)
114+
115+
assert payload["type"] == "function_call_output"
116+
assert payload["call_id"] == call.call_id
117+
item = payload["output"][0]
118+
assert isinstance(item, dict)
119+
assert item["type"] == "input_file"
120+
assert item["file_url"] == "https://example.com/report.pdf"
121+
122+
79123
def test_tool_call_output_item_text_dict_variant() -> None:
124+
"""Dict with type='text' and text field should be treated as structured output."""
80125
call = _make_tool_call()
81126
# Dict variant using the pydantic model schema (type="text").
82127
out = {"type": "text", "text": "hey"}
@@ -91,45 +136,237 @@ def test_tool_call_output_item_text_dict_variant() -> None:
91136
assert item["text"] == "hey"
92137

93138

94-
def test_tool_call_output_item_image_forwards_file_id_and_detail() -> None:
95-
"""Ensure image outputs forward provided file_id and detail fields."""
139+
def test_tool_call_output_item_image_dict_variant() -> None:
140+
"""Dict with type='image' and image_url field should be treated as structured output."""
96141
call = _make_tool_call()
97-
out = ToolOutputImage(file_id="file_123", detail="high")
142+
out = {"type": "image", "image_url": "http://example.com/img.png", "detail": "auto"}
98143
payload = ItemHelpers.tool_call_output_item(call, out)
99144

100145
assert payload["type"] == "function_call_output"
101146
assert payload["call_id"] == call.call_id
147+
assert isinstance(payload["output"], list) and len(payload["output"]) == 1
148+
item = payload["output"][0]
149+
assert isinstance(item, dict)
150+
assert item["type"] == "input_image"
151+
assert item["image_url"] == "http://example.com/img.png"
152+
assert item["detail"] == "auto"
153+
154+
155+
def test_tool_call_output_item_image_dict_variant_with_file_id() -> None:
156+
"""Dict with type='image' and image_url field should be treated as structured output."""
157+
call = _make_tool_call()
158+
out = {"type": "image", "file_id": "file_123"}
159+
payload = ItemHelpers.tool_call_output_item(call, out)
160+
161+
assert payload["type"] == "function_call_output"
162+
assert payload["call_id"] == call.call_id
163+
assert isinstance(payload["output"], list) and len(payload["output"]) == 1
102164
item = payload["output"][0]
103165
assert isinstance(item, dict)
104166
assert item["type"] == "input_image"
105167
assert item["file_id"] == "file_123"
106-
assert item["detail"] == "high"
107168

108169

109-
def test_tool_call_output_item_file_forwards_file_id_and_filename() -> None:
110-
"""Ensure file outputs forward provided file_id and filename fields."""
170+
def test_tool_call_output_item_file_dict_variant_with_file_data() -> None:
171+
"""Dict with type='file' and file_data field should be treated as structured output."""
111172
call = _make_tool_call()
112-
out = ToolOutputFileContent(file_id="file_456", filename="report.pdf")
173+
out = {"type": "file", "file_data": "foobar", "filename": "report.pdf"}
113174
payload = ItemHelpers.tool_call_output_item(call, out)
114175

115176
assert payload["type"] == "function_call_output"
116177
assert payload["call_id"] == call.call_id
178+
assert isinstance(payload["output"], list) and len(payload["output"]) == 1
117179
item = payload["output"][0]
118180
assert isinstance(item, dict)
119181
assert item["type"] == "input_file"
120-
assert item["file_id"] == "file_456"
182+
assert item["file_data"] == "foobar"
121183
assert item["filename"] == "report.pdf"
122184

123185

124-
def test_tool_call_output_item_file_forwards_file_url() -> None:
125-
"""Ensure file outputs forward provided file_url when present."""
186+
def test_tool_call_output_item_file_dict_variant_with_file_url() -> None:
187+
"""Dict with type='file' and file_url field should be treated as structured output."""
126188
call = _make_tool_call()
127-
out = ToolOutputFileContent(file_url="https://example.com/report.pdf")
189+
out = {"type": "file", "file_url": "https://example.com/report.pdf", "filename": "report.pdf"}
128190
payload = ItemHelpers.tool_call_output_item(call, out)
129191

130192
assert payload["type"] == "function_call_output"
131193
assert payload["call_id"] == call.call_id
194+
assert isinstance(payload["output"], list) and len(payload["output"]) == 1
132195
item = payload["output"][0]
133196
assert isinstance(item, dict)
134197
assert item["type"] == "input_file"
135198
assert item["file_url"] == "https://example.com/report.pdf"
199+
assert item["filename"] == "report.pdf"
200+
201+
202+
def test_tool_call_output_item_file_dict_variant_with_file_id() -> None:
203+
"""Dict with type='file' and file_id field should be treated as structured output."""
204+
call = _make_tool_call()
205+
out = {"type": "file", "file_id": "file_123", "filename": "report.pdf"}
206+
payload = ItemHelpers.tool_call_output_item(call, out)
207+
208+
assert payload["type"] == "function_call_output"
209+
assert payload["call_id"] == call.call_id
210+
assert isinstance(payload["output"], list) and len(payload["output"]) == 1
211+
item = payload["output"][0]
212+
assert isinstance(item, dict)
213+
assert item["type"] == "input_file"
214+
assert item["file_id"] == "file_123"
215+
assert item["filename"] == "report.pdf"
216+
217+
218+
def test_tool_call_output_item_image_with_extra_fields() -> None:
219+
"""Dict with type='image', image_url, and extra fields should still be converted."""
220+
call = _make_tool_call()
221+
out = {"type": "image", "image_url": "http://example.com/img.png", "foobar": 213}
222+
payload = ItemHelpers.tool_call_output_item(call, out)
223+
224+
assert payload["type"] == "function_call_output"
225+
assert payload["call_id"] == call.call_id
226+
assert isinstance(payload["output"], list) and len(payload["output"]) == 1
227+
item = payload["output"][0]
228+
assert isinstance(item, dict)
229+
assert item["type"] == "input_image"
230+
assert item["image_url"] == "http://example.com/img.png"
231+
# Extra field should be ignored by Pydantic
232+
assert "foobar" not in item
233+
234+
235+
def test_tool_call_output_item_mixed_list_with_valid_dicts() -> None:
236+
"""List with valid dict variants (with type field) should be converted."""
237+
call = _make_tool_call()
238+
out = [
239+
{"type": "text", "text": "hello"},
240+
{"type": "image", "image_url": "http://example.com/img.png"},
241+
{"type": "file", "file_id": "file_123"},
242+
]
243+
payload = ItemHelpers.tool_call_output_item(call, out)
244+
245+
assert payload["type"] == "function_call_output"
246+
assert payload["call_id"] == call.call_id
247+
assert isinstance(payload["output"], list) and len(payload["output"]) == 3
248+
249+
assert payload["output"][0]["type"] == "input_text"
250+
assert payload["output"][0]["text"] == "hello"
251+
assert payload["output"][1]["type"] == "input_image"
252+
assert payload["output"][1]["image_url"] == "http://example.com/img.png"
253+
assert payload["output"][2]["type"] == "input_file"
254+
assert payload["output"][2]["file_id"] == "file_123"
255+
256+
257+
def test_tool_call_output_item_text_type_only_not_converted() -> None:
258+
"""Dict with only type='text' should NOT be treated as structured output."""
259+
call = _make_tool_call()
260+
out = {"type": "text"}
261+
payload = ItemHelpers.tool_call_output_item(call, out)
262+
263+
assert payload["type"] == "function_call_output"
264+
assert payload["call_id"] == call.call_id
265+
# Should be converted to string since it doesn't have required fields
266+
assert isinstance(payload["output"], str)
267+
assert payload["output"] == "{'type': 'text'}"
268+
269+
270+
def test_tool_call_output_item_image_type_only_not_converted() -> None:
271+
"""Dict with only type='image' should NOT be treated as structured output."""
272+
call = _make_tool_call()
273+
out = {"type": "image"}
274+
payload = ItemHelpers.tool_call_output_item(call, out)
275+
276+
assert payload["type"] == "function_call_output"
277+
assert payload["call_id"] == call.call_id
278+
# Should be converted to string since it doesn't have required fields
279+
assert isinstance(payload["output"], str)
280+
assert payload["output"] == "{'type': 'image'}"
281+
282+
283+
def test_tool_call_output_item_file_type_only_not_converted() -> None:
284+
"""Dict with only type='file' should NOT be treated as structured output."""
285+
call = _make_tool_call()
286+
out = {"type": "file"}
287+
payload = ItemHelpers.tool_call_output_item(call, out)
288+
289+
assert payload["type"] == "function_call_output"
290+
assert payload["call_id"] == call.call_id
291+
assert isinstance(payload["output"], str)
292+
assert payload["output"] == "{'type': 'file'}"
293+
294+
295+
def test_tool_call_output_item_empty_dict_not_converted() -> None:
296+
"""Empty dict should NOT be treated as structured output."""
297+
call = _make_tool_call()
298+
out: dict[str, str] = {}
299+
payload = ItemHelpers.tool_call_output_item(call, out)
300+
301+
assert payload["type"] == "function_call_output"
302+
assert payload["call_id"] == call.call_id
303+
assert isinstance(payload["output"], str)
304+
assert payload["output"] == "{}"
305+
306+
307+
def test_tool_call_output_item_dict_without_type_not_converted() -> None:
308+
"""Dict without 'type' field should NOT be treated as structured output."""
309+
call = _make_tool_call()
310+
out = {"msg": "1234"}
311+
payload = ItemHelpers.tool_call_output_item(call, out)
312+
313+
assert payload["type"] == "function_call_output"
314+
assert payload["call_id"] == call.call_id
315+
# Should be converted to string since it lacks 'type' field
316+
assert isinstance(payload["output"], str)
317+
assert payload["output"] == "{'msg': '1234'}"
318+
319+
320+
def test_tool_call_output_item_image_dict_variant_with_location_not_converted() -> None:
321+
"""Dict with type='image' and location field should NOT be treated as structured output."""
322+
call = _make_tool_call()
323+
out = {"type": "image", "location": "/path/to/img.png"}
324+
payload = ItemHelpers.tool_call_output_item(call, out)
325+
326+
assert payload["type"] == "function_call_output"
327+
assert payload["call_id"] == call.call_id
328+
# Should be converted to string since it lacks required fields (image_url or file_id)
329+
assert isinstance(payload["output"], str)
330+
assert payload["output"] == "{'type': 'image', 'location': '/path/to/img.png'}"
331+
332+
333+
def test_tool_call_output_item_file_dict_variant_with_path_not_converted() -> None:
334+
"""Dict with type='file' and path field should NOT be treated as structured output."""
335+
call = _make_tool_call()
336+
out = {"type": "file", "path": "/path/to/file.txt"}
337+
payload = ItemHelpers.tool_call_output_item(call, out)
338+
339+
assert payload["type"] == "function_call_output"
340+
assert payload["call_id"] == call.call_id
341+
# Should be converted to string since it lacks required fields (file_data, file_url, or file_id)
342+
assert isinstance(payload["output"], str)
343+
assert payload["output"] == "{'type': 'file', 'path': '/path/to/file.txt'}"
344+
345+
346+
def test_tool_call_output_item_list_without_type_not_converted() -> None:
347+
"""List with dicts lacking 'type' field should NOT be treated as structured output."""
348+
call = _make_tool_call()
349+
out = [{"msg": "foobar"}]
350+
payload = ItemHelpers.tool_call_output_item(call, out)
351+
352+
assert payload["type"] == "function_call_output"
353+
assert payload["call_id"] == call.call_id
354+
# Should be converted to string since list items lack 'type' field
355+
assert isinstance(payload["output"], str)
356+
assert payload["output"] == "[{'msg': 'foobar'}]"
357+
358+
359+
def test_tool_call_output_item_mixed_list_partial_invalid_not_converted() -> None:
360+
"""List with mix of valid and invalid dicts should NOT be treated as structured output."""
361+
call = _make_tool_call()
362+
out = [
363+
{"type": "text", "text": "hello"}, # Valid
364+
{"msg": "foobar"}, # Invalid
365+
]
366+
payload = ItemHelpers.tool_call_output_item(call, out)
367+
368+
assert payload["type"] == "function_call_output"
369+
assert payload["call_id"] == call.call_id
370+
# All-or-nothing: if any item is invalid, convert entire list to string
371+
assert isinstance(payload["output"], str)
372+
assert payload["output"] == "[{'type': 'text', 'text': 'hello'}, {'msg': 'foobar'}]"

0 commit comments

Comments
 (0)