Skip to content

Commit e0d92ee

Browse files
committed
feat(platform: FailoverPlatform
1 parent f857a0a commit e0d92ee

File tree

8 files changed

+633
-0
lines changed

8 files changed

+633
-0
lines changed

docs/components/platform.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,57 @@ 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 ``Symfony\AI\Platform\FailoverPlatform`` can be used to automatically call a backup platform::
471+
472+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
473+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
474+
use Symfony\AI\Platform\FailoverPlatform;
475+
use Symfony\AI\Platform\Message\Message;
476+
use Symfony\AI\Platform\Message\MessageBag;
477+
478+
$ollamaPlatform = OllamaPlatformFactory::create('http://127.0.0.1:11434', http_client());
479+
$openAiPlatform = OpenAiPlatformFactory::create('sk-foo', http_client());
480+
481+
$failoverPlatform = new FailoverPlatform([
482+
$ollamaPlatform, // # Ollama will fail as 'gpt-4o' is not available in the catalog
483+
$openAiPlatform,
484+
]);
485+
486+
$result = $failoverPlatform->invoke('gpt-4o', new MessageBag(
487+
Message::forSystem('You are a helpful assistant.'),
488+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
489+
));
490+
491+
echo $result->asText().\PHP_EOL;
492+
493+
This platform can also be configured when using the bundle::
494+
495+
ai:
496+
platform:
497+
openai:
498+
# ...
499+
ollama:
500+
# ...
501+
failover:
502+
ollama_to_openai:
503+
platforms:
504+
- 'ai.platform.ollama'
505+
- 'ai.platform.openai'
506+
retry_period: 120
507+
508+
A ``retry_period`` can be configured to determine after how many seconds a failed platform must be tried again,
509+
default value is ``60``.
510+
511+
.. note::
512+
513+
Platforms are executed in the order they're injected into ``FailoverPlatform``.
514+
464515
Testing Tools
465516
-------------
466517

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\Ollama\PlatformFactory as OllamaPlatformFactory;
13+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
14+
use Symfony\AI\Platform\FailoverPlatform;
15+
use Symfony\AI\Platform\Message\Message;
16+
use Symfony\AI\Platform\Message\MessageBag;
17+
18+
require_once dirname(__DIR__).'/bootstrap.php';
19+
20+
$ollamaPlatform = OllamaPlatformFactory::create(env('OLLAMA_HOST_URL'), http_client());
21+
$openAiPlatform = OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), http_client());
22+
23+
$platform = new FailoverPlatform([
24+
$ollamaPlatform, // # Ollama will fail as 'gpt-4o' is not available in the catalog
25+
$openAiPlatform,
26+
]);
27+
28+
$result = $platform->invoke('gpt-4o', new MessageBag(
29+
Message::forSystem('You are a helpful assistant.'),
30+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
31+
));
32+
33+
echo $result->asText().\PHP_EOL;

src/ai-bundle/config/options.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,19 @@
115115
->end()
116116
->end()
117117
->end()
118+
->arrayNode('failover')
119+
->useAttributeAsKey('name')
120+
->arrayPrototype()
121+
->children()
122+
->arrayNode('platforms')
123+
->scalarPrototype()->end()
124+
->end()
125+
->integerNode('retry_period')
126+
->defaultValue(60)
127+
->end()
128+
->end()
129+
->end()
130+
->end()
118131
->arrayNode('gemini')
119132
->children()
120133
->stringNode('api_key')->isRequired()->end()

src/ai-bundle/src/AiBundle.php

Lines changed: 25 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;
@@ -76,6 +77,7 @@
7677
use Symfony\AI\Platform\CachedPlatform;
7778
use Symfony\AI\Platform\Capability;
7879
use Symfony\AI\Platform\Exception\RuntimeException;
80+
use Symfony\AI\Platform\FailoverPlatform;
7981
use Symfony\AI\Platform\Message\Content\File;
8082
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
8183
use Symfony\AI\Platform\ModelClientInterface;
@@ -511,6 +513,29 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
511513
return;
512514
}
513515

