Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/components/chat.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ You can find more advanced usage in combination with an Agent using the store fo
* `Long-term context with Doctrine DBAL`_
* `Current session context storage with HttpFoundation session`_
* `Current process context storage with InMemory`_
* `Long-term context with Cloudflare`_
* `Long-term context with Meilisearch`_
* `Long-term context with MongoDb`_
* `Long-term context with Pogocache`_
Expand All @@ -47,6 +48,7 @@ Supported Message stores
------------------------

* `Cache`_
* `Cloudflare`_
* `Doctrine DBAL`_
* `HttpFoundation session`_
* `InMemory`_
Expand Down Expand Up @@ -134,12 +136,14 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store:
.. _`Long-term context with Doctrine DBAL`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-doctrine-dbal.php
.. _`Current session context storage with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php
.. _`Current process context storage with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php
.. _`Long-term context with Cloudflare`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cloudflare.php
.. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php
.. _`Long-term context with MongoDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-mongodb.php
.. _`Long-term context with Pogocache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-pogocache.php
.. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php
.. _`Long-term context with SurrealDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-surrealdb.php
.. _`Cache`: https://symfony.com/doc/current/components/cache.html
.. _`Cloudflare`: https://developers.cloudflare.com/kv/
.. _`Doctrine DBAL`: https://www.doctrine-project.org/projects/dbal.html
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
.. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/
Expand Down
42 changes: 42 additions & 0 deletions examples/chat/persistent-chat-cloudflare.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

use Symfony\AI\Agent\Agent;
use Symfony\AI\Chat\Bridge\Cloudflare\MessageStore;
use Symfony\AI\Chat\Chat;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

require_once dirname(__DIR__).'/bootstrap.php';

$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());

$store = new MessageStore(
http_client(),
namespace: 'symfony',
accountId: env('CLOUDFLARE_ACCOUNT_ID'),
apiKey: env('CLOUDFLARE_API_KEY'),
);
$store->setup();

$agent = new Agent($platform, 'gpt-4o-mini');
$chat = new Chat($agent, $store);

$messages = new MessageBag(
Message::forSystem('You are a helpful assistant. You only answer with short sentences.'),
);

$chat->initiate($messages);
$chat->submit(Message::ofUser('My name is Christopher.'));
$message = $chat->submit(Message::ofUser('What is my name?'));

echo $message->getContent().\PHP_EOL;
13 changes: 13 additions & 0 deletions src/ai-bundle/config/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,19 @@
->end()
->end()
->end()
->arrayNode('cloudflare')
->useAttributeAsKey('name')
->arrayPrototype()
->children()
->stringNode('account_id')->cannotBeEmpty()->end()
->stringNode('api_key')->cannotBeEmpty()->end()
->stringNode('namespace')->cannotBeEmpty()->end()
->stringNode('endpoint_url')
->info('If the version of the Cloudflare API is updated, use this key to support it.')
->end()
->end()
->end()
->end()
->arrayNode('doctrine')
->children()
->arrayNode('dbal')
Expand Down
29 changes: 29 additions & 0 deletions src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
use Symfony\AI\AiBundle\Profiler\TraceablePlatform;
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;
use Symfony\AI\Chat\Bridge\Cloudflare\MessageStore as CloudflareMessageStore;
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
use Symfony\AI\Chat\Bridge\Local\CacheStore as CacheMessageStore;
Expand Down Expand Up @@ -1588,6 +1589,34 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
}
}

if ('cloudflare' === $type) {
foreach ($messageStores as $name => $messageStore) {
$arguments = [
new Reference('http_client'),
$messageStore['namespace'],
$messageStore['account_id'],
$messageStore['api_key'],
new Reference('serializer'),
];

if (\array_key_exists('endpoint_url', $messageStore)) {
$arguments[5] = $messageStore['endpoint_url'];
}

$definition = new Definition(CloudflareMessageStore::class);
$definition
->setLazy(true)
->setArguments($arguments)
->addTag('proxy', ['interface' => MessageStoreInterface::class])
->addTag('proxy', ['interface' => ManagedMessageStoreInterface::class])
->addTag('ai.message_store');

$container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition);
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
}
}

if ('doctrine' === $type) {
foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) {
$definition = new Definition(DoctrineDbalMessageStore::class);
Expand Down
85 changes: 85 additions & 0 deletions src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3837,6 +3837,78 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl()
$this->assertTrue($definition->hasTag('ai.message_store'));
}

public function testCloudflareMessageStoreIsConfigured()
{
$container = $this->buildContainer([
'ai' => [
'message_store' => [
'cloudflare' => [
'my_cloudflare_message_store' => [
'account_id' => 'foo',
'api_key' => 'bar',
'namespace' => 'random',
],
],
],
],
]);

$definition = $container->getDefinition('ai.message_store.cloudflare.my_cloudflare_message_store');

$this->assertTrue($definition->isLazy());
$this->assertCount(5, $definition->getArguments());
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
$this->assertSame('http_client', (string) $definition->getArgument(0));
$this->assertSame('random', $definition->getArgument(1));
$this->assertSame('foo', $definition->getArgument(2));
$this->assertSame('bar', $definition->getArgument(3));
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
$this->assertSame('serializer', (string) $definition->getArgument(4));

$this->assertSame([
['interface' => MessageStoreInterface::class],
['interface' => ManagedMessageStoreInterface::class],
], $definition->getTag('proxy'));
$this->assertTrue($definition->hasTag('ai.message_store'));
}

public function testCloudflareMessageStoreWithCustomEndpointIsConfigured()
{
$container = $this->buildContainer([
'ai' => [
'message_store' => [
'cloudflare' => [
'my_cloudflare_message_store_with_new_endpoint' => [
'account_id' => 'foo',
'api_key' => 'bar',
'namespace' => 'random',
'endpoint_url' => 'https://api.cloudflare.com/client/v6/accounts',
],
],
],
],
]);

$definition = $container->getDefinition('ai.message_store.cloudflare.my_cloudflare_message_store_with_new_endpoint');

$this->assertTrue($definition->isLazy());
$this->assertCount(6, $definition->getArguments());
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
$this->assertSame('http_client', (string) $definition->getArgument(0));
$this->assertSame('random', $definition->getArgument(1));
$this->assertSame('foo', $definition->getArgument(2));
$this->assertSame('bar', $definition->getArgument(3));
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
$this->assertSame('serializer', (string) $definition->getArgument(4));
$this->assertSame('https://api.cloudflare.com/client/v6/accounts', $definition->getArgument(5));

$this->assertSame([
['interface' => MessageStoreInterface::class],
['interface' => ManagedMessageStoreInterface::class],
], $definition->getTag('proxy'));
$this->assertTrue($definition->hasTag('ai.message_store'));
}

public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey()
{
$container = $this->buildContainer([
Expand Down Expand Up @@ -4781,6 +4853,19 @@ private function getFullConfig(): array
'key' => 'foo',
],
],
'cloudflare' => [
'my_cloudflare_message_store' => [
'account_id' => 'foo',
'api_key' => 'bar',
'namespace' => 'random',
],
'my_cloudflare_message_store_with_new_endpoint' => [
'account_id' => 'foo',
'api_key' => 'bar',
'namespace' => 'random',
'endpoint_url' => 'https://api.cloudflare.com/client/v6/accounts',
],
],
'doctrine' => [
'dbal' => [
'default' => [
Expand Down
2 changes: 2 additions & 0 deletions src/chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ CHANGELOG

* Introduce the component
* Add support for external message stores:
- Symfony Cache
- Cloudflare
- Doctrine
- Meilisearch
- MongoDb
Expand Down
153 changes: 153 additions & 0 deletions src/chat/src/Bridge/Cloudflare/MessageStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\Chat\Bridge\Cloudflare;

use Symfony\AI\Chat\Exception\InvalidArgumentException;
use Symfony\AI\Chat\ManagedStoreInterface;
use Symfony\AI\Chat\MessageNormalizer;
use Symfony\AI\Chat\MessageStoreInterface;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\MessageInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @author Guillaume Loulier <[email protected]>
*/
final class MessageStore implements ManagedStoreInterface, MessageStoreInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $namespace,
#[\SensitiveParameter] private readonly string $accountId,
#[\SensitiveParameter] private readonly string $apiKey,
private readonly SerializerInterface&NormalizerInterface&DenormalizerInterface $serializer = new Serializer([
new ArrayDenormalizer(),
new MessageNormalizer(),
], [new JsonEncoder()]),
private readonly string $endpointUrl = 'https://api.cloudflare.com/client/v4/accounts',
) {
}

public function setup(array $options = []): void
{
if ([] !== $options) {
throw new InvalidArgumentException('No supported options.');
}

$namespaces = $this->request('GET', 'storage/kv/namespaces');

$filteredNamespaces = array_filter(
$namespaces['result'],
fn (array $payload): bool => $payload['title'] === $this->namespace,
);

if (0 !== \count($filteredNamespaces)) {
return;
}

$this->request('POST', 'storage/kv/namespaces', [
'title' => $this->namespace,
]);
}

public function drop(): void
{
$currentNamespaceUuid = $this->retrieveCurrentNamespaceUuid();

$keys = $this->request('GET', \sprintf('storage/kv/namespaces/%s/keys', $currentNamespaceUuid));

if ([] === $keys['result']) {
return;
}

$this->request('POST', \sprintf('storage/kv/namespaces/%s/bulk/delete', $currentNamespaceUuid), array_map(
static fn (array $payload): string => $payload['name'],
$keys['result'],
));
}

public function save(MessageBag $messages): void
{
$currentNamespaceUuid = $this->retrieveCurrentNamespaceUuid();

$this->request('PUT', \sprintf('storage/kv/namespaces/%s/bulk', $currentNamespaceUuid), array_map(
fn (MessageInterface $message): array => [
'key' => $message->getId()->toRfc4122(),
'value' => $this->serializer->serialize($message, 'json'),
],
$messages->getMessages(),
));
}

public function load(): MessageBag
{
$currentNamespaceUuid = $this->retrieveCurrentNamespaceUuid();

$keys = $this->request('GET', \sprintf('storage/kv/namespaces/%s/keys', $currentNamespaceUuid));

$messages = $this->request('POST', \sprintf('storage/kv/namespaces/%s/bulk/get', $currentNamespaceUuid), [
'keys' => array_map(
static fn (array $payload): string => $payload['name'],
$keys['result'],
),
]);

return new MessageBag(...array_map(
fn (string $message): MessageInterface => $this->serializer->deserialize($message, MessageInterface::class, 'json'),
$messages['result']['values'],
));
}

/**
* @param array<string, mixed>|list<array<string, string>> $payload
*
* @return array<string, mixed>
*/
private function request(string $method, string $endpoint, array $payload = []): array
{
$finalOptions = [
'auth_bearer' => $this->apiKey,
];

if ([] !== $payload) {
$finalOptions['json'] = $payload;
}

$response = $this->httpClient->request($method, \sprintf('%s/%s/%s', $this->endpointUrl, $this->accountId, $endpoint), $finalOptions);

return $response->toArray();
}

private function retrieveCurrentNamespaceUuid(): string
{
$namespaces = $this->request('GET', 'storage/kv/namespaces');

$filteredNamespaces = array_filter(
$namespaces['result'],
fn (array $payload): bool => $payload['title'] === $this->namespace,
);

if (0 === \count($filteredNamespaces)) {
throw new InvalidArgumentException('No namespace found.');
}

reset($filteredNamespaces);

return $filteredNamespaces[0]['id'];
}
}
Loading