diff --git a/src/Contracts/TransporterContract.php b/src/Contracts/TransporterContract.php index 4b077ceb..ad5edce6 100644 --- a/src/Contracts/TransporterContract.php +++ b/src/Contracts/TransporterContract.php @@ -7,6 +7,7 @@ use OpenAI\Exceptions\ErrorException; use OpenAI\Exceptions\TransporterException; use OpenAI\Exceptions\UnserializableResponse; +use OpenAI\ValueObjects\Transporter\AdaptableResponse; use OpenAI\ValueObjects\Transporter\Payload; use OpenAI\ValueObjects\Transporter\Response; use Psr\Http\Message\ResponseInterface; @@ -17,16 +18,25 @@ interface TransporterContract { /** - * Sends a request to a server. + * Sends a request to a server expecting an object back. * - * @return Response|string> + * @return Response> * * @throws ErrorException|UnserializableResponse|TransporterException */ public function requestObject(Payload $payload): Response; /** - * Sends a content request to a server. + * Sends a request to a server expecting an adaptable response (object/string) back. + * + * @return AdaptableResponse|string> + * + * @throws ErrorException|UnserializableResponse|TransporterException + */ + public function requestStringOrObject(Payload $payload): AdaptableResponse; + + /** + * Sends a content request to a server expecting a string back. * * @throws ErrorException|TransporterException */ diff --git a/src/Exceptions/UnserializableResponse.php b/src/Exceptions/UnserializableResponse.php index 2f9d0643..3a34631c 100644 --- a/src/Exceptions/UnserializableResponse.php +++ b/src/Exceptions/UnserializableResponse.php @@ -6,13 +6,11 @@ use Exception; use JsonException; +use Psr\Http\Message\ResponseInterface; final class UnserializableResponse extends Exception { - /** - * Creates a new Exception instance. - */ - public function __construct(JsonException $exception) + public function __construct(JsonException $exception, public ResponseInterface $response) { parent::__construct($exception->getMessage(), 0, $exception); } diff --git a/src/Resources/Audio.php b/src/Resources/Audio.php index 3e3f7218..545ca8c9 100644 --- a/src/Resources/Audio.php +++ b/src/Resources/Audio.php @@ -64,7 +64,7 @@ public function transcribe(array $parameters): TranscriptionResponse $payload = Payload::upload('audio/transcriptions', $parameters); /** @var Response, temperature: float, avg_logprob: float, compression_ratio: float, no_speech_prob: float, transient?: bool}>, words: array, text: string}> $response */ - $response = $this->transporter->requestObject($payload); + $response = $this->transporter->requestStringOrObject($payload); return TranscriptionResponse::from($response->data(), $response->meta()); } @@ -100,7 +100,7 @@ public function translate(array $parameters): TranslationResponse $payload = Payload::upload('audio/translations', $parameters); /** @var Response, temperature: float, avg_logprob: float, compression_ratio: float, no_speech_prob: float, transient?: bool}>, text: string}> $response */ - $response = $this->transporter->requestObject($payload); + $response = $this->transporter->requestStringOrObject($payload); return TranslationResponse::from($response->data(), $response->meta()); } diff --git a/src/Responses/Audio/SpeechStreamResponse.php b/src/Responses/Audio/SpeechStreamResponse.php index b1f5166d..16e43576 100644 --- a/src/Responses/Audio/SpeechStreamResponse.php +++ b/src/Responses/Audio/SpeechStreamResponse.php @@ -32,7 +32,6 @@ public function getIterator(): Generator public function meta(): MetaInformation { - // @phpstan-ignore-next-line return MetaInformation::from($this->response->getHeaders()); } @@ -44,7 +43,9 @@ public static function fake(?string $content = null, ?MetaInformation $meta = nu if ($meta instanceof \OpenAI\Responses\Meta\MetaInformation) { foreach ($meta->toArray() as $key => $value) { - $response = $response->withHeader($key, (string) $value); + if (is_scalar($value)) { + $response = $response->withHeader($key, (string) $value); + } } } diff --git a/src/Responses/Meta/MetaInformation.php b/src/Responses/Meta/MetaInformation.php index 4eec8f67..f5721373 100644 --- a/src/Responses/Meta/MetaInformation.php +++ b/src/Responses/Meta/MetaInformation.php @@ -6,12 +6,12 @@ use OpenAI\Responses\Concerns\ArrayAccessible; /** - * @implements MetaInformationContract + * @implements MetaInformationContract}> */ final class MetaInformation implements MetaInformationContract { /** - * @use ArrayAccessible + * @use ArrayAccessible}> */ use ArrayAccessible; @@ -20,13 +20,29 @@ private function __construct( public readonly MetaInformationOpenAI $openai, public readonly ?MetaInformationRateLimit $requestLimit, public readonly ?MetaInformationRateLimit $tokenLimit, + public readonly MetaInformationCustom $custom, ) {} /** - * @param array{x-request-id: string[], openai-model: string[], openai-organization: string[], openai-version: string[], openai-processing-ms: string[], x-ratelimit-limit-requests: string[], x-ratelimit-remaining-requests: string[], x-ratelimit-reset-requests: string[], x-ratelimit-limit-tokens: string[], x-ratelimit-remaining-tokens: string[], x-ratelimit-reset-tokens: string[]} $headers + * @param array> $headers */ public static function from(array $headers): self { + $knownHeaders = [ + 'x-request-id', + 'openai-model', + 'openai-organization', + 'openai-project', + 'openai-version', + 'openai-processing-ms', + 'x-ratelimit-limit-requests', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-reset-requests', + 'x-ratelimit-limit-tokens', + 'x-ratelimit-remaining-tokens', + 'x-ratelimit-reset-tokens', + ]; + $headers = array_change_key_case($headers, CASE_LOWER); $requestId = $headers['x-request-id'][0] ?? null; @@ -34,6 +50,7 @@ public static function from(array $headers): self $openai = MetaInformationOpenAI::from([ 'model' => $headers['openai-model'][0] ?? null, 'organization' => $headers['openai-organization'][0] ?? null, + 'project' => $headers['openai-project'][0] ?? null, 'version' => $headers['openai-version'][0] ?? null, 'processingMs' => isset($headers['openai-processing-ms'][0]) ? (int) $headers['openai-processing-ms'][0] : null, ]); @@ -58,11 +75,23 @@ public static function from(array $headers): self $tokenLimit = null; } + $customHeaders = []; + foreach ($headers as $name => $values) { + if (in_array($name, $knownHeaders, true)) { + continue; + } + + $customHeaders[$name] = $values[0] ?? null; + } + + $custom = MetaInformationCustom::from($customHeaders); + return new self( $requestId, $openai, $requestLimit, $tokenLimit, + $custom, ); } @@ -74,6 +103,7 @@ public function toArray(): array return array_filter([ 'openai-model' => $this->openai->model, 'openai-organization' => $this->openai->organization, + 'openai-project' => $this->openai->project, 'openai-processing-ms' => $this->openai->processingMs, 'openai-version' => $this->openai->version, 'x-ratelimit-limit-requests' => $this->requestLimit->limit ?? null, @@ -83,6 +113,7 @@ public function toArray(): array 'x-ratelimit-reset-requests' => $this->requestLimit->reset ?? null, 'x-ratelimit-reset-tokens' => $this->tokenLimit->reset ?? null, 'x-request-id' => $this->requestId, - ], fn (string|int|null $value): bool => ! is_null($value)); + 'custom' => ! $this->custom->isEmpty() ? $this->custom->toArray() : null, + ], fn (array|string|int|null $value): bool => ! is_null($value)); } } diff --git a/src/Responses/Meta/MetaInformationCustom.php b/src/Responses/Meta/MetaInformationCustom.php new file mode 100644 index 00000000..59faca3e --- /dev/null +++ b/src/Responses/Meta/MetaInformationCustom.php @@ -0,0 +1,34 @@ + $headers + */ + private function __construct( + public array $headers + ) {} + + /** + * @param array $headers + */ + public static function from(array $headers): self + { + return new self(array_filter($headers)); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->headers; + } + + public function isEmpty(): bool + { + return $this->headers === []; + } +} diff --git a/src/Responses/Meta/MetaInformationOpenAI.php b/src/Responses/Meta/MetaInformationOpenAI.php index 879df149..7671df2d 100644 --- a/src/Responses/Meta/MetaInformationOpenAI.php +++ b/src/Responses/Meta/MetaInformationOpenAI.php @@ -7,18 +7,20 @@ final class MetaInformationOpenAI private function __construct( public readonly ?string $model, public readonly ?string $organization, + public readonly ?string $project, public readonly ?string $version, public readonly ?int $processingMs, ) {} /** - * @param array{model: ?string, organization: ?string, version: ?string, processingMs: ?int} $attributes + * @param array{model: ?string, organization: ?string, project: ?string, version: ?string, processingMs: ?int} $attributes */ public static function from(array $attributes): self { return new self( $attributes['model'], $attributes['organization'], + $attributes['project'], $attributes['version'], $attributes['processingMs'], ); diff --git a/src/Responses/StreamResponse.php b/src/Responses/StreamResponse.php index 1fa01a46..c50bc65f 100644 --- a/src/Responses/StreamResponse.php +++ b/src/Responses/StreamResponse.php @@ -95,7 +95,6 @@ private function readLine(StreamInterface $stream): string public function meta(): MetaInformation { - // @phpstan-ignore-next-line return MetaInformation::from($this->response->getHeaders()); } } diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index a2206658..e9573479 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -12,6 +12,7 @@ use OpenAI\Exceptions\ErrorException; use OpenAI\Exceptions\TransporterException; use OpenAI\Exceptions\UnserializableResponse; +use OpenAI\ValueObjects\Transporter\AdaptableResponse; use OpenAI\ValueObjects\Transporter\BaseUri; use OpenAI\ValueObjects\Transporter\Headers; use OpenAI\ValueObjects\Transporter\Payload; @@ -50,8 +51,31 @@ public function requestObject(Payload $payload): Response $contents = (string) $response->getBody(); + $this->throwIfJsonError($response, $contents); + + try { + /** @var array{error?: array{message: string, type: string, code: string}} $data */ + $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new UnserializableResponse($jsonException, $response); + } + + return Response::from($data, $response->getHeaders()); + } + + /** + * {@inheritDoc} + */ + public function requestStringOrObject(Payload $payload): AdaptableResponse + { + $request = $payload->toRequest($this->baseUri, $this->headers, $this->queryParams); + + $response = $this->sendRequest(fn (): \Psr\Http\Message\ResponseInterface => $this->client->sendRequest($request)); + + $contents = (string) $response->getBody(); + if (str_contains($response->getHeaderLine('Content-Type'), ContentType::TEXT_PLAIN->value)) { - return Response::from($contents, $response->getHeaders()); + return AdaptableResponse::from($contents, $response->getHeaders()); } $this->throwIfJsonError($response, $contents); @@ -60,10 +84,10 @@ public function requestObject(Payload $payload): Response /** @var array{error?: array{message: string, type: string, code: string}} $data */ $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $jsonException) { - throw new UnserializableResponse($jsonException); + throw new UnserializableResponse($jsonException, $response); } - return Response::from($data, $response->getHeaders()); + return AdaptableResponse::from($data, $response->getHeaders()); } /** @@ -133,7 +157,7 @@ private function throwIfJsonError(ResponseInterface $response, string|ResponseIn throw new ErrorException($response['error'], $statusCode); } } catch (JsonException $jsonException) { - throw new UnserializableResponse($jsonException); + throw new UnserializableResponse($jsonException, $response); } } } diff --git a/src/ValueObjects/Transporter/AdaptableResponse.php b/src/ValueObjects/Transporter/AdaptableResponse.php new file mode 100644 index 00000000..a769747f --- /dev/null +++ b/src/ValueObjects/Transporter/AdaptableResponse.php @@ -0,0 +1,59 @@ +> $headers + * @return AdaptableResponse + */ + public static function from(array|string $data, array $headers): self + { + $meta = MetaInformation::from($headers); + + return new self($data, $meta); + } + + /** + * Returns the response data. + * + * @return TData + */ + public function data(): array|string + { + return $this->data; + } + + /** + * Returns the meta information. + */ + public function meta(): MetaInformation + { + return $this->meta; + } +} diff --git a/src/ValueObjects/Transporter/Response.php b/src/ValueObjects/Transporter/Response.php index 1645ab44..fe64fb02 100644 --- a/src/ValueObjects/Transporter/Response.php +++ b/src/ValueObjects/Transporter/Response.php @@ -7,11 +7,11 @@ use OpenAI\Responses\Meta\MetaInformation; /** - * @template-covariant TData of array|string + * @template-covariant TData of array * * @internal */ -final class Response +final readonly class Response { /** * Creates a new Response value object. @@ -19,8 +19,8 @@ final class Response * @param TData $data */ private function __construct( - private readonly array|string $data, - private readonly MetaInformation $meta + private array $data, + private MetaInformation $meta ) { // .. } @@ -32,9 +32,8 @@ private function __construct( * @param array> $headers * @return Response */ - public static function from(array|string $data, array $headers): self + public static function from(array $data, array $headers): self { - // @phpstan-ignore-next-line $meta = MetaInformation::from($headers); return new self($data, $meta); @@ -45,7 +44,7 @@ public static function from(array|string $data, array $headers): self * * @return TData */ - public function data(): array|string + public function data(): array { return $this->data; } diff --git a/tests/Arch.php b/tests/Arch.php index f7c8b14a..b63c7a6d 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -19,6 +19,7 @@ ->expect('OpenAI\Exceptions') ->toOnlyUse([ 'Psr\Http\Client', + 'Psr\Http\Message\ResponseInterface', ])->toImplement(Throwable::class); test('resources')->expect('OpenAI\Resources')->toOnlyUse([ diff --git a/tests/Datasets/RequestMethods.php b/tests/Datasets/RequestMethods.php new file mode 100644 index 00000000..5786aaf7 --- /dev/null +++ b/tests/Datasets/RequestMethods.php @@ -0,0 +1,6 @@ + ['gpt-3.5-turbo-instruct'], 'openai-organization' => ['org-1234'], + 'openai-project' => ['project-5678'], 'openai-processing-ms' => [410], 'openai-version' => ['2020-10-01'], 'x-ratelimit-limit-requests' => [3000], @@ -41,6 +42,15 @@ function metaHeadersWithDifferentCases(): array ]; } +function metaHeadersWithCustomCases(): array +{ + return array_merge(metaHeaders(), [ + 'Custom-Header-One' => ['custom-value-1'], + 'Custom-Header-Two' => ['custom-value-2'], + 'Custom-Blank-Header' => [null], + ]); +} + function meta(): MetaInformation { return MetaInformation::from(metaHeaders()); diff --git a/tests/Pest.php b/tests/Pest.php index e8d8de39..5105b75c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,6 +3,7 @@ use OpenAI\Client; use OpenAI\Contracts\TransporterContract; use OpenAI\ValueObjects\ApiKey; +use OpenAI\ValueObjects\Transporter\AdaptableResponse; use OpenAI\ValueObjects\Transporter\BaseUri; use OpenAI\ValueObjects\Transporter\Headers; use OpenAI\ValueObjects\Transporter\Payload; @@ -10,7 +11,7 @@ use OpenAI\ValueObjects\Transporter\Response; use Psr\Http\Message\ResponseInterface; -function mockClient(string $method, string $resource, array $params, Response|ResponseInterface|string $response, $methodName = 'requestObject', bool $validateParams = true) +function mockClient(string $method, string $resource, array $params, Response|AdaptableResponse|ResponseInterface|string $response, $methodName = 'requestObject', bool $validateParams = true) { $transporter = Mockery::mock(TransporterContract::class); diff --git a/tests/Resources/Audio.php b/tests/Resources/Audio.php index 574ff214..c6f88079 100644 --- a/tests/Resources/Audio.php +++ b/tests/Resources/Audio.php @@ -10,13 +10,14 @@ use OpenAI\Responses\Audio\TranslationResponseSegment; use OpenAI\Responses\Meta\MetaInformation; use OpenAI\Responses\StreamResponse; +use OpenAI\ValueObjects\Transporter\AdaptableResponse; test('transcribe to text', function () { $client = mockClient('POST', 'audio/transcriptions', [ 'file' => audioFileResource(), 'model' => 'whisper-1', 'response_format' => 'text', - ], \OpenAI\ValueObjects\Transporter\Response::from(audioTranscriptionText(), metaHeaders()), validateParams: false); + ], AdaptableResponse::from(audioTranscriptionText(), metaHeaders()), methodName: 'requestStringOrObject', validateParams: false); $result = $client->audio()->transcribe([ 'file' => audioFileResource(), @@ -41,7 +42,7 @@ 'file' => audioFileResource(), 'model' => 'whisper-1', 'response_format' => 'json', - ], \OpenAI\ValueObjects\Transporter\Response::from(audioTranscriptionJson(), metaHeaders()), validateParams: false); + ], AdaptableResponse::from(audioTranscriptionJson(), metaHeaders()), methodName: 'requestStringOrObject', validateParams: false); $result = $client->audio()->transcribe([ 'file' => audioFileResource(), @@ -66,7 +67,7 @@ 'file' => audioFileResource(), 'model' => 'whisper-1', 'response_format' => 'verbose_json', - ], \OpenAI\ValueObjects\Transporter\Response::from(audioTranscriptionVerboseJson(), metaHeaders()), validateParams: false); + ], AdaptableResponse::from(audioTranscriptionVerboseJson(), metaHeaders()), methodName: 'requestStringOrObject', validateParams: false); $result = $client->audio()->transcribe([ 'file' => audioFileResource(), @@ -138,7 +139,7 @@ 'file' => audioFileResource(), 'model' => 'whisper-1', 'response_format' => 'text', - ], \OpenAI\ValueObjects\Transporter\Response::from(audioTranslationText(), metaHeaders()), validateParams: false); + ], AdaptableResponse::from(audioTranslationText(), metaHeaders()), methodName: 'requestStringOrObject', validateParams: false); $result = $client->audio()->translate([ 'file' => audioFileResource(), @@ -163,7 +164,7 @@ 'file' => audioFileResource(), 'model' => 'whisper-1', 'response_format' => 'json', - ], \OpenAI\ValueObjects\Transporter\Response::from(audioTranslationJson(), metaHeaders()), validateParams: false); + ], AdaptableResponse::from(audioTranslationJson(), metaHeaders()), methodName: 'requestStringOrObject', validateParams: false); $result = $client->audio()->translate([ 'file' => audioFileResource(), @@ -188,7 +189,7 @@ 'file' => audioFileResource(), 'model' => 'whisper-1', 'response_format' => 'verbose_json', - ], \OpenAI\ValueObjects\Transporter\Response::from(audioTranslationVerboseJson(), metaHeaders()), validateParams: false); + ], AdaptableResponse::from(audioTranslationVerboseJson(), metaHeaders()), methodName: 'requestStringOrObject', validateParams: false); $result = $client->audio()->translate([ 'file' => audioFileResource(), diff --git a/tests/Responses/Meta/MetaInformation.php b/tests/Responses/Meta/MetaInformation.php index 2aa0138b..8914ed69 100644 --- a/tests/Responses/Meta/MetaInformation.php +++ b/tests/Responses/Meta/MetaInformation.php @@ -1,6 +1,7 @@ tokenLimit->reset->toBe('2ms'); }); +test('includes custom headers', function () { + $headers = metaHeaders(); + $headers['x-custom-foo'] = ['bar']; + $headers['Another-Header'] = ['baz']; + + $meta = MetaInformation::from((new \GuzzleHttp\Psr7\Response(headers: $headers))->getHeaders()); + + $array = $meta->toArray(); + + expect($array['custom']) + ->toBeArray() + ->toHaveKey('x-custom-foo', 'bar') + ->toHaveKey('another-header', 'baz'); + + expect($meta['custom']['x-custom-foo'])->toBe('bar'); +}); + test('from response headers without "x-request-id"', function () { $headers = metaHeaders(); unset($headers['x-request-id']); @@ -97,6 +115,22 @@ ->tokenLimit->toBeNull(); }); +test('from response headers with custom headers', function () { + $meta = MetaInformation::from((new \GuzzleHttp\Psr7\Response(headers: metaHeadersWithCustomCases()))->getHeaders()); + + expect($meta) + ->toBeInstanceOf(MetaInformation::class) + ->requestId->toBe('3813fa4fa3f17bdf0d7654f0f49ebab4') + ->custom->toBeInstanceOf(MetaInformationCustom::class); + + expect($meta->custom) + ->headers->toBeArray(); + + expect($meta->custom->headers) + ->toHaveKey('custom-header-one') + ->toHaveKey('custom-header-two'); +}); + test('as array accessible', function () { $meta = MetaInformation::from(metaHeaders()); @@ -111,6 +145,7 @@ ->toBe([ 'openai-model' => 'gpt-3.5-turbo-instruct', 'openai-organization' => 'org-1234', + 'openai-project' => 'project-5678', 'openai-processing-ms' => 410, 'openai-version' => '2020-10-01', 'x-ratelimit-limit-requests' => 3000, diff --git a/tests/Transporters/HttpTransporter.php b/tests/Transporters/HttpTransporter.php index 4fd772af..b8063f1c 100644 --- a/tests/Transporters/HttpTransporter.php +++ b/tests/Transporters/HttpTransporter.php @@ -7,6 +7,7 @@ use OpenAI\Exceptions\ErrorException; use OpenAI\Exceptions\TransporterException; use OpenAI\Exceptions\UnserializableResponse; +use OpenAI\Responses\Models\ListResponse; use OpenAI\Transporters\HttpTransporter; use OpenAI\ValueObjects\ApiKey; use OpenAI\ValueObjects\Transporter\BaseUri; @@ -31,7 +32,7 @@ ); }); -test('request object', function () { +test('request object', function (string $requestMethod) { $payload = Payload::list('models'); $response = new Response(200, ['Content-Type' => 'application/json; charset=utf-8', ...metaHeaders()], json_encode([ @@ -51,10 +52,10 @@ return true; })->andReturn($response); - $this->http->requestObject($payload); -}); + $this->http->$requestMethod($payload); +})->with('request methods'); -test('request object response', function () { +test('request object response', function (string $requestMethod) { $payload = Payload::list('models'); $response = new Response(200, ['Content-Type' => 'application/json; charset=utf-8', ...metaHeaders()], json_encode([ @@ -71,7 +72,7 @@ ->once() ->andReturn($response); - $response = $this->http->requestObject($payload); + $response = $this->http->$requestMethod($payload); expect($response->data())->toBe([ [ @@ -81,9 +82,9 @@ 'finish_reason' => 'length', ], ]); -}); +})->with('request methods'); -test('request object server user errors', function () { +test('request object server user errors', function (string $requestMethod) { $payload = Payload::list('models'); $response = new Response(401, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -100,7 +101,7 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') @@ -108,7 +109,7 @@ ->and($e->getErrorType())->toBe('invalid_request_error') ->and($e->getStatusCode())->toBe(401); }); -}); +})->with('request methods'); test('request object server errors', function () { $payload = Payload::create('completions', ['model' => 'gpt-4']); @@ -136,7 +137,7 @@ }); }); -test('error code may be null', function () { +test('error code may be null', function (string $requestMethod) { $payload = Payload::create('completions', ['model' => 'gpt-42']); $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -153,16 +154,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorCode())->toBeNull() ->and($e->getErrorType())->toBe('invalid_request_error'); }); -}); +})->with('request methods'); -test('error code may be integer', function () { +test('error code may be integer', function (string $requestMethod) { $payload = Payload::create('completions', ['model' => 'gpt-42']); $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -179,16 +180,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorCode())->toBe(123) ->and($e->getErrorType())->toBe('invalid_request_error'); }); -}); +})->with('request methods'); -test('error type may be null', function () { +test('error type may be null', function (string $requestMethod) { $payload = Payload::list('models'); $response = new Response(429, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -205,16 +206,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('You exceeded your current quota, please check') ->and($e->getErrorMessage())->toBe('You exceeded your current quota, please check') ->and($e->getErrorCode())->toBe('quota_exceeded') ->and($e->getErrorType())->toBeNull(); }); -}); +})->with('request methods'); -test('error message may be an array', function () { +test('error message may be an array', function (string $requestMethod) { $payload = Payload::create('completions', ['model' => 'gpt-4']); $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -234,16 +235,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('Invalid schema for function \'get_current_weather\':'.PHP_EOL.'In context=(\'properties\', \'location\'), array schema missing items') ->and($e->getErrorMessage())->toBe('Invalid schema for function \'get_current_weather\':'.PHP_EOL.'In context=(\'properties\', \'location\'), array schema missing items') ->and($e->getErrorCode())->toBeNull() ->and($e->getErrorType())->toBe('invalid_request_error'); }); -}); +})->with('request methods'); -test('error message may be empty', function () { +test('error message may be empty', function (string $requestMethod) { $payload = Payload::create('completions', ['model' => 'gpt-4']); $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -260,16 +261,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('invalid_api_key') ->and($e->getErrorMessage())->toBe('invalid_api_key') ->and($e->getErrorCode())->toBe('invalid_api_key') ->and($e->getErrorType())->toBe('invalid_request_error'); }); -}); +})->with('request methods'); -test('error message may be empty and code is an integer', function () { +test('error message may be empty and code is an integer', function (string $requestMethod) { $payload = Payload::create('completions', ['model' => 'gpt-4']); $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -286,16 +287,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('123') ->and($e->getErrorMessage())->toBe('123') ->and($e->getErrorCode())->toBe(123) ->and($e->getErrorType())->toBe('invalid_request_error'); }); -}); +})->with('request methods'); -test('error message and code may be empty', function () { +test('error message and code may be empty', function (string $requestMethod) { $payload = Payload::create('completions', ['model' => 'gpt-4']); $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ @@ -312,16 +313,16 @@ ->once() ->andReturn($response); - expect(fn () => $this->http->requestObject($payload)) + expect(fn () => $this->http->$requestMethod($payload)) ->toThrow(function (ErrorException $e) { expect($e->getMessage())->toBe('Unknown error') ->and($e->getErrorMessage())->toBe('Unknown error') ->and($e->getErrorCode())->toBeNull() ->and($e->getErrorType())->toBe('invalid_request_error'); }); -}); +})->with('request methods'); -test('request object client errors', function () { +test('request object client errors', function (string $requestMethod) { $payload = Payload::list('models'); $baseUri = BaseUri::from('api.openai.com'); @@ -333,14 +334,14 @@ ->once() ->andThrow(new ConnectException('Could not resolve host.', $payload->toRequest($baseUri, $headers, $queryParams))); - expect(fn () => $this->http->requestObject($payload))->toThrow(function (TransporterException $e) { + expect(fn () => $this->http->$requestMethod($payload))->toThrow(function (TransporterException $e) { expect($e->getMessage())->toBe('Could not resolve host.') ->and($e->getCode())->toBe(0) ->and($e->getPrevious())->toBeInstanceOf(ConnectException::class); }); -}); +})->with('request methods'); -test('request object client error in response', function () { +test('request object client error in response', function (string $requestMethod) { $payload = Payload::list('models'); $baseUri = BaseUri::from('api.openai.com'); @@ -363,13 +364,13 @@ ])) )); - expect(fn () => $this->http->requestObject($payload))->toThrow(function (ErrorException $e) { + expect(fn () => $this->http->$requestMethod($payload))->toThrow(function (ErrorException $e) { expect($e->getMessage()) ->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.'); }); -}); +})->with('request methods'); -test('request object serialization errors', function () { +test('request object serialization errors', function (string $requestMethod) { $payload = Payload::list('models'); $response = new Response(200, ['Content-Type' => 'application/json; charset=utf-8'], 'err'); @@ -379,10 +380,24 @@ ->once() ->andReturn($response); - $this->http->requestObject($payload); + $this->http->$requestMethod($payload); +})->with('request methods')->throws(UnserializableResponse::class, 'Syntax error', 0); + +test('request object invalid server 404 html', function () { + $payload = Payload::list('models'); + + $response = new Response(404, ['Content-Type' => 'text/plain; charset=utf-8'], '404 page not found'); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andReturn($response); + + $response = $this->http->requestObject($payload); + ListResponse::from($response->data(), $response->meta()); })->throws(UnserializableResponse::class, 'Syntax error', 0); -test('request plain text', function () { +test('request string or object text', function () { $payload = Payload::upload('audio/transcriptions', []); $response = new Response(200, ['Content-Type' => 'text/plain; charset=utf-8', ...metaHeaders()], 'Hello, how are you?'); @@ -392,7 +407,7 @@ ->once() ->andReturn($response); - $response = $this->http->requestObject($payload); + $response = $this->http->requestStringOrObject($payload); expect($response->data())->toBe('Hello, how are you?'); });