Skip to content

Commit a680dea

Browse files
committed
[Platform][OpenAI] Add tests for streaming results and implement StreamChunk class
1 parent 75bf89e commit a680dea

File tree

2 files changed

+203
-0
lines changed

2 files changed

+203
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Result;
13+
14+
/**
15+
* @author Oscar Esteve <oscarsdt@gmail.com>
16+
*/
17+
final class StreamChunk extends BaseResult implements \Stringable
18+
{
19+
/**
20+
* @param string|iterable<mixed>|object|null $content
21+
*/
22+
public function __construct(
23+
private readonly string|iterable|object|null $content,
24+
) {
25+
}
26+
27+
public function __toString(): string
28+
{
29+
return (string) $this->content;
30+
}
31+
32+
public function getContent(): string|iterable|object|null
33+
{
34+
return $this->content;
35+
}
36+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Gpt;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter;
16+
use Symfony\AI\Platform\Metadata\TokenUsage;
17+
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\AI\Platform\Result\StreamChunk;
19+
use Symfony\AI\Platform\Result\StreamResult;
20+
use Symfony\AI\Platform\Result\ToolCallResult;
21+
use Symfony\Component\HttpClient\EventSourceHttpClient;
22+
use Symfony\Component\HttpClient\MockHttpClient;
23+
use Symfony\Component\HttpClient\Response\MockResponse;
24+
25+
final class ResultConverterStreamTest extends TestCase
26+
{
27+
public function testStreamTextDeltas()
28+
{
29+
$sseBody = ''
30+
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
31+
."data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"},\"index\":0}]}\n\n"
32+
."data: {\"choices\":[{\"delta\":{\"content\":\"world\"},\"index\":0}]}\n\n"
33+
."data: {\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"
34+
."data: [DONE]\n\n";
35+
36+
$mockClient = new MockHttpClient([
37+
new MockResponse($sseBody, [
38+
'http_code' => 200,
39+
'response_headers' => [
40+
'content-type' => 'text/event-stream',
41+
],
42+
]),
43+
]);
44+
$esClient = new EventSourceHttpClient($mockClient);
45+
$asyncResponse = $esClient->request('GET', 'http://localhost/stream');
46+
47+
$converter = new ResultConverter();
48+
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
49+
50+
$this->assertInstanceOf(StreamResult::class, $result);
51+
52+
/** @var StreamChunk[] $chunks */
53+
$chunks = [];
54+
$content = '';
55+
56+
foreach ($result->getContent() as $chunk) {
57+
$chunks[] = $chunk;
58+
$content .= $chunk;
59+
}
60+
61+
// Only text deltas are yielded; role and finish chunks are ignored
62+
$this->assertSame('Hello world', $content);
63+
$this->assertCount(2, $chunks);
64+
$this->assertSame('Hello ', $chunks[0]->getContent());
65+
$this->assertEquals('http://localhost/stream', $chunks[0]->getRawResult()->getObject()->getInfo()['url']);
66+
}
67+
68+
public function testStreamToolCallsAreAssembledAndYielded()
69+
{
70+
// Simulate a tool call that is streamed in multiple argument parts
71+
$sseBody = ''
72+
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
73+
."data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"test_function\",\"arguments\":\"{\\\"arg1\\\": \\\"value1\\\"}\"}}]},\"index\":0}]}\n\n"
74+
."data: {\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"tool_calls\"}]}\n\n"
75+
."data: {\"usage\":{\"prompt_tokens\":1039,\"completion_tokens\":10,\"total_tokens\":1049,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}\n\n"
76+
."data: [DONE]\n\n";
77+
78+
$mockClient = new MockHttpClient([
79+
new MockResponse($sseBody, [
80+
'http_code' => 200,
81+
'response_headers' => [
82+
'content-type' => 'text/event-stream',
83+
],
84+
]),
85+
]);
86+
$esClient = new EventSourceHttpClient($mockClient);
87+
$asyncResponse = $esClient->request('GET', 'http://localhost/stream');
88+
89+
$converter = new ResultConverter();
90+
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
91+
92+
$this->assertInstanceOf(StreamResult::class, $result);
93+
94+
$yielded = [];
95+
foreach ($result->getContent() as $delta) {
96+
$yielded[] = $delta;
97+
}
98+
99+
// Expect only one yielded item and it should be a ToolCallResult
100+
$this->assertCount(1, $yielded);
101+
$this->assertInstanceOf(ToolCallResult::class, $yielded[0]);
102+
/** @var ToolCallResult $toolCallResult */
103+
$toolCallResult = $yielded[0];
104+
$toolCalls = $toolCallResult->getContent();
105+
106+
$this->assertCount(1, $toolCalls);
107+
$this->assertSame('call_123', $toolCalls[0]->getId());
108+
$this->assertSame('test_function', $toolCalls[0]->getName());
109+
$this->assertSame(['arg1' => 'value1'], $toolCalls[0]->getArguments());
110+
$this->assertSame(
111+
[
112+
'prompt_tokens' => 1039,
113+
'completion_tokens' => 10,
114+
'total_tokens' => 1049,
115+
'prompt_tokens_details' => [
116+
'cached_tokens' => 0,
117+
'audio_tokens' => 0,
118+
],
119+
'completion_tokens_details' => [
120+
'reasoning_tokens' => 0,
121+
'audio_tokens' => 0,
122+
'accepted_prediction_tokens' => 0,
123+
'rejected_prediction_tokens' => 0,
124+
],
125+
],
126+
$toolCallResult->getMetadata()->get('usage')
127+
);
128+
}
129+
130+
public function testStreamTokenUsage()
131+
{
132+
$sseBody = ''
133+
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
134+
."data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"},\"index\":0}]}\n\n"
135+
."data: {\"choices\":[{\"delta\":{\"content\":\"world\"},\"index\":0}]}\n\n"
136+
."data: {\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"
137+
."data: {\"usage\":{\"prompt_tokens\":1039,\"completion_tokens\":10,\"total_tokens\":1049,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}\n\n"
138+
."data: [DONE]\n\n";
139+
140+
$mockClient = new MockHttpClient([
141+
new MockResponse($sseBody, [
142+
'http_code' => 200,
143+
'response_headers' => [
144+
'content-type' => 'text/event-stream',
145+
],
146+
]),
147+
]);
148+
$esClient = new EventSourceHttpClient($mockClient);
149+
$asyncResponse = $esClient->request('GET', 'http://localhost/stream');
150+
151+
$converter = new ResultConverter();
152+
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
153+
154+
$this->assertInstanceOf(StreamResult::class, $result);
155+
156+
$yielded = [];
157+
foreach ($result->getContent() as $delta) {
158+
$yielded[] = $delta;
159+
}
160+
$this->assertCount(3, $yielded);
161+
$this->assertInstanceOf(TokenUsage::class, $yielded[2]);
162+
$this->assertSame(1039, $yielded[2]->promptTokens);
163+
$this->assertSame(10, $yielded[2]->completionTokens);
164+
$this->assertSame(1049, $yielded[2]->totalTokens);
165+
$this->assertSame(0, $yielded[2]->cachedTokens);
166+
}
167+
}

0 commit comments

Comments
 (0)