Skip to content

Commit 92c72fb

Browse files
committed
feat(Platform): implement ProviderFactory and ProviderConfigFactory (RFC #402)
1 parent d8c374e commit 92c72fb

14 files changed

+706
-0
lines changed

.github/workflows/unit-tests.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,9 @@ jobs:
8282
source .github/workflows/.utils.sh
8383
8484
echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} '(cd src/{} && $COMPOSER_UP && $PHPUNIT)'"
85+
86+
- name: Run platform provider-factory tests (special bootstrap)
87+
if: contains(env.PACKAGES, 'platform')
88+
run: |
89+
set -e
90+
(cd src/platform && $COMPOSER_UP && $PHPUNIT -c phpunit.provider-factory.xml)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit
3+
bootstrap="tests/bootstrap_fake_bridges.php"
4+
colors="true"
5+
cacheDirectory=".phpunit.cache"
6+
executionOrder="depends,defects"
7+
failOnRisky="true"
8+
failOnWarning="true"
9+
>
10+
<testsuites>
11+
<testsuite name="provider-factory">
12+
<directory>tests</directory>
13+
</testsuite>
14+
</testsuites>
15+
16+
<groups>
17+
<include>
18+
<group>pf</group>
19+
</include>
20+
</groups>
21+
22+
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
23+
<include>
24+
<directory>src</directory>
25+
</include>
26+
</source>
27+
</phpunit>

src/platform/phpunit.xml.dist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
</testsuite>
1717
</testsuites>
1818

