diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 7c8e2ebeaff4..4113fa351ae7 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -1,11 +1,11 @@ # What is this? ## Helper utilities -from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Union, get_args 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,6 +58,12 @@ def safe_divide( return numerator / denominator +# Module-level constant derived from the source-of-truth Literal type. +# Avoids recreating the set on every call (map_finish_reason is called per-chunk +# during streaming) and stays in sync when the Literal is updated. +_VALID_OPENAI_FINISH_REASONS = frozenset(get_args(OpenAIChatCompletionFinishReason)) + + def map_finish_reason( finish_reason: str, ): # openai supports 5 stop sequences - 'stop', 'length', 'function_call', 'content_filter', 'null' @@ -96,6 +102,18 @@ def map_finish_reason( return "tool_calls" elif finish_reason == "compaction": return "length" + # Unknown finish_reason values (e.g. provider-specific error codes like + # "network_error" from ZhipuAI/GLM-5) are not in OpenAIChatCompletionFinishReason + # Literal and will cause a Pydantic ValidationError in Choices.__init__. + # Map them to "finish_reason_unspecified" so the stream can be assembled + # without raising an exception. + if finish_reason not in _VALID_OPENAI_FINISH_REASONS: + verbose_logger.warning( + "litellm.map_finish_reason: unknown finish_reason %r from provider; " + "mapping to 'finish_reason_unspecified' to avoid ValidationError.", + finish_reason, + ) + return "finish_reason_unspecified" return finish_reason 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 cd9c401143e3..4118b4cb058f 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,66 @@ """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 map_finish_reason, reconstruct_model_name + + +class TestMapFinishReason: + @pytest.mark.parametrize( + "value", + [ + "stop", + "length", + "function_call", + "tool_calls", + "content_filter", + "finish_reason_unspecified", + "eos", + "guardrail_intervened", + "malformed_function_call", + ], + ) + def test_known_openai_values_pass_through(self, value: str) -> None: + assert map_finish_reason(value) == value + + def test_anthropic_tool_use_maps_to_tool_calls(self) -> None: + assert map_finish_reason("tool_use") == "tool_calls" + + def test_anthropic_max_tokens_maps_to_length(self) -> None: + assert map_finish_reason("max_tokens") == "length" + + def test_anthropic_end_turn_maps_to_stop(self) -> None: + assert map_finish_reason("end_turn") == "stop" + + def test_cohere_complete_maps_to_stop(self) -> None: + assert map_finish_reason("COMPLETE") == "stop" + + def test_cohere_max_tokens_maps_to_length(self) -> None: + assert map_finish_reason("MAX_TOKENS") == "length" + + def test_cohere_error_toxic_maps_to_content_filter(self) -> None: + assert map_finish_reason("ERROR_TOXIC") == "content_filter" + + def test_vertex_ai_stop_maps_to_stop(self) -> None: + assert map_finish_reason("STOP") == "stop" + + def test_vertex_ai_safety_maps_to_content_filter(self) -> None: + assert map_finish_reason("SAFETY") == "content_filter" + + def test_vertex_ai_finish_reason_unspecified_maps_correctly(self) -> None: + assert map_finish_reason("FINISH_REASON_UNSPECIFIED") == "finish_reason_unspecified" + + def test_vertex_ai_malformed_function_call_maps_correctly(self) -> None: + assert map_finish_reason("MALFORMED_FUNCTION_CALL") == "malformed_function_call" + + def test_unknown_value_maps_to_finish_reason_unspecified(self) -> None: + assert map_finish_reason("some_unknown_reason") == "finish_reason_unspecified" + + def test_empty_string_maps_to_finish_reason_unspecified(self) -> None: + assert map_finish_reason("") == "finish_reason_unspecified" + + def test_zhipuai_glm_network_error_regression(self) -> None: + assert map_finish_reason("network_error") == "finish_reason_unspecified" def test_reconstruct_model_name_prefers_deployment_value():