Skip to content

Commit 12669c7

Browse files
committed
Add stream usage support for OpenAI GPT
1 parent 2c4d20d commit 12669c7

File tree

4 files changed

+289
-1
lines changed

4 files changed

+289
-1
lines changed

src/agent/src/Toolbox/StreamResult.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ public function getContent(): \Generator
3333
if ($value instanceof ToolCallResult) {
3434
yield from ($this->handleToolCallsCallback)($value, Message::ofAssistant($streamedResult))->getContent();
3535

36-
break;
36+
continue;
37+
}
38+
39+
if (!\is_string($value)) {
40+
yield $value;
41+
42+
continue;
3743
}
3844

3945
$streamedResult .= $value;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Agent\Tests\Toolbox;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\AI\Agent\Toolbox\StreamResult as ToolboxStreamResult;
16+
use Symfony\AI\Platform\Message\AssistantMessage;
17+
use Symfony\AI\Platform\Metadata\TokenUsage;
18+
use Symfony\AI\Platform\Result\BaseResult;
19+
use Symfony\AI\Platform\Result\ToolCall;
20+
use Symfony\AI\Platform\Result\ToolCallResult;
21+
22+
final class StreamResultTest extends TestCase
23+
{
24+
public function testStreamsPlainChunksWithoutToolCall(): void
25+
{
26+
$chunks = ['He', 'llo'];
27+
$generator = (function () use ($chunks) {
28+
foreach ($chunks as $c) {
29+
yield $c;
30+
}
31+
})();
32+
33+
$callbackCalled = false;
34+
$callback = function () use (&$callbackCalled) {
35+
$callbackCalled = true;
36+
37+
// Return any result, won't be used in this test
38+
return new class extends BaseResult {
39+
public function getContent(): iterable
40+
{
41+
yield 'ignored';
42+
}
43+
};
44+
};
45+
46+
$stream = new ToolboxStreamResult($generator, $callback);
47+
$received = [];
48+
foreach ($stream->getContent() as $value) {
49+
$received[] = $value;
50+
}
51+
52+
$this->assertSame($chunks, $received);
53+
$this->assertFalse($callbackCalled, 'Callback should not be called when no ToolCallResult appears.');
54+
}
55+
56+
public function testInvokesCallbackOnToolCallAndYieldsItsContent(): void
57+
{
58+
$toolCallResult = new ToolCallResult(new ToolCall('id1', 'tool1', ['arg' => 'value']));
59+
60+
$generator = (function () use ($toolCallResult) {
61+
yield 'He';
62+
yield 'llo';
63+
yield $toolCallResult;
64+
yield 'AFTER';
65+
})();
66+
67+
$receivedAssistantMessage = null;
68+
$receivedToolCallResult = null;
69+
70+
$callback = function (ToolCallResult $result, AssistantMessage $assistantMessage) use (&$receivedAssistantMessage, &$receivedToolCallResult) {
71+
$receivedToolCallResult = $result;
72+
$receivedAssistantMessage = $assistantMessage;
73+
74+
// Return a result that itself yields more chunks
75+
return new class extends BaseResult {
76+
public function getContent(): iterable
77+
{
78+
yield ' world';
79+
yield '!';
80+
}
81+
};
82+
};
83+
84+
$stream = new ToolboxStreamResult($generator, $callback);
85+
86+
$received = [];
87+
foreach ($stream->getContent() as $value) {
88+
$received[] = $value;
89+
}
90+
91+
$this->assertSame(['He', 'llo', ' world', '!', 'AFTER'], $received);
92+
$this->assertInstanceOf(ToolCallResult::class, $receivedToolCallResult);
93+
$this->assertInstanceOf(AssistantMessage::class, $receivedAssistantMessage);
94+
$this->assertSame('Hello', $receivedAssistantMessage->content);
95+
}
96+
97+
public function testStreamsPlainChunksWithTokenUsage(): void
98+
{
99+
$chunks = [
100+
'He',
101+
'llo',
102+
new TokenUsage(),
103+
];
104+
$generator = (function () use ($chunks) {
105+
foreach ($chunks as $c) {
106+
yield $c;
107+
}
108+
})();
109+
110+
$callbackCalled = false;
111+
$callback = function () use (&$callbackCalled) {
112+
$callbackCalled = true;
113+
114+
// Return any result, won't be used in this test
115+
return new class extends BaseResult {
116+
public function getContent(): iterable
117+
{
118+
yield 'ignored';
119+
}
120+
};
121+
};
122+
123+
$stream = new ToolboxStreamResult($generator, $callback);
124+
$received = [];
125+
foreach ($stream->getContent() as $value) {
126+
$received[] = $value;
127+
}
128+
129+
$this->assertSame($chunks, $received);
130+
$this->assertFalse($callbackCalled, 'Callback should not be called when no ToolCallResult appears.');
131+
}
132+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\AI\Platform\Exception\ContentFilterException;
1818
use Symfony\AI\Platform\Exception\RateLimitExceededException;
1919
use Symfony\AI\Platform\Exception\RuntimeException;
20+
use Symfony\AI\Platform\Metadata\TokenUsage;
2021
use Symfony\AI\Platform\Model;
2122
use Symfony\AI\Platform\Result\ChoiceResult;
2223
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -106,6 +107,16 @@ private function convertStream(HttpResponse $result): \Generator
106107
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
107108
}
108109

