Skip to content

Commit 064c45e

Browse files
committed
Migrate from OpenAI chat completions to Responses
Chat completions is deprecated. Responses is the recommended API to use now. See: https://platform.openai.com/docs/guides/migrate-to-responses
1 parent 0c5c68a commit 064c45e

File tree

10 files changed

+174
-174
lines changed

10 files changed

+174
-174
lines changed

examples/openai/audio-input.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,11 @@
99
* file that was distributed with this source code.
1010
*/
1111

12-
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
13-
use Symfony\AI\Platform\Message\Content\Audio;
14-
use Symfony\AI\Platform\Message\Message;
15-
use Symfony\AI\Platform\Message\MessageBag;
12+
use Symfony\AI\Platform\Exception\RuntimeException;
1613

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

19-
throw new RuntimeException('This example is temporarily unavailable due to migration to Responses API (which does not support audio yet)');
16+
throw new RuntimeException('This example is temporarily unavailable due to migration to Responses API (which does not support audio yet).');
2017
// $platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
2118
//
2219
// $messages = new MessageBag(
@@ -25,6 +22,7 @@
2522
// Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3'),
2623
// ),
2724
// );
25+
//
2826
// $result = $platform->invoke('gpt-4o-audio-preview', $messages);
2927
//
3028
// echo $result->asText().\PHP_EOL;

examples/openai/chat-with-string-options.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@
2121
Message::forSystem('You are a pirate and you write funny.'),
2222
Message::ofUser('What is the Symfony framework?'),
2323
);
24-
$result = $platform->invoke('gpt-4o-mini?max_tokens=7', $messages);
24+
$result = $platform->invoke('gpt-4o-mini?max_output_tokens=16', $messages);
2525

2626
echo $result->asText().\PHP_EOL;

examples/openai/chat.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
Message::ofUser('What is the Symfony framework?'),
2323
);
2424
$result = $platform->invoke('gpt-4o-mini', $messages, [
25-
'max_tokens' => 500, // specific options just for this call
25+
'max_output_tokens' => 500, // specific options just for this call
2626
]);
2727

2828
echo $result->asText().\PHP_EOL;

examples/openai/structured-output-clock.php

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,19 @@
3333
$agent = new Agent($platform, 'gpt-4o-mini', [$toolProcessor], [$toolProcessor]);
3434

3535
$messages = new MessageBag(Message::ofUser('What date and time is it?'));
36-
$result = $agent->call($messages, ['response_format' => [
36+
$result = $agent->call($messages, ['text' => ['format' => [
3737
'type' => 'json_schema',
38-
'json_schema' => [
39-
'name' => 'clock',
40-
'strict' => true,
41-
'schema' => [
42-
'type' => 'object',
43-
'properties' => [
44-
'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'],
45-
'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'],
46-
],
47-
'required' => ['date', 'time'],
48-
'additionalProperties' => false,
38+
'name' => 'clock',
39+
'strict' => true,
40+
'schema' => [
41+
'type' => 'object',
42+
'properties' => [
43+
'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'],
44+
'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'],
4945
],
46+
'required' => ['date', 'time'],
47+
'additionalProperties' => false,
5048
],
51-
]]);
49+
]]]);
5250

5351
dump($result->getContent());

examples/openai/token-metadata.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
Message::ofUser('What is the Symfony framework?'),
2626
);
2727
$result = $agent->call($messages, [
28-
'max_tokens' => 500, // specific options just for this call
28+
'max_output_tokens' => 500, // specific options just for this call
2929
]);
3030

3131
print_token_usage($result->getMetadata());

src/platform/src/Bridge/OpenAi/Contract/OpenAiContract.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,16 @@ final class OpenAiContract extends Contract
2323
public static function create(NormalizerInterface ...$normalizer): Contract
2424
{
2525
return parent::create(
26+
new Gpt\Message\MessageBagNormalizer(),
27+
new Gpt\Message\AssistantMessageNormalizer(),
28+
new Gpt\Message\Content\ImageNormalizer(),
29+
new Gpt\Message\Content\ImageUrlNormalizer(),
30+
new Gpt\Message\Content\TextNormalizer(),
31+
new Gpt\ToolNormalizer(),
32+
new Gpt\ToolCallNormalizer(),
33+
new Gpt\Message\ToolCallMessageNormalizer(),
34+
new Gpt\Message\Content\DocumentNormalizer(),
2635
new AudioNormalizer(),
27-
new DocumentNormalizer(),
2836
...$normalizer
2937
);
3038
}

