fix(stream_chunk_builder): guard empty choices and missing role in build_base_response

build_base_response() read the assistant role via
first_chunk_with_choices["choices"][0]["delta"]["role"] with no bounds or key
check, causing two failures:

- IndexError when no chunk carries a non-empty "choices" array, because
  next() fell back to the first chunk whose "choices" may be []
- KeyError when the first choice's "delta" omits "role" or is {}

Both surface as "litellm.APIError: Error building chunks for logging/streaming
usage calculation". async_data_generator() writes that into the response
stream, so the client's answer is truncated mid-stream with no data: [DONE],
and the request never reaches SpendLogs. Observed in production on Anthropic
streaming.

Fall back to None, guard the array length, and default the role to
"assistant". The loop directly below already guards with
len(chunk["choices"]) > 0.
This commit is contained in:
ZXT-zjbiliy 2026-08-21 13:56:30 +08:00
parent e17988f4fe
commit f01309c5af
2 changed files with 112 additions and 2 deletions

View file

@ -302,8 +302,15 @@ class ChunkProcessor:
model: Final = ChunkProcessor._get_model_from_chunks(chunks, first_chunk_model)
system_fingerprint: Final = chunk.get("system_fingerprint", None)
first_chunk_with_choices: Final = next((c for c in chunks if c.get("choices")), chunk)
role: Final = first_chunk_with_choices["choices"][0]["delta"]["role"]
# Fall back to None rather than `chunk`: if no chunk carries a non-empty
# `choices` array, indexing [0] on the first chunk raises IndexError.
first_chunk_with_choices = next((c for c in chunks if c.get("choices")), None)
role: str = "assistant"
if first_chunk_with_choices is not None:
_choices = first_chunk_with_choices["choices"]
if len(_choices) > 0:
# `delta` may be absent or omit `role` (e.g. content-only deltas).
role = _choices[0].get("delta", {}).get("role") or "assistant"
finish_reason = "stop"
for chunk in chunks:
if "choices" in chunk and len(chunk["choices"]) > 0:

View file

@ -1342,3 +1342,106 @@ def test_calculate_usage_fills_unknown_split_from_reasoning_estimate(
assert usage.completion_tokens == 100
assert usage.completion_tokens_details.reasoning_tokens == expected_reasoning_tokens
assert usage.completion_tokens_details.text_tokens == expected_text_tokens
def _empty_choices_chunk(**extra):
chunk = {
"id": "chatcmpl-empty-choices",
"object": "chat.completion.chunk",
"created": 1,
"model": "claude-opus-4-8",
"choices": [],
}
chunk.update(extra)
return chunk
@pytest.mark.parametrize(
"chunks",
[
pytest.param(
[_empty_choices_chunk(), _empty_choices_chunk()],
id="all_chunks_have_empty_choices",
),
pytest.param(
[
_empty_choices_chunk(usage={"prompt_tokens": 10}),
_empty_choices_chunk(usage={"completion_tokens": 0}),
],
id="usage_only_chunks",
),
],
)
def test_build_base_response_handles_empty_choices(chunks):
"""Empty `choices` arrays must not raise IndexError.
`next((c for c in chunks if c.get("choices")), chunk)` used to fall back to the
first chunk, whose `choices` may be `[]`, so `["choices"][0]` went out of range.
The resulting error is surfaced to the client mid-stream and the request never
reaches SpendLogs.
"""
processor = ChunkProcessor(chunks=list(chunks))
response = processor.build_base_response(list(chunks))
assert response.choices[0].message.role == "assistant"
@pytest.mark.parametrize(
"delta",
[
pytest.param({"content": "Hello"}, id="delta_without_role"),
pytest.param({}, id="delta_empty_dict"),
],
)
def test_build_base_response_handles_delta_without_role(delta):
"""A `delta` that omits `role` must not raise KeyError."""
chunks = [
{
"id": "chatcmpl-no-role",
"object": "chat.completion.chunk",
"created": 1,
"model": "claude-opus-4-8",
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
]
processor = ChunkProcessor(chunks=list(chunks))
response = processor.build_base_response(list(chunks))
assert response.choices[0].message.role == "assistant"
def test_build_base_response_still_reads_role_and_finish_reason():
"""Regression guard: well-formed chunks keep their role and finish_reason."""
chunks = [
_empty_choices_chunk(),
{
"id": "chatcmpl-normal",
"object": "chat.completion.chunk",
"created": 1,
"model": "claude-opus-4-8",
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": "Hi"},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-normal",
"object": "chat.completion.chunk",
"created": 2,
"model": "claude-opus-4-8",
"choices": [
{"index": 0, "delta": {"content": "!"}, "finish_reason": "stop"}
],
},
]
processor = ChunkProcessor(chunks=list(chunks))
response = processor.build_base_response(list(chunks))
assert response.choices[0].message.role == "assistant"
assert response.choices[0].finish_reason == "stop"