From 6e2ccb7d2c0994a632b753c72a5ba6b3fa8b4a55 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Sun, 25 Sep 2022 23:16:01 +0100 Subject: [PATCH] feat: `completions`, `edits`, `embeddings` and `models` --- .editorconfig | 18 +++ .gitattributes | 11 ++ .github/FUNDING.yml | 5 + .github/workflows/formats.yml | 47 +++++++ .github/workflows/tests.yml | 39 ++++++ .gitignore | 9 ++ CHANGELOG.md | 8 ++ CONTRIBUTING.md | 61 +++++++++ LICENSE.md | 21 ++++ README.md | 114 +++++++++++++++++ Tests/Client.php | 16 +++ Tests/Fixtures/Completion.php | 26 ++++ Tests/Fixtures/Edit.php | 22 ++++ Tests/Fixtures/Embedding.php | 17 +++ Tests/Fixtures/File.php | 16 +++ Tests/Fixtures/Model.php | 30 +++++ Tests/Fixtures/MyFile.json | 5 + Tests/OpenAI.php | 15 +++ Tests/Pest.php | 28 +++++ Tests/Resources/Completions.php | 15 +++ Tests/Resources/Edits.php | 17 +++ Tests/Resources/Embeddings.php | 27 ++++ Tests/Resources/Files.php | 21 ++++ Tests/Resources/Models.php | 29 +++++ Tests/Transporters/HttpTransporter.php | 136 +++++++++++++++++++++ Tests/ValueObjects/ApiToken.php | 11 ++ Tests/ValueObjects/Transporter/BaseUri.php | 9 ++ Tests/ValueObjects/Transporter/Headers.php | 35 ++++++ Tests/ValueObjects/Transporter/Payload.php | 51 ++++++++ composer.json | 61 +++++++++ phpstan.neon.dist | 6 + phpunit.xml.dist | 16 +++ rector.php | 27 ++++ src/Client.php | 74 +++++++++++ src/Contracts/Stringable.php | 16 +++ src/Contracts/Transporter.php | 25 ++++ src/Enums/Transporter/ContentType.php | 14 +++ src/Enums/Transporter/Method.php | 16 +++ src/Exceptions/ErrorException.php | 44 +++++++ src/Exceptions/TransporterException.php | 19 +++ src/Exceptions/UnserializableResponse.php | 19 +++ src/OpenAI.php | 35 ++++++ src/Resources/Completions.php | 30 +++++ src/Resources/Concerns/Transportable.php | 18 +++ src/Resources/Edits.php | 30 +++++ src/Resources/Embeddings.php | 30 +++++ src/Resources/Files.php | 76 ++++++++++++ src/Resources/Models.php | 43 +++++++ src/Transporters/HttpTransporter.php | 59 +++++++++ src/ValueObjects/ApiToken.php | 34 ++++++ src/ValueObjects/ResourceUri.php | 61 +++++++++ src/ValueObjects/Transporter/BaseUri.php | 37 ++++++ src/ValueObjects/Transporter/Headers.php | 64 ++++++++++ src/ValueObjects/Transporter/Payload.php | 99 +++++++++++++++ 54 files changed, 1812 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/formats.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100755 LICENSE.md create mode 100644 README.md create mode 100644 Tests/Client.php create mode 100644 Tests/Fixtures/Completion.php create mode 100644 Tests/Fixtures/Edit.php create mode 100644 Tests/Fixtures/Embedding.php create mode 100644 Tests/Fixtures/File.php create mode 100644 Tests/Fixtures/Model.php create mode 100644 Tests/Fixtures/MyFile.json create mode 100644 Tests/OpenAI.php create mode 100644 Tests/Pest.php create mode 100644 Tests/Resources/Completions.php create mode 100644 Tests/Resources/Edits.php create mode 100644 Tests/Resources/Embeddings.php create mode 100644 Tests/Resources/Files.php create mode 100644 Tests/Resources/Models.php create mode 100644 Tests/Transporters/HttpTransporter.php create mode 100644 Tests/ValueObjects/ApiToken.php create mode 100644 Tests/ValueObjects/Transporter/BaseUri.php create mode 100644 Tests/ValueObjects/Transporter/Headers.php create mode 100644 Tests/ValueObjects/Transporter/Payload.php create mode 100644 composer.json create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 rector.php create mode 100644 src/Client.php create mode 100644 src/Contracts/Stringable.php create mode 100644 src/Contracts/Transporter.php create mode 100644 src/Enums/Transporter/ContentType.php create mode 100644 src/Enums/Transporter/Method.php create mode 100644 src/Exceptions/ErrorException.php create mode 100644 src/Exceptions/TransporterException.php create mode 100644 src/Exceptions/UnserializableResponse.php create mode 100644 src/OpenAI.php create mode 100644 src/Resources/Completions.php create mode 100644 src/Resources/Concerns/Transportable.php create mode 100644 src/Resources/Edits.php create mode 100644 src/Resources/Embeddings.php create mode 100644 src/Resources/Files.php create mode 100644 src/Resources/Models.php create mode 100644 src/Transporters/HttpTransporter.php create mode 100644 src/ValueObjects/ApiToken.php create mode 100644 src/ValueObjects/ResourceUri.php create mode 100644 src/ValueObjects/Transporter/BaseUri.php create mode 100644 src/ValueObjects/Transporter/Headers.php create mode 100644 src/ValueObjects/Transporter/Payload.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..79c3e0d8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d77fc5cf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +/tests export-ignore +/.github export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +CHANGELOG.md export-ignore +CONTRIBUTING.md export-ignore +phpstan.neon.dist export-ignore +phpunit.xml.dist export-ignore +README.md export-ignore +rector.php export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..0f791b11 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms + +github: nunomaduro +patreon: nunomaduro +custom: https://www.paypal.com/paypalme/enunomaduro diff --git a/.github/workflows/formats.yml b/.github/workflows/formats.yml new file mode 100644 index 00000000..a64fcc8b --- /dev/null +++ b/.github/workflows/formats.yml @@ -0,0 +1,47 @@ +name: Formats + +on: ['push', 'pull_request'] + +jobs: + ci: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.1] + dependency-version: [prefer-lowest, prefer-stable] + + name: Formats P${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ~/.composer/cache/files + key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, zip + tools: prestissimo + coverage: pcov + + - name: Install Composer dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + + - name: Coding Style Checks + run: composer test:lint + + - name: Refacto Checks + run: composer test:refacto + + - name: Type Checks + run: composer test:types diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..75719b87 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests + +on: ['push', 'pull_request'] + +jobs: + ci: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + php: [8.1, 8.2] + dependency-version: [prefer-lowest, prefer-stable] + + name: Tests P${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ~/.composer/cache/files + key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, zip + coverage: none + + - name: Install Composer dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + + - name: Unit Tests + run: composer test:unit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..316d8fc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.phpunit.cache +/.php-cs-fixer.cache +/.php-cs-fixer.php +/composer.lock +/phpunit.xml +/vendor/ +*.swp +*.swo +playground/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..15dd6eec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] +- Adds first version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..285c7cc3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# CONTRIBUTING + +Contributions are welcome, and are accepted via pull requests. +Please review these guidelines before submitting any pull requests. + +## Process + +1. Fork the project +1. Create a new branch +1. Code, test, commit and push +1. Open a pull request detailing your changes. Make sure to follow the [template](.github/PULL_REQUEST_TEMPLATE.md) + +## Guidelines + +* Please ensure the coding style running `composer lint`. +* Send a coherent commit history, making sure each individual commit in your pull request is meaningful. +* You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. +* Please remember that we follow [SemVer](http://semver.org/). + +## Setup + +Clone your fork, then install the dev dependencies: +```bash +composer install +``` + +## Refacto + +Refacto your code: +```bash +composer refacto +``` + +## Lint + +Lint your code: +```bash +composer lint +``` + +## Tests + +Run all tests: +```bash +composer test +``` + +Check code quality: +```bash +composer test:refacto +``` + +Check types: +```bash +composer test:types +``` + +Unit tests: +```bash +composer test:unit +``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 00000000..14b90ed4 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Nuno Maduro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..d8d1d1af --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +

