Skip to content

Commit f4be872

Browse files
committed
feat(platform: FailoverPlatform
1 parent fad3786 commit f4be872

File tree

23 files changed

+968
-0
lines changed

23 files changed

+968
-0
lines changed

docs/components/platform.rst

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,70 @@ Thanks to Symfony's Cache component, platform calls can be cached to reduce call
544544

545545
echo $secondResult->getContent().\PHP_EOL;
546546

547+
High Availability
548+
-----------------
549+
550+
As most platform exposes a REST API, errors can occurs during generation phase due to network issues, timeout and more.
551+
552+
To prevent exceptions at the application level and allows to keep a smooth experience for end users,
553+
the :class:`Symfony\\AI\\Platform\\Bridge\\Failover\\FailoverPlatform` can be used to automatically call a backup platform::
554+
555+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
556+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
557+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
558+
use Symfony\AI\Platform\Message\Message;
559+
use Symfony\AI\Platform\Message\MessageBag;
560+
use Symfony\Component\RateLimiter\RateLimiterFactory;
561+
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
562+
563+
$rateLimiter = new RateLimiterFactory([
564+
'policy' => 'sliding_window',
565+
'id' => 'failover',
566+
'interval' => '3 seconds',
567+
'limit' => 1,
568+
], new InMemoryStorage());
569+
570+
// # Ollama will fail as 'gpt-4o' is not available in the catalog
571+
$platform = new FailoverPlatform([
572+
OllamaPlatformFactory::create(env('OLLAMA_HOST_URL'), http_client()),
573+
OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), http_client()),
574+
], $rateLimiter);
575+
576+
$result = $platform->invoke('gpt-4o', new MessageBag(
577+
Message::forSystem('You are a helpful assistant.'),
578+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
579+
));
580+
581+
echo $result->asText().\PHP_EOL;
582+
583+
This platform can also be configured when using the bundle::
584+
585+
# config/packages/ai.yaml
586+
ai:
587+
platform:
588+
openai:
589+
# ...
590+
ollama:
591+
# ...
592+
failover:
593+
ollama_to_openai:
594+
platforms:
595+
- 'ai.platform.ollama'
596+
- 'ai.platform.openai'
597+
rate_limiter: 'limiter.failover_platform'
598+
599+
# config/packages/rate_limiter.yaml
600+
framework:
601+
rate_limiter:
602+
failover_platform:
603+
policy: 'sliding_window'
604+
limit: 100
605+
interval: '60 minutes'
606+
607+
.. note::
608+
609+
Platforms are executed in the order they're injected into :class:`Symfony\\AI\\Platform\\Bridge\\Failover\\FailoverPlatform`.
610+
547611
Testing Tools
548612
-------------
549613

examples/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"symfony/ai-docker-model-runner-platform": "@dev",
4949
"symfony/ai-elasticsearch-store": "@dev",
5050
"symfony/ai-eleven-labs-platform": "@dev",
51+
"symfony/ai-failover-platform": "@dev",
5152
"symfony/ai-gemini-platform": "@dev",
5253
"symfony/ai-generic-platform": "@dev",
5354
"symfony/ai-session-message-store": "@dev",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
13+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
14+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter;
15+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
16+
use Symfony\AI\Platform\Message\Message;
17+
use Symfony\AI\Platform\Message\MessageBag;
18+
use Symfony\Component\RateLimiter\RateLimiterFactory;
19+
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
20+
21+
require_once dirname(__DIR__).'/bootstrap.php';
22+
23+
$rateLimiter = new RateLimiterFactory([
24+
'policy' => 'sliding_window',
25+
'id' => 'failover',
26+
'interval' => '3 seconds',
27+
'limit' => 1,
28+
], new InMemoryStorage());
29+
30+
// # Ollama will fail as 'gpt-4o' is not available in the catalog
31+
$platform = new FailoverPlatform([
32+
OllamaPlatformFactory::create(env('OLLAMA_HOST_URL'), http_client()),
33+
OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), http_client()),
34+
], $rateLimiter);
35+
36+
$result = $platform->invoke('gpt-4o', new MessageBag(
37+
Message::forSystem('You are a helpful assistant.'),
38+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
39+
));
40+
41+
assert($result->getResultConverter() instanceof ResultConverter);
42+
43+
echo $result->asText().\PHP_EOL;

splitsh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"ai-deep-seek-platform": "src/platform/src/Bridge/DeepSeek",
4949
"ai-docker-model-runner-platform": "src/platform/src/Bridge/DockerModelRunner",
5050
"ai-eleven-labs-platform": "src/platform/src/Bridge/ElevenLabs",
51+
"ai-failover-platform": "src/platform/src/Bridge/Failover",
5152
"ai-gemini-platform": "src/platform/src/Bridge/Gemini",
5253
"ai-generic-platform": "src/platform/src/Bridge/Generic",
5354
"ai-hugging-face-platform": "src/platform/src/Bridge/HuggingFace",

