Skip to content

Commit 1993641

Browse files
committed
Add streaming response generation
Closes #21
1 parent 3eafe66 commit 1993641

File tree

6 files changed

+250
-0
lines changed

6 files changed

+250
-0
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ _This library is not developed or endorsed by Google._
2222
- [Multimodal input](#multimodal-input)
2323
- [Chat Session (Multi-Turn Conversations)](#chat-session-multi-turn-conversations)
2424
- [Chat Session with history](#chat-session-with-history)
25+
- [Streaming responses](#streaming-responses)
2526
- [Tokens counting](#tokens-counting)
2627
- [Listing models](#listing-models)
2728

@@ -150,6 +151,34 @@ func main() {
150151
This code will print "Hello World!" to the standard output.
151152
```
152153

154+
### Streaming responses
155+
156+
In the streaming response, the callback function will be called whenever a response is returned from the server.
157+
158+
Long responses may be broken into separate responses, and you can start receiving responses faster using a content stream.
159+
160+
```php
161+
$client = new GeminiAPI\Client('GEMINI_API_KEY');
162+
163+
$callback = function (GenerateContentResponse $response): void {
164+
static $count = 0;
165+
166+
print "\nResponse #{$count}\n";
167+
print $response->text();
168+
$count++;
169+
};
170+
171+
$client->geminiPro()->generateContentStream(
172+
$callback,
173+
new TextPart('PHP in less than 100 chars')
174+
);
175+
// Response #0
176+
// PHP: a versatile, general-purpose scripting language for web development, popular for
177+
// Response #1
178+
// its simple syntax and rich library of functions.
179+
```
180+
181+
153182
### Embed Content
154183

155184
```php

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
"phpstan/phpstan": "^1.10.50",
3636
"phpunit/phpunit": "^10.5"
3737
},
38+
"suggest": {
39+
"ext-curl": "Required for streaming responses"
40+
},
3841
"autoload": {
3942
"psr-4": {
4043
"GeminiAPI\\": "src/"

src/Client.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
namespace GeminiAPI;
66

7+
use BadMethodCallException;
8+
use CurlHandle;
79
use GeminiAPI\ClientInterface as GeminiClientInterface;
810
use GeminiAPI\Enums\ModelName;
11+
use GeminiAPI\Json\ObjectListParser;
912
use GeminiAPI\Requests\CountTokensRequest;
1013
use GeminiAPI\Requests\EmbedContentRequest;
1114
use GeminiAPI\Requests\GenerateContentRequest;
15+
use GeminiAPI\Requests\GenerateContentStreamRequest;
1216
use GeminiAPI\Requests\ListModelsRequest;
1317
use GeminiAPI\Requests\RequestInterface;
1418
use GeminiAPI\Responses\CountTokensResponse;
@@ -23,6 +27,11 @@
2327
use Psr\Http\Message\StreamFactoryInterface;
2428
use RuntimeException;
2529

30+
use function curl_close;
31+
use function curl_exec;
32+
use function curl_init;
33+
use function curl_setopt;
34+
use function extension_loaded;
2635
use function json_decode;
2736

2837
class Client implements GeminiClientInterface
@@ -76,6 +85,38 @@ public function generateContent(GenerateContentRequest $request): GenerateConten
7685
return GenerateContentResponse::fromArray($json);
7786
}
7887

88+
/**
89+
* @param callable(GenerateContentResponse): void $callback
90+
* @throws BadMethodCallException
91+
* @throws RuntimeException
92+
*/
93+
public function generateContentStream(
94+
GenerateContentStreamRequest $request,
95+
callable $callback,
96+
): void {
97+
if (!extension_loaded('curl')) {
98+
throw new BadMethodCallException('Gemini API requires `curl` extension for streaming responses');
99+
}
100+
101+
$parser = new ObjectListParser(
102+
/* @phpstan-ignore-next-line */
103+
static fn (array $arr) => $callback(GenerateContentResponse::fromArray($arr)),
104+
);
105+
106+
$ch = curl_init("{$this->baseUrl}/v1/{$request->getOperation()}");
107+
curl_setopt($ch, CURLOPT_POST, true);
108+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request));
109+
curl_setopt($ch, CURLOPT_HTTPHEADER, [
110+
'Content-type: application/json',
111+
self::API_KEY_HEADER_NAME . ": {$this->apiKey}",
112+
]);
113+
curl_setopt($ch, CURLOPT_WRITEFUNCTION,
114+
static fn (CurlHandle $ch, string $str): int => $parser->consume($str),
115+
);
116+
curl_exec($ch);
117+
curl_close($ch);
118+
}
119+
79120
/**
80121
* @throws ClientExceptionInterface
81122
*/

src/GenerativeModel.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
namespace GeminiAPI;
66

7+
use BadMethodCallException;
78
use GeminiAPI\Enums\ModelName;
89
use GeminiAPI\Enums\Role;
910
use GeminiAPI\Requests\CountTokensRequest;
1011
use GeminiAPI\Requests\GenerateContentRequest;
12+
use GeminiAPI\Requests\GenerateContentStreamRequest;
1113
use GeminiAPI\Responses\CountTokensResponse;
1214
use GeminiAPI\Responses\GenerateContentResponse;
1315
use GeminiAPI\Resources\Content;
@@ -58,6 +60,28 @@ public function generateContentWithContents(array $contents): GenerateContentRes
5860
return $this->client->generateContent($request);
5961
}
6062

63+
/**
64+
* @param callable(GenerateContentResponse): void $callback
65+
* @param PartInterface ...$parts
66+
* @return void
67+
* @throws BadMethodCallException
68+
*/
69+
public function generateContentStream(
70+
callable $callback,
71+
PartInterface ...$parts,
72+
): void {
73+
$content = new Content($parts, Role::User);
74+
75+
$request = new GenerateContentStreamRequest(
76+
$this->modelName,
77+
[$content],
78+
$this->safetySettings,
79+
$this->generationConfig,
80+
);
81+
82+
$this->client->generateContentStream($request, $callback);
83+
}
84+
6185
public function startChat(): ChatSession
6286
{
6387
return new ChatSession($this);

src/Json/ObjectListParser.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace GeminiAPI\Json;
6+
7+
use RuntimeException;
8+
9+
class ObjectListParser
10+
{
11+
private int $depth = 0;
12+
private bool $inString = false;
13+
private bool $inEscape = false;
14+
private string $json = '';
15+
16+
/** @var callable(array): void */
17+
private $callback; // @phpstan-ignore-line
18+
19+
/**
20+
* @phpstan-ignore-next-line
21+
* @param callable(array): void $callback
22+
*/
23+
public function __construct(callable $callback) {
24+
$this->callback = $callback;
25+
}
26+
27+
/**
28+
* @param string $str
29+
* @return int
30+
* @throws RuntimeException
31+
*/
32+
public function consume(string $str): int
33+
{
34+
$offset = 0;
35+
for ($i = 0; $i < strlen($str); $i++) {
36+
if ($this->inEscape) {
37+
$this->inEscape = false;
38+
} elseif ($this->inString) {
39+
if ($str[$i] === '\\') {
40+
$this->inEscape = true;
41+
} elseif ($str[$i] === '"') {
42+
$this->inString = false;
43+
}
44+
} elseif ($str[$i] === '"') {
45+
$this->inString = true;
46+
} elseif ($str[$i] === '{') {
47+
if ($this->depth === 0) {
48+
$offset = $i;
49+
}
50+
$this->depth++;
51+
} elseif ($str[$i] === '}') {
52+
$this->depth--;
53+
if ($this->depth === 0) {
54+
$this->json .= substr($str, $offset, $i - $offset + 1);
55+
$arr = json_decode($this->json, true);
56+
57+
if (json_last_error() !== JSON_ERROR_NONE) {
58+
throw new RuntimeException('ObjectListParser could not decode the given message');
59+
}
60+
61+
($this->callback)($arr);
62+
$this->json = '';
63+
$offset = $i + 1;
64+
}
65+
}
66+
}
67+
68+
$this->json .= substr($str, $offset) ?: '';
69+
70+
return strlen($str);
71+
}
72+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace GeminiAPI\Requests;
6+
7+
use GeminiAPI\Enums\ModelName;
8+
use GeminiAPI\GenerationConfig;
9+
use GeminiAPI\SafetySetting;
10+
use GeminiAPI\Traits\ArrayTypeValidator;
11+
use GeminiAPI\Resources\Content;
12+
use JsonSerializable;
13+
14+
use function json_encode;
15+
16+
class GenerateContentStreamRequest implements JsonSerializable, RequestInterface
17+
{
18+
use ArrayTypeValidator;
19+
20+
/**
21+
* @param ModelName $modelName
22+
* @param Content[] $contents
23+
* @param SafetySetting[] $safetySettings
24+
* @param GenerationConfig|null $generationConfig
25+
*/
26+
public function __construct(
27+
public readonly ModelName $modelName,
28+
public readonly array $contents,
29+
public readonly array $safetySettings = [],
30+
public readonly ?GenerationConfig $generationConfig = null,
31+
) {
32+
$this->ensureArrayOfType($this->contents, Content::class);
33+
$this->ensureArrayOfType($this->safetySettings, SafetySetting::class);
34+
}
35+
36+
public function getOperation(): string
37+
{
38+
return "{$this->modelName->value}:streamGenerateContent";
39+
}
40+
41+
public function getHttpMethod(): string
42+
{
43+
return 'POST';
44+
}
45+
46+
public function getHttpPayload(): string
47+
{
48+
return (string) $this;
49+
}
50+
51+
/**
52+
* @return array{
53+
* model: string,
54+
* contents: Content[],
55+
* safetySettings?: SafetySetting[],
56+
* generationConfig?: GenerationConfig,
57+
* }
58+
*/
59+
public function jsonSerialize(): array
60+
{
61+
$arr = [
62+
'model' => $this->modelName->value,
63+
'contents' => $this->contents,
64+
];
65+
66+
if (!empty($this->safetySettings)) {
67+
$arr['safetySettings'] = $this->safetySettings;
68+
}
69+
70+
if ($this->generationConfig) {
71+
$arr['generationConfig'] = $this->generationConfig;
72+
}
73+
74+
return $arr;
75+
}
76+
77+
public function __toString(): string
78+
{
79+
return json_encode($this) ?: '';
80+
}
81+
}

0 commit comments

Comments
 (0)