Skip to content

LiteLlm finalizes a stream cut off mid-answer as finish_reason STOP with no error #7693

Open

Description

@ishan-1010

🔴 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:

  1. pip install google-adk litellm uvicorn starlette (ran it on main, 2de5876)
  2. Run the script below. A local OpenAI-compatible server streams two chunks and then drops the connection for the cut model, 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 = True

Expected 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

  1. xxwtiancai commented on Oct 23, 2026

    @xxwtiancai

    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 to LiteLlm:

    litellm graceful close (no finish chunk) hard reset
    1.85.7 (the version in the report) [('Oslo is ', None), ('4C and', None), (None, 'stop')] — a synthetic stop chunk silent end, same synthetic stop (matches the reported behavior exactly)
    1.106.2 (current cap) same synthetic stop raises APIConnectionError (MidStreamFallbackError)

    So by the time ADK's streaming loop ends, last_finish_reason is already "stop" — the last_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:

    • The durable fix for the reported behavior belongs upstream in litellm (don't synthesize a finish chunk when the provider never sent one, or expose an incomplete-stream signal).
    • ADK-side, last_finish_reason is None at finalization is still reachable through custom llm_client implementations that stream raw chunks, and finalizing those as a clean STOP is arguably still wrong — I have a small local patch that maps that case to FinishReason.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?

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

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions