Skip to content

Commit d9d0e7a

Browse files
authored
feat(OpenAI): add support for actions on web_search_call (#707)
1 parent 6fd45a8 commit d9d0e7a

File tree

10 files changed

+289
-4
lines changed

10 files changed

+289
-4
lines changed

src/Responses/Responses/Output/OutputWebSearchToolCall.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
use OpenAI\Contracts\ResponseContract;
88
use OpenAI\Responses\Concerns\ArrayAccessible;
9+
use OpenAI\Responses\Responses\Output\WebSearch\OutputWebSearchAction;
910
use OpenAI\Testing\Responses\Concerns\Fakeable;
1011

1112
/**
12-
* @phpstan-type OutputWebSearchToolCallType array{id: string, status: string, type: 'web_search_call'}
13+
* @phpstan-import-type WebSearchActionType from OutputWebSearchAction
14+
*
15+
* @phpstan-type OutputWebSearchToolCallType array{id: string, status: string, type: 'web_search_call', action?: WebSearchActionType}
1316
*
1417
* @implements ResponseContract<OutputWebSearchToolCallType>
1518
*/
@@ -29,6 +32,7 @@ private function __construct(
2932
public readonly string $id,
3033
public readonly string $status,
3134
public readonly string $type,
35+
public readonly ?OutputWebSearchAction $action,
3236
) {}
3337

3438
/**
@@ -40,6 +44,9 @@ public static function from(array $attributes): self
4044
id: $attributes['id'],
4145
status: $attributes['status'],
4246
type: $attributes['type'],
47+
action: isset($attributes['action'])
48+
? OutputWebSearchAction::from($attributes['action'])
49+
: null
4350
);
4451
}
4552

@@ -48,10 +55,16 @@ public static function from(array $attributes): self
4855
*/
4956
public function toArray(): array
5057
{
51-
return [
58+
$data = [
5259
'id' => $this->id,
5360
'status' => $this->status,
5461
'type' => $this->type,
5562
];
63+
64+
if ($this->action !== null) {
65+
$data['action'] = $this->action->toArray();
66+
}
67+
68+
return $data;
5669
}
5770
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenAI\Responses\Responses\Output\WebSearch;
6+
7+
use OpenAI\Contracts\ResponseContract;
8+
use OpenAI\Responses\Concerns\ArrayAccessible;
9+
use OpenAI\Testing\Responses\Concerns\Fakeable;
10+
11+
/**
12+
* @phpstan-import-type WebSearchActionSourcesType from OutputWebSearchActionSources
13+
*
14+
* @phpstan-type WebSearchActionType array{type: 'search', query?: string, sources?: array<int, WebSearchActionSourcesType>}
15+
*
16+
* @implements ResponseContract<WebSearchActionType>
17+
*/
18+
final class OutputWebSearchAction implements ResponseContract
19+
{
20+
/**
21+
* @use ArrayAccessible<WebSearchActionType>
22+
*/
23+
use ArrayAccessible;
24+
25+
use Fakeable;
26+
27+
/**
28+
* @param 'search' $type
29+
* @param array<int, OutputWebSearchActionSources> $sources
30+
*/
31+
private function __construct(
32+
public readonly string $type,
33+
public readonly ?string $query,
34+
public readonly ?array $sources,
35+
) {}
36+
37+
/**
38+
* @param WebSearchActionType $attributes
39+
*/
40+
public static function from(array $attributes): self
41+
{
42+
return new self(
43+
type: $attributes['type'],
44+
query: $attributes['query'] ?? null,
45+
sources: isset($attributes['sources'])
46+
? array_map(
47+
static fn (array $source): OutputWebSearchActionSources => OutputWebSearchActionSources::from($source),
48+
$attributes['sources'],
49+
)
50+
: null,
51+
);
52+
}
53+
54+
/**
55+
* {@inheritDoc}
56+
*/
57+
public function toArray(): array
58+
{
59+
$data = [
60+
'type' => $this->type,
61+
];
62+
63+
if ($this->sources !== null) {
64+
$data['sources'] = array_map(
65+
static fn (OutputWebSearchActionSources $source): array => $source->toArray(),
66+
$this->sources,
67+
);
68+
}
69+
70+
if ($this->query !== null) {
71+
$data['query'] = $this->query;
72+
}
73+
74+
return $data;
75+
}
76+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenAI\Responses\Responses\Output\WebSearch;
6+
7+
use OpenAI\Contracts\ResponseContract;
8+
use OpenAI\Responses\Concerns\ArrayAccessible;
9+
use OpenAI\Testing\Responses\Concerns\Fakeable;
10+
11+
/**
12+
* @phpstan-type WebSearchActionSourcesType array{type: 'url', url: string}
13+
*
14+
* @implements ResponseContract<WebSearchActionSourcesType>
15+
*/
16+
final class OutputWebSearchActionSources implements ResponseContract
17+
{
18+
/**
19+
* @use ArrayAccessible<WebSearchActionSourcesType>
20+
*/
21+
use ArrayAccessible;
22+
23+
use Fakeable;
24+
25+
/**
26+
* @param 'url' $type
27+
*/
28+
private function __construct(
29+
public readonly string $type,
30+
public readonly string $url
31+
) {}
32+
33+
/**
34+
* @param WebSearchActionSourcesType $attributes
35+
*/
36+
public static function from(array $attributes): self
37+
{
38+
return new self(
39+
type: $attributes['type'],
40+
url: $attributes['url'],
41+
);
42+
}
43+
44+
/**
45+
* {@inheritDoc}
46+
*/
47+
public function toArray(): array
48+
{
49+
return [
50+
'type' => $this->type,
51+
'url' => $this->url,
52+
];
53+
}
54+
}

src/Testing/Responses/Fixtures/Responses/CancelResponseFixture.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ final class CancelResponseFixture
1919
'type' => 'web_search_call',
2020
'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
2121
'status' => 'completed',
22+
'action' => [
23+
'type' => 'search',
24+
'query' => 'what was a positive news story from today?',
25+
'sources' => [
26+
['type' => 'url', 'url' => 'https://example.com/news/positive-story'],
27+
['type' => 'url', 'url' => 'https://another.example.com/related-article'],
28+
],
29+
],
2230
],
2331
[
2432
'type' => 'message',

src/Testing/Responses/Fixtures/Responses/CreateResponseFixture.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ final class CreateResponseFixture
1919
'type' => 'web_search_call',
2020
'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
2121
'status' => 'completed',
22+
'action' => [
23+
'type' => 'search',
24+
'query' => 'what was a positive news story from today?',
25+
'sources' => [
26+
['type' => 'url', 'url' => 'https://example.com/news/positive-story'],
27+
['type' => 'url', 'url' => 'https://another.example.com/related-article'],
28+
],
29+
],
2230
],
2331
[
2432
'type' => 'message',

src/Testing/Responses/Fixtures/Responses/ResponseObjectFixture.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ final class ResponseObjectFixture
1919
'type' => 'web_search_call',
2020
'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
2121
'status' => 'completed',
22+
'action' => [
23+
'type' => 'search',
24+
'query' => 'what was a positive news story from today?',
25+
'sources' => [
26+
['type' => 'url', 'url' => 'https://example.com/news/positive-story'],
27+
['type' => 'url', 'url' => 'https://another.example.com/related-article'],
28+
],
29+
],
2230
],
2331
[
2432
'type' => 'message',

src/Testing/Responses/Fixtures/Responses/RetrieveResponseFixture.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ final class RetrieveResponseFixture
1919
'type' => 'web_search_call',
2020
'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
2121
'status' => 'completed',
22+
'action' => [
23+
'type' => 'search',
24+
'query' => 'what was a positive news story from today?',
25+
'sources' => [
26+
['type' => 'url', 'url' => 'https://example.com/news/positive-story'],
27+
['type' => 'url', 'url' => 'https://another.example.com/related-article'],
28+
],
29+
],
2230
],
2331
[
2432
'type' => 'message',

tests/Fixtures/Responses.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,14 @@ function outputWebSearchToolCall(): array
478478
'id' => 'ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
479479
'status' => 'completed',
480480
'type' => 'web_search_call',
481+
'action' => [
482+
'type' => 'search',
483+
'sources' => [
484+
['type' => 'url', 'url' => 'https://example.com/news/positive-story'],
485+
['type' => 'url', 'url' => 'https://another.example.com/related-article'],
486+
],
487+
'query' => 'what was a positive news story from today?',
488+
],
481489
];
482490
}
483491

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
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":{}}}
22
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":{}}}
33
data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","type":"web_search_call","status":"in_progress"}}
4-
data: {"type":"response.output_item.done","output_index":0,"item":{"id":"ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c","type":"web_search_call","status":"completed"}}
4+
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"}]}}}
55
data: {"type":"response.output_item.added","output_index":1,"item":{"id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","type":"message","status":"in_progress","role":"assistant","content":[]}}
66
data: {"type":"response.content_part.added","item_id":"msg_67ccf190ca3881909d433c50b1f6357e087bb177ab789d5c","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}}
77
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..."}
88
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..."}
99
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":"..."}]}}
1010
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":"..."}]}]}}
11-
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":{}}}
11+
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":{}}}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
use OpenAI\Responses\Responses\Output\OutputWebSearchToolCall;
4+
use OpenAI\Responses\Responses\Output\WebSearch\OutputWebSearchAction;
5+
6+
test('from with full action', function () {
7+
$response = OutputWebSearchToolCall::from(outputWebSearchToolCall());
8+
9+
expect($response)
10+
->toBeInstanceOf(OutputWebSearchToolCall::class)
11+
->id->toBe('ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c')
12+
->status->toBe('completed')
13+
->type->toBe('web_search_call')
14+
->action->toBeInstanceOf(OutputWebSearchAction::class)
15+
->action->query->toBe('what was a positive news story from today?')
16+
->action->sources->toBeArray()
17+
->action->sources->toHaveCount(2);
18+
19+
// Ensure first source is parsed correctly
20+
expect($response->action->sources[0])
21+
->type->toBe('url')
22+
->url->toBe('https://example.com/news/positive-story');
23+
});
24+
25+
test('as array accessible', function () {
26+
$response = OutputWebSearchToolCall::from(outputWebSearchToolCall());
27+
28+
expect($response['id'])->toBe('ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c');
29+
});
30+
31+
test('to array with full action', function () {
32+
$response = OutputWebSearchToolCall::from(outputWebSearchToolCall());
33+
34+
expect($response->toArray())
35+
->toBeArray()
36+
->toBe(outputWebSearchToolCall());
37+
});
38+
39+
test('from without action', function () {
40+
$payload = outputWebSearchToolCall();
41+
unset($payload['action']);
42+
43+
$response = OutputWebSearchToolCall::from($payload);
44+
45+
expect($response)
46+
->toBeInstanceOf(OutputWebSearchToolCall::class)
47+
->id->toBe('ws_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c')
48+
->status->toBe('completed')
49+
->type->toBe('web_search_call')
50+
->action->toBeNull();
51+
52+
expect($response->toArray())
53+
->toBeArray()
54+
->toBe($payload)
55+
->not->toHaveKey('action');
56+
});
57+
58+
test('from with action but without query', function () {
59+
$payload = outputWebSearchToolCall();
60+
unset($payload['action']['query']);
61+
62+
$response = OutputWebSearchToolCall::from($payload);
63+
64+
expect($response)
65+
->toBeInstanceOf(OutputWebSearchToolCall::class)
66+
->action->toBeInstanceOf(OutputWebSearchAction::class)
67+
->action->query->toBeNull()
68+
->action->sources->toHaveCount(2);
69+
70+
$array = $response->toArray();
71+
expect($array)
72+
->toBeArray()
73+
->toBe($payload);
74+
75+
expect($array['action'])
76+
->toBeArray()
77+
->not->toHaveKey('query');
78+
});
79+
80+
test('from with action but without query & sources', function () {
81+
$payload = outputWebSearchToolCall();
82+
unset($payload['action']['query']);
83+
unset($payload['action']['sources']);
84+
85+
$response = OutputWebSearchToolCall::from($payload);
86+
87+
expect($response)
88+
->toBeInstanceOf(OutputWebSearchToolCall::class)
89+
->action->toBeInstanceOf(OutputWebSearchAction::class)
90+
->action->query->toBeNull()
91+
->action->sources->toBeNull();
92+
93+
$array = $response->toArray();
94+
expect($array)
95+
->toBeArray()
96+
->toBe($payload);
97+
98+
expect($array['action'])
99+
->toBeArray()
100+
->not->toHaveKey('query')
101+
->not->toHaveKey('sources');
102+
});

0 commit comments

Comments
 (0)