Skip to content

Commit c90c43a

Browse files
michabbbclaudePlytas
authored
implement context caching (#135)
* Add context caching support * Fix validation and request handling * add claude workflow config * Claude PR Assistant workflow * Claude Code Review workflow * Add input validation to UpdateRequest - Add name parameter validation (empty string and invalid characters) - Add TTL format validation for duration format (e.g., "60s", "120.5s") - Follow same validation pattern as DeleteRequest for consistency 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix code style: Update not operator spacing in UpdateRequest 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * nothing has changed 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix PHPStan warning for runtime API inconsistency in UsageMetadata Add @phpstan-ignore nullCoalesce.offset for promptTokenCount field. The Gemini API documentation specifies that promptTokenCount is required, but in practice the API sometimes returns responses without this field (particularly for cached content operations). This causes runtime errors when the field is missing. The ?? 0 fallback handles the real-world API behavior, while the PHPStan ignore comment suppresses the static analysis warning that claims the offset always exists (which contradicts actual API responses). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Make promptTokenCount optional in UsageMetadata type definition Replace @phpstan-ignore with proper type definition that reflects real API behavior. Changed promptTokenCount from required (int) to optional (?int) in the DocBlock. Cache creation operations may not involve LLM interactions, resulting in incomplete usage metadata where promptTokenCount and other fields can be missing from the API response. This fix resolves PHPStan warnings across all PHP versions (8.1-8.4) while maintaining the existing runtime fallback behavior. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove Claude workflow configurations * Fix usage for named arguments --------- Co-authored-by: micha <mbladowski@macropage.de> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Vytautas Smilingis <vytautas@whatagraph.com>
1 parent 3925639 commit c90c43a

23 files changed

Lines changed: 805 additions & 4 deletions

src/Client.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66

77
use BackedEnum;
88
use Gemini\Contracts\ClientContract;
9+
use Gemini\Contracts\Resources\CachedContentsContract;
910
use Gemini\Contracts\Resources\FilesContract;
1011
use Gemini\Contracts\Resources\GenerativeModelContract;
1112
use Gemini\Contracts\TransporterContract;
1213
use Gemini\Enums\ModelType;
14+
use Gemini\Resources\CachedContents;
1315
use Gemini\Resources\ChatSession;
1416
use Gemini\Resources\EmbeddingModel;
1517
use Gemini\Resources\Files;
@@ -74,4 +76,9 @@ public function files(): FilesContract
7476
{
7577
return new Files($this->transporter);
7678
}
79+
80+
public function cachedContents(): CachedContentsContract
81+
{
82+
return new CachedContents($this->transporter);
83+
}
7784
}

src/Contracts/ClientContract.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Gemini\Contracts;
66

77
use BackedEnum;
8+
use Gemini\Contracts\Resources\CachedContentsContract;
89
use Gemini\Contracts\Resources\ChatSessionContract;
910
use Gemini\Contracts\Resources\EmbeddingModalContract;
1011
use Gemini\Contracts\Resources\FilesContract;
@@ -32,4 +33,6 @@ public function embeddingModel(BackedEnum|string $model): EmbeddingModalContract
3233
public function chat(BackedEnum|string $model): ChatSessionContract;
3334

3435
public function files(): FilesContract;
36+
37+
public function cachedContents(): CachedContentsContract;
3538
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Gemini\Contracts\Resources;
6+
7+
use BackedEnum;
8+
use Gemini\Data\Blob;
9+
use Gemini\Data\Content;
10+
use Gemini\Data\Tool;
11+
use Gemini\Data\ToolConfig;
12+
use Gemini\Data\UploadedFile;
13+
use Gemini\Responses\CachedContents\ListResponse;
14+
use Gemini\Responses\CachedContents\MetadataResponse;
15+
16+
interface CachedContentsContract
17+
{
18+
/**
19+
* @param array<Tool> $tools
20+
* @param string|Blob|array<string|Blob|UploadedFile>|Content|UploadedFile ...$parts
21+
*/
22+
public function create(
23+
BackedEnum|string $model,
24+
?Content $systemInstruction = null,
25+
array $tools = [],
26+
?ToolConfig $toolConfig = null,
27+
?string $ttl = null,
28+
?string $displayName = null,
29+
string|Blob|array|Content|UploadedFile ...$parts,
30+
): MetadataResponse;
31+
32+
public function retrieve(string $name): MetadataResponse;
33+
34+
public function list(?int $pageSize = null, ?string $pageToken = null): ListResponse;
35+
36+
public function update(string $name, ?string $ttl = null, ?string $expireTime = null): MetadataResponse;
37+
38+
public function delete(string $name): void;
39+
}

