diff --git a/src/Providers/Gemini/Concerns/ProcessesRateLimits.php b/src/Providers/Gemini/Concerns/ProcessesRateLimits.php new file mode 100644 index 000000000..0e06269f1 --- /dev/null +++ b/src/Providers/Gemini/Concerns/ProcessesRateLimits.php @@ -0,0 +1,79 @@ + $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 $responseData + */ + protected function extractRetryAfterSeconds(array $responseData): ?int + { + $retryDelay = data_get($this->responseDetail($responseData, 'RetryInfo'), 'retryDelay'); + + if (is_string($retryDelay) && preg_match('/^(?\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) { + return (int) $matches['seconds']; + } + + return null; + } + + /** + * @param array $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 $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; + } +} diff --git a/src/Providers/Gemini/Gemini.php b/src/Providers/Gemini/Gemini.php index f8e845aaa..5673bbcf7 100644 --- a/src/Providers/Gemini/Gemini.php +++ b/src/Providers/Gemini/Gemini.php @@ -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; @@ -36,6 +37,7 @@ class Gemini extends Provider { use InitializesClient; + use ProcessesRateLimits; public function __construct( #[\SensitiveParameter] public readonly string $apiKey, @@ -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), }; diff --git a/tests/Providers/Gemini/ExceptionHandlingTest.php b/tests/Providers/Gemini/ExceptionHandlingTest.php index 4805aa461..eec31bd78 100644 --- a/tests/Providers/Gemini/ExceptionHandlingTest.php +++ b/tests/Providers/Gemini/ExceptionHandlingTest.php @@ -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);