Repository navigation
LiteLlm finalizes a stream cut off mid-answer as finish_reason STOP with no error #7693
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?