diff --git a/src/Responses/Responses/CreateResponse.php b/src/Responses/Responses/CreateResponse.php index 578e1ae3..b1fbafd6 100644 --- a/src/Responses/Responses/CreateResponse.php +++ b/src/Responses/Responses/CreateResponse.php @@ -52,7 +52,7 @@ * @phpstan-import-type FunctionToolType from FunctionTool * @phpstan-import-type WebSearchToolType from WebSearchTool * @phpstan-import-type CodeInterpreterToolType from CodeInterpreterTool - * @phpstan-import-type ErrorType from CreateResponseError + * @phpstan-import-type ErrorType from GenericResponseError * @phpstan-import-type IncompleteDetailsType from CreateResponseIncompleteDetails * @phpstan-import-type UsageType from CreateResponseUsage * @phpstan-import-type FunctionToolChoiceType from FunctionToolChoice @@ -93,7 +93,7 @@ private function __construct( public readonly string $object, public readonly int $createdAt, public readonly string $status, - public readonly ?CreateResponseError $error, + public readonly ?GenericResponseError $error, public readonly ?CreateResponseIncompleteDetails $incompleteDetails, public readonly array|string|null $instructions, public readonly ?int $maxToolCalls, @@ -184,7 +184,7 @@ public static function from(array $attributes, MetaInformation $meta): self createdAt: $attributes['created_at'], status: $attributes['status'], error: isset($attributes['error']) - ? CreateResponseError::from($attributes['error']) + ? GenericResponseError::from($attributes['error']) : null, incompleteDetails: isset($attributes['incomplete_details']) ? CreateResponseIncompleteDetails::from($attributes['incomplete_details']) diff --git a/src/Responses/Responses/CreateResponseError.php b/src/Responses/Responses/GenericResponseError.php similarity index 83% rename from src/Responses/Responses/CreateResponseError.php rename to src/Responses/Responses/GenericResponseError.php index 42e1e625..77c5d80d 100644 --- a/src/Responses/Responses/CreateResponseError.php +++ b/src/Responses/Responses/GenericResponseError.php @@ -9,11 +9,11 @@ use OpenAI\Testing\Responses\Concerns\Fakeable; /** - * @phpstan-type ErrorType array{code: string, message: string} + * @phpstan-type ErrorType array{code: string|int, message: string} * * @implements ResponseContract */ -final class CreateResponseError implements ResponseContract +final class GenericResponseError implements ResponseContract { /** * @use ArrayAccessible @@ -33,7 +33,7 @@ private function __construct( public static function from(array $attributes): self { return new self( - code: $attributes['code'], + code: (string) $attributes['code'], message: $attributes['message'], ); } diff --git a/src/Responses/Responses/Output/OutputMcpCall.php b/src/Responses/Responses/Output/OutputMcpCall.php index 9436cc6b..d5375e50 100644 --- a/src/Responses/Responses/Output/OutputMcpCall.php +++ b/src/Responses/Responses/Output/OutputMcpCall.php @@ -6,10 +6,13 @@ use OpenAI\Contracts\ResponseContract; use OpenAI\Responses\Concerns\ArrayAccessible; +use OpenAI\Responses\Responses\GenericResponseError; use OpenAI\Testing\Responses\Concerns\Fakeable; /** - * @phpstan-type OutputMcpCallType array{id: string, server_label: string, type: 'mcp_call', approval_request_id: ?string, arguments: string, error: ?string, name: string, output: ?string} + * @phpstan-import-type ErrorType from GenericResponseError + * + * @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} * * @implements ResponseContract */ @@ -32,7 +35,7 @@ private function __construct( public readonly string $arguments, public readonly string $name, public readonly ?string $approvalRequestId = null, - public readonly ?string $error = null, + public readonly ?GenericResponseError $error = null, public readonly ?string $output = null, ) {} @@ -41,6 +44,20 @@ private function __construct( */ public static function from(array $attributes): self { + // OpenAI has odd structure (presumably a bug) where the errorType can sometimes be a full-fledged HTTP error object. + // As MCP calls are valid HTTP requests - we need to handle strings & objects here. + $errorType = null; + if (isset($attributes['error'])) { + if (is_array($attributes['error'])) { + $errorType = GenericResponseError::from($attributes['error']); + } elseif (is_string($attributes['error'])) { + $errorType = GenericResponseError::from([ + 'code' => 'unknown_error', + 'message' => $attributes['error'], + ]); + } + } + return new self( id: $attributes['id'], serverLabel: $attributes['server_label'], @@ -48,7 +65,7 @@ public static function from(array $attributes): self arguments: $attributes['arguments'], name: $attributes['name'], approvalRequestId: $attributes['approval_request_id'], - error: $attributes['error'], + error: $errorType, output: $attributes['output'], ); } @@ -65,7 +82,9 @@ public function toArray(): array 'arguments' => $this->arguments, 'name' => $this->name, 'approval_request_id' => $this->approvalRequestId, - 'error' => $this->error, + 'error' => $this->error instanceof GenericResponseError + ? $this->error->toArray() + : $this->error, 'output' => $this->output, ]; } diff --git a/src/Responses/Responses/RetrieveResponse.php b/src/Responses/Responses/RetrieveResponse.php index f2c4ea42..0606f960 100644 --- a/src/Responses/Responses/RetrieveResponse.php +++ b/src/Responses/Responses/RetrieveResponse.php @@ -53,7 +53,7 @@ * @phpstan-import-type FunctionToolType from FunctionTool * @phpstan-import-type WebSearchToolType from WebSearchTool * @phpstan-import-type CodeInterpreterToolType from CodeInterpreterTool - * @phpstan-import-type ErrorType from CreateResponseError + * @phpstan-import-type ErrorType from GenericResponseError * @phpstan-import-type IncompleteDetailsType from CreateResponseIncompleteDetails * @phpstan-import-type UsageType from CreateResponseUsage * @phpstan-import-type FunctionToolChoiceType from FunctionToolChoice @@ -94,7 +94,7 @@ private function __construct( public readonly string $object, public readonly int $createdAt, public readonly string $status, - public readonly ?CreateResponseError $error, + public readonly ?GenericResponseError $error, public readonly ?CreateResponseIncompleteDetails $incompleteDetails, public readonly array|string|null $instructions, public readonly ?int $maxToolCalls, @@ -185,7 +185,7 @@ public static function from(array $attributes, MetaInformation $meta): self createdAt: $attributes['created_at'], status: $attributes['status'], error: isset($attributes['error']) - ? CreateResponseError::from($attributes['error']) + ? GenericResponseError::from($attributes['error']) : null, incompleteDetails: isset($attributes['incomplete_details']) ? CreateResponseIncompleteDetails::from($attributes['incomplete_details']) diff --git a/src/Responses/Responses/Tool/RemoteMcpTool.php b/src/Responses/Responses/Tool/RemoteMcpTool.php index 88b0f73b..9c49005c 100644 --- a/src/Responses/Responses/Tool/RemoteMcpTool.php +++ b/src/Responses/Responses/Tool/RemoteMcpTool.php @@ -11,7 +11,7 @@ /** * @phpstan-import-type McpToolNamesFilterType from McpToolNamesFilter * - * @phpstan-type RemoteMcpToolType array{type: 'mcp', server_label: string, server_url: string, require_approval: 'never'|'always'|array<'never'|'always', McpToolNamesFilterType>|null, allowed_tools: array|McpToolNamesFilterType|null, headers: array|null} + * @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|McpToolNamesFilterType|null, headers: array|null, server_description?: string|null} * * @implements ResponseContract */ @@ -33,10 +33,13 @@ final class RemoteMcpTool implements ResponseContract private function __construct( public readonly string $type, public readonly string $serverLabel, - public readonly string $serverUrl, + public readonly ?string $serverUrl = null, public readonly string|array|null $requireApproval = null, public readonly array|McpToolNamesFilter|null $allowedTools = null, public readonly ?array $headers = null, + public readonly ?string $connectorId = null, + public readonly ?string $authorization = null, + public readonly ?string $serverDescription = null, ) {} /** @@ -59,10 +62,13 @@ public static function from(array $attributes): self return new self( type: $attributes['type'], serverLabel: $attributes['server_label'], - serverUrl: $attributes['server_url'], + serverUrl: $attributes['server_url'] ?? null, requireApproval: $requireApproval, allowedTools: $allowedTools, // @phpstan-ignore-line headers: $attributes['headers'] ?? null, + connectorId: $attributes['connector_id'] ?? null, + authorization: $attributes['authorization'] ?? null, + serverDescription: $attributes['server_description'] ?? null, ); } @@ -90,6 +96,9 @@ public function toArray(): array 'require_approval' => $requireApproval, 'allowed_tools' => $allowedTools, 'headers' => $this->headers, + 'connector_id' => $this->connectorId, + 'authorization' => $this->authorization, + 'server_description' => $this->serverDescription, ]; } } diff --git a/tests/Fixtures/Responses.php b/tests/Fixtures/Responses.php index 2de7c28a..8ecb544e 100644 --- a/tests/Fixtures/Responses.php +++ b/tests/Fixtures/Responses.php @@ -50,6 +50,7 @@ function createResponseResource(): array toolFileSearch(), toolImageGeneration(), toolRemoteMcp(), + toolConnectorMcp(), ], 'top_logprobs' => null, 'top_p' => 1.0, @@ -186,6 +187,7 @@ function retrieveResponseResource(): array toolFileSearch(), toolImageGeneration(), toolRemoteMcp(), + toolConnectorMcp(), ], 'top_logprobs' => null, 'top_p' => 1.0, @@ -311,6 +313,44 @@ function outputMcpCall(): array ]; } +/** + * @return array + */ +function outputMcpErrorCallObject(): array +{ + return [ + 'id' => 'mcp_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', + 'type' => 'mcp_call', + 'approval_request_id' => null, + 'arguments' => json_encode(['topk' => 50]), + 'error' => [ + 'type' => 'http_error', + 'code' => 401, + 'message' => 'Unauthorized', + ], + 'name' => 'list_recent_files', + 'output' => null, + 'server_label' => 'Dropbox', + ]; +} + +/** + * @return array + */ +function outputMcpErrorCallString(): array +{ + return [ + 'id' => 'mcp_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', + 'type' => 'mcp_call', + 'approval_request_id' => null, + 'arguments' => json_encode(['topk' => 50]), + 'error' => 'Missing or invalid authorization token.', + 'name' => 'list_recent_files', + 'output' => null, + 'server_label' => 'Dropbox', + ]; +} + /** * @return array */ @@ -544,6 +584,27 @@ function toolRemoteMcp(): array 'require_approval' => null, 'allowed_tools' => null, 'headers' => null, + 'connector_id' => null, + 'authorization' => null, + 'server_description' => null, + ]; +} + +/** + * @return array + */ +function toolConnectorMcp(): array +{ + return [ + 'type' => 'mcp', + 'server_label' => 'Dropbox', + 'server_url' => null, + 'require_approval' => 'never', + 'allowed_tools' => null, + 'headers' => null, + 'connector_id' => 'connector_dropbox', + 'authorization' => '', + 'server_description' => null, ]; } diff --git a/tests/Responses/Responses/Output/OutputMcpCall.php b/tests/Responses/Responses/Output/OutputMcpCall.php index 68e49266..6d4d8cc9 100644 --- a/tests/Responses/Responses/Output/OutputMcpCall.php +++ b/tests/Responses/Responses/Output/OutputMcpCall.php @@ -1,5 +1,6 @@ output->toBeNull(); }); +test('from error as http object', function () { + $response = OutputMcpCall::from(outputMcpErrorCallObject()); + + expect($response) + ->toBeInstanceOf(OutputMcpCall::class) + ->error->toBeInstanceOf(GenericResponseError::class) + ->and($response->error) + ->code->toBe('401') + ->message->toBe('Unauthorized'); +}); + +test('from error as string', function () { + $response = OutputMcpCall::from(outputMcpErrorCallString()); + + expect($response) + ->toBeInstanceOf(OutputMcpCall::class) + ->error->toBeInstanceOf(GenericResponseError::class) + ->and($response->error) + ->code->toBe('unknown_error') + ->message->toBe('Missing or invalid authorization token.'); + +}); + test('as array accessible', function () { $response = OutputMcpCall::from(outputMcpCall()); diff --git a/tests/Responses/Responses/RetrieveResponse.php b/tests/Responses/Responses/RetrieveResponse.php index 116bc08b..5f32194e 100644 --- a/tests/Responses/Responses/RetrieveResponse.php +++ b/tests/Responses/Responses/RetrieveResponse.php @@ -31,7 +31,7 @@ ->text->toBeInstanceOf(CreateResponseFormat::class) ->toolChoice->toBe('auto') ->tools->toBeArray() - ->tools->toHaveCount(4) + ->tools->toHaveCount(5) ->topP->toBe(1.0) ->truncation->toBe('disabled') ->usage->toBeInstanceOf(CreateResponseUsage::class) diff --git a/tests/Responses/Responses/Tool/RemoteMcpTool.php b/tests/Responses/Responses/Tool/RemoteMcpTool.php index c1984d50..517cdf0c 100644 --- a/tests/Responses/Responses/Tool/RemoteMcpTool.php +++ b/tests/Responses/Responses/Tool/RemoteMcpTool.php @@ -21,6 +21,16 @@ ->type->toBe('mcp'); }); +test('from connector results', function () { + $response = RemoteMcpTool::from(toolConnectorMcp()); + + expect($response) + ->toBeInstanceOf(RemoteMcpTool::class) + ->connectorId->toBe('connector_dropbox') + ->serverUrl->toBeNull() + ->serverLabel->toBe('Dropbox'); +}); + test('from object as require_approval', function () { $payload = toolRemoteMcp(); $payload['require_approval'] = [