Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/Responses/Responses/Output/OutputWebSearchToolCall.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutputWebSearchToolCallType>
*/
Expand All @@ -29,6 +32,7 @@ private function __construct(
public readonly string $id,
public readonly string $status,
public readonly string $type,
public readonly ?OutputWebSearchAction $action,
) {}

/**
Expand All @@ -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
);
}

Expand All @@ -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;
}
}
76 changes: 76 additions & 0 deletions src/Responses/Responses/Output/WebSearch/OutputWebSearchAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace OpenAI\Responses\Responses\Output\WebSearch;

use OpenAI\Contracts\ResponseContract;
use OpenAI\Responses\Concerns\ArrayAccessible;
use OpenAI\Testing\Responses\Concerns\Fakeable;

/**
* @phpstan-import-type WebSearchActionSourcesType from OutputWebSearchActionSources
*
* @phpstan-type WebSearchActionType array{type: 'search', query?: string, sources?: array<int, WebSearchActionSourcesType>}
*
* @implements ResponseContract<WebSearchActionType>
*/
final class OutputWebSearchAction implements ResponseContract
{
/**
* @use ArrayAccessible<WebSearchActionType>
*/
use ArrayAccessible;

use Fakeable;

/**
* @param 'search' $type
* @param array<int, OutputWebSearchActionSources> $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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace OpenAI\Responses\Responses\Output\WebSearch;

use OpenAI\Contracts\ResponseContract;
use OpenAI\Responses\Concerns\ArrayAccessible;
use OpenAI\Testing\Responses\Concerns\Fakeable;

/**
* @phpstan-type WebSearchActionSourcesType array{type: 'url', url: string}
*
* @implements ResponseContract<WebSearchActionSourcesType>
*/
final class OutputWebSearchActionSources implements ResponseContract
{
/**
* @use ArrayAccessible<WebSearchActionSourcesType>
*/
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,
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions tests/Fixtures/Responses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
],
];
}

Expand Down
4 changes: 2 additions & 2 deletions tests/Fixtures/Streams/ResponseCompletionCreate.txt
Original file line number Diff line number Diff line change
@@ -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":{}}}
102 changes: 102 additions & 0 deletions tests/Responses/Responses/Output/OutputWebSearchToolCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

use OpenAI\Responses\Responses\Output\OutputWebSearchToolCall;
use OpenAI\Responses\Responses\Output\WebSearch\OutputWebSearchAction;

test('from with full action', function () {
$response = OutputWebSearchToolCall::from(outputWebSearchToolCall());

expect($response)
->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');
});