110+
if ($usage = $data['usage'] ?? null) {
111+
yield new TokenUsage(
112+
promptTokens: $usage['prompt_tokens'] ?? null,
113+
completionTokens: $usage['completion_tokens'] ?? null,
114+
thinkingTokens: $usage['completion_tokens_details']['reasoning_tokens'] ?? null,
115+
cachedTokens: $usage['prompt_tokens_details']['cached_tokens'] ?? null,
116+
totalTokens: $usage['total_tokens'] ?? null,
117+
);
118+
}
119+
109120
if (!isset($data['choices'][0]['delta']['content'])) {
110121
continue;
111122
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\StreamResult;
19+
use Symfony\AI\Platform\Result\ToolCallResult;
20+
use Symfony\Component\HttpClient\EventSourceHttpClient;
21+
use Symfony\Component\HttpClient\MockHttpClient;
22+
use Symfony\Component\HttpClient\Response\MockResponse;
23+
24+
final class ResultConverterStreamTest extends TestCase
25+
{
26+
public function testStreamTextDeltas()
27+
{
28+
$sseBody = ''
29+
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
30+
."data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"},\"index\":0}]}\n\n"
31+
."data: {\"choices\":[{\"delta\":{\"content\":\"world\"},\"index\":0}]}\n\n"
32+
."data: {\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"
33+
."data: [DONE]\n\n";
34+
35+
$mockClient = new MockHttpClient([
36+
new MockResponse($sseBody, [
37+
'http_code' => 200,
38+
'response_headers' => [
39+
'content-type' => 'text/event-stream',
40+
],
41+
]),
42+
]);
43+
$esClient = new EventSourceHttpClient($mockClient);
44+
$asyncResponse = $esClient->request('GET', 'http://localhost/stream');
45+
46+
$converter = new ResultConverter();
47+
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
48+
49+
$this->assertInstanceOf(StreamResult::class, $result);
50+
$chunks = [];
51+
foreach ($result->getContent() as $delta) {
52+
$chunks[] = $delta;
53+
}
54+
55+
// Only text deltas are yielded; role and finish chunks are ignored
56+
$this->assertSame(['Hello ', 'world'], $chunks);
57+
}
58+
59+
public function testStreamToolCallsAreAssembledAndYielded()
60+
{
61+
// Simulate a tool call that is streamed in multiple argument parts
62+
$sseBody = ''
63+
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
64+
."data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"test_function\",\"arguments\":\"{\\\"arg1\\\": \\\"value1\\\"}\"}}]},\"index\":0}]}\n\n"
65+
."data: {\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"tool_calls\"}]}\n\n"
66+
."data: [DONE]\n\n";
67+
68+
$mockClient = new MockHttpClient([
69+
new MockResponse($sseBody, [
70+
'http_code' => 200,
71+
'response_headers' => [
72+
'content-type' => 'text/event-stream',
73+
],
74+
]),
75+
]);
76+
$esClient = new EventSourceHttpClient($mockClient);
77+
$asyncResponse = $esClient->request('GET', 'http://localhost/stream');
78+
79+
$converter = new ResultConverter();
80+
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
81+
82+
$this->assertInstanceOf(StreamResult::class, $result);
83+
84+
$yielded = [];
85+
foreach ($result->getContent() as $delta) {
86+
$yielded[] = $delta;
87+
}
88+
89+
// Expect only one yielded item and it should be a ToolCallResult
90+
$this->assertCount(1, $yielded);
91+
$this->assertInstanceOf(ToolCallResult::class, $yielded[0]);
92+
/** @var ToolCallResult $toolCallResult */
93+
$toolCallResult = $yielded[0];
94+
$toolCalls = $toolCallResult->getContent();
95+
96+
$this->assertCount(1, $toolCalls);
97+
$this->assertSame('call_123', $toolCalls[0]->getId());
98+
$this->assertSame('test_function', $toolCalls[0]->getName());
99+
$this->assertSame(['arg1' => 'value1'], $toolCalls[0]->getArguments());
100+
}
101+
102+
public function testStreamTokenUsage()
103+
{
104+
$sseBody = ''
105+
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
106+
."data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"},\"index\":0}]}\n\n"
107+
."data: {\"choices\":[{\"delta\":{\"content\":\"world\"},\"index\":0}]}\n\n"
108+
."data: {\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\"}]}\n\n"
109+
."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"
110+
."data: [DONE]\n\n";
111+
112+
$mockClient = new MockHttpClient([
113+
new MockResponse($sseBody, [
114+
'http_code' => 200,
115+
'response_headers' => [
116+
'content-type' => 'text/event-stream',
117+
],
118+
]),
119+
]);
120+
$esClient = new EventSourceHttpClient($mockClient);
121+
$asyncResponse = $esClient->request('GET', 'http://localhost/stream');
122+
123+
$converter = new ResultConverter();
124+
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
125+
126+
$this->assertInstanceOf(StreamResult::class, $result);
127+
128+
$yielded = [];
129+
foreach ($result->getContent() as $delta) {
130+
$yielded[] = $delta;
131+
}
132+
$this->assertCount(3, $yielded);
133+
$this->assertInstanceOf(TokenUsage::class, $yielded[2]);
134+
$this->assertSame(1039, $yielded[2]->promptTokens);
135+
$this->assertSame(10, $yielded[2]->completionTokens);
136+
$this->assertSame(1049, $yielded[2]->totalTokens);
137+
$this->assertSame(0, $yielded[2]->cachedTokens);
138+
}
139+
}

0 commit comments

Comments
 (0)