From 55053cc3aa2a839270c36dcdac25d7af252f4735 Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Thu, 23 Oct 2025 06:12:11 -0400 Subject: [PATCH] feat(OpenAI): add support for actions on web_search_call --- .../Output/OutputWebSearchToolCall.php | 17 ++- .../WebSearch/OutputWebSearchAction.php | 76 +++++++++++++ .../OutputWebSearchActionSources.php | 54 ++++++++++ .../Responses/CancelResponseFixture.php | 8 ++ .../Responses/CreateResponseFixture.php | 8 ++ .../Responses/ResponseObjectFixture.php | 8 ++ .../Responses/RetrieveResponseFixture.php | 8 ++ tests/Fixtures/Responses.php | 8 ++ .../Streams/ResponseCompletionCreate.txt | 4 +- .../Output/OutputWebSearchToolCall.php | 102 ++++++++++++++++++ 10 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 src/Responses/Responses/Output/WebSearch/OutputWebSearchAction.php create mode 100644 src/Responses/Responses/Output/WebSearch/OutputWebSearchActionSources.php create mode 100644 tests/Responses/Responses/Output/OutputWebSearchToolCall.php diff --git a/src/Responses/Responses/Output/OutputWebSearchToolCall.php b/src/Responses/Responses/Output/OutputWebSearchToolCall.php index 1fe2fadc..5c135e83 100644 --- a/src/Responses/Responses/Output/OutputWebSearchToolCall.php +++ b/src/Responses/Responses/Output/OutputWebSearchToolCall.php @@ -6,10 +6,13 @@ use OpenAI\Contracts\ResponseContract; use OpenAI\Responses\Concerns\ArrayAccessible; +use OpenAI\Responses\Responses\Output\WebSearch\OutputWebSearchAction; use OpenAI\Testing\Responses\Concerns\Fakeable; /** - * @phpstan-type OutputWebSearchToolCallType array{id: string, status: string, type: 'web_search_call'} + * @phpstan-import-type WebSearchActionType from OutputWebSearchAction + * + * @phpstan-type OutputWebSearchToolCallType array{id: string, status: string, type: 'web_search_call', action?: WebSearchActionType} * * @implements ResponseContract */ @@ -29,6 +32,7 @@ private function __construct( public readonly string $id, public readonly string $status, public readonly string $type, + public readonly ?OutputWebSearchAction $action, ) {} /** @@ -40,6 +44,9 @@ public static function from(array $attributes): self id: $attributes['id'], status: $attributes['status'], type: $attributes['type'], + action: isset($attributes['action']) + ? OutputWebSearchAction::from($attributes['action']) + : null ); } @@ -48,10 +55,16 @@ public static function from(array $attributes): self */ public function toArray(): array { - return [ + $data = [ 'id' => $this->id, 'status' => $this->status, 'type' => $this->type, ]; + + if ($this->action !== null) { + $data['action'] = $this->action->toArray(); + } + + return $data; } } diff --git a/src/Responses/Responses/Output/WebSearch/OutputWebSearchAction.php b/src/Responses/Responses/Output/WebSearch/OutputWebSearchAction.php new file mode 100644 index 00000000..378a02fb --- /dev/null +++ b/src/Responses/Responses/Output/WebSearch/OutputWebSearchAction.php @@ -0,0 +1,76 @@ +} + * + * @implements ResponseContract + */ +final class OutputWebSearchAction implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'search' $type + * @param array $sources + */ + private function __construct( + public readonly string $type, + public readonly ?string $query, + public readonly ?array $sources, + ) {} + + /** + * @param WebSearchActionType $attributes + */ + public static function from(array $attributes): self + { + return new self( + type: $attributes['type'], + query: $attributes['query'] ?? null, + sources: isset($attributes['sources']) + ? array_map( + static fn (array $source): OutputWebSearchActionSources => OutputWebSearchActionSources::from($source), + $attributes['sources'], + ) + : null, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $data = [ + 'type' => $this->type, + ]; + + if ($this->sources !== null) { + $data['sources'] = array_map( + static fn (OutputWebSearchActionSources $source): array => $source->toArray(), + $this->sources, + ); + } + + if ($this->query !== null) { + $data['query'] = $this->query; + } + + return $data; + } +} diff --git a/src/Responses/Responses/Output/WebSearch/OutputWebSearchActionSources.php b/src/Responses/Responses/Output/WebSearch/OutputWebSearchActionSources.php new file mode 100644 index 00000000..83ecbb9c --- /dev/null +++ b/src/Responses/Responses/Output/WebSearch/OutputWebSearchActionSources.php @@ -0,0 +1,54 @@ + + */ +final class OutputWebSearchActionSources implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'url' $type + */ + private function __construct( + public readonly string $type, + public readonly string $url + ) {} + + /** + * @param WebSearchActionSourcesType $attributes + */ + public static function from(array $attributes): self + { + return new self( + type: $attributes['type'], + url: $attributes['url'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'url' => $this->url, + ]; + } +} diff --git a/src/Testing/Responses/Fixtures/Responses/CancelResponseFixture.php b/src/Testing/Responses/Fixtures/Responses/CancelResponseFixture.php index f3058291..0022abb0 100644 --- a/src/Testing/Responses/Fixtures/Responses/CancelResponseFixture.php +++ b/src/Testing/Responses/Fixtures/Responses/CancelResponseFixture.php @@ -19,6 +19,14 @@ final class CancelResponseFixture 'type' => 'web_search_call', 'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', 'status' => 'completed', + 'action' => [ + 'type' => 'search', + 'query' => 'what was a positive news story from today?', + 'sources' => [ + ['type' => 'url', 'url' => 'https://example.com/news/positive-story'], + ['type' => 'url', 'url' => 'https://another.example.com/related-article'], + ], + ], ], [ 'type' => 'message', diff --git a/src/Testing/Responses/Fixtures/Responses/CreateResponseFixture.php b/src/Testing/Responses/Fixtures/Responses/CreateResponseFixture.php index 1d16c224..24ee6de7 100644 --- a/src/Testing/Responses/Fixtures/Responses/CreateResponseFixture.php +++ b/src/Testing/Responses/Fixtures/Responses/CreateResponseFixture.php @@ -19,6 +19,14 @@ final class CreateResponseFixture 'type' => 'web_search_call', 'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', 'status' => 'completed', + 'action' => [ + 'type' => 'search', + 'query' => 'what was a positive news story from today?', + 'sources' => [ + ['type' => 'url', 'url' => 'https://example.com/news/positive-story'], + ['type' => 'url', 'url' => 'https://another.example.com/related-article'], + ], + ], ], [ 'type' => 'message', diff --git a/src/Testing/Responses/Fixtures/Responses/ResponseObjectFixture.php b/src/Testing/Responses/Fixtures/Responses/ResponseObjectFixture.php index 4d049b2f..3cfdc490 100644 --- a/src/Testing/Responses/Fixtures/Responses/ResponseObjectFixture.php +++ b/src/Testing/Responses/Fixtures/Responses/ResponseObjectFixture.php @@ -19,6 +19,14 @@ final class ResponseObjectFixture 'type' => 'web_search_call', 'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', 'status' => 'completed', + 'action' => [ + 'type' => 'search', + 'query' => 'what was a positive news story from today?', + 'sources' => [ + ['type' => 'url', 'url' => 'https://example.com/news/positive-story'], + ['type' => 'url', 'url' => 'https://another.example.com/related-article'], + ], + ], ], [ 'type' => 'message', diff --git a/src/Testing/Responses/Fixtures/Responses/RetrieveResponseFixture.php b/src/Testing/Responses/Fixtures/Responses/RetrieveResponseFixture.php index 9231f856..cd113971 100644 --- a/src/Testing/Responses/Fixtures/Responses/RetrieveResponseFixture.php +++ b/src/Testing/Responses/Fixtures/Responses/RetrieveResponseFixture.php @@ -19,6 +19,14 @@ final class RetrieveResponseFixture 'type' => 'web_search_call', 'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', 'status' => 'completed', + 'action' => [ + 'type' => 'search', + 'query' => 'what was a positive news story from today?', + 'sources' => [ + ['type' => 'url', 'url' => 'https://example.com/news/positive-story'], + ['type' => 'url', 'url' => 'https://another.example.com/related-article'], + ], + ], ], [ 'type' => 'message', diff --git a/tests/Fixtures/Responses.php b/tests/Fixtures/Responses.php index 39cc0ef9..2c849893 100644 --- a/tests/Fixtures/Responses.php +++ b/tests/Fixtures/Responses.php @@ -478,6 +478,14 @@ function outputWebSearchToolCall(): array 'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', 'status' => 'completed', 'type' => 'web_search_call', + 'action' => [ + 'type' => 'search', + 'sources' => [ + ['type' => 'url', 'url' => 'https://example.com/news/positive-story'], + ['type' => 'url', 'url' => 'https://another.example.com/related-article'], + ], + 'query' => 'what was a positive news story from today?', + ], ]; } diff --git a/tests/Fixtures/Streams/ResponseCompletionCreate.txt b/tests/Fixtures/Streams/ResponseCompletionCreate.txt index e8f05b1e..e7fe7825 100644 --- a/tests/Fixtures/Streams/ResponseCompletionCreate.txt +++ b/tests/Fixtures/Streams/ResponseCompletionCreate.txt @@ -1,11 +1,11 @@ data: {"type":"response.created","response":{"id":"resp_67ccf18ef5fc8190b16dbee19bc54e5f087bb177ab789d5c","object":"response","created_at":1741484430,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"web_search_preview","domains":[],"search_context_size":"medium","user_location":{"type":"approximate","city":null,"country":"US","region":null,"timezone":null}}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} data: {"type":"response.in_progress","response":{"id":"resp_67ccf18ef5fc8190b16dbee19bc54e5f087bb177ab789d5c","object":"response","created_at":1741484430,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"web_search_preview","domains":[],"search_context_size":"medium","user_location":{"type":"approximate","city":null,"country":"US","region":null,"timezone":null}}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","type":"web_search_call","status":"in_progress"}} -data: {"type":"response.output_item.done","output_index":0,"item":{"id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","type":"web_search_call","status":"completed"}} +data: {"type":"response.output_item.done","output_index":0,"item":{"id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","type":"web_search_call","status":"completed","action":{"type":"search","query":"what was a positive news story from today?","sources":[{"type":"url","url":"https://example.com/news/positive-story"},{"type":"url","url":"https://another.example.com/related-article"}]}}} data: {"type":"response.output_item.added","output_index":1,"item":{"id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","type":"message","status":"in_progress","role":"assistant","content":[]}} data: {"type":"response.content_part.added","item_id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} data: {"type":"response.output_text.delta","item_id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","output_index":1,"content_index":0,"delta":"As of today, March 9, 2025, one notable positive news story..."} data: {"type":"response.output_text.done","item_id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","output_index":1,"content_index":0,"text":"As of today, March 9, 2025, one notable positive news story..."} data: {"type":"response.content_part.done","item_id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","output_index":1,"content_index":0,"part":{"type":"output_text","text":"As of today, March 9, 2025, one notable positive news story...","annotations":[{"type":"url_citation","start_index":442,"end_index":557,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":962,"end_index":1077,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":1336,"end_index":1451,"url":"https://.../?utm_source=chatgpt.com","title":"..."}]}} data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"As of today, March 9, 2025, one notable positive news story...","annotations":[{"type":"url_citation","start_index":442,"end_index":557,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":962,"end_index":1077,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":1336,"end_index":1451,"url":"https://.../?utm_source=chatgpt.com","title":"..."}]}]}} -data: {"type":"response.completed","response":{"id":"resp_67ccf18ef5fc8190b16dbee19bc54e5f087bb177ab789d5c","object":"response","created_at":1741484430,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"web_search_call","id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","status":"completed"},{"type":"message","id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","status":"completed","role":"assistant","content":[{"type":"output_text","text":"As of today, March 9, 2025, one notable positive news story...","annotations":[{"type":"url_citation","start_index":442,"end_index":557,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":962,"end_index":1077,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":1336,"end_index":1451,"url":"https://.../?utm_source=chatgpt.com","title":"..."}]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"web_search_preview","domains":[],"search_context_size":"medium","user_location":{"type":"approximate","city":null,"country":"US","region":null,"timezone":null}}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":328,"input_tokens_details":{"cached_tokens":0},"output_tokens":356,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":684},"user":null,"metadata":{}}} +data: {"type":"response.completed","response":{"id":"resp_67ccf18ef5fc8190b16dbee19bc54e5f087bb177ab789d5c","object":"response","created_at":1741484430,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"web_search_call","id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","status":"completed","action":{"type":"search","query":"what was a positive news story from today?","sources":[{"type":"url","url":"https://example.com/news/positive-story"},{"type":"url","url":"https://another.example.com/related-article"}]}},{"type":"message","id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","status":"completed","role":"assistant","content":[{"type":"output_text","text":"As of today, March 9, 2025, one notable positive news story...","annotations":[{"type":"url_citation","start_index":442,"end_index":557,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":962,"end_index":1077,"url":"https://.../?utm_source=chatgpt.com","title":"..."},{"type":"url_citation","start_index":1336,"end_index":1451,"url":"https://.../?utm_source=chatgpt.com","title":"..."}]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"web_search_preview","domains":[],"search_context_size":"medium","user_location":{"type":"approximate","city":null,"country":"US","region":null,"timezone":null}}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":328,"input_tokens_details":{"cached_tokens":0},"output_tokens":356,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":684},"user":null,"metadata":{}}} diff --git a/tests/Responses/Responses/Output/OutputWebSearchToolCall.php b/tests/Responses/Responses/Output/OutputWebSearchToolCall.php new file mode 100644 index 00000000..ea261358 --- /dev/null +++ b/tests/Responses/Responses/Output/OutputWebSearchToolCall.php @@ -0,0 +1,102 @@ +toBeInstanceOf(OutputWebSearchToolCall::class) + ->id->toBe('ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c') + ->status->toBe('completed') + ->type->toBe('web_search_call') + ->action->toBeInstanceOf(OutputWebSearchAction::class) + ->action->query->toBe('what was a positive news story from today?') + ->action->sources->toBeArray() + ->action->sources->toHaveCount(2); + + // Ensure first source is parsed correctly + expect($response->action->sources[0]) + ->type->toBe('url') + ->url->toBe('https://example.com/news/positive-story'); +}); + +test('as array accessible', function () { + $response = OutputWebSearchToolCall::from(outputWebSearchToolCall()); + + expect($response['id'])->toBe('ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c'); +}); + +test('to array with full action', function () { + $response = OutputWebSearchToolCall::from(outputWebSearchToolCall()); + + expect($response->toArray()) + ->toBeArray() + ->toBe(outputWebSearchToolCall()); +}); + +test('from without action', function () { + $payload = outputWebSearchToolCall(); + unset($payload['action']); + + $response = OutputWebSearchToolCall::from($payload); + + expect($response) + ->toBeInstanceOf(OutputWebSearchToolCall::class) + ->id->toBe('ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c') + ->status->toBe('completed') + ->type->toBe('web_search_call') + ->action->toBeNull(); + + expect($response->toArray()) + ->toBeArray() + ->toBe($payload) + ->not->toHaveKey('action'); +}); + +test('from with action but without query', function () { + $payload = outputWebSearchToolCall(); + unset($payload['action']['query']); + + $response = OutputWebSearchToolCall::from($payload); + + expect($response) + ->toBeInstanceOf(OutputWebSearchToolCall::class) + ->action->toBeInstanceOf(OutputWebSearchAction::class) + ->action->query->toBeNull() + ->action->sources->toHaveCount(2); + + $array = $response->toArray(); + expect($array) + ->toBeArray() + ->toBe($payload); + + expect($array['action']) + ->toBeArray() + ->not->toHaveKey('query'); +}); + +test('from with action but without query & sources', function () { + $payload = outputWebSearchToolCall(); + unset($payload['action']['query']); + unset($payload['action']['sources']); + + $response = OutputWebSearchToolCall::from($payload); + + expect($response) + ->toBeInstanceOf(OutputWebSearchToolCall::class) + ->action->toBeInstanceOf(OutputWebSearchAction::class) + ->action->query->toBeNull() + ->action->sources->toBeNull(); + + $array = $response->toArray(); + expect($array) + ->toBeArray() + ->toBe($payload); + + expect($array['action']) + ->toBeArray() + ->not->toHaveKey('query') + ->not->toHaveKey('sources'); +});