516+
if ('failover' === $type) {
517+
foreach ($platform as $name => $config) {
518+
$definition = (new Definition(FailoverPlatform::class))
519+
->setLazy(true)
520+
->setArguments([
521+
array_map(
522+
static fn (string $wrappedPlatform): Reference => new Reference($wrappedPlatform),
523+
$config['platforms'],
524+
),
525+
new Reference(ClockInterface::class),
526+
$config['retry_period'],
527+
new Reference(LoggerInterface::class),
528+
])
529+
->addTag('proxy', ['interface' => PlatformInterface::class])
530+
->addTag('ai.platform', ['name' => $type]);
531+
532+
$container->setDefinition('ai.platform.'.$type.'.'.$name, $definition);
533+
$container->registerAliasForArgument('ai.platform.'.$type.'.'.$name, PlatformInterface::class, $name);
534+
}
535+
536+
return;
537+
}
538+
514539
if ('gemini' === $type) {
515540
if (!ContainerBuilder::willBeAvailable('symfony/ai-gemini-platform', GeminiPlatformFactory::class, ['symfony/ai-bundle'])) {
516541
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: 117 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;
@@ -35,6 +37,7 @@
3537
use Symfony\AI\Platform\CachedPlatform;
3638
use Symfony\AI\Platform\Capability;
3739
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
40+
use Symfony\AI\Platform\FailoverPlatform;
3841
use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer;
3942
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
4043
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
@@ -4019,6 +4022,104 @@ public function testElevenLabsPlatformWithApiCatalogCanBeRegistered()
40194022
$this->assertSame([['interface' => ModelCatalogInterface::class]], $modelCatalogDefinition->getTag('proxy'));
40204023
}
40214024

4025+
public function testFailoverPlatformCanBeCreated()
4026+
{
4027+
$container = $this->buildContainer([
4028+
'ai' => [
4029+
'platform' => [
4030+
'ollama' => [
4031+
'host_url' => 'http://127.0.0.1:11434',
4032+
],
4033+
'openai' => [
4034+
'api_key' => 'sk-openai_key_full',
4035+
],
4036+
'failover' => [
4037+
'main' => [
4038+
'platforms' => [
4039+
'ai.platform.ollama',
4040+
'ai.platform.openai',
4041+
],
4042+
],
4043+
],
4044+
],
4045+
],
4046+
]);
4047+
4048+
$this->assertTrue($container->hasDefinition('ai.platform.failover.main'));
4049+
4050+
$definition = $container->getDefinition('ai.platform.failover.main');
4051+
4052+
$this->assertTrue($definition->isLazy());
4053+
$this->assertSame(FailoverPlatform::class, $definition->getClass());
4054+
4055+
$this->assertCount(4, $definition->getArguments());
4056+
$this->assertCount(2, $definition->getArgument(0));
4057+
$this->assertEquals([
4058+
new Reference('ai.platform.ollama'),
4059+
new Reference('ai.platform.openai'),
4060+
], $definition->getArgument(0));
4061+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
4062+
$this->assertSame(ClockInterface::class, (string) $definition->getArgument(1));
4063+
$this->assertSame(60, $definition->getArgument(2));
4064+
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
4065+
$this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3));
4066+
4067+
$this->assertTrue($definition->hasTag('proxy'));
4068+
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
4069+
$this->assertTrue($definition->hasTag('ai.platform'));
4070+
$this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform'));
4071+
4072+
$this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $main'));
4073+
4074+
$container = $this->buildContainer([
4075+
'ai' => [
4076+
'platform' => [
4077+
'ollama' => [
4078+
'host_url' => 'http://127.0.0.1:11434',
4079+
],
4080+
'openai' => [
4081+
'api_key' => 'sk-openai_key_full',
4082+
],
4083+
'failover' => [
4084+
'main' => [
4085+
'platforms' => [
4086+
'ai.platform.ollama',
4087+
'ai.platform.openai',
4088+
],
4089+
'retry_period' => 120,
4090+
],
4091+
],
4092+
],
4093+
],
4094+
]);
4095+
4096+
$this->assertTrue($container->hasDefinition('ai.platform.failover.main'));
4097+
4098+
$definition = $container->getDefinition('ai.platform.failover.main');
4099+
4100+
$this->assertTrue($definition->isLazy());
4101+
$this->assertSame(FailoverPlatform::class, $definition->getClass());
4102+
4103+
$this->assertCount(4, $definition->getArguments());
4104+
$this->assertCount(2, $definition->getArgument(0));
4105+
$this->assertEquals([
4106+
new Reference('ai.platform.ollama'),
4107+
new Reference('ai.platform.openai'),
4108+
], $definition->getArgument(0));
4109+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
4110+
$this->assertSame(ClockInterface::class, (string) $definition->getArgument(1));
4111+
$this->assertSame(120, $definition->getArgument(2));
4112+
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
4113+
$this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3));
4114+
4115+
$this->assertTrue($definition->hasTag('proxy'));
4116+
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
4117+
$this->assertTrue($definition->hasTag('ai.platform'));
4118+
$this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform'));
4119+
4120+
$this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $main'));
4121+
}
4122+
40224123
public function testOpenAiPlatformWithDefaultRegion()
40234124
{
40244125
$container = $this->buildContainer([
@@ -7013,6 +7114,7 @@ private function buildContainer(array $configuration): ContainerBuilder
70137114
$container->setParameter('kernel.environment', 'dev');
70147115
$container->setParameter('kernel.build_dir', 'public');
70157116
$container->setDefinition(ClockInterface::class, new Definition(MonotonicClock::class));
7117+
$container->setDefinition(LoggerInterface::class, new Definition(NullLogger::class));
70167118

70177119
$extension = (new AiBundle())->getContainerExtension();
70187120
$extension->load($configuration, $container);
@@ -7068,6 +7170,21 @@ private function getFullConfig(): array
70687170
'host' => 'https://api.elevenlabs.io/v1',
70697171
'api_key' => 'elevenlabs_key_full',
70707172
],
7173+
'failover' => [
7174+
'main' => [
7175+
'platforms' => [
7176+
'ai.platform.ollama',
7177+
'ai.platform.openai',
7178+
],
7179+
],
7180+
'main_with_custom_retry_period' => [
7181+
'platforms' => [
7182+
'ai.platform.ollama',
7183+
'ai.platform.openai',
7184+
],
7185+
'retry_period' => 120,
7186+
],
7187+
],
70717188
'gemini' => [
70727189
'api_key' => 'gemini_key_full',
70737190
],
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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\Exception;
13+
14+
/**
15+
* @author Guillaume Loulier <[email protected]>
16+
*/
17+
final class LogicException extends \LogicException implements ExceptionInterface
18+
{
19+
}

0 commit comments

Comments
 (0)