From 4a47838d76b20f77f7b8c5e2172dfdeef9e2cc9a Mon Sep 17 00:00:00 2001 From: Sven Olsen Date: Sat, 18 Jul 2026 17:23:46 -0700 Subject: [PATCH] fix(anthropic): map refusal and context-window stop reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic API can end a response with stop_reason "refusal" (safety classifier intervention) or "model_context_window_exceeded", but both fell through to FinishReasonUnknown, leaving clients unable to distinguish them from a missing stop reason. Map "refusal" to FinishReasonContentFilter, matching how the OpenAI and Google providers already surface their native content-filter stops, and map "model_context_window_exceeded" to FinishReasonLength alongside "max_tokens". 💘 Generated with Crush Assisted-by: Kimi K3 (max thinking) via Crush --- providers/anthropic/anthropic.go | 4 +++- providers/anthropic/anthropic_test.go | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index a1816bd8f..007a4edbf 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -1268,10 +1268,12 @@ func mapFinishReason(finishReason string) fantasy.FinishReason { switch finishReason { case "end_turn", "pause_turn", "stop_sequence": return fantasy.FinishReasonStop - case "max_tokens": + case "max_tokens", "model_context_window_exceeded": return fantasy.FinishReasonLength case "tool_use": return fantasy.FinishReasonToolCalls + case "refusal": + return fantasy.FinishReasonContentFilter default: return fantasy.FinishReasonUnknown } diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 2ee0cfe70..c4854be99 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -3237,3 +3237,26 @@ func TestStream_TruncatedWithoutStopReason(t *testing.T) { require.True(t, providerErr.IsRetryable()) require.ErrorIs(t, providerErr.Cause, io.ErrUnexpectedEOF) } + +func TestMapFinishReason(t *testing.T) { + t.Parallel() + + tests := []struct { + reason string + expected fantasy.FinishReason + }{ + {"end_turn", fantasy.FinishReasonStop}, + {"pause_turn", fantasy.FinishReasonStop}, + {"stop_sequence", fantasy.FinishReasonStop}, + {"max_tokens", fantasy.FinishReasonLength}, + {"model_context_window_exceeded", fantasy.FinishReasonLength}, + {"tool_use", fantasy.FinishReasonToolCalls}, + {"refusal", fantasy.FinishReasonContentFilter}, + {"", fantasy.FinishReasonUnknown}, + {"unrecognized_future_reason", fantasy.FinishReasonUnknown}, + } + + for _, tc := range tests { + require.Equal(t, tc.expected, mapFinishReason(tc.reason), "stop_reason %q", tc.reason) + } +}