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:
xykong 2026-03-10 23:55:24 +08:00 committed by GitHub
parent 323b473835
commit 810de556bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 81 additions and 3 deletions

View file

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

View file

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