From 017461627ed575c0fc6881fb9654572a11e9fa6d Mon Sep 17 00:00:00 2001 From: Haoran Shu <43215937+ComeOnOliver@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:34:54 -0700 Subject: [PATCH] fix(core): preserve unmapped finish_reason and add 'refusal' mapping Previously, map_finish_reason() would default to 'stop' for any unmapped finish_reason, which is lossy. This restores the previous behavior of passing through unmapped values as-is, so callers can handle provider-specific finish reasons correctly. Also adds 'refusal' to the finish reason map and type, as Anthropic can return this stop reason (see https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons#refusal). Fixes #23793 --- litellm/litellm_core_utils/core_helpers.py | 8 +++++--- litellm/types/llms/openai.py | 1 + .../litellm_core_utils/test_core_helpers.py | 11 ++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 256b16ff312..3a16a0ca127 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -98,16 +98,18 @@ _FINISH_REASON_MAP: dict[str, OpenAIChatCompletionFinishReason] = { "content_filter": "content_filter", # Anthropic Sonnet 4 "content_filtered": "content_filter", + # Anthropic refusal + "refusal": "refusal", } -def map_finish_reason(finish_reason: str) -> OpenAIChatCompletionFinishReason: +def map_finish_reason(finish_reason: str) -> str: mapped = _FINISH_REASON_MAP.get(finish_reason) if mapped is None: verbose_logger.warning( - "Unmapped finish_reason '%s', defaulting to 'stop'", finish_reason + "Unmapped finish_reason '%s', passing through as-is", finish_reason ) - return "stop" + return finish_reason return mapped diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index a2df3f2e0d6..cd204122a83 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -2131,6 +2131,7 @@ OpenAIChatCompletionFinishReason = Literal[ "function_call", "tool_calls", "length", + "refusal", "guardrail_intervened", "eos", "finish_reason_unspecified", diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index 72c3b2b077e..02e0cce6779 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -55,7 +55,7 @@ def test_reconstruct_model_name_returns_original_for_other_providers(): # map_finish_reason tests # --------------------------------------------------------------------------- -VALID_OPENAI_FINISH_REASONS = {"stop", "length", "tool_calls", "function_call", "content_filter"} +VALID_OPENAI_FINISH_REASONS = {"stop", "length", "tool_calls", "function_call", "content_filter", "refusal"} class TestMapFinishReasonAnthropic: @@ -68,6 +68,7 @@ class TestMapFinishReasonAnthropic: ("tool_use", "tool_calls"), ("compaction", "length"), ("content_filtered", "content_filter"), + ("refusal", "refusal"), ], ) def test_anthropic_finish_reasons(self, provider_reason: str, expected: str) -> None: @@ -132,11 +133,11 @@ class TestMapFinishReasonOpenAIPassthrough: class TestMapFinishReasonUnknown: - def test_unknown_value_defaults_to_stop(self): - assert map_finish_reason("some_unknown_value") == "stop" + def test_unknown_value_passes_through(self): + assert map_finish_reason("some_unknown_value") == "some_unknown_value" - def test_empty_string_defaults_to_stop(self): - assert map_finish_reason("") == "stop" + def test_empty_string_passes_through(self): + assert map_finish_reason("") == "" class TestFinishReasonMapOutputsAreValid: