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