diff --git a/src/platform/src/Bridge/Anthropic/ResultConverter.php b/src/platform/src/Bridge/Anthropic/ResultConverter.php index 92edde313..6916aa256 100644 --- a/src/platform/src/Bridge/Anthropic/ResultConverter.php +++ b/src/platform/src/Bridge/Anthropic/ResultConverter.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Anthropic; +use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\RawHttpResult; @@ -38,8 +39,16 @@ public function supports(Model $model): bool public function convert(RawHttpResult|RawResultInterface $result, array $options = []): ResultInterface { + $response = $result->getObject(); + + if (429 === $response->getStatusCode()) { + $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null; + $retryAfterValue = $retryAfter ? (float) $retryAfter : null; + throw new RateLimitExceededException($retryAfterValue); + } + if ($options['stream'] ?? false) { - return new StreamResult($this->convertStream($result->getObject())); + return new StreamResult($this->convertStream($response)); } $data = $result->getData(); diff --git a/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php b/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php index b9fdaa468..9e7cafe1d 100644 --- a/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php +++ b/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Platform\Bridge\Gemini\Gemini; use Symfony\AI\Platform\Bridge\Gemini\Gemini; +use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\ChoiceResult; @@ -42,8 +43,14 @@ public function supports(Model $model): bool public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface { + $response = $result->getObject(); + + if (429 === $response->getStatusCode()) { + throw new RateLimitExceededException(); + } + if ($options['stream'] ?? false) { - return new StreamResult($this->convertStream($result->getObject())); + return new StreamResult($this->convertStream($response)); } $data = $result->getData(); diff --git a/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php index 8f9acd63a..43592cb27 100644 --- a/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php +++ b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php @@ -14,6 +14,7 @@ use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Exception\AuthenticationException; use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\ChoiceResult; @@ -50,8 +51,21 @@ public function convert(RawResultInterface|RawHttpResult $result, array $options throw new AuthenticationException($errorMessage); } + if (429 === $response->getStatusCode()) { + $headers = $response->getHeaders(false); + $resetTime = null; + + if (isset($headers['x-ratelimit-reset-requests'][0])) { + $resetTime = self::parseResetTime($headers['x-ratelimit-reset-requests'][0]); + } elseif (isset($headers['x-ratelimit-reset-tokens'][0])) { + $resetTime = self::parseResetTime($headers['x-ratelimit-reset-tokens'][0]); + } + + throw new RateLimitExceededException($resetTime); + } + if ($options['stream'] ?? false) { - return new StreamResult($this->convertStream($result->getObject())); + return new StreamResult($this->convertStream($response)); } $data = $result->getData(); @@ -194,4 +208,25 @@ private function convertToolCall(array $toolCall): ToolCall return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); } + + /** + * Converts OpenAI's reset time format (e.g. "1s", "6m0s", "2m30s") into seconds. + * + * Supported formats: + * - "1s" + * - "6m0s" + * - "2m30s" + */ + private static function parseResetTime(string $resetTime): float + { + $seconds = 0; + + if (preg_match('/^(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) { + $minutes = isset($matches[1]) ? (int) $matches[1] : 0; + $secs = isset($matches[2]) ? (int) $matches[2] : 0; + $seconds = ($minutes * 60) + $secs; + } + + return (float) $seconds; + } } diff --git a/src/platform/src/Exception/RateLimitExceededException.php b/src/platform/src/Exception/RateLimitExceededException.php new file mode 100644 index 000000000..b64520b56 --- /dev/null +++ b/src/platform/src/Exception/RateLimitExceededException.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Floran Pagliai + */ +final class RateLimitExceededException extends RuntimeException +{ + public function __construct( + private readonly ?float $retryAfter = null, + ) { + parent::__construct('Rate limit exceeded.'); + } + + public function getRetryAfter(): ?float + { + return $this->retryAfter; + } +} diff --git a/src/platform/tests/Bridge/Anthropic/ResultConverterRateLimitTest.php b/src/platform/tests/Bridge/Anthropic/ResultConverterRateLimitTest.php new file mode 100644 index 000000000..86d9a0492 --- /dev/null +++ b/src/platform/tests/Bridge/Anthropic/ResultConverterRateLimitTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Anthropic; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Anthropic\ResultConverter; +use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(ResultConverter::class)] +#[Small] +final class ResultConverterRateLimitTest extends TestCase +{ + public function testRateLimitExceededThrowsException() + { + $httpClient = new MockHttpClient([ + new MockResponse('{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the rate limit for your organization"}}', [ + 'http_code' => 429, + 'response_headers' => [ + 'retry-after' => '60', + ], + ]), + ]); + + $httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages'); + $handler = new ResultConverter(); + + $this->expectException(RateLimitExceededException::class); + $this->expectExceptionMessage('Rate limit exceeded'); + + try { + $handler->convert(new RawHttpResult($httpResponse)); + } catch (RateLimitExceededException $e) { + $this->assertSame(60.0, $e->getRetryAfter()); + throw $e; + } + } + + public function testRateLimitExceededWithoutRetryAfter() + { + $httpClient = new MockHttpClient([ + new MockResponse('{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the rate limit for your organization"}}', [ + 'http_code' => 429, + ]), + ]); + + $httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages'); + $handler = new ResultConverter(); + + $this->expectException(RateLimitExceededException::class); + $this->expectExceptionMessage('Rate limit exceeded'); + + try { + $handler->convert(new RawHttpResult($httpResponse)); + } catch (RateLimitExceededException $e) { + $this->assertNull($e->getRetryAfter()); + throw $e; + } + } +} diff --git a/src/platform/tests/Bridge/Gemini/Gemini/ResultConverterRateLimitTest.php b/src/platform/tests/Bridge/Gemini/Gemini/ResultConverterRateLimitTest.php new file mode 100644 index 000000000..f180ebc90 --- /dev/null +++ b/src/platform/tests/Bridge/Gemini/Gemini/ResultConverterRateLimitTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Gemini\Gemini; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Gemini\Gemini\ResultConverter; +use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(ResultConverter::class)] +#[Small] +final class ResultConverterRateLimitTest extends TestCase +{ + public function testRateLimitExceededThrowsException() + { + $httpClient = new MockHttpClient([ + new MockResponse('{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}', [ + 'http_code' => 429, + ]), + ]); + + $httpResponse = $httpClient->request('POST', 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent'); + $handler = new ResultConverter(); + + $this->expectException(RateLimitExceededException::class); + $this->expectExceptionMessage('Rate limit exceeded.'); + + try { + $handler->convert(new RawHttpResult($httpResponse)); + } catch (RateLimitExceededException $e) { + $this->assertNull($e->getRetryAfter()); + throw $e; + } + } +} diff --git a/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterRateLimitTest.php b/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterRateLimitTest.php new file mode 100644 index 000000000..b3aeff43f --- /dev/null +++ b/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterRateLimitTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAi\Gpt; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter; +use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(ResultConverter::class)] +#[Small] +final class ResultConverterRateLimitTest extends TestCase +{ + public function testRateLimitExceededWithRequestsResetTime() + { + $httpClient = new MockHttpClient([ + new MockResponse('{"error":{"message":"Rate limit reached for requests","type":"rate_limit_error"}}', [ + 'http_code' => 429, + 'response_headers' => [ + 'x-ratelimit-limit-requests' => '60', + 'x-ratelimit-remaining-requests' => '0', + 'x-ratelimit-reset-requests' => '20s', + ], + ]), + ]); + + $httpResponse = $httpClient->request('POST', 'https://api.openai.com/v1/chat/completions'); + $handler = new ResultConverter(); + + $this->expectException(RateLimitExceededException::class); + $this->expectExceptionMessage('Rate limit exceeded.'); + + try { + $handler->convert(new RawHttpResult($httpResponse)); + } catch (RateLimitExceededException $e) { + $this->assertSame(20.0, $e->getRetryAfter()); + throw $e; + } + } + + public function testRateLimitExceededWithTokensResetTime() + { + $httpClient = new MockHttpClient([ + new MockResponse('{"error":{"message":"Rate limit reached for tokens","type":"rate_limit_error"}}', [ + 'http_code' => 429, + 'response_headers' => [ + 'x-ratelimit-limit-tokens' => '150000', + 'x-ratelimit-remaining-tokens' => '0', + 'x-ratelimit-reset-tokens' => '2m30s', + ], + ]), + ]); + + $httpResponse = $httpClient->request('POST', 'https://api.openai.com/v1/chat/completions'); + $handler = new ResultConverter(); + + $this->expectException(RateLimitExceededException::class); + $this->expectExceptionMessage('Rate limit exceeded.'); + + try { + $handler->convert(new RawHttpResult($httpResponse)); + } catch (RateLimitExceededException $e) { + $this->assertSame(150.0, $e->getRetryAfter()); + throw $e; + } + } +}