From 810de556bd84a0f69a437f1723f94765ddce114b Mon Sep 17 00:00:00 2001 From: xykong Date: Tue, 10 Mar 2026 23:55:24 +0800 Subject: [PATCH] fix(streaming): map unknown finish_reason values to finish_reason_unspecified to prevent ValidationError in stream_chunk_builder (#22673) * fix(streaming): map unknown finish_reason values to finish_reason_unspecified Some LLM providers return non-standard finish_reason values that are not in the OpenAIChatCompletionFinishReason Literal (e.g. ZhipuAI/GLM returns 'network_error' when a streaming error occurs mid-response). Previously map_finish_reason() fell through with return finish_reason, passing the unknown value directly to Choices.__init__() which calls Pydantic validation. This caused a ValidationError that was caught by stream_chunk_builder() and re-raised as the misleading: litellm.APIError: Error building chunks for logging/streaming usage calculation Fix: after all known provider-specific mappings, check if the value is in the valid set (stop, length, tool_calls, content_filter, function_call, guardrail_intervened, eos, finish_reason_unspecified, malformed_function_call). Any value not in this set is mapped to 'finish_reason_unspecified' instead of being returned as-is. This is consistent with how other unknown stop reasons (e.g. Vertex AI's FINISH_REASON_UNSPECIFIED) are already handled. * refactor: use get_args(OpenAIChatCompletionFinishReason) for valid set Per code review feedback: replace the hardcoded _valid_finish_reasons set with a module-level frozenset derived dynamically from the source-of-truth Literal type via typing.get_args(). This ensures the valid-reason check stays in sync automatically when new finish reasons are added to the Literal, and avoids recreating the set on every streaming chunk call. * test(map_finish_reason): add unit tests and warning log for unknown finish reasons - Add TestMapFinishReason class in test_core_helpers.py covering: - All known OpenAI-native values pass through unchanged (parametrized) - Provider-specific mappings: Anthropic, Cohere, Vertex AI - Unknown/provider-specific values map to 'finish_reason_unspecified' - Regression test for ZhipuAI/GLM-5 'network_error' case - Add verbose_logger.warning() in map_finish_reason() when an unknown finish_reason is encountered, so operators can track which providers return non-standard values --- litellm/litellm_core_utils/core_helpers.py | 22 ++++++- .../litellm_core_utils/test_core_helpers.py | 62 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 7c8e2ebeaff..4113fa351ae 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 cd9c401143e..4118b4cb058 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():