fix(convert_dict_to_response): name the non-list choices type in the converter error

When a provider returns choices as null, an object, a string or a number, the
converter said the response had no 'choices' even though the key was present in
the raw keys it listed. A shared message now keeps the old wording for a missing
key and names the offending type otherwise.

The cached-stream regression test also pins the chunk count so a leaked extra
chunk fails it.
This commit is contained in:
mateo-berri 2026-09-09 15:12:13 -07:00
parent 29af8b7349
commit cbeac27656
3 changed files with 21 additions and 16 deletions

View file

@ -3,7 +3,7 @@ import json
import re
import time
import traceback
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Final, Literal, cast
import litellm
@ -151,6 +151,16 @@ def _clear_later_replay_slice_metadata(choice: StreamingChoices) -> None:
del choice.enhancements
def _invalid_choices_message(response_object: Mapping[str, object]) -> str:
raw_keys: Final = list(response_object.keys())
if "choices" not in response_object:
return f"LiteLLM: provider returned a response with no 'choices'. Raw keys: {raw_keys}"
return (
f"LiteLLM: provider returned 'choices' that is not a list ({type(response_object['choices']).__name__}). "
f"Raw keys: {raw_keys}"
)
async def convert_to_streaming_response_async(
response_object: dict | None = None,
):
@ -184,9 +194,7 @@ async def convert_to_streaming_response_async(
raise APIError(
status_code=500,
message=(
f"LiteLLM: provider returned a response with no 'choices'. Raw keys: {list(response_object.keys())}"
),
message=_invalid_choices_message(response_object),
llm_provider="",
model="",
)
@ -292,9 +300,7 @@ def convert_to_streaming_response(
raise APIError(
status_code=500,
message=(
f"LiteLLM: provider returned a response with no 'choices'. Raw keys: {list(response_object.keys())}"
),
message=_invalid_choices_message(response_object),
llm_provider="",
model="",
)
@ -628,10 +634,7 @@ def convert_to_model_response_object(
raise APIError(
status_code=500,
message=(
"LiteLLM: provider returned a response with no 'choices'. "
f"Raw keys: {list(response_object.keys())}"
),
message=_invalid_choices_message(response_object),
llm_provider="",
model="",
)

View file

@ -167,9 +167,9 @@ def test_convert_missing_choices_raises_api_error() -> None:
assert "no 'choices'" in str(exc_info.value)
@pytest.mark.parametrize("choices", [{}, "", None, 0])
@pytest.mark.parametrize(("choices", "type_name"), [({}, "dict"), ("", "str"), (None, "NoneType"), (0, "int")])
@pytest.mark.asyncio
async def test_convert_non_list_choices_raises_api_error(choices: object) -> None:
async def test_convert_non_list_choices_raises_api_error(choices: object, type_name: str) -> None:
from litellm.exceptions import APIError
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
convert_to_streaming_response,
@ -183,14 +183,15 @@ async def test_convert_non_list_choices_raises_api_error(choices: object) -> Non
"object": "chat.completion",
"choices": choices,
}
with pytest.raises(APIError, match="no 'choices'"):
expected: Final = f"'choices' that is not a list \\({type_name}\\)"
with pytest.raises(APIError, match=expected):
convert_to_model_response_object(
response_object=resp,
model_response_object=ModelResponse(),
response_type="completion",
)
with pytest.raises(APIError, match="no 'choices'"):
with pytest.raises(APIError, match=expected):
list(convert_to_streaming_response(response_object=resp))
with pytest.raises(APIError, match="no 'choices'"):
with pytest.raises(APIError, match=expected):
async for _ in convert_to_streaming_response_async(response_object=resp):
pass

View file

@ -2670,6 +2670,7 @@ async def test_cached_response_without_choices_streams_a_single_stop_chunk(
chunks: Final = tuple([chunk async for chunk in wrapper])
assert len(chunks) == 1
assert tuple(choice.finish_reason for chunk in chunks for choice in chunk.choices) == ("stop",)
assert all(choice.delta.content in (None, "") for chunk in chunks for choice in chunk.choices)