diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a434b17..87668e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,45 +10,33 @@ jobs: fail-fast: true matrix: php: [8.2, 8.3, 8.4] - symfony: [5.4.*, 6.4.*, 7.0.*] - dependency-version: [prefer-lowest, prefer-stable] - exclude: - - php: 8.4 - symfony: 5.4.* - - php: 8.4 - symfony: 6.4.* - - php: 8.4 - symfony: 7.0.* - - name: Tests P${{ matrix.php }} - SF${{ matrix.symfony }} - ubuntu-latest - ${{ matrix.dependency-version }} + symfony: [^6.4, false] + dependency: [stable] + include: + - { php: 8.4, symfony: ^7.4, dependency: highest } + - { php: 8.4, symfony: ^8.0, dependency: highest } + + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + name: Tests P${{ matrix.php }} - SF${{ matrix.symfony }} - ubuntu-latest - ${{ matrix.dependency }} steps: - name: Checkout uses: actions/checkout@v5 - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.composer/cache/files - key: dependencies-php-${{ matrix.php }}-SF${{ matrix.symfony }}-${{ matrix.dependency-version }}-composer-${{ hashFiles('composer.json') }} - - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: dom, mbstring, zip coverage: none - - - name: Require Symfony Version - run: > - composer require - "symfony/config:${{ matrix.symfony }}" - "symfony/dependency-injection:${{ matrix.symfony }}" - "symfony/http-kernel:${{ matrix.symfony }}" - --no-interaction --no-update + tools: flex - name: Install Composer dependencies - run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + uses: ramsey/composer-install@v2 + with: + dependency-versions: ${{ matrix.dependency }} - name: Integration Tests run: php ./vendor/bin/simple-phpunit diff --git a/.gitignore b/.gitignore index 8e954e3..9b27164 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ /composer.lock /phpunit.xml /vendor/ +/var/ *.swp *.swo diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f06ac..2d7b52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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/). +## v0.17.0 +### Changed +- Refactored into a single `OpenAI\Symfony\OpenAIBundle` class +- Add `project` and `base_uri` configuration options +- Drop support for unsupported Symfony versions. Now requires Symfony 6.4 or 7.3+ +- Add support for Symfony 8.0 + ## v0.12.0 (2025-05-06) ### Changed - Changed underlying `openai/client` package version to 0.12.0 diff --git a/README.md b/README.md index 3d6e4d3..e8fbc1b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ OPENAI_API_KEY=sk-... OPENAI_ORGANIZATION=... ``` +For more configuration options, take a look at the [Configuration Reference](#configuration-reference). + Finally, you may use the `openai` service to access the OpenAI API: ```php @@ -57,6 +59,18 @@ echo $result['choices'][0]['text']; // an open-source, widely-used, server-side For usage examples, take a look at the [openai-php/client](https://github.com/openai-php/client) repository. +## Configuration Reference + +The bundle provides the following configuration options, which you can set in your `config/packages/openai.yaml` file: + +```yaml +openai: + api_key: '%env(OPENAI_API_KEY)%' # Your OpenAI API key (required) + organization: '%env(OPENAI_ORGANIZATION)%' # Your OpenAI organization ID (optional) + project: 'proj_...' # The project ID (optional) + base_uri: 'api.openai.com/v1' # The base URI for the OpenAI API (optional) +``` + --- OpenAI PHP for Symfony is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. diff --git a/composer.json b/composer.json index 7639d3a..e7b26ce 100644 --- a/composer.json +++ b/composer.json @@ -20,16 +20,17 @@ "openai-php/client": "^0.17.0", "psr/http-client": "^1.0.3", "psr/http-factory": "^1.1.0", - "symfony/config": "^5.4|^6.3|^7.1.1", - "symfony/dependency-injection": "^5.4|^6.3|^7.1.5", - "symfony/http-client": "^5.4|^6.3|^7.1.5", - "symfony/http-kernel": "^5.4|^6.3|^7.1.5" + "symfony/config": "^6.4|^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/http-client": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0" }, "require-dev": { - "laravel/pint": "^1.18.1", - "phpstan/phpstan": "^1.12.6", - "rector/rector": "^0.14.8", - "symfony/phpunit-bridge": "^5.4|^6.3|^7.1.4" + "laravel/pint": "^1.24.0", + "phpstan/phpstan": "^2.1.22", + "rector/rector": "^2.1.5", + "symfony/phpunit-bridge": "^6.4.25|^7.3|^8.0", + "symfony/framework-bundle": "^6.4|^7.3|^8.0" }, "autoload": { "psr-4": { @@ -53,7 +54,7 @@ "scripts": { "lint": "pint -v", "refactor": "rector --debug", - "test:lint": "pint --test -v", + "test:lint": "pint --test -v ./src ./tests", "test:types": "phpstan analyse --ansi", "test:unit": "simple-phpunit --colors=always", "test": [ diff --git a/rector.php b/rector.php index 2293079..622e4e2 100644 --- a/rector.php +++ b/rector.php @@ -4,16 +4,15 @@ use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector; use Rector\Config\RectorConfig; +use Rector\Php81\Rector\Array_\FirstClassCallableRector; use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; return static function (RectorConfig $rectorConfig): void { $rectorConfig->paths([ __DIR__.'/src', - ]); - - $rectorConfig->skip([ - __DIR__.'/src/Resources/config/', + __DIR__.'/tests', + __DIR__.'/rector.php', ]); $rectorConfig->rules([ @@ -28,4 +27,8 @@ SetList::TYPE_DECLARATION, SetList::PRIVATIZATION, ]); + + $rectorConfig->skip([ + FirstClassCallableRector::class, + ]); }; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php deleted file mode 100644 index b22bb5b..0000000 --- a/src/DependencyInjection/Configuration.php +++ /dev/null @@ -1,36 +0,0 @@ -getRootNode(); - - assert($rootNode instanceof ArrayNodeDefinition); - - $children = $rootNode->children(); - - assert($children instanceof NodeBuilder); - - $children->scalarNode('api_key')->defaultValue('%env(OPENAI_API_KEY)%')->end(); - $children->scalarNode('organization')->defaultValue('%env(default::OPENAI_ORGANIZATION)%')->end(); - - return $treeBuilder; - } -} diff --git a/src/DependencyInjection/OpenAIExtension.php b/src/DependencyInjection/OpenAIExtension.php deleted file mode 100644 index 36dc837..0000000 --- a/src/DependencyInjection/OpenAIExtension.php +++ /dev/null @@ -1,39 +0,0 @@ -> $configs - */ - public function load(array $configs, ContainerBuilder $container): void - { - $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.php'); - - $configuration = $this->getConfiguration($configs, $container); - - assert($configuration instanceof ConfigurationInterface); - - $config = $this->processConfiguration($configuration, $configs); - - $definition = $container->getDefinition(Factory::class); - $definition->addMethodCall('withApiKey', [$config['api_key']]); - if ($config['organization']) { - $definition->addMethodCall('withOrganization', [$config['organization']]); - } - } -} diff --git a/src/OpenAIBundle.php b/src/OpenAIBundle.php index c14715b..9d78a06 100644 --- a/src/OpenAIBundle.php +++ b/src/OpenAIBundle.php @@ -4,6 +4,76 @@ namespace OpenAI\Symfony; -use Symfony\Component\HttpKernel\Bundle\Bundle; +use OpenAI\Client; +use OpenAI\Contracts\ClientContract; +use OpenAI\Factory; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; -final class OpenAIBundle extends Bundle {} +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; + +final class OpenAIBundle extends AbstractBundle +{ + protected string $extensionAlias = 'openai'; + + public function configure(DefinitionConfigurator $definition): void + { + $root = $definition->rootNode(); + assert($root instanceof ArrayNodeDefinition); + $children = $root->children(); + $children + ->scalarNode('api_key') + ->defaultValue('%env(OPENAI_API_KEY)%') + ->info('OpenAI API Key used to authenticate with the OpenAI API') + ->isRequired(); + $children + ->scalarNode('organization') + ->info('OpenAI API Organization used to authenticate with the OpenAI API') + ->defaultValue('%env(default::OPENAI_ORGANIZATION)%') + ->info(''); + $children + ->scalarNode('project') + ->defaultNull() + ->info('OpenAI API project'); + $children + ->scalarNode('base_uri') + ->defaultNull() + ->info('OpenAI API base URL used to make requests. Defaults to: api.openai.com/v1'); + } + + /** + * @param array{api_key: string, organization: string, project: ?string, base_uri: ?string} $config + */ + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->services() + ->set('openai.http_client', Psr18Client::class) + ->arg(0, service('http_client')); + + $factory = $container->services() + ->set(Factory::class) + ->factory([\OpenAI::class, 'factory']) + ->call('withHttpClient', [service('openai.http_client')]) + ->call('withHttpHeader', ['OpenAI-Beta', 'assistants=v2']) + ->call('withApiKey', [$config['api_key']]) + ->call('withOrganization', [$config['organization']]); + if ($config['project']) { + $factory->call('withProject', [$config['project']]); + } + if ($config['base_uri']) { + $factory->call('withBaseUri', [$config['base_uri']]); + } + + $container->services() + ->set(Client::class) + ->factory([service(Factory::class), 'make']); + + $container->services() + ->alias(ClientContract::class, Client::class) + ->alias('openai', Client::class); + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php deleted file mode 100644 index 67fee2f..0000000 --- a/src/Resources/config/services.php +++ /dev/null @@ -1,28 +0,0 @@ -services() - ->set('openai.http_client', Psr18Client::class) - ->arg(0, service('http_client')) - - ->set(Factory::class) - ->factory([OpenAI::class, 'factory']) - ->call('withHttpClient', [service('openai.http_client')]) - ->call('withHttpHeader', ['OpenAI-Beta', 'assistants=v2']) - - ->set(Client::class) - ->factory([service(Factory::class), 'make']) - - ->alias(ClientContract::class, Client::class) - ->alias('openai', Client::class); -}; diff --git a/tests/DependencyInjection/OpenAIExtensionTest.php b/tests/DependencyInjection/OpenAIExtensionTest.php deleted file mode 100644 index 6514b13..0000000 --- a/tests/DependencyInjection/OpenAIExtensionTest.php +++ /dev/null @@ -1,52 +0,0 @@ - 200, - 'response_headers' => [ - 'content-type' => 'application/json', - 'x-request-id' => '0123456789abcdef0123456789abcdef', - ], - ]); - }); - - $container = new ContainerBuilder; - $container->set('http_client', $httpClient); - - $extension = new OpenAIExtension; - $extension->load([ - 'openai' => [ - 'api_key' => 'pk-123456789', - ], - ], $container); - - $openai = $container->get('openai'); - self::assertInstanceOf(Client::class, $openai); - - $response = $openai->files()->delete('file.txt'); - self::assertSame('file.txt', $response->id); - - self::assertSame($openai, $container->get(ClientContract::class), 'Alias for the ClientContract interface'); - } -} diff --git a/tests/OpenAIBundleTest.php b/tests/OpenAIBundleTest.php new file mode 100644 index 0000000..960339f --- /dev/null +++ b/tests/OpenAIBundleTest.php @@ -0,0 +1,91 @@ +extension('framework', [ + 'secret' => 'S0ME_SECRET', + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + ]); + + $container->extension('openai', [ + 'api_key' => 'pk-123456789', + 'organization' => 'org-123456789', + ]); + + $container->services() + ->set('http_client', MockHttpClient::class) + ->public() + + ->set('tested_services', \ArrayObject::class) + ->args([[ + 'openai' => service('openai'), + Client::class => service(Client::class), + ClientContract::class => service(ClientContract::class), + ]]) + ->public(); + } + }; + + // Using a mock to test the service configuration + $httpClient = new MockHttpClient(function (string $method, string $url, array $options = []): MockResponse { + self::assertSame('DELETE', $method); + self::assertSame('https://api.openai.com/v1/files/file.txt', $url); + self::assertContains('Authorization: Bearer pk-123456789', $options['headers']); + + return new MockResponse('{"id":"file.txt","object":"file","deleted":true}', [ + 'http_code' => 200, + 'response_headers' => [ + 'content-type' => 'application/json', + 'x-request-id' => '0123456789abcdef0123456789abcdef', + ], + ]); + }); + + $kernel->boot(); + $container = $kernel->getContainer(); + $container->set('http_client', $httpClient); + + $testedServices = $container->get('tested_services'); + assert($testedServices instanceof \ArrayObject); + $openai = $testedServices['openai']; + self::assertInstanceOf(Client::class, $openai); + self::assertSame($openai, $testedServices[Client::class]); + self::assertSame($openai, $testedServices[ClientContract::class]); + + $response = $openai->files()->delete('file.txt'); + self::assertSame('file.txt', $response->id); + } +}