From 5b75e51172b42c0eb5bfde737aac82cf45eb52c5 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 11:46:56 +0200 Subject: [PATCH 01/12] feat(Platform): implement ProviderFactory and ProviderConfigFactory (RFC #402) --- .github/workflows/unit-tests.yaml | 6 + src/platform/phpunit.provider-factory.xml | 27 ++++ src/platform/phpunit.xml.dist | 6 + .../src/Factory/ProviderConfigFactory.php | 132 +++++++++++++++++ src/platform/src/Factory/ProviderFactory.php | 77 ++++++++++ .../src/Factory/ProviderFactoryInterface.php | 8 ++ src/platform/src/Provider/ProviderConfig.php | 24 ++++ src/platform/src/Transport/Dsn.php | 134 ++++++++++++++++++ .../Azure/Meta/PlatformFactory.php | 15 ++ .../Azure/OpenAi/PlatformFactory.php | 15 ++ .../FakeBridges/OpenAi/PlatformFactory.php | 15 ++ .../Factory/ProviderConfigFactoryTest.php | 116 +++++++++++++++ .../tests/Factory/ProviderFactoryTest.php | 113 +++++++++++++++ src/platform/tests/bootstrap_fake_bridges.php | 18 +++ 14 files changed, 706 insertions(+) create mode 100644 src/platform/phpunit.provider-factory.xml create mode 100644 src/platform/src/Factory/ProviderConfigFactory.php create mode 100644 src/platform/src/Factory/ProviderFactory.php create mode 100644 src/platform/src/Factory/ProviderFactoryInterface.php create mode 100644 src/platform/src/Provider/ProviderConfig.php create mode 100644 src/platform/src/Transport/Dsn.php create mode 100644 src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php create mode 100644 src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php create mode 100644 src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php create mode 100644 src/platform/tests/Factory/ProviderConfigFactoryTest.php create mode 100644 src/platform/tests/Factory/ProviderFactoryTest.php create mode 100644 src/platform/tests/bootstrap_fake_bridges.php diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 0afa31e5f..d1d2d0eb3 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -82,3 +82,9 @@ jobs: source .github/workflows/.utils.sh echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} '(cd src/{} && $COMPOSER_UP && $PHPUNIT)'" + + - name: Run platform provider-factory tests (special bootstrap) + if: contains(env.PACKAGES, 'platform') + run: | + set -e + (cd src/platform && $COMPOSER_UP && $PHPUNIT -c phpunit.provider-factory.xml) diff --git a/src/platform/phpunit.provider-factory.xml b/src/platform/phpunit.provider-factory.xml new file mode 100644 index 000000000..3c5c9bbc9 --- /dev/null +++ b/src/platform/phpunit.provider-factory.xml @@ -0,0 +1,27 @@ + + + + + tests + + + + + + pf + + + + + + src + + + diff --git a/src/platform/phpunit.xml.dist b/src/platform/phpunit.xml.dist index 7c04fa4fb..c28d7eedf 100644 --- a/src/platform/phpunit.xml.dist +++ b/src/platform/phpunit.xml.dist @@ -16,6 +16,12 @@ + + + pf + + + src diff --git a/src/platform/src/Factory/ProviderConfigFactory.php b/src/platform/src/Factory/ProviderConfigFactory.php new file mode 100644 index 000000000..35fc6b43f --- /dev/null +++ b/src/platform/src/Factory/ProviderConfigFactory.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Factory; + +use Symfony\AI\Platform\Provider\ProviderConfig; +use Symfony\AI\Platform\Transport\Dsn; + +final class ProviderConfigFactory +{ + public static function fromDsn(string|Dsn $dsn): ProviderConfig + { + $dsn = \is_string($dsn) ? Dsn::fromString($dsn) : $dsn; + + $provider = strtolower($dsn->getProvider()); + if ('' === $provider) { + throw new \InvalidArgumentException('DSN must include a provider (e.g. "ai+openai://...").'); + } + + $host = $dsn->getHost(); + if ('' === $host) { + $host = self::defaultHostOrFail($provider); + } + + $scheme = 'https'; + $port = $dsn->getPort(); + $baseUri = $scheme.'://'.$host.($port ? ':'.$port : ''); + + $q = $dsn->getQuery(); + + $headers = []; + + if (isset($q['headers']) && \is_array($q['headers'])) { + foreach ($q['headers'] as $hk => $hv) { + $headers[$hk] = $hv; + } + } + + foreach ($q as $k => $v) { + if (preg_match('/^headers\[(.+)\]$/', (string) $k, $m)) { + $headers[$m[1]] = $v; + continue; + } + if (str_starts_with((string) $k, 'headers_')) { + $hk = substr((string) $k, \strlen('headers_')); + if ('' !== $hk) { + $headers[$hk] = $v; + } + } + } + + $options = array_filter([ + 'model' => $q['model'] ?? null, + 'version' => $q['version'] ?? null, + 'deployment' => $q['deployment'] ?? null, + 'organization' => $q['organization'] ?? null, + 'location' => $q['location'] ?? ($q['region'] ?? null), + 'timeout' => isset($q['timeout']) ? (int) $q['timeout'] : null, + 'verify_peer' => isset($q['verify_peer']) ? self::toBool($q['verify_peer']) : null, + 'proxy' => $q['proxy'] ?? null, + ], static fn ($v) => null !== $v && '' !== $v); + + switch ($provider) { + case 'azure': + $engine = strtolower((string) ($q['engine'] ?? 'openai')); + if (!\in_array($engine, ['openai', 'meta'], true)) { + throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)); + } + $options['engine'] = $engine; + + if ('' === $dsn->getHost()) { + throw new \InvalidArgumentException('Azure DSN requires host: ".openai.azure.com" or ".meta.azure.com".'); + } + if (!isset($options['deployment']) || '' === $options['deployment']) { + throw new \InvalidArgumentException('Azure DSN requires "deployment" query param.'); + } + if (!isset($options['version']) || '' === $options['version']) { + throw new \InvalidArgumentException('Azure DSN requires "version" query param.'); + } + break; + + case 'openai': + case 'anthropic': + case 'gemini': + case 'vertex': + case 'ollama': + break; + + default: + throw new \InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)); + } + + return new ProviderConfig( + provider: $provider, + baseUri: $baseUri, + apiKey: $dsn->getUser(), + options: $options, + headers: $headers + ); + } + + private static function toBool(mixed $value): bool + { + if (\is_bool($value)) { + return $value; + } + $v = strtolower((string) $value); + + return \in_array($v, ['1', 'true', 'yes', 'on'], true); + } + + private static function defaultHostOrFail(string $provider): string + { + return match ($provider) { + 'openai' => 'api.openai.com', + 'anthropic' => 'api.anthropic.com', + 'gemini' => 'generativelanguage.googleapis.com', + 'vertex' => 'us-central1-aiplatform.googleapis.com', + 'ollama' => 'localhost', + 'azure' => throw new \InvalidArgumentException('Azure DSN must specify host (e.g. ".openai.azure.com").'), + default => throw new \InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)), + }; + } +} diff --git a/src/platform/src/Factory/ProviderFactory.php b/src/platform/src/Factory/ProviderFactory.php new file mode 100644 index 000000000..a875c9cc8 --- /dev/null +++ b/src/platform/src/Factory/ProviderFactory.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Factory; + +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class ProviderFactory implements ProviderFactoryInterface +{ + public function __construct(private ?HttpClientInterface $http = null) + { + } + + public function fromDsn(string $dsn): object + { + $config = ProviderConfigFactory::fromDsn($dsn); + $providerKey = strtolower($config->provider); + + if ('azure' === $providerKey) { + $engine = strtolower($config->options['engine'] ?? 'openai'); + $factoryFqcn = match ($engine) { + 'openai' => \Symfony\AI\Platform\Bridge\Azure\OpenAI\PlatformFactory::class, + 'meta' => \Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory::class, + default => throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)), + }; + } else { + $factoryMap = [ + 'openai' => \Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory::class, + 'anthropic' => \Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory::class, + 'azure' => \Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory::class, + 'gemini' => \Symfony\AI\Platform\Bridge\Gemini\PlatformFactory::class, + 'vertex' => \Symfony\AI\Platform\Bridge\VertexAI\PlatformFactory::class, + 'ollama' => \Symfony\AI\Platform\Bridge\Ollama\PlatformFactory::class, + ]; + + if (!isset($factoryMap[$providerKey])) { + throw new \InvalidArgumentException(\sprintf('Unsupported AI provider "%s".', $config->provider)); + } + + $factoryFqcn = $factoryMap[$providerKey]; + } + + $authHeaders = match ($providerKey) { + 'openai', 'anthropic', 'gemini', 'vertex' => $config->apiKey ? ['Authorization' => 'Bearer '.$config->apiKey] : [], + 'azure' => $config->apiKey ? ['api-key' => $config->apiKey] : [], + default => [], + }; + + $headers = array_filter($authHeaders + $config->headers, static fn ($v) => null !== $v && '' !== $v); + + $http = $this->http ?? HttpClient::create([ + 'base_uri' => $config->baseUri, + 'headers' => $headers, + 'timeout' => isset($config->options['timeout']) ? (float) $config->options['timeout'] : null, + 'proxy' => $config->options['proxy'] ?? null, + 'verify_peer' => $config->options['verify_peer'] ?? null, + ]); + + $contract = [ + 'provider' => $config->provider, + 'base_uri' => $config->baseUri, + 'options' => $config->options, + 'headers' => $headers, + ]; + + return $factoryFqcn::create($config->apiKey ?? '', $http, $contract); + } +} diff --git a/src/platform/src/Factory/ProviderFactoryInterface.php b/src/platform/src/Factory/ProviderFactoryInterface.php new file mode 100644 index 000000000..daba7a6cd --- /dev/null +++ b/src/platform/src/Factory/ProviderFactoryInterface.php @@ -0,0 +1,8 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Provider; + +final readonly class ProviderConfig +{ + public function __construct( + public string $provider, + public string $baseUri, + public ?string $apiKey, + public array $options = [], + public array $headers = [], + ) { + } +} diff --git a/src/platform/src/Transport/Dsn.php b/src/platform/src/Transport/Dsn.php new file mode 100644 index 000000000..06c375024 --- /dev/null +++ b/src/platform/src/Transport/Dsn.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Transport; + +final class Dsn +{ + public function __construct(private string $scheme, private string $host = '', private ?int $port = null, private ?string $user = null, private ?string $password = null, private array $query = []) + { + } + + public static function fromString(string $dsn): self + { + if (!preg_match('#^([a-zA-Z][a-zA-Z0-9+.\-]*)://#', $dsn, $m)) { + throw new \InvalidArgumentException(\sprintf('Invalid DSN "%s": missing scheme.', $dsn)); + } + $scheme = $m[1]; + + $parts = parse_url($dsn); + if (false !== $parts && isset($parts['scheme'])) { + $query = []; + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + } + + return new self( + scheme: $parts['scheme'], + host: $parts['host'] ?? '', + port: $parts['port'] ?? null, + user: isset($parts['user']) ? urldecode($parts['user']) : null, + password: isset($parts['pass']) ? urldecode($parts['pass']) : null, + query: $query + ); + } + + $rest = substr($dsn, \strlen($m[0])); + $queryStr = ''; + if (false !== ($qpos = strpos($rest, '?'))) { + $queryStr = substr($rest, $qpos + 1); + $rest = substr($rest, 0, $qpos); + } + + $user = null; + $password = null; + $host = ''; + $port = null; + + if (false !== ($at = strpos($rest, '@'))) { + $userinfo = substr($rest, 0, $at); + $rest = substr($rest, $at + 1); + + if (false !== ($colon = strpos($userinfo, ':'))) { + $user = urldecode(substr($userinfo, 0, $colon)); + $password = urldecode(substr($userinfo, $colon + 1)); + } else { + $user = urldecode($userinfo); + } + } + + if ('' !== $rest && '/' !== $rest[0]) { + $slash = strpos($rest, '/'); + $authority = false === $slash ? $rest : substr($rest, 0, $slash); + $rest = false === $slash ? '' : substr($rest, $slash); + + $hp = explode(':', $authority, 2); + $host = $hp[0] ?? ''; + if (isset($hp[1]) && '' !== $hp[1] && ctype_digit($hp[1])) { + $port = (int) $hp[1]; + } + } + + $query = []; + if ('' !== $queryStr) { + parse_str($queryStr, $query); + } + + return new self( + scheme: $scheme, + host: $host, + port: $port, + user: $user, + password: $password, + query: $query + ); + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function getUser(): ?string + { + return $this->user; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getQuery(): array + { + return $this->query; + } + + public function getProvider(): string + { + $scheme = strtolower($this->scheme); + if (str_starts_with($scheme, 'ai+')) { + return substr($scheme, 3); + } + + return $scheme; + } +} diff --git a/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php b/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php new file mode 100644 index 000000000..9b75bc827 --- /dev/null +++ b/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php @@ -0,0 +1,15 @@ + 'azure-meta']; + } +} diff --git a/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php b/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php new file mode 100644 index 000000000..a64ec80da --- /dev/null +++ b/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php @@ -0,0 +1,15 @@ + 'azure-openai']; + } +} diff --git a/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php b/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php new file mode 100644 index 000000000..f94b7aa7d --- /dev/null +++ b/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php @@ -0,0 +1,15 @@ + 'openai']; + } +} diff --git a/src/platform/tests/Factory/ProviderConfigFactoryTest.php b/src/platform/tests/Factory/ProviderConfigFactoryTest.php new file mode 100644 index 000000000..254642072 --- /dev/null +++ b/src/platform/tests/Factory/ProviderConfigFactoryTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Factory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Factory\ProviderConfigFactory; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory as OpenAIBridge; +use Symfony\AI\Platform\Bridge\Azure\OpenAI\PlatformFactory as AzureOpenAIBridge; +use Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory as AzureMetaBridge; + +#[Group('pf')] +#[CoversClass(ProviderConfigFactory::class)] +class ProviderConfigFactoryTest extends TestCase +{ + public function testOpenAiDefaults(): void + { + $cfg = ProviderConfigFactory::fromDsn( + 'ai+openai://sk-test@api.openai.com?model=gpt-4o-mini&organization=org_123&headers[x-foo]=bar' + ); + + $this->assertSame('openai', $cfg->provider); + $this->assertSame('https://api.openai.com', $cfg->baseUri); + $this->assertSame('sk-test', $cfg->apiKey); + $this->assertSame('gpt-4o-mini', $cfg->options['model'] ?? null); + $this->assertSame('org_123', $cfg->options['organization'] ?? null); + $this->assertSame('bar', $cfg->headers['x-foo'] ?? null); + } + + public function testOpenAiWithoutHostUsesDefault(): void + { + $cfg = ProviderConfigFactory::fromDsn('ai+openai://sk-test@/?model=gpt-4o-mini'); + + $this->assertSame('https://api.openai.com', $cfg->baseUri); + $this->assertSame('gpt-4o-mini', $cfg->options['model'] ?? null); + } + + public function testAzureOpenAiHappyPath(): void + { + $cfg = ProviderConfigFactory::fromDsn( + 'ai+azure://AZ_KEY@my-resource.openai.azure.com?deployment=gpt-4o&version=2024-08-01-preview&engine=openai' + ); + + $this->assertSame('azure', $cfg->provider); + $this->assertSame('https://my-resource.openai.azure.com', $cfg->baseUri); + $this->assertSame('AZ_KEY', $cfg->apiKey); + $this->assertSame('gpt-4o', $cfg->options['deployment'] ?? null); + $this->assertSame('2024-08-01-preview', $cfg->options['version'] ?? null); + $this->assertSame('openai', $cfg->options['engine'] ?? null); + } + + public function testAzureMetaHappyPath(): void + { + $cfg = ProviderConfigFactory::fromDsn( + 'ai+azure://AZ_KEY@my-resource.meta.azure.com?deployment=llama-3.1&version=2024-08-01-preview&engine=meta' + ); + + $this->assertSame('azure', $cfg->provider); + $this->assertSame('https://my-resource.meta.azure.com', $cfg->baseUri); + $this->assertSame('meta', $cfg->options['engine'] ?? null); + $this->assertSame('llama-3.1', $cfg->options['deployment'] ?? null); + } + + public function testGenericOptionsAndBooleans(): void + { + $cfg = ProviderConfigFactory::fromDsn( + 'ai+openai://sk@/?model=gpt-4o-mini&timeout=10&verify_peer=true&proxy=http://proxy:8080' + ); + + $this->assertSame(10, $cfg->options['timeout'] ?? null); + $this->assertTrue($cfg->options['verify_peer'] ?? false); + $this->assertSame('http://proxy:8080', $cfg->options['proxy'] ?? null); + } + + public function testUnknownProviderThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + ProviderConfigFactory::fromDsn('ai+unknown://key@host'); + } + + public function testAzureMissingHostThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@/?deployment=gpt-4o&version=2024-08-01-preview'); + } + + public function testAzureMissingDeploymentThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@my.openai.azure.com?version=2024-08-01-preview'); + } + + public function testAzureMissingVersionThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@my.openai.azure.com?deployment=gpt-4o'); + } + + public function testAzureUnsupportedEngineThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + ProviderConfigFactory::fromDsn( + 'ai+azure://AZ_KEY@my.openai.azure.com?deployment=gpt-4o&version=2024-08-01-preview&engine=unknown' + ); + } +} diff --git a/src/platform/tests/Factory/ProviderFactoryTest.php b/src/platform/tests/Factory/ProviderFactoryTest.php new file mode 100644 index 000000000..e42a95be5 --- /dev/null +++ b/src/platform/tests/Factory/ProviderFactoryTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Factory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Factory\ProviderFactory; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAIBridge; +use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAIBridge; +use Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory as AzureMetaBridge; + +#[Group('pf')] +#[CoversClass(ProviderFactory::class)] +final class ProviderFactoryTest extends TestCase +{ + protected function tearDown(): void + { + OpenAIBridge::$lastArgs = []; + AzureOpenAIBridge::$lastArgs = []; + AzureMetaBridge::$lastArgs = []; + } + + public function testBuildsOpenAiWithBearerAuth(): void + { + $factory = new ProviderFactory(); + + $obj = $factory->fromDsn('ai+openai://sk-test@api.openai.com?model=gpt-4o-mini'); + + $this->assertIsObject($obj); + $this->assertSame('openai', $obj->bridge ?? null); + + $args = OpenAIBridge::$lastArgs ?? []; + $this->assertSame('sk-test', $args['apiKey'] ?? null); + $this->assertSame('https://api.openai.com', $args['contract']['base_uri'] ?? null); + $this->assertSame('openai', $args['contract']['provider'] ?? null); + $this->assertSame('gpt-4o-mini', $args['contract']['options']['model'] ?? null); + $headers = $args['contract']['headers'] ?? []; + $this->assertSame('Bearer sk-test', $headers['Authorization'] ?? null); + $this->assertArrayNotHasKey('api-key', $headers); + } + + public function testBuildsAzureOpenAiWithApiKeyHeader(): void + { + $factory = new ProviderFactory(); + + $obj = $factory->fromDsn( + 'ai+azure://AZ@my-resource.openai.azure.com?deployment=gpt-4o&version=2024-08-01-preview&engine=openai' + ); + + $this->assertIsObject($obj); + $this->assertSame('azure-openai', $obj->bridge ?? null); + + $args = AzureOpenAIBridge::$lastArgs ?? []; + $this->assertSame('AZ', $args['apiKey'] ?? null); + $this->assertSame('https://my-resource.openai.azure.com', $args['contract']['base_uri'] ?? null); + $this->assertSame('azure', $args['contract']['provider'] ?? null); + $this->assertSame('gpt-4o', $args['contract']['options']['deployment'] ?? null); + $this->assertSame('2024-08-01-preview', $args['contract']['options']['version'] ?? null); + $this->assertSame('openai', $args['contract']['options']['engine'] ?? null); + + $headers = $args['contract']['headers'] ?? []; + $this->assertSame('AZ', $headers['api-key'] ?? null); + $this->assertArrayNotHasKey('Authorization', $headers); + } + + public function testBuildsAzureMetaWhenEngineMeta(): void + { + $factory = new ProviderFactory(); + + $obj = $factory->fromDsn( + 'ai+azure://AZ@my-resource.meta.azure.com?deployment=llama-3.1&version=2024-08-01-preview&engine=meta' + ); + + $this->assertIsObject($obj); + $this->assertSame('azure-meta', $obj->bridge ?? null); + + $args = AzureMetaBridge::$lastArgs ?? []; + $this->assertSame('AZ', $args['apiKey'] ?? null); + $this->assertSame('https://my-resource.meta.azure.com', $args['contract']['base_uri'] ?? null); + $this->assertSame('azure', $args['contract']['provider'] ?? null); + $this->assertSame('meta', $args['contract']['options']['engine'] ?? null); + $this->assertSame('llama-3.1', $args['contract']['options']['deployment'] ?? null); + + $headers = $args['contract']['headers'] ?? []; + $this->assertSame('AZ', $headers['api-key'] ?? null); + } + + public function testUnsupportedProviderThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + + $factory = new ProviderFactory(); + $factory->fromDsn('ai+madeup://x@y.z'); + } + + public function testAzureMissingDeploymentOrVersionBubblesUp(): void + { + $this->expectException(\InvalidArgumentException::class); + + $factory = new ProviderFactory(); + $factory->fromDsn('ai+azure://AZ@my-resource.openai.azure.com?version=2024-08-01-preview'); + } +} diff --git a/src/platform/tests/bootstrap_fake_bridges.php b/src/platform/tests/bootstrap_fake_bridges.php new file mode 100644 index 000000000..b24f28e74 --- /dev/null +++ b/src/platform/tests/bootstrap_fake_bridges.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +$loader = require __DIR__.'/../../../vendor/autoload.php'; + +$loader->addPsr4( + 'Symfony\\AI\\Platform\\Bridge\\', + __DIR__.'/Factory/FakeBridges', + true // PREPEND +); From 4ef8cd4e8cf9854738fa560961a992b635d36a4e Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 11:49:00 +0200 Subject: [PATCH 02/12] feat(Platform): implement ProviderFactory and ProviderConfigFactory (RFC #402) --- src/platform/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 4c8160791..cf66986f0 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -61,3 +61,7 @@ CHANGELOG * Add tool calling support for Ollama platform * Allow beta feature flags to be passed into Anthropic model options * Add Ollama streaming output support + +## [Unreleased] + +- feature #402 [AI] Introduced `ProviderFactory` and `ProviderConfigFactory` to create AI provider platforms from DSNs. From 081abfbba6ca123266d8471083e0718e003387c5 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 17:44:00 +0200 Subject: [PATCH 03/12] feat(Platform): implement ProviderFactory and ProviderConfigFactory (RFC #402) --- src/platform/tests/Factory/ProviderConfigFactoryTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/tests/Factory/ProviderConfigFactoryTest.php b/src/platform/tests/Factory/ProviderConfigFactoryTest.php index 254642072..888447edb 100644 --- a/src/platform/tests/Factory/ProviderConfigFactoryTest.php +++ b/src/platform/tests/Factory/ProviderConfigFactoryTest.php @@ -15,8 +15,8 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Factory\ProviderConfigFactory; -use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory as OpenAIBridge; -use Symfony\AI\Platform\Bridge\Azure\OpenAI\PlatformFactory as AzureOpenAIBridge; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAIBridge; +use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAIBridge; use Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory as AzureMetaBridge; #[Group('pf')] From c62f4664cd78e169cfc2b1afb71c9a73887a1ee1 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 17:46:28 +0200 Subject: [PATCH 04/12] feat(Platform): implement ProviderFactory and ProviderConfigFactory (RFC #402) --- src/platform/src/Factory/ProviderFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/src/Factory/ProviderFactory.php b/src/platform/src/Factory/ProviderFactory.php index a875c9cc8..ad7714093 100644 --- a/src/platform/src/Factory/ProviderFactory.php +++ b/src/platform/src/Factory/ProviderFactory.php @@ -34,7 +34,7 @@ public function fromDsn(string $dsn): object }; } else { $factoryMap = [ - 'openai' => \Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory::class, + 'openai' => \Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory::class, 'anthropic' => \Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory::class, 'azure' => \Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory::class, 'gemini' => \Symfony\AI\Platform\Bridge\Gemini\PlatformFactory::class, From 95977af82146b6d1e981ddbcf1bc042a599037d4 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 17:47:23 +0200 Subject: [PATCH 05/12] feat(Platform): implement ProviderFactory and ProviderConfigFactory (RFC #402) --- src/platform/src/Factory/ProviderFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/src/Factory/ProviderFactory.php b/src/platform/src/Factory/ProviderFactory.php index ad7714093..bf807c69f 100644 --- a/src/platform/src/Factory/ProviderFactory.php +++ b/src/platform/src/Factory/ProviderFactory.php @@ -28,7 +28,7 @@ public function fromDsn(string $dsn): object if ('azure' === $providerKey) { $engine = strtolower($config->options['engine'] ?? 'openai'); $factoryFqcn = match ($engine) { - 'openai' => \Symfony\AI\Platform\Bridge\Azure\OpenAI\PlatformFactory::class, + 'openai' => \Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory::class, 'meta' => \Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory::class, default => throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)), }; From 30550647c1f18604e4ceeff64b0a8d837a690fde Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 17:53:09 +0200 Subject: [PATCH 06/12] feat(Platform): remove return types from test methods to satisfy CI rule --- .../Factory/ProviderConfigFactoryTest.php | 20 +++++++++---------- .../tests/Factory/ProviderFactoryTest.php | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/platform/tests/Factory/ProviderConfigFactoryTest.php b/src/platform/tests/Factory/ProviderConfigFactoryTest.php index 888447edb..a06adb973 100644 --- a/src/platform/tests/Factory/ProviderConfigFactoryTest.php +++ b/src/platform/tests/Factory/ProviderConfigFactoryTest.php @@ -23,7 +23,7 @@ #[CoversClass(ProviderConfigFactory::class)] class ProviderConfigFactoryTest extends TestCase { - public function testOpenAiDefaults(): void + public function testOpenAiDefaults() { $cfg = ProviderConfigFactory::fromDsn( 'ai+openai://sk-test@api.openai.com?model=gpt-4o-mini&organization=org_123&headers[x-foo]=bar' @@ -37,7 +37,7 @@ public function testOpenAiDefaults(): void $this->assertSame('bar', $cfg->headers['x-foo'] ?? null); } - public function testOpenAiWithoutHostUsesDefault(): void + public function testOpenAiWithoutHostUsesDefault() { $cfg = ProviderConfigFactory::fromDsn('ai+openai://sk-test@/?model=gpt-4o-mini'); @@ -45,7 +45,7 @@ public function testOpenAiWithoutHostUsesDefault(): void $this->assertSame('gpt-4o-mini', $cfg->options['model'] ?? null); } - public function testAzureOpenAiHappyPath(): void + public function testAzureOpenAiHappyPath() { $cfg = ProviderConfigFactory::fromDsn( 'ai+azure://AZ_KEY@my-resource.openai.azure.com?deployment=gpt-4o&version=2024-08-01-preview&engine=openai' @@ -59,7 +59,7 @@ public function testAzureOpenAiHappyPath(): void $this->assertSame('openai', $cfg->options['engine'] ?? null); } - public function testAzureMetaHappyPath(): void + public function testAzureMetaHappyPath() { $cfg = ProviderConfigFactory::fromDsn( 'ai+azure://AZ_KEY@my-resource.meta.azure.com?deployment=llama-3.1&version=2024-08-01-preview&engine=meta' @@ -71,7 +71,7 @@ public function testAzureMetaHappyPath(): void $this->assertSame('llama-3.1', $cfg->options['deployment'] ?? null); } - public function testGenericOptionsAndBooleans(): void + public function testGenericOptionsAndBooleans() { $cfg = ProviderConfigFactory::fromDsn( 'ai+openai://sk@/?model=gpt-4o-mini&timeout=10&verify_peer=true&proxy=http://proxy:8080' @@ -82,31 +82,31 @@ public function testGenericOptionsAndBooleans(): void $this->assertSame('http://proxy:8080', $cfg->options['proxy'] ?? null); } - public function testUnknownProviderThrows(): void + public function testUnknownProviderThrows() { $this->expectException(\InvalidArgumentException::class); ProviderConfigFactory::fromDsn('ai+unknown://key@host'); } - public function testAzureMissingHostThrows(): void + public function testAzureMissingHostThrows() { $this->expectException(\InvalidArgumentException::class); ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@/?deployment=gpt-4o&version=2024-08-01-preview'); } - public function testAzureMissingDeploymentThrows(): void + public function testAzureMissingDeploymentThrows() { $this->expectException(\InvalidArgumentException::class); ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@my.openai.azure.com?version=2024-08-01-preview'); } - public function testAzureMissingVersionThrows(): void + public function testAzureMissingVersionThrows() { $this->expectException(\InvalidArgumentException::class); ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@my.openai.azure.com?deployment=gpt-4o'); } - public function testAzureUnsupportedEngineThrows(): void + public function testAzureUnsupportedEngineThrows() { $this->expectException(\InvalidArgumentException::class); ProviderConfigFactory::fromDsn( diff --git a/src/platform/tests/Factory/ProviderFactoryTest.php b/src/platform/tests/Factory/ProviderFactoryTest.php index e42a95be5..7625b44c6 100644 --- a/src/platform/tests/Factory/ProviderFactoryTest.php +++ b/src/platform/tests/Factory/ProviderFactoryTest.php @@ -30,7 +30,7 @@ protected function tearDown(): void AzureMetaBridge::$lastArgs = []; } - public function testBuildsOpenAiWithBearerAuth(): void + public function testBuildsOpenAiWithBearerAuth() { $factory = new ProviderFactory(); @@ -49,7 +49,7 @@ public function testBuildsOpenAiWithBearerAuth(): void $this->assertArrayNotHasKey('api-key', $headers); } - public function testBuildsAzureOpenAiWithApiKeyHeader(): void + public function testBuildsAzureOpenAiWithApiKeyHeader() { $factory = new ProviderFactory(); @@ -73,7 +73,7 @@ public function testBuildsAzureOpenAiWithApiKeyHeader(): void $this->assertArrayNotHasKey('Authorization', $headers); } - public function testBuildsAzureMetaWhenEngineMeta(): void + public function testBuildsAzureMetaWhenEngineMeta() { $factory = new ProviderFactory(); @@ -95,7 +95,7 @@ public function testBuildsAzureMetaWhenEngineMeta(): void $this->assertSame('AZ', $headers['api-key'] ?? null); } - public function testUnsupportedProviderThrows(): void + public function testUnsupportedProviderThrows() { $this->expectException(\InvalidArgumentException::class); @@ -103,7 +103,7 @@ public function testUnsupportedProviderThrows(): void $factory->fromDsn('ai+madeup://x@y.z'); } - public function testAzureMissingDeploymentOrVersionBubblesUp(): void + public function testAzureMissingDeploymentOrVersionBubblesUp() { $this->expectException(\InvalidArgumentException::class); From c2874d42be2d39a0f3f82602b11ba2c1a0941c8a Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 18:13:36 +0200 Subject: [PATCH 07/12] feat(Platform): php-cs-fixer changes --- src/platform/src/Factory/ProviderFactoryInterface.php | 9 +++++++++ .../FakeBridges/Azure/Meta/PlatformFactory.php | 11 +++++++++++ .../FakeBridges/Azure/OpenAi/PlatformFactory.php | 11 +++++++++++ .../Factory/FakeBridges/OpenAi/PlatformFactory.php | 11 +++++++++++ .../tests/Factory/ProviderConfigFactoryTest.php | 3 --- src/platform/tests/Factory/ProviderFactoryTest.php | 6 +++--- 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/platform/src/Factory/ProviderFactoryInterface.php b/src/platform/src/Factory/ProviderFactoryInterface.php index daba7a6cd..5a8af6047 100644 --- a/src/platform/src/Factory/ProviderFactoryInterface.php +++ b/src/platform/src/Factory/ProviderFactoryInterface.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\AI\Platform\Factory; interface ProviderFactoryInterface diff --git a/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php b/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php index 9b75bc827..845e1284e 100644 --- a/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php +++ b/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php @@ -1,8 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\AI\Platform\Bridge\Azure\Meta; use Symfony\Contracts\HttpClient\HttpClientInterface; + final class PlatformFactory { public static array $lastArgs = []; @@ -10,6 +20,7 @@ final class PlatformFactory public static function create(string $apiKey, HttpClientInterface $http, array $contract): object { self::$lastArgs = compact('apiKey', 'http', 'contract'); + return (object) ['bridge' => 'azure-meta']; } } diff --git a/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php b/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php index a64ec80da..959329284 100644 --- a/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php +++ b/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php @@ -1,8 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\AI\Platform\Bridge\Azure\OpenAi; use Symfony\Contracts\HttpClient\HttpClientInterface; + final class PlatformFactory { public static array $lastArgs = []; @@ -10,6 +20,7 @@ final class PlatformFactory public static function create(string $apiKey, HttpClientInterface $http, array $contract): object { self::$lastArgs = compact('apiKey', 'http', 'contract'); + return (object) ['bridge' => 'azure-openai']; } } diff --git a/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php b/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php index f94b7aa7d..7a9552489 100644 --- a/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php +++ b/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php @@ -1,8 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\AI\Platform\Bridge\OpenAi; use Symfony\Contracts\HttpClient\HttpClientInterface; + final class PlatformFactory { public static array $lastArgs = []; @@ -10,6 +20,7 @@ final class PlatformFactory public static function create(string $apiKey, HttpClientInterface $http, array $contract): object { self::$lastArgs = compact('apiKey', 'http', 'contract'); + return (object) ['bridge' => 'openai']; } } diff --git a/src/platform/tests/Factory/ProviderConfigFactoryTest.php b/src/platform/tests/Factory/ProviderConfigFactoryTest.php index a06adb973..b284b3f85 100644 --- a/src/platform/tests/Factory/ProviderConfigFactoryTest.php +++ b/src/platform/tests/Factory/ProviderConfigFactoryTest.php @@ -15,9 +15,6 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Factory\ProviderConfigFactory; -use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAIBridge; -use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAIBridge; -use Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory as AzureMetaBridge; #[Group('pf')] #[CoversClass(ProviderConfigFactory::class)] diff --git a/src/platform/tests/Factory/ProviderFactoryTest.php b/src/platform/tests/Factory/ProviderFactoryTest.php index 7625b44c6..7f29e4dcc 100644 --- a/src/platform/tests/Factory/ProviderFactoryTest.php +++ b/src/platform/tests/Factory/ProviderFactoryTest.php @@ -14,10 +14,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Symfony\AI\Platform\Factory\ProviderFactory; -use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAIBridge; -use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAIBridge; use Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory as AzureMetaBridge; +use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAIBridge; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAIBridge; +use Symfony\AI\Platform\Factory\ProviderFactory; #[Group('pf')] #[CoversClass(ProviderFactory::class)] From 4c3afbfad94e6b69f9dd59b4ebd842ee01cc1d67 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 18:37:24 +0200 Subject: [PATCH 08/12] feat(Platform): phpstan changes --- .../src/Factory/ProviderConfigFactory.php | 17 ++++++++++------- src/platform/src/Factory/ProviderFactory.php | 2 +- src/platform/src/Provider/ProviderConfig.php | 4 ++++ src/platform/src/Transport/Dsn.php | 4 ++++ .../tests/Factory/ProviderFactoryTest.php | 7 +++++++ 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/platform/src/Factory/ProviderConfigFactory.php b/src/platform/src/Factory/ProviderConfigFactory.php index 35fc6b43f..f2041467a 100644 --- a/src/platform/src/Factory/ProviderConfigFactory.php +++ b/src/platform/src/Factory/ProviderConfigFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Factory; +use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Provider\ProviderConfig; use Symfony\AI\Platform\Transport\Dsn; @@ -22,7 +23,7 @@ public static function fromDsn(string|Dsn $dsn): ProviderConfig $provider = strtolower($dsn->getProvider()); if ('' === $provider) { - throw new \InvalidArgumentException('DSN must include a provider (e.g. "ai+openai://...").'); + throw new InvalidArgumentException('DSN must include a provider (e.g. "ai+openai://...").'); } $host = $dsn->getHost(); @@ -72,18 +73,20 @@ public static function fromDsn(string|Dsn $dsn): ProviderConfig case 'azure': $engine = strtolower((string) ($q['engine'] ?? 'openai')); if (!\in_array($engine, ['openai', 'meta'], true)) { - throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)); + throw new InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)); } $options['engine'] = $engine; - if ('' === $dsn->getHost()) { - throw new \InvalidArgumentException('Azure DSN requires host: ".openai.azure.com" or ".meta.azure.com".'); + $host = (string) ($dsn->getHost() ?? ''); + if ('' === $host) { + throw new InvalidArgumentException('Azure DSN requires host: ".openai.azure.com" or ".meta.azure.com".'); } + if (!isset($options['deployment']) || '' === $options['deployment']) { - throw new \InvalidArgumentException('Azure DSN requires "deployment" query param.'); + throw new InvalidArgumentException('Azure DSN requires "deployment" query param.'); } if (!isset($options['version']) || '' === $options['version']) { - throw new \InvalidArgumentException('Azure DSN requires "version" query param.'); + throw new InvalidArgumentException('Azure DSN requires "version" query param.'); } break; @@ -95,7 +98,7 @@ public static function fromDsn(string|Dsn $dsn): ProviderConfig break; default: - throw new \InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)); + throw new InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)); } return new ProviderConfig( diff --git a/src/platform/src/Factory/ProviderFactory.php b/src/platform/src/Factory/ProviderFactory.php index bf807c69f..a4a8e9046 100644 --- a/src/platform/src/Factory/ProviderFactory.php +++ b/src/platform/src/Factory/ProviderFactory.php @@ -38,7 +38,7 @@ public function fromDsn(string $dsn): object 'anthropic' => \Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory::class, 'azure' => \Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory::class, 'gemini' => \Symfony\AI\Platform\Bridge\Gemini\PlatformFactory::class, - 'vertex' => \Symfony\AI\Platform\Bridge\VertexAI\PlatformFactory::class, + 'vertex' => \Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory::class, 'ollama' => \Symfony\AI\Platform\Bridge\Ollama\PlatformFactory::class, ]; diff --git a/src/platform/src/Provider/ProviderConfig.php b/src/platform/src/Provider/ProviderConfig.php index bf1aaed71..3163ed8a7 100644 --- a/src/platform/src/Provider/ProviderConfig.php +++ b/src/platform/src/Provider/ProviderConfig.php @@ -13,6 +13,10 @@ final readonly class ProviderConfig { + /** + * @param array $options + * @param array $headers + */ public function __construct( public string $provider, public string $baseUri, diff --git a/src/platform/src/Transport/Dsn.php b/src/platform/src/Transport/Dsn.php index 06c375024..d324dafd7 100644 --- a/src/platform/src/Transport/Dsn.php +++ b/src/platform/src/Transport/Dsn.php @@ -13,6 +13,9 @@ final class Dsn { + /** + * @param array $query + */ public function __construct(private string $scheme, private string $host = '', private ?int $port = null, private ?string $user = null, private ?string $password = null, private array $query = []) { } @@ -117,6 +120,7 @@ public function getPassword(): ?string return $this->password; } + /** @return array */ public function getQuery(): array { return $this->query; diff --git a/src/platform/tests/Factory/ProviderFactoryTest.php b/src/platform/tests/Factory/ProviderFactoryTest.php index 7f29e4dcc..b31f0cbd5 100644 --- a/src/platform/tests/Factory/ProviderFactoryTest.php +++ b/src/platform/tests/Factory/ProviderFactoryTest.php @@ -19,6 +19,13 @@ use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAIBridge; use Symfony\AI\Platform\Factory\ProviderFactory; +/** + * @phpstan-type BridgeArgs array{apiKey:string, http:object, contract:array} + * + * @phpstan-var class-string $openAiFqcn + * @phpstan-var class-string $azureOpenAiFqcn + * @phpstan-var class-string $azureMetaFqcn + */ #[Group('pf')] #[CoversClass(ProviderFactory::class)] final class ProviderFactoryTest extends TestCase From df6c32694235c9bbcda91ac37675bdd108a56502 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 18:54:11 +0200 Subject: [PATCH 09/12] feat(Platform): phpstan changes --- src/platform/src/Factory/ProviderConfigFactory.php | 10 +++++----- src/platform/src/Factory/ProviderFactory.php | 3 ++- src/platform/src/Transport/Dsn.php | 6 ++++-- src/platform/tests/Factory/ProviderFactoryTest.php | 7 +++---- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/platform/src/Factory/ProviderConfigFactory.php b/src/platform/src/Factory/ProviderConfigFactory.php index f2041467a..000c4e058 100644 --- a/src/platform/src/Factory/ProviderConfigFactory.php +++ b/src/platform/src/Factory/ProviderConfigFactory.php @@ -77,15 +77,15 @@ public static function fromDsn(string|Dsn $dsn): ProviderConfig } $options['engine'] = $engine; - $host = (string) ($dsn->getHost() ?? ''); + $host = $dsn->getHost(); if ('' === $host) { throw new InvalidArgumentException('Azure DSN requires host: ".openai.azure.com" or ".meta.azure.com".'); } - if (!isset($options['deployment']) || '' === $options['deployment']) { + if (empty($options['deployment'])) { throw new InvalidArgumentException('Azure DSN requires "deployment" query param.'); } - if (!isset($options['version']) || '' === $options['version']) { + if (empty($options['version'])) { throw new InvalidArgumentException('Azure DSN requires "version" query param.'); } break; @@ -128,8 +128,8 @@ private static function defaultHostOrFail(string $provider): string 'gemini' => 'generativelanguage.googleapis.com', 'vertex' => 'us-central1-aiplatform.googleapis.com', 'ollama' => 'localhost', - 'azure' => throw new \InvalidArgumentException('Azure DSN must specify host (e.g. ".openai.azure.com").'), - default => throw new \InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)), + 'azure' => throw new InvalidArgumentException('Azure DSN must specify host (e.g. ".openai.azure.com").'), + default => throw new InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)), }; } } diff --git a/src/platform/src/Factory/ProviderFactory.php b/src/platform/src/Factory/ProviderFactory.php index a4a8e9046..cda212798 100644 --- a/src/platform/src/Factory/ProviderFactory.php +++ b/src/platform/src/Factory/ProviderFactory.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Factory; +use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -43,7 +44,7 @@ public function fromDsn(string $dsn): object ]; if (!isset($factoryMap[$providerKey])) { - throw new \InvalidArgumentException(\sprintf('Unsupported AI provider "%s".', $config->provider)); + throw new InvalidArgumentException(\sprintf('Unsupported AI provider "%s".', $config->provider)); } $factoryFqcn = $factoryMap[$providerKey]; diff --git a/src/platform/src/Transport/Dsn.php b/src/platform/src/Transport/Dsn.php index d324dafd7..590ef139f 100644 --- a/src/platform/src/Transport/Dsn.php +++ b/src/platform/src/Transport/Dsn.php @@ -11,6 +11,8 @@ namespace Symfony\AI\Platform\Transport; +use Symfony\AI\Platform\Exception\InvalidArgumentException; + final class Dsn { /** @@ -23,7 +25,7 @@ public function __construct(private string $scheme, private string $host = '', p public static function fromString(string $dsn): self { if (!preg_match('#^([a-zA-Z][a-zA-Z0-9+.\-]*)://#', $dsn, $m)) { - throw new \InvalidArgumentException(\sprintf('Invalid DSN "%s": missing scheme.', $dsn)); + throw new InvalidArgumentException(\sprintf('Invalid DSN "%s": missing scheme.', $dsn)); } $scheme = $m[1]; @@ -74,7 +76,7 @@ public static function fromString(string $dsn): self $rest = false === $slash ? '' : substr($rest, $slash); $hp = explode(':', $authority, 2); - $host = $hp[0] ?? ''; + $host = $hp[0]; if (isset($hp[1]) && '' !== $hp[1] && ctype_digit($hp[1])) { $port = (int) $hp[1]; } diff --git a/src/platform/tests/Factory/ProviderFactoryTest.php b/src/platform/tests/Factory/ProviderFactoryTest.php index b31f0cbd5..5692ef02b 100644 --- a/src/platform/tests/Factory/ProviderFactoryTest.php +++ b/src/platform/tests/Factory/ProviderFactoryTest.php @@ -21,10 +21,6 @@ /** * @phpstan-type BridgeArgs array{apiKey:string, http:object, contract:array} - * - * @phpstan-var class-string $openAiFqcn - * @phpstan-var class-string $azureOpenAiFqcn - * @phpstan-var class-string $azureMetaFqcn */ #[Group('pf')] #[CoversClass(ProviderFactory::class)] @@ -32,8 +28,11 @@ final class ProviderFactoryTest extends TestCase { protected function tearDown(): void { + /* @phpstan-ignore-next-line */ OpenAIBridge::$lastArgs = []; + /* @phpstan-ignore-next-line */ AzureOpenAIBridge::$lastArgs = []; + /* @phpstan-ignore-next-line */ AzureMetaBridge::$lastArgs = []; } From b27d12dccba513818314e054e3e9eac246f5f6d2 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 19:08:34 +0200 Subject: [PATCH 10/12] feat(Platform): phpstan changes --- src/platform/src/Factory/ProviderFactory.php | 2 +- src/platform/tests/Factory/ProviderFactoryTest.php | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/platform/src/Factory/ProviderFactory.php b/src/platform/src/Factory/ProviderFactory.php index cda212798..e06a8092e 100644 --- a/src/platform/src/Factory/ProviderFactory.php +++ b/src/platform/src/Factory/ProviderFactory.php @@ -31,7 +31,7 @@ public function fromDsn(string $dsn): object $factoryFqcn = match ($engine) { 'openai' => \Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory::class, 'meta' => \Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory::class, - default => throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)), + default => throw new InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)), }; } else { $factoryMap = [ diff --git a/src/platform/tests/Factory/ProviderFactoryTest.php b/src/platform/tests/Factory/ProviderFactoryTest.php index 5692ef02b..25af9353c 100644 --- a/src/platform/tests/Factory/ProviderFactoryTest.php +++ b/src/platform/tests/Factory/ProviderFactoryTest.php @@ -42,9 +42,8 @@ public function testBuildsOpenAiWithBearerAuth() $obj = $factory->fromDsn('ai+openai://sk-test@api.openai.com?model=gpt-4o-mini'); - $this->assertIsObject($obj); $this->assertSame('openai', $obj->bridge ?? null); - + /** @phpstan-ignore-next-line */ $args = OpenAIBridge::$lastArgs ?? []; $this->assertSame('sk-test', $args['apiKey'] ?? null); $this->assertSame('https://api.openai.com', $args['contract']['base_uri'] ?? null); @@ -63,9 +62,8 @@ public function testBuildsAzureOpenAiWithApiKeyHeader() 'ai+azure://AZ@my-resource.openai.azure.com?deployment=gpt-4o&version=2024-08-01-preview&engine=openai' ); - $this->assertIsObject($obj); $this->assertSame('azure-openai', $obj->bridge ?? null); - + /** @phpstan-ignore-next-line */ $args = AzureOpenAIBridge::$lastArgs ?? []; $this->assertSame('AZ', $args['apiKey'] ?? null); $this->assertSame('https://my-resource.openai.azure.com', $args['contract']['base_uri'] ?? null); @@ -87,9 +85,8 @@ public function testBuildsAzureMetaWhenEngineMeta() 'ai+azure://AZ@my-resource.meta.azure.com?deployment=llama-3.1&version=2024-08-01-preview&engine=meta' ); - $this->assertIsObject($obj); $this->assertSame('azure-meta', $obj->bridge ?? null); - + /** @phpstan-ignore-next-line */ $args = AzureMetaBridge::$lastArgs ?? []; $this->assertSame('AZ', $args['apiKey'] ?? null); $this->assertSame('https://my-resource.meta.azure.com', $args['contract']['base_uri'] ?? null); From 9b866af3bd1366f61c2420a0fb234d21da818694 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Wed, 3 Sep 2025 23:09:47 +0200 Subject: [PATCH 11/12] feat(Platform): update CHANGELOG.md --- src/platform/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index cf66986f0..140d0d2c2 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -64,4 +64,4 @@ CHANGELOG ## [Unreleased] -- feature #402 [AI] Introduced `ProviderFactory` and `ProviderConfigFactory` to create AI provider platforms from DSNs. +- feature #402 [Platform] Introduced `ProviderFactory` and `ProviderConfigFactory` to create AI provider platforms from DSNs. From 2d1db70fff565a80819082a48216c262d8bc1739 Mon Sep 17 00:00:00 2001 From: Jakub Skowron Date: Thu, 4 Sep 2025 07:27:15 +0200 Subject: [PATCH 12/12] feat(Platform): update CHANGELOG.md --- src/platform/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 140d0d2c2..733a87c06 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -64,4 +64,4 @@ CHANGELOG ## [Unreleased] -- feature #402 [Platform] Introduced `ProviderFactory` and `ProviderConfigFactory` to create AI provider platforms from DSNs. +- Introduced `ProviderFactory` and `ProviderConfigFactory` to create AI provider platforms from DSNs.