[Bug] `dspy.LM(stream=True)` caches LiteLLM's live stream before rejecting it
What happened?
Supplying LiteLLM's stream=True kwarg to dspy.LM returns a live LiteLLM
CustomStreamWrapper, but DSPy subsequently handles it as a completed response.
The first call fails with:
TypeError: 'CustomStreamWrapper' object is not subscriptable
With cache=True, DSPy stores the live wrapper before LM.forward() fails while processing it. On a subsequent identical request, DSPy finds the cached wrapper and attempts to deep-copy it. This fails before the provider is called:
LMUnexpectedError: [<configured model>] cannot pickle '_thread.RLock' object
This exposes two coupled problems: LM.forward() cannot handle the live wrapper returned by raw stream=True, and DSPy caches that unsupported object before validating it. The cached wrapper then causes the subsequent identical request to fail during cache retrieval.
Steps to reproduce
import os
import dspy
from dotenv import load_dotenv
load_dotenv()
# Use a fresh in-memory cache so call 1 misses and call 2 hits.
dspy.configure_cache(enable_disk_cache=False, enable_memory_cache=True)
dspy.cache.reset_memory_cache()
lm = dspy.LM(
model=os.getenv("TEST_MODEL", "openai/qwen3.6-35b-fast"),
api_key=os.getenv("PROVIDER_API_KEY") or os.getenv("OPENAI_API_KEY"),
api_base=os.getenv("PROVIDER_API_BASE") or os.getenv("OPENAI_BASE_URL"),
stream=True,
cache=True,
)
prompt = "Return the single word: SUCCESS"
try:
lm(prompt)
except TypeError as exc:
print(f"Call 1 (cache miss): {exc}")
print("Call 2: identical request, cache hit")
lm(prompt) # Intentionally uncaught: raises LMUnexpectedError from cache deepcopy.
Observed output:
Call 1 (cache miss): 'CustomStreamWrapper' object is not subscriptable
Call 2: identical request, cache hit
Traceback (most recent call last):
...
LMUnexpectedError: [<configured model>] cannot pickle '_thread.RLock' object
The same first-call CustomStreamWrapper error occurs with cache=False; caching
adds the second, persistent failure.
Execution trace
This sequence follows directly from the tested source at commit
35ef21f1689576c4fbe06c27e22eabb5db1b24b6:
dspy.LM(..., stream=True)acceptsstreamthrough**kwargs;BaseLMstores those kwargs inself.kwargs(base_lm.py:171-206).LM.forward()mergesself.kwargsinto the request, wraps the completion function withrequest_cachewhencache=True, and invokes it (lm.py:232-257).- With no
dspy.streamify()side channel,litellm_completion()returns the directlitellm.completion(..., **request)result (lm.py:454-458, lm.py:495-510). LiteLLM documentsstream=Trueas returning a streaming response and annotatescompletion()as returningModelResponse | CustomStreamWrapper(LiteLLM source). - On the cache miss,
request_cachecalls the completion function, stores its raw result withcache.put(...), then returns that same result (cache.py:267-286). Therefore the liveCustomStreamWrapperis cached beforeLM.forward()sees it. - Only after that return does
LM.forward()call_check_truncation(results)(lm.py:253-265)._check_truncation()indexesresults["choices"](lm.py:437-445), producing the first-call not-subscriptable error. - On call two,
request_cachecheckscache.get(...)before calling LiteLLM and returns immediately on a hit (cache.py:267-277). The memory-cache read callscopy.deepcopy(response)(cache.py:125-150); the observedRLockerror is raised there and wrapped asLMUnexpectedErrorbyLM.forward()(lm.py:258-261).
Expected behavior
DSPy should reject stream=True when it is passed directly to either
dspy.LM(...) or an LM call. The validation should happen before a provider
request is issued or a cached value is read or written, and the error should
direct users to DSPy's supported streaming API.
I think the simplest fix would be to check for stream=True after the LM arguments are combined, but before DSPy accesses the cache or calls the provider. If this matches the intended behavior, I’d be happy to open a PR with the fix and regression tests :)
Environment
- DSPy:
mainat35ef21f1689576c4fbe06c27e22eabb5db1b24b6(3.3.1) - LiteLLM: 1.99.0
- Python: 3.12.3
DSPy version
3.3.1
Source: stanfordnlp/dspy