diff --git a/src/Client.php b/src/Client.php index 70f26f0..ecad008 100644 --- a/src/Client.php +++ b/src/Client.php @@ -6,10 +6,12 @@ use BackedEnum; use Gemini\Contracts\ClientContract; +use Gemini\Contracts\Resources\CachedContentsContract; use Gemini\Contracts\Resources\FilesContract; use Gemini\Contracts\Resources\GenerativeModelContract; use Gemini\Contracts\TransporterContract; use Gemini\Enums\ModelType; +use Gemini\Resources\CachedContents; use Gemini\Resources\ChatSession; use Gemini\Resources\EmbeddingModel; use Gemini\Resources\Files; @@ -74,4 +76,9 @@ public function files(): FilesContract { return new Files($this->transporter); } + + public function cachedContents(): CachedContentsContract + { + return new CachedContents($this->transporter); + } } diff --git a/src/Contracts/ClientContract.php b/src/Contracts/ClientContract.php index 67ace82..356f621 100644 --- a/src/Contracts/ClientContract.php +++ b/src/Contracts/ClientContract.php @@ -5,6 +5,7 @@ namespace Gemini\Contracts; use BackedEnum; +use Gemini\Contracts\Resources\CachedContentsContract; use Gemini\Contracts\Resources\ChatSessionContract; use Gemini\Contracts\Resources\EmbeddingModalContract; use Gemini\Contracts\Resources\FilesContract; @@ -32,4 +33,6 @@ public function embeddingModel(BackedEnum|string $model): EmbeddingModalContract public function chat(BackedEnum|string $model): ChatSessionContract; public function files(): FilesContract; + + public function cachedContents(): CachedContentsContract; } diff --git a/src/Contracts/Resources/CachedContentsContract.php b/src/Contracts/Resources/CachedContentsContract.php new file mode 100644 index 0000000..bc587a1 --- /dev/null +++ b/src/Contracts/Resources/CachedContentsContract.php @@ -0,0 +1,39 @@ + $tools + * @param string|Blob|array|Content|UploadedFile ...$parts + */ + public function create( + BackedEnum|string $model, + ?Content $systemInstruction = null, + array $tools = [], + ?ToolConfig $toolConfig = null, + ?string $ttl = null, + ?string $displayName = null, + string|Blob|array|Content|UploadedFile ...$parts, + ): MetadataResponse; + + public function retrieve(string $name): MetadataResponse; + + public function list(?int $pageSize = null, ?string $pageToken = null): ListResponse; + + public function update(string $name, ?string $ttl = null, ?string $expireTime = null): MetadataResponse; + + public function delete(string $name): void; +} diff --git a/src/Data/CachedContent.php b/src/Data/CachedContent.php new file mode 100644 index 0000000..d2ebb8f --- /dev/null +++ b/src/Data/CachedContent.php @@ -0,0 +1,109 @@ + $attributes + */ + public static function from(array $attributes): self + { + + if (! is_string($attributes['name'] ?? null)) { + throw new InvalidArgumentException('Name must be a string'); + } + + if (! is_string($attributes['model'] ?? null)) { + throw new InvalidArgumentException('Model must be a string'); + } + + if (isset($attributes['displayName']) && ! is_string($attributes['displayName'])) { + throw new InvalidArgumentException('DisplayName must be a string'); + } + + if (! is_array($attributes['usageMetadata'] ?? null)) { + throw new InvalidArgumentException('UsageMetadata must be an array'); + } + + if (! is_string($attributes['createTime'] ?? null)) { + throw new InvalidArgumentException('CreateTime must be a string'); + } + + if (! is_string($attributes['updateTime'] ?? null)) { + throw new InvalidArgumentException('UpdateTime must be a string'); + } + + if (! is_string($attributes['expireTime'] ?? null)) { + throw new InvalidArgumentException('ExpireTime must be a string'); + } + + $name = $attributes['name']; + $model = $attributes['model']; + /** @var string|null $displayName */ + $displayName = $attributes['displayName'] ?? null; + + /** + * @var array{ + * promptTokenCount: int, + * totalTokenCount: int, + * candidatesTokenCount: ?int, + * cachedContentTokenCount: ?int, + * toolUsePromptTokenCount: ?int, + * thoughtsTokenCount: ?int, + * promptTokensDetails: list|null, + * cacheTokensDetails: list|null, + * candidatesTokensDetails: list|null, + * toolUsePromptTokensDetails: list|null + * } $usageMetadataData + */ + $usageMetadataData = $attributes['usageMetadata']; + $usageMetadata = UsageMetadata::from($usageMetadataData); + $createTime = $attributes['createTime']; + $updateTime = $attributes['updateTime']; + $expireTime = $attributes['expireTime']; + + return new self( + name: $name, + model: $model, + displayName: $displayName, + usageMetadata: $usageMetadata, + createTime: $createTime, + updateTime: $updateTime, + expireTime: $expireTime, + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'model' => $this->model, + 'displayName' => $this->displayName, + 'usageMetadata' => $this->usageMetadata->toArray(), + 'createTime' => $this->createTime, + 'updateTime' => $this->updateTime, + 'expireTime' => $this->expireTime, + ]; + } +} diff --git a/src/Data/UsageMetadata.php b/src/Data/UsageMetadata.php index b4d3354..94ed145 100644 --- a/src/Data/UsageMetadata.php +++ b/src/Data/UsageMetadata.php @@ -39,12 +39,13 @@ public function __construct( ) {} /** - * @param array{ promptTokenCount: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list|null, cacheTokensDetails: list|null, candidatesTokensDetails: list|null, toolUsePromptTokensDetails: list|null } $attributes + * @param array{ promptTokenCount?: int, totalTokenCount: int, candidatesTokenCount: ?int, cachedContentTokenCount: ?int, toolUsePromptTokenCount: ?int, thoughtsTokenCount: ?int, promptTokensDetails: list|null, cacheTokensDetails: list|null, candidatesTokensDetails: list|null, toolUsePromptTokensDetails: list|null } $attributes */ public static function from(array $attributes): self { return new self( - promptTokenCount: $attributes['promptTokenCount'], + // Cache creation operations may not involve LLM interactions, resulting in incomplete usage metadata + promptTokenCount: $attributes['promptTokenCount'] ?? 0, totalTokenCount: $attributes['totalTokenCount'], candidatesTokenCount: $attributes['candidatesTokenCount'] ?? null, cachedContentTokenCount: $attributes['cachedContentTokenCount'] ?? null, diff --git a/src/Enums/Method.php b/src/Enums/Method.php index 010db0c..7693f76 100644 --- a/src/Enums/Method.php +++ b/src/Enums/Method.php @@ -8,6 +8,7 @@ enum Method: string { case GET = 'GET'; case POST = 'POST'; + case PATCH = 'PATCH'; case PUT = 'PUT'; case DELETE = 'DELETE'; } diff --git a/src/Foundation/Request.php b/src/Foundation/Request.php index 17b9a49..6d9e74e 100644 --- a/src/Foundation/Request.php +++ b/src/Foundation/Request.php @@ -70,7 +70,7 @@ public function toRequest(string $baseUrl, array $headers = [], array $queryPara $body = null; - if ($this->method === Method::POST) { + if (in_array($this->method, [Method::POST, Method::PATCH, Method::PUT], true)) { $parameters = match (true) { method_exists($this, 'body') => $this->body(), default => [], @@ -82,7 +82,9 @@ public function toRequest(string $baseUrl, array $headers = [], array $queryPara $request = $psr17Factory->createRequest($this->method->value, $uri); if ($body instanceof StreamInterface) { - $request = $request->withBody($body); + $request = $request + ->withHeader('Content-Type', 'application/json') + ->withBody($body); } foreach ($headers as $name => $value) { diff --git a/src/Requests/CachedContents/CreateRequest.php b/src/Requests/CachedContents/CreateRequest.php new file mode 100644 index 0000000..a85dfeb --- /dev/null +++ b/src/Requests/CachedContents/CreateRequest.php @@ -0,0 +1,62 @@ + $tools + * @param array|Content|UploadedFile> $parts + */ + public function __construct( + protected readonly string $model, + protected readonly ?Content $systemInstruction = null, + protected readonly array $tools = [], + protected readonly ?ToolConfig $toolConfig = null, + protected readonly ?string $ttl = null, + protected readonly ?string $displayName = null, + /** @var array|Content|UploadedFile> */ + protected array $parts = [], + ) {} + + public function resolveEndpoint(): string + { + return 'cachedContents'; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return array_filter([ + 'model' => $this->model, + 'contents' => array_map( + static fn (Content $c): array => $c->toArray(), + $this->partsToContents(...$this->parts) + ), + 'systemInstruction' => $this->systemInstruction?->toArray(), + 'tools' => array_map(static fn (Tool $t): array => $t->toArray(), $this->tools), + 'toolConfig' => $this->toolConfig?->toArray(), + 'ttl' => $this->ttl, + 'displayName' => $this->displayName, + ], static fn ($v) => $v !== null); + } +} diff --git a/src/Requests/CachedContents/DeleteRequest.php b/src/Requests/CachedContents/DeleteRequest.php new file mode 100644 index 0000000..3566188 --- /dev/null +++ b/src/Requests/CachedContents/DeleteRequest.php @@ -0,0 +1,30 @@ +name; + } +} diff --git a/src/Requests/CachedContents/ListRequest.php b/src/Requests/CachedContents/ListRequest.php new file mode 100644 index 0000000..97d94fc --- /dev/null +++ b/src/Requests/CachedContents/ListRequest.php @@ -0,0 +1,31 @@ + $this->pageSize, + 'pageToken' => $this->pageToken, + ], static fn ($v) => $v !== null); + } +} diff --git a/src/Requests/CachedContents/RetrieveRequest.php b/src/Requests/CachedContents/RetrieveRequest.php new file mode 100644 index 0000000..ec5d3cc --- /dev/null +++ b/src/Requests/CachedContents/RetrieveRequest.php @@ -0,0 +1,30 @@ +name; + } +} diff --git a/src/Requests/CachedContents/UpdateRequest.php b/src/Requests/CachedContents/UpdateRequest.php new file mode 100644 index 0000000..8788eac --- /dev/null +++ b/src/Requests/CachedContents/UpdateRequest.php @@ -0,0 +1,51 @@ +name; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return array_filter([ + 'ttl' => $this->ttl, + 'expireTime' => $this->expireTime, + ], static fn ($v) => $v !== null); + } +} diff --git a/src/Resources/CachedContents.php b/src/Resources/CachedContents.php new file mode 100644 index 0000000..259e874 --- /dev/null +++ b/src/Resources/CachedContents.php @@ -0,0 +1,87 @@ +|Content|UploadedFile ...$parts + * @param array $tools + */ + public function create( + BackedEnum|string $model, + ?Content $systemInstruction = null, + array $tools = [], + ?ToolConfig $toolConfig = null, + ?string $ttl = null, + ?string $displayName = null, + string|Blob|array|Content|UploadedFile ...$parts, + ): MetadataResponse { + /** @var array|Content|UploadedFile> $parts */ + /** @var ResponseDTO,createTime:string,updateTime:string,expireTime:string}> $response */ + $response = $this->transporter->request(new CreateRequest( + model: $this->parseModel($model), + systemInstruction: $systemInstruction, + tools: $tools, + toolConfig: $toolConfig, + ttl: $ttl, + displayName: $displayName, + parts: array_values($parts), + )); + + return MetadataResponse::from($response->data()); + } + + public function retrieve(string $name): MetadataResponse + { + /** @var ResponseDTO,createTime:string,updateTime:string,expireTime:string}> $response */ + $response = $this->transporter->request(new RetrieveRequest($name)); + + return MetadataResponse::from($response->data()); + } + + public function list(?int $pageSize = null, ?string $pageToken = null): ListResponse + { + /** @var ResponseDTO>|null,nextPageToken:string|null}> $response */ + $response = $this->transporter->request(new ListRequest($pageSize, $pageToken)); + + return ListResponse::from($response->data()); + } + + public function update(string $name, ?string $ttl = null, ?string $expireTime = null): MetadataResponse + { + /** @var ResponseDTO,createTime:string,updateTime:string,expireTime:string}> $response */ + $response = $this->transporter->request(new UpdateRequest($name, $ttl, $expireTime)); + + return MetadataResponse::from($response->data()); + } + + public function delete(string $name): void + { + $this->transporter->request(new DeleteRequest($name)); + } +} diff --git a/src/Responses/CachedContents/ListResponse.php b/src/Responses/CachedContents/ListResponse.php new file mode 100644 index 0000000..1f9a569 --- /dev/null +++ b/src/Responses/CachedContents/ListResponse.php @@ -0,0 +1,38 @@ + $cachedContents + */ + public function __construct(public readonly array $cachedContents, public readonly ?string $nextPageToken = null) {} + + /** + * @param array{cachedContents:?array>, nextPageToken:?string} $attributes + */ + public static function from(array $attributes): self + { + return new self( + cachedContents: array_map(fn (array $c) => CachedContent::from($c), $attributes['cachedContents'] ?? []), + nextPageToken: $attributes['nextPageToken'] ?? null, + ); + } + + public function toArray(): array + { + return [ + 'cachedContents' => array_map(fn (CachedContent $c) => $c->toArray(), $this->cachedContents), + 'nextPageToken' => $this->nextPageToken, + ]; + } +} diff --git a/src/Responses/CachedContents/MetadataResponse.php b/src/Responses/CachedContents/MetadataResponse.php new file mode 100644 index 0000000..364f78b --- /dev/null +++ b/src/Responses/CachedContents/MetadataResponse.php @@ -0,0 +1,29 @@ +, createTime: string, updateTime: string, expireTime: string} $attributes + */ + public static function from(array $attributes): self + { + return new self(cachedContent: CachedContent::from($attributes)); + } + + public function toArray(): array + { + return $this->cachedContent->toArray(); + } +} diff --git a/src/Testing/ClientFake.php b/src/Testing/ClientFake.php index 2e87b08..edf2d78 100644 --- a/src/Testing/ClientFake.php +++ b/src/Testing/ClientFake.php @@ -11,6 +11,7 @@ use Gemini\Responses\StreamResponse; use Gemini\Testing\FunctionCalls\TestFunctionCall; use Gemini\Testing\Requests\TestRequest; +use Gemini\Testing\Resources\CachedContentsTestResource; use Gemini\Testing\Resources\ChatSessionTestResource; use Gemini\Testing\Resources\EmbeddingModelTestResource; use Gemini\Testing\Resources\FilesTestResource; @@ -248,4 +249,9 @@ public function files(): FilesTestResource { return new FilesTestResource(fake: $this); } + + public function cachedContents(): CachedContentsTestResource + { + return new CachedContentsTestResource(fake: $this); + } } diff --git a/src/Testing/Resources/CachedContentsTestResource.php b/src/Testing/Resources/CachedContentsTestResource.php new file mode 100644 index 0000000..5d1996c --- /dev/null +++ b/src/Testing/Resources/CachedContentsTestResource.php @@ -0,0 +1,57 @@ +record(method: __FUNCTION__, args: func_get_args()); + } + + public function retrieve(string $name): MetadataResponse + { + return $this->record(method: __FUNCTION__, args: func_get_args()); + } + + public function list(?int $pageSize = null, ?string $pageToken = null): ListResponse + { + return $this->record(method: __FUNCTION__, args: func_get_args()); + } + + public function update(string $name, ?string $ttl = null, ?string $expireTime = null): MetadataResponse + { + return $this->record(method: __FUNCTION__, args: func_get_args()); + } + + public function delete(string $name): void + { + $this->record(method: __FUNCTION__, args: func_get_args()); + } +} diff --git a/src/Testing/Responses/Fixtures/CachedContents/ListResponseFixture.php b/src/Testing/Responses/Fixtures/CachedContents/ListResponseFixture.php new file mode 100644 index 0000000..74616ad --- /dev/null +++ b/src/Testing/Responses/Fixtures/CachedContents/ListResponseFixture.php @@ -0,0 +1,15 @@ + [ + MetadataResponseFixture::ATTRIBUTES, + ], + 'nextPageToken' => null, + ]; +} diff --git a/src/Testing/Responses/Fixtures/CachedContents/MetadataResponseFixture.php b/src/Testing/Responses/Fixtures/CachedContents/MetadataResponseFixture.php new file mode 100644 index 0000000..2b735e5 --- /dev/null +++ b/src/Testing/Responses/Fixtures/CachedContents/MetadataResponseFixture.php @@ -0,0 +1,21 @@ + 'cachedContents/123-456', + 'model' => 'models/gemini-pro', + 'displayName' => 'A17_FlightPlan', + 'usageMetadata' => [ + 'promptTokenCount' => 0, + 'totalTokenCount' => 0, + ], + 'createTime' => '2014-10-02T15:01:23Z', + 'updateTime' => '2014-10-02T16:01:23Z', + 'expireTime' => '2014-10-02T17:01:23Z', + ]; +} diff --git a/tests/Resources/CachedContents.php b/tests/Resources/CachedContents.php new file mode 100644 index 0000000..7ba9fa6 --- /dev/null +++ b/tests/Resources/CachedContents.php @@ -0,0 +1,45 @@ +cachedContents()->create('models/gemini-pro'); + + expect($result)->toBeInstanceOf(MetadataResponse::class); +}); + +it('retrieve cache', function () { + $client = mockClient(method: Method::GET, endpoint: 'cachedContents/123', response: MetadataResponse::fake()); + + $result = $client->cachedContents()->retrieve('cachedContents/123'); + + expect($result)->toBeInstanceOf(MetadataResponse::class); +}); + +it('list caches', function () { + $client = mockClient(method: Method::GET, endpoint: 'cachedContents', response: ListResponse::fake()); + + $result = $client->cachedContents()->list(); + + expect($result)->toBeInstanceOf(ListResponse::class); +}); + +it('update cache', function () { + $client = mockClient(method: Method::PATCH, endpoint: 'cachedContents/123', response: MetadataResponse::fake()); + + $result = $client->cachedContents()->update('cachedContents/123'); + + expect($result)->toBeInstanceOf(MetadataResponse::class); +}); + +it('delete cache', function () { + $client = mockClient(method: Method::DELETE, endpoint: 'cachedContents/123', response: MetadataResponse::fake()); + + $client->cachedContents()->delete('cachedContents/123'); + + expect(true)->toBeTrue(); +}); diff --git a/tests/Responses/CachedContents/ListResponse.php b/tests/Responses/CachedContents/ListResponse.php new file mode 100644 index 0000000..8712c2c --- /dev/null +++ b/tests/Responses/CachedContents/ListResponse.php @@ -0,0 +1,35 @@ +toArray()); + + expect($response) + ->toBeInstanceOf(ListResponse::class) + ->cachedContents->each->toBeInstanceOf(CachedContent::class); +}); + +it('fake', function () { + $response = ListResponse::fake(); + + expect($response->cachedContents)->toHaveCount(1); +}); + +it('to array', function () { + $attributes = ListResponse::fake()->toArray(); + $response = ListResponse::from($attributes); + + expect($response->toArray()) + ->toBeArray() + ->toBe($attributes); +}); + +it('fake with override', function () { + $response = ListResponse::fake([ + 'nextPageToken' => 'next', + ]); + + expect($response->nextPageToken)->toBe('next'); +}); diff --git a/tests/Responses/CachedContents/MetadataResponse.php b/tests/Responses/CachedContents/MetadataResponse.php new file mode 100644 index 0000000..cd7704c --- /dev/null +++ b/tests/Responses/CachedContents/MetadataResponse.php @@ -0,0 +1,36 @@ +toArray()); + + expect($response) + ->toBeInstanceOf(MetadataResponse::class) + ->cachedContent->toBeInstanceOf(CachedContent::class); +}); + +it('fake', function () { + $response = MetadataResponse::fake(); + + expect($response) + ->cachedContent->name->toBe('cachedContents/123-456'); +}); + +it('to array', function () { + $attributes = MetadataResponse::fake()->toArray(); + $response = MetadataResponse::from($attributes); + + expect($response->toArray()) + ->toBeArray() + ->toBe($attributes); +}); + +it('fake with override', function () { + $response = MetadataResponse::fake([ + 'name' => 'cachedContents/987-654', + ]); + + expect($response->cachedContent->name)->toBe('cachedContents/987-654'); +}); diff --git a/tests/Testing/Resources/CachedContentsTestResource.php b/tests/Testing/Resources/CachedContentsTestResource.php new file mode 100644 index 0000000..48a4012 --- /dev/null +++ b/tests/Testing/Resources/CachedContentsTestResource.php @@ -0,0 +1,66 @@ +cachedContents()->create('models/gemini-pro'); + + $fake->assertSent(resource: CachedContents::class, callback: function ($method) { + return $method === 'create'; + }); +}); + +it('records a retrieve request', function () { + $fake = new ClientFake([ + MetadataResponse::fake(), + ]); + + $fake->cachedContents()->retrieve('cachedContents/123'); + + $fake->assertSent(resource: CachedContents::class, callback: function ($method, $parameters) { + return $method === 'retrieve' && $parameters[0] === 'cachedContents/123'; + }); +}); + +it('records a list request', function () { + $fake = new ClientFake([ + ListResponse::fake(), + ]); + + $fake->cachedContents()->list(); + + $fake->assertSent(resource: CachedContents::class, callback: function ($method) { + return $method === 'list'; + }); +}); + +it('records an update request', function () { + $fake = new ClientFake([ + MetadataResponse::fake(), + ]); + + $fake->cachedContents()->update('cachedContents/123'); + + $fake->assertSent(resource: CachedContents::class, callback: function ($method, $parameters) { + return $method === 'update' && $parameters[0] === 'cachedContents/123'; + }); +}); + +it('records a delete request', function () { + $fake = new ClientFake([ + MetadataResponse::fake(), + ]); + + $fake->cachedContents()->delete('cachedContents/123'); + + $fake->assertSent(resource: CachedContents::class, callback: function ($method, $parameters) { + return $method === 'delete' && $parameters[0] === 'cachedContents/123'; + }); +});