From a4fc73f892c535958ab184e2d9b4bd1b13071291 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 00:12:39 -0300 Subject: [PATCH 1/6] fix(completion): unify finish_reason mapping to OpenAI-compatible values Replace if/elif chain in map_finish_reason() with _FINISH_REASON_MAP dict covering all known provider values. Unknown values now default to "stop" with a warning log. Fix Gemini FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL returning non-OpenAI values. Add missing Gemini values (TOO_MANY_TOOL_CALLS, MALFORMED_RESPONSE). Clean OpenAIChatCompletionFinishReason type and OPENAI_FINISH_REASONS constant. Fixes #21744, #21041, #16651, #19744, #21348, #22003 --- litellm/constants.py | 5 +- .../google_genai/adapters/transformation.py | 2 - litellm/litellm_core_utils/core_helpers.py | 86 +++++++------- .../vertex_and_google_ai_studio_gemini.py | 6 +- litellm/types/llms/openai.py | 2 +- .../litellm_core_utils/test_core_helpers.py | 107 +++++++++++++++++- ...test_vertex_and_google_ai_studio_gemini.py | 30 +++-- 7 files changed, 174 insertions(+), 64 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index b1a0021bcc6..1b86c6b61aa 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1200,12 +1200,9 @@ OPENAI_FINISH_REASONS = [ "stop", "length", "function_call", + "tool_calls", "content_filter", "null", - "finish_reason_unspecified", - "malformed_function_call", - "guardrail_intervened", - "eos", ] HUMANLOOP_PROMPT_CACHE_TTL_SECONDS = int( os.getenv("HUMANLOOP_PROMPT_CACHE_TTL_SECONDS", 60) diff --git a/litellm/google_genai/adapters/transformation.py b/litellm/google_genai/adapters/transformation.py index 0a296012210..c5d9fd124fa 100644 --- a/litellm/google_genai/adapters/transformation.py +++ b/litellm/google_genai/adapters/transformation.py @@ -770,8 +770,6 @@ class GoogleGenAIAdapter: "content_filter": "SAFETY", "tool_calls": "STOP", "function_call": "STOP", - "finish_reason_unspecified": "FINISH_REASON_UNSPECIFIED", - "malformed_function_call": "MALFORMED_FUNCTION_CALL", } return mapping.get(finish_reason, "STOP") diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 7c8e2ebeaff..5dad19f2599 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -58,45 +58,55 @@ def safe_divide( return numerator / denominator -def map_finish_reason( - finish_reason: str, -): # openai supports 5 stop sequences - 'stop', 'length', 'function_call', 'content_filter', 'null' - # anthropic mapping - if finish_reason == "stop_sequence": +_FINISH_REASON_MAP = { + # Anthropic + "stop_sequence": "stop", + "end_turn": "stop", + "max_tokens": "length", + "tool_use": "tool_calls", + "compaction": "length", + # Cohere + "COMPLETE": "stop", + "ERROR_TOXIC": "content_filter", + "ERROR": "stop", + # HuggingFace / Together AI + "eos_token": "stop", + "eos": "stop", + # Gemini / Vertex AI + "STOP": "stop", + "MAX_TOKENS": "length", + "SAFETY": "content_filter", + "RECITATION": "content_filter", + "FINISH_REASON_UNSPECIFIED": "stop", + "MALFORMED_FUNCTION_CALL": "stop", + "LANGUAGE": "content_filter", + "OTHER": "content_filter", + "BLOCKLIST": "content_filter", + "PROHIBITED_CONTENT": "content_filter", + "SPII": "content_filter", + "IMAGE_SAFETY": "content_filter", + "IMAGE_PROHIBITED_CONTENT": "content_filter", + "TOO_MANY_TOOL_CALLS": "stop", + "MALFORMED_RESPONSE": "stop", + # Bedrock + "guardrail_intervened": "content_filter", + # OpenAI passthrough + "stop": "stop", + "length": "length", + "tool_calls": "tool_calls", + "function_call": "function_call", + "content_filter": "content_filter", +} + + +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 + ) return "stop" - # cohere mapping - https://docs.cohere.com/reference/generate - elif finish_reason == "COMPLETE": - return "stop" - elif finish_reason == "MAX_TOKENS": # cohere + vertex ai - return "length" - elif finish_reason == "ERROR_TOXIC": - return "content_filter" - elif ( - finish_reason == "ERROR" - ): # openai currently doesn't support an 'error' finish reason - return "stop" - # huggingface mapping https://huggingface.github.io/text-generation-inference/#/Text%20Generation%20Inference/generate_stream - elif finish_reason == "eos_token" or finish_reason == "stop_sequence": - return "stop" - elif ( - finish_reason == "FINISH_REASON_UNSPECIFIED" - ): # vertex ai - got from running `print(dir(response_obj.candidates[0].finish_reason))`: ['FINISH_REASON_UNSPECIFIED', 'MAX_TOKENS', 'OTHER', 'RECITATION', 'SAFETY', 'STOP',] - return "finish_reason_unspecified" - elif finish_reason == "MALFORMED_FUNCTION_CALL": - return "malformed_function_call" - elif finish_reason == "SAFETY" or finish_reason == "RECITATION": # vertex ai - return "content_filter" - elif finish_reason == "STOP": # vertex ai - return "stop" - elif finish_reason == "end_turn" or finish_reason == "stop_sequence": # anthropic - return "stop" - elif finish_reason == "max_tokens": # anthropic - return "length" - elif finish_reason == "tool_use": # anthropic - return "tool_calls" - elif finish_reason == "compaction": - return "length" - return finish_reason + return mapped def remove_index_from_tool_calls( 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 d248d2862e8..85124a037a6 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 @@ -1209,7 +1209,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): and what it means """ return { - "FINISH_REASON_UNSPECIFIED": "finish_reason_unspecified", + "FINISH_REASON_UNSPECIFIED": "stop", "STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter", @@ -1219,9 +1219,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "BLOCKLIST": "content_filter", "PROHIBITED_CONTENT": "content_filter", "SPII": "content_filter", - "MALFORMED_FUNCTION_CALL": "malformed_function_call", # openai doesn't have a way of representing this + "MALFORMED_FUNCTION_CALL": "stop", "IMAGE_SAFETY": "content_filter", "IMAGE_PROHIBITED_CONTENT": "content_filter", + "TOO_MANY_TOOL_CALLS": "stop", + "MALFORMED_RESPONSE": "stop", } def translate_exception_str(self, exception_string: str): diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 15e8d1be930..679a8f575c6 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -2058,7 +2058,7 @@ class OpenAIBatchResult(TypedDict, total=False): OpenAIChatCompletionFinishReason = Literal[ - "stop", "content_filter", "function_call", "tool_calls", "length", "guardrail_intervened", "eos", "finish_reason_unspecified", "malformed_function_call" # last 2 are vertex ai specific, guardrail_intervened is bedrock specific + "stop", "content_filter", "function_call", "tool_calls", "length" ] 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 cd9c401143e..0ef76e0942d 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -1,6 +1,12 @@ """Tests for litellm_core_utils.core_helpers module.""" -from litellm.litellm_core_utils.core_helpers import reconstruct_model_name +import pytest + +from litellm.litellm_core_utils.core_helpers import ( + _FINISH_REASON_MAP, + map_finish_reason, + reconstruct_model_name, +) def test_reconstruct_model_name_prefers_deployment_value(): @@ -43,3 +49,102 @@ def test_reconstruct_model_name_returns_original_for_other_providers(): ) assert result == "claude-3-sonnet" + + +# --------------------------------------------------------------------------- +# map_finish_reason tests +# --------------------------------------------------------------------------- + +VALID_OPENAI_FINISH_REASONS = {"stop", "length", "tool_calls", "function_call", "content_filter"} + + +class TestMapFinishReasonAnthropic: + def test_stop_sequence(self): + assert map_finish_reason("stop_sequence") == "stop" + + def test_end_turn(self): + assert map_finish_reason("end_turn") == "stop" + + def test_max_tokens(self): + assert map_finish_reason("max_tokens") == "length" + + def test_tool_use(self): + assert map_finish_reason("tool_use") == "tool_calls" + + def test_compaction(self): + assert map_finish_reason("compaction") == "length" + + +class TestMapFinishReasonGemini: + @pytest.mark.parametrize( + "gemini_reason,expected", + [ + ("STOP", "stop"), + ("MAX_TOKENS", "length"), + ("SAFETY", "content_filter"), + ("RECITATION", "content_filter"), + ("FINISH_REASON_UNSPECIFIED", "stop"), + ("MALFORMED_FUNCTION_CALL", "stop"), + ("LANGUAGE", "content_filter"), + ("OTHER", "content_filter"), + ("BLOCKLIST", "content_filter"), + ("PROHIBITED_CONTENT", "content_filter"), + ("SPII", "content_filter"), + ("IMAGE_SAFETY", "content_filter"), + ("IMAGE_PROHIBITED_CONTENT", "content_filter"), + ("TOO_MANY_TOOL_CALLS", "stop"), + ("MALFORMED_RESPONSE", "stop"), + ], + ) + def test_gemini_finish_reasons(self, gemini_reason, expected): + assert map_finish_reason(gemini_reason) == expected + + +class TestMapFinishReasonCohere: + def test_complete(self): + assert map_finish_reason("COMPLETE") == "stop" + + def test_error_toxic(self): + assert map_finish_reason("ERROR_TOXIC") == "content_filter" + + def test_error(self): + assert map_finish_reason("ERROR") == "stop" + + +class TestMapFinishReasonHuggingFace: + def test_eos_token(self): + assert map_finish_reason("eos_token") == "stop" + + def test_eos(self): + assert map_finish_reason("eos") == "stop" + + +class TestMapFinishReasonBedrock: + def test_guardrail_intervened(self): + assert map_finish_reason("guardrail_intervened") == "content_filter" + + +class TestMapFinishReasonOpenAIPassthrough: + @pytest.mark.parametrize( + "reason", ["stop", "length", "tool_calls", "function_call", "content_filter"] + ) + def test_openai_values_pass_through(self, reason): + assert map_finish_reason(reason) == reason + + +class TestMapFinishReasonUnknown: + def test_unknown_value_defaults_to_stop(self): + assert map_finish_reason("some_unknown_value") == "stop" + + def test_empty_string_defaults_to_stop(self): + assert map_finish_reason("") == "stop" + + +class TestFinishReasonMapOutputsAreValid: + def test_all_mapped_values_are_valid_openai_reasons(self): + """Every value in _FINISH_REASON_MAP must be a valid OpenAI finish reason.""" + for provider_reason, openai_reason in _FINISH_REASON_MAP.items(): + assert openai_reason in VALID_OPENAI_FINISH_REASONS, ( + f"Mapped value '{openai_reason}' (from '{provider_reason}') " + f"is not a valid OpenAI finish reason" + ) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 6047da66b6d..5e10d249ba6 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -608,34 +608,32 @@ def test_check_finish_reason(): def test_finish_reason_unspecified_and_malformed_function_call(): """ - Test that FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL - return their lowercase values instead of being mapped to 'stop' - since we don't have good mappings for these. + Test that FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL + are mapped to OpenAI-compatible 'stop' finish reason. """ finish_reason_mappings = VertexGeminiConfig.get_finish_reason_mapping() - - # Test FINISH_REASON_UNSPECIFIED returns lowercase version - assert finish_reason_mappings["FINISH_REASON_UNSPECIFIED"] == "finish_reason_unspecified" + + # Test FINISH_REASON_UNSPECIFIED maps to "stop" + assert finish_reason_mappings["FINISH_REASON_UNSPECIFIED"] == "stop" assert ( VertexGeminiConfig._check_finish_reason( chat_completion_message=None, finish_reason="FINISH_REASON_UNSPECIFIED" ) - == "finish_reason_unspecified" + == "stop" ) - - # Test MALFORMED_FUNCTION_CALL returns lowercase version - assert finish_reason_mappings["MALFORMED_FUNCTION_CALL"] == "malformed_function_call" + + # Test MALFORMED_FUNCTION_CALL maps to "stop" + assert finish_reason_mappings["MALFORMED_FUNCTION_CALL"] == "stop" assert ( VertexGeminiConfig._check_finish_reason( chat_completion_message=None, finish_reason="MALFORMED_FUNCTION_CALL" ) - == "malformed_function_call" + == "stop" ) - - # Ensure these values are in the OpenAI finish reasons constant - from litellm import OPENAI_FINISH_REASONS - assert "finish_reason_unspecified" in OPENAI_FINISH_REASONS - assert "malformed_function_call" in OPENAI_FINISH_REASONS + + # Test new Gemini finish reasons + assert finish_reason_mappings["TOO_MANY_TOOL_CALLS"] == "stop" + assert finish_reason_mappings["MALFORMED_RESPONSE"] == "stop" def test_vertex_ai_usage_metadata_response_token_count(): From 3f1167e5b7c4dbc0f66e5dd02656966a3a3cc582 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 00:22:10 -0300 Subject: [PATCH 2/6] fix(constants): remove "null" from OPENAI_FINISH_REASONS to align with OpenAI spec --- litellm/constants.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/constants.py b/litellm/constants.py index 1b86c6b61aa..a7a8f53cd40 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1202,7 +1202,6 @@ OPENAI_FINISH_REASONS = [ "function_call", "tool_calls", "content_filter", - "null", ] HUMANLOOP_PROMPT_CACHE_TTL_SECONDS = int( os.getenv("HUMANLOOP_PROMPT_CACHE_TTL_SECONDS", 60) From 3196d40a04645200e4f610e3825a5e216d4c9772 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 15:48:05 -0300 Subject: [PATCH 3/6] fix(vertex): delegate Gemini finish reason mapping to centralized _FINISH_REASON_MAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Greptile review feedback on PR #22138 — removes duplicated Gemini finish reason dict in VertexGeminiConfig and delegates to the shared map_finish_reason() to prevent the two mappings from drifting apart. --- .../vertex_and_google_ai_studio_gemini.py | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) 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 85124a037a6..c8c692f0c6c 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 @@ -1206,25 +1206,13 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): """ Return Dictionary of finish reasons which indicate response was flagged - and what it means + and what it means. + + Delegates to the centralized _FINISH_REASON_MAP to avoid duplication. """ - return { - "FINISH_REASON_UNSPECIFIED": "stop", - "STOP": "stop", - "MAX_TOKENS": "length", - "SAFETY": "content_filter", - "RECITATION": "content_filter", - "LANGUAGE": "content_filter", - "OTHER": "content_filter", - "BLOCKLIST": "content_filter", - "PROHIBITED_CONTENT": "content_filter", - "SPII": "content_filter", - "MALFORMED_FUNCTION_CALL": "stop", - "IMAGE_SAFETY": "content_filter", - "IMAGE_PROHIBITED_CONTENT": "content_filter", - "TOO_MANY_TOOL_CALLS": "stop", - "MALFORMED_RESPONSE": "stop", - } + from litellm.litellm_core_utils.core_helpers import _FINISH_REASON_MAP + + return _FINISH_REASON_MAP def translate_exception_str(self, exception_string: str): if ( @@ -1728,15 +1716,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): chat_completion_message: Optional[ChatCompletionResponseMessage], finish_reason: Optional[str], ) -> OpenAIChatCompletionFinishReason: - mapped_finish_reason = VertexGeminiConfig.get_finish_reason_mapping() + from litellm.litellm_core_utils.core_helpers import map_finish_reason + if chat_completion_message and chat_completion_message.get("function_call"): return "function_call" elif chat_completion_message and chat_completion_message.get("tool_calls"): return "tool_calls" - elif ( - finish_reason and finish_reason in mapped_finish_reason.keys() - ): # vertex ai - return mapped_finish_reason[finish_reason] + elif finish_reason: + return map_finish_reason(finish_reason) else: return "stop" From b6784e7d8c2e4e4dd22958a933cd6d624344d119 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 16:13:23 -0300 Subject: [PATCH 4/6] fix(types): annotate _FINISH_REASON_MAP and map_finish_reason with OpenAIChatCompletionFinishReason Fixes mypy errors where dict[str, str] was incompatible with the expected Literal type in get_finish_reason_mapping() and _check_finish_reason() return types. --- litellm/litellm_core_utils/core_helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 5dad19f2599..ee111f35929 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Union import httpx from litellm._logging import verbose_logger -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import AllMessageValues, OpenAIChatCompletionFinishReason if TYPE_CHECKING: from opentelemetry.trace import Span as _Span @@ -58,7 +58,7 @@ def safe_divide( return numerator / denominator -_FINISH_REASON_MAP = { +_FINISH_REASON_MAP: dict[str, OpenAIChatCompletionFinishReason] = { # Anthropic "stop_sequence": "stop", "end_turn": "stop", @@ -99,7 +99,7 @@ _FINISH_REASON_MAP = { } -def map_finish_reason(finish_reason: str) -> str: +def map_finish_reason(finish_reason: str) -> OpenAIChatCompletionFinishReason: mapped = _FINISH_REASON_MAP.get(finish_reason) if mapped is None: verbose_logger.warning( From d501c33a9dc5b25a3afbfab22df3bb07e77da355 Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 10 Mar 2026 18:43:51 -0300 Subject: [PATCH 5/6] 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 f705bc9f311..a7f26a0ec37 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 2a737f26938..74d5ad330e5 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 b5d5c06924d..50960134695 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 8c20ace98a0..ccd467a1f7b 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" From 55f4c8d2033d891810b5f3253878acfef0b9147d Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 10 Mar 2026 19:02:44 -0300 Subject: [PATCH 6/6] fix: address Greptile review feedback - Filter get_finish_reason_mapping() to Gemini-only keys instead of returning the full cross-provider _FINISH_REASON_MAP - Shallow-copy caller-supplied provider_specific_fields before mutating to avoid unexpected side-effects --- .../vertex_and_google_ai_studio_gemini.py | 20 +++++++++++++------ litellm/types/utils.py | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) 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 74d5ad330e5..dd22669ac21 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 @@ -1230,18 +1230,26 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "IMAGE_PROHIBITED_CONTENT": "The token generation was stopped as the response was flagged for prohibited image content.", } + _GEMINI_FINISH_REASON_KEYS = frozenset({ + "STOP", "MAX_TOKENS", "SAFETY", "RECITATION", "FINISH_REASON_UNSPECIFIED", + "MALFORMED_FUNCTION_CALL", "LANGUAGE", "OTHER", "BLOCKLIST", + "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY", "IMAGE_PROHIBITED_CONTENT", + "TOO_MANY_TOOL_CALLS", "MALFORMED_RESPONSE", + }) + @staticmethod def get_finish_reason_mapping() -> Dict[str, OpenAIChatCompletionFinishReason]: """ - Return Dictionary of finish reasons which indicate response was flagged - - and what it means. - - Delegates to the centralized _FINISH_REASON_MAP to avoid duplication. + Return Dictionary of Gemini/Vertex AI finish reasons and their + OpenAI-compatible mappings. """ from litellm.litellm_core_utils.core_helpers import _FINISH_REASON_MAP - return dict(_FINISH_REASON_MAP) + return { + k: v + for k, v in _FINISH_REASON_MAP.items() + if k in VertexGeminiConfig._GEMINI_FINISH_REASON_KEYS + } def translate_exception_str(self, exception_string: str): if ( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 50960134695..ecf4669f3d0 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1329,7 +1329,7 @@ class Choices(SafeAttributeModel, OpenAIObject): mapped = map_finish_reason(finish_reason) params["finish_reason"] = mapped if finish_reason != mapped: - provider_specific_fields = provider_specific_fields or {} + provider_specific_fields = dict(provider_specific_fields) if provider_specific_fields else {} provider_specific_fields["native_finish_reason"] = finish_reason else: params["finish_reason"] = "stop"