Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/Providers/Gemini/Concerns/ProcessesRateLimits.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Prism\Prism\Providers\Gemini\Concerns;

use Illuminate\Support\Carbon;
use Prism\Prism\ValueObjects\ProviderRateLimit;

trait ProcessesRateLimits
{
/**
* @param array<string, mixed> $responseData
* @return ProviderRateLimit[]
*/
protected function processRateLimits(array $responseData): array
{
$quotaViolations = data_get($this->responseDetail($responseData, 'QuotaFailure'), 'violations', []);

if (! is_array($quotaViolations)) {
return [];
}

$resetsAt = $this->buildResetsAtFromResponse($responseData);

return collect($quotaViolations)
->filter(fn (mixed $violation): bool => is_array($violation))
->map(fn (array $violation): ProviderRateLimit => new ProviderRateLimit(
name: (string) data_get($violation, 'quotaId', data_get($violation, 'quotaMetric', 'quota')),
limit: $this->toNullableInt(data_get($violation, 'quotaValue')),
remaining: null,
resetsAt: $resetsAt
))
->all();
}

/**
* @param array<string, mixed> $responseData
*/
protected function extractRetryAfterSeconds(array $responseData): ?int
{
$retryDelay = data_get($this->responseDetail($responseData, 'RetryInfo'), 'retryDelay');

if (is_string($retryDelay) && preg_match('/^(?<seconds>\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) {
return (int) $matches['seconds'];
}

return null;
}

/**
* @param array<string, mixed> $responseData
*/
private function buildResetsAtFromResponse(array $responseData): ?Carbon
{
$retryAfter = $this->extractRetryAfterSeconds($responseData);

return $retryAfter === null ? null : Carbon::now()->addSeconds($retryAfter);
}

private function toNullableInt(mixed $value): ?int
{
return is_int($value) || (is_string($value) && preg_match('/^\d+$/', $value) === 1)
? (int) $value
: null;
}

/**
* @param array<string, mixed> $responseData
*/
private function responseDetail(array $responseData, string $type): mixed
{
$details = data_get($responseData, 'error.details', []);

return is_array($details)
? collect($details)->first(fn (mixed $detail): bool => data_get($detail, '@type') === "type.googleapis.com/google.rpc.$type")
: null;
}
}
9 changes: 8 additions & 1 deletion src/Providers/Gemini/Gemini.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Images\Request as ImagesRequest;
use Prism\Prism\Images\Response as ImagesResponse;
use Prism\Prism\Providers\Gemini\Concerns\ProcessesRateLimits;
use Prism\Prism\Providers\Gemini\Handlers\Audio;
use Prism\Prism\Providers\Gemini\Handlers\Cache;
use Prism\Prism\Providers\Gemini\Handlers\Embeddings;
Expand All @@ -36,6 +37,7 @@
class Gemini extends Provider
{
use InitializesClient;
use ProcessesRateLimits;

public function __construct(
#[\SensitiveParameter] public readonly string $apiKey,
Expand Down Expand Up @@ -110,8 +112,13 @@ public function stream(TextRequest $request): Generator

public function handleRequestException(string $model, RequestException $e): never
{
$responseData = $e->response->json() ?? [];

match ($e->response->getStatusCode()) {
429 => throw PrismRateLimitedException::make([]),
429 => throw PrismRateLimitedException::make(
rateLimits: $this->processRateLimits($responseData),
retryAfter: $this->extractRetryAfterSeconds($responseData)
),
503 => throw PrismProviderOverloadedException::make(class_basename($this)),
default => $this->handleResponseErrors($e),
};
Expand Down
39 changes: 39 additions & 0 deletions tests/Providers/Gemini/ExceptionHandlingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,45 @@ function createGeminiMockResponse(int $statusCode, array $json = []): Response
->toThrow(PrismRateLimitedException::class);
});

it('extracts retry_after and rate limit details from quota violations', function (): void {
$mockResponse = createGeminiMockResponse(429, [
'error' => [
'code' => 429,
'message' => 'You exceeded your current quota. Please retry in 35.458759309s.',
'status' => 'RESOURCE_EXHAUSTED',
'details' => [
[
'@type' => 'type.googleapis.com/google.rpc.QuotaFailure',
'violations' => [
[
'quotaMetric' => 'generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count',
'quotaId' => 'GenerateContentPaidTierInputTokensPerModelPerMinute',
'quotaValue' => '16000',
],
],
],
[
'@type' => 'type.googleapis.com/google.rpc.RetryInfo',
'retryDelay' => '35s',
],
],
],
]);
$exception = new RequestException($mockResponse);

try {
$this->provider->handleRequestException('gemini-pro', $exception);
$this->fail('Expected PrismRateLimitedException to be thrown.');
} catch (PrismRateLimitedException $e) {
expect($e->retryAfter)->toBe(35)
->and($e->rateLimits)->toHaveCount(1)
->and($e->rateLimits[0]->name)->toBe('GenerateContentPaidTierInputTokensPerModelPerMinute')
->and($e->rateLimits[0]->limit)->toBe(16000)
->and($e->rateLimits[0]->remaining)->toBeNull()
->and($e->rateLimits[0]->resetsAt)->not->toBeNull();
}
});

it('handles provider overloaded errors (503)', function (): void {
$mockResponse = createGeminiMockResponse(503, []);
$exception = new RequestException($mockResponse);
Expand Down
Loading