Skip to content

Commit 049f417

Browse files
authored
fix(OpenAI): augment support for Response API - MCP Connectors (#654)
* fix(OpenAI): augment support for Response API - MCP Connectors * fix(OpenAI): rework error response into generic class * fix(OpenAI): update tests and rework code param * chore(OpenAI): pint * test(OpenAI): assertions for mcp errors
1 parent 03ac006 commit 049f417

File tree

9 files changed

+140
-17
lines changed

9 files changed

+140
-17
lines changed

src/Responses/Responses/CreateResponse.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
* @phpstan-import-type FunctionToolType from FunctionTool
5353
* @phpstan-import-type WebSearchToolType from WebSearchTool
5454
* @phpstan-import-type CodeInterpreterToolType from CodeInterpreterTool
55-
* @phpstan-import-type ErrorType from CreateResponseError
55+
* @phpstan-import-type ErrorType from GenericResponseError
5656
* @phpstan-import-type IncompleteDetailsType from CreateResponseIncompleteDetails
5757
* @phpstan-import-type UsageType from CreateResponseUsage
5858
* @phpstan-import-type FunctionToolChoiceType from FunctionToolChoice
@@ -93,7 +93,7 @@ private function __construct(
9393
public readonly string $object,
9494
public readonly int $createdAt,
9595
public readonly string $status,
96-
public readonly ?CreateResponseError $error,
96+
public readonly ?GenericResponseError $error,
9797
public readonly ?CreateResponseIncompleteDetails $incompleteDetails,
9898
public readonly array|string|null $instructions,
9999
public readonly ?int $maxToolCalls,
@@ -184,7 +184,7 @@ public static function from(array $attributes, MetaInformation $meta): self
184184
createdAt: $attributes['created_at'],
185185
status: $attributes['status'],
186186
error: isset($attributes['error'])
187-
? CreateResponseError::from($attributes['error'])
187+
? GenericResponseError::from($attributes['error'])
188188
: null,
189189
incompleteDetails: isset($attributes['incomplete_details'])
190190
? CreateResponseIncompleteDetails::from($attributes['incomplete_details'])

src/Responses/Responses/CreateResponseError.php renamed to src/Responses/Responses/GenericResponseError.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
use OpenAI\Testing\Responses\Concerns\Fakeable;
1010

1111
/**
12-
* @phpstan-type ErrorType array{code: string, message: string}
12+
* @phpstan-type ErrorType array{code: string|int, message: string}
1313
*
1414
* @implements ResponseContract<ErrorType>
1515
*/
16-
final class CreateResponseError implements ResponseContract
16+
final class GenericResponseError implements ResponseContract
1717
{
1818
/**
1919
* @use ArrayAccessible<ErrorType>
@@ -33,7 +33,7 @@ private function __construct(
3333
public static function from(array $attributes): self
3434
{
3535
return new self(
36-
code: $attributes['code'],
36+
code: (string) $attributes['code'],
3737
message: $attributes['message'],
3838
);
3939
}

src/Responses/Responses/Output/OutputMcpCall.php

Lines changed: 23 additions & 4 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\GenericResponseError;
910
use OpenAI\Testing\Responses\Concerns\Fakeable;
1011

1112
/**
12-
* @phpstan-type OutputMcpCallType array{id: string, server_label: string, type: 'mcp_call', approval_request_id: ?string, arguments: string, error: ?string, name: string, output: ?string}
13+
* @phpstan-import-type ErrorType from GenericResponseError
14+
*
15+
* @phpstan-type OutputMcpCallType array{id: string, server_label: string, type: 'mcp_call', approval_request_id: ?string, arguments: string, error: string|ErrorType|null, name: string, output: ?string}
1316
*
1417
* @implements ResponseContract<OutputMcpCallType>
1518
*/
@@ -32,7 +35,7 @@ private function __construct(
3235
public readonly string $arguments,
3336
public readonly string $name,
3437
public readonly ?string $approvalRequestId = null,
35-
public readonly ?string $error = null,
38+
public readonly ?GenericResponseError $error = null,
3639
public readonly ?string $output = null,
3740
) {}
3841

@@ -41,14 +44,28 @@ private function __construct(
4144
*/
4245
public static function from(array $attributes): self
4346
{
47+
// OpenAI has odd structure (presumably a bug) where the errorType can sometimes be a full-fledged HTTP error object.
48+
// As MCP calls are valid HTTP requests - we need to handle strings & objects here.
49+
$errorType = null;
50+
if (isset($attributes['error'])) {
51+
if (is_array($attributes['error'])) {
52+
$errorType = GenericResponseError::from($attributes['error']);
53+
} elseif (is_string($attributes['error'])) {
54+
$errorType = GenericResponseError::from([
55+
'code' => 'unknown_error',
56+
'message' => $attributes['error'],
57+
]);
58+
}
59+
}
60+
4461
return new self(
4562
id: $attributes['id'],
4663
serverLabel: $attributes['server_label'],
4764
type: $attributes['type'],
4865
arguments: $attributes['arguments'],
4966
name: $attributes['name'],
5067
approvalRequestId: $attributes['approval_request_id'],
51-
error: $attributes['error'],
68+
error: $errorType,
5269
output: $attributes['output'],
5370
);
5471
}
@@ -65,7 +82,9 @@ public function toArray(): array
6582
'arguments' => $this->arguments,
6683
'name' => $this->name,
6784
'approval_request_id' => $this->approvalRequestId,
68-
'error' => $this->error,
85+
'error' => $this->error instanceof GenericResponseError
86+
? $this->error->toArray()
87+
: $this->error,
6988
'output' => $this->output,
7089
];
7190
}

src/Responses/Responses/RetrieveResponse.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
* @phpstan-import-type FunctionToolType from FunctionTool
5454
* @phpstan-import-type WebSearchToolType from WebSearchTool
5555
* @phpstan-import-type CodeInterpreterToolType from CodeInterpreterTool
56-
* @phpstan-import-type ErrorType from CreateResponseError
56+
* @phpstan-import-type ErrorType from GenericResponseError
5757
* @phpstan-import-type IncompleteDetailsType from CreateResponseIncompleteDetails
5858
* @phpstan-import-type UsageType from CreateResponseUsage
5959
* @phpstan-import-type FunctionToolChoiceType from FunctionToolChoice
@@ -94,7 +94,7 @@ private function __construct(
9494
public readonly string $object,
9595
public readonly int $createdAt,
9696
public readonly string $status,
97-
public readonly ?CreateResponseError $error,
97+
public readonly ?GenericResponseError $error,
9898
public readonly ?CreateResponseIncompleteDetails $incompleteDetails,
9999
public readonly array|string|null $instructions,
100100
public readonly ?int $maxToolCalls,
@@ -185,7 +185,7 @@ public static function from(array $attributes, MetaInformation $meta): self
185185
createdAt: $attributes['created_at'],
186186
status: $attributes['status'],
187187
error: isset($attributes['error'])
188-
? CreateResponseError::from($attributes['error'])
188+
? GenericResponseError::from($attributes['error'])
189189
: null,
190190
incompleteDetails: isset($attributes['incomplete_details'])
191191
? CreateResponseIncompleteDetails::from($attributes['incomplete_details'])

src/Responses/Responses/Tool/RemoteMcpTool.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
/**
1212
* @phpstan-import-type McpToolNamesFilterType from McpToolNamesFilter
1313
*
14-
* @phpstan-type RemoteMcpToolType array{type: 'mcp', server_label: string, server_url: string, require_approval: 'never'|'always'|array<'never'|'always', McpToolNamesFilterType>|null, allowed_tools: array<int, string>|McpToolNamesFilterType|null, headers: array<string, string>|null}
14+
* @phpstan-type RemoteMcpToolType array{type: 'mcp', server_label: string, authorization: string|null, connector_id?: string|null, server_url: string|null, require_approval: 'never'|'always'|array<'never'|'always', McpToolNamesFilterType>|null, allowed_tools: array<int, string>|McpToolNamesFilterType|null, headers: array<string, string>|null, server_description?: string|null}
1515
*
1616
* @implements ResponseContract<RemoteMcpToolType>
1717
*/
@@ -33,10 +33,13 @@ final class RemoteMcpTool implements ResponseContract
3333
private function __construct(
3434
public readonly string $type,
3535
public readonly string $serverLabel,
36-
public readonly string $serverUrl,
36+
public readonly ?string $serverUrl = null,
3737
public readonly string|array|null $requireApproval = null,
3838
public readonly array|McpToolNamesFilter|null $allowedTools = null,
3939
public readonly ?array $headers = null,
40+
public readonly ?string $connectorId = null,
41+
public readonly ?string $authorization = null,
42+
public readonly ?string $serverDescription = null,
4043
) {}
4144

4245
/**
@@ -59,10 +62,13 @@ public static function from(array $attributes): self
5962
return new self(
6063
type: $attributes['type'],
6164
serverLabel: $attributes['server_label'],
62-
serverUrl: $attributes['server_url'],
65+
serverUrl: $attributes['server_url'] ?? null,
6366
requireApproval: $requireApproval,
6467
allowedTools: $allowedTools, // @phpstan-ignore-line
6568
headers: $attributes['headers'] ?? null,
69+
connectorId: $attributes['connector_id'] ?? null,
70+
authorization: $attributes['authorization'] ?? null,
71+
serverDescription: $attributes['server_description'] ?? null,
6672
);
6773
}
6874

@@ -90,6 +96,9 @@ public function toArray(): array
9096
'require_approval' => $requireApproval,
9197
'allowed_tools' => $allowedTools,
9298
'headers' => $this->headers,
99+
'connector_id' => $this->connectorId,
100+
'authorization' => $this->authorization,
101+
'server_description' => $this->serverDescription,
93102
];
94103
}
95104
}

tests/Fixtures/Responses.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function createResponseResource(): array
5050
toolFileSearch(),
5151
toolImageGeneration(),
5252
toolRemoteMcp(),
53+
toolConnectorMcp(),
5354
],
5455
'top_logprobs' => null,
5556
'top_p' => 1.0,
@@ -186,6 +187,7 @@ function retrieveResponseResource(): array
186187
toolFileSearch(),
187188
toolImageGeneration(),
188189
toolRemoteMcp(),
190+
toolConnectorMcp(),
189191
],
190192
'top_logprobs' => null,
191193
'top_p' => 1.0,
@@ -311,6 +313,44 @@ function outputMcpCall(): array
311313
];
312314
}
313315

316+
/**
317+
* @return array<string, mixed>
318+
*/
319+
function outputMcpErrorCallObject(): array
320+
{
321+
return [
322+
'id' => 'mcp_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
323+
'type' => 'mcp_call',
324+
'approval_request_id' => null,
325+
'arguments' => json_encode(['topk' => 50]),
326+
'error' => [
327+
'type' => 'http_error',
328+
'code' => 401,
329+
'message' => 'Unauthorized',
330+
],
331+
'name' => 'list_recent_files',
332+
'output' => null,
333+
'server_label' => 'Dropbox',
334+
];
335+
}
336+
337+
/**
338+
* @return array<string, mixed>
339+
*/
340+
function outputMcpErrorCallString(): array
341+
{
342+
return [
343+
'id' => 'mcp_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c',
344+
'type' => 'mcp_call',
345+
'approval_request_id' => null,
346+
'arguments' => json_encode(['topk' => 50]),
347+
'error' => 'Missing or invalid authorization token.',
348+
'name' => 'list_recent_files',
349+
'output' => null,
350+
'server_label' => 'Dropbox',
351+
];
352+
}
353+
314354
/**
315355
* @return array<string, mixed>
316356
*/
@@ -544,6 +584,27 @@ function toolRemoteMcp(): array
544584
'require_approval' => null,
545585
'allowed_tools' => null,
546586
'headers' => null,
587+
'connector_id' => null,
588+
'authorization' => null,
589+
'server_description' => null,
590+
];
591+
}
592+
593+
/**
594+
* @return array<string, mixed>
595+
*/
596+
function toolConnectorMcp(): array
597+
{
598+
return [
599+
'type' => 'mcp',
600+
'server_label' => 'Dropbox',
601+
'server_url' => null,
602+
'require_approval' => 'never',
603+
'allowed_tools' => null,
604+
'headers' => null,
605+
'connector_id' => 'connector_dropbox',
606+
'authorization' => '<redacted>',
607+
'server_description' => null,
547608
];
548609
}
549610

tests/Responses/Responses/Output/OutputMcpCall.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use OpenAI\Responses\Responses\GenericResponseError;
34
use OpenAI\Responses\Responses\Output\OutputMcpCall;
45

56
test('from', function () {
@@ -17,6 +18,29 @@
1718
->output->toBeNull();
1819
});
1920

21+
test('from error as http object', function () {
22+
$response = OutputMcpCall::from(outputMcpErrorCallObject());
23+
24+
expect($response)
25+
->toBeInstanceOf(OutputMcpCall::class)
26+
->error->toBeInstanceOf(GenericResponseError::class)
27+
->and($response->error)
28+
->code->toBe('401')
29+
->message->toBe('Unauthorized');
30+
});
31+
32+
test('from error as string', function () {
33+
$response = OutputMcpCall::from(outputMcpErrorCallString());
34+
35+
expect($response)
36+
->toBeInstanceOf(OutputMcpCall::class)
37+
->error->toBeInstanceOf(GenericResponseError::class)
38+
->and($response->error)
39+
->code->toBe('unknown_error')
40+
->message->toBe('Missing or invalid authorization token.');
41+
42+
});
43+
2044
test('as array accessible', function () {
2145
$response = OutputMcpCall::from(outputMcpCall());
2246

tests/Responses/Responses/RetrieveResponse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
->text->toBeInstanceOf(CreateResponseFormat::class)
3232
->toolChoice->toBe('auto')
3333
->tools->toBeArray()
34-
->tools->toHaveCount(4)
34+
->tools->toHaveCount(5)
3535
->topP->toBe(1.0)
3636
->truncation->toBe('disabled')
3737
->usage->toBeInstanceOf(CreateResponseUsage::class)

tests/Responses/Responses/Tool/RemoteMcpTool.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@
2121
->type->toBe('mcp');
2222
});
2323

24+
test('from connector results', function () {
25+
$response = RemoteMcpTool::from(toolConnectorMcp());
26+
27+
expect($response)
28+
->toBeInstanceOf(RemoteMcpTool::class)
29+
->connectorId->toBe('connector_dropbox')
30+
->serverUrl->toBeNull()
31+
->serverLabel->toBe('Dropbox');
32+
});
33+
2434
test('from object as require_approval', function () {
2535
$payload = toolRemoteMcp();
2636
$payload['require_approval'] = [

0 commit comments

Comments
 (0)