Skip to content

Commit 8ac6436

Browse files
authored
Fix OpenAIResponsesModel web search find_in_page action handling (#3709)
1 parent 9d93be1 commit 8ac6436

File tree

3 files changed

+84
-10
lines changed

3 files changed

+84
-10
lines changed

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,8 @@ async def _map_messages( # noqa: C901
16801680
and item.tool_call_id
16811681
and (args := item.args_as_dict())
16821682
):
1683+
# We need to exclude None values because of https://github.com/pydantic/pydantic-ai/issues/3653
1684+
args = {k: v for k, v in args.items() if v is not None}
16831685
web_search_item = responses.ResponseFunctionWebSearchParam(
16841686
id=item.tool_call_id,
16851687
action=cast(responses.response_function_web_search_param.Action, args),
@@ -2562,7 +2564,8 @@ def _map_web_search_tool_call(
25622564
}
25632565

25642566
if action := item.action:
2565-
args = action.model_dump(mode='json')
2567+
# We need to exclude None values because of https://github.com/pydantic/pydantic-ai/issues/3653
2568+
args = action.model_dump(mode='json', exclude_none=True)
25662569

25672570
# To prevent `Unknown parameter: 'input[2].action.sources'` for `ActionSearch`
25682571
if sources := args.pop('sources', None):

tests/models/test_openai_responses.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from .mock_openai import MockOpenAIResponses, get_mock_responses_kwargs, response_message
5555

5656
with try_import() as imports_successful:
57+
from openai.types.responses import ResponseFunctionWebSearch
5758
from openai.types.responses.response_output_message import Content, ResponseOutputMessage, ResponseOutputText
5859
from openai.types.responses.response_reasoning_item import (
5960
Content as ReasoningContent,
@@ -1068,7 +1069,7 @@ async def test_openai_responses_model_web_search_tool_stream(allow_model_request
10681069
BuiltinToolReturnPart(
10691070
tool_name='web_search',
10701071
content={
1071-
'sources': [{'type': 'api', 'url': None, 'name': 'oai-weather'}],
1072+
'sources': [{'type': 'api', 'name': 'oai-weather'}],
10721073
'status': 'completed',
10731074
},
10741075
tool_call_id='ws_00a60507bf41223d0068c9d30021d081a0962d80d50c12e317',
@@ -1155,7 +1156,7 @@ async def test_openai_responses_model_web_search_tool_stream(allow_model_request
11551156
index=2,
11561157
part=BuiltinToolReturnPart(
11571158
tool_name='web_search',
1158-
content={'status': 'completed', 'sources': [{'type': 'api', 'url': None, 'name': 'oai-weather'}]},
1159+
content={'status': 'completed', 'sources': [{'type': 'api', 'name': 'oai-weather'}]},
11591160
tool_call_id='ws_00a60507bf41223d0068c9d30021d081a0962d80d50c12e317',
11601161
timestamp=IsDatetime(),
11611162
provider_name='openai',
@@ -1249,7 +1250,7 @@ async def test_openai_responses_model_web_search_tool_stream(allow_model_request
12491250
BuiltinToolResultEvent( # pyright: ignore[reportDeprecated]
12501251
result=BuiltinToolReturnPart(
12511252
tool_name='web_search',
1252-
content={'sources': [{'type': 'api', 'url': None, 'name': 'oai-weather'}], 'status': 'completed'},
1253+
content={'sources': [{'type': 'api', 'name': 'oai-weather'}], 'status': 'completed'},
12531254
tool_call_id='ws_00a60507bf41223d0068c9d30021d081a0962d80d50c12e317',
12541255
timestamp=IsDatetime(),
12551256
provider_name='openai',
@@ -1288,7 +1289,7 @@ async def test_openai_responses_model_web_search_tool_stream(allow_model_request
12881289
BuiltinToolReturnPart(
12891290
tool_name='web_search',
12901291
content={
1291-
'sources': [{'type': 'api', 'url': None, 'name': 'oai-weather'}],
1292+
'sources': [{'type': 'api', 'name': 'oai-weather'}],
12921293
'status': 'completed',
12931294
},
12941295
tool_call_id='ws_00a60507bf41223d0068c9d31b6aec81a09d9e568afa7b59aa',
@@ -8136,3 +8137,73 @@ async def test_openai_responses_runs_with_instructions_only(
81368137
assert result.output
81378138
assert isinstance(result.output, str)
81388139
assert len(result.output) > 0
8140+
8141+
8142+
async def test_web_search_call_action_find_in_page(allow_model_requests: None):
8143+
"""Test for https://github.com/pydantic/pydantic-ai/issues/3653"""
8144+
c1 = response_message(
8145+
[
8146+
ResponseFunctionWebSearch.model_construct(
8147+
id='web-search-1',
8148+
action={
8149+
'type': 'find_in_page',
8150+
'pattern': 'test',
8151+
'url': 'https://example.com',
8152+
},
8153+
status='completed',
8154+
type='web_search_call',
8155+
),
8156+
]
8157+
)
8158+
c2 = response_message(
8159+
[
8160+
ResponseOutputMessage(
8161+
id='output-1',
8162+
content=cast(list[Content], [ResponseOutputText(text='done', type='output_text', annotations=[])]),
8163+
role='assistant',
8164+
status='completed',
8165+
type='message',
8166+
)
8167+
]
8168+
)
8169+
mock_client = MockOpenAIResponses.create_mock([c1, c2])
8170+
model = OpenAIResponsesModel('gpt-5', provider=OpenAIProvider(openai_client=mock_client))
8171+
agent = Agent(model=model)
8172+
8173+
result = await agent.run('test')
8174+
8175+
assert result.all_messages()[1] == snapshot(
8176+
ModelResponse(
8177+
parts=[
8178+
BuiltinToolCallPart(
8179+
tool_name='web_search',
8180+
args={'type': 'find_in_page', 'pattern': 'test', 'url': 'https://example.com'},
8181+
tool_call_id='web-search-1',
8182+
provider_name='openai',
8183+
),
8184+
BuiltinToolReturnPart(
8185+
tool_name='web_search',
8186+
content={'status': 'completed'},
8187+
tool_call_id='web-search-1',
8188+
timestamp=IsDatetime(),
8189+
provider_name='openai',
8190+
),
8191+
],
8192+
model_name='gpt-4o-123',
8193+
timestamp=IsDatetime(),
8194+
provider_name='openai',
8195+
provider_url='https://api.openai.com/v1',
8196+
provider_response_id='123',
8197+
run_id=IsStr(),
8198+
)
8199+
)
8200+
8201+
response_kwargs = get_mock_responses_kwargs(mock_client)
8202+
assert response_kwargs[1]['input'][1] == snapshot(
8203+
{
8204+
'id': 'web-search-1',
8205+
'action': {'type': 'find_in_page', 'pattern': 'test', 'url': 'https://example.com'},
8206+
'status': 'completed',
8207+
'type': 'web_search_call',
8208+
}
8209+
)

tests/test_vercel_ai.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,13 +385,13 @@ async def test_run(allow_model_requests: None, openai_api_key: str):
385385
{
386386
'type': 'tool-input-delta',
387387
'toolCallId': IsStr(),
388-
'inputTextDelta': '{"query":null,"type":"search"}',
388+
'inputTextDelta': '{"type":"search"}',
389389
},
390390
{
391391
'type': 'tool-input-available',
392-
'toolCallId': 'ws_00e767404995b9950068e6480ac0888191a7897231e6ca9911',
392+
'toolCallId': IsStr(),
393393
'toolName': 'web_search',
394-
'input': {'query': None, 'type': 'search'},
394+
'input': {'type': 'search'},
395395
'providerExecuted': True,
396396
'providerMetadata': {'pydantic_ai': {'provider_name': 'openai'}},
397397
},
@@ -407,13 +407,13 @@ async def test_run(allow_model_requests: None, openai_api_key: str):
407407
{
408408
'type': 'tool-input-delta',
409409
'toolCallId': IsStr(),
410-
'inputTextDelta': '{"query":null,"type":"search"}',
410+
'inputTextDelta': '{"type":"search"}',
411411
},
412412
{
413413
'type': 'tool-input-available',
414414
'toolCallId': 'ws_00e767404995b9950068e6480e11208191834104e1aaab1148',
415415
'toolName': 'web_search',
416-
'input': {'query': None, 'type': 'search'},
416+
'input': {'type': 'search'},
417417
'providerExecuted': True,
418418
'providerMetadata': {'pydantic_ai': {'provider_name': 'openai'}},
419419
},

0 commit comments

Comments
 (0)