Skip to content
Open
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chr-hertel should we go with

Suggested change
$platformId = 'ai.platform.bedrock_'.$name;
$platformId = 'ai.platform.bedrock.'.$name;

?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, would be more consistent - see azure

Copy link
Contributor Author

@uerka uerka Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then i guess we might want to change here to avoid platform named "ai.platform.bedrock.eu" to become "ai.traceable_platform.eu" that could collide with azure/generic or other platforms. should we go with that change or change azure/generic as atm it seems should handle same strange traceable platform name conversion?

$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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why removing this?

Copy link
Contributor Author

@uerka uerka Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because conversion atm handled with ResultConverter, so this code seems not supposed to be called like that.

{
$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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this change needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh - not sure about this one.
When its nullable we can pass null as first argument in this line that feels like a better idea than creating BedrockRuntimeClient in the config.
i moved creating client later in code, so i dont really see the problem of having it nullable from now - except of failing tests atm.

i dont mind to have it as required argument and always require it for configuring bedrock platform, but seems like creating client on the fly should work also fine (as soon as creds can be resolved through env vars).

would appreciate your input on this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

understood - let's keep it like that, but go with explicit null check below:

if (null === $bedrockRuntimeClient) {

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
Loading