diff --git a/src/Enums/FinishReason.php b/src/Enums/FinishReason.php index f6fffa579..04817c5de 100644 --- a/src/Enums/FinishReason.php +++ b/src/Enums/FinishReason.php @@ -10,6 +10,8 @@ enum FinishReason: string case Length = 'length'; case ContentFilter = 'content-filter'; case ToolCalls = 'tool-calls'; + case Pause = 'pause'; + case Refusal = 'refusal'; case Error = 'error'; case Other = 'other'; case Unknown = 'unknown'; diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php index ee6b2e37a..77e170320 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -56,7 +56,14 @@ public function handle(): Response return match ($this->tempResponse->finishReason) { FinishReason::ToolCalls => $this->handleToolCalls(), FinishReason::Stop, FinishReason::Length => $this->handleStop(), - default => throw new PrismException('Anthropic: unknown finish reason'), + FinishReason::Pause => $this->handlePause(), + FinishReason::Refusal => throw new PrismException( + 'Anthropic: model refused to respond (stop_reason: refusal)' + ), + default => throw new PrismException(sprintf( + 'Anthropic: unhandled finish reason "%s"', + data_get($this->httpResponse->json(), 'stop_reason') ?? 'unknown' + )), }; } @@ -92,6 +99,28 @@ public static function buildHttpRequestPayload(PrismRequest $request): array 'cache_control' => $request->providerOptions('cache_control'), ]); } + /** + * Anthropic returns stop_reason="pause_turn" when a long-running server-side + * tool (e.g. web_search, web_fetch) needs the client to continue the turn. + * Per Anthropic's docs, the client should append the assistant message to + * the conversation and re-send the request unchanged so the model can resume. + */ + protected function handlePause(): Response + { + $this->addStep(); + + $this->request->addMessage(new AssistantMessage( + $this->tempResponse->text, + $this->tempResponse->toolCalls, + $this->tempResponse->additionalContent, + )); + + if ($this->responseBuilder->steps->count() < $this->request->maxSteps()) { + return $this->handle(); + } + + return $this->responseBuilder->toResponse(); + } protected function handleToolCalls(): Response { diff --git a/src/Providers/Anthropic/Maps/FinishReasonMap.php b/src/Providers/Anthropic/Maps/FinishReasonMap.php index af580c73b..ba322300d 100644 --- a/src/Providers/Anthropic/Maps/FinishReasonMap.php +++ b/src/Providers/Anthropic/Maps/FinishReasonMap.php @@ -14,6 +14,8 @@ public static function map(string $reason): FinishReason 'end_turn', 'stop_sequence' => FinishReason::Stop, 'tool_use' => FinishReason::ToolCalls, 'max_tokens' => FinishReason::Length, + 'pause_turn' => FinishReason::Pause, + 'refusal' => FinishReason::Refusal, default => FinishReason::Unknown, }; } diff --git a/tests/Fixtures/anthropic/generate-text-with-pause-turn-1.json b/tests/Fixtures/anthropic/generate-text-with-pause-turn-1.json new file mode 100644 index 000000000..ce50f019b --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-pause-turn-1.json @@ -0,0 +1 @@ +{"id":"msg_01PauseTurnExample","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Let me look that up for you."}],"stop_reason":"pause_turn","stop_sequence":null,"usage":{"input_tokens":42,"output_tokens":12}} diff --git a/tests/Fixtures/anthropic/generate-text-with-pause-turn-2.json b/tests/Fixtures/anthropic/generate-text-with-pause-turn-2.json new file mode 100644 index 000000000..ecc76e12d --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-pause-turn-2.json @@ -0,0 +1 @@ +{"id":"msg_01PauseTurnExampleResume","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Here is what I found: the answer is 42."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":58,"output_tokens":15}} diff --git a/tests/Fixtures/anthropic/generate-text-with-refusal-1.json b/tests/Fixtures/anthropic/generate-text-with-refusal-1.json new file mode 100644 index 000000000..d168be143 --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-refusal-1.json @@ -0,0 +1 @@ +{"id":"msg_01RefusalExample","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":""}],"stop_reason":"refusal","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":0}} diff --git a/tests/Providers/Anthropic/AnthropicTextTest.php b/tests/Providers/Anthropic/AnthropicTextTest.php index 55ec9f1fb..3719e0dc4 100644 --- a/tests/Providers/Anthropic/AnthropicTextTest.php +++ b/tests/Providers/Anthropic/AnthropicTextTest.php @@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\Citations\CitationSourcePositionType; use Prism\Prism\Enums\Citations\CitationSourceType; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; +use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Exceptions\PrismProviderOverloadedException; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Exceptions\PrismRequestTooLargeException; @@ -668,6 +670,52 @@ ->asText(); })->throws(PrismRequestTooLargeException::class); + + it('throws a descriptive exception when Anthropic returns a refusal stop_reason', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-refusal'); + + try { + Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withPrompt('Tell me something forbidden.') + ->asText(); + + $this->fail('Expected PrismException to be thrown.'); + } catch (PrismException $e) { + expect($e->getMessage())->toContain('refusal'); + } + }); +}); + +describe('pause_turn', function (): void { + it('resumes the turn when Anthropic returns stop_reason="pause_turn"', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-pause-turn'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withPrompt('Look something up for me.') + ->withMaxSteps(5) + ->asText(); + + // Two HTTP round-trips: the paused response, then the resumed completion. + expect($response->steps)->toHaveCount(2); + expect($response->steps->first()->finishReason)->toBe(FinishReason::Pause); + expect($response->steps->last()->finishReason)->toBe(FinishReason::Stop); + expect($response->text)->toContain('the answer is 42'); + }); + + it('stops resuming once maxSteps is reached', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-pause-turn'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withPrompt('Look something up for me.') + ->withMaxSteps(1) + ->asText(); + + expect($response->steps)->toHaveCount(1); + expect($response->steps->first()->finishReason)->toBe(FinishReason::Pause); + }); }); it('allows automatic caching enabled via providerOptions', function (): void {