Skip to content

LiteLlm streaming drops a thought_signature sent on a tool-call delta without the call's name or arguments聽#7485

Closed

Description

@vetler

馃敶 Required Information

Describe the Bug:
With LiteLlm and stream=True, a thought_signature that arrives on a tool-call delta of its own, without the call's name or arguments, is dropped. _model_response_to_chunk treats a delta with neither a name nor arguments as empty and skips it, so the signature never reaches the function-call part. Gemini 3 then rejects the next request in the turn ("Function call is missing a thought_signature in functionCall parts").

This is the remaining case from #7438. #7441 (landed as 3abea3e) keeps a signature that arrives on the same delta as the call's name, which is what Vertex AI sends today. The signature-only case was raised on #7438 and left for a follow-up.

Steps to Reproduce:

  1. Check out main (reproduced at f5db310).
  2. Run the script under Minimal Reproduction Code. It mocks the LiteLLM client, so no credentials are needed.
  3. The stream sends the call's name and arguments on one delta and the signature on a second delta for the same index.

Expected Behavior:

function calls: [('call_1', 'get_weather')]
thought_signature: b'late-signature'

Observed Behavior:

function calls: [('call_1', 'get_weather')]
thought_signature: None

Environment Details:

  • ADK Library Version (pip show google-adk): main at f5db310
  • Desktop OS: macOS
  • Python Version (python -V): 3.11.16

Model Information:

  • Are you using LiteLLM: Yes (litellm 1.85.7)
  • Which model is being used: Gemini 3 (gemini-3-flash-preview)

馃煛 Optional Information

Regression:
No. Before #7441 every streamed signature was lost; this is the one shape #7441 does not cover.

Additional Context:
I have not seen this shape from a real endpoint. In live runs with gemini-3-flash-preview, both Vertex AI's OpenAI-compatible endpoint and LiteLLM's vertex_ai provider put the signature on the delta that names the call (raw deltas in #7441). So nobody should be hitting this today; it matters for a provider or a LiteLLM version that splits the signature into its own delta.

A fix needs to avoid opening a new call for such a delta, which would reach the model as a call with no name, and to attach the signature by the delta's own index, since the fallback index used for providers with improper indexing may already point past that call.

Fix: #7467.

Minimal Reproduction Code:

"""LiteLlm drops a thought_signature that streams on a tool-call delta of its own.

No network or credentials: the LiteLLM client is mocked. The first delta
carries the call's name and arguments, a second delta for the same index
carries only the signature, then the stream finishes.
"""

import asyncio
import base64
from unittest.mock import AsyncMock, MagicMock

import litellm
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.llm_request import LlmRequest
from google.genai import types

SIGNATURE = base64.b64encode(b"late-signature").decode()


def delta(tool_call, finish_reason=None):
  return litellm.ModelResponseStream(choices=[{
      "index": 0,
      "finish_reason": finish_reason,
      "delta": {"role": "assistant", "tool_calls": [tool_call]},
  }])


async def stream():
  yield delta({
      "index": 0,
      "id": "call_1",
      "type": "function",
      "function": {"name": "get_weather", "arguments": '{"city": "Oslo"}'},
  })
  # Same call, signature only: no name, no arguments.
  yield delta({
      "index": 0,
      "type": "function",
      "function": {"name": None, "arguments": ""},
      "extra_content": {"google": {"thought_signature": SIGNATURE}},
  })
  yield litellm.ModelResponseStream(
      choices=[{"index": 0, "finish_reason": "tool_calls", "delta": {}}]
  )


async def main():
  llm = LiteLlm(model="google/gemini-3-flash-preview", custom_llm_provider="openai")
  llm.llm_client = MagicMock()
  llm.llm_client.acompletion = AsyncMock(return_value=stream())
  request = LlmRequest(
      model="google/gemini-3-flash-preview",
      contents=[types.Content(role="user", parts=[types.Part(text="Weather in Oslo?")])],
      config=types.GenerateContentConfig(),
  )
  final = None
  async for response in llm.generate_content_async(request, stream=True):
    if not response.partial:
      final = response
  calls = [p for p in final.content.parts if p.function_call]
  print("function calls:", [(p.function_call.id, p.function_call.name) for p in calls])
  print("thought_signature:", calls[0].thought_signature)


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%), for this delta shape

Activity

  1. surajksharma07 commented on Oct 12, 2026

    @surajksharma07
    Collaborator

    Reproduced this on 2.11.0 + current main (which has 3abea3e) @vetler. The call comes through fine as call_1/get_weather but the signature-only delta gets skipped and thought_signature ends up None.

    Until #7467 lands a possible workaround: wrap the LiteLLM client so tool-call deltas are held until the stream moves on and a signature-only delta gets folded into the named call at its own index (or kept until that call starts). That kept the signature for both orders with a second call in between and never opened a nameless call. Could you check if that holds up on your side too ideally with litellm 1.85.7 since I was on 1.84.0?

    Please give #7467 another full run against current main before review and once it's merged it'd be worth confirming the import picked up the latest revision this time unlike #7441. Will keep this issue updated.

  2. added
    models[Component] This issue is related to model support
    on Oct 16, 2026
  3. vetler commented on Oct 19, 2026

    @vetler
    ContributorAuthor

    @surajksharma07 Thanks for reproducing it.

    Workaround. It holds up here with litellm 1.85.7 on current main (d3770b8). I wrapped the client the way you describe: tool-call deltas are held until the stream moves on, a signature-only delta is folded into the named call at its own index (or kept until that call starts), and the delta itself is dropped. Without the wrapper, main loses the signature when it arrives after its call with a second call in between, before its call, or on a delta of its own that also carries the finish reason. With it, all three keep the signature on the right call and no nameless call is streamed. The usual case, where the signature comes with the name, is unaffected.

    #7467. I merged current main into it, including #7005. Since #7005 finalizes once per streamed part, a signature-only delta that shares a part with its call, or carries the finish reason, is still applied before finalizing. The same four cases pass on #7467 without the wrapper, so it can simply be dropped after upgrading. The full unit suite on 3.11 to 3.14 against current main passes apart from two tests that also fail on main on my machine but not in CI: one relies on GNU base64 rejecting a directory (macOS ships the BSD one), and the release-dependency check wanted opentelemetry 1.44 where my local lock still had 1.42.

    I'll check that the import picks up this revision once it lands.

  4. adk-bot commented on Oct 19, 2026

    @adk-bot
    Collaborator

    Resolved by #7467 (imported and merged in commit 557743c). Closing issue.

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

Metadata

Metadata

Labels

models[Component] This issue is related to model support

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions