Skip to content

ApigeeLlm: tool call arguments that are valid JSON but not an object crash the non-streamed path and pass through as args when streamed聽#7740

Description

@Somay-kousis

馃敶 Required Information

Describe the Bug:
When a Chat Completions tool call's arguments is complete JSON but not an object ([1, 2], "x", 5, null), ChatCompletionsResponseHandler handles it two different ways:

  • Non-streamed (process_response): _upsert_tool_call parses the string and then calls function_call.args = dict(args) (apigee_llm.py:1370 on main), which raises TypeError or ValueError. The response never reaches the caller.
  • Streamed (process_chunk): _get_content_parts assigns the parsed value directly (apigee_llm.py:1285), so FunctionCall.args becomes a list, string, int or None and the call goes on to tool execution.

This is independent of the finish reason (same with tool_calls and stop) and separate from the truncated-arguments case in #7648 / #7686, where it came up (surajksharma07 spotted it there).

Steps to Reproduce:

  1. Use current main at bf6739723 (package version 2.13.0).
  2. Run the script below. It feeds synthetic responses to the handler; no credentials or network.
from google.adk.models.apigee_llm import ChatCompletionsResponseHandler

def nonstream(args, finish):
  resp = {"model": "m", "choices": [{"finish_reason": finish, "message": {"role": "assistant", "content": None,
          "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "w", "arguments": args}}]}}]}
  try:
    r = ChatCompletionsResponseHandler().process_response(resp)
    return f"args={[p.function_call.args for p in r.content.parts if p.function_call]}"
  except Exception as e:
    return f"{type(e).__name__}: {e}"

def stream(args, finish):
  h = ChatCompletionsResponseHandler()
  out = list(h.process_chunk({"model": "m", "choices": [{"delta": {"role": "assistant", "tool_calls": [
      {"index": 0, "id": "c1", "type": "function", "function": {"name": "w", "arguments": args}}]}, "finish_reason": None}]}))
  out += list(h.process_chunk({"model": "m", "choices": [{"delta": {}, "finish_reason": finish}]}))
  final = [r for r in out if not r.partial]
  return f"args={[p.function_call.args for r in final for p in r.content.parts if p.function_call]}"

for a in ["[1, 2]", '"x"', "5", "null"]:
  for f in ["tool_calls", "stop"]:
    print(f"{a:8} {f:10} non-stream: {nonstream(a, f)}")
    print(f"{a:8} {f:10} stream:     {stream(a, f)}")

Expected Behavior:
Both paths treat non-object arguments the same way, and neither hands a non-dict FunctionCall.args to tool execution. For example: reject the call with a clear error (or a function-response error the model can recover from), consistently on both paths.

Observed Behavior:

arguments non-streamed streamed
[1, 2] TypeError: cannot convert dictionary update sequence element #0 to a sequence args=[[1, 2]]
"x" ValueError: dictionary update sequence element #0 has length 1; 2 is required args=['x']
5 TypeError: 'int' object is not iterable args=[5]
null TypeError: 'NoneType' object is not iterable args=[None]

Same output with finish_reason tool_calls and stop.

Environment Details:

  • ADK: main bf6739723 (2.13.0)
  • Python: 3.12.13
  • OS: macOS

Model Information:

  • Any model behind ApigeeLlm using the Chat Completions API. Reproduced with synthetic responses, no live call.

馃煛 Optional Information

Additional Context:
n1shanthb offered to take the fix in #7648:
@n1shanthb

Activity

  1. n1shanthb commented on Oct 26, 2026

    @n1shanthb

    Thanks for opening this and providing the detailed reproduction, @Somay-kousis!

    I'll investigate the expected FunctionCall.args behavior across both paths and prepare a focused fix .

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions