Skip to content

Commit aff83f9

Browse files
authored
fix: support finish reason "length" (#216)
* Fix: support finish reason "length" The reason is real https://community.openai.com/t/bug-in-api-response-finish-reason-field/287212 and is even mentioned here https://github.com/php-llm/llm-chain/blob/5da000ec0fb4fcc4d78399e40eac05506cfff6fc/src/Bridge/OpenAI/GPT/ResponseConverter.php#L159 Plus added some tests for the ResponseConverter * chore: fix code style
1 parent 5da000e commit aff83f9

File tree

2 files changed

+178
-1
lines changed

2 files changed

+178
-1
lines changed

src/Bridge/OpenAI/GPT/ResponseConverter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ private function convertChoice(array $choice): Choice
165165
return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
166166
}
167167

168-
if ('stop' === $choice['finish_reason']) {
168+
if (in_array($choice['finish_reason'], ['stop', 'length'], true)) {
169169
return new Choice($choice['message']['content']);
170170
}
171171

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\GPT;
6+
7+
use PhpLlm\LlmChain\Bridge\OpenAI\GPT\ResponseConverter;
8+
use PhpLlm\LlmChain\Exception\ContentFilterException;
9+
use PhpLlm\LlmChain\Exception\RuntimeException;
10+
use PhpLlm\LlmChain\Model\Response\ChoiceResponse;
11+
use PhpLlm\LlmChain\Model\Response\TextResponse;
12+
use PhpLlm\LlmChain\Model\Response\ToolCallResponse;
13+
use PHPUnit\Framework\Attributes\CoversClass;
14+
use PHPUnit\Framework\Attributes\Small;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
17+
use Symfony\Contracts\HttpClient\ResponseInterface;
18+
19+
#[CoversClass(ResponseConverter::class)]
20+
#[Small]
21+
class ResponseConverterTest extends TestCase
22+
{
23+
public function testConvertTextResponse(): void
24+
{
25+
$converter = new ResponseConverter();
26+
$httpResponse = $this->createMock(ResponseInterface::class);
27+
$httpResponse->method('toArray')->willReturn([
28+
'choices' => [
29+
[
30+
'message' => [
31+
'role' => 'assistant',
32+
'content' => 'Hello world',
33+
],
34+
'finish_reason' => 'stop',
35+
],
36+
],
37+
]);
38+
39+
$response = $converter->convert($httpResponse);
40+
41+
$this->assertInstanceOf(TextResponse::class, $response);
42+
$this->assertEquals('Hello world', $response->getContent());
43+
}
44+
45+
public function testConvertToolCallResponse(): void
46+
{
47+
$converter = new ResponseConverter();
48+
$httpResponse = $this->createMock(ResponseInterface::class);
49+
$httpResponse->method('toArray')->willReturn([
50+
'choices' => [
51+
[
52+
'message' => [
53+
'role' => 'assistant',
54+
'content' => null,
55+
'tool_calls' => [
56+
[
57+
'id' => 'call_123',
58+
'type' => 'function',
59+
'function' => [
60+
'name' => 'test_function',
61+
'arguments' => '{"arg1": "value1"}',
62+
],
63+
],
64+
],
65+
],
66+
'finish_reason' => 'tool_calls',
67+
],
68+
],
69+
]);
70+
71+
$response = $converter->convert($httpResponse);
72+
73+
$this->assertInstanceOf(ToolCallResponse::class, $response);
74+
$toolCalls = $response->getContent();
75+
$this->assertCount(1, $toolCalls);
76+
$this->assertEquals('call_123', $toolCalls[0]->id);
77+
$this->assertEquals('test_function', $toolCalls[0]->name);
78+
$this->assertEquals(['arg1' => 'value1'], $toolCalls[0]->arguments);
79+
}
80+
81+
public function testConvertMultipleChoices(): void
82+
{
83+
$converter = new ResponseConverter();
84+
$httpResponse = $this->createMock(ResponseInterface::class);
85+
$httpResponse->method('toArray')->willReturn([
86+
'choices' => [
87+
[
88+
'message' => [
89+
'role' => 'assistant',
90+
'content' => 'Choice 1',
91+
],
92+
'finish_reason' => 'stop',
93+
],
94+
[
95+
'message' => [
96+
'role' => 'assistant',
97+
'content' => 'Choice 2',
98+
],
99+
'finish_reason' => 'stop',
100+
],
101+
],
102+
]);
103+
104+
$response = $converter->convert($httpResponse);
105+
106+
$this->assertInstanceOf(ChoiceResponse::class, $response);
107+
$choices = $response->getContent();
108+
$this->assertCount(2, $choices);
109+
$this->assertEquals('Choice 1', $choices[0]->getContent());
110+
$this->assertEquals('Choice 2', $choices[1]->getContent());
111+
}
112+
113+
public function testContentFilterException(): void
114+
{
115+
$converter = new ResponseConverter();
116+
$httpResponse = $this->createMock(ResponseInterface::class);
117+
118+
$httpResponse->expects($this->exactly(2))
119+
->method('toArray')
120+
->willReturnCallback(function ($throw = true) {
121+
if ($throw) {
122+
throw new class extends \Exception implements ClientExceptionInterface {
123+
public function getResponse(): ResponseInterface
124+
{
125+
throw new RuntimeException('Not implemented');
126+
}
127+
};
128+
}
129+
130+
return [
131+
'error' => [
132+
'code' => 'content_filter',
133+
'message' => 'Content was filtered',
134+
],
135+
];
136+
});
137+
138+
$this->expectException(ContentFilterException::class);
139+
$this->expectExceptionMessage('Content was filtered');
140+
141+
$converter->convert($httpResponse);
142+
}
143+
144+
public function testThrowsExceptionWhenNoChoices(): void
145+
{
146+
$converter = new ResponseConverter();
147+
$httpResponse = $this->createMock(ResponseInterface::class);
148+
$httpResponse->method('toArray')->willReturn([]);
149+
150+
$this->expectException(RuntimeException::class);
151+
$this->expectExceptionMessage('Response does not contain choices');
152+
153+
$converter->convert($httpResponse);
154+
}
155+
156+
public function testThrowsExceptionForUnsupportedFinishReason(): void
157+
{
158+
$converter = new ResponseConverter();
159+
$httpResponse = $this->createMock(ResponseInterface::class);
160+
$httpResponse->method('toArray')->willReturn([
161+
'choices' => [
162+
[
163+
'message' => [
164+
'role' => 'assistant',
165+
'content' => 'Test content',
166+
],
167+
'finish_reason' => 'unsupported_reason',
168+
],
169+
],
170+
]);
171+
172+
$this->expectException(RuntimeException::class);
173+
$this->expectExceptionMessage('Unsupported finish reason "unsupported_reason"');
174+
175+
$converter->convert($httpResponse);
176+
}
177+
}

0 commit comments

Comments
 (0)