src/ai-bundle/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"symfony/ai-doctrine-message-store": "@dev",
5757
"symfony/ai-elasticsearch-store": "@dev",
5858
"symfony/ai-eleven-labs-platform": "@dev",
59+
"symfony/ai-failover-platform": "@dev",
5960
"symfony/ai-gemini-platform": "@dev",
6061
"symfony/ai-generic-platform": "@dev",
6162
"symfony/ai-hugging-face-platform": "@dev",

src/ai-bundle/config/options.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@
114114
->end()
115115
->end()
116116
->end()
117+
->arrayNode('failover')
118+
->useAttributeAsKey('name')
119+
->arrayPrototype()
120+
->children()
121+
->arrayNode('platforms')
122+
->scalarPrototype()->end()
123+
->end()
124+
->stringNode('rate_limiter')->cannotBeEmpty()->end()
125+
->end()
126+
->end()
127+
->end()
117128
->arrayNode('gemini')
118129
->children()
119130
->stringNode('api_key')->isRequired()->end()

src/ai-bundle/src/AiBundle.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Google\Auth\ApplicationDefaultCredentials;
1515
use Google\Auth\FetchAuthTokenInterface;
16+
use Psr\Log\LoggerInterface;
1617
use Symfony\AI\Agent\Agent;
1718
use Symfony\AI\Agent\AgentInterface;
1819
use Symfony\AI\Agent\Attribute\AsInputProcessor;
@@ -60,6 +61,8 @@
6061
use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory as DockerModelRunnerPlatformFactory;
6162
use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsApiCatalog;
6263
use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory;
64+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
65+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatformFactory;
6366
use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory as GeminiPlatformFactory;
6467
use Symfony\AI\Platform\Bridge\Generic\PlatformFactory as GenericPlatformFactory;
6568
use Symfony\AI\Platform\Bridge\HuggingFace\PlatformFactory as HuggingFacePlatformFactory;
@@ -511,6 +514,34 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
511514
return;
512515
}
513516

