Skip to content

Commit 48d0f1f

Browse files
committed
feat(platform: FailoverPlatform
1 parent ef8a308 commit 48d0f1f

File tree

7 files changed

+392
-0
lines changed

7 files changed

+392
-0
lines changed
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@
115115
->end()
116116
->end()
117117
->end()
118+
->arrayNode('failover')
119+
->children()
120+
->integerNode('retry_period')
121+
->defaultValue(60)
122+
->end()
123+
->arrayNode('platforms')
124+
->scalarPrototype()->end()
125+
->end()
126+
->end()
127+
->end()
118128
->arrayNode('gemini')
119129
->children()
120130
->stringNode('api_key')->isRequired()->end()

src/ai-bundle/src/AiBundle.php

Lines changed: 23 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;
@@ -488,6 +490,27 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
488490
return;
489491
}
490492

493+
if ('failover' === $type) {
494+
$definition = (new Definition(FailoverPlatform::class))
495+
->setLazy(true)
496+
->setArguments([
497+
array_map(
498+
static fn (string $platform): Reference => new Reference($platform),
499+
$platform['platforms'],
500+
),
501+
new Reference(ClockInterface::class),
502+
$platform['retry_period'],
503+
new Reference(LoggerInterface::class),
504+
])
505+
->addTag('proxy', ['interface' => PlatformInterface::class])
506+
->addTag('ai.platform', ['name' => $type]);
507+
508+
$container->setDefinition('ai.platform.'.$type, $definition);
509+
$container->registerAliasForArgument('ai.platform.'.$type, PlatformInterface::class, $type);
510+
511+
return;
512+
}
513+
491514
if ('gemini' === $type) {
492515
$platformId = 'ai.platform.gemini';
493516
$definition = (new Definition(Platform::class))

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

Lines changed: 141 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;
@@ -34,6 +36,7 @@
3436
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
3537
use Symfony\AI\Platform\Capability;
3638
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
39+
use Symfony\AI\Platform\FailoverPlatform;
3740
use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer;
3841
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
3942
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
@@ -74,6 +77,8 @@
7477
use Symfony\AI\Store\ManagedStoreInterface;
7578
use Symfony\AI\Store\RetrieverInterface;
7679
use Symfony\AI\Store\StoreInterface;
80+
use Symfony\Component\Clock\ClockInterface;
81+
use Symfony\Component\Clock\MonotonicClock;
7782
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
7883
use Symfony\Component\DependencyInjection\ContainerBuilder;
7984
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -4016,6 +4021,133 @@ public function testElevenLabsPlatformWithApiCatalogCanBeRegistered()
40164021
$this->assertSame([['interface' => ModelCatalogInterface::class]], $modelCatalogDefinition->getTag('proxy'));
40174022
}
40184023

