From 9094b9794826cd2ac1a168a78ca4de7dc36b165c Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Wed, 13 Aug 2025 21:28:02 -0400 Subject: [PATCH 1/7] chore(meta): rework exception handling --- src/Contracts/TransporterContract.php | 2 +- src/Exceptions/UnserializableResponse.php | 6 ++---- src/Transporters/HttpTransporter.php | 6 +----- src/ValueObjects/Transporter/Response.php | 8 ++++---- tests/Transporters/HttpTransporter.php | 15 +++++++++++++++ 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/Contracts/TransporterContract.php b/src/Contracts/TransporterContract.php index 4b077ceb..daedf0ec 100644 --- a/src/Contracts/TransporterContract.php +++ b/src/Contracts/TransporterContract.php @@ -19,7 +19,7 @@ interface TransporterContract /** * Sends a request to a server. * - * @return Response|string> + * @return Response> * * @throws ErrorException|UnserializableResponse|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/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index a2206658..0379c602 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -50,17 +50,13 @@ public function requestObject(Payload $payload): Response $contents = (string) $response->getBody(); - if (str_contains($response->getHeaderLine('Content-Type'), ContentType::TEXT_PLAIN->value)) { - return Response::from($contents, $response->getHeaders()); - } - $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); + throw new UnserializableResponse($jsonException, $response); } return Response::from($data, $response->getHeaders()); diff --git a/src/ValueObjects/Transporter/Response.php b/src/ValueObjects/Transporter/Response.php index 1645ab44..84d6114b 100644 --- a/src/ValueObjects/Transporter/Response.php +++ b/src/ValueObjects/Transporter/Response.php @@ -7,7 +7,7 @@ use OpenAI\Responses\Meta\MetaInformation; /** - * @template-covariant TData of array|string + * @template-covariant TData of array * * @internal */ @@ -19,7 +19,7 @@ final class Response * @param TData $data */ private function __construct( - private readonly array|string $data, + private readonly array $data, private readonly MetaInformation $meta ) { // .. @@ -32,7 +32,7 @@ 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); @@ -45,7 +45,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/Transporters/HttpTransporter.php b/tests/Transporters/HttpTransporter.php index 4fd772af..929566c2 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; @@ -382,6 +383,20 @@ $this->http->requestObject($payload); })->throws(UnserializableResponse::class, 'Syntax error', 0); +test('request object 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 () { $payload = Payload::upload('audio/transcriptions', []); From 62523b3b4e2aac4bc0a205113d66453f287409ed Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Thu, 14 Aug 2025 06:27:22 -0400 Subject: [PATCH 2/7] chore: introduce AdaptableResponse (string or object) --- src/Contracts/TransporterContract.php | 14 ++++- src/Resources/Audio.php | 4 +- src/Transporters/HttpTransporter.php | 30 +++++++++- .../Transporter/AdaptableResponse.php | 60 +++++++++++++++++++ src/ValueObjects/Transporter/Response.php | 6 +- tests/Arch.php | 1 + tests/Pest.php | 3 +- tests/Resources/Audio.php | 13 ++-- tests/Transporters/HttpTransporter.php | 2 +- 9 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 src/ValueObjects/Transporter/AdaptableResponse.php diff --git a/src/Contracts/TransporterContract.php b/src/Contracts/TransporterContract.php index daedf0ec..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,7 +18,7 @@ interface TransporterContract { /** - * Sends a request to a server. + * Sends a request to a server expecting an object back. * * @return Response> * @@ -26,7 +27,16 @@ interface TransporterContract 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/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/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index 0379c602..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; @@ -62,6 +63,33 @@ public function requestObject(Payload $payload): 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 AdaptableResponse::from($contents, $response->getHeaders()); + } + + $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 AdaptableResponse::from($data, $response->getHeaders()); + } + /** * {@inheritDoc} */ @@ -129,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..9e0c75fb --- /dev/null +++ b/src/ValueObjects/Transporter/AdaptableResponse.php @@ -0,0 +1,60 @@ +> $headers + * @return AdaptableResponse + */ + public static function from(array|string $data, array $headers): self + { + // @phpstan-ignore-next-line + $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 84d6114b..100979cf 100644 --- a/src/ValueObjects/Transporter/Response.php +++ b/src/ValueObjects/Transporter/Response.php @@ -11,7 +11,7 @@ * * @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 $data, - private readonly MetaInformation $meta + private array $data, + private MetaInformation $meta ) { // .. } 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/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/Transporters/HttpTransporter.php b/tests/Transporters/HttpTransporter.php index 929566c2..037e4588 100644 --- a/tests/Transporters/HttpTransporter.php +++ b/tests/Transporters/HttpTransporter.php @@ -407,7 +407,7 @@ ->once() ->andReturn($response); - $response = $this->http->requestObject($payload); + $response = $this->http->requestStringOrObject($payload); expect($response->data())->toBe('Hello, how are you?'); }); From 1616096f65726c631a109348583f787be53c3edf Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Thu, 14 Aug 2025 07:15:47 -0400 Subject: [PATCH 3/7] chore: test object | string/object the same --- tests/Datasets/RequestMethods.php | 6 ++ tests/Transporters/HttpTransporter.php | 82 +++++++++++++------------- 2 files changed, 47 insertions(+), 41 deletions(-) create mode 100644 tests/Datasets/RequestMethods.php 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 @@ + 'application/json; charset=utf-8', ...metaHeaders()], json_encode([ @@ -52,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([ @@ -72,7 +72,7 @@ ->once() ->andReturn($response); - $response = $this->http->requestObject($payload); + $response = $this->http->$requestMethod($payload); expect($response->data())->toBe([ [ @@ -82,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([ @@ -101,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.') @@ -109,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']); @@ -137,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([ @@ -154,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([ @@ -180,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([ @@ -206,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([ @@ -235,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([ @@ -261,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([ @@ -287,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([ @@ -313,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'); @@ -334,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'); @@ -364,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'); @@ -380,10 +380,10 @@ ->once() ->andReturn($response); - $this->http->requestObject($payload); -})->throws(UnserializableResponse::class, 'Syntax error', 0); + $this->http->$requestMethod($payload); +})->with('request methods')->throws(UnserializableResponse::class, 'Syntax error', 0); -test('request object server 404 html', function () { +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'); @@ -397,7 +397,7 @@ 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?'); From 1d0441b52df594a4fbb176d9e55c7ee8d20a2d70 Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 15 Aug 2025 06:43:20 -0400 Subject: [PATCH 4/7] feat: add support for custom headers + openai-project --- src/Responses/Meta/MetaInformation.php | 41 ++++++++++++++++++-- src/Responses/Meta/MetaInformationCustom.php | 34 ++++++++++++++++ src/Responses/Meta/MetaInformationOpenAI.php | 4 +- tests/Fixtures/Meta.php | 9 +++++ tests/Responses/Meta/MetaInformation.php | 35 +++++++++++++++++ 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/Responses/Meta/MetaInformationCustom.php diff --git a/src/Responses/Meta/MetaInformation.php b/src/Responses/Meta/MetaInformation.php index 4eec8f67..474d8ce2 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,25 @@ public static function from(array $headers): self $tokenLimit = null; } + $customHeaders = []; + foreach ($headers as $name => $values) { + if (in_array($name, $knownHeaders, true)) { + continue; + } + if (! is_array($values) || ! isset($values[0])) { + continue; + } + $customHeaders[$name] = $values[0]; + } + + $custom = MetaInformationCustom::from($customHeaders); + return new self( $requestId, $openai, $requestLimit, $tokenLimit, + $custom, ); } @@ -74,6 +105,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 +115,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 ($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..891cb2cb --- /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($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/tests/Fixtures/Meta.php b/tests/Fixtures/Meta.php index 469faa54..e47170ff 100644 --- a/tests/Fixtures/Meta.php +++ b/tests/Fixtures/Meta.php @@ -7,6 +7,7 @@ function metaHeaders(): array return [ '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], @@ -41,6 +42,14 @@ function metaHeadersWithDifferentCases(): array ]; } +function metaHeadersWithCustomCases(): array +{ + return array_merge(metaHeaders(), [ + 'Custom-Header-One' => ['custom-value-1'], + 'Custom-Header-Two' => ['custom-value-2'], + ]); +} + function meta(): MetaInformation { return MetaInformation::from(metaHeaders()); 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, From 4e0e02c87682fe920f00f10753ee8d999f9ee10b Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 15 Aug 2025 06:46:24 -0400 Subject: [PATCH 5/7] chore: handle addition of 'custom' array to meta headers --- src/Responses/Audio/SpeechStreamResponse.php | 5 +++-- src/Responses/StreamResponse.php | 1 - src/ValueObjects/Transporter/AdaptableResponse.php | 1 - src/ValueObjects/Transporter/Response.php | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) 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/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/ValueObjects/Transporter/AdaptableResponse.php b/src/ValueObjects/Transporter/AdaptableResponse.php index 9e0c75fb..a769747f 100644 --- a/src/ValueObjects/Transporter/AdaptableResponse.php +++ b/src/ValueObjects/Transporter/AdaptableResponse.php @@ -34,7 +34,6 @@ private function __construct( */ public static function from(array|string $data, array $headers): self { - // @phpstan-ignore-next-line $meta = MetaInformation::from($headers); return new self($data, $meta); diff --git a/src/ValueObjects/Transporter/Response.php b/src/ValueObjects/Transporter/Response.php index 100979cf..fe64fb02 100644 --- a/src/ValueObjects/Transporter/Response.php +++ b/src/ValueObjects/Transporter/Response.php @@ -34,7 +34,6 @@ private function __construct( */ public static function from(array $data, array $headers): self { - // @phpstan-ignore-next-line $meta = MetaInformation::from($headers); return new self($data, $meta); From 9ec376bbe60caab212f49e49f28d2c274d2fb63d Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 15 Aug 2025 07:08:13 -0400 Subject: [PATCH 6/7] chore: support malformed custom headers --- src/Responses/Meta/MetaInformation.php | 8 +++----- src/Responses/Meta/MetaInformationCustom.php | 4 ++-- tests/Fixtures/Meta.php | 1 + 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Responses/Meta/MetaInformation.php b/src/Responses/Meta/MetaInformation.php index 474d8ce2..0516c8a0 100644 --- a/src/Responses/Meta/MetaInformation.php +++ b/src/Responses/Meta/MetaInformation.php @@ -80,10 +80,8 @@ public static function from(array $headers): self if (in_array($name, $knownHeaders, true)) { continue; } - if (! is_array($values) || ! isset($values[0])) { - continue; - } - $customHeaders[$name] = $values[0]; + + $customHeaders[$name] = $values[0] ?? null; } $custom = MetaInformationCustom::from($customHeaders); @@ -116,6 +114,6 @@ public function toArray(): array 'x-ratelimit-reset-tokens' => $this->tokenLimit->reset ?? null, 'x-request-id' => $this->requestId, 'custom' => ! $this->custom->isEmpty() ? $this->custom->toArray() : null, - ], fn ($value): bool => ! is_null($value)); + ], fn (mixed $value): bool => ! is_null($value)); } } diff --git a/src/Responses/Meta/MetaInformationCustom.php b/src/Responses/Meta/MetaInformationCustom.php index 891cb2cb..59faca3e 100644 --- a/src/Responses/Meta/MetaInformationCustom.php +++ b/src/Responses/Meta/MetaInformationCustom.php @@ -12,11 +12,11 @@ private function __construct( ) {} /** - * @param array $headers + * @param array $headers */ public static function from(array $headers): self { - return new self($headers); + return new self(array_filter($headers)); } /** diff --git a/tests/Fixtures/Meta.php b/tests/Fixtures/Meta.php index e47170ff..be8e7ee1 100644 --- a/tests/Fixtures/Meta.php +++ b/tests/Fixtures/Meta.php @@ -47,6 +47,7 @@ function metaHeadersWithCustomCases(): array return array_merge(metaHeaders(), [ 'Custom-Header-One' => ['custom-value-1'], 'Custom-Header-Two' => ['custom-value-2'], + 'Custom-Blank-Header' => [null], ]); } From 6bb1b27468af78a9b4517b7fa5060607ca5f162f Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 15 Aug 2025 07:10:21 -0400 Subject: [PATCH 7/7] chore: prefer strict type over mixed --- src/Responses/Meta/MetaInformation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Responses/Meta/MetaInformation.php b/src/Responses/Meta/MetaInformation.php index 0516c8a0..f5721373 100644 --- a/src/Responses/Meta/MetaInformation.php +++ b/src/Responses/Meta/MetaInformation.php @@ -114,6 +114,6 @@ public function toArray(): array 'x-ratelimit-reset-tokens' => $this->tokenLimit->reset ?? null, 'x-request-id' => $this->requestId, 'custom' => ! $this->custom->isEmpty() ? $this->custom->toArray() : null, - ], fn (mixed $value): bool => ! is_null($value)); + ], fn (array|string|int|null $value): bool => ! is_null($value)); } }