517+
if ('failover' === $type) {
518+
foreach ($platform as $name => $config) {
519+
if (!ContainerBuilder::willBeAvailable('symfony/ai-failover-platform', FailoverPlatformFactory::class, ['symfony/ai-bundle'])) {
520+
throw new RuntimeException('Failover platform configuration requires "symfony/ai-failover-platform" package. Try running "composer require symfony/ai-failover-platform".');
521+
}
522+
523+
$definition = (new Definition(FailoverPlatform::class))
524+
->setFactory(FailoverPlatformFactory::class.'::create')
525+
->setLazy(true)
526+
->setArguments([
527+
array_map(
528+
static fn (string $wrappedPlatform): Reference => new Reference($wrappedPlatform),
529+
$config['platforms'],
530+
),
531+
new Reference($config['rate_limiter']),
532+
new Reference(ClockInterface::class),
533+
new Reference(LoggerInterface::class),
534+
])
535+
->addTag('proxy', ['interface' => PlatformInterface::class])
536+
->addTag('ai.platform', ['name' => $type]);
537+
538+
$container->setDefinition('ai.platform.'.$type.'.'.$name, $definition);
539+
$container->registerAliasForArgument('ai.platform.'.$type.'.'.$name, PlatformInterface::class, $name);
540+
}
541+
542+
return;
543+
}
544+
514545
if ('gemini' === $type) {
515546
if (!ContainerBuilder::willBeAvailable('symfony/ai-gemini-platform', GeminiPlatformFactory::class, ['symfony/ai-bundle'])) {
516547
throw new RuntimeException('Gemini platform configuration requires "symfony/ai-gemini-platform" package. Try running "composer require symfony/ai-gemini-platform".');

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
use PHPUnit\Framework\Attributes\TestWith;
1919
use PHPUnit\Framework\TestCase;
2020
use Probots\Pinecone\Client as PineconeClient;
21+
use Psr\Log\LoggerInterface;
22+
use Psr\Log\NullLogger;
2123
use Symfony\AI\Agent\AgentInterface;
2224
use Symfony\AI\Agent\Memory\MemoryInputProcessor;
2325
use Symfony\AI\Agent\Memory\StaticMemoryProvider;
@@ -31,6 +33,8 @@
3133
use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsApiCatalog;
3234
use Symfony\AI\Platform\Bridge\ElevenLabs\ModelCatalog as ElevenLabsModelCatalog;
3335
use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory;
36+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
37+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatformFactory;
3438
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
3539
use Symfony\AI\Platform\CachedPlatform;
3640
use Symfony\AI\Platform\Capability;
@@ -84,6 +88,8 @@
8488
use Symfony\Component\DependencyInjection\Reference;
8589
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
8690
use Symfony\Component\HttpClient\HttpClient;
91+
use Symfony\Component\RateLimiter\RateLimiterFactory;
92+
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
8793
use Symfony\Contracts\HttpClient\HttpClientInterface;
8894

8995
class AiBundleTest extends TestCase
@@ -3947,6 +3953,62 @@ public function testElevenLabsPlatformWithApiCatalogCanBeRegistered()
39473953
$this->assertSame([['interface' => ModelCatalogInterface::class]], $modelCatalogDefinition->getTag('proxy'));
39483954
}
39493955

3956+
public function testFailoverPlatformCanBeCreated()
3957+
{
3958+
$container = $this->buildContainer([
3959+
'ai' => [
3960+
'platform' => [
3961+
'ollama' => [
3962+
'host_url' => 'http://127.0.0.1:11434',
3963+
],
3964+
'openai' => [
3965+
'api_key' => 'sk-openai_key_full',
3966+
],
3967+
'failover' => [
3968+
'main' => [
3969+
'platforms' => [
3970+
'ai.platform.ollama',
3971+
'ai.platform.openai',
3972+
],
3973+
'rate_limiter' => 'limiter.failover_platform',
3974+
],
3975+
],
3976+
],
3977+
],
3978+
]);
3979+
3980+
$this->assertTrue($container->hasDefinition('ai.platform.failover.main'));
3981+
3982+
$definition = $container->getDefinition('ai.platform.failover.main');
3983+
3984+
$this->assertSame([
3985+
FailoverPlatformFactory::class,
3986+
'create',
3987+
], $definition->getFactory());
3988+
$this->assertTrue($definition->isLazy());
3989+
$this->assertSame(FailoverPlatform::class, $definition->getClass());
3990+
3991+
$this->assertCount(4, $definition->getArguments());
3992+
$this->assertCount(2, $definition->getArgument(0));
3993+
$this->assertEquals([
3994+
new Reference('ai.platform.ollama'),
3995+
new Reference('ai.platform.openai'),
3996+
], $definition->getArgument(0));
3997+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
3998+
$this->assertSame('limiter.failover_platform', (string) $definition->getArgument(1));
3999+
$this->assertInstanceOf(Reference::class, $definition->getArgument(2));
4000+
$this->assertSame(ClockInterface::class, (string) $definition->getArgument(2));
4001+
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
4002+
$this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3));
4003+
4004+
$this->assertTrue($definition->hasTag('proxy'));
4005+
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
4006+
$this->assertTrue($definition->hasTag('ai.platform'));
4007+
$this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform'));
4008+
4009+
$this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $main'));
4010+
}
4011+
39504012
public function testOpenAiPlatformWithDefaultRegion()
39514013
{
39524014
$container = $this->buildContainer([
@@ -6981,6 +7043,16 @@ private function buildContainer(array $configuration): ContainerBuilder
69817043
$container->setParameter('kernel.environment', 'dev');
69827044
$container->setParameter('kernel.build_dir', 'public');
69837045
$container->setDefinition(ClockInterface::class, new Definition(MonotonicClock::class));
7046+
$container->setDefinition(LoggerInterface::class, new Definition(NullLogger::class));
7047+
$container->setDefinition('limiter.failover_platform', new Definition(RateLimiterFactory::class, [
7048+
[
7049+
'policy' => 'sliding_window',
7050+
'id' => 'test',
7051+
'interval' => '60 seconds',
7052+
'limit' => 1,
7053+
],
7054+
new Definition(InMemoryStorage::class),
7055+
]));
69847056

69857057
$extension = (new AiBundle())->getContainerExtension();
69867058
$extension->load($configuration, $container);
@@ -7036,6 +7108,15 @@ private function getFullConfig(): array
70367108
'host' => 'https://api.elevenlabs.io/v1',
70377109
'api_key' => 'elevenlabs_key_full',
70387110
],
7111+
'failover' => [
7112+
'main' => [
7113+
'platforms' => [
7114+
'ai.platform.ollama',
7115+
'ai.platform.openai',
7116+
],
7117+
'rate_limiter' => 'limiter.failover_platform',
7118+
],
7119+
],
70397120
'gemini' => [
70407121
'api_key' => 'gemini_key_full',
70417122
],

src/platform/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"phpstan/phpstan-phpunit": "^2.0",
6666
"phpstan/phpstan-strict-rules": "^2.0",
6767
"phpunit/phpunit": "^11.5.46",
68+
"symfony/ai-agent": "@dev",
6869
"symfony/cache": "^7.3|^8.0",
6970
"symfony/console": "^7.3|^8.0",
7071
"symfony/dotenv": "^7.3|^8.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.git* export-ignore

0 commit comments

Comments
 (0)