Skip to content

fix: return an iterable stream when interception downgrades stream:true - #38051

Open
likalight wants to merge 2 commits into
BerriAI:litellm_internal_stagingfrom
likalight:fix/code-interpreter-fake-stream-iterable
Open

likalight wants to merge 2 commits into
BerriAI:litellm_internal_stagingfrom
likalight:fix/code-interpreter-fake-stream-iterable

Conversation

@likalight

@likalight likalight commented Aug 24, 2026

Copy link
Copy Markdown

Fixes #37652.

Root cause

_wrap_response_as_fake_stream (litellm/litellm_core_utils/chat_completion_agentic_loop.py)
returned the bare converted chunk:

return convert_model_response_to_streaming(cast(ModelResponse, response))

convert_model_response_to_streaming returns a single ModelResponseStream
one chunk, not a stream. Measured on a clean checkout:

returned type : ModelResponseStream
has __aiter__ : False
object field  : chat.completion.chunk

That is both reported symptoms in one line:

  1. The crash. Interception downgrades stream: true to a single
    non-streamed call so the agentic loop can run, then hands this value back to
    the streaming path, which does async for over it —
    TypeError: 'async for' requires an object with __aiter__ method, got ModelResponseStream.
    No tool call is needed to reach it; a plain assistant reply is enough.
  2. The malformed cache entry. The same object is chat.completion.chunk-shaped
    where a full chat.completion is expected, so a cached copy carries delta
    instead of message and a later read raises KeyError: 'message' inside
    convert_to_model_response_object. The reporter saw this in production
    without an independent repro; the object field above is the mechanism.

The cast at one of the call sites already claimed
"ModelResponse | CustomStreamWrapper", which the returned value never
satisfied.

The fix

Wrap the response in a CustomStreamWrapper over a MockResponseIterator
the same construction litellm/completion_extras/litellm_responses_transformation/handler.py
already uses to present a non-streamed response as a stream. The result is
genuinely async- and sync-iterable, and the existing cast becomes true.

The helper needs model, custom_llm_provider and logging_obj to build the
wrapper; both call sites already have all three, so they are threaded through as
keyword-only arguments.

This takes option (a) from the issue — make the combination work — rather than
(b) reject it with a 4xx, since the downgrade machinery was clearly built to
support it and only the hand-back was wrong.

Verification

Five tests in tests/test_litellm/litellm_core_utils/test_agentic_loop_fake_stream.py:

  • the result exposes __aiter__ and is a CustomStreamWrapper — the reported crash

  • it is sync-iterable too

  • iterating yields chat.completion.chunk objects that reassemble the original
    content, so the downgrade is transparent to the client

  • an already-wrapped stream passes through unchanged (no double wrapping)

  • an object with no choices is returned untouched

  • tests/test_litellm/litellm_core_utils: 10 failures on this branch and the
    identical 10 on a clean checkout
    (verified by diffing the failure lists), so
    nothing here regresses; those are pre-existing.

  • ruff format --check clean on both touched files; ruff check reports the
    same 5 pre-existing findings on the source file as base, none added.

  • Type-discipline checker: identical counts to base on every rule.

Note on the cache symptom

I fixed the shape at its source rather than adding a guard in the cache layer.
If you would also like a defensive check where the entry is written — rejecting
a chat.completion.chunk where a chat.completion is expected — I am happy to
add it, but it seemed better not to paper over the producer inside this fix.


On the red code-quality check: it is not from this PR. It fails on every
PR against litellm_internal_staging right now, including ones that touch no
workflow files, because three unit shards in .github/workflows/test-unit.yml
cap the job below the startup-safety invariant. Fixed independently in #38046;
this PR needs no change for it.

likalight and others added 2 commits August 24, 2026 09:31
`_wrap_response_as_fake_stream` returned the bare converted chunk, so a
`code_interpreter_interception` request with `stream: true` failed with
`TypeError: 'async for' requires an object with __aiter__ method, got
ModelResponseStream`. The same bare chunk is `chat.completion.chunk`-shaped
where a full `chat.completion` is expected, which is how a malformed entry
reaches the response cache and later raises `KeyError: 'message'`.

Wrap the response in a `CustomStreamWrapper` over a `MockResponseIterator`
— the pattern the responses-transformation handler already uses — so the
value is genuinely async- and sync-iterable. The `cast` at the call site
already claimed `CustomStreamWrapper`; now that is true.

Fixes BerriAI#37652

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces a bare converted response chunk with a sync- and async-iterable CustomStreamWrapper when interception downgrades a streaming request.

  • Threads model, provider, and logging context into the fake-stream helper.
  • Preserves existing wrappers and responses without choices.
  • Adds coverage for iterable behavior, chunk content, pass-through behavior, and unsupported objects.

Confidence Score: 4/5

The PR appears safe to merge, with only a non-blocking test-organization issue.

The fake-stream implementation restores the expected iterable streaming contract, and no reachable runtime regression was established; the remaining concern is that its tests are separated from the mapped test file.

Files Needing Attention: tests/test_litellm/litellm_core_utils/test_agentic_loop_fake_stream.py

Important Files Changed

Filename Overview
litellm/litellm_core_utils/chat_completion_agentic_loop.py Wraps downgraded complete responses with the established stream abstraction; no blocking correctness issue was established.
tests/test_litellm/litellm_core_utils/test_agentic_loop_fake_stream.py Adds focused regression coverage, but places it in a new module rather than the repository’s existing mapped test file.

Reviews (1): Last reviewed commit: "style: apply ruff format to the new agen..." | Re-trigger Greptile

@@ -0,0 +1,72 @@
"""A downgraded stream must come back as something `async for` can consume."""

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.

P2 Regression tests use separate module

This bug fix places all five regression cases in a new module instead of extending the existing mapped test file for chat_completion_agentic_loop.py, fragmenting coverage and making the cases easier to omit from targeted test runs.

Context Used: CLAUDE.md (source)

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!

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...litellm_core_utils/chat_completion_agentic_loop.py 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing likalight:fix/code-interpreter-fake-stream-iterable (77e9281) with litellm_internal_staging (d447be1)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (f005afa) during the generation of this report, so d447be1 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants