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 */
3541final 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