From d501c33a9dc5b25a3afbfab22df3bb07e77da355 Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 10 Mar 2026 18:43:51 -0300 Subject: [PATCH] feat(types): expose native_finish_reason in provider_specific_fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider's finish_reason is mapped to a different OpenAI-compatible value (e.g. "MALFORMED_FUNCTION_CALL" → "stop"), the original value is now preserved in choices[].provider_specific_fields["native_finish_reason"]. This allows agent loops to distinguish between different stop conditions without breaking the unified OpenAI-compatible finish_reason mapping. Also returns a defensive copy from get_finish_reason_mapping() to prevent accidental mutation of the global _FINISH_REASON_MAP. --- docs/my-website/docs/completion/output.md | 22 ++++++++ .../vertex_and_google_ai_studio_gemini.py | 2 +- litellm/types/utils.py | 6 +- tests/test_litellm/types/test_types_utils.py | 56 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/my-website/docs/completion/output.md b/docs/my-website/docs/completion/output.md index f705bc9f31..a7f26a0ec3 100644 --- a/docs/my-website/docs/completion/output.md +++ b/docs/my-website/docs/completion/output.md @@ -51,6 +51,28 @@ Here's what an example response looks like } ``` +## Native Finish Reason + +LiteLLM maps all provider-specific `finish_reason` values to OpenAI-compatible values (`stop`, `length`, `tool_calls`, `function_call`, `content_filter`). When the original provider value differs from the mapped value, it is preserved in `provider_specific_fields["native_finish_reason"]`. + +This is useful for agent loops that need to distinguish between different stop conditions (e.g., Gemini's `MALFORMED_FUNCTION_CALL` vs a normal `stop`). + +```python +response = completion(model="gemini/gemini-2.0-flash", messages=messages) + +choice = response.choices[0] +print(choice.finish_reason) # "stop" (OpenAI-compatible) + +# Access the original provider value when it differs: +if hasattr(choice, "provider_specific_fields") and choice.provider_specific_fields: + native = choice.provider_specific_fields.get("native_finish_reason") + if native == "MALFORMED_FUNCTION_CALL": + # Handle malformed function call differently from a normal stop + pass +``` + +When the provider already returns an OpenAI-compatible value (e.g., `stop`), `native_finish_reason` is not set. + ## Additional Attributes You can also access information like latency. diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 2a737f2693..74d5ad330e 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -1241,7 +1241,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): """ from litellm.litellm_core_utils.core_helpers import _FINISH_REASON_MAP - return _FINISH_REASON_MAP + return dict(_FINISH_REASON_MAP) def translate_exception_str(self, exception_string: str): if ( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index b5d5c06924..5096013469 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1326,7 +1326,11 @@ class Choices(SafeAttributeModel, OpenAIObject): **params, ): if finish_reason is not None: - params["finish_reason"] = map_finish_reason(finish_reason) + mapped = map_finish_reason(finish_reason) + params["finish_reason"] = mapped + if finish_reason != mapped: + provider_specific_fields = provider_specific_fields or {} + provider_specific_fields["native_finish_reason"] = finish_reason else: params["finish_reason"] = "stop" if index is not None: diff --git a/tests/test_litellm/types/test_types_utils.py b/tests/test_litellm/types/test_types_utils.py index 8c20ace98a..ccd467a1f7 100644 --- a/tests/test_litellm/types/test_types_utils.py +++ b/tests/test_litellm/types/test_types_utils.py @@ -223,3 +223,59 @@ def test_chat_completion_token_logprob_invalid_top_logprobs_rejected(): logprob=-0.31725305, top_logprobs="invalid_string", ) + + +# --------------------------------------------------------------------------- +# native_finish_reason in provider_specific_fields +# --------------------------------------------------------------------------- + + +class TestNativeFinishReason: + """Choices exposes the raw provider finish_reason in provider_specific_fields + when it differs from the mapped OpenAI-compatible value.""" + + def test_provider_reason_exposed_when_mapped(self): + from litellm.types.utils import Choices + + choice = Choices(finish_reason="end_turn") + assert choice.finish_reason == "stop" + assert choice.provider_specific_fields["native_finish_reason"] == "end_turn" + + def test_provider_reason_not_set_when_already_openai(self): + from litellm.types.utils import Choices + + choice = Choices(finish_reason="stop") + assert choice.finish_reason == "stop" + assert not hasattr(choice, "provider_specific_fields") + + def test_provider_reason_merged_with_existing_fields(self): + from litellm.types.utils import Choices + + choice = Choices( + finish_reason="max_tokens", + provider_specific_fields={"citations": [{"url": "http://example.com"}]}, + ) + assert choice.finish_reason == "length" + assert choice.provider_specific_fields["native_finish_reason"] == "max_tokens" + assert choice.provider_specific_fields["citations"] == [{"url": "http://example.com"}] + + def test_gemini_safety_reason_exposed(self): + from litellm.types.utils import Choices + + choice = Choices(finish_reason="SAFETY") + assert choice.finish_reason == "content_filter" + assert choice.provider_specific_fields["native_finish_reason"] == "SAFETY" + + def test_anthropic_tool_use_reason_exposed(self): + from litellm.types.utils import Choices + + choice = Choices(finish_reason="tool_use") + assert choice.finish_reason == "tool_calls" + assert choice.provider_specific_fields["native_finish_reason"] == "tool_use" + + def test_max_tokens_reason_exposed(self): + from litellm.types.utils import Choices + + choice = Choices(finish_reason="MAX_TOKENS") + assert choice.finish_reason == "length" + assert choice.provider_specific_fields["native_finish_reason"] == "MAX_TOKENS"