|
16 | 16 | use Cortex\SDK\OpenAI\Data\Responses\Message\OutputText; |
17 | 17 | use Cortex\SDK\OpenAI\Data\Responses\OutputItems\MessageOutputItem; |
18 | 18 | use Cortex\SDK\OpenAI\Data\Responses\OutputItems\ReasoningOutputItem; |
19 | | -use Cortex\SDK\OpenAI\Data\Responses\OutputItems\FunctionToolCallOutputItem; |
20 | 19 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCreated; |
21 | 20 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCompleted; |
22 | 21 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseInProgress; |
| 22 | +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\FunctionToolCallOutputItem; |
23 | 23 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputItemDone; |
24 | 24 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputTextDone; |
25 | 25 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseContentPartDone; |
26 | 26 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputItemAdded; |
27 | 27 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputTextDelta; |
28 | 28 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseContentPartAdded; |
29 | | -use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningTextDelta; |
30 | | -use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningTextDone; |
31 | | -use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningContentPartAdded; |
32 | | -use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFunctionCallArgumentsDelta; |
33 | 29 | use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFunctionCallArgumentsDone; |
| 30 | +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFunctionCallArgumentsDelta; |
34 | 31 |
|
35 | 32 | describe('Non-streaming', function (): void { |
36 | 33 | test('it can create a response', function (): void { |
|
139 | 136 | ->and($response->output[0]->name)->toBe('get_weather') |
140 | 137 | ->and($response->output[0]->status)->toBe('completed') |
141 | 138 | ->and($response->output[0]->callId)->toBe('call_sXyT5eGTVisSbFWEBSjquORK') |
142 | | - ->and($response->output[0]->arguments)->toBeArray() |
143 | | - ->and($response->output[0]->arguments)->toHaveKey('location') |
144 | | - ->and($response->output[0]->arguments['location'])->toBe('Manchester, UK') |
145 | | - ->and($response->output[0]->arguments)->toHaveKey('unit') |
146 | | - ->and($response->output[0]->arguments['unit'])->toBe('celsius') |
| 139 | + ->and($response->output[0]->arguments)->toBe('{"location":"Manchester, UK","unit":"celsius"}') |
147 | 140 | ->and($response->usage)->toBeInstanceOf(Usage::class) |
148 | 141 | ->and($response->usage->inputTokens)->toBe(86) |
149 | 142 | ->and($response->usage->outputTokens)->toBe(22) |
150 | 143 | ->and($response->usage->totalTokens)->toBe(108); |
151 | 144 |
|
| 145 | + // Verify the arguments can be decoded |
| 146 | + $arguments = json_decode((string) $response->output[0]->arguments, true); |
| 147 | + expect($arguments)->toBeArray() |
| 148 | + ->and($arguments['location'])->toBe('Manchester, UK') |
| 149 | + ->and($arguments['unit'])->toBe('celsius'); |
| 150 | + |
152 | 151 | MockClient::getGlobal()->assertSentCount(1, CreateResponse::class); |
153 | 152 | }); |
154 | 153 |
|
|
168 | 167 | 'schema' => [ |
169 | 168 | 'type' => 'object', |
170 | 169 | 'properties' => [ |
171 | | - 'name' => ['type' => 'string'], |
172 | | - 'email' => ['type' => 'string'], |
173 | | - 'plan_interest' => ['type' => 'string'], |
174 | | - 'demo_requested' => ['type' => 'boolean'], |
| 170 | + 'name' => [ |
| 171 | + 'type' => 'string', |
| 172 | + ], |
| 173 | + 'email' => [ |
| 174 | + 'type' => 'string', |
| 175 | + ], |
| 176 | + 'plan_interest' => [ |
| 177 | + 'type' => 'string', |
| 178 | + ], |
| 179 | + 'demo_requested' => [ |
| 180 | + 'type' => 'boolean', |
| 181 | + ], |
175 | 182 | ], |
176 | 183 | 'required' => ['name', 'email', 'plan_interest', 'demo_requested'], |
177 | 184 | 'additionalProperties' => false, |
|
197 | 204 | ->and($response->usage->totalTokens)->toBe(111); |
198 | 205 |
|
199 | 206 | // Verify the JSON can be decoded and has expected structure |
200 | | - $decoded = json_decode($response->output[0]->content[0]->text, true); |
| 207 | + $decoded = json_decode((string) $response->output[0]->content[0]->text, true); |
201 | 208 |
|
202 | 209 | expect($decoded)->toBeArray() |
203 | 210 | ->and($decoded['name'])->toBe('John Smith') |
|
359 | 366 |
|
360 | 367 | $events = iterator_to_array($response->getIterator()); |
361 | 368 |
|
362 | | - expect($events)->toBeArray() |
| 369 | + expect($events)->toHaveCount(18) |
363 | 370 | ->and($events[0])->toBeInstanceOf(ResponseCreated::class) |
364 | | - ->and($events[1])->toBeInstanceOf(ResponseInProgress::class); |
| 371 | + ->and($events[1])->toBeInstanceOf(ResponseInProgress::class) |
365 | 372 |
|
366 | | - // Check for function call related events |
367 | | - $functionCallArgumentsDeltaEvents = array_filter($events, fn ($event) => $event instanceof ResponseFunctionCallArgumentsDelta); |
368 | | - expect($functionCallArgumentsDeltaEvents)->not->toBeEmpty(); |
| 373 | + // Function call output item added (in progress with empty arguments) |
| 374 | + ->and($events[2])->toBeInstanceOf(ResponseOutputItemAdded::class) |
| 375 | + ->and($events[2]->item)->toBeInstanceOf(FunctionToolCallOutputItem::class) |
| 376 | + ->and($events[2]->item->id)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') |
| 377 | + ->and($events[2]->item->name)->toBe('get_weather') |
| 378 | + ->and($events[2]->item->callId)->toBe('call_mpisVwMpENDmDdyLFejlSBkS') |
| 379 | + ->and($events[2]->item->status)->toBe('in_progress') |
| 380 | + ->and($events[2]->item->arguments)->toBe('') |
| 381 | + |
| 382 | + // Function call arguments deltas |
| 383 | + ->and($events[3])->toBeInstanceOf(ResponseFunctionCallArgumentsDelta::class) |
| 384 | + ->and($events[3]->delta)->toBe('{"') |
| 385 | + ->and($events[3]->itemId)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') |
| 386 | + ->and($events[4])->toBeInstanceOf(ResponseFunctionCallArgumentsDelta::class) |
| 387 | + ->and($events[4]->delta)->toBe('location') |
| 388 | + ->and($events[6])->toBeInstanceOf(ResponseFunctionCallArgumentsDelta::class) |
| 389 | + ->and($events[6]->delta)->toBe('Manchester') |
| 390 | + |
| 391 | + // Function call arguments done |
| 392 | + ->and($events[15])->toBeInstanceOf(ResponseFunctionCallArgumentsDone::class) |
| 393 | + ->and($events[15]->arguments)->toBe('{"location":"Manchester, UK","unit":"celsius"}') |
| 394 | + ->and($events[15]->itemId)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') |
369 | 395 |
|
370 | | - $functionCallArgumentsDoneEvents = array_filter($events, fn ($event) => $event instanceof ResponseFunctionCallArgumentsDone); |
371 | | - expect($functionCallArgumentsDoneEvents)->not->toBeEmpty(); |
| 396 | + // Output item done |
| 397 | + ->and($events[16])->toBeInstanceOf(ResponseOutputItemDone::class) |
| 398 | + ->and($events[16]->item)->toBeInstanceOf(FunctionToolCallOutputItem::class) |
| 399 | + ->and($events[16]->item->id)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') |
| 400 | + ->and($events[16]->item->name)->toBe('get_weather') |
| 401 | + ->and($events[16]->item->callId)->toBe('call_mpisVwMpENDmDdyLFejlSBkS') |
| 402 | + ->and($events[16]->item->status)->toBe('completed') |
| 403 | + ->and($events[16]->item->arguments)->toBe('{"location":"Manchester, UK","unit":"celsius"}') |
372 | 404 |
|
373 | | - // Check that we have a function call output item |
374 | | - $functionCallOutputItemEvents = array_filter($events, fn ($event) => $event instanceof ResponseOutputItemAdded && $event->item instanceof FunctionToolCallOutputItem); |
375 | | - expect($functionCallOutputItemEvents)->not->toBeEmpty(); |
| 405 | + // Completed |
| 406 | + ->and($events[17])->toBeInstanceOf(ResponseCompleted::class); |
376 | 407 |
|
377 | | - // Last event should be ResponseCompleted |
378 | | - expect(end($events))->toBeInstanceOf(ResponseCompleted::class); |
379 | | - })->todo(); |
| 408 | + // Verify the arguments can be decoded |
| 409 | + $arguments = json_decode((string) $events[16]->item->arguments, true); |
| 410 | + expect($arguments)->toBeArray() |
| 411 | + ->and($arguments['location'])->toBe('Manchester, UK') |
| 412 | + ->and($arguments['unit'])->toBe('celsius'); |
| 413 | + }); |
380 | 414 |
|
381 | 415 | test('it can create a streamed response with structured output', function (): void { |
382 | 416 | $openai = OpenAI::fake([ |
|
388 | 422 | 'model' => 'gpt-4o-mini', |
389 | 423 | 'max_output_tokens' => 1024, |
390 | 424 | 'text' => [ |
391 | | - 'type' => 'json_schema', |
392 | | - 'json_schema' => [ |
| 425 | + 'format' => [ |
| 426 | + 'type' => 'json_schema', |
393 | 427 | 'name' => 'contact_info', |
394 | | - 'strict' => true, |
395 | 428 | 'schema' => [ |
396 | 429 | 'type' => 'object', |
397 | 430 | 'properties' => [ |
398 | | - 'name' => ['type' => 'string'], |
399 | | - 'email' => ['type' => 'string'], |
400 | | - 'plan_interest' => ['type' => 'string'], |
401 | | - 'demo_requested' => ['type' => 'boolean'], |
| 431 | + 'name' => [ |
| 432 | + 'type' => 'string', |
| 433 | + ], |
| 434 | + 'email' => [ |
| 435 | + 'type' => 'string', |
| 436 | + ], |
| 437 | + 'plan_interest' => [ |
| 438 | + 'type' => 'string', |
| 439 | + ], |
| 440 | + 'demo_requested' => [ |
| 441 | + 'type' => 'boolean', |
| 442 | + ], |
402 | 443 | ], |
403 | 444 | 'required' => ['name', 'email', 'plan_interest', 'demo_requested'], |
404 | 445 | 'additionalProperties' => false, |
405 | 446 | ], |
| 447 | + 'strict' => true, |
406 | 448 | ], |
407 | 449 | ], |
408 | 450 | 'input' => 'Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.', |
|
413 | 455 |
|
414 | 456 | $events = iterator_to_array($response->getIterator()); |
415 | 457 |
|
416 | | - expect($events)->toBeArray() |
| 458 | + expect($events)->toHaveCount(30) |
417 | 459 | ->and($events[0])->toBeInstanceOf(ResponseCreated::class) |
418 | | - ->and($events[1])->toBeInstanceOf(ResponseInProgress::class); |
| 460 | + ->and($events[1])->toBeInstanceOf(ResponseInProgress::class) |
| 461 | + |
| 462 | + // Message output item added |
| 463 | + ->and($events[2])->toBeInstanceOf(ResponseOutputItemAdded::class) |
| 464 | + ->and($events[2]->item)->toBeInstanceOf(MessageOutputItem::class) |
| 465 | + ->and($events[2]->item->id)->toBe('msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50') |
| 466 | + |
| 467 | + // Content part added |
| 468 | + ->and($events[3])->toBeInstanceOf(ResponseContentPartAdded::class) |
| 469 | + ->and($events[3]->part)->toBeInstanceOf(OutputText::class) |
| 470 | + |
| 471 | + // Text deltas building JSON |
| 472 | + ->and($events[4])->toBeInstanceOf(ResponseOutputTextDelta::class) |
| 473 | + ->and($events[4]->delta)->toBe('{"') |
| 474 | + ->and($events[5])->toBeInstanceOf(ResponseOutputTextDelta::class) |
| 475 | + ->and($events[5]->delta)->toBe('name') |
| 476 | + ->and($events[7])->toBeInstanceOf(ResponseOutputTextDelta::class) |
| 477 | + ->and($events[7]->delta)->toBe('John') |
| 478 | + ->and($events[19])->toBeInstanceOf(ResponseOutputTextDelta::class) |
| 479 | + ->and($events[19]->delta)->toBe('Enterprise') |
419 | 480 |
|
420 | | - // Check for text delta events (structured output comes as text) |
421 | | - $textDeltaEvents = array_filter($events, fn ($event) => $event instanceof ResponseOutputTextDelta); |
422 | | - expect($textDeltaEvents)->not->toBeEmpty(); |
| 481 | + // Text done |
| 482 | + ->and($events[26])->toBeInstanceOf(ResponseOutputTextDone::class) |
| 483 | + ->and($events[26]->text)->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') |
423 | 484 |
|
424 | | - // Check that we have a message output item |
425 | | - $messageOutputItemEvents = array_filter($events, fn ($event) => $event instanceof ResponseOutputItemAdded && $event->item instanceof MessageOutputItem); |
426 | | - expect($messageOutputItemEvents)->not->toBeEmpty(); |
| 485 | + // Content part done |
| 486 | + ->and($events[27])->toBeInstanceOf(ResponseContentPartDone::class) |
| 487 | + ->and($events[27]->part)->toBeArray() |
| 488 | + ->and($events[27]->part['text'])->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') |
427 | 489 |
|
428 | | - // Last event should be ResponseCompleted |
429 | | - expect(end($events))->toBeInstanceOf(ResponseCompleted::class); |
430 | | - })->todo(); |
| 490 | + // Output item done |
| 491 | + ->and($events[28])->toBeInstanceOf(ResponseOutputItemDone::class) |
| 492 | + ->and($events[28]->item)->toBeInstanceOf(MessageOutputItem::class) |
| 493 | + ->and($events[28]->item->id)->toBe('msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50') |
| 494 | + ->and($events[28]->item->status)->toBe('completed') |
| 495 | + ->and($events[28]->item->content)->toHaveCount(1) |
| 496 | + ->and($events[28]->item->content[0])->toBeInstanceOf(OutputText::class) |
| 497 | + ->and($events[28]->item->content[0]->text)->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') |
| 498 | + |
| 499 | + // Completed |
| 500 | + ->and($events[29])->toBeInstanceOf(ResponseCompleted::class); |
| 501 | + |
| 502 | + // Verify the JSON can be decoded and has expected structure |
| 503 | + $decoded = json_decode((string) $events[28]->item->content[0]->text, true); |
| 504 | + expect($decoded)->toBeArray() |
| 505 | + ->and($decoded['name'])->toBe('John Smith') |
| 506 | + ->and($decoded['email'])->toBe('john@example.com') |
| 507 | + ->and($decoded['plan_interest'])->toBe('Enterprise') |
| 508 | + ->and($decoded['demo_requested'])->toBeTrue(); |
| 509 | + }); |
431 | 510 | }); |
0 commit comments