Skip to content

Commit ae3bb53

Browse files
committed
Add stream usage support for OpenAI GPT
1 parent c4b5c59 commit ae3bb53

File tree

4 files changed

+284
-3
lines changed

4 files changed

+284
-3
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: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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\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+
// Return any result, won't be used in this test
37+
return new class() extends BaseResult {
38+
public function getContent(): iterable { yield 'ignored'; }
39+
};
40+
};
41+
42+
$stream = new ToolboxStreamResult($generator, $callback);
43+
$received = [];
44+
foreach ($stream->getContent() as $value) {
45+
$received[] = $value;
46+
}
47+
48+
$this->assertSame($chunks, $received);
49+
$this->assertFalse($callbackCalled, 'Callback should not be called when no ToolCallResult appears.');
50+
}
51+
52+
public function testInvokesCallbackOnToolCallAndYieldsItsContent(): void
53+
{
54+
$toolCallResult = new ToolCallResult(new ToolCall('id1', 'tool1', ['arg' => 'value']));
55+
56+
$generator = (function () use ($toolCallResult) {
57+
yield 'He';
58+
yield 'llo';
59+
yield $toolCallResult;
60+
// Anything after should be ignored due to break in StreamResult
61+
yield 'AFTER';
62+
})();
63+
64+
$receivedAssistantMessage = null;
65+
$receivedToolCallResult = null;
66+
67+
$callback = function (ToolCallResult $result, AssistantMessage $assistantMessage) use (&$receivedAssistantMessage, &$receivedToolCallResult) {
68+
$receivedToolCallResult = $result;
69+
$receivedAssistantMessage = $assistantMessage;
70+
71+
// Return a result that itself yields more chunks
72+
return new class() extends BaseResult {
73+
public function getContent(): iterable {
74+
yield ' world';
75+
yield '!';
76+
}
77+
};
78+
};
79+
80+
$stream = new ToolboxStreamResult($generator, $callback);
81+
82+
$received = [];
83+
foreach ($stream->getContent() as $value) {
84+
$received[] = $value;
85+
}
86+
87+
$this->assertSame(['He', 'llo', ' world', '!'], $received);
88+
$this->assertInstanceOf(ToolCallResult::class, $receivedToolCallResult);
89+
$this->assertInstanceOf(AssistantMessage::class, $receivedAssistantMessage);
90+
$this->assertSame('Hello', $receivedAssistantMessage->content);
91+
}
92+
93+
94+
public function testStreamsPlainChunksWithTokenUsage(): void
95+
{
96+
$chunks = [
97+
'He',
98+
'llo',
99+
new TokenUsage()
100+
];
101+
$generator = (function () use ($chunks) {
102+
foreach ($chunks as $c) {
103+
yield $c;
104+
}
105+
})();
106+
107+
$callbackCalled = false;
108+
$callback = function () use (&$callbackCalled) {
109+
$callbackCalled = true;
110+
// Return any result, won't be used in this test
111+
return new class() extends BaseResult {
112+
public function getContent(): iterable { yield 'ignored'; }
113+
};
114+
};
115+
116+
$stream = new ToolboxStreamResult($generator, $callback);
117+
$received = [];
118+
foreach ($stream->getContent() as $value) {
119+
$received[] = $value;
120+
}
121+
122+
$this->assertSame($chunks, $received);
123+
$this->assertFalse($callbackCalled, 'Callback should not be called when no ToolCallResult appears.');
124+
}
125+
}

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

Lines changed: 13 additions & 2 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
}
@@ -220,8 +231,8 @@ private function convertToolCall(array $toolCall): ToolCall
220231
private static function parseResetTime(string $resetTime): ?int
221232
{
222233
if (preg_match('/^(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) {
223-
$minutes = isset($matches[1]) ? (int) $matches[1] : 0;
224-
$secs = isset($matches[2]) ? (int) $matches[2] : 0;
234+
$minutes = isset($matches[1]) ? (int)$matches[1] : 0;
235+
$secs = isset($matches[2]) ? (int)$matches[2] : 0;
225236

226237
return ($minutes * 60) + $secs;
227238
}
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 <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\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)