Skip to content

Commit 4ae8a9f

Browse files
committed
feat(chat): Introduce Cloudflare message store
1 parent bcd859f commit 4ae8a9f

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 <[email protected]>
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
@@ -844,6 +844,19 @@
844844
->end()
845845
->end()
846846
->end()
847+
->arrayNode('cloudflare')
848+
->useAttributeAsKey('name')
849+
->arrayPrototype()
850+
->children()
851+
->stringNode('account_id')->cannotBeEmpty()->end()
852+
->stringNode('api_key')->cannotBeEmpty()->end()
853+
->stringNode('namespace')->cannotBeEmpty()->end()
854+
->stringNode('endpoint_url')
855+
->info('If the version of the Cloudflare API is updated, use this key to support it.')
856+
->end()
857+
->end()
858+
->end()
859+
->end()
847860
->arrayNode('doctrine')
848861
->children()
849862
->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;
@@ -1625,6 +1626,34 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
16251626
}
16261627
}
16271628

1629+
if ('cloudflare' === $type) {
1630+
foreach ($messageStores as $name => $messageStore) {
1631+
$arguments = [
1632+
new Reference('http_client'),
1633+
$messageStore['namespace'],
1634+
$messageStore['account_id'],
1635+
$messageStore['api_key'],
1636+
new Reference('serializer'),
1637+
];
1638+
1639+
if (\array_key_exists('endpoint_url', $messageStore)) {
1640+
$arguments[5] = $messageStore['endpoint_url'];
1641+
}
1642+
1643+
$definition = new Definition(CloudflareMessageStore::class);
1644+
$definition
1645+
->setLazy(true)
1646+
->setArguments($arguments)
1647+
->addTag('proxy', ['interface' => MessageStoreInterface::class])
1648+
->addTag('proxy', ['interface' => ManagedMessageStoreInterface::class])
1649+
->addTag('ai.message_store');
1650+
1651+
$container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition);
1652+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
1653+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
1654+
}
1655+
}
1656+
16281657
if ('doctrine' === $type) {
16291658
foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) {
16301659
$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
@@ -3374,6 +3374,78 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl()
33743374
$this->assertTrue($definition->hasTag('ai.message_store'));
33753375
}
33763376

3377+
public function testCloudflareMessageStoreIsConfigured()
3378+
{
3379+
$container = $this->buildContainer([
3380+
'ai' => [
3381+
'message_store' => [
3382+
'cloudflare' => [
3383+
'my_cloudflare_message_store' => [
3384+
'account_id' => 'foo',
3385+
'api_key' => 'bar',
3386+
'namespace' => 'random',
3387+
],
3388+
],
3389+
],
3390+
],
3391+
]);
3392+
3393+
$definition = $container->getDefinition('ai.message_store.cloudflare.my_cloudflare_message_store');
3394+
3395+
$this->assertTrue($definition->isLazy());
3396+
$this->assertCount(5, $definition->getArguments());
3397+
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
3398+
$this->assertSame('http_client', (string) $definition->getArgument(0));
3399+
$this->assertSame('random', $definition->getArgument(1));
3400+
$this->assertSame('foo', $definition->getArgument(2));
3401+
$this->assertSame('bar', $definition->getArgument(3));
3402+
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
3403+
$this->assertSame('serializer', (string) $definition->getArgument(4));
3404+
3405+
$this->assertSame([
3406+
['interface' => MessageStoreInterface::class],
3407+
['interface' => ManagedMessageStoreInterface::class],
3408+
], $definition->getTag('proxy'));
3409+
$this->assertTrue($definition->hasTag('ai.message_store'));
3410+
}
3411+
3412+
public function testCloudflareMessageStoreWithCustomEndpointIsConfigured()
3413+
{
3414+
$container = $this->buildContainer([
3415+
'ai' => [
3416+
'message_store' => [
3417+
'cloudflare' => [
3418+
'my_cloudflare_message_store_with_new_endpoint' => [
3419+
'account_id' => 'foo',
3420+
'api_key' => 'bar',
3421+
'namespace' => 'random',
3422+
'endpoint_url' => 'https://api.cloudflare.com/client/v6/accounts',
3423+
],
3424+
],
3425+
],
3426+
],
3427+
]);
3428+
3429+
$definition = $container->getDefinition('ai.message_store.cloudflare.my_cloudflare_message_store_with_new_endpoint');
3430+
3431+
$this->assertTrue($definition->isLazy());
3432+
$this->assertCount(6, $definition->getArguments());
3433+
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
3434+
$this->assertSame('http_client', (string) $definition->getArgument(0));
3435+
$this->assertSame('random', $definition->getArgument(1));
3436+
$this->assertSame('foo', $definition->getArgument(2));
3437+
$this->assertSame('bar', $definition->getArgument(3));
3438+
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
3439+
$this->assertSame('serializer', (string) $definition->getArgument(4));
3440+
$this->assertSame('https://api.cloudflare.com/client/v6/accounts', $definition->getArgument(5));
3441+
3442+
$this->assertSame([
3443+
['interface' => MessageStoreInterface::class],
3444+
['interface' => ManagedMessageStoreInterface::class],
3445+
], $definition->getTag('proxy'));
3446+
$this->assertTrue($definition->hasTag('ai.message_store'));
3447+
}
3448+
33773449
public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey()
33783450
{
33793451
$container = $this->buildContainer([
@@ -4321,6 +4393,19 @@ private function getFullConfig(): array
43214393
'key' => 'foo',
43224394
],
43234395
],
4396+
'cloudflare' => [
4397+
'my_cloudflare_message_store' => [
4398+
'account_id' => 'foo',
4399+
'api_key' => 'bar',
4400+
'namespace' => 'random',
4401+
],
4402+
'my_cloudflare_message_store_with_new_endpoint' => [
4403+
'account_id' => 'foo',
4404+
'api_key' => 'bar',
4405+
'namespace' => 'random',
4406+
'endpoint_url' => 'https://api.cloudflare.com/client/v6/accounts',
4407+
],
4408+
],
43244409
'doctrine' => [
43254410
'dbal' => [
43264411
'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 <[email protected]>
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 <[email protected]>
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)