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
This commit is contained in:
Chesars 2026-02-26 00:12:39 -03:00
parent adba088df2
commit a4fc73f892
7 changed files with 174 additions and 64 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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(

View file

@ -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):

View file

@ -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"
]

View file

@ -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"
)

View file

@ -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():