+ OpenAI PHP +

+ GitHub Workflow Status (master) + Total Downloads + Latest Version + License +

+

+ +------ +**OpenAI PHP** is a supercharged PHP API client that allows you to interact with the [Open AI API](https://beta.openai.com/docs/api-reference/introduction). + +> This project is a work-in-progress. Code and documentation are currently under development and are subject to change. + +## Get Started + +> **Requires [PHP 8.1+](https://php.net/releases/)** + +First, install OpenAI via the [Composer](https://getcomposer.org/) package manager: + +```bash +composer require openai-php/client dev-master +``` + +Then, interact with OpenAI's API: + +```php +$client = OpenAI::client('YOUR_API_KEY'); + +$result = $client->completions()->create([ + 'model' => 'davinci', + 'prompt' => 'PHP is', +]); + +echo $result['choices'][0]['text']; // an open-source, widely-used, server-side scripting language. +``` + +## TODO + + + +- [x] Models +- [x] Completions +- [x] Edits +- [x] Embeddings +- [ ] Files +- [ ] FineTunes +- [ ] Moderations +- [ ] Classifications + +## Usage + +### `Models` Resource + +#### `list` + +Lists the currently available models, and provides basic information about each one such as the owner and availability. + +```php +$client->models()->list(); // ['data' => [...], ...] +``` + +#### `retrieve` + +Retrieves a model instance, providing basic information about the model such as the owner and permissioning. + +```php +$client->models()->retrieve($model); // ['id' => 'text-davinci-002', ...] +``` + +### `Completions` Resource + +#### `create` + +Creates a completion for the provided prompt and parameters. + +```php +$client->completions()->create($parameters); // ['choices' => [...], ...] +``` + +### `Edits` Resource + +#### `create` + +Creates a new edit for the provided input, instruction, and parameters. + +```php +$client->edits()->create(); // ['choices' => [...], ...] +``` + +### `Embeddings` Resource + +#### `create` + +Creates an embedding vector representing the input text. + +```php +$client->embeddings()->create(); // ['data' => [...], ...] +``` + +### `Files` Resource + +#### `list` + +Returns a list of files that belong to the user's organization. + +```php +$client->files()->list(); // ['data' => [...], ...] +``` + +--- + +OpenAI PHP is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. diff --git a/Tests/Client.php b/Tests/Client.php new file mode 100644 index 00000000..b4e1f931 --- /dev/null +++ b/Tests/Client.php @@ -0,0 +1,16 @@ +models())->toBeInstanceOf(Models::class); +}); + +it('has completions', function () { + $openAI = OpenAI::client('foo'); + + expect($openAI->completions())->toBeInstanceOf(Completions::class); +}); diff --git a/Tests/Fixtures/Completion.php b/Tests/Fixtures/Completion.php new file mode 100644 index 00000000..cce1b5d8 --- /dev/null +++ b/Tests/Fixtures/Completion.php @@ -0,0 +1,26 @@ + + */ +function completion(): array +{ + return [ + 'id' => 'cmpl-5uS6a68SwurhqAqLBpZtibIITICna', + 'object' => 'text_completion', + 'created' => 1664136088, + 'model' => 'davinci', + 'choices' => [[ + 'text' => "el, she elaborates more on the Corruptor's role, suggesting K", + 'index' => 0, + 'logprobs' => null, + 'finish_reason' => 'length', + ], + ], + 'usage' => [ + 'prompt_tokens' => 1, + 'completion_tokens' => 16, + 'total_tokens' => 17, + ], + ]; +} diff --git a/Tests/Fixtures/Edit.php b/Tests/Fixtures/Edit.php new file mode 100644 index 00000000..972311f9 --- /dev/null +++ b/Tests/Fixtures/Edit.php @@ -0,0 +1,22 @@ + + */ +function edit(): array +{ + return [ + 'object' => 'edit', + 'created' => 1664135921, + 'choices' => [[ + 'text' => "What day of the week is it?\n", + 'index' => 0, + ]], + 'usage' => [ + 'prompt_tokens' => 25, + 'completion_tokens' => 28, + 'total_tokens' => 53, + ], + + ]; +} diff --git a/Tests/Fixtures/Embedding.php b/Tests/Fixtures/Embedding.php new file mode 100644 index 00000000..955d08cf --- /dev/null +++ b/Tests/Fixtures/Embedding.php @@ -0,0 +1,17 @@ + + */ +function embedding(): array +{ + return [ + 'object' => 'embedding', + 'index' => 0, + 'embedding' => [ + -0.008906792, + -0.013743395, + 0.009874112, + ], + ]; +} diff --git a/Tests/Fixtures/File.php b/Tests/Fixtures/File.php new file mode 100644 index 00000000..7ad1a75a --- /dev/null +++ b/Tests/Fixtures/File.php @@ -0,0 +1,16 @@ + + */ +function fileResource(): array +{ + return [ + 'id' => 'file-XjGxS3KTG0uNmNOK362iJua3', + 'object' => 'file', + 'bytes' => 140, + 'created_at' => 1613779121, + 'filename' => 'mydata.jsonl', + 'purpose' => 'fine-tune', + ]; +} diff --git a/Tests/Fixtures/Model.php b/Tests/Fixtures/Model.php new file mode 100644 index 00000000..825a77b6 --- /dev/null +++ b/Tests/Fixtures/Model.php @@ -0,0 +1,30 @@ + + */ +function model(): array +{ + return [ + 'id' => 'text-babbage:001', + 'object' => 'model', + 'created' => 1642018370, + 'owned_by' => 'openai', + 'permission' => [ + 'id' => 'snapperm-7oP3WFr9x7qf5xb3eZrVABAH', + 'object' => 'model_permission', + 'created' => 1642018480, + 'allow_create_engine' => false, + 'allow_sampling' => true, + 'allow_logprobs' => true, + 'allow_search_indices' => false, + 'allow_view' => true, + 'allow_fine_tuning' => false, + 'organization' => '*', + 'group' => null, + 'is_blocking' => false, + ], + 'root' => 'text-babbage:001', + 'parent' => null, + ]; +} diff --git a/Tests/Fixtures/MyFile.json b/Tests/Fixtures/MyFile.json new file mode 100644 index 00000000..5e783888 --- /dev/null +++ b/Tests/Fixtures/MyFile.json @@ -0,0 +1,5 @@ +[ + {"prompt": "", "completion": ""}, + {"prompt": "", "completion": ""}, + {"prompt": "", "completion": ""} +] diff --git a/Tests/OpenAI.php b/Tests/OpenAI.php new file mode 100644 index 00000000..dd1a601d --- /dev/null +++ b/Tests/OpenAI.php @@ -0,0 +1,15 @@ +toBeInstanceOf(Client::class); +}); + +it('sets organization when provided', function () { + $openAI = OpenAI::client('foo', 'nunomaduro'); + + expect($openAI)->toBeInstanceOf(Client::class); +}); diff --git a/Tests/Pest.php b/Tests/Pest.php new file mode 100644 index 00000000..f2ede99d --- /dev/null +++ b/Tests/Pest.php @@ -0,0 +1,28 @@ +shouldReceive('request') + ->once() + ->withArgs(function (Payload $payload) use ($method, $resource) { + $baseUri = BaseUri::from('api.openai.com/v1'); + $headers = Headers::withAuthorization(ApiToken::from('foo')); + + $request = $payload->toRequest($baseUri, $headers); + + return $request->getMethod() === $method + && $request->getUri()->getPath() === "/v1/$resource"; + })->andReturn($response); + + return new Client($transporter); +} diff --git a/Tests/Resources/Completions.php b/Tests/Resources/Completions.php new file mode 100644 index 00000000..46cbcd46 --- /dev/null +++ b/Tests/Resources/Completions.php @@ -0,0 +1,15 @@ + 'da-vince', + 'prompt' => 'hi', + ], completion()); + + $result = $client->completions()->create([ + 'model' => 'da-vince', + 'prompt' => 'hi', + ]); + + expect($result)->toBeArray()->toBe(completion()); +}); diff --git a/Tests/Resources/Edits.php b/Tests/Resources/Edits.php new file mode 100644 index 00000000..87af0656 --- /dev/null +++ b/Tests/Resources/Edits.php @@ -0,0 +1,17 @@ + 'text-davinci-edit-001', + 'input' => 'What day of the wek is it?', + 'instruction' => 'Fix the spelling mistakes', + ], edit()); + + $result = $client->edits()->create([ + 'object' => 'edit', + 'created' => 1664135921, + 'choices' => [], + ]); + + expect($result)->toBeArray()->toBe(edit()); +}); diff --git a/Tests/Resources/Embeddings.php b/Tests/Resources/Embeddings.php new file mode 100644 index 00000000..fc92e1b0 --- /dev/null +++ b/Tests/Resources/Embeddings.php @@ -0,0 +1,27 @@ + 'text-similarity-babbage-001', + 'input' => 'The food was delicious and the waiter...', + ], [ + 'object' => 'list', + 'data' => [ + embedding(), + embedding(), + ], + ]); + + $result = $client->embeddings()->create([ + 'model' => 'text-similarity-babbage-001', + 'input' => 'The food was delicious and the waiter...', + ]); + + expect($result)->toBeArray()->toBe([ + 'object' => 'list', + 'data' => [ + embedding(), + embedding(), + ], + ]); +}); diff --git a/Tests/Resources/Files.php b/Tests/Resources/Files.php new file mode 100644 index 00000000..909f6983 --- /dev/null +++ b/Tests/Resources/Files.php @@ -0,0 +1,21 @@ + 'list', + 'data' => [ + fileResource(), + fileResource(), + ], + ]); + + $result = $client->files()->list(); + + expect($result)->toBeArray()->toBe([ + 'object' => 'list', + 'data' => [ + fileResource(), + fileResource(), + ], + ]); +}); diff --git a/Tests/Resources/Models.php b/Tests/Resources/Models.php new file mode 100644 index 00000000..12acaf4a --- /dev/null +++ b/Tests/Resources/Models.php @@ -0,0 +1,29 @@ + 'list', + 'data' => [ + model(), + model(), + ], + ]); + + $result = $client->models()->list(); + + expect($result)->toBeArray()->toBe([ + 'object' => 'list', + 'data' => [ + model(), + model(), + ], + ]); +}); + +test('retreive', function () { + $client = mockClient('GET', 'models/da-vince', [], model()); + + $result = $client->models()->retrieve('da-vince'); + + expect($result)->toBeArray()->toBe(model()); +}); diff --git a/Tests/Transporters/HttpTransporter.php b/Tests/Transporters/HttpTransporter.php new file mode 100644 index 00000000..85e716ac --- /dev/null +++ b/Tests/Transporters/HttpTransporter.php @@ -0,0 +1,136 @@ +client = Mockery::mock(ClientInterface::class); + + $apiToken = ApiToken::from('foo'); + + $this->http = new HttpTransporter( + $this->client, + BaseUri::from('api.openai.com/v1'), + Headers::withAuthorization($apiToken)->withContentType(ContentType::JSON), + ); +}); + +test('request', function () { + $payload = Payload::list('models'); + + $response = new Response(200, [], json_encode([ + 'qdwq', + ])); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->withArgs(function (Psr7Request $request) { + expect($request->getMethod())->toBe('GET') + ->and($request->getUri()) + ->getHost()->toBe('api.openai.com') + ->getScheme()->toBe('https') + ->getPath()->toBe('/v1/models'); + + return true; + })->andReturn($response); + + $this->http->request($payload); +}); + +test('response', function () { + $payload = Payload::list('models'); + + $response = new Response(200, [], json_encode([ + [ + 'text' => 'Hey!', + 'index' => 0, + 'logprobs' => null, + 'finish_reason' => 'length', + ], + ])); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andReturn($response); + + $response = $this->http->request($payload); + + expect($response)->toBe([ + [ + 'text' => 'Hey!', + 'index' => 0, + 'logprobs' => null, + 'finish_reason' => 'length', + ], + ]); +}); + +test('server errors', function () { + $payload = Payload::list('models'); + + $response = new Response(401, [], json_encode([ + 'error' => [ + 'message' => 'Incorrect API key provided: foo. You can find your API key at https://beta.openai.com.', + 'type' => 'invalid_request_error', + 'param' => null, + 'code' => 'invalid_api_key', + ], + ])); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->request($payload)) + ->toThrow(function (ErrorException $e) { + expect($e->getMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://beta.openai.com.') + ->and($e->getErrorMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://beta.openai.com.') + ->and($e->getErrorCode())->toBe('invalid_api_key') + ->and($e->getErrorType())->toBe('invalid_request_error'); + }); +}); + +test('client errors', function () { + $payload = Payload::list('models'); + + $baseUri = BaseUri::from('api.openai.com'); + $headers = Headers::withAuthorization(ApiToken::from('foo')); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andThrow(new ConnectException('Could not resolve host.', $payload->toRequest($baseUri, $headers))); + + expect(fn () => $this->http->request($payload))->toThrow(function (TransporterException $e) { + expect($e->getMessage())->toBe('Could not resolve host.') + ->and($e->getCode())->toBe(0) + ->and($e->getPrevious())->toBeInstanceOf(ConnectException::class); + }); +}); + +test('serialization errors', function () { + $payload = Payload::list('models'); + + $response = new Response(200, [], 'err'); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andReturn($response); + + $this->http->request($payload); +})->throws(UnserializableResponse::class, 'Syntax error'); diff --git a/Tests/ValueObjects/ApiToken.php b/Tests/ValueObjects/ApiToken.php new file mode 100644 index 00000000..6bb46c46 --- /dev/null +++ b/Tests/ValueObjects/ApiToken.php @@ -0,0 +1,11 @@ +toString())->toBe('foo'); +}); diff --git a/Tests/ValueObjects/Transporter/BaseUri.php b/Tests/ValueObjects/Transporter/BaseUri.php new file mode 100644 index 00000000..1863eaf6 --- /dev/null +++ b/Tests/ValueObjects/Transporter/BaseUri.php @@ -0,0 +1,9 @@ +toString())->toBe('https://api.openai.com/v1/'); +}); diff --git a/Tests/ValueObjects/Transporter/Headers.php b/Tests/ValueObjects/Transporter/Headers.php new file mode 100644 index 00000000..5eecff16 --- /dev/null +++ b/Tests/ValueObjects/Transporter/Headers.php @@ -0,0 +1,35 @@ +toArray())->toBe([ + 'Authorization' => 'Bearer foo', + ]); +}); + +it('can have content/type', function () { + $headers = Headers::withAuthorization(ApiToken::from('foo')) + ->withContentType(ContentType::JSON); + + expect($headers->toArray())->toBe([ + 'Authorization' => 'Bearer foo', + 'Content-Type' => 'application/json', + ]); +}); + +it('can have organization', function () { + $headers = Headers::withAuthorization(ApiToken::from('foo')) + ->withContentType(ContentType::JSON) + ->withOrganization('nunomaduro'); + + expect($headers->toArray())->toBe([ + 'Authorization' => 'Bearer foo', + 'Content-Type' => 'application/json', + 'OpenAI-Organization' => 'nunomaduro', + ]); +}); diff --git a/Tests/ValueObjects/Transporter/Payload.php b/Tests/ValueObjects/Transporter/Payload.php new file mode 100644 index 00000000..5d37a83f --- /dev/null +++ b/Tests/ValueObjects/Transporter/Payload.php @@ -0,0 +1,51 @@ +withContentType(ContentType::JSON); + + expect($payload->toRequest($baseUri, $headers)->getMethod())->toBe('POST'); +}); + +it('has a uri', function () { + $payload = Payload::list('models'); + + $baseUri = BaseUri::from('api.openai.com/v1'); + $headers = Headers::withAuthorization(ApiToken::from('foo'))->withContentType(ContentType::JSON); + + $uri = $payload->toRequest($baseUri, $headers)->getUri(); + + expect($uri->getHost())->toBe('api.openai.com') + ->and($uri->getScheme())->toBe('https') + ->and($uri->getPath())->toBe('/v1/models'); +}); + +test('get verb does not have a body', function () { + $payload = Payload::list('models'); + + $baseUri = BaseUri::from('api.openai.com/v1'); + $headers = Headers::withAuthorization(ApiToken::from('foo'))->withContentType(ContentType::JSON); + + expect($payload->toRequest($baseUri, $headers)->getBody()->getContents())->toBe(''); +}); + +test('post verb has a body', function () { + $payload = Payload::create('models', [ + 'name' => 'test', + ]); + + $baseUri = BaseUri::from('api.openai.com/v1'); + $headers = Headers::withAuthorization(ApiToken::from('foo'))->withContentType(ContentType::JSON); + + expect($payload->toRequest($baseUri, $headers)->getBody()->getContents())->toBe(json_encode([ + 'name' => 'test', + ])); +}); diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..5823ec0a --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "openai-php/client", + "description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API", + "keywords": ["php", "openai", "sdk", "codex", "GPT-3", "api", "client", "natural", "language", "processing"], + "license": "MIT", + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "require": { + "php": "^8.1.0", + "guzzlehttp/guzzle": "^7.5.0" + }, + "require-dev": { + "laravel/pint": "^1.2.0", + "nunomaduro/collision": "^7.0.0", + "pestphp/pest": "^2.0.0", + "pestphp/pest-plugin-mock": "^2.0.0", + "phpstan/phpstan": "^1.8.6", + "rector/rector": "^0.14.3", + "symfony/var-dumper": "^6.2.0" + }, + "autoload": { + "psr-4": { + "OpenAI\\": "src/" + }, + "files": [ + "src/OpenAI.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true, + "preferred-install": "dist", + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "scripts": { + "lint": "pint -v", + "refactor": "rector --debug", + "test:lint": "pint --test -v", + "test:refactor": "rector --dry-run", + "test:types": "phpstan analyse --ansi", + "test:unit": "pest --colors=always", + "test": [ + "@test:lint", + "@test:refactor", + "@test:types", + "@test:unit" + ] + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..5fd25fcd --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - src + + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..772ac2e2 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + ./src + + + + + ./tests + + + diff --git a/rector.php b/rector.php new file mode 100644 index 00000000..589b8cd0 --- /dev/null +++ b/rector.php @@ -0,0 +1,27 @@ +paths([ + __DIR__.'/src', + ]); + + $rectorConfig->rules([ + InlineConstructorDefaultToPropertyRector::class, + ]); + + $rectorConfig->sets([ + LevelSetList::UP_TO_PHP_81, + SetList::CODE_QUALITY, + SetList::DEAD_CODE, + SetList::EARLY_RETURN, + SetList::TYPE_DECLARATION, + SetList::PRIVATIZATION, + ]); +}; diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 00000000..2add79f2 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,74 @@ +transporter); + } + + /** + * Get a vector representation of a given input that can be easily consumed by machine learning models and algorithms. + * + * @see https://beta.openai.com/docs/api-reference/embeddings + */ + public function embeddings(): Embeddings + { + return new Embeddings($this->transporter); + } + + /** + * Given a prompt and an instruction, the model will return an edited version of the prompt. + * + * @see https://beta.openai.com/docs/api-reference/edits + */ + public function edits(): Edits + { + return new Edits($this->transporter); + } + + /** + * Files are used to upload documents that can be used with features like Fine-tuning. + * + * @see https://beta.openai.com/docs/api-reference/files + */ + public function files(): Files + { + return new Files($this->transporter); + } + + /** + * List and describe the various models available in the API. + * + * @see https://beta.openai.com/docs/api-reference/models + */ + public function models(): Models + { + return new Models($this->transporter); + } +} diff --git a/src/Contracts/Stringable.php b/src/Contracts/Stringable.php new file mode 100644 index 00000000..eca72d60 --- /dev/null +++ b/src/Contracts/Stringable.php @@ -0,0 +1,16 @@ + + * + * @throws ErrorException|UnserializableResponse|TransporterException + */ + public function request(Payload $payload): array; +} diff --git a/src/Enums/Transporter/ContentType.php b/src/Enums/Transporter/ContentType.php new file mode 100644 index 00000000..145b6b15 --- /dev/null +++ b/src/Enums/Transporter/ContentType.php @@ -0,0 +1,14 @@ +getMessage(); + } + + /** + * Returns the error type. + */ + public function getErrorType(): string + { + return $this->contents['type']; + } + + /** + * Returns the error type. + */ + public function getErrorCode(): string + { + return $this->contents['code']; + } +} diff --git a/src/Exceptions/TransporterException.php b/src/Exceptions/TransporterException.php new file mode 100644 index 00000000..9ddd30de --- /dev/null +++ b/src/Exceptions/TransporterException.php @@ -0,0 +1,19 @@ +getMessage(), 0, $exception); + } +} diff --git a/src/Exceptions/UnserializableResponse.php b/src/Exceptions/UnserializableResponse.php new file mode 100644 index 00000000..2f9d0643 --- /dev/null +++ b/src/Exceptions/UnserializableResponse.php @@ -0,0 +1,19 @@ +getMessage(), 0, $exception); + } +} diff --git a/src/OpenAI.php b/src/OpenAI.php new file mode 100644 index 00000000..0bea2ffc --- /dev/null +++ b/src/OpenAI.php @@ -0,0 +1,35 @@ +withOrganization($organization); + } + + $client = new GuzzleClient(); + + $transporter = new HttpTransporter($client, $baseUri, $headers); + + return new Client($transporter); + } +} diff --git a/src/Resources/Completions.php b/src/Resources/Completions.php new file mode 100644 index 00000000..0f97f858 --- /dev/null +++ b/src/Resources/Completions.php @@ -0,0 +1,30 @@ + $parameters + * @return array|string> + */ + public function create(array $parameters): array + { + $payload = Payload::create('completions', $parameters); + + /** @var array|string> $result */ + $result = $this->transporter->request($payload); + + return $result; + } +} diff --git a/src/Resources/Concerns/Transportable.php b/src/Resources/Concerns/Transportable.php new file mode 100644 index 00000000..6ff35dd8 --- /dev/null +++ b/src/Resources/Concerns/Transportable.php @@ -0,0 +1,18 @@ + $parameters + * @return array|string> + */ + public function create(array $parameters): array + { + $payload = Payload::create('edits', $parameters); + + /** @var array|string> $result */ + $result = $this->transporter->request($payload); + + return $result; + } +} diff --git a/src/Resources/Embeddings.php b/src/Resources/Embeddings.php new file mode 100644 index 00000000..c4f3d9c3 --- /dev/null +++ b/src/Resources/Embeddings.php @@ -0,0 +1,30 @@ + $parameters + * @return array|string> + */ + public function create(array $parameters): array + { + $payload = Payload::create('embeddings', $parameters); + + /** @var array|string> $result */ + $result = $this->transporter->request($payload); + + return $result; + } +} diff --git a/src/Resources/Files.php b/src/Resources/Files.php new file mode 100644 index 00000000..8b7f902c --- /dev/null +++ b/src/Resources/Files.php @@ -0,0 +1,76 @@ +>> + */ + public function list(): array + { + $payload = Payload::list('files'); + + /** @var array>> $result */ + $result = $this->transporter->request($payload); + + return $result; + } + + /** + * Upload a file that contains document(s) to be used across various endpoints/features. + * + * @see https://beta.openai.com/docs/api-reference/files/upload + * + * @param array $parameters + */ + public function upload(array $parameters): never + { + throw new Exception('Not implemented yet.'); + } + + /** + * Delete a file. + * + * @see https://beta.openai.com/docs/api-reference/files/delete + */ + public function delete(string $file): never + { + throw new Exception('Not implemented yet.'); + } + + /** + *Returns information about a specific file. + * + * @see https://beta.openai.com/docs/api-reference/files/retrieve + * + * @return array + */ + public function retrieve(string $file): void + { + throw new Exception('Not implemented yet.'); + } + + /** + * Returns the contents of the specified file + * + * @see https://beta.openai.com/docs/api-reference/files/retrieve-content + * + * @return array + */ + public function download(string $file): void + { + throw new Exception('Not implemented yet.'); + } +} diff --git a/src/Resources/Models.php b/src/Resources/Models.php new file mode 100644 index 00000000..54acda3f --- /dev/null +++ b/src/Resources/Models.php @@ -0,0 +1,43 @@ +>> + */ + public function list(): array + { + $payload = Payload::list('models'); + + /** @var array>> $result */ + $result = $this->transporter->request($payload); + + return $result; + } + + /** + * Retrieves a model instance, providing basic information about the model such as the owner and permissioning. + * + * @see https://beta.openai.com/docs/api-reference/models/retrieve + * + * @return array + */ + public function retrieve(string $model): array + { + $payload = Payload::retrieve('models', $model); + + return $this->transporter->request($payload); + } +} diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php new file mode 100644 index 00000000..97caa803 --- /dev/null +++ b/src/Transporters/HttpTransporter.php @@ -0,0 +1,59 @@ +toRequest($this->baseUri, $this->headers); + + try { + $response = $this->client->sendRequest($request); + } catch (ClientExceptionInterface $clientException) { + throw new TransporterException($clientException); + } + + $contents = $response->getBody()->getContents(); + + try { + /** @var array{error?: array{message: string, type: string, code: string}} $response */ + $response = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new UnserializableResponse($jsonException); + } + + if (isset($response['error'])) { + throw new ErrorException($response['error']); + } + + return $response; + } +} diff --git a/src/ValueObjects/ApiToken.php b/src/ValueObjects/ApiToken.php new file mode 100644 index 00000000..eb3e862b --- /dev/null +++ b/src/ValueObjects/ApiToken.php @@ -0,0 +1,34 @@ +apiToken; + } +} diff --git a/src/ValueObjects/ResourceUri.php b/src/ValueObjects/ResourceUri.php new file mode 100644 index 00000000..25385c04 --- /dev/null +++ b/src/ValueObjects/ResourceUri.php @@ -0,0 +1,61 @@ +uri; + } +} diff --git a/src/ValueObjects/Transporter/BaseUri.php b/src/ValueObjects/Transporter/BaseUri.php new file mode 100644 index 00000000..8a85ed2b --- /dev/null +++ b/src/ValueObjects/Transporter/BaseUri.php @@ -0,0 +1,37 @@ +baseUri}/"; + } +} diff --git a/src/ValueObjects/Transporter/Headers.php b/src/ValueObjects/Transporter/Headers.php new file mode 100644 index 00000000..0e5b0837 --- /dev/null +++ b/src/ValueObjects/Transporter/Headers.php @@ -0,0 +1,64 @@ + $headers + */ + private function __construct(private readonly array $headers) + { + // .. + } + + /** + * Creates a new Headers value object with the given API token. + */ + public static function withAuthorization(ApiToken $apiToken): self + { + return new self([ + 'Authorization' => "Bearer {$apiToken->toString()}", + ]); + } + + /** + * Creates a new Headers value object, with the given content type, and the existing headers. + */ + public function withContentType(ContentType $contentType): self + { + return new self([ + ...$this->headers, + 'Content-Type' => $contentType->value, + ]); + } + + /** + * Creates a new Headers value object, with the given organization, and the existing headers. + */ + public function withOrganization(string $organization): self + { + return new self([ + ...$this->headers, + 'OpenAI-Organization' => $organization, + ]); + } + + /** + * @return array $headers + */ + public function toArray(): array + { + return $this->headers; + } +} diff --git a/src/ValueObjects/Transporter/Payload.php b/src/ValueObjects/Transporter/Payload.php new file mode 100644 index 00000000..d0f6004c --- /dev/null +++ b/src/ValueObjects/Transporter/Payload.php @@ -0,0 +1,99 @@ + $parameters + */ + private function __construct( + private readonly ContentType $contentType, + private readonly Method $method, + private readonly ResourceUri $uri, + private readonly array $parameters = [], + ) { + // .. + } + + /** + * Creates a new Payload value object from the given parameters. + */ + public static function list(string $resource): self + { + $contentType = ContentType::JSON; + $method = Method::GET; + $uri = ResourceUri::list($resource); + + return new self($contentType, $method, $uri); + } + + /** + * Creates a new Payload value object from the given parameters. + */ + public static function retrieve(string $resource, string $id): self + { + $contentType = ContentType::JSON; + $method = Method::GET; + $uri = ResourceUri::retrieve($resource, $id); + + return new self($contentType, $method, $uri); + } + + /** + * Creates a new Payload value object from the given parameters. + * + * @param array $parameters + */ + public static function create(string $resource, array $parameters): self + { + $contentType = ContentType::JSON; + $method = Method::POST; + $uri = ResourceUri::create($resource); + + return new self($contentType, $method, $uri, $parameters); + } + + /** + * Creates a new Payload value object from the given parameters. + * + * @param array $parameters + */ + public static function upload(string $resource, array $parameters): self + { + $contentType = ContentType::MULTIPART; + $method = Method::POST; + $uri = ResourceUri::upload($resource); + + return new self($contentType, $method, $uri, $parameters); + } + + /** + * Creates a new Psr 7 Request instance. + */ + public function toRequest(BaseUri $baseUri, Headers $headers): Psr7Request + { + $body = null; + $uri = $baseUri->toString().$this->uri->toString(); + + $headers = $headers->withContentType($this->contentType); + + if ($this->method === Method::POST) { + $body = json_encode($this->parameters, JSON_THROW_ON_ERROR); + } + + return new Psr7Request($this->method->value, $uri, $headers->toArray(), $body); + } +}