diff --git a/docs/bundles/ai-bundle.rst b/docs/bundles/ai-bundle.rst index ecbe540bb..388ad3b05 100644 --- a/docs/bundles/ai-bundle.rst +++ b/docs/bundles/ai-bundle.rst @@ -51,6 +51,11 @@ Advanced Example with Multiple Agents deployment: '%env(AZURE_OPENAI_GPT)%' api_key: '%env(AZURE_OPENAI_KEY)%' api_version: '%env(AZURE_GPT_VERSION)%' + bedrock: + # multiple instances possible - for example region depending + default: ~ + eu: + bedrock_runtime_client: 'async_aws.client.bedrock_runtime_eu' eleven_labs: host: '%env(ELEVEN_LABS_HOST)%' api_key: '%env(ELEVEN_LABS_API_KEY)%' @@ -100,6 +105,10 @@ Advanced Example with Multiple Agents platform: 'ai.platform.eleven_labs' model: 'text-to-speech' tools: false + nova: + platform: 'ai.platform.bedrock_default' + model: 'nova-pro' + tools: false store: chromadb: # multiple collections possible per type diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 1a3396e44..a87be44f1 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -62,6 +62,18 @@ ->end() ->end() ->end() + ->arrayNode('bedrock') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('bedrock_runtime_client') + ->defaultNull() + ->info('Service ID of the Bedrock runtime client to use') + ->end() + ->stringNode('model_catalog')->defaultNull()->end() + ->end() + ->end() + ->end() ->arrayNode('cache') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 6980570d1..4ea8963fd 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -29,6 +29,7 @@ use Symfony\AI\Platform\Bridge\Anthropic\Contract\AnthropicContract; use Symfony\AI\Platform\Bridge\Anthropic\ModelCatalog as AnthropicModelCatalog; use Symfony\AI\Platform\Bridge\Azure\OpenAi\ModelCatalog as AzureOpenAiModelCatalog; +use Symfony\AI\Platform\Bridge\Bedrock\ModelCatalog as BedrockModelCatalog; use Symfony\AI\Platform\Bridge\Cartesia\ModelCatalog as CartesiaModelCatalog; use Symfony\AI\Platform\Bridge\Cerebras\ModelCatalog as CerebrasModelCatalog; use Symfony\AI\Platform\Bridge\Decart\ModelCatalog as DecartModelCatalog; @@ -96,6 +97,7 @@ ->set('ai.platform.model_catalog.albert', AlbertModelCatalog::class) ->set('ai.platform.model_catalog.anthropic', AnthropicModelCatalog::class) ->set('ai.platform.model_catalog.azure.openai', AzureOpenAiModelCatalog::class) + ->set('ai.platform.model_catalog.bedrock', BedrockModelCatalog::class) ->set('ai.platform.model_catalog.cartesia', CartesiaModelCatalog::class) ->set('ai.platform.model_catalog.cerebras', CerebrasModelCatalog::class) ->set('ai.platform.model_catalog.decart', DecartModelCatalog::class) diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index af38f73cd..2aae5ad58 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -53,6 +53,7 @@ use Symfony\AI\Platform\Bridge\Albert\PlatformFactory as AlbertPlatformFactory; use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; +use Symfony\AI\Platform\Bridge\Bedrock\PlatformFactory as BedrockFactory; use Symfony\AI\Platform\Bridge\Cartesia\PlatformFactory as CartesiaPlatformFactory; use Symfony\AI\Platform\Bridge\Cerebras\PlatformFactory as CerebrasPlatformFactory; use Symfony\AI\Platform\Bridge\Decart\PlatformFactory as DecartPlatformFactory; @@ -408,6 +409,31 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('bedrock' === $type) { + foreach ($platform as $name => $config) { + if (!ContainerBuilder::willBeAvailable('symfony/ai-bedrock-platform', BedrockFactory::class, ['symfony/ai-bundle'])) { + throw new RuntimeException('Bedrock platform configuration requires "symfony/ai-bedrock-platform" package. Try running "composer require symfony/ai-bedrock-platform".'); + } + + $platformId = 'ai.platform.bedrock_'.$name; + $definition = (new Definition(Platform::class)) + ->setFactory(BedrockFactory::class.'::create') + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + $config['bedrock_runtime_client'] ? new Reference($config['bedrock_runtime_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE) : null, + $config['model_catalog'] ? new Reference($config['model_catalog']) : new Reference('ai.platform.model_catalog.bedrock'), + null, + new Reference('event_dispatcher'), + ]) + ->addTag('ai.platform', ['name' => 'bedrock_'.$name]); + + $container->setDefinition($platformId, $definition); + } + + return; + } + if ('cache' === $type) { foreach ($platform as $name => $cachedPlatformConfig) { $definition = (new Definition(CachedPlatform::class)) diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index ed2157c22..79afaf4e0 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -11,6 +11,7 @@ namespace Symfony\AI\AiBundle\Tests\DependencyInjection; +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; use Codewithkyrian\ChromaDB\Client; use MongoDB\Client as MongoDbClient; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; @@ -7013,6 +7014,7 @@ 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('async_aws.client.bedrock_us', new Definition(BedrockRuntimeClient::class)); $extension = (new AiBundle())->getContainerExtension(); $extension->load($configuration, $container); @@ -7049,6 +7051,12 @@ private function getFullConfig(): array 'api_version' => '2024-02-15-preview', ], ], + 'bedrock' => [ + 'default' => [], + 'us' => [ + 'bedrock_runtime_client' => 'async_aws.client.bedrock_us', + ], + ], 'cache' => [ 'azure' => [ 'platform' => 'ai.platform.azure.my_azure_instance', diff --git a/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php index 1d47b7690..edaa604d7 100644 --- a/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php +++ b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php @@ -13,15 +13,10 @@ use AsyncAws\BedrockRuntime\BedrockRuntimeClient; use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; -use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; use Symfony\AI\Platform\Bridge\Anthropic\Claude; use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResult; -use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; -use Symfony\AI\Platform\Result\TextResult; -use Symfony\AI\Platform\Result\ToolCall; -use Symfony\AI\Platform\Result\ToolCallResult; /** * @author Björn Altmann @@ -60,31 +55,6 @@ public function request(Model $model, array|string $payload, array $options = [] return new RawBedrockResult($this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request))); } - public function convert(InvokeModelResponse $bedrockResponse): ToolCallResult|TextResult - { - $data = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); - - if (!isset($data['content']) || [] === $data['content']) { - throw new RuntimeException('Response does not contain any content.'); - } - - if (!isset($data['content'][0]['text']) && !isset($data['content'][0]['type'])) { - throw new RuntimeException('Response content does not contain any text or type.'); - } - - $toolCalls = []; - foreach ($data['content'] as $content) { - if ('tool_use' === $content['type']) { - $toolCalls[] = new ToolCall($content['id'], $content['name'], $content['input']); - } - } - if ([] !== $toolCalls) { - return new ToolCallResult(...$toolCalls); - } - - return new TextResult($data['content'][0]['text']); - } - private function getModelId(Model $model): string { $configuredRegion = $this->bedrockRuntimeClient->getConfiguration()->get('region'); diff --git a/src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php b/src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php index a1990da62..31329a0c4 100644 --- a/src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php +++ b/src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php @@ -34,6 +34,8 @@ public function supports(Model $model): bool public function request(Model $model, array|string $payload, array $options = []): RawBedrockResult { + unset($payload['model']); + $modelOptions = []; if (isset($options['tools'])) { $modelOptions['toolConfig']['tools'] = $options['tools']; diff --git a/src/platform/src/Bridge/Bedrock/PlatformFactory.php b/src/platform/src/Bridge/Bedrock/PlatformFactory.php index 2d7632fe0..be2af8485 100644 --- a/src/platform/src/Bridge/Bedrock/PlatformFactory.php +++ b/src/platform/src/Bridge/Bedrock/PlatformFactory.php @@ -33,7 +33,7 @@ final class PlatformFactory { public static function create( - BedrockRuntimeClient $bedrockRuntimeClient = new BedrockRuntimeClient(), + ?BedrockRuntimeClient $bedrockRuntimeClient = null, ModelCatalogInterface $modelCatalog = new ModelCatalog(), ?Contract $contract = null, ?EventDispatcherInterface $eventDispatcher = null, @@ -42,6 +42,10 @@ public static function create( throw new RuntimeException('For using the Bedrock platform, the async-aws/bedrock-runtime package is required. Try running "composer require async-aws/bedrock-runtime".'); } + if (null === $bedrockRuntimeClient) { + $bedrockRuntimeClient = new BedrockRuntimeClient(); + } + return new Platform( [ new ClaudeModelClient($bedrockRuntimeClient), diff --git a/src/platform/src/Bridge/Bedrock/Tests/Anthropic/ClaudeModelClientTest.php b/src/platform/src/Bridge/Bedrock/Tests/Anthropic/ClaudeModelClientTest.php new file mode 100644 index 000000000..fdf510062 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Tests/Anthropic/ClaudeModelClientTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Tests\Anthropic; + +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; +use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; +use AsyncAws\Core\Configuration; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResult; + +final class ClaudeModelClientTest extends TestCase +{ + private const VERSION = '2023-05-31'; + + private MockObject&BedrockRuntimeClient $bedrockClient; + private ClaudeModelClient $modelClient; + private Claude $model; + + protected function setUp(): void + { + $this->model = new Claude('claude-sonnet-4-5-20250929'); + $this->bedrockClient = $this->getMockBuilder(BedrockRuntimeClient::class) + ->setConstructorArgs([ + Configuration::create([Configuration::OPTION_REGION => Configuration::DEFAULT_REGION]), + ]) + ->onlyMethods(['invokeModel']) + ->getMock(); + } + + public function testPassesModelId() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('us.anthropic.claude-sonnet-4-5-20250929-v1:0', $arg->getModelId()); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new ClaudeModelClient($this->bedrockClient, self::VERSION); + + $response = $this->modelClient->request($this->model, ['message' => 'test']); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testUnsetsModelName() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertArrayNotHasKey('model', $body); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new ClaudeModelClient($this->bedrockClient, self::VERSION); + + $response = $this->modelClient->request($this->model, ['message' => 'test', 'model' => 'claude']); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testSetsAnthropicVersion() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertSame('bedrock-'.self::VERSION, $body['anthropic_version']); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new ClaudeModelClient($this->bedrockClient, self::VERSION); + + $response = $this->modelClient->request($this->model, ['message' => 'test']); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testSetsToolOptionsIfToolsEnabled() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertSame(['type' => 'auto'], $body['tool_choice']); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new ClaudeModelClient($this->bedrockClient, self::VERSION); + + $options = [ + 'tools' => ['Tool'], + ]; + + $response = $this->modelClient->request($this->model, ['message' => 'test'], $options); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } +} diff --git a/src/platform/src/Bridge/Bedrock/Tests/Nova/NovaModelClientTest.php b/src/platform/src/Bridge/Bedrock/Tests/Nova/NovaModelClientTest.php new file mode 100644 index 000000000..0f1195e76 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Tests/Nova/NovaModelClientTest.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Tests\Nova; + +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; +use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; +use AsyncAws\Core\Configuration; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\NovaModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResult; + +final class NovaModelClientTest extends TestCase +{ + private MockObject&BedrockRuntimeClient $bedrockClient; + private NovaModelClient $modelClient; + private Nova $model; + + protected function setUp(): void + { + $this->model = new Nova('nova-pro'); + $this->bedrockClient = $this->getMockBuilder(BedrockRuntimeClient::class) + ->setConstructorArgs([ + Configuration::create([Configuration::OPTION_REGION => Configuration::DEFAULT_REGION]), + ]) + ->onlyMethods(['invokeModel']) + ->getMock(); + } + + public function testPassesModelId() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('us.amazon.nova-pro-v1:0', $arg->getModelId()); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new NovaModelClient($this->bedrockClient); + + $response = $this->modelClient->request($this->model, ['message' => 'test']); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testUnsetsModelName() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertArrayNotHasKey('model', $body); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new NovaModelClient($this->bedrockClient); + + $response = $this->modelClient->request($this->model, ['message' => 'test', 'model' => 'nova-pro']); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testSetsToolOptionsIfToolsEnabled() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertSame(['tools' => ['Tool']], $body['toolConfig']); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new NovaModelClient($this->bedrockClient); + + $options = [ + 'tools' => ['Tool'], + ]; + + $response = $this->modelClient->request($this->model, ['message' => 'test'], $options); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testPassesTemperature() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertArrayHasKey('inferenceConfig', $body); + $this->assertSame(['temperature' => 0.35], $body['inferenceConfig']); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new NovaModelClient($this->bedrockClient); + + $options = [ + 'temperature' => 0.35, + ]; + + $response = $this->modelClient->request($this->model, ['message' => 'test'], $options); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testPassesMaxTokens() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertArrayHasKey('inferenceConfig', $body); + $this->assertSame(['maxTokens' => 1000], $body['inferenceConfig']); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new NovaModelClient($this->bedrockClient); + + $options = [ + 'max_tokens' => 1000, + ]; + + $response = $this->modelClient->request($this->model, ['message' => 'test'], $options); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } + + public function testPassesBothTemperatureAndMaxTokens() + { + $this->bedrockClient->expects($this->once()) + ->method('invokeModel') + ->with($this->callback(function ($arg) { + $this->assertInstanceOf(InvokeModelRequest::class, $arg); + $this->assertSame('application/json', $arg->getContentType()); + $this->assertTrue(json_validate($arg->getBody())); + + $body = json_decode($arg->getBody(), true); + $this->assertArrayHasKey('inferenceConfig', $body); + $this->assertSame(['temperature' => 0.35, 'maxTokens' => 1000], $body['inferenceConfig']); + + return true; + })) + ->willReturn($this->createMock(InvokeModelResponse::class)); + + $this->modelClient = new NovaModelClient($this->bedrockClient); + + $options = [ + 'max_tokens' => 1000, + 'temperature' => 0.35, + ]; + + $response = $this->modelClient->request($this->model, ['message' => 'test'], $options); + $this->assertInstanceOf(RawBedrockResult::class, $response); + } +}