4024+
public function testFailoverPlatformCanBeCreated()
4025+
{
4026+
$container = $this->buildContainer([
4027+
'ai' => [
4028+
'platform' => [
4029+
'ollama' => [
4030+
'host_url' => 'http://127.0.0.1:11434',
4031+
],
4032+
'openai' => [
4033+
'api_key' => 'sk-openai_key_full',
4034+
],
4035+
'failover' => [
4036+
'platforms' => [
4037+
'ai.platform.ollama',
4038+
'ai.platform.openai',
4039+
],
4040+
],
4041+
],
4042+
],
4043+
]);
4044+
4045+
$this->assertTrue($container->hasDefinition('ai.platform.failover'));
4046+
4047+
$definition = $container->getDefinition('ai.platform.failover');
4048+
4049+
$this->assertTrue($definition->isLazy());
4050+
$this->assertSame(FailoverPlatform::class, $definition->getClass());
4051+
4052+
$this->assertCount(4, $definition->getArguments());
4053+
$this->assertCount(2, $definition->getArgument(0));
4054+
$this->assertEquals([
4055+
new Reference('ai.platform.ollama'),
4056+
new Reference('ai.platform.openai'),
4057+
], $definition->getArgument(0));
4058+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
4059+
$this->assertSame(ClockInterface::class, (string) $definition->getArgument(1));
4060+
$this->assertSame(60, $definition->getArgument(2));
4061+
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
4062+
$this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3));
4063+
4064+
$this->assertTrue($definition->hasTag('proxy'));
4065+
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
4066+
$this->assertTrue($definition->hasTag('ai.platform'));
4067+
$this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform'));
4068+
4069+
$this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $failover'));
4070+
4071+
$container = $this->buildContainer([
4072+
'ai' => [
4073+
'platform' => [
4074+
'ollama' => [
4075+
'host_url' => 'http://127.0.0.1:11434',
4076+
],
4077+
'openai' => [
4078+
'api_key' => 'sk-openai_key_full',
4079+
],
4080+
'failover' => [
4081+
'platforms' => [
4082+
'ai.platform.ollama',
4083+
'ai.platform.openai',
4084+
],
4085+
'retry_period' => 120,
4086+
],
4087+
],
4088+
],
4089+
]);
4090+
4091+
$this->assertTrue($container->hasDefinition('ai.platform.failover'));
4092+
4093+
$definition = $container->getDefinition('ai.platform.failover');
4094+
4095+
$this->assertTrue($definition->isLazy());
4096+
$this->assertSame(FailoverPlatform::class, $definition->getClass());
4097+
4098+
$this->assertCount(4, $definition->getArguments());
4099+
$this->assertCount(2, $definition->getArgument(0));
4100+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
4101+
$this->assertSame(ClockInterface::class, (string) $definition->getArgument(1));
4102+
$this->assertSame(120, $definition->getArgument(2));
4103+
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
4104+
$this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3));
4105+
4106+
$this->assertTrue($definition->hasTag('proxy'));
4107+
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
4108+
$this->assertTrue($definition->hasTag('ai.platform'));
4109+
$this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform'));
4110+
4111+
$this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $failover'));
4112+
}
4113+
4114+
#[TestDox('Token usage processor tags use the correct agent ID')]
4115+
public function testTokenUsageProcessorTags()
4116+
{
4117+
$container = $this->buildContainer([
4118+
'ai' => [
4119+
'platform' => [
4120+
'openai' => [
4121+
'api_key' => 'sk-test_key',
4122+
],
4123+
],
4124+
'agent' => [
4125+
'tracked_agent' => [
4126+
'platform' => 'ai.platform.openai',
4127+
'model' => 'gpt-4',
4128+
'track_token_usage' => true,
4129+
],
4130+
],
4131+
],
4132+
]);
4133+
4134+
$agentId = 'ai.agent.tracked_agent';
4135+
4136+
// Token usage processor must exist for OpenAI platform
4137+
$tokenUsageProcessor = $container->getDefinition('ai.platform.token_usage_processor.openai');
4138+
$outputTags = $tokenUsageProcessor->getTag('ai.agent.output_processor');
4139+
4140+
$foundTag = false;
4141+
foreach ($outputTags as $tag) {
4142+
if (($tag['agent'] ?? '') === $agentId) {
4143+
$foundTag = true;
4144+
break;
4145+
}
4146+
}
4147+
4148+
$this->assertTrue($foundTag, 'Token usage processor should have output tag with full agent ID');
4149+
}
4150+
40194151
public function testOpenAiPlatformWithDefaultRegion()
40204152
{
40214153
$container = $this->buildContainer([
@@ -6987,6 +7119,8 @@ private function buildContainer(array $configuration): ContainerBuilder
69877119
$container->setParameter('kernel.debug', true);
69887120
$container->setParameter('kernel.environment', 'dev');
69897121
$container->setParameter('kernel.build_dir', 'public');
7122+
$container->setDefinition(ClockInterface::class, new Definition(MonotonicClock::class));
7123+
$container->setDefinition(LoggerInterface::class, new Definition(NullLogger::class));
69907124

69917125
$extension = (new AiBundle())->getContainerExtension();
69927126
$extension->load($configuration, $container);
@@ -7042,6 +7176,13 @@ private function getFullConfig(): array
70427176
'host' => 'https://api.elevenlabs.io/v1',
70437177
'api_key' => 'elevenlabs_key_full',
70447178
],
7179+
'failover' => [
7180+
'platforms' => [
7181+
'ai.platform.ollama',
7182+
'ai.platform.openai',
7183+
],
7184+
'retry_period' => 120,
7185+
],
70457186
'gemini' => [
70467187
'api_key' => 'gemini_key_full',
70477188
],
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+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Psr\Log\NullLogger;
16+
use Symfony\AI\Platform\Exception\LogicException;
17+
use Symfony\AI\Platform\Exception\RuntimeException;
18+
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
19+
use Symfony\AI\Platform\Result\DeferredResult;
20+
use Symfony\Component\Clock\ClockInterface;
21+
use Symfony\Component\Clock\MonotonicClock;
22+
23+
/**
24+
* @author Guillaume Loulier <[email protected]>
25+
*/
26+
final class FailoverPlatform implements PlatformInterface
27+
{
28+
/**
29+
* @var \SplObjectStorage<PlatformInterface, int>
30+
*/
31+
private \SplObjectStorage $deadPlatforms;
32+
33+
/**
34+
* @param PlatformInterface[] $platforms
35+
*/
36+
public function __construct(
37+
private readonly iterable $platforms,
38+
private readonly ClockInterface $clock = new MonotonicClock(),
39+
private readonly int $retryPeriod = 60,
40+
private readonly LoggerInterface $logger = new NullLogger(),
41+
) {
42+
if ([] === $platforms) {
43+
throw new LogicException(\sprintf('"%s" must have at least one platform configured.', self::class));
44+
}
45+
46+
$this->deadPlatforms = new \SplObjectStorage();
47+
}
48+
49+
public function invoke(string $model, object|array|string $input, array $options = []): DeferredResult
50+
{
51+
return $this->do(static fn (PlatformInterface $platform): DeferredResult => $platform->invoke($model, $input, $options));
52+
}
53+
54+
public function getModelCatalog(): ModelCatalogInterface
55+
{
56+
return $this->do(static fn (PlatformInterface $platform): ModelCatalogInterface => $platform->getModelCatalog());
57+
}
58+
59+
private function do(\Closure $func): DeferredResult|ModelCatalogInterface
60+
{
61+
foreach ($this->platforms as $platform) {
62+
if ($this->deadPlatforms->offsetExists($platform) && ($this->clock->now()->getTimestamp() - $this->deadPlatforms[$platform]) > $this->retryPeriod) {
63+
$this->deadPlatforms->offsetUnset($platform);
64+
}
65+
66+
if ($this->deadPlatforms->offsetExists($platform)) {
67+
continue;
68+
}
69+
70+
try {
71+
return $func($platform);
72+
} catch (\Throwable $throwable) {
73+
$this->deadPlatforms->offsetSet($platform, $this->clock->now()->getTimestamp());
74+
75+
$this->logger->warning('The {platform} platform has encountered an exception: {exception}', [
76+
'platform' => $platform::class,
77+
'exception' => $throwable->getMessage(),
78+
]);
79+
80+
continue;
81+
}
82+
}
83+
84+
throw new RuntimeException('All platforms failed.');
85+
}
86+
}

0 commit comments

Comments
 (0)