Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 4 additions & 0 deletions src/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 27 additions & 0 deletions src/platform/phpunit.provider-factory.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap_fake_bridges.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
failOnRisky="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="provider-factory">
<directory>tests</directory>
</testsuite>
</testsuites>

<groups>
<include>
<group>pf</group>
</include>
</groups>

<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
6 changes: 6 additions & 0 deletions src/platform/phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
</testsuite>
</testsuites>

<groups>
<exclude>
<group>pf</group>
</exclude>
</groups>

<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
Expand Down
135 changes: 135 additions & 0 deletions src/platform/src/Factory/ProviderConfigFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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: "<resource>.openai.azure.com" or "<resource>.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. "<resource>.openai.azure.com").'),
default => throw new InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)),
Comment on lines +125 to +132
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those should not be defined in the DSN class, but rather (like symfony/notfier does) in the AIBundle.php, ofc SfNotfier does this in FrameworkBundle

};
}
}
78 changes: 78 additions & 0 deletions src/platform/src/Factory/ProviderFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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) {
Copy link
Contributor

@OskarStark OskarStark Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets go with dedicated PlatformFactory classes in each bridge (like the bridges in symfony/symfony)

'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);
}
}
17 changes: 17 additions & 0 deletions src/platform/src/Factory/ProviderFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;
}
28 changes: 28 additions & 0 deletions src/platform/src/Provider/ProviderConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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<string,mixed> $options
* @param array<string,mixed> $headers
*/
public function __construct(
public string $provider,
public string $baseUri,
public ?string $apiKey,
public array $options = [],
public array $headers = [],
) {
}
}
Loading