Skip to content

Commit 325859b

Browse files
committed
Add StreamChunk support and enhance token usage handling in GPT streaming
- Introduced `StreamChunk` class for better stream content representation. - Updated `ResultConverterStreamTest` to validate `StreamChunk` behavior. - Enhanced `ResultConverter` to handle deferred token usage metadata for tool calls.
1 parent cd07702 commit 325859b

File tree

4 files changed

+97
-13
lines changed

4 files changed

+97
-13
lines changed

src/agent/tests/Toolbox/StreamResultTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function getContent(): iterable
9191
$this->assertSame(['He', 'llo', ' world', '!', 'AFTER'], $received);
9292
$this->assertInstanceOf(ToolCallResult::class, $receivedToolCallResult);
9393
$this->assertInstanceOf(AssistantMessage::class, $receivedAssistantMessage);
94-
$this->assertSame('Hello', $receivedAssistantMessage->content);
94+
$this->assertSame('Hello', $receivedAssistantMessage->getContent());
9595
}
9696

9797
public function testStreamsPlainChunksWithTokenUsage()

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\AI\Platform\Result\RawHttpResult;
2424
use Symfony\AI\Platform\Result\RawResultInterface;
2525
use Symfony\AI\Platform\Result\ResultInterface;
26+
use Symfony\AI\Platform\Result\StreamChunk;
2627
use Symfony\AI\Platform\Result\StreamResult;
2728
use Symfony\AI\Platform\Result\TextResult;
2829
use Symfony\AI\Platform\Result\ToolCall;
@@ -89,30 +90,49 @@ public function convert(RawResultInterface|RawHttpResult $result, array $options
8990
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
9091
{
9192
$toolCalls = [];
93+
/** @var ToolCallResult|null $toolCallResult */
94+
$toolCallResult = null;
9295
foreach ($result->getDataStream() as $data) {
9396
if ($this->streamIsToolCall($data)) {
9497
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
9598
}
9699

97100
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
98-
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
101+
$toolCallResult = new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
102+
// postpone yielding the tool call result until the usage is available
103+
continue;
99104
}
100105

106+
// Usage arrives after the tool calls are finished.
101107
if ($usage = $data['usage'] ?? null) {
102-
yield new TokenUsage(
103-
promptTokens: $usage['prompt_tokens'] ?? null,
104-
completionTokens: $usage['completion_tokens'] ?? null,
105-
thinkingTokens: $usage['completion_tokens_details']['reasoning_tokens'] ?? null,
106-
cachedTokens: $usage['prompt_tokens_details']['cached_tokens'] ?? null,
107-
totalTokens: $usage['total_tokens'] ?? null,
108-
);
108+
if ($toolCallResult) {
109+
$toolCallResult->getMetadata()->add('usage', $usage);
110+
yield $toolCallResult;
111+
$toolCallResult = null;
112+
} else {
113+
yield new TokenUsage(
114+
promptTokens: $usage['prompt_tokens'] ?? null,
115+
completionTokens: $usage['completion_tokens'] ?? null,
116+
thinkingTokens: $usage['completion_tokens_details']['reasoning_tokens'] ?? null,
117+
cachedTokens: $usage['prompt_tokens_details']['cached_tokens'] ?? null,
118+
totalTokens: $usage['total_tokens'] ?? null,
119+
);
120+
}
109121
}
110122

111123
if (!isset($data['choices'][0]['delta']['content'])) {
112124
continue;
113125
}
114126

115-
yield $data['choices'][0]['delta']['content'];
127+
$chunk = new StreamChunk($data['choices'][0]['delta']['content']);
128+
$chunk->setRawResult($result);
129+
130+
yield $chunk;
131+
}
132+
133+
// Yield the last tool call result if any.
134+
if ($toolCallResult) {
135+
yield $toolCallResult;
116136
}
117137
}
118138

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 <[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\Result;
13+
14+
/**
15+
* @author Oscar Esteve <[email protected]>
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+
}

src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterStreamTest.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter;
1616
use Symfony\AI\Platform\Metadata\TokenUsage;
1717
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\AI\Platform\Result\StreamChunk;
1819
use Symfony\AI\Platform\Result\StreamResult;
1920
use Symfony\AI\Platform\Result\ToolCallResult;
2021
use Symfony\Component\HttpClient\EventSourceHttpClient;
@@ -47,13 +48,21 @@ public function testStreamTextDeltas()
4748
$result = $converter->convert(new RawHttpResult($asyncResponse), ['stream' => true]);
4849

4950
$this->assertInstanceOf(StreamResult::class, $result);
51+
52+
/** @var StreamChunk[] $chunks */
5053
$chunks = [];
51-
foreach ($result->getContent() as $delta) {
52-
$chunks[] = $delta;
54+
$content = '';
55+
56+
foreach ($result->getContent() as $chunk) {
57+
$chunks[] = $chunk;
58+
$content .= $chunk;
5359
}
5460

5561
// Only text deltas are yielded; role and finish chunks are ignored
56-
$this->assertSame(['Hello ', 'world'], $chunks);
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']);
5766
}
5867

5968
public function testStreamToolCallsAreAssembledAndYielded()
@@ -63,6 +72,7 @@ public function testStreamToolCallsAreAssembledAndYielded()
6372
."data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n"
6473
."data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"test_function\",\"arguments\":\"{\\\"arg1\\\": \\\"value1\\\"}\"}}]},\"index\":0}]}\n\n"
6574
."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"
6676
."data: [DONE]\n\n";
6777

6878
$mockClient = new MockHttpClient([
@@ -97,6 +107,24 @@ public function testStreamToolCallsAreAssembledAndYielded()
97107
$this->assertSame('call_123', $toolCalls[0]->getId());
98108
$this->assertSame('test_function', $toolCalls[0]->getName());
99109
$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+
);
100128
}
101129

102130
public function testStreamTokenUsage()

0 commit comments

Comments
 (0)