From 013d301514409ec42f0d1191940785c1a77f28f4 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Fri, 12 Dec 2025 22:05:58 +0100 Subject: [PATCH] feat(platform: FailoverPlatform --- docs/components/platform.rst | 64 +++ examples/composer.json | 1 + examples/misc/failover-platform.php | 43 ++ splitsh.json | 1 + src/ai-bundle/composer.json | 1 + src/ai-bundle/config/options.php | 11 + src/ai-bundle/src/AiBundle.php | 31 ++ .../DependencyInjection/AiBundleTest.php | 81 ++++ src/platform/composer.json | 1 + .../src/Bridge/Failover/.gitattributes | 3 + .../Failover/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 + src/platform/src/Bridge/Failover/.gitignore | 4 + src/platform/src/Bridge/Failover/CHANGELOG.md | 7 + .../src/Bridge/Failover/FailoverPlatform.php | 87 ++++ .../Failover/FailoverPlatformFactory.php | 42 ++ src/platform/src/Bridge/Failover/LICENSE | 19 + src/platform/src/Bridge/Failover/README.md | 12 + .../Failover/Tests/FailoverPlatformTest.php | 399 ++++++++++++++++++ .../src/Bridge/Failover/composer.json | 59 +++ .../src/Bridge/Failover/phpstan.dist.neon | 23 + .../src/Bridge/Failover/phpunit.xml.dist | 32 ++ src/platform/src/Exception/LogicException.php | 19 + 23 files changed, 968 insertions(+) create mode 100644 examples/misc/failover-platform.php create mode 100644 src/platform/src/Bridge/Failover/.gitattributes create mode 100644 src/platform/src/Bridge/Failover/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/platform/src/Bridge/Failover/.github/workflows/close-pull-request.yml create mode 100644 src/platform/src/Bridge/Failover/.gitignore create mode 100644 src/platform/src/Bridge/Failover/CHANGELOG.md create mode 100644 src/platform/src/Bridge/Failover/FailoverPlatform.php create mode 100644 src/platform/src/Bridge/Failover/FailoverPlatformFactory.php create mode 100644 src/platform/src/Bridge/Failover/LICENSE create mode 100644 src/platform/src/Bridge/Failover/README.md create mode 100644 src/platform/src/Bridge/Failover/Tests/FailoverPlatformTest.php create mode 100644 src/platform/src/Bridge/Failover/composer.json create mode 100644 src/platform/src/Bridge/Failover/phpstan.dist.neon create mode 100644 src/platform/src/Bridge/Failover/phpunit.xml.dist create mode 100644 src/platform/src/Exception/LogicException.php diff --git a/docs/components/platform.rst b/docs/components/platform.rst index 33998b11e..c0229ff74 100644 --- a/docs/components/platform.rst +++ b/docs/components/platform.rst @@ -461,6 +461,70 @@ Thanks to Symfony's Cache component, platform calls can be cached to reduce call echo $secondResult->getContent().\PHP_EOL; +High Availability +----------------- + +As most platform exposes a REST API, errors can occurs during generation phase due to network issues, timeout and more. + +To prevent exceptions at the application level and allows to keep a smooth experience for end users, +the :class:`Symfony\\AI\\Platform\\Bridge\\Failover\\FailoverPlatform` can be used to automatically call a backup platform:: + + use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform; + use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory; + use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; + use Symfony\AI\Platform\Message\Message; + use Symfony\AI\Platform\Message\MessageBag; + use Symfony\Component\RateLimiter\RateLimiterFactory; + use Symfony\Component\RateLimiter\Storage\InMemoryStorage; + + $rateLimiter = new RateLimiterFactory([ + 'policy' => 'sliding_window', + 'id' => 'failover', + 'interval' => '3 seconds', + 'limit' => 1, + ], new InMemoryStorage()); + + // # Ollama will fail as 'gpt-4o' is not available in the catalog + $platform = new FailoverPlatform([ + OllamaPlatformFactory::create(env('OLLAMA_HOST_URL'), http_client()), + OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), http_client()), + ], $rateLimiter); + + $result = $platform->invoke('gpt-4o', new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), + )); + + echo $result->asText().\PHP_EOL; + +This platform can also be configured when using the bundle:: + + # config/packages/ai.yaml + ai: + platform: + openai: + # ... + ollama: + # ... + failover: + ollama_to_openai: + platforms: + - 'ai.platform.ollama' + - 'ai.platform.openai' + rate_limiter: 'limiter.failover_platform' + + # config/packages/rate_limiter.yaml + framework: + rate_limiter: + failover_platform: + policy: 'sliding_window' + limit: 100 + interval: '60 minutes' + +.. note:: + + Platforms are executed in the order they're injected into :class:`Symfony\\AI\\Platform\\Bridge\\Failover\\FailoverPlatform`. + Testing Tools ------------- diff --git a/examples/composer.json b/examples/composer.json index cfd53bdbd..82cd96107 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -48,6 +48,7 @@ "symfony/ai-docker-model-runner-platform": "@dev", "symfony/ai-elasticsearch-store": "@dev", "symfony/ai-eleven-labs-platform": "@dev", + "symfony/ai-failover-platform": "@dev", "symfony/ai-gemini-platform": "@dev", "symfony/ai-generic-platform": "@dev", "symfony/ai-session-message-store": "@dev", diff --git a/examples/misc/failover-platform.php b/examples/misc/failover-platform.php new file mode 100644 index 000000000..c9381bda0 --- /dev/null +++ b/examples/misc/failover-platform.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform; +use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$rateLimiter = new RateLimiterFactory([ + 'policy' => 'sliding_window', + 'id' => 'failover', + 'interval' => '3 seconds', + 'limit' => 1, +], new InMemoryStorage()); + +// # Ollama will fail as 'gpt-4o' is not available in the catalog +$platform = new FailoverPlatform([ + OllamaPlatformFactory::create(env('OLLAMA_HOST_URL'), http_client()), + OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), http_client()), +], $rateLimiter); + +$result = $platform->invoke('gpt-4o', new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +)); + +assert($result->getResultConverter() instanceof ResultConverter); + +echo $result->asText().\PHP_EOL; diff --git a/splitsh.json b/splitsh.json index c0d4ffa7b..a2a31d35d 100644 --- a/splitsh.json +++ b/splitsh.json @@ -43,6 +43,7 @@ "ai-deep-seek-platform": "src/platform/src/Bridge/DeepSeek", "ai-docker-model-runner-platform": "src/platform/src/Bridge/DockerModelRunner", "ai-eleven-labs-platform": "src/platform/src/Bridge/ElevenLabs", + "ai-failover-platform": "src/platform/src/Bridge/Failover", "ai-gemini-platform": "src/platform/src/Bridge/Gemini", "ai-generic-platform": "src/platform/src/Bridge/Generic", "ai-hugging-face-platform": "src/platform/src/Bridge/HuggingFace", diff --git a/src/ai-bundle/composer.json b/src/ai-bundle/composer.json index e12bccd59..4cfdf4aa9 100644 --- a/src/ai-bundle/composer.json +++ b/src/ai-bundle/composer.json @@ -55,6 +55,7 @@ "symfony/ai-doctrine-message-store": "@dev", "symfony/ai-eleven-labs-platform": "@dev", "symfony/ai-elasticsearch-store": "@dev", + "symfony/ai-failover-platform": "@dev", "symfony/ai-gemini-platform": "@dev", "symfony/ai-generic-platform": "@dev", "symfony/ai-session-message-store": "@dev", diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 1a3396e44..abf701abf 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -114,6 +114,17 @@ ->end() ->end() ->end() + ->arrayNode('failover') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->arrayNode('platforms') + ->scalarPrototype()->end() + ->end() + ->stringNode('rate_limiter')->cannotBeEmpty()->end() + ->end() + ->end() + ->end() ->arrayNode('gemini') ->children() ->stringNode('api_key')->isRequired()->end() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index af38f73cd..c47371057 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -13,6 +13,7 @@ use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\FetchAuthTokenInterface; +use Psr\Log\LoggerInterface; use Symfony\AI\Agent\Agent; use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Attribute\AsInputProcessor; @@ -60,6 +61,8 @@ use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory as DockerModelRunnerPlatformFactory; use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsApiCatalog; use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; +use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform; +use Symfony\AI\Platform\Bridge\Failover\FailoverPlatformFactory; use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory as GeminiPlatformFactory; use Symfony\AI\Platform\Bridge\Generic\PlatformFactory as GenericPlatformFactory; use Symfony\AI\Platform\Bridge\HuggingFace\PlatformFactory as HuggingFacePlatformFactory; @@ -511,6 +514,34 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('failover' === $type) { + if (!ContainerBuilder::willBeAvailable('symfony/ai-failover-platform', FailoverPlatformFactory::class, ['symfony/ai-bundle'])) { + throw new RuntimeException('Failover platform configuration requires "symfony/ai-failover-platform" package. Try running "composer require symfony/ai-failover-platform".'); + } + + foreach ($platform as $name => $config) { + $definition = (new Definition(FailoverPlatform::class)) + ->setFactory(FailoverPlatformFactory::class.'::create') + ->setLazy(true) + ->setArguments([ + array_map( + static fn (string $wrappedPlatform): Reference => new Reference($wrappedPlatform), + $config['platforms'], + ), + new Reference($config['rate_limiter']), + new Reference(ClockInterface::class), + new Reference(LoggerInterface::class), + ]) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->addTag('ai.platform', ['name' => $type]); + + $container->setDefinition('ai.platform.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.platform.'.$type.'.'.$name, PlatformInterface::class, $name); + } + + return; + } + if ('gemini' === $type) { if (!ContainerBuilder::willBeAvailable('symfony/ai-gemini-platform', GeminiPlatformFactory::class, ['symfony/ai-bundle'])) { throw new RuntimeException('Gemini platform configuration requires "symfony/ai-gemini-platform" package. Try running "composer require symfony/ai-gemini-platform".'); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index ed2157c22..62cf324db 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -18,6 +18,8 @@ use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Probots\Pinecone\Client as PineconeClient; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Memory\MemoryInputProcessor; use Symfony\AI\Agent\Memory\StaticMemoryProvider; @@ -31,6 +33,8 @@ use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsApiCatalog; use Symfony\AI\Platform\Bridge\ElevenLabs\ModelCatalog as ElevenLabsModelCatalog; use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; +use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform; +use Symfony\AI\Platform\Bridge\Failover\FailoverPlatformFactory; use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog; use Symfony\AI\Platform\CachedPlatform; use Symfony\AI\Platform\Capability; @@ -84,6 +88,8 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; use Symfony\Contracts\HttpClient\HttpClientInterface; class AiBundleTest extends TestCase @@ -4019,6 +4025,62 @@ public function testElevenLabsPlatformWithApiCatalogCanBeRegistered() $this->assertSame([['interface' => ModelCatalogInterface::class]], $modelCatalogDefinition->getTag('proxy')); } + public function testFailoverPlatformCanBeCreated() + { + $container = $this->buildContainer([ + 'ai' => [ + 'platform' => [ + 'ollama' => [ + 'host_url' => 'http://127.0.0.1:11434', + ], + 'openai' => [ + 'api_key' => 'sk-openai_key_full', + ], + 'failover' => [ + 'main' => [ + 'platforms' => [ + 'ai.platform.ollama', + 'ai.platform.openai', + ], + 'rate_limiter' => 'limiter.failover_platform', + ], + ], + ], + ], + ]); + + $this->assertTrue($container->hasDefinition('ai.platform.failover.main')); + + $definition = $container->getDefinition('ai.platform.failover.main'); + + $this->assertSame([ + FailoverPlatformFactory::class, + 'create', + ], $definition->getFactory()); + $this->assertTrue($definition->isLazy()); + $this->assertSame(FailoverPlatform::class, $definition->getClass()); + + $this->assertCount(4, $definition->getArguments()); + $this->assertCount(2, $definition->getArgument(0)); + $this->assertEquals([ + new Reference('ai.platform.ollama'), + new Reference('ai.platform.openai'), + ], $definition->getArgument(0)); + $this->assertInstanceOf(Reference::class, $definition->getArgument(1)); + $this->assertSame('limiter.failover_platform', (string) $definition->getArgument(1)); + $this->assertInstanceOf(Reference::class, $definition->getArgument(2)); + $this->assertSame(ClockInterface::class, (string) $definition->getArgument(2)); + $this->assertInstanceOf(Reference::class, $definition->getArgument(3)); + $this->assertSame(LoggerInterface::class, (string) $definition->getArgument(3)); + + $this->assertTrue($definition->hasTag('proxy')); + $this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy')); + $this->assertTrue($definition->hasTag('ai.platform')); + $this->assertSame([['name' => 'failover']], $definition->getTag('ai.platform')); + + $this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $main')); + } + public function testOpenAiPlatformWithDefaultRegion() { $container = $this->buildContainer([ @@ -7013,6 +7075,16 @@ private function buildContainer(array $configuration): ContainerBuilder $container->setParameter('kernel.environment', 'dev'); $container->setParameter('kernel.build_dir', 'public'); $container->setDefinition(ClockInterface::class, new Definition(MonotonicClock::class)); + $container->setDefinition(LoggerInterface::class, new Definition(NullLogger::class)); + $container->setDefinition('limiter.failover_platform', new Definition(RateLimiterFactory::class, [ + [ + 'policy' => 'sliding_window', + 'id' => 'test', + 'interval' => '60 seconds', + 'limit' => 1, + ], + new Definition(InMemoryStorage::class), + ])); $extension = (new AiBundle())->getContainerExtension(); $extension->load($configuration, $container); @@ -7068,6 +7140,15 @@ private function getFullConfig(): array 'host' => 'https://api.elevenlabs.io/v1', 'api_key' => 'elevenlabs_key_full', ], + 'failover' => [ + 'main' => [ + 'platforms' => [ + 'ai.platform.ollama', + 'ai.platform.openai', + ], + 'rate_limiter' => 'limiter.failover_platform', + ], + ], 'gemini' => [ 'api_key' => 'gemini_key_full', ], diff --git a/src/platform/composer.json b/src/platform/composer.json index c0a852cd8..47de394bb 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -64,6 +64,7 @@ "phpstan/phpstan": "^2.1.17", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.46", + "symfony/ai-agent": "@dev", "symfony/cache": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", "symfony/dotenv": "^7.3|^8.0", diff --git a/src/platform/src/Bridge/Failover/.gitattributes b/src/platform/src/Bridge/Failover/.gitattributes new file mode 100644 index 000000000..14c3c3594 --- /dev/null +++ b/src/platform/src/Bridge/Failover/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/platform/src/Bridge/Failover/.github/PULL_REQUEST_TEMPLATE.md b/src/platform/src/Bridge/Failover/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/platform/src/Bridge/Failover/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/platform/src/Bridge/Failover/.github/workflows/close-pull-request.yml b/src/platform/src/Bridge/Failover/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..bb5a02835 --- /dev/null +++ b/src/platform/src/Bridge/Failover/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/platform/src/Bridge/Failover/.gitignore b/src/platform/src/Bridge/Failover/.gitignore new file mode 100644 index 000000000..76367ee5b --- /dev/null +++ b/src/platform/src/Bridge/Failover/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/platform/src/Bridge/Failover/CHANGELOG.md b/src/platform/src/Bridge/Failover/CHANGELOG.md new file mode 100644 index 000000000..0915f3546 --- /dev/null +++ b/src/platform/src/Bridge/Failover/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.1 +--- + + * Add the bridge diff --git a/src/platform/src/Bridge/Failover/FailoverPlatform.php b/src/platform/src/Bridge/Failover/FailoverPlatform.php new file mode 100644 index 000000000..2edb76e17 --- /dev/null +++ b/src/platform/src/Bridge/Failover/FailoverPlatform.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Failover; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Platform\Exception\LogicException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\MonotonicClock; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; + +/** + * @author Guillaume Loulier + */ +final class FailoverPlatform implements PlatformInterface +{ + /** + * @var \WeakMap + */ + private readonly \WeakMap $failedPlatforms; + + /** + * @param PlatformInterface[] $platforms + */ + public function __construct( + private readonly iterable $platforms, + private readonly RateLimiterFactoryInterface $rateLimiterFactory, + private readonly ClockInterface $clock = new MonotonicClock(), + private readonly LoggerInterface $logger = new NullLogger(), + ) { + if ([] === $platforms) { + throw new LogicException(\sprintf('"%s" must have at least one platform configured.', self::class)); + } + + $this->failedPlatforms = new \WeakMap(); + } + + public function invoke(string $model, object|array|string $input, array $options = []): DeferredResult + { + return $this->do(static fn (PlatformInterface $platform): DeferredResult => $platform->invoke($model, $input, $options)); + } + + public function getModelCatalog(): ModelCatalogInterface + { + return $this->do(static fn (PlatformInterface $platform): ModelCatalogInterface => $platform->getModelCatalog()); + } + + private function do(\Closure $func): DeferredResult|ModelCatalogInterface + { + foreach ($this->platforms as $platform) { + $limiter = $this->rateLimiterFactory->create($platform::class); + + try { + if ($limiter->consume()->isAccepted() && $this->failedPlatforms->offsetExists($platform)) { + $this->failedPlatforms->offsetUnset($platform); + } + + return $func($platform); + } catch (\Throwable $throwable) { + $this->failedPlatforms->offsetSet($platform, $this->clock->now()->getTimestamp()); + + $this->logger->error('The {platform} platform due to an error/exception: {message}', [ + 'platform' => $platform::class, + 'message' => $throwable->getMessage(), + 'exception' => $throwable, + ]); + + continue; + } + } + + throw new RuntimeException('All platforms failed.'); + } +} diff --git a/src/platform/src/Bridge/Failover/FailoverPlatformFactory.php b/src/platform/src/Bridge/Failover/FailoverPlatformFactory.php new file mode 100644 index 000000000..92bc66aa4 --- /dev/null +++ b/src/platform/src/Bridge/Failover/FailoverPlatformFactory.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Failover; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\MonotonicClock; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; + +/** + * @author Guillaume Loulier + */ +final class FailoverPlatformFactory +{ + /** + * @param PlatformInterface[] $platforms + */ + public static function create( + iterable $platforms, + RateLimiterFactoryInterface $rateLimiterFactory, + ClockInterface $clock = new MonotonicClock(), + LoggerInterface $logger = new NullLogger(), + ): PlatformInterface { + return new FailoverPlatform( + $platforms, + $rateLimiterFactory, + $clock, + $logger, + ); + } +} diff --git a/src/platform/src/Bridge/Failover/LICENSE b/src/platform/src/Bridge/Failover/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/platform/src/Bridge/Failover/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/platform/src/Bridge/Failover/README.md b/src/platform/src/Bridge/Failover/README.md new file mode 100644 index 000000000..99456b6d0 --- /dev/null +++ b/src/platform/src/Bridge/Failover/README.md @@ -0,0 +1,12 @@ +Failover Platform +================= + +Failover platform bridge for Symfony AI. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/platform/src/Bridge/Failover/Tests/FailoverPlatformTest.php b/src/platform/src/Bridge/Failover/Tests/FailoverPlatformTest.php new file mode 100644 index 000000000..3a7822f64 --- /dev/null +++ b/src/platform/src/Bridge/Failover/Tests/FailoverPlatformTest.php @@ -0,0 +1,399 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Failover\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Result\InMemoryRawResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Test\InMemoryPlatform; +use Symfony\AI\Platform\Test\PlainConverter; +use Symfony\Component\Clock\MonotonicClock; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; + +final class FailoverPlatformTest extends TestCase +{ + public function testPlatformCannotPerformInvokeWithoutRemainingPlatform() + { + $mainPlatform = $this->createMock(PlatformInterface::class); + $mainPlatform->expects($this->once())->method('invoke') + ->willThrowException(new \Exception('The invoke method cannot be called from the main platform.')); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->once())->method('invoke') + ->willThrowException(new \Exception('The invoke method cannot be called from a failed platform.')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + $mainPlatform, + ], self::createRateLimiterFactory(), logger: $logger); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('All platforms failed.'); + $this->expectExceptionCode(0); + $failoverPlatform->invoke('foo', 'foo'); + } + + public function testPlatformCannotRetrieveModelCatalogWithoutRemainingPlatform() + { + $mainPlatform = $this->createMock(PlatformInterface::class); + $mainPlatform->expects($this->once())->method('getModelCatalog') + ->willThrowException(new \Exception('The ModelCatalog cannot be retrieved from the main platform.')); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->once())->method('getModelCatalog') + ->willThrowException(new \Exception('The ModelCatalog cannot be retrieved from a failed platform.')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + $mainPlatform, + ], self::createRateLimiterFactory(), logger: $logger); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('All platforms failed.'); + $this->expectExceptionCode(0); + $failoverPlatform->getModelCatalog(); + } + + public function testPlatformCanPerformInvokeWithRemainingPlatform() + { + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->once())->method('invoke') + ->willThrowException(new \Exception('The invoke method cannot be called from a failed platform.')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + new InMemoryPlatform(static fn (): string => 'foo'), + ], self::createRateLimiterFactory(), logger: $logger); + + $result = $failoverPlatform->invoke('foo', 'foo'); + + $this->assertSame('foo', $result->asText()); + } + + public function testPlatformCanRetrieveModelCatalogWithRemainingPlatform() + { + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->once())->method('getModelCatalog') + ->willThrowException(new \Exception('The ModelCatalog cannot be retrieved from a failed platform.')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + new InMemoryPlatform(static fn (): string => 'foo'), + ], self::createRateLimiterFactory(), logger: $logger); + + $this->assertInstanceOf(FallbackModelCatalog::class, $failoverPlatform->getModelCatalog()); + } + + public function testPlatformCanPerformInvokeWhileRemovingPlatformAfterRetryPeriod() + { + $clock = new MonotonicClock(); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->any())->method('invoke') + ->willReturnCallback(static function (): DeferredResult { + static $call = 0; + + if (1 === ++$call) { + throw new \Exception('An error occurred from a failed platform while calling invoke.'); + } + + return new DeferredResult( + new PlainConverter(new TextResult('foo')), + new InMemoryRawResult(['foo' => 'bar']), + ); + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + new InMemoryPlatform(static fn (): string => 'bar'), + ], self::createRateLimiterFactory(), logger: $logger); + + $firstResult = $failoverPlatform->invoke('foo', 'foo'); + + $this->assertSame('bar', $firstResult->asText()); + + $clock->sleep(4); + + $finalResult = $failedPlatform->invoke('foo', 'bar'); + + $this->assertSame('foo', $finalResult->asText()); + } + + public function testPlatformCanRetrieveModelCatalogWhileRemovingPlatformAfterRetryPeriod() + { + $clock = new MonotonicClock(); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->any())->method('getModelCatalog') + ->willReturnCallback(static function (): ModelCatalogInterface { + static $call = 0; + + if (1 === ++$call) { + throw new \Exception('An error occurred from a failed platform while retrieving the ModelCatalog.'); + } + + return new FallbackModelCatalog(); + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + new InMemoryPlatform(static fn (): string => 'bar'), + ], self::createRateLimiterFactory(), logger: $logger); + + $this->assertInstanceOf(FallbackModelCatalog::class, $failoverPlatform->getModelCatalog()); + + $clock->sleep(4); + + $this->assertInstanceOf(FallbackModelCatalog::class, $failoverPlatform->getModelCatalog()); + } + + public function testPlatformCannotPerformInvokeWhileAllPlatformFailedDuringRetryPeriod() + { + $clock = new MonotonicClock(); + + $firstPlatform = $this->createMock(PlatformInterface::class); + $firstPlatform->expects($this->any())->method('invoke') + ->willReturnCallback(static function (): DeferredResult { + static $call = 0; + + if (1 === ++$call) { + return new DeferredResult( + new PlainConverter(new TextResult('foo')), + new InMemoryRawResult(['foo' => 'bar']), + ); + } + + throw new \Exception('An error occurred from the main platform while calling invoke.'); + }); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->any())->method('invoke') + ->willReturnCallback(static function (): DeferredResult { + static $call = 0; + + if (1 === ++$call) { + throw new \Exception('An error occurred from a failing platform while calling invoke.'); + } + + throw new \Exception('An error occurred from a failing platform while calling invoke.'); + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(3))->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + $firstPlatform, + ], self::createRateLimiterFactory(), logger: $logger); + + $firstResult = $failoverPlatform->invoke('foo', 'foo'); + + $this->assertSame('foo', $firstResult->asText()); + + $clock->sleep(2); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('All platforms failed.'); + $this->expectExceptionCode(0); + $failoverPlatform->invoke('foo', 'foo'); + } + + public function testPlatformCannotRetrieveModelCatalogWhileAllPlatformFailedDuringRetryPeriod() + { + $clock = new MonotonicClock(); + + $firstPlatform = $this->createMock(PlatformInterface::class); + $firstPlatform->expects($this->any())->method('getModelCatalog') + ->willReturnCallback(static function (): ModelCatalogInterface { + static $call = 0; + + if (1 === ++$call) { + return new FallbackModelCatalog(); + } + + throw new \Exception('An error occurred'); + }); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->any())->method('getModelCatalog') + ->willReturnCallback(static function (): ModelCatalogInterface { + static $call = 0; + + if (1 === ++$call) { + throw new \Exception('An error occurred from a failing platform'); + } + + throw new \Exception('An error occurred from a failing platform'); + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(3))->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + $firstPlatform, + ], self::createRateLimiterFactory(), logger: $logger); + + $this->assertInstanceOf(FallbackModelCatalog::class, $failoverPlatform->getModelCatalog()); + + $clock->sleep(2); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('All platforms failed.'); + $this->expectExceptionCode(0); + $failoverPlatform->getModelCatalog(); + } + + public function testPlatformCanPerformInvokeWhileAllPlatformFailedDuringRetryPeriodThenRecovered() + { + $clock = new MonotonicClock(); + + $firstPlatform = $this->createMock(PlatformInterface::class); + $firstPlatform->expects($this->any())->method('invoke') + ->willReturnCallback(static function (): DeferredResult { + static $call = 0; + + if (4 === ++$call) { + throw new \Exception('An error occurred from the first platform while calling invoke.'); + } + + return new DeferredResult( + new PlainConverter(new TextResult('foo')), + new InMemoryRawResult(['foo' => 'bar']), + ); + }); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->any())->method('invoke') + ->willReturnCallback(static function (): DeferredResult { + static $call = 0; + + if (1 === ++$call) { + throw new \Exception('An error occurred from a failing platform'); + } + + if (3 === ++$call) { + throw new \Exception('An error occurred from a failing platform'); + } + + throw new \Exception('An error occurred from a failing platform'); + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(3))->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + $firstPlatform, + ], self::createRateLimiterFactory(), logger: $logger); + + $failoverPlatform->invoke('foo', 'foo'); + + $clock->sleep(1); + + $failoverPlatform->invoke('foo', 'foo'); + + $clock->sleep(1); + + $finalResult = $failoverPlatform->invoke('foo', 'foo'); + + $this->assertSame('foo', $finalResult->asText()); + } + + public function testPlatformCanRetrieveModelCatalogWhileAllPlatformFailedDuringRetryPeriodThenRecovered() + { + $clock = new MonotonicClock(); + + $firstPlatform = $this->createMock(PlatformInterface::class); + $firstPlatform->expects($this->any())->method('getModelCatalog') + ->willReturnCallback(static function (): ModelCatalogInterface { + static $call = 0; + + if (4 === ++$call) { + throw new \Exception('An error occurred from the first platform while retrieving the model catalog.'); + } + + return new FallbackModelCatalog(); + }); + + $failedPlatform = $this->createMock(PlatformInterface::class); + $failedPlatform->expects($this->any())->method('getModelCatalog') + ->willReturnCallback(static function (): ModelCatalogInterface { + static $call = 0; + + if (1 === ++$call) { + throw new \Exception('An error occurred from a failing platform'); + } + + if (3 === ++$call) { + throw new \Exception('An error occurred from a failing platform'); + } + + throw new \Exception('An error occurred from a failing platform'); + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(3))->method('error'); + + $failoverPlatform = new FailoverPlatform([ + $failedPlatform, + $firstPlatform, + ], self::createRateLimiterFactory(), logger: $logger); + + $failoverPlatform->getModelCatalog(); + + $clock->sleep(1); + + $failoverPlatform->getModelCatalog(); + + $clock->sleep(1); + + $failoverPlatform->getModelCatalog(); + } + + private static function createRateLimiterFactory(): RateLimiterFactoryInterface + { + return new RateLimiterFactory([ + 'policy' => 'sliding_window', + 'id' => 'failover', + 'interval' => '60 seconds', + 'limit' => 3, + ], new InMemoryStorage()); + } +} diff --git a/src/platform/src/Bridge/Failover/composer.json b/src/platform/src/Bridge/Failover/composer.json new file mode 100644 index 000000000..d17973074 --- /dev/null +++ b/src/platform/src/Bridge/Failover/composer.json @@ -0,0 +1,59 @@ +{ + "name": "symfony/ai-failover-platform", + "description": "Failover platform bridge for Symfony AI", + "license": "MIT", + "type": "symfony-ai-platform", + "keywords": [ + "ai", + "bridge", + "platform" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-platform": "@dev", + "symfony/rate-limiter": "^7.3|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\Bridge\\Failover\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/", + "Symfony\\AI\\Platform\\Bridge\\Failover\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} diff --git a/src/platform/src/Bridge/Failover/phpstan.dist.neon b/src/platform/src/Bridge/Failover/phpstan.dist.neon new file mode 100644 index 000000000..5503dac38 --- /dev/null +++ b/src/platform/src/Bridge/Failover/phpstan.dist.neon @@ -0,0 +1,23 @@ +includes: + - ../../../../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - . + - Tests/ + excludePaths: + - vendor/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + reportUnmatched: false + - + identifier: missingType.iterableValue + path: Tests/* + reportUnmatched: false + - + identifier: 'symfonyAi.forbidNativeException' + path: Tests/* + reportUnmatched: false diff --git a/src/platform/src/Bridge/Failover/phpunit.xml.dist b/src/platform/src/Bridge/Failover/phpunit.xml.dist new file mode 100644 index 000000000..605d4d07b --- /dev/null +++ b/src/platform/src/Bridge/Failover/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + diff --git a/src/platform/src/Exception/LogicException.php b/src/platform/src/Exception/LogicException.php new file mode 100644 index 000000000..bad5360ac --- /dev/null +++ b/src/platform/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Guillaume Loulier + */ +final class LogicException extends \LogicException implements ExceptionInterface +{ +}