Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/bundles/ai-bundle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)%'
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/ai-bundle/config/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/ai-bundle/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 8 additions & 0 deletions src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand Down
30 changes: 0 additions & 30 deletions src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
6 changes: 5 additions & 1 deletion src/platform/src/Bridge/Bedrock/PlatformFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}
Loading