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** 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);
+ }
+}