Skip to content

Commit 59107fc

Browse files
committed
stream chat completion feature is fixed
1 parent 931575b commit 59107fc

File tree

3 files changed

+201
-10
lines changed

3 files changed

+201
-10
lines changed

src/DTO/ErrorData.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
namespace MoeMizrak\LaravelOpenrouter\DTO;
44

5+
use Spatie\DataTransferObject\DataTransferObject;
6+
57
/**
68
* DTO for error messages.
79
*
810
* Class ErrorData
911
* @package MoeMizrak\LaravelOpenrouter\DTO
1012
*/
11-
class ErrorData
13+
class ErrorData extends DataTransferObject
1214
{
1315
/**
1416
* Error code e.g. 400, 408 ...

src/OpenRouterRequest.php

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
namespace MoeMizrak\LaravelOpenrouter;
44

55
use GuzzleHttp\Exception\GuzzleException;
6+
use GuzzleHttp\Promise\PromiseInterface;
67
use MoeMizrak\LaravelOpenrouter\DTO\ChatData;
78
use MoeMizrak\LaravelOpenrouter\DTO\CostResponseData;
9+
use MoeMizrak\LaravelOpenrouter\DTO\ErrorData;
810
use MoeMizrak\LaravelOpenrouter\DTO\LimitResponseData;
911
use MoeMizrak\LaravelOpenrouter\DTO\ResponseData;
1012
use Psr\Http\Message\ResponseInterface;
@@ -27,16 +29,24 @@ class OpenRouterRequest extends OpenRouterAPI
2729
* Sends a model request for the given chat conversation.
2830
*
2931
* @param ChatData $chatData
30-
* @return ResponseData
3132
*
33+
* @return ErrorData|ResponseData
3234
* @throws GuzzleException
3335
* @throws UnknownProperties
3436
*/
35-
public function chatRequest(ChatData $chatData): ResponseData
37+
public function chatRequest(ChatData $chatData): ErrorData|ResponseData
3638
{
3739
// The path for the chat completion request.
3840
$chatCompletionPath = 'chat/completions';
3941

42+
// Detect if stream chat completion is requested, and return ErrorData stating that chatStreamRequest needs to be used instead.
43+
if ($chatData->stream) {
44+
return new ErrorData([
45+
'code' => 400,
46+
'message' => 'For stream chat completion please use "chatStreamRequest" method instead!',
47+
]);
48+
}
49+
4050
// Filter null values from the chatData object and return array.
4151
$chatData = $this->filterNullValuesRecursive($chatData);
4252

@@ -52,15 +62,101 @@ public function chatRequest(ChatData $chatData): ResponseData
5262
$options
5363
);
5464

65+
// Decode the json response
66+
$response = $this->jsonDecode($response);
67+
5568
return $this->formChatResponse($response);
5669
}
5770

71+
/**
72+
* Sends a streaming request for the given chat conversation.
73+
*
74+
* @param ChatData $chatData
75+
* @param int $readByte - Default 4096 byte (4kB) for performance.
76+
*
77+
* @return PromiseInterface
78+
*/
79+
public function chatStreamRequest(ChatData $chatData, int $readByte = 4096): PromiseInterface
80+
{
81+
// The path for the chat completion request.
82+
$chatCompletionPath = 'chat/completions';
83+
84+
$chatData->stream = true;
85+
86+
// Filter null values from the chatData object and return array.
87+
$chatData = $this->filterNullValuesRecursive($chatData);
88+
89+
// Add headers for streaming.
90+
$headers = [
91+
'Content-Type' => 'text/event-stream',
92+
'Cache-Control' => 'no-cache'
93+
];
94+
95+
// Options for the Guzzle request
96+
$options = [
97+
'json' => $chatData,
98+
'headers' => $headers
99+
];
100+
101+
// Send POST request to the OpenRouter API chat completion endpoint and get the streaming response.
102+
$promise = $this->client->requestAsync(
103+
'POST',
104+
$chatCompletionPath,
105+
$options
106+
);
107+
108+
/*
109+
* Return streaming response promise which can be resolved with promise->wait().
110+
*/
111+
return $promise->then(
112+
function (ResponseInterface $response) use($readByte) {
113+
$streamingResponse = $response->getBody()->read($readByte);
114+
115+
return $this->filterStreamingResponse($streamingResponse);
116+
}
117+
);
118+
}
119+
120+
/**
121+
* It filters streaming response string so that response string is mapped into ResponseData.
122+
*
123+
* @param string $streamingResponse
124+
*
125+
* @return array
126+
* @throws UnknownProperties
127+
*/
128+
private function filterStreamingResponse(string $streamingResponse): array
129+
{
130+
// Split the string by lines
131+
$lines = explode("\n", $streamingResponse);
132+
133+
// Filter out unnecessary lines
134+
$filteredLines = array_filter($lines, function($line) {
135+
// Check if line starts with "data: {"
136+
return strpos($line, 'data: {') === 0;
137+
});
138+
139+
// Decode the JSON data in each line
140+
$responseDataArray = [];
141+
142+
foreach ($filteredLines as $line) {
143+
// Remove "data: " and decode JSON
144+
$data = json_decode(substr($line, strlen('data: ')), true);
145+
// Check whether decoding was successful.
146+
if (json_last_error() === JSON_ERROR_NONE) {
147+
$responseDataArray[] = $this->formChatResponse($data);
148+
}
149+
}
150+
151+
return $responseDataArray;
152+
}
153+
58154
/**
59155
* Sends a cost request for the given generation id.
60156
*
61157
* @param string $generationId
62-
* @return CostResponseData
63158
*
159+
* @return CostResponseData
64160
* @throws GuzzleException
65161
* @throws UnknownProperties
66162
*/
@@ -101,17 +197,14 @@ public function limitRequest(): LimitResponseData
101197