src/Data/CachedContent.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Gemini\Data;
6+
7+
use Gemini\Contracts\Arrayable;
8+
use InvalidArgumentException;
9+
10+
/**
11+
* Metadata about cached content.
12+
*
13+
* @link https://ai.google.dev/api/caching#CachedContent
14+
*/
15+
final class CachedContent implements Arrayable
16+
{
17+
public function __construct(
18+
public readonly string $name,
19+
public readonly string $model,
20+
public readonly ?string $displayName,
21+
public readonly UsageMetadata $usageMetadata,
22+
public readonly string $createTime,
23+
public readonly string $updateTime,
24+
public readonly string $expireTime,
25+
) {}
26+
27+
/**
28+
* @param array<string, mixed> $attributes
29+
*/
30+
public static function from(array $attributes): self
31+
{
32+
33+
if (! is_string($attributes['name'] ?? null)) {
34+
throw new InvalidArgumentException('Name must be a string');
35+
}
36+
37+
if (! is_string($attributes['model'] ?? null)) {
38+
throw new InvalidArgumentException('Model must be a string');
39+
}
40+
41+
if (isset($attributes['displayName']) && ! is_string($attributes['displayName'])) {
42+
throw new InvalidArgumentException('DisplayName must be a string');
43+
}
44+
45+
if (! is_array($attributes['usageMetadata'] ?? null)) {
46+
throw new InvalidArgumentException('UsageMetadata must be an array');
47+
}
48+
49+
if (! is_string($attributes['createTime'] ?? null)) {
50+
throw new InvalidArgumentException('CreateTime must be a string');
51+
}
52+
53+
if (! is_string($attributes['updateTime'] ?? null)) {
54+
throw new InvalidArgumentException('UpdateTime must be a string');
55+
}
56+
57+
if (! is_string($attributes['expireTime'] ?? null)) {
58+
throw new InvalidArgumentException('ExpireTime must be a string');
59+
}
60+
61+
$name = $attributes['name'];
62+
$model = $attributes['model'];
63+
/** @var string|null $displayName */
64+
$displayName = $attributes['displayName'] ?? null;
65+
66+
/**
67+
* @var array{
68+
* promptTokenCount: int,
69+
* totalTokenCount: int,
70+
* candidatesTokenCount: ?int,
71+
* cachedContentTokenCount: ?int,
72+
* toolUsePromptTokenCount: ?int,
73+
* thoughtsTokenCount: ?int,
74+
* promptTokensDetails: list<array{modality: string, tokenCount: int}>|null,
75+
* cacheTokensDetails: list<array{modality: string, tokenCount: int}>|null,
76+
* candidatesTokensDetails: list<array{modality: string, tokenCount: int}>|null,
77+
* toolUsePromptTokensDetails: list<array{modality: string, tokenCount: int}>|null
78+
* } $usageMetadataData
79+
*/
80+
$usageMetadataData = $attributes['usageMetadata'];
81+
$usageMetadata = UsageMetadata::from($usageMetadataData);
82+
$createTime = $attributes['createTime'];
83+
$updateTime = $attributes['updateTime'];
84+
$expireTime = $attributes['expireTime'];
85+
86+
return new self(
87+
name: $name,
88+
model: $model,
89+
displayName: $displayName,
90+
usageMetadata: $usageMetadata,
91+
createTime: $createTime,
92+
updateTime: $updateTime,
93+
expireTime: $expireTime,
94+
);
95+
}
96+
97+
public function toArray(): array
98+
{
99+
return [
100+
'name' => $this->name,
101+
'model' => $this->model,
102+
'displayName' => $this->displayName,
103+
'usageMetadata' => $this->usageMetadata->toArray(),
104+
'createTime' => $this->createTime,
105+
'updateTime' => $this->updateTime,
106+
'expireTime' => $this->expireTime,
107+
];
108+
}
109+
}

src/Data/UsageMetadata.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ public function __construct(
3939
) {}
4040