19+
<groups>
20+
<exclude>
21+
<group>pf</group>
22+
</exclude>
23+
</groups>
24+
1925
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
2026
<include>
2127
<directory>src</directory>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Factory;
13+
14+
use Symfony\AI\Platform\Provider\ProviderConfig;
15+
use Symfony\AI\Platform\Transport\Dsn;
16+
17+
final class ProviderConfigFactory
18+
{
19+
public static function fromDsn(string|Dsn $dsn): ProviderConfig
20+
{
21+
$dsn = \is_string($dsn) ? Dsn::fromString($dsn) : $dsn;
22+
23+
$provider = strtolower($dsn->getProvider());
24+
if ('' === $provider) {
25+
throw new \InvalidArgumentException('DSN must include a provider (e.g. "ai+openai://...").');
26+
}
27+
28+
$host = $dsn->getHost();
29+
if ('' === $host) {
30+
$host = self::defaultHostOrFail($provider);
31+
}
32+
33+
$scheme = 'https';
34+
$port = $dsn->getPort();
35+
$baseUri = $scheme.'://'.$host.($port ? ':'.$port : '');
36+
37+
$q = $dsn->getQuery();
38+
39+
$headers = [];
40+
41+
if (isset($q['headers']) && \is_array($q['headers'])) {
42+
foreach ($q['headers'] as $hk => $hv) {
43+
$headers[$hk] = $hv;
44+
}
45+
}
46+
47+
foreach ($q as $k => $v) {
48+
if (preg_match('/^headers\[(.+)\]$/', (string) $k, $m)) {
49+
$headers[$m[1]] = $v;
50+
continue;
51+
}
52+
if (str_starts_with((string) $k, 'headers_')) {
53+
$hk = substr((string) $k, \strlen('headers_'));
54+
if ('' !== $hk) {
55+
$headers[$hk] = $v;
56+
}
57+
}
58+
}
59+
60+
$options = array_filter([
61+
'model' => $q['model'] ?? null,
62+
'version' => $q['version'] ?? null,
63+
'deployment' => $q['deployment'] ?? null,
64+
'organization' => $q['organization'] ?? null,
65+
'location' => $q['location'] ?? ($q['region'] ?? null),
66+
'timeout' => isset($q['timeout']) ? (int) $q['timeout'] : null,
67+
'verify_peer' => isset($q['verify_peer']) ? self::toBool($q['verify_peer']) : null,
68+
'proxy' => $q['proxy'] ?? null,
69+
], static fn ($v) => null !== $v && '' !== $v);
70+
71+
switch ($provider) {
72+
case 'azure':
73+
$engine = strtolower((string) ($q['engine'] ?? 'openai'));
74+
if (!\in_array($engine, ['openai', 'meta'], true)) {
75+
throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine));
76+
}
77+
$options['engine'] = $engine;
78+
79+
if ('' === $dsn->getHost()) {
80+
throw new \InvalidArgumentException('Azure DSN requires host: "<resource>.openai.azure.com" or "<resource>.meta.azure.com".');
81+
}
82+
if (!isset($options['deployment']) || '' === $options['deployment']) {
83+
throw new \InvalidArgumentException('Azure DSN requires "deployment" query param.');
84+
}
85+
if (!isset($options['version']) || '' === $options['version']) {
86+
throw new \InvalidArgumentException('Azure DSN requires "version" query param.');
87+
}
88+
break;
89+
90+
case 'openai':
91+
case 'anthropic':
92+
case 'gemini':
93+
case 'vertex':
94+
case 'ollama':
95+
break;
96+
97+
default:
98+
throw new \InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider));
99+
}
100+
101+
return new ProviderConfig(
102+
provider: $provider,
103+
baseUri: $baseUri,
104+
apiKey: $dsn->getUser(),
105+
options: $options,
106+
headers: $headers
107+
);
108+
}
109+
110+
private static function toBool(mixed $value): bool
111+
{
112+
if (\is_bool($value)) {
113+
return $value;
114+
}
115+
$v = strtolower((string) $value);
116+
117+
return \in_array($v, ['1', 'true', 'yes', 'on'], true);
118+
}
119+
120+
private static function defaultHostOrFail(string $provider): string
121+
{
122+
return match ($provider) {
123+
'openai' => 'api.openai.com',
124+
'anthropic' => 'api.anthropic.com',
125+
'gemini' => 'generativelanguage.googleapis.com',
126+
'vertex' => 'us-central1-aiplatform.googleapis.com',
127+
'ollama' => 'localhost',
128+
'azure' => throw new \InvalidArgumentException('Azure DSN must specify host (e.g. "<resource>.openai.azure.com").'),
129+
default => throw new \InvalidArgumentException(\sprintf('Unknown AI provider "%s".', $provider)),
130+
};
131+
}
132+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Factory;
13+
14+
use Symfony\Component\HttpClient\HttpClient;
15+
use Symfony\Contracts\HttpClient\HttpClientInterface;
16+
17+
final class ProviderFactory implements ProviderFactoryInterface
18+
{
19+
public function __construct(private ?HttpClientInterface $http = null)
20+
{
21+
}
22+
23+
public function fromDsn(string $dsn): object
24+
{
25+
$config = ProviderConfigFactory::fromDsn($dsn);
26+
$providerKey = strtolower($config->provider);
27+
28+
if ('azure' === $providerKey) {
29+
$engine = strtolower($config->options['engine'] ?? 'openai');
30+
$factoryFqcn = match ($engine) {
31+
'openai' => \Symfony\AI\Platform\Bridge\Azure\OpenAI\PlatformFactory::class,
32+
'meta' => \Symfony\AI\Platform\Bridge\Azure\Meta\PlatformFactory::class,
33+
default => throw new \InvalidArgumentException(\sprintf('Unsupported Azure engine "%s". Supported: "openai", "meta".', $engine)),
34+
};
35+
} else {
36+
$factoryMap = [
37+
'openai' => \Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory::class,
38+
'anthropic' => \Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory::class,
39+
'azure' => \Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory::class,
40+
'gemini' => \Symfony\AI\Platform\Bridge\Gemini\PlatformFactory::class,
41+
'vertex' => \Symfony\AI\Platform\Bridge\VertexAI\PlatformFactory::class,
42+
'ollama' => \Symfony\AI\Platform\Bridge\Ollama\PlatformFactory::class,
43+
];
44+
45+
if (!isset($factoryMap[$providerKey])) {
46+
throw new \InvalidArgumentException(\sprintf('Unsupported AI provider "%s".', $config->provider));
47+
}
48+
49+
$factoryFqcn = $factoryMap[$providerKey];
50+
}
51+
52+
$authHeaders = match ($providerKey) {
53+
'openai', 'anthropic', 'gemini', 'vertex' => $config->apiKey ? ['Authorization' => 'Bearer '.$config->apiKey] : [],
54+
'azure' => $config->apiKey ? ['api-key' => $config->apiKey] : [],
55+
default => [],
56+
};
57+
58+
$headers = array_filter($authHeaders + $config->headers, static fn ($v) => null !== $v && '' !== $v);
59+
60+
$http = $this->http ?? HttpClient::create([
61+
'base_uri' => $config->baseUri,
62+
'headers' => $headers,
63+
'timeout' => isset($config->options['timeout']) ? (float) $config->options['timeout'] : null,
64+
'proxy' => $config->options['proxy'] ?? null,
65+
'verify_peer' => $config->options['verify_peer'] ?? null,
66+
]);
67+
68+
$contract = [
69+
'provider' => $config->provider,
70+
'base_uri' => $config->baseUri,
71+
'options' => $config->options,
72+
'headers' => $headers,
73+
];
74+
75+
return $factoryFqcn::create($config->apiKey ?? '', $http, $contract);
76+
}
77+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Symfony\AI\Platform\Factory;
4+
5+
interface ProviderFactoryInterface
6+
{
7+
public function fromDsn(string $dsn): object;
8+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Provider;
13+
14+
final readonly class ProviderConfig
15+
{
16+
public function __construct(
17+
public string $provider,
18+
public string $baseUri,
19+
public ?string $apiKey,
20+
public array $options = [],
21+
public array $headers = [],
22+
) {
23+
}
24+
}

0 commit comments

Comments
 (0)