From d916b3fb24512164772ca46e906ce92dd6be271d Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 29 Mar 2026 11:22:43 +0200 Subject: [PATCH] fix(gemini,groq): include finish reason details in unhandled finish reason exceptions The default branch in the finish reason match threw a generic "unhandled finish reason" message without including what the actual reason was, making debugging difficult. Now includes both the mapped FinishReason enum value and the raw provider finish reason string. Affects Gemini Text, Gemini Structured, and Groq Text handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Providers/Gemini/Handlers/Structured.php | 6 ++++- src/Providers/Gemini/Handlers/Text.php | 6 ++++- src/Providers/Groq/Handlers/Text.php | 6 ++++- .../generate-structured-content-filter-1.json | 27 +++++++++++++++++++ .../generate-text-content-filter-1.json | 27 +++++++++++++++++++ .../Providers/Gemini/GeminiStructuredTest.php | 19 +++++++++++++ tests/Providers/Gemini/GeminiTextTest.php | 12 +++++++++ 7 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/gemini/generate-structured-content-filter-1.json create mode 100644 tests/Fixtures/gemini/generate-text-content-filter-1.json diff --git a/src/Providers/Gemini/Handlers/Structured.php b/src/Providers/Gemini/Handlers/Structured.php index 6d4870f59..9625504c7 100644 --- a/src/Providers/Gemini/Handlers/Structured.php +++ b/src/Providers/Gemini/Handlers/Structured.php @@ -68,7 +68,11 @@ public function handle(Request $request): StructuredResponse return match ($finishReason) { FinishReason::ToolCalls => $this->handleToolCalls($data, $request), FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $finishReason), - default => throw new PrismException('Gemini: unhandled finish reason'), + default => throw new PrismException(sprintf( + 'Gemini: unhandled finish reason "%s" (raw: %s)', + $finishReason->value, + data_get($data, 'candidates.0.finishReason', 'unknown'), + )), }; } diff --git a/src/Providers/Gemini/Handlers/Text.php b/src/Providers/Gemini/Handlers/Text.php index fce051b67..ae5df02f3 100644 --- a/src/Providers/Gemini/Handlers/Text.php +++ b/src/Providers/Gemini/Handlers/Text.php @@ -59,7 +59,11 @@ public function handle(Request $request): TextResponse return match ($finishReason) { FinishReason::ToolCalls => $this->handleToolCalls($data, $request), FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $finishReason), - default => throw new PrismException('Gemini: unhandled finish reason'), + default => throw new PrismException(sprintf( + 'Gemini: unhandled finish reason "%s" (raw: %s)', + $finishReason->value, + data_get($data, 'candidates.0.finishReason', 'unknown'), + )), }; } diff --git a/src/Providers/Groq/Handlers/Text.php b/src/Providers/Groq/Handlers/Text.php index a88c3007e..aaa11e4dc 100644 --- a/src/Providers/Groq/Handlers/Text.php +++ b/src/Providers/Groq/Handlers/Text.php @@ -51,7 +51,11 @@ public function handle(Request $request): TextResponse return match ($finishReason) { FinishReason::ToolCalls => $this->handleToolCalls($data, $request, $response), FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $response, $finishReason), - default => throw new PrismException('Groq: unhandled finish reason'), + default => throw new PrismException(sprintf( + 'Groq: unhandled finish reason "%s" (raw: %s)', + $finishReason->value, + data_get($data, 'choices.0.finish_reason', 'unknown'), + )), }; } diff --git a/tests/Fixtures/gemini/generate-structured-content-filter-1.json b/tests/Fixtures/gemini/generate-structured-content-filter-1.json new file mode 100644 index 000000000..8cb28c4d6 --- /dev/null +++ b/tests/Fixtures/gemini/generate-structured-content-filter-1.json @@ -0,0 +1,27 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "" + } + ], + "role": "model" + }, + "finishReason": "SAFETY", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "HIGH" + } + ] + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 0, + "totalTokenCount": 10 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/gemini/generate-text-content-filter-1.json b/tests/Fixtures/gemini/generate-text-content-filter-1.json new file mode 100644 index 000000000..8cb28c4d6 --- /dev/null +++ b/tests/Fixtures/gemini/generate-text-content-filter-1.json @@ -0,0 +1,27 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "" + } + ], + "role": "model" + }, + "finishReason": "SAFETY", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "HIGH" + } + ] + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 0, + "totalTokenCount": 10 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Providers/Gemini/GeminiStructuredTest.php b/tests/Providers/Gemini/GeminiStructuredTest.php index c05af645f..e16bc3843 100644 --- a/tests/Providers/Gemini/GeminiStructuredTest.php +++ b/tests/Providers/Gemini/GeminiStructuredTest.php @@ -7,6 +7,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\Provider; +use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Facades\Prism; use Prism\Prism\Schema\AnyOfSchema; use Prism\Prism\Schema\ArraySchema; @@ -404,3 +405,21 @@ expect($response->steps[0]->additionalContent['thoughtSummaries'])->toBeArray(); expect($response->steps[0]->additionalContent['thoughtSummaries'][0])->toContain('Let me think about'); }); + +it('includes finish reason details in exception for content filter on structured output', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-structured-content-filter'); + + expect(fn () => Prism::structured() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withSchema(new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('answer', 'The answer', true), + ], + requiredFields: ['answer'], + )) + ->withPrompt('Test prompt') + ->asStructured() + )->toThrow(PrismException::class, 'Gemini: unhandled finish reason "content-filter" (raw: SAFETY)'); +}); diff --git a/tests/Providers/Gemini/GeminiTextTest.php b/tests/Providers/Gemini/GeminiTextTest.php index 354d86cb8..92d63f767 100644 --- a/tests/Providers/Gemini/GeminiTextTest.php +++ b/tests/Providers/Gemini/GeminiTextTest.php @@ -482,6 +482,18 @@ function (Request $request): bool { }); }); +describe('Error handling for Gemini', function (): void { + it('includes finish reason details in exception for content filter', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-content-filter'); + + expect(fn () => Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withPrompt('Test prompt') + ->asText() + )->toThrow(PrismException::class, 'Gemini: unhandled finish reason "content-filter" (raw: SAFETY)'); + }); +}); + describe('Cache support for Gemini', function (): void { it('can use a cache object with a text request', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/use-cache-with-text');