102198
/**
103199
* Forms the response as ResponseData including id, model, object created, choices and usage if exits.
104-
* First decodes the json response and get the result, then map it in ResponseData to return the response.
105200
*
106-
* @param ResponseInterface|null $response
201+
* @param mixed $response
202+
*
107203
* @return ResponseData
108204
* @throws UnknownProperties
109205
*/
110-
private function formChatResponse(?ResponseInterface $response = null) : ResponseData
206+
private function formChatResponse(mixed $response = null) : ResponseData
111207
{
112-
// Decode the json response
113-
$response = $this->jsonDecode($response);
114-
115208
// Map the response data to ResponseData and return it.
116209
return new ResponseData([
117210
'id' => Arr::get($response, 'id'),
@@ -128,6 +221,7 @@ private function formChatResponse(?ResponseInterface $response = null) : Respons
128221
* First decodes the json response, then map it in CostResponseData to return the response.
129222
*
130223
* @param ResponseInterface|null $response
224+
*
131225
* @return CostResponseData
132226
* @throws UnknownProperties
133227
*/
@@ -167,6 +261,7 @@ private function formCostsResponse(?ResponseInterface $response = null) : CostRe
167261
* First decodes the json response and get the result, then map it in LimitResponseData to return the response.
168262
*
169263
* @param ResponseInterface|null $response
264+
*
170265
* @return LimitResponseData
171266
* @throws UnknownProperties
172267
*/
@@ -190,6 +285,7 @@ private function formLimitResponse(?ResponseInterface $response = null): LimitRe
190285
* Decodes response to json.
191286
*
192287
* @param ResponseInterface|null $response
288+
*
193289
* @return mixed|null
194290
*/
195291
private function jsonDecode(?ResponseInterface $response = null): mixed

tests/OpenRouterAPITest.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,99 @@ public function it_makes_a_basic_chat_completion_open_route_api_request()
9090
$this->assertNotNull(Arr::get($response->choices[0], 'message.content'));
9191
}
9292

93+
/**
94+
* @test
95+
*/
96+
public function it_makes_a_basic_chat_completion_open_route_api_request_with_historical_data()
97+
{
98+
/* SETUP */
99+
$firstMessage = new MessageData([
100+
'role' => RoleType::USER,
101+
'content' => 'My name is Moe, the AI necromancer.',
102+
]);
103+
$chatData = new ChatData([
104+
'messages' => [
105+
$firstMessage,
106+
],
107+
'model' => $this->model,
108+
'max_tokens' => $this->maxTokens,
109+
]);
110+
$oldResponse = $this->api->chatRequest($chatData);
111+
$historicalMessage = new MessageData([
112+
'role' => RoleType::ASSISTANT,
113+
'content' => Arr::get($oldResponse->choices[0],'message.content'),
114+
]);
115+
$newMessage = new MessageData([
116+
'role' => RoleType::USER,
117+
'content' => 'Who am I?',
118+
]);
119+
$chatData = new ChatData([
120+
'messages' => [
121+
$historicalMessage,
122+
$newMessage,
123+
],
124+
'model' => $this->model,
125+
'max_tokens' => $this->maxTokens,
126+
]);
127+
128+
/* EXECUTE */
129+
$response = $this->api->chatRequest($chatData);
130+
131+
/* ASSERT */
132+
$this->generalTestAssertions($response);
133+
$this->assertEquals(RoleType::ASSISTANT, Arr::get($response->choices[0], 'message.role'));
134+
$content = Arr::get($response->choices[0], 'message.content');
135+
$this->assertTrue(str_contains($content, 'Moe'));
136+
}
137+
138+
/**
139+
* @test
140+
*/
141+
public function it_makes_a_basic_chat_completion_stream_request()
142+
{
143+
/* SETUP */
144+
$chatData = new ChatData([
145+
'messages' => [
146+
$this->messageData,
147+
],
148+
'model' => $this->model,
149+
'max_tokens' => $this->maxTokens,
150+
]);
151+
152+
/* EXECUTE */
153+
$promise = $this->api->chatStreamRequest($chatData, 2048);
154+
155+
/* ASSERT */
156+
$response = $promise->wait();
157+
$this->assertNotNull($response);
158+
$this->assertIsArray($response);
159+
$this->assertEquals('chat.completion.chunk', $response[0]->object);
160+
$this->assertNotNull(Arr::get($response[0]->choices[0], 'delta'));
161+
}
162+
163+
/**
164+
* @test
165+
*/
166+
public function it_responds_error_data_when_stream_request_is_made_to_chat_completion_function()
167+
{
168+
/* SETUP */
169+
$chatData = new ChatData([
170+
'messages' => [
171+
$this->messageData,
172+
],
173+
'model' => $this->model,
174+
'max_tokens' => $this->maxTokens,
175+
'stream' => true,
176+
]);
177+
178+
/* EXECUTE */
179+
$response = $this->api->chatRequest($chatData);
180+
181+
/* ASSERT */
182+
$this->assertEquals(400, $response->code);
183+
$this->assertEquals('For stream chat completion please use "chatStreamRequest" method instead!', $response->message);
184+
}
185+
93186
/**
94187
* @test
95188
*/

0 commit comments

Comments
 (0)