src/platform/src/Bridge/OpenAi/Gpt/ModelClient.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\AI\Platform\Model;
1717
use Symfony\AI\Platform\ModelClientInterface;
1818
use Symfony\AI\Platform\Result\RawHttpResult;
19+
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
1920
use Symfony\Component\HttpClient\EventSourceHttpClient;
2021
use Symfony\Contracts\HttpClient\HttpClientInterface;
2122

@@ -42,9 +43,18 @@ public function supports(Model $model): bool
4243

4344
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
4445
{
45-
return new RawHttpResult($this->httpClient->request('POST', self::getBaseUrl($this->region).'/v1/chat/completions', [
46+
if (isset($options[PlatformSubscriber::RESPONSE_FORMAT]['json_schema']['schema'])) {
47+
$schema = $options[PlatformSubscriber::RESPONSE_FORMAT]['json_schema'];
48+
$options['text']['format'] = $schema;
49+
$options['text']['format']['name'] = $schema['name'];
50+
$options['text']['format']['type'] = $options[PlatformSubscriber::RESPONSE_FORMAT]['type'];
51+
52+
unset($options[PlatformSubscriber::RESPONSE_FORMAT]);
53+
}
54+
55+
return new RawHttpResult($this->httpClient->request('POST', self::getBaseUrl($this->region).'/v1/responses', [
4656
'auth_bearer' => $this->apiKey,
47-
'json' => array_merge($options, $payload),
57+
'json' => array_merge($options, ['model' => $model->getName()], $payload),
4858
]));
4959
}
5060
}

src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php

Lines changed: 88 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,17 @@
3131
/**
3232
* @author Christopher Hertel <[email protected]>
3333
* @author Denis Zunke <[email protected]>
34+
*
35+
* @phpstan-type OutputMessage array{content: array<Refusal|OutputText>, id: string, role: string, type: 'message'}
36+
* @phpstan-type OutputText array{type: 'output_text', text: string}
37+
* @phpstan-type Refusal array{type: 'refusal', refusal: string}
38+
* @phpstan-type FunctionCall array{id: string, arguments: string, call_id: string, name: string, type: 'function_call'}
39+
* @phpstan-type Reasoning array{summary: array{text?: string}, id: string}
3440
*/
3541
final class ResultConverter implements ResultConverterInterface
3642
{
43+
private const KEY_OUTPUT = 'output';
44+
3745
public function supports(Model $model): bool
3846
{
3947
return $model instanceof Gpt;
@@ -76,128 +84,114 @@ public function convert(RawResultInterface|RawHttpResult $result, array $options
7684
throw new RuntimeException(\sprintf('Error "%s"-%s (%s): "%s".', $data['error']['code'], $data['error']['type'], $data['error']['param'], $data['error']['message']));
7785
}
7886

79-
if (!isset($data['choices'])) {
80-
throw new RuntimeException('Response does not contain choices.');
87+
if (!isset($data[self::KEY_OUTPUT])) {
88+
throw new RuntimeException('Response does not contain output.');
8189
}
8290

83-
$choices = array_map($this->convertChoice(...), $data['choices']);
91+
$results = $this->convertOutputArray($data[self::KEY_OUTPUT]);
8492

85-
return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices);
93+
return 1 === \count($results) ? array_pop($results) : new ChoiceResult(...$results);
8694
}
8795

88-
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
96+
/**
97+
* @param array<OutputMessage|FunctionCall|Reasoning> $output
98+
*
99+
* @return ResultInterface[]
100+
*/
101+
private function convertOutputArray(array $output): array
89102
{
90-
$toolCalls = [];
91-
foreach ($result->getDataStream() as $data) {
92-
if ($this->streamIsToolCall($data)) {
93-
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
94-
}
103+
[$toolCallResult, $output] = $this->extractFunctionCalls($output);
95104

96-
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
97-
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
98-
}
99-
100-
if (!isset($data['choices'][0]['delta']['content'])) {
101-
continue;
102-
}
103-
104-
yield $data['choices'][0]['delta']['content'];
105+
$results = array_filter(array_map($this->processOutputItem(...), $output));
106+
if ($toolCallResult) {
107+
$results[] = $toolCallResult;
105108
}
109+
110+
return $results;
106111
}
107112

108113
/**
109-
* @param array<string, mixed> $toolCalls
110-
* @param array<string, mixed> $data
111-
*
112-
* @return array<string, mixed>
114+
* @param OutputMessage|Reasoning $item
113115
*/
114-
private function convertStreamToToolCalls(array $toolCalls, array $data): array
116+
private function processOutputItem(array $item): ?ResultInterface
115117
{
116-
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
117-
return $toolCalls;
118-
}
118+
$type = $item['type'] ?? null;
119119

120-
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
121-
if (isset($toolCall['id'])) {
122-
// initialize tool call
123-
$toolCalls[$i] = [
124-
'id' => $toolCall['id'],
125-
'function' => $toolCall['function'],
126-
];
120+
return match ($type) {
121+
'message' => $this->convertOutputMessage($item),
122+
'reasoning' => $this->convertReasoning($item),
123+
default => throw new RuntimeException(\sprintf('Unsupported output type "%s".', $type)),
124+
};
125+
}
126+
127+
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
128+
{
129+
foreach ($result->getDataStream() as $event) {
130+
if (isset($event['delta'])) {
131+
yield $event['delta'];
132+
}
133+
if (!str_contains('completed', $event['type'] ?? '')) {
127134
continue;
128135
}
129136

130-
// add arguments delta to tool call
131-
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
132-
}
137+
[$toolCallResult] = $this->extractFunctionCalls($event['response'][self::KEY_OUTPUT] ?? []);
133138

134-
return $toolCalls;
139+
if ($toolCallResult && 'response.completed' === $event['type']) {
140+
yield $toolCallResult;
141+
}
142+
}
135143
}
136144

137145
/**
138-
* @param array<string, mixed> $data
146+
* @param array<OutputMessage|FunctionCall|Reasoning> $output
147+
*
148+
* @return list<ToolCallResult|array<OutputMessage|Reasoning>|null>
139149
*/
140-
private function streamIsToolCall(array $data): bool
150+
private function extractFunctionCalls(array $output): array
141151
{
142-
return isset($data['choices'][0]['delta']['tool_calls']);
143-
}
152+
$functionCalls = [];
153+
foreach ($output as $key => $item) {
154+
if ('function_call' === ($item['type'] ?? null)) {
155+
$functionCalls[] = $item;
156+
unset($output[$key]);
157+
}
158+
}
144159

145-
/**
146-
* @param array<string, mixed> $data
147-
*/
148-
private function isToolCallsStreamFinished(array $data): bool
149-
{
150-
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
160+
$toolCallResult = $functionCalls ? new ToolCallResult(
161+
...array_map($this->convertFunctionCall(...), $functionCalls)
162+
) : null;
163+
164+
return [$toolCallResult, $output];
151165
}
152166

153167
/**
154-
* @param array{
155-
* index: int,
156-
* message: array{
157-
* role: 'assistant',
158-
* content: ?string,
159-
* tool_calls: array{
160-
* id: string,
161-
* type: 'function',
162-
* function: array{
163-
* name: string,
164-
* arguments: string
165-
* },
166-
* },
167-
* refusal: ?mixed
168-
* },
169-
* logprobs: string,
170-
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
171-
* } $choice
168+
* @param OutputMessage $output
172169
*/
173-
private function convertChoice(array $choice): ToolCallResult|TextResult
170+
private function convertOutputMessage(array $output): ?TextResult
174171
{
175-
if ('tool_calls' === $choice['finish_reason']) {
176-
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
172+
$content = $output['content'] ?? [];
173+
if ([] === $content) {
174+
return null;
177175
}
178176

179-
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
180-
return new TextResult($choice['message']['content']);
177+
$content = array_pop($content);
178+
if ('refusal' === $content['type']) {
179+
return new TextResult(\sprintf('Model refused to generate output: %s', $content['refusal']));
181180
}
182181

183-
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
182+
return new TextResult($content['text']);
184183
}
185184

186185
/**
187-
* @param array{
188-
* id: string,
189-
* type: 'function',
190-
* function: array{
191-
* name: string,
192-
* arguments: string
193-
* }
194-
* } $toolCall
186+
* @param FunctionCall $toolCall
187+
*
188+
* @throws \JsonException
195189
*/
196-
private function convertToolCall(array $toolCall): ToolCall
190+
private function convertFunctionCall(array $toolCall): ToolCall
197191
{
198-
$arguments = json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR);
192+
$arguments = json_decode($toolCall['arguments'], true, flags: \JSON_THROW_ON_ERROR);
199193

200-
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
194+
return new ToolCall($toolCall['id'], $toolCall['name'], $arguments);
201195
}
202196

203197
/**
@@ -219,4 +213,15 @@ private static function parseResetTime(string $resetTime): ?int
219213

220214
return null;
221215
}
216+
217+
/**
218+
* @param Reasoning $item
219+
*/
220+
private function convertReasoning(array $item): ?ResultInterface
221+
{
222+
// Reasoning is sometimes missing if it exceeds the context limit.
223+
$summary = $item['summary']['text'] ?? null;
224+
225+
return $summary ? new TextResult($summary) : null;
226+
}
222227
}

0 commit comments

Comments
 (0)