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/CHANGELOG.md b/src/platform/CHANGELOG.md
index 4c8160791..733a87c06 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]
+
+- Introduced `ProviderFactory` and `ProviderConfigFactory` to create AI provider platforms from DSNs.
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..000c4e058
--- /dev/null
+++ b/src/platform/src/Factory/ProviderConfigFactory.php
@@ -0,0 +1,135 @@
+
+ *
+ * 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\Exception\InvalidArgumentException;
+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;
+
+ $host = $dsn->getHost();
+ if ('' === $host) {
+ throw new InvalidArgumentException('Azure DSN requires host: ".openai.azure.com" or ".meta.azure.com".');
+ }
+
+ if (empty($options['deployment'])) {
+ throw new InvalidArgumentException('Azure DSN requires "deployment" query param.');
+ }
+ if (empty($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..e06a8092e
--- /dev/null
+++ b/src/platform/src/Factory/ProviderFactory.php
@@ -0,0 +1,78 @@
+
+ *
+ * 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\Exception\InvalidArgumentException;
+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..5a8af6047
--- /dev/null
+++ b/src/platform/src/Factory/ProviderFactoryInterface.php
@@ -0,0 +1,17 @@
+
+ *
+ * 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
+{
+ public function fromDsn(string $dsn): object;
+}
diff --git a/src/platform/src/Provider/ProviderConfig.php b/src/platform/src/Provider/ProviderConfig.php
new file mode 100644
index 000000000..3163ed8a7
--- /dev/null
+++ b/src/platform/src/Provider/ProviderConfig.php
@@ -0,0 +1,28 @@
+
+ *
+ * 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
+{
+ /**
+ * @param array $options
+ * @param array $headers
+ */
+ 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..590ef139f
--- /dev/null
+++ b/src/platform/src/Transport/Dsn.php
@@ -0,0 +1,140 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\Platform\Transport;
+
+use Symfony\AI\Platform\Exception\InvalidArgumentException;
+
+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 = [])
+ {
+ }
+
+ 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;
+ }
+
+ /** @return array */
+ 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..845e1284e
--- /dev/null
+++ b/src/platform/tests/Factory/FakeBridges/Azure/Meta/PlatformFactory.php
@@ -0,0 +1,26 @@
+
+ *
+ * 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 = [];
+
+ 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
new file mode 100644
index 000000000..959329284
--- /dev/null
+++ b/src/platform/tests/Factory/FakeBridges/Azure/OpenAi/PlatformFactory.php
@@ -0,0 +1,26 @@
+
+ *
+ * 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 = [];
+
+ 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
new file mode 100644
index 000000000..7a9552489
--- /dev/null
+++ b/src/platform/tests/Factory/FakeBridges/OpenAi/PlatformFactory.php
@@ -0,0 +1,26 @@
+
+ *
+ * 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 = [];
+
+ 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
new file mode 100644
index 000000000..b284b3f85
--- /dev/null
+++ b/src/platform/tests/Factory/ProviderConfigFactoryTest.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\ProviderConfigFactory;
+
+#[Group('pf')]
+#[CoversClass(ProviderConfigFactory::class)]
+class ProviderConfigFactoryTest extends TestCase
+{
+ public function testOpenAiDefaults()
+ {
+ $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()
+ {
+ $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()
+ {
+ $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()
+ {
+ $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()
+ {
+ $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()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ ProviderConfigFactory::fromDsn('ai+unknown://key@host');
+ }
+
+ public function testAzureMissingHostThrows()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@/?deployment=gpt-4o&version=2024-08-01-preview');
+ }
+
+ 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()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ ProviderConfigFactory::fromDsn('ai+azure://AZ_KEY@my.openai.azure.com?deployment=gpt-4o');
+ }
+
+ public function testAzureUnsupportedEngineThrows()
+ {
+ $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..25af9353c
--- /dev/null
+++ b/src/platform/tests/Factory/ProviderFactoryTest.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\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;
+
+/**
+ * @phpstan-type BridgeArgs array{apiKey:string, http:object, contract:array}
+ */
+#[Group('pf')]
+#[CoversClass(ProviderFactory::class)]
+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 = [];
+ }
+
+ public function testBuildsOpenAiWithBearerAuth()
+ {
+ $factory = new ProviderFactory();
+
+ $obj = $factory->fromDsn('ai+openai://sk-test@api.openai.com?model=gpt-4o-mini');
+
+ $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);
+ $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()
+ {
+ $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->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);
+ $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()
+ {
+ $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->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);
+ $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()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ $factory = new ProviderFactory();
+ $factory->fromDsn('ai+madeup://x@y.z');
+ }
+
+ public function testAzureMissingDeploymentOrVersionBubblesUp()
+ {
+ $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
+);