Skip to content

Commit fb21a8a

Browse files
authored
feat(openai): Responses API (#256)
1 parent d543756 commit fb21a8a

31 files changed

+759
-552
lines changed

docs/providers/openai.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ $response = Prism::structured()
4646
]) // [!code focus]
4747
```
4848

49+
### Previous Responses
50+
51+
Prism supports OpenAI's [conversation state](https://platform.openai.com/docs/guides/conversation-state#openai-apis-for-conversation-state) with the `previous_response_id` parameter.
52+
53+
```php
54+
$response = Prism::structured()
55+
->withProviderOptions([ // [!code focus]
56+
'previous_response_id' => 'response_id' // [!code focus]
57+
]) // [!code focus]
58+
```
59+
60+
### Truncation
61+
62+
```php
63+
$response = Prism::structured()
64+
->withProviderOptions([ // [!code focus]
65+
'truncation' => 'auto' // [!code focus]
66+
]) // [!code focus]
67+
```
68+
4969
### Caching
5070

5171
Automatic caching does not currently work with JsonMode. Please ensure you use StructuredMode if you wish to utilise automatic caching.

src/Concerns/CallsTools.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function (ToolCall $toolCall) use ($tools): ToolResult {
3333

3434
return new ToolResult(
3535
toolCallId: $toolCall->id,
36+
toolCallResultId: $toolCall->resultId,
3637
toolName: $toolCall->name,
3738
args: $toolCall->arguments(),
3839
result: $result,

src/Providers/OpenAI/Concerns/MapsFinishReason.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ trait MapsFinishReason
1414
*/
1515
protected function mapFinishReason(array $data): FinishReason
1616
{
17-
return FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', ''));
17+
return FinishReasonMap::map(
18+
data_get($data, 'output.{last}.status', ''),
19+
data_get($data, 'output.{last}.type', ''),
20+
);
1821
}
1922
}

src/Providers/OpenAI/Handlers/Structured.php

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ public function handle(Request $request): StructuredResponse
4848

4949
$data = $response->json();
5050

51-
$this->handleRefusal(data_get($data, 'choices.0.message', []));
51+
$this->handleRefusal(data_get($data, 'output.{last}.content.0', []));
5252

5353
$responseMessage = new AssistantMessage(
54-
data_get($data, 'choices.0.message.content') ?? '',
54+
data_get($data, 'output.{last}.content.0.text') ?? '',
5555
);
5656

5757
$this->responseBuilder->addResponseMessage($responseMessage);
@@ -69,12 +69,13 @@ public function handle(Request $request): StructuredResponse
6969
protected function addStep(array $data, Request $request, ClientResponse $clientResponse): void
7070
{
7171
$this->responseBuilder->addStep(new Step(
72-
text: data_get($data, 'choices.0.message.content') ?? '',
72+
text: data_get($data, 'output.{last}.content.0.text') ?? '',
7373
finishReason: $this->mapFinishReason($data),
7474
usage: new Usage(
75-
promptTokens: data_get($data, 'usage.prompt_tokens', 0) - data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0),
76-
completionTokens: data_get($data, 'usage.completion_tokens'),
77-
cacheReadInputTokens: data_get($data, 'usage.prompt_tokens_details.cached_tokens'),
75+
promptTokens: data_get($data, 'usage.input_tokens', 0) - data_get($data, 'usage.input_tokens_details.cached_tokens', 0),
76+
completionTokens: data_get($data, 'usage.output_tokens'),
77+
cacheReadInputTokens: data_get($data, 'usage.input_tokens_details.cached_tokens'),
78+
thoughtTokens: data_get($data, 'usage.output_token_details.reasoning_tokens'),
7879
),
7980
meta: new Meta(
8081
id: data_get($data, 'id'),
@@ -88,22 +89,26 @@ protected function addStep(array $data, Request $request, ClientResponse $client
8889
}
8990

9091
/**
91-
* @param array{type: 'json_schema', json_schema: array<string, mixed>}|array{type: 'json_object'} $responseFormat
92+
* @param array{type: 'json_schema', name: string, schema: array<mixed>, strict?: bool}|array{type: 'json_object'} $responseFormat
9293
*/
9394
protected function sendRequest(Request $request, array $responseFormat): ClientResponse
9495
{
9596
try {
9697
return $this->client->post(
97-
'chat/completions',
98+
'responses',
9899
array_merge([
99100
'model' => $request->model(),
100-
'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
101-
'max_completion_tokens' => $request->maxTokens(),
101+
'input' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
102+
'max_output_tokens' => $request->maxTokens(),
102103
], Arr::whereNotNull([
103104
'temperature' => $request->temperature(),
104105
'top_p' => $request->topP(),
105106
'metadata' => $request->providerOptions('metadata'),
106-
'response_format' => $responseFormat,
107+
'previous_response_id' => $request->providerOptions('previous_response_id'),
108+
'truncation' => $request->providerOptions('truncation'),
109+
'text' => [
110+
'format' => $responseFormat,
111+
],
107112
]))
108113
);
109114
} catch (Throwable $e) {
@@ -130,14 +135,15 @@ protected function handleStructuredMode(Request $request): ClientResponse
130135
throw new PrismException(sprintf('%s model does not support structured mode', $request->model()));
131136
}
132137

133-
return $this->sendRequest($request, [
138+
/** @var array{type: 'json_schema', name: string, schema: array<mixed>, strict?: bool} $responseFormat */
139+
$responseFormat = Arr::whereNotNull([
134140
'type' => 'json_schema',
135-
'json_schema' => Arr::whereNotNull([
136-
'name' => $request->schema()->name(),
137-
'schema' => $request->schema()->toArray(),
138-
'strict' => $request->providerOptions('schema.strict') ? true : null,
139-
]),
141+
'name' => $request->schema()->name(),
142+
'schema' => $request->schema()->toArray(),
143+
'strict' => $request->providerOptions('schema.strict') ? true : null,
140144
]);
145+
146+
return $this->sendRequest($request, $responseFormat);
141147
}
142148

143149
protected function handleJsonMode(Request $request): ClientResponse
@@ -154,8 +160,8 @@ protected function handleJsonMode(Request $request): ClientResponse
154160
*/
155161
protected function handleRefusal(array $message): void
156162
{
157-
if (! is_null(data_get($message, 'refusal', null))) {
158-
throw new PrismException(sprintf('OpenAI Refusal: %s', $message['refusal']));
163+
if (data_get($message, 'type') === 'refusal') {
164+
throw new PrismException(sprintf('OpenAI Refusal: %s', $message['refusal'] ?? 'Reason unknown.'));
159165
}
160166
}
161167

src/Providers/OpenAI/Handlers/Text.php

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ public function handle(Request $request): Response
5151
$data = $response->json();
5252

5353
$responseMessage = new AssistantMessage(
54-
data_get($data, 'choices.0.message.content') ?? '',
55-
ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
54+
data_get($data, 'output.{last}.content.0.text') ?? '',
55+
ToolCallMap::map(
56+
array_filter(data_get($data, 'output', []), fn (array $output): bool => $output['type'] === 'function_call'),
57+
array_filter(data_get($data, 'output', []), fn (array $output): bool => $output['type'] === 'reasoning'),
58+
),
5659
);
5760

5861
$this->responseBuilder->addResponseMessage($responseMessage);
@@ -73,7 +76,7 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse
7376
{
7477
$toolResults = $this->callTools(
7578
$request->tools(),
76-
ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
79+
ToolCallMap::map(array_filter(data_get($data, 'output', []), fn (array $output): bool => $output['type'] === 'function_call')),
7780
);
7881

7982
$request->addMessage(new ToolResultMessage($toolResults));
@@ -106,17 +109,19 @@ protected function sendRequest(Request $request): ClientResponse
106109
{
107110
try {
108111
return $this->client->post(
109-
'chat/completions',
112+
'responses',
110113
array_merge([
111114
'model' => $request->model(),
112-
'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
113-
'max_completion_tokens' => $request->maxTokens(),
115+
'input' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
116+
'max_output_tokens' => $request->maxTokens(),
114117
], Arr::whereNotNull([
115118
'temperature' => $request->temperature(),
116119
'top_p' => $request->topP(),
117120
'metadata' => $request->providerOptions('metadata'),
118121
'tools' => ToolMap::map($request->tools()),
119122
'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
123+
'previous_response_id' => $request->providerOptions('previous_response_id'),
124+
'truncation' => $request->providerOptions('truncation'),
120125
]))
121126
);
122127
} catch (Throwable $e) {
@@ -131,14 +136,15 @@ protected function sendRequest(Request $request): ClientResponse
131136
protected function addStep(array $data, Request $request, ClientResponse $clientResponse, array $toolResults = []): void
132137
{
133138
$this->responseBuilder->addStep(new Step(
134-
text: data_get($data, 'choices.0.message.content') ?? '',
139+
text: data_get($data, 'output.{last}.content.0.text') ?? '',
135140
finishReason: $this->mapFinishReason($data),
136-
toolCalls: ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
141+
toolCalls: ToolCallMap::map(array_filter(data_get($data, 'output', []), fn (array $output): bool => $output['type'] === 'function_call')),
137142
toolResults: $toolResults,
138143
usage: new Usage(
139-
promptTokens: data_get($data, 'usage.prompt_tokens', 0) - data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0),
140-
completionTokens: data_get($data, 'usage.completion_tokens'),
141-
cacheReadInputTokens: data_get($data, 'usage.prompt_tokens_details.cached_tokens'),
144+
promptTokens: data_get($data, 'usage.input_tokens', 0) - data_get($data, 'usage.input_tokens_details.cached_tokens', 0),
145+
completionTokens: data_get($data, 'usage.output_tokens'),
146+
cacheReadInputTokens: data_get($data, 'usage.input_tokens_details.cached_tokens'),
147+
thoughtTokens: data_get($data, 'usage.output_token_details.reasoning_tokens'),
142148
),
143149
meta: new Meta(
144150
id: data_get($data, 'id'),

src/Providers/OpenAI/Maps/FinishReasonMap.php

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,29 @@
88

99
class FinishReasonMap
1010
{
11-
public static function map(string $reason): FinishReason
11+
public static function map(string $status, ?string $type = null): FinishReason
1212
{
13-
return match ($reason) {
14-
'stop', => FinishReason::Stop,
15-
'tool_calls' => FinishReason::ToolCalls,
16-
'length' => FinishReason::Length,
17-
'content_filter' => FinishReason::ContentFilter,
13+
/**
14+
* @deprecated can be removed once chat/completions replaced with responses in Stream.
15+
*/
16+
if (is_null($type)) {
17+
return match ($status) {
18+
'stop', => FinishReason::Stop,
19+
'tool_calls' => FinishReason::ToolCalls,
20+
'length' => FinishReason::Length,
21+
'content_filter' => FinishReason::ContentFilter,
22+
default => FinishReason::Unknown,
23+
};
24+
}
25+
26+
return match ($status) {
27+
'incomplete' => FinishReason::Length,
28+
'failed' => FinishReason::Error,
29+
'completed' => match ($type) {
30+
'function_call' => FinishReason::ToolCalls,
31+
'message' => FinishReason::Stop,
32+
default => FinishReason::Unknown,
33+
},
1834
default => FinishReason::Unknown,
1935
};
2036
}

src/Providers/OpenAI/Maps/MessageMap.php

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ protected function mapToolResultMessage(ToolResultMessage $message): void
7070
{
7171
foreach ($message->toolResults as $toolResult) {
7272
$this->mappedMessages[] = [
73-
'role' => 'tool',
74-
'tool_call_id' => $toolResult->toolCallId,
75-
'content' => $toolResult->result,
73+
'type' => 'function_call_output',
74+
'call_id' => $toolResult->toolCallResultId,
75+
'output' => $toolResult->result,
7676
];
7777
}
7878
}
@@ -82,7 +82,7 @@ protected function mapUserMessage(UserMessage $message): void
8282
$this->mappedMessages[] = [
8383
'role' => 'user',
8484
'content' => [
85-
['type' => 'text', 'text' => $message->text()],
85+
['type' => 'input_text', 'text' => $message->text()],
8686
...self::mapImageParts($message->images()),
8787
...self::mapDocumentParts($message->documents()),
8888
...self::mapFileParts($message->files()),
@@ -97,12 +97,10 @@ protected function mapUserMessage(UserMessage $message): void
9797
protected static function mapImageParts(array $images): array
9898
{
9999
return array_map(fn (Image $image): array => [
100-
'type' => 'image_url',
101-
'image_url' => [
102-
'url' => $image->isUrl()
103-
? $image->image
104-
: sprintf('data:%s;base64,%s', $image->mimeType, $image->image),
105-
],
100+
'type' => 'input_image',
101+
'image_url' => $image->isUrl()
102+
? $image->image
103+
: sprintf('data:%s;base64,%s', $image->mimeType, $image->image),
106104
], $images);
107105
}
108106

@@ -118,11 +116,9 @@ protected static function mapDocumentParts(array $documents): array
118116
}
119117

120118
return [
121-
'type' => 'file',
122-
'file' => [
123-
'file_data' => sprintf('data:%s;base64,%s', $document->mimeType, $document->document), // @phpstan-ignore argument.type
124-
'filename' => $document->documentTitle,
125-
],
119+
'type' => 'input_file',
120+
'filename' => $document->documentTitle,
121+
'file_data' => sprintf('data:%s;base64,%s', $document->mimeType, $document->document), // @phpstan-ignore argument.type
126122
];
127123
}, $documents);
128124
}
@@ -134,28 +130,38 @@ protected static function mapDocumentParts(array $documents): array
134130
protected static function mapFileParts(array $files): array
135131
{
136132
return array_map(fn (OpenAIFile $file): array => [
137-
'type' => 'file',
138-
'file' => [
139-
'file_id' => $file->fileId,
140-
],
133+
'type' => 'input_file',
134+
'file_id' => $file->fileId,
141135
], $files);
142136
}
143137

144138
protected function mapAssistantMessage(AssistantMessage $message): void
145139
{
146-
$toolCalls = array_map(fn (ToolCall $toolCall): array => [
147-
'id' => $toolCall->id,
148-
'type' => 'function',
149-
'function' => [
150-
'name' => $toolCall->name,
151-
'arguments' => json_encode($toolCall->arguments()),
152-
],
153-
], $message->toolCalls);
140+
if ($message->content !== '' && $message->content !== '0') {
141+
$this->mappedMessages[] = [
142+
'role' => 'assistant',
143+
'content' => $message->content,
144+
];
145+
}
154146

155-
$this->mappedMessages[] = array_filter([
156-
'role' => 'assistant',
157-
'content' => $message->content,
158-
'tool_calls' => $toolCalls,
159-
]);
147+
if ($message->toolCalls !== []) {
148+
array_push(
149+
$this->mappedMessages,
150+
...array_filter(
151+
array_map(fn (ToolCall $toolCall): ?array => is_null($toolCall->reasoningId) ? null : [
152+
'type' => 'reasoning',
153+
'id' => $toolCall->reasoningId,
154+
'summary' => $toolCall->reasoningSummary,
155+
], $message->toolCalls)
156+
),
157+
...array_map(fn (ToolCall $toolCall): array => [
158+
'id' => $toolCall->id,
159+
'call_id' => $toolCall->resultId,
160+
'type' => 'function_call',
161+
'name' => $toolCall->name,
162+
'arguments' => json_encode($toolCall->arguments()),
163+
], $message->toolCalls)
164+
);
165+
}
160166
}
161167
}

src/Providers/OpenAI/Maps/ToolCallMap.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,23 @@
99
class ToolCallMap
1010
{
1111
/**
12-
* @param ?array<int, array<string, mixed>> $toolCalls
12+
* @param array<int, array<string, mixed>> $toolCalls
13+
* @param null|array<int, array<string, mixed>> $reasonings
1314
* @return array<int, ToolCall>
1415
*/
15-
public static function map(?array $toolCalls): array
16+
public static function map(?array $toolCalls, ?array $reasonings = null): array
1617
{
1718
if ($toolCalls === null) {
1819
return [];
1920
}
2021

2122
return array_map(fn (array $toolCall): ToolCall => new ToolCall(
2223
id: data_get($toolCall, 'id'),
23-
name: data_get($toolCall, 'function.name'),
24-
arguments: data_get($toolCall, 'function.arguments'),
24+
resultId: data_get($toolCall, 'call_id'),
25+
name: data_get($toolCall, 'name'),
26+
arguments: data_get($toolCall, 'arguments'),
27+
reasoningId: data_get($reasonings, '0.id'),
28+
reasoningSummary: data_get($reasonings, '0.summary'),
2529
), $toolCalls);
2630
}
2731
}

src/Providers/OpenAI/Maps/ToolChoiceMap.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ public static function map(string|ToolChoice|null $toolChoice): string|array|nul
1616
if (is_string($toolChoice)) {
1717
return [
1818
'type' => 'function',
19-
'function' => [
20-
'name' => $toolChoice,
21-
],
19+
'name' => $toolChoice,
2220
];
2321
}
2422

0 commit comments

Comments
 (0)