mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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
This commit is contained in:
parent
323b473835
commit
810de556bd
2 changed files with 81 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue