Repository navigation
Description
🔴 Required Information
Describe the Bug:
When a LiteLlm stream ends without a finish chunk (the connection drops mid-answer), generate_content_async still finalizes the buffered text with finish_reason=STOP and no error_code. The fallback is last_finish_reason or "stop" (lite_llm.py#L4587, #L4594, #L4608). So a truncated answer looks the same as a finished one to the agent, to callbacks and to telemetry. Came up in #7630.
Steps to Reproduce:
pip install google-adk litellm uvicorn starlette(ran it on main, 2de5876)- Run the script below. A local OpenAI-compatible server streams two chunks and then drops the connection for the
cutmodel, no API key needed.
repro.py
import asyncio, json, threading, time
import uvicorn
from starlette.applications import Starlette
from starlette.responses import StreamingResponse
from starlette.routing import Route
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.llm_request import LlmRequest
from google.genai import types
def chunk(delta, finish=None):
return {"id": "c1", "object": "chat.completion.chunk", "created": int(time.time()), "model": "gpt-4o",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
async def completions(request):
cut = (await request.json())["model"] == "cut"
async def sse():
yield f"data: {json.dumps(chunk({'role': 'assistant', 'content': 'Oslo is '}))}\n\n"
yield f"data: {json.dumps(chunk({'content': '4C and'}))}\n\n"
if cut:
raise ConnectionResetError("server dropped the stream")
yield f"data: {json.dumps(chunk({'content': ' cloudy.'}))}\n\n"
yield f"data: {json.dumps(chunk({}, 'stop'))}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(sse(), media_type="text/event-stream")
server = uvicorn.Server(uvicorn.Config(
Starlette(routes=[Route("/v1/chat/completions", completions, methods=["POST"])]),
port=8797, log_level="critical"))
threading.Thread(target=server.run, daemon=True).start()
while not server.started:
time.sleep(0.05)
async def main():
for model in ("complete", "cut"):
llm = LiteLlm(model=f"openai/{model}", api_base="http://127.0.0.1:8797/v1", api_key="unused")
request = LlmRequest(model=f"openai/{model}", contents=[types.Content(role="user", parts=[types.Part(text="weather?")])])
last = None
async for response in llm.generate_content_async(request, stream=True):
last = response
text = "".join(p.text or "" for p in last.content.parts)
print(f"{model:9} final: partial={last.partial} finish_reason={last.finish_reason} error_code={last.error_code} text={text!r}")
asyncio.run(main())
server.should_exit = TrueExpected Behavior:
A stream that ends without a finish reason is reported as incomplete, for example an error_code or a finish reason other than STOP, so callers and telemetry can tell it was cut off.
Observed Behavior:
complete final: partial=False finish_reason=FinishReason.STOP error_code=None text='Oslo is 4C and cloudy.'
cut final: partial=False finish_reason=FinishReason.STOP error_code=None text='Oslo is 4C and'
Environment Details:
- ADK Library Version (pip show google-adk): main at 2de5876
- Desktop OS: macOS
- Python Version (python -V): 3.12
Model Information:
- Are you using LiteLLM: Yes, litellm 1.85.7
- Which model is being used: openai provider against a local server (same code path for any LiteLlm provider)
🟡 Optional Information
Regression:
Haven't checked older versions.
Not sure which signal you'd want there, an error_code or a different finish reason, so leaving the fix open. Happy to send a PR once that's decided.
Activity
xxwtiancai commentedon Oct 23, 2026
I dug into this with a chunk-level probe and found the truncation signal is erased inside litellm, before ADK's fallback can matter — so the fix for the live scenario probably can't live at the lines linked above.
What the probe shows. A local OpenAI-compatible server streams two text chunks and then either closes gracefully (no finish chunk, no
[DONE]) or drops the connection. Capturing the raw chunks litellm delivers toLiteLlm:[('Oslo is ', None), ('4C and', None), (None, 'stop')]— a synthetic stop chunkAPIConnectionError(MidStreamFallbackError)So by the time ADK's streaming loop ends,
last_finish_reasonis already"stop"— thelast_finish_reason or "stop"fallbacks never fire in this scenario, and changing them cannot alter the reported outcome. The evidence is gone before ADK sees the stream.Where I think this leaves it:
last_finish_reason is Noneat finalization is still reachable through customllm_clientimplementations that stream raw chunks, and finalizing those as a cleanSTOPis arguably still wrong — I have a small local patch that maps that case toFinishReason.OTHER+error_code(following_apply_provider_finish_reason's existing convention), with mocked-stream tests. Happy to send it if you want the contract hardened independently of litellm, but I don't want to present it as fixing the live repro.@surajksharma07 how would you like to proceed — report/track upstream with litellm, harden the ADK contract anyway, or both?
surajksharma07 commentedon Oct 26, 2026
Seeing the same on main (2.13.0) with litellm 1.84.0 @ishan-1010. litellm adds a synthetic stop on the cut stream and a raw llm_client stream with no finish reason also ends up as STOP via the or "stop" fallback.
As a workaround you could try a small LiteLLMClient wrapper that checks the stream’s received_finish_reason and rewrites the synthetic stop (or appends a final chunk) as "incomplete" so ADK reports it as FinishReason.OTHER with error_code set. Both cases work that way on my side against a local SSE server.
Could you check if that works with your setup too including a tool-call stream that gets cut off?
Both make sense: harden the ADK fallback to OTHER + error_code like you suggested and raise the synthetic stop with litellm upstream. Please test the patch well with mocked streams before raising the PR.
ishan-1010 commentedon Oct 27, 2026
@surajksharma07 the wrapper works here. main (2084efb), litellm 1.85.7, local SSE server, wrapper rewrites the finish reason when
received_finish_reasonis stillNone:LiteLLMClienterror_codeerror_codeerror_codeerror_codeerror_codeerror_codeone thing for the hardened fallback: a tool call cut after its args arrived still comes back with the call in its parts, only
error_codemarks it incomplete. should that call still be offered to the flow?wrapper and test script
surajksharma07 commentedon Oct 27, 2026
That table lines up with what I saw @ishan-1010 and good that cutargs still lands on MALFORMED_FUNCTION_CALL either way.
On your question: today it does get offered. The flow only skips partial events before dispatching function calls and never looks at error_code (base_llm_flow.py:507-521) so with the wrapper the tool-close/tool-reset calls still run. #7741 (ef76578) goes the same way on purpose. It only marks the text-only finalizer as truncated and keeps or "tool_calls" treating args that fully parsed as the completeness check. That’s right: a parsed call is complete and the only real loss is a later parallel call that never arrived. If maintainers want stricter the gate belongs in the flow not in lite_llm.
Two notes on #7741: it covers the raw-client fallback but not your live case since litellm has already set "stop" there so a received_finish_reason check like your wrapper’s would be a natural follow-up. @xxwtiancai could you also add a test pinning that a tool-call stream with no finish chunk stays STOP and run the full test_litellm.py before review?
@ishan-1010 would you be up for filing the synthetic-stop case with litellm using your probe? Then we can link it here.
ishan-1010 commentedon Oct 27, 2026
@surajksharma07 it's already filed upstream as BerriAI/litellm#46542 (open since Oct 14, same
finish_reason_handlerfallback to "stop"). still reproduces on 1.106.4 with a clean close. a connection reset does raiseMidStreamFallbackErrorthere, so only the clean close is hidden. two fix PRs are open for it: BerriAI/litellm#40266 (raise on a clean EOF) and BerriAI/litellm#42140 (opt-in strict mode). linking it here rather than filing a duplicate.ishan-1010 commentedon Oct 28, 2026
@surajksharma07 one correction, LiteLLM staff have a fix for this in BerriAI/litellm#47665 (approved, not merged yet). a stream that ends without a finish reason will raise instead of returning stop.
surajksharma07 commentedon Oct 28, 2026
Thanks for tracking down BerriAI/litellm#46542 and the #47665 fix @ishan-1010, much better than filing a duplicate. One catch: ADK currently pins litellm>=1.84,<1.106.3 so #47665 won’t reach ADK users until that pin is raised after the release is checked.
Once it is a clean close should raise out of the stream loop (lite_llm.py has no try around acompletion at ~L5007) so the run errors the same way a reset already does on 1.106.x instead of ending as a truncated STOP. Your received_finish_reason wrapper is still a good workaround until then. Could you re-run your probe on the litellm release that ships #47665 and confirm it raises for both text and tool-call streams?
#7741 is still needed for raw llm_client streams that litellm never touches. @xxwtiancai the tool-call case is pinned by test_streaming_tool_call_cut_off_after_complete_arguments_is_kept and the full test_litellm.py passes (770) which covers what I asked for. Please keep testing it against main before review.
Will flag #7741 for maintainer review and mention the litellm pin here so the bump can be tracked once #47665 is released.
ishan-1010 commentedon Oct 29, 2026
Sure, will do once it's released. BerriAI/litellm#47665 isn't merged yet.
surajksharma07 commentedon Nov 2, 2026
Quick update @ishan-1010: BerriAI/litellm#47665 was merged on Oct 31 (efc2304) but it isn’t in a release yet. 1.106.6 is the latest stable and 1.108.0rc1 / 1.109.0.dev2 were cut before the merge. It’s tagged as a breaking change and raises MidStreamFallbackError (“Stream ended before a finish_reason was received”) while empty streams and mock_response still end in stop so ADK’s mock-based tests shouldn’t change.
On the ADK side main (c3abc93) still has the or "stop" fallbacks (lite_llm.py ~L5276-5297) and the litellm<1.106.3 pin so your wrapper is still the workaround for now. #7815 only changed telemetry so it doesn’t touch this.
#7741 still merges cleanly on current main but hasn’t had a review yet. @xuanyang15 could you take a look when you get a chance? It covers the raw llm_client path which the litellm fix won’t reach.
When a litellm release with #47665 ships please re-run your probe for text and tool-call streams so we can check the run errors out cleanly before the pin is raised.
fix: report a LiteLlm stream without a finish reason as truncated