Skip to content

Commit 0c5c68a

Browse files
committed
Create Responses normalizers for message types
To migrate from OpenAI's chat completions to Responses, we need to change incoming messages to the updated data structure
1 parent ff9e3a4 commit 0c5c68a

File tree

9 files changed

+504
-2
lines changed

9 files changed

+504
-2
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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\Platform\Bridge\OpenAi\Contract\Gpt\Message;
13+
14+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
15+
use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer;
16+
use Symfony\AI\Platform\Message\AssistantMessage;
17+
use Symfony\AI\Platform\Model;
18+
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
19+
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
20+
21+
/**
22+
* @author Guillermo Lengemann <[email protected]>
23+
*/
24+
final class AssistantMessageNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface
25+
{
26+
use NormalizerAwareTrait;
27+
28+
/**
29+
* @param AssistantMessage $data
30+
*
31+
* @return array{
32+
* role: 'assistant',
33+
* type: 'message',
34+
* content: ?string
35+
* }
36+
*/
37+
public function normalize(mixed $data, ?string $format = null, array $context = []): array
38+
{
39+
if ($data->hasToolCalls()) {
40+
return $this->normalizer->normalize($data->getToolCalls(), $format, $context);
41+
}
42+
43+
return [
44+
'role' => $data->getRole()->value,
45+
'type' => 'message',
46+
'content' => $data->getContent(),
47+
];
48+
}
49+
50+
protected function supportedDataClass(): string
51+
{
52+
return AssistantMessage::class;
53+
}
54+
55+
protected function supportsModel(Model $model): bool
56+
{
57+
return $model instanceof Gpt;
58+
}
59+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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\Platform\Bridge\OpenAi\Contract\Gpt\Message;
13+
14+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
15+
use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer;
16+
use Symfony\AI\Platform\Message\AssistantMessage;
17+
use Symfony\AI\Platform\Message\MessageBag;
18+
use Symfony\AI\Platform\Model;
19+
use Symfony\Component\Serializer\Exception\ExceptionInterface;
20+
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
21+
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
22+
23+
/**
24+
* @author Pauline Vos <[email protected]>
25+
*/
26+
final class MessageBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface
27+
{
28+
use NormalizerAwareTrait;
29+
30+
/**
31+
* @param MessageBag $data
32+
*
33+
* @return array{
34+
* input: array<string, mixed>,
35+
* model?: string,
36+
* system?: string,
37+
* }
38+
*
39+
* @throws ExceptionInterface
40+
*/
41+
public function normalize(mixed $data, ?string $format = null, array $context = []): array
42+
{
43+
$messages['input'] = [];
44+
45+
foreach ($data->withoutSystemMessage()->getMessages() as $message) {
46+
$normalized = $this->normalizer->normalize($message, $format, $context);
47+
48+
if ($message instanceof AssistantMessage && $message->hasToolCalls()) {
49+
$messages['input'] = array_merge($messages['input'], $normalized);
50+
continue;
51+
}
52+
53+
$messages['input'][] = $normalized;
54+
}
55+
56+
if ($data->getSystemMessage()) {
57+
$messages['instructions'] = $data->getSystemMessage()->getContent();
58+
}
59+
60+
return $messages;
61+
}
62+
63+
protected function supportedDataClass(): string
64+
{
65+
return MessageBag::class;
66+
}
67+
68+
protected function supportsModel(Model $model): bool
69+
{
70+
return $model instanceof Gpt;
71+
}
72+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\Platform\Bridge\OpenAi\Contract\Gpt\Message;
13+
14+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
15+
use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer;
16+
use Symfony\AI\Platform\Message\ToolCallMessage;
17+
use Symfony\AI\Platform\Model;
18+
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
19+
20+
/**
21+
* @author Christopher Hertel <[email protected]>
22+
*/
23+
final class ToolCallMessageNormalizer extends ModelContractNormalizer
24+
{
25+
use NormalizerAwareTrait;
26+
27+
/**
28+
* @param ToolCallMessage $data
29+
*
30+
* @return array{
31+
* type: 'function_call_output',
32+
* call_id: string,
33+
* output: string
34+
* }
35+
*/
36+
public function normalize(mixed $data, ?string $format = null, array $context = []): array
37+
{
38+
return [
39+
'type' => 'function_call_output',
40+
'call_id' => $data->getToolCall()->getId(),
41+
'output' => $data->getContent(),
42+
];
43+
}
44+
45+
protected function supportedDataClass(): string
46+
{
47+
return ToolCallMessage::class;
48+
}
49+
50+
protected function supportsModel(Model $model): bool
51+
{
52+
return $model instanceof Gpt;
53+
}
54+
}

src/platform/src/Message/MessageBag.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ public function getSystemMessage(): ?SystemMessage
5454
return null;
5555
}
5656

57+
/**
58+
* @return AssistantMessage[]
59+
*/
60+
public function getAssistantMessages(): array
61+
{
62+
return array_filter($this->messages, function (MessageInterface $message) {
63+
return $message instanceof AssistantMessage;
64+
});
65+
}
66+
5767
public function getUserMessage(): ?UserMessage
5868
{
5969
foreach ($this->messages as $message) {
@@ -92,6 +102,39 @@ public function withoutSystemMessage(): self
92102
return $messages;
93103
}
94104

105+
/**
106+
* @return ToolCallMessage[]
107+
*/
108+
public function getToolCallMessages(): array
109+
{
110+
return array_filter(
111+
$this->messages,
112+
static fn (MessageInterface $message) => $message instanceof ToolCallMessage,
113+
);
114+
}
115+
116+
public function withoutToolCallMessages(): self
117+
{
118+
$messages = clone $this;
119+
$messages->messages = array_values(array_filter(
120+
$messages->messages,
121+
static fn (MessageInterface $message) => !$message instanceof ToolCallMessage,
122+
));
123+
124+
return $messages;
125+
}
126+
127+
public function withoutAssistantMessages(): self
128+
{
129+
$messages = clone $this;
130+
$messages->messages = array_values(array_filter(
131+
$messages->messages,
132+
static fn (MessageInterface $message) => !$message instanceof AssistantMessage,
133+
));
134+
135+
return $messages;
136+
}
137+
95138
public function prepend(MessageInterface $message): self
96139
{
97140
$messages = clone $this;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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\Platform\Tests\Bridge\OpenAi\Contract\Gpt\Message;
13+
14+
use PHPUnit\Framework\Attributes\DataProvider;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\AI\Platform\Bridge\Gemini\Gemini;
17+
use Symfony\AI\Platform\Bridge\OpenAi\Contract\Gpt\Message\AssistantMessageNormalizer;
18+
use Symfony\AI\Platform\Bridge\OpenAi\Contract\Gpt\ToolCallNormalizer;
19+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
20+
use Symfony\AI\Platform\Contract;
21+
use Symfony\AI\Platform\Message\AssistantMessage;
22+
use Symfony\AI\Platform\Message\Content\Text;
23+
use Symfony\AI\Platform\Message\Message;
24+
use Symfony\AI\Platform\Model;
25+
use Symfony\AI\Platform\Result\ToolCall;
26+
use Symfony\Component\Serializer\Serializer;
27+
28+
class AssistantMessageNormalizerTest extends TestCase
29+
{
30+
#[DataProvider('normalizeProvider')]
31+
public function testNormalize(AssistantMessage $message, array $expected)
32+
{
33+
$normalizer = new AssistantMessageNormalizer();
34+
$normalizer->setNormalizer(new Serializer([new ToolCallNormalizer()]));
35+
36+
$actual = $normalizer->normalize($message, null, [Contract::CONTEXT_MODEL => new Gpt('o3')]);
37+
$this->assertEquals($expected, $actual);
38+
}
39+
40+
public static function normalizeProvider(): \Generator
41+
{
42+
$message = Message::ofAssistant('Foo');
43+
yield 'without tool calls' => [
44+
$message,
45+
[
46+
'role' => 'assistant',
47+
'type' => 'message',
48+
'content' => 'Foo',
49+
],
50+
];
51+
52+
$toolCall = new ToolCall('some-id', 'roll-die', ['sides' => 24]);
53+
yield 'with tool calls' => [
54+
Message::ofAssistant(null, [$toolCall]),
55+
[
56+
[
57+
'arguments' => json_encode($toolCall->getArguments()),
58+
'call_id' => $toolCall->getId(),
59+
'name' => $toolCall->getName(),
60+
'type' => 'function_call',
61+
],
62+
],
63+
];
64+
}
65+
66+
#[DataProvider('supportsNormalizationProvider')]
67+
public function testSupportsNormalization(mixed $data, Model $model, bool $expected)
68+
{
69+
$this->assertSame(
70+
$expected,
71+
(new AssistantMessageNormalizer())->supportsNormalization($data, null, [Contract::CONTEXT_MODEL => $model])
72+
);
73+
}
74+
75+
public static function supportsNormalizationProvider(): \Generator
76+
{
77+
$assistantMessage = Message::ofAssistant('Foo');
78+
$gpt = new Gpt('o3');
79+
80+
yield 'supported' => [$assistantMessage, $gpt, true];
81+
yield 'unsupported model' => [$assistantMessage, new Gemini('foo'), false];
82+
yield 'unsupported data' => [new Text('foo'), $gpt, false];
83+
}
84+
}

0 commit comments

Comments
 (0)