4141
/**
42-
* @param array{ promptTokenCount: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list<array{ modality: string, tokenCount: int}>|null, cacheTokensDetails: list<array{ modality: string, tokenCount: int}>|null, candidatesTokensDetails: list<array{ modality: string, tokenCount: int}>|null, toolUsePromptTokensDetails: list<array{ modality: string, tokenCount: int}>|null } $attributes
42+
* @param array{ promptTokenCount?: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list<array{ modality: string, tokenCount: int}>|null, cacheTokensDetails: list<array{ modality: string, tokenCount: int}>|null, candidatesTokensDetails: list<array{ modality: string, tokenCount: int}>|null, toolUsePromptTokensDetails: list<array{ modality: string, tokenCount: int}>|null } $attributes
4343
*/
4444
public static function from(array $attributes): self
4545
{
4646
return new self(
47-
promptTokenCount: $attributes['promptTokenCount'],
47+
// Cache creation operations may not involve LLM interactions, resulting in incomplete usage metadata
48+
promptTokenCount: $attributes['promptTokenCount'] ?? 0,
4849
totalTokenCount: $attributes['totalTokenCount'],
4950
candidatesTokenCount: $attributes['candidatesTokenCount'] ?? null,
5051
cachedContentTokenCount: $attributes['cachedContentTokenCount'] ?? null,

src/Enums/Method.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ enum Method: string
88
{
99
case GET = 'GET';
1010
case POST = 'POST';
11+
case PATCH = 'PATCH';
1112
case PUT = 'PUT';
1213
case DELETE = 'DELETE';
1314
}

src/Foundation/Request.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public function toRequest(string $baseUrl, array $headers = [], array $queryPara
7070

7171
$body = null;
7272

73-
if ($this->method === Method::POST) {
73+
if (in_array($this->method, [Method::POST, Method::PATCH, Method::PUT], true)) {
7474
$parameters = match (true) {
7575
method_exists($this, 'body') => $this->body(),
7676
default => [],
@@ -82,7 +82,9 @@ public function toRequest(string $baseUrl, array $headers = [], array $queryPara
8282
$request = $psr17Factory->createRequest($this->method->value, $uri);
8383

8484
if ($body instanceof StreamInterface) {
85-
$request = $request->withBody($body);
85+
$request = $request
86+
->withHeader('Content-Type', 'application/json')
87+
->withBody($body);
8688
}
8789

8890
foreach ($headers as $name => $value) {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Gemini\Requests\CachedContents;
6+
7+
use Gemini\Concerns\HasContents;
8+
use Gemini\Data\Blob;
9+
use Gemini\Data\Content;
10+
use Gemini\Data\Tool;
11+
use Gemini\Data\ToolConfig;
12+
use Gemini\Data\UploadedFile;
13+
use Gemini\Enums\Method;
14+
use Gemini\Foundation\Request;
15+
use Gemini\Requests\Concerns\HasJsonBody;
16+
17+
class CreateRequest extends Request
18+
{
19+
use HasContents;
20+
use HasJsonBody;
21+
22+
protected Method $method = Method::POST;
23+
24+
/**
25+
* @param array<Tool> $tools
26+
* @param array<int, string|Blob|array<string|Blob|UploadedFile>|Content|UploadedFile> $parts
27+
*/
28+
public function __construct(
29+
protected readonly string $model,
30+
protected readonly ?Content $systemInstruction = null,
31+
protected readonly array $tools = [],
32+
protected readonly ?ToolConfig $toolConfig = null,
33+
protected readonly ?string $ttl = null,
34+
protected readonly ?string $displayName = null,
35+
/** @var array<int, string|Blob|array<string|Blob|UploadedFile>|Content|UploadedFile> */
36+
protected array $parts = [],
37+
) {}
38+
39+
public function resolveEndpoint(): string
40+
{
41+
return 'cachedContents';
42+
}
43+
44+
/**
45+
* @return array<string, mixed>
46+
*/
47+
protected function defaultBody(): array
48+
{
49+
return array_filter([
50+
'model' => $this->model,
51+
'contents' => array_map(
52+
static fn (Content $c): array => $c->toArray(),
53+
$this->partsToContents(...$this->parts)
54+
),
55+
'systemInstruction' => $this->systemInstruction?->toArray(),
56+
'tools' => array_map(static fn (Tool $t): array => $t->toArray(), $this->tools),
57+
'toolConfig' => $this->toolConfig?->toArray(),
58+
'ttl' => $this->ttl,
59+
'displayName' => $this->displayName,
60+
], static fn ($v) => $v !== null);
61+
}
62+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Gemini\Requests\CachedContents;
6+
7+
use Gemini\Enums\Method;
8+
use Gemini\Foundation\Request;
9+
use InvalidArgumentException;
10+
11+
class DeleteRequest extends Request
12+
{
13+
protected Method $method = Method::DELETE;
14+
15+
public function __construct(protected readonly string $name)
16+
{
17+
if ($name === '') {
18+
throw new InvalidArgumentException('Name cannot be empty');
19+
}
20+
21+
if (! preg_match('/^[a-zA-Z0-9\/_-]+$/', $name)) {
22+
throw new InvalidArgumentException('Name contains invalid characters');
23+
}
24+
}
25+
26+
public function resolveEndpoint(): string
27+
{
28+
return $this->name;
29+
}
30+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Gemini\Requests\CachedContents;
6+
7+
use Gemini\Enums\Method;
8+
use Gemini\Foundation\Request;
9+
10+
class ListRequest extends Request
11+
{
12+
protected Method $method = Method::GET;
13+
14+
public function __construct(
15+
protected readonly ?int $pageSize = null,
16+
protected readonly ?string $pageToken = null,
17+
) {}
18+
19+
public function resolveEndpoint(): string
20+
{
21+
return 'cachedContents';
22+
}
23+
24+
public function defaultQuery(): array
25+
{
26+
return array_filter([
27+
'pageSize' => $this->pageSize,
28+
'pageToken' => $this->pageToken,
29+
], static fn ($v) => $v !== null);
30+
}
31+
}

0 commit comments

Comments
 (0)