From bbda5b0ccd941b7229263b5fb5d386d99116d43d Mon Sep 17 00:00:00 2001 From: Alexander Morozov Date: Mon, 6 Jul 2026 14:57:36 +0400 Subject: [PATCH 1/3] Support streaming compaction output --- .../Responses/Streaming/OutputItem.php | 7 +++++-- tests/Fixtures/Responses.php | 5 +++++ .../ResponseOutputItemCompactionDone.txt | 1 + .../Responses/CreateStreamedResponse.php | 17 +++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/Streams/ResponseOutputItemCompactionDone.txt diff --git a/src/Responses/Responses/Streaming/OutputItem.php b/src/Responses/Responses/Streaming/OutputItem.php index 1223c0e3b..68e037452 100644 --- a/src/Responses/Responses/Streaming/OutputItem.php +++ b/src/Responses/Responses/Streaming/OutputItem.php @@ -10,6 +10,7 @@ use OpenAI\Responses\Concerns\HasMetaInformation; use OpenAI\Responses\Meta\MetaInformation; use OpenAI\Responses\Responses\Output\OutputCodeInterpreterToolCall; +use OpenAI\Responses\Responses\Output\OutputCompaction; use OpenAI\Responses\Responses\Output\OutputComputerToolCall; use OpenAI\Responses\Responses\Output\OutputFileSearchToolCall; use OpenAI\Responses\Responses\Output\OutputFunctionToolCall; @@ -34,8 +35,9 @@ * @phpstan-import-type OutputMcpApprovalRequestType from OutputMcpApprovalRequest * @phpstan-import-type OutputMcpCallType from OutputMcpCall * @phpstan-import-type OutputCodeInterpreterToolCallType from OutputCodeInterpreterToolCall + * @phpstan-import-type OutputCompactionType from OutputCompaction * - * @phpstan-type OutputItemType array{type: string, output_index: int, sequence_number: int, item: OutputCodeInterpreterToolCallType|OutputComputerToolCallType|OutputFileSearchToolCallType|OutputFunctionToolCallType|OutputMessageType|OutputReasoningType|OutputWebSearchToolCallType|OutputMcpListToolsType|OutputMcpApprovalRequestType|OutputMcpCallType|OutputImageGenerationToolCallType} + * @phpstan-type OutputItemType array{type: string, output_index: int, sequence_number: int, item: OutputCodeInterpreterToolCallType|OutputComputerToolCallType|OutputFileSearchToolCallType|OutputFunctionToolCallType|OutputMessageType|OutputReasoningType|OutputWebSearchToolCallType|OutputMcpListToolsType|OutputMcpApprovalRequestType|OutputMcpCallType|OutputImageGenerationToolCallType|OutputCompactionType} * * @implements ResponseContract */ @@ -53,7 +55,7 @@ private function __construct( public readonly string $type, public readonly int $outputIndex, public readonly int $sequenceNumber, - public readonly OutputMessage|OutputCodeInterpreterToolCall|OutputFileSearchToolCall|OutputFunctionToolCall|OutputWebSearchToolCall|OutputComputerToolCall|OutputReasoning|OutputMcpListTools|OutputMcpApprovalRequest|OutputMcpCall|OutputImageGenerationToolCall $item, + public readonly OutputMessage|OutputCodeInterpreterToolCall|OutputFileSearchToolCall|OutputFunctionToolCall|OutputWebSearchToolCall|OutputComputerToolCall|OutputReasoning|OutputMcpListTools|OutputMcpApprovalRequest|OutputMcpCall|OutputImageGenerationToolCall|OutputCompaction $item, private readonly MetaInformation $meta, ) {} @@ -74,6 +76,7 @@ public static function from(array $attributes, MetaInformation $meta): self 'mcp_approval_request' => OutputMcpApprovalRequest::from($attributes['item']), 'mcp_call' => OutputMcpCall::from($attributes['item']), 'code_interpreter_call' => OutputCodeInterpreterToolCall::from($attributes['item']), + 'compaction' => OutputCompaction::from($attributes['item']), }; return new self( diff --git a/tests/Fixtures/Responses.php b/tests/Fixtures/Responses.php index 36fd6a2a2..4da44a0f7 100644 --- a/tests/Fixtures/Responses.php +++ b/tests/Fixtures/Responses.php @@ -1042,3 +1042,8 @@ function responseRateLimitsUpdatedEvent() { return fopen(__DIR__.'/Streams/ResponseRateLimitsUpdated.txt', 'r'); } + +function responseOutputItemCompactionDoneEvent() +{ + return fopen(__DIR__.'/Streams/ResponseOutputItemCompactionDone.txt', 'r'); +} diff --git a/tests/Fixtures/Streams/ResponseOutputItemCompactionDone.txt b/tests/Fixtures/Streams/ResponseOutputItemCompactionDone.txt new file mode 100644 index 000000000..c8d409118 --- /dev/null +++ b/tests/Fixtures/Streams/ResponseOutputItemCompactionDone.txt @@ -0,0 +1 @@ +data: {"type":"response.output_item.done","output_index":0,"sequence_number":11,"item":{"id":"cmp_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","encrypted_content":"encrypted_string_value","type":"compaction","created_by":"user_123"}} diff --git a/tests/Responses/Responses/CreateStreamedResponse.php b/tests/Responses/Responses/CreateStreamedResponse.php index e303d37f7..907243fbd 100644 --- a/tests/Responses/Responses/CreateStreamedResponse.php +++ b/tests/Responses/Responses/CreateStreamedResponse.php @@ -2,6 +2,8 @@ use OpenAI\Responses\Responses\CreateResponse; use OpenAI\Responses\Responses\CreateStreamedResponse; +use OpenAI\Responses\Responses\Output\OutputCompaction; +use OpenAI\Responses\Responses\Streaming\OutputItem; use OpenAI\Responses\Responses\Streaming\RateLimits; use OpenAI\Responses\Responses\Streaming\ReasoningTextDelta; use OpenAI\Responses\Responses\Streaming\ReasoningTextDone; @@ -68,3 +70,18 @@ 'type' => 'response.rate_limits.updated', ]); }); + +test('output item done event with compaction item', function () { + $response = CreateStreamedResponse::fake(responseOutputItemCompactionDoneEvent()); + + expect($response->getIterator()->current()) + ->toBeInstanceOf(CreateStreamedResponse::class) + ->event->toBe('response.output_item.done') + ->response->toBeInstanceOf(OutputItem::class) + ->response->outputIndex->toBe(0) + ->response->sequenceNumber->toBe(11) + ->response->item->toBeInstanceOf(OutputCompaction::class) + ->response->item->id->toBe('cmp_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c') + ->response->item->encryptedContent->toBe('encrypted_string_value') + ->response->item->createdBy->toBe('user_123'); +}); From f4ae434417c2eb798050b1108d253baba01f1b5e Mon Sep 17 00:00:00 2001 From: Alexander Morozov Date: Tue, 18 Aug 2026 15:21:13 +0400 Subject: [PATCH 2/3] Add web search result parsing --- .../Output/OutputWebSearchToolCall.php | 20 +++++- .../Output/OutputWebSearchToolCallResult.php | 72 +++++++++++++++++++ tests/Fixtures/Responses.php | 9 +++ .../Output/OutputWebSearchToolCall.php | 45 +++++++++++- 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/Responses/Responses/Output/OutputWebSearchToolCallResult.php diff --git a/src/Responses/Responses/Output/OutputWebSearchToolCall.php b/src/Responses/Responses/Output/OutputWebSearchToolCall.php index 5c135e83f..82865f3d5 100644 --- a/src/Responses/Responses/Output/OutputWebSearchToolCall.php +++ b/src/Responses/Responses/Output/OutputWebSearchToolCall.php @@ -11,8 +11,9 @@ /** * @phpstan-import-type WebSearchActionType from OutputWebSearchAction + * @phpstan-import-type OutputWebSearchToolCallResultType from OutputWebSearchToolCallResult * - * @phpstan-type OutputWebSearchToolCallType array{id: string, status: string, type: 'web_search_call', action?: WebSearchActionType} + * @phpstan-type OutputWebSearchToolCallType array{id: string, status: string, type: 'web_search_call', action?: WebSearchActionType, results?: array} * * @implements ResponseContract */ @@ -27,12 +28,14 @@ final class OutputWebSearchToolCall implements ResponseContract /** * @param 'web_search_call' $type + * @param ?array $results */ private function __construct( public readonly string $id, public readonly string $status, public readonly string $type, public readonly ?OutputWebSearchAction $action, + public readonly ?array $results, ) {} /** @@ -46,7 +49,13 @@ public static function from(array $attributes): self type: $attributes['type'], action: isset($attributes['action']) ? OutputWebSearchAction::from($attributes['action']) - : null + : null, + results: isset($attributes['results']) + ? array_map( + static fn (array $result): OutputWebSearchToolCallResult => OutputWebSearchToolCallResult::from($result), + $attributes['results'], + ) + : null, ); } @@ -65,6 +74,13 @@ public function toArray(): array $data['action'] = $this->action->toArray(); } + if ($this->results !== null) { + $data['results'] = array_map( + static fn (OutputWebSearchToolCallResult $result): array => $result->toArray(), + $this->results, + ); + } + return $data; } } diff --git a/src/Responses/Responses/Output/OutputWebSearchToolCallResult.php b/src/Responses/Responses/Output/OutputWebSearchToolCallResult.php new file mode 100644 index 000000000..910f1878b --- /dev/null +++ b/src/Responses/Responses/Output/OutputWebSearchToolCallResult.php @@ -0,0 +1,72 @@ + + */ +final class OutputWebSearchToolCallResult implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'image_result' $type + */ + private function __construct( + public readonly string $type, + public readonly string $imageUrl, + public readonly string $sourceWebsiteUrl, + public readonly ?string $thumbnailUrl, + public readonly ?string $caption, + ) {} + + /** + * @param OutputWebSearchToolCallResultType $attributes + */ + public static function from(array $attributes): self + { + return new self( + type: $attributes['type'], + imageUrl: $attributes['image_url'], + sourceWebsiteUrl: $attributes['source_website_url'], + thumbnailUrl: $attributes['thumbnail_url'] ?? null, + caption: $attributes['caption'] ?? null, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $data = [ + 'type' => $this->type, + 'image_url' => $this->imageUrl, + ]; + + if ($this->thumbnailUrl !== null) { + $data['thumbnail_url'] = $this->thumbnailUrl; + } + + $data['source_website_url'] = $this->sourceWebsiteUrl; + + if ($this->caption !== null) { + $data['caption'] = $this->caption; + } + + return $data; + } +} diff --git a/tests/Fixtures/Responses.php b/tests/Fixtures/Responses.php index d94f48ad3..f496f3685 100644 --- a/tests/Fixtures/Responses.php +++ b/tests/Fixtures/Responses.php @@ -560,6 +560,15 @@ function outputWebSearchToolCall(): array ], 'query' => 'what was a positive news story from today?', ], + 'results' => [ + [ + 'type' => 'image_result', + 'image_url' => 'https://example.com/images/positive-story.jpg', + 'thumbnail_url' => 'https://example.com/images/positive-story-thumbnail.jpg', + 'source_website_url' => 'https://example.com/news/positive-story', + 'caption' => 'A positive news story', + ], + ], ]; } diff --git a/tests/Responses/Responses/Output/OutputWebSearchToolCall.php b/tests/Responses/Responses/Output/OutputWebSearchToolCall.php index 68bd07ae3..9c1d245cf 100644 --- a/tests/Responses/Responses/Output/OutputWebSearchToolCall.php +++ b/tests/Responses/Responses/Output/OutputWebSearchToolCall.php @@ -1,6 +1,7 @@ action->toBeInstanceOf(OutputWebSearchAction::class) ->action->query->toBe('what was a positive news story from today?') ->action->sources->toBeArray() - ->action->sources->toHaveCount(2); + ->action->sources->toHaveCount(2) + ->results->toBeArray() + ->results->toHaveCount(1); // Ensure first source is parsed correctly expect($response->action->sources[0]) ->type->toBe('url') ->url->toBe('https://example.com/news/positive-story'); + + expect($response->results[0]) + ->toBeInstanceOf(OutputWebSearchToolCallResult::class) + ->type->toBe('image_result') + ->imageUrl->toBe('https://example.com/images/positive-story.jpg') + ->thumbnailUrl->toBe('https://example.com/images/positive-story-thumbnail.jpg') + ->sourceWebsiteUrl->toBe('https://example.com/news/positive-story') + ->caption->toBe('A positive news story'); }); test('as array accessible', function () { @@ -55,6 +66,38 @@ ->not->toHaveKey('action'); }); +test('from without results', function () { + $payload = outputWebSearchToolCall(); + unset($payload['results']); + + $response = OutputWebSearchToolCall::from($payload); + + expect($response) + ->toBeInstanceOf(OutputWebSearchToolCall::class) + ->results->toBeNull(); + + expect($response->toArray()) + ->toBeArray() + ->toBe($payload) + ->not->toHaveKey('results'); +}); + +test('from result without optional fields', function () { + $payload = outputWebSearchToolCall(); + unset($payload['results'][0]['thumbnail_url'], $payload['results'][0]['caption']); + + $response = OutputWebSearchToolCall::from($payload); + + expect($response->results[0]) + ->toBeInstanceOf(OutputWebSearchToolCallResult::class) + ->thumbnailUrl->toBeNull() + ->caption->toBeNull(); + + expect($response->toArray()) + ->toBeArray() + ->toBe($payload); +}); + test('from with action but without query', function () { $payload = outputWebSearchToolCall(); unset($payload['action']['query']); From 2a96ea6f4a8455a7805d3be1dff07faeee7c2332 Mon Sep 17 00:00:00 2001 From: Alexander Morozov Date: Tue, 18 Aug 2026 16:01:12 +0400 Subject: [PATCH 3/3] Add web search image settings --- .../Responses/Tool/WebSearchImageSettings.php | 58 +++++++++++++++++++ .../Responses/Tool/WebSearchTool.php | 22 ++++++- tests/Fixtures/Responses.php | 5 ++ .../Responses/Tool/WebSearchImageSettings.php | 38 ++++++++++++ .../Responses/Tool/WebSearchTool.php | 43 ++++++++++++++ 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/Responses/Responses/Tool/WebSearchImageSettings.php create mode 100644 tests/Responses/Responses/Tool/WebSearchImageSettings.php create mode 100644 tests/Responses/Responses/Tool/WebSearchTool.php diff --git a/src/Responses/Responses/Tool/WebSearchImageSettings.php b/src/Responses/Responses/Tool/WebSearchImageSettings.php new file mode 100644 index 000000000..f30a86a64 --- /dev/null +++ b/src/Responses/Responses/Tool/WebSearchImageSettings.php @@ -0,0 +1,58 @@ + + */ +final class WebSearchImageSettings implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + private function __construct( + public readonly ?int $maxResults, + public readonly ?bool $caption, + ) {} + + /** + * @param WebSearchImageSettingsType $attributes + */ + public static function from(array $attributes): self + { + return new self( + maxResults: $attributes['max_results'] ?? null, + caption: $attributes['caption'] ?? null, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $data = []; + + if ($this->maxResults !== null) { + $data['max_results'] = $this->maxResults; + } + + if ($this->caption !== null) { + $data['caption'] = $this->caption; + } + + return $data; + } +} diff --git a/src/Responses/Responses/Tool/WebSearchTool.php b/src/Responses/Responses/Tool/WebSearchTool.php index 41f0d2c1d..ecffffe6e 100644 --- a/src/Responses/Responses/Tool/WebSearchTool.php +++ b/src/Responses/Responses/Tool/WebSearchTool.php @@ -9,9 +9,10 @@ use OpenAI\Testing\Responses\Concerns\Fakeable; /** + * @phpstan-import-type WebSearchImageSettingsType from WebSearchImageSettings * @phpstan-import-type UserLocationType from WebSearchUserLocation * - * @phpstan-type WebSearchToolType array{type: 'web_search'|'web_search_preview'|'web_search_preview_2025_03_11', search_context_size: 'low'|'medium'|'high', user_location: ?UserLocationType} + * @phpstan-type WebSearchToolType array{type: 'web_search'|'web_search_preview'|'web_search_preview_2025_03_11', search_context_size: 'low'|'medium'|'high', user_location: ?UserLocationType, search_content_types?: array, image_settings?: WebSearchImageSettingsType} * * @implements ResponseContract */ @@ -27,11 +28,14 @@ final class WebSearchTool implements ResponseContract /** * @param 'web_search'|'web_search_preview'|'web_search_preview_2025_03_11' $type * @param 'low'|'medium'|'high' $searchContextSize + * @param ?array $searchContentTypes */ private function __construct( public readonly string $type, public readonly string $searchContextSize, public readonly ?WebSearchUserLocation $userLocation, + public readonly ?array $searchContentTypes, + public readonly ?WebSearchImageSettings $imageSettings, ) {} /** @@ -45,6 +49,10 @@ public static function from(array $attributes): self userLocation: isset($attributes['user_location']) ? WebSearchUserLocation::from($attributes['user_location']) : null, + searchContentTypes: $attributes['search_content_types'] ?? null, + imageSettings: isset($attributes['image_settings']) + ? WebSearchImageSettings::from($attributes['image_settings']) + : null, ); } @@ -53,10 +61,20 @@ public static function from(array $attributes): self */ public function toArray(): array { - return [ + $data = [ 'type' => $this->type, 'search_context_size' => $this->searchContextSize, 'user_location' => $this->userLocation?->toArray(), ]; + + if ($this->searchContentTypes !== null) { + $data['search_content_types'] = $this->searchContentTypes; + } + + if ($this->imageSettings !== null) { + $data['image_settings'] = $this->imageSettings->toArray(); + } + + return $data; } } diff --git a/tests/Fixtures/Responses.php b/tests/Fixtures/Responses.php index f496f3685..b5ffe4e05 100644 --- a/tests/Fixtures/Responses.php +++ b/tests/Fixtures/Responses.php @@ -906,6 +906,11 @@ function toolWebSearchPreview(): array 'region' => 'California', 'timezone' => 'America/Los_Angeles', ], + 'search_content_types' => ['image', 'text'], + 'image_settings' => [ + 'max_results' => 3, + 'caption' => true, + ], ]; } diff --git a/tests/Responses/Responses/Tool/WebSearchImageSettings.php b/tests/Responses/Responses/Tool/WebSearchImageSettings.php new file mode 100644 index 000000000..16a3e82ee --- /dev/null +++ b/tests/Responses/Responses/Tool/WebSearchImageSettings.php @@ -0,0 +1,38 @@ + 3, + 'caption' => true, + ]); + + expect($response) + ->toBeInstanceOf(WebSearchImageSettings::class) + ->maxResults->toBe(3) + ->caption->toBeTrue(); +}); + +test('from without optional keys', function () { + $response = WebSearchImageSettings::from([]); + + expect($response) + ->toBeInstanceOf(WebSearchImageSettings::class) + ->maxResults->toBeNull() + ->caption->toBeNull() + ->toArray()->toBe([]); +}); + +test('preserves a disabled caption setting', function () { + $response = WebSearchImageSettings::from([ + 'caption' => false, + ]); + + expect($response) + ->maxResults->toBeNull() + ->caption->toBeFalse() + ->toArray()->toBe([ + 'caption' => false, + ]); +}); diff --git a/tests/Responses/Responses/Tool/WebSearchTool.php b/tests/Responses/Responses/Tool/WebSearchTool.php new file mode 100644 index 000000000..e2b31505a --- /dev/null +++ b/tests/Responses/Responses/Tool/WebSearchTool.php @@ -0,0 +1,43 @@ +toBeInstanceOf(WebSearchTool::class) + ->type->toBe('web_search_preview') + ->searchContextSize->toBe('medium') + ->userLocation->toBeInstanceOf(WebSearchUserLocation::class) + ->searchContentTypes->toBe(['image', 'text']) + ->imageSettings->toBeInstanceOf(WebSearchImageSettings::class) + ->imageSettings->maxResults->toBe(3) + ->imageSettings->caption->toBeTrue(); +}); + +test('from without image search settings', function () { + $payload = toolWebSearchPreview(); + unset($payload['search_content_types'], $payload['image_settings']); + + $response = WebSearchTool::from($payload); + + expect($response) + ->toBeInstanceOf(WebSearchTool::class) + ->searchContentTypes->toBeNull() + ->imageSettings->toBeNull(); + + expect($response->toArray()) + ->toBe($payload) + ->not->toHaveKeys(['search_content_types', 'image_settings']); +}); + +test('to array', function () { + $response = WebSearchTool::from(toolWebSearchPreview()); + + expect($response->toArray()) + ->toBeArray() + ->toBe(toolWebSearchPreview()); +});