Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions litellm/litellm_core_utils/core_helpers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Comment thread
RheagalFire marked this conversation as resolved.
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"
Comment on lines +105 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider logging unknown finish reasons

Silently mapping unknown values to "finish_reason_unspecified" is the right approach to prevent the ValidationError, but it may hide useful debugging information. Consider adding a verbose_logger.warning() (already imported) when an unknown finish reason is encountered so operators can track which providers are returning non-standard values:

Suggested change
# 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:
return "finish_reason_unspecified"
# 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("Unknown finish_reason '%s' mapped to 'finish_reason_unspecified'", finish_reason)
return "finish_reason_unspecified"

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

return finish_reason


Expand Down
62 changes: 61 additions & 1 deletion tests/test_litellm/litellm_core_utils/test_core_helpers.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
Loading