Skip to content

Commit 013d301

Browse files
committed
feat(platform: FailoverPlatform
1 parent 818062d commit 013d301

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
@@ -461,6 +461,70 @@ Thanks to Symfony's Cache component, platform calls can be cached to reduce call
461461

462462
echo $secondResult->getContent().\PHP_EOL;
463463

464+
High Availability
465+
-----------------
466+
467+
As most platform exposes a REST API, errors can occurs during generation phase due to network issues, timeout and more.
468+
469+
To prevent exceptions at the application level and allows to keep a smooth experience for end users,
470+
the :class:`Symfony\\AI\\Platform\\Bridge\\Failover\\FailoverPlatform` can be used to automatically call a backup platform::
471+
472+
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
473+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
474+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
475+
use Symfony\AI\Platform\Message\Message;
476+
use Symfony\AI\Platform\Message\MessageBag;
477+
use Symfony\Component\RateLimiter\RateLimiterFactory;
478+
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
479+
480+
$rateLimiter = new RateLimiterFactory([
481+
'policy' => 'sliding_window',
482+
'id' => 'failover',
483+
'interval' => '3 seconds',
484+
'limit' => 1,
485+
], new InMemoryStorage());
486+
487+
// # Ollama will fail as 'gpt-4o' is not available in the catalog
488+
$platform = new FailoverPlatform([
489+
OllamaPlatformFactory::create(env('OLLAMA_HOST_URL'), http_client()),
490+
OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), http_client()),
491+
], $rateLimiter);
492+
493+
$result = $platform->invoke('gpt-4o', new MessageBag(
494+
Message::forSystem('You are a helpful assistant.'),
495+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
496+
));
497+
498+
echo $result->asText().\PHP_EOL;
499+
500+
This platform can also be configured when using the bundle::
501+
502+
# config/packages/ai.yaml
503+
ai:
504+
platform:
505+
openai:
506+
# ...
507+
ollama:
508+
# ...
509+
failover:
510+
ollama_to_openai:
511+
platforms:
512+
- 'ai.platform.ollama'
513+
- 'ai.platform.openai'
514+
rate_limiter: 'limiter.failover_platform'
515+
516+
# config/packages/rate_limiter.yaml
517+
framework:
518+
rate_limiter:
519+
failover_platform:
520+
policy: 'sliding_window'
521+
limit: 100
522+
interval: '60 minutes'
523+
524+
.. note::
525+
526+
Platforms are executed in the order they're injected into :class:`Symfony\\AI\\Platform\\Bridge\\Failover\\FailoverPlatform`.
527+
464528
Testing Tools
465529
-------------
466530

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 <[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+
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
@@ -43,6 +43,7 @@
4343
"ai-deep-seek-platform": "src/platform/src/Bridge/DeepSeek",
4444
"ai-docker-model-runner-platform": "src/platform/src/Bridge/DockerModelRunner",
4545
"ai-eleven-labs-platform": "src/platform/src/Bridge/ElevenLabs",
46+
"ai-failover-platform": "src/platform/src/Bridge/Failover",
4647
"ai-gemini-platform": "src/platform/src/Bridge/Gemini",
4748
"ai-generic-platform": "src/platform/src/Bridge/Generic",
4849
"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
@@ -55,6 +55,7 @@
5555
"symfony/ai-doctrine-message-store": "@dev",
5656
"symfony/ai-eleven-labs-platform": "@dev",
5757
"symfony/ai-elasticsearch-store": "@dev",
58+
"symfony/ai-failover-platform": "@dev",
5859
"symfony/ai-gemini-platform": "@dev",
5960
"symfony/ai-generic-platform": "@dev",
6061
"symfony/ai-session-message-store": "@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+
if (!ContainerBuilder::willBeAvailable('symfony/ai-failover-platform', FailoverPlatformFactory::class, ['symfony/ai-bundle'])) {
519+
throw new RuntimeException('Failover platform configuration requires "symfony/ai-failover-platform" package. Try running "composer require symfony/ai-failover-platform".');
520+
}
521+
522+
foreach ($platform as $name => $config) {
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
@@ -4019,6 +4025,62 @@ public function testElevenLabsPlatformWithApiCatalogCanBeRegistered()
40194025
$this->assertSame([['interface' => ModelCatalogInterface::class]], $modelCatalogDefinition->getTag('proxy'));
40204026
}
40214027

4028+
public function testFailoverPlatformCanBeCreated()
4029+
{
4030+
$container = $this->buildContainer([
4031+
'ai' => [
4032+
'platform' => [
4033+
'ollama' => [
4034+
'host_url' => 'http://127.0.0.1:11434',
4035+
],
4036+
'openai' => [
4037+
'api_key' => 'sk-openai_key_full',
4038+
],
4039+
'failover' => [
4040+
'main' => [
4041+
'platforms' => [
4042+
'ai.platform.ollama',
4043+
'ai.platform.openai',
4044+
],
4045+
'rate_limiter' => 'limiter.failover_platform',
4046+
],
4047+
],
4048+
],
4049+
],
4050+
]);
4051+
4052+
$this->assertTrue($container->hasDefinition('ai.platform.failover.main'));
4053+
4054+
$definition = $container->getDefinition('ai.platform.failover.main');
4055+
4056+
$this->assertSame([
4057+
FailoverPlatformFactory::class,
4058+
'create',
4059+
], $definition->getFactory());
4060+
$this->assertTrue($definition->isLazy());
4061+
$this->assertSame(FailoverPlatform::class, $definition->getClass());
4062+
4063+
$this->assertCount(4, $definition->getArguments());
4064+
$this->assertCount(2, $definition->getArgument(0));
4065+
$this->assertEquals([
4066+
new Reference('ai.platform.ollama'),
4067+
new Reference('ai.platform.openai'),
4068+
], $definition->getArgument(0));
4069+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
4070+
$this->assertSame('limiter.failover_platform', (string) $definition->getArgument(1));
4071+
$this->assertInstanceOf(Reference::class, $definition->getArgument(2));
4072+
$this->assertSame(ClockInterface::class, (string) $definition->getArgument(2));
4073+
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
4074+
$this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3));
4075+
4076+
$this->assertTrue($definition->hasTag('proxy'));
4077+
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
4078+
$this->assertTrue($definition->hasTag('ai.platform'));
4079+
$this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform'));
4080+
4081+
$this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $main'));
4082+
}
4083+
40224084
public function testOpenAiPlatformWithDefaultRegion()
40234085
{
40244086
$container = $this->buildContainer([
@@ -7013,6 +7075,16 @@ private function buildContainer(array $configuration): ContainerBuilder
70137075
$container->setParameter('kernel.environment', 'dev');
70147076
$container->setParameter('kernel.build_dir', 'public');
70157077
$container->setDefinition(ClockInterface::class, new Definition(MonotonicClock::class));
7078+
$container->setDefinition(LoggerInterface::class, new Definition(NullLogger::class));
7079+
$container->setDefinition('limiter.failover_platform', new Definition(RateLimiterFactory::class, [
7080+
[
7081+
'policy' => 'sliding_window',
7082+
'id' => 'test',
7083+
'interval' => '60 seconds',
7084+
'limit' => 1,
7085+
],
7086+
new Definition(InMemoryStorage::class),
7087+
]));
70167088

70177089
$extension = (new AiBundle())->getContainerExtension();
70187090
$extension->load($configuration, $container);
@@ -7068,6 +7140,15 @@ private function getFullConfig(): array
70687140
'host' => 'https://api.elevenlabs.io/v1',
70697141
'api_key' => 'elevenlabs_key_full',
70707142
],
7143+
'failover' => [
7144+
'main' => [
7145+
'platforms' => [
7146+
'ai.platform.ollama',
7147+
'ai.platform.openai',
7148+
],
7149+
'rate_limiter' => 'limiter.failover_platform',
7150+
],
7151+
],
70717152
'gemini' => [
70727153
'api_key' => 'gemini_key_full',
70737154
],

src/platform/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"phpstan/phpstan": "^2.1.17",
6565
"phpstan/phpstan-strict-rules": "^2.0",
6666
"phpunit/phpunit": "^11.5.46",
67+
"symfony/ai-agent": "@dev",
6768
"symfony/cache": "^7.3|^8.0",
6869
"symfony/console": "^7.3|^8.0",
6970
"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)