Skip to content

Commit ea9eeae

Browse files
committed
feature #1000 [Chat] Introduce Cloudflare message store (Guikingone)
This PR was merged into the main branch. Discussion ---------- [Chat] Introduce `Cloudflare` message store | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | yes | Issues | #16 | License | MIT Commits ------- 9ea65f1 feat(chat): Introduce Cloudflare message store
2 parents 581d29b + 9ea65f1 commit ea9eeae

File tree

8 files changed

+654
-0
lines changed

8 files changed

+654
-0
lines changed

docs/components/chat.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ You can find more advanced usage in combination with an Agent using the store fo
3737
* `Long-term context with Doctrine DBAL`_
3838
* `Current session context storage with HttpFoundation session`_
3939
* `Current process context storage with InMemory`_
40+
* `Long-term context with Cloudflare`_
4041
* `Long-term context with Meilisearch`_
4142
* `Long-term context with MongoDb`_
4243
* `Long-term context with Pogocache`_
@@ -47,6 +48,7 @@ Supported Message stores
4748
------------------------
4849

4950
* `Cache`_
51+
* `Cloudflare`_
5052
* `Doctrine DBAL`_
5153
* `HttpFoundation session`_
5254
* `InMemory`_
@@ -134,12 +136,14 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store:
134136
.. _`Long-term context with Doctrine DBAL`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-doctrine-dbal.php
135137
.. _`Current session context storage with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php
136138
.. _`Current process context storage with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php
139+
.. _`Long-term context with Cloudflare`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cloudflare.php
137140
.. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php
138141
.. _`Long-term context with MongoDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-mongodb.php
139142
.. _`Long-term context with Pogocache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-pogocache.php
140143
.. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php
141144
.. _`Long-term context with SurrealDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-surrealdb.php
142145
.. _`Cache`: https://symfony.com/doc/current/components/cache.html
146+
.. _`Cloudflare`: https://developers.cloudflare.com/kv/
143147
.. _`Doctrine DBAL`: https://www.doctrine-project.org/projects/dbal.html
144148
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
145149
.. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Chat\Bridge\Cloudflare\MessageStore;
14+
use Symfony\AI\Chat\Chat;
15+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
16+
use Symfony\AI\Platform\Message\Message;
17+
use Symfony\AI\Platform\Message\MessageBag;
18+
19+
require_once dirname(__DIR__).'/bootstrap.php';
20+
21+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
22+
23+
$store = new MessageStore(
24+
http_client(),
25+
namespace: 'symfony',
26+
accountId: env('CLOUDFLARE_ACCOUNT_ID'),
27+
apiKey: env('CLOUDFLARE_API_KEY'),
28+
);
29+
$store->setup();
30+
31+
$agent = new Agent($platform, 'gpt-4o-mini');
32+
$chat = new Chat($agent, $store);
33+
34+
$messages = new MessageBag(
35+
Message::forSystem('You are a helpful assistant. You only answer with short sentences.'),
36+
);
37+
38+
$chat->initiate($messages);
39+
$chat->submit(Message::ofUser('My name is Christopher.'));
40+
$message = $chat->submit(Message::ofUser('What is my name?'));
41+
42+
echo $message->getContent().\PHP_EOL;

src/ai-bundle/config/options.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,19 @@
847847
->end()
848848
->end()
849849
->end()
850+
->arrayNode('cloudflare')
851+
->useAttributeAsKey('name')
852+
->arrayPrototype()
853+
->children()
854+
->stringNode('account_id')->cannotBeEmpty()->end()
855+
->stringNode('api_key')->cannotBeEmpty()->end()
856+
->stringNode('namespace')->cannotBeEmpty()->end()
857+
->stringNode('endpoint_url')
858+
->info('If the version of the Cloudflare API is updated, use this key to support it.')
859+
->end()
860+
->end()
861+
->end()
862+
->end()
850863
->arrayNode('doctrine')
851864
->children()
852865
->arrayNode('dbal')

src/ai-bundle/src/AiBundle.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use Symfony\AI\AiBundle\Profiler\TraceablePlatform;
3737
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
3838
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;
39+
use Symfony\AI\Chat\Bridge\Cloudflare\MessageStore as CloudflareMessageStore;
3940
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
4041
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
4142
use Symfony\AI\Chat\Bridge\Local\CacheStore as CacheMessageStore;
@@ -1588,6 +1589,34 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
15881589
}
15891590
}
15901591

1592+
if ('cloudflare' === $type) {
1593+
foreach ($messageStores as $name => $messageStore) {
1594+
$arguments = [
1595+
new Reference('http_client'),
1596+
$messageStore['namespace'],
1597+
$messageStore['account_id'],
1598+
$messageStore['api_key'],
1599+
new Reference('serializer'),
1600+
];
1601+
1602+
if (\array_key_exists('endpoint_url', $messageStore)) {
1603+
$arguments[5] = $messageStore['endpoint_url'];
1604+
}
1605+
1606+
$definition = new Definition(CloudflareMessageStore::class);
1607+
$definition
1608+
->setLazy(true)
1609+
->setArguments($arguments)
1610+
->addTag('proxy', ['interface' => MessageStoreInterface::class])
1611+
->addTag('proxy', ['interface' => ManagedMessageStoreInterface::class])
1612+
->addTag('ai.message_store');
1613+
1614+
$container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition);
1615+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
1616+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
1617+
}
1618+
}
1619+
15911620
if ('doctrine' === $type) {
15921621
foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) {
15931622
$definition = new Definition(DoctrineDbalMessageStore::class);

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3837,6 +3837,78 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl()
38373837
$this->assertTrue($definition->hasTag('ai.message_store'));
38383838
}
38393839

3840+
public function testCloudflareMessageStoreIsConfigured()
3841+
{
3842+
$container = $this->buildContainer([
3843+
'ai' => [
3844+
'message_store' => [
3845+
'cloudflare' => [
3846+
'my_cloudflare_message_store' => [
3847+
'account_id' => 'foo',
3848+
'api_key' => 'bar',
3849+
'namespace' => 'random',
3850+
],
3851+
],
3852+
],
3853+
],
3854+
]);
3855+
3856+
$definition = $container->getDefinition('ai.message_store.cloudflare.my_cloudflare_message_store');
3857+
3858+
$this->assertTrue($definition->isLazy());
3859+
$this->assertCount(5, $definition->getArguments());
3860+
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
3861+
$this->assertSame('http_client', (string) $definition->getArgument(0));
3862+
$this->assertSame('random', $definition->getArgument(1));
3863+
$this->assertSame('foo', $definition->getArgument(2));
3864+
$this->assertSame('bar', $definition->getArgument(3));
3865+
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
3866+
$this->assertSame('serializer', (string) $definition->getArgument(4));
3867+
3868+
$this->assertSame([
3869+
['interface' => MessageStoreInterface::class],
3870+
['interface' => ManagedMessageStoreInterface::class],
3871+
], $definition->getTag('proxy'));
3872+
$this->assertTrue($definition->hasTag('ai.message_store'));
3873+
}
3874+
3875+
public function testCloudflareMessageStoreWithCustomEndpointIsConfigured()
3876+
{
3877+
$container = $this->buildContainer([
3878+
'ai' => [
3879+
'message_store' => [
3880+
'cloudflare' => [
3881+
'my_cloudflare_message_store_with_new_endpoint' => [
3882+
'account_id' => 'foo',
3883+
'api_key' => 'bar',
3884+
'namespace' => 'random',
3885+
'endpoint_url' => 'https://api.cloudflare.com/client/v6/accounts',
3886+
],
3887+
],
3888+
],
3889+
],
3890+
]);
3891+
3892+
$definition = $container->getDefinition('ai.message_store.cloudflare.my_cloudflare_message_store_with_new_endpoint');
3893+
3894+
$this->assertTrue($definition->isLazy());
3895+
$this->assertCount(6, $definition->getArguments());
3896+
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
3897+
$this->assertSame('http_client', (string) $definition->getArgument(0));
3898+
$this->assertSame('random', $definition->getArgument(1));
3899+
$this->assertSame('foo', $definition->getArgument(2));
3900+
$this->assertSame('bar', $definition->getArgument(3));
3901+
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
3902+
$this->assertSame('serializer', (string) $definition->getArgument(4));
3903+
$this->assertSame('https://api.cloudflare.com/client/v6/accounts', $definition->getArgument(5));
3904+
3905+
$this->assertSame([
3906+
['interface' => MessageStoreInterface::class],
3907+
['interface' => ManagedMessageStoreInterface::class],
3908+
], $definition->getTag('proxy'));
3909+
$this->assertTrue($definition->hasTag('ai.message_store'));
3910+
}
3911+
38403912
public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey()
38413913
{
38423914
$container = $this->buildContainer([
@@ -4781,6 +4853,19 @@ private function getFullConfig(): array
47814853
'key' => 'foo',
47824854
],
47834855
],
4856+
'cloudflare' => [
4857+
'my_cloudflare_message_store' => [
4858+
'account_id' => 'foo',
4859+
'api_key' => 'bar',
4860+
'namespace' => 'random',
4861+
],
4862+
'my_cloudflare_message_store_with_new_endpoint' => [
4863+
'account_id' => 'foo',
4864+
'api_key' => 'bar',
4865+
'namespace' => 'random',
4866+
'endpoint_url' => 'https://api.cloudflare.com/client/v6/accounts',
4867+
],
4868+
],
47844869
'doctrine' => [
47854870
'dbal' => [
47864871
'default' => [

src/chat/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ CHANGELOG
66

77
* Introduce the component
88
* Add support for external message stores:
9+
- Symfony Cache
10+
- Cloudflare
911
- Doctrine
1012
- Meilisearch
1113
- MongoDb
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Chat\Bridge\Cloudflare;
13+
14+
use Symfony\AI\Chat\Exception\InvalidArgumentException;
15+
use Symfony\AI\Chat\ManagedStoreInterface;
16+
use Symfony\AI\Chat\MessageNormalizer;
17+
use Symfony\AI\Chat\MessageStoreInterface;
18+
use Symfony\AI\Platform\Message\MessageBag;
19+
use Symfony\AI\Platform\Message\MessageInterface;
20+
use Symfony\Component\Serializer\Encoder\JsonEncoder;
21+
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
22+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
23+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
24+
use Symfony\Component\Serializer\Serializer;
25+
use Symfony\Component\Serializer\SerializerInterface;
26+
use Symfony\Contracts\HttpClient\HttpClientInterface;
27+
28+
/**
29+
* @author Guillaume Loulier <personal@guillaumeloulier.fr>
30+
*/
31+
final class MessageStore implements ManagedStoreInterface, MessageStoreInterface
32+
{
33+
public function __construct(
34+
private readonly HttpClientInterface $httpClient,
35+
private readonly string $namespace,
36+
#[\SensitiveParameter] private readonly string $accountId,
37+
#[\SensitiveParameter] private readonly string $apiKey,
38+
private readonly SerializerInterface&NormalizerInterface&DenormalizerInterface $serializer = new Serializer([
39+
new ArrayDenormalizer(),
40+
new MessageNormalizer(),
41+
], [new JsonEncoder()]),
42+
private readonly string $endpointUrl = 'https://api.cloudflare.com/client/v4/accounts',
43+
) {
44+
}
45+
46+
public function setup(array $options = []): void
47+
{
48+
if ([] !== $options) {
49+
throw new InvalidArgumentException('No supported options.');
50+
}
51+
52+
$namespaces = $this->request('GET', 'storage/kv/namespaces');
53+
54+
$filteredNamespaces = array_filter(
55+
$namespaces['result'],
56+
fn (array $payload): bool => $payload['title'] === $this->namespace,
57+
);
58+
59+
if (0 !== \count($filteredNamespaces)) {
60+
return;
61+
}
62+
63+
$this->request('POST', 'storage/kv/namespaces', [
64+
'title' => $this->namespace,
65+
]);
66+
}
67+
68+
public function drop(): void
69+
{
70+
$currentNamespaceUuid = $this->retrieveCurrentNamespaceUuid();
71+
72+
$keys = $this->request('GET', \sprintf('storage/kv/namespaces/%s/keys', $currentNamespaceUuid));
73+
74+
if ([] === $keys['result']) {
75+
return;
76+
}
77+
78+
$this->request('POST', \sprintf('storage/kv/namespaces/%s/bulk/delete', $currentNamespaceUuid), array_map(
79+
static fn (array $payload): string => $payload['name'],
80+
$keys['result'],
81+
));
82+
}
83+
84+
public function save(MessageBag $messages): void
85+
{
86+
$currentNamespaceUuid = $this->retrieveCurrentNamespaceUuid();
87+
88+
$this->request('PUT', \sprintf('storage/kv/namespaces/%s/bulk', $currentNamespaceUuid), array_map(
89+
fn (MessageInterface $message): array => [
90+
'key' => $message->getId()->toRfc4122(),
91+
'value' => $this->serializer->serialize($message, 'json'),
92+
],
93+
$messages->getMessages(),
94+
));
95+
}
96+
97+
public function load(): MessageBag
98+
{
99+
$currentNamespaceUuid = $this->retrieveCurrentNamespaceUuid();
100+
101+
$keys = $this->request('GET', \sprintf('storage/kv/namespaces/%s/keys', $currentNamespaceUuid));
102+
103+
$messages = $this->request('POST', \sprintf('storage/kv/namespaces/%s/bulk/get', $currentNamespaceUuid), [
104+
'keys' => array_map(
105+
static fn (array $payload): string => $payload['name'],
106+
$keys['result'],
107+
),
108+
]);
109+
110+
return new MessageBag(...array_map(
111+
fn (string $message): MessageInterface => $this->serializer->deserialize($message, MessageInterface::class, 'json'),
112+
$messages['result']['values'],
113+
));
114+
}
115+
116+
/**
117+
* @param array<string, mixed>|list<array<string, string>> $payload
118+
*
119+
* @return array<string, mixed>
120+
*/
121+
private function request(string $method, string $endpoint, array $payload = []): array
122+
{
123+
$finalOptions = [
124+
'auth_bearer' => $this->apiKey,
125+
];
126+
127+
if ([] !== $payload) {
128+
$finalOptions['json'] = $payload;
129+
}
130+
131+
$response = $this->httpClient->request($method, \sprintf('%s/%s/%s', $this->endpointUrl, $this->accountId, $endpoint), $finalOptions);
132+
133+
return $response->toArray();
134+
}
135+
136+
private function retrieveCurrentNamespaceUuid(): string
137+
{
138+
$namespaces = $this->request('GET', 'storage/kv/namespaces');
139+
140+
$filteredNamespaces = array_filter(
141+
$namespaces['result'],
142+
fn (array $payload): bool => $payload['title'] === $this->namespace,
143+
);
144+
145+
if (0 === \count($filteredNamespaces)) {
146+
throw new InvalidArgumentException('No namespace found.');
147+
}
148+
149+
reset($filteredNamespaces);
150+
151+
return $filteredNamespaces[0]['id'];
152+
}
153+
}

0 commit comments

Comments
 (0)