feat(types): expose native_finish_reason in provider_specific_fields

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.
This commit is contained in:
Chesars
2026-03-10 18:43:51 -03:00
parent 2315d4b73c
commit d501c33a9d
4 changed files with 84 additions and 2 deletions
+22
View File
@@ -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.
@@ -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 (
+5 -1
View File
@@ -